From 3dd7d9cf07837242fdb2131d7cef3272e5897920 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Sun, 20 Sep 2026 02:31:28 -0500 Subject: [PATCH 1/2] Make easy init preserve repository config --- CHANGELOG.md | 4 + docs/try-in-10-minutes.md | 13 +++ docs/upgrade-existing-repo.md | 14 ++- src/code_mower/init.py | 49 +++++++- tests/test_safe_init_1054.py | 206 ++++++++++++++++++++++++++++++++++ 5 files changed, 277 insertions(+), 9 deletions(-) create mode 100644 tests/test_safe_init_1054.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fabf3faa..2a893680 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ later entries are regular releases. and Claude lanes in one run no longer make publication ambiguous, while a missing, duplicate, wrong-job, or wrong-attempt seal still fails closed (#1032). +- `code-mower init --easy` now preserves an existing root `code-mower.yml` for + previews and staged applies. Applying packaged starter defaults beside an + existing config requires `--packaged-starter`, remediation prints exact + preview/apply commands, and generated lane-config paths are unique (#1054). - Hosted-agent installation guidance now includes a Python-based uv bootstrap that does not pipe a remote script into a shell. Remote-only orchestrators get an explicit packaged-starter doctor command, doctor JSON is identified as diff --git a/docs/try-in-10-minutes.md b/docs/try-in-10-minutes.md index a3bafa75..a29cd5eb 100644 --- a/docs/try-in-10-minutes.md +++ b/docs/try-in-10-minutes.md @@ -107,6 +107,19 @@ code-mower init --easy --apply --output-dir .code-mower.generated `init --easy` is non-mutating by default. `--apply` writes a generated tree for review in `.code-mower.generated`, creates the missing Code Mower labels when GitHub access allows it, and still does not trigger reviewers or upload data. +When the checkout already has a root `code-mower.yml`, easy mode renders from +that repository configuration so an upgrade keeps its selected lanes and +policy. To deliberately stage starter defaults instead, name that choice: + +```bash +code-mower init --easy --packaged-starter --apply \ + --output-dir .code-mower.generated +``` + +An apply that would otherwise choose the packaged starter while a root config +exists is refused with the exact repository-config and explicit-starter commands. +Use `code-mower migration setup-drift --repo-path .` before copying generated +upgrade files into the checkout. The generated tree includes local Codex and Claude audit lanes, the `code-mower/gate` workflow, stale-audit cleanup, owner escalation labels, and diff --git a/docs/upgrade-existing-repo.md b/docs/upgrade-existing-repo.md index f9cb40cc..627c16fb 100644 --- a/docs/upgrade-existing-repo.md +++ b/docs/upgrade-existing-repo.md @@ -63,16 +63,20 @@ repository setup upgrade follows below. From a clean repository checkout: ```bash +code-mower init --easy code-mower init code-mower.yml --profile PROFILE --dry-run code-mower init code-mower.yml --profile PROFILE --apply \ --output-dir .code-mower.generated ``` -Replace `PROFILE` with the profile the repository already uses. This explicit -config path preserves its participant, lane, and policy choices while rendering -the v1.5.0 support files. If the repository truly has no `code-mower.yml`, use -`code-mower init --easy --apply --output-dir .code-mower.generated` and review -the packaged starter as a new adoption. +Easy mode now detects a root `code-mower.yml` and previews from it. Replace +`PROFILE` with the profile the repository already uses when you run the explicit +commands. Both forms preserve participant, lane, and policy choices while +rendering the installed package's support files. If the repository truly has no +`code-mower.yml`, use `code-mower init --easy --apply --output-dir +.code-mower.generated` and review the packaged starter as a new adoption. If a +root config exists and you intentionally want starter defaults, make that choice +explicit with `--packaged-starter`. Treat `.code-mower.generated` as review input. Do not copy it wholesale until you have compared it with the existing repository files. diff --git a/src/code_mower/init.py b/src/code_mower/init.py index b99830e4..3c67eefd 100644 --- a/src/code_mower/init.py +++ b/src/code_mower/init.py @@ -193,8 +193,6 @@ def _audit_workflow_paths(lane): ("tools/audit_publication.py", "audit_publication.py", "product-support-helper", "0644"), ("tools/trailer_comment_labeler.py", "trailer_comment_labeler.py", "product-support-helper", "0644"), ("tools/lane_configs/__init__.py", "lane_configs/__init__.py", "product-support-helper", "0644"), - ("tools/lane_configs/claude.py", "lane_configs/claude.py", "product-support-helper", "0644"), - ("tools/lane_configs/codex.py", "lane_configs/codex.py", "product-support-helper", "0644"), ( "tools/builder_lineage.py", "builder_lineage.py", @@ -2531,12 +2529,35 @@ def setup_drift_next_step(*, profile_id: str) -> str: return ( "root code-mower.yml exists but the packaged starter config was selected; " f"rerun with `code-mower init code-mower.yml --profile {quoted_profile} --dry-run` " - "to use the explicit repository config, or compare with " + "to preview the repository config, then stage the upgrade with " + f"`code-mower init code-mower.yml --profile {quoted_profile} --apply " + f"--output-dir {DEFAULT_APPLY_OUTPUT_DIR}`; compare with " "`code-mower migration setup-drift --repo-path .` " "(see docs/upgrade-existing-repo.md)" ) +def implicit_starter_apply_error(*, profile_id: str, output_dir: str) -> str: + """Explain how to stage either safe config source after refusing ambiguity.""" + + quoted_profile = shlex.quote(profile_id) + quoted_output_dir = shlex.quote(output_dir) + repository_command = ( + f"code-mower init {ADOPTION_CONFIG_PATH} --profile {quoted_profile} --apply " + f"--output-dir {quoted_output_dir}" + ) + starter_command = ( + f"code-mower init --packaged-starter --profile {quoted_profile} --apply " + f"--output-dir {quoted_output_dir}" + ) + return ( + f"root {ADOPTION_CONFIG_PATH} exists; refusing to apply the packaged starter " + "without an explicit selection. Preserve the repository configuration with " + f"`{repository_command}`, or explicitly select the packaged starter with " + f"`{starter_command}`" + ) + + def render_init_plan( config: Mapping[str, Any], profile_id: str = "recommended", @@ -2773,6 +2794,9 @@ def render_init_plan( { "path": path, "source": "lane-config-template", + "copy_from": f"src/code_mower/lane_configs/{trailer_module}.py", + "package_copy_from": f"lane_configs/{trailer_module}.py", + "package_copy_first": True, } ) smoke_tests.extend(_lane_smoke_tests(lane_id, lane, package_mode=package_mode)) @@ -3507,8 +3531,16 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) explicit_config = args.config is not None + has_root_config = root_adoption_config_present() if args.config is None: - args.config = PACKAGED_STARTER_CONFIG_NAME + # Easy mode is also the upgrade entrypoint people remember. Preserve an + # existing repository's selected policy instead of silently switching + # the generated tree back to starter defaults. + args.config = ( + ADOPTION_CONFIG_PATH + if args.easy and has_root_config and not args.packaged_starter + else PACKAGED_STARTER_CONFIG_NAME + ) if args.easy: args.profile = "recommended" @@ -3557,6 +3589,15 @@ def main(argv: list[str] | None = None) -> int: # basename matches the starter; only a resolved packaged fallback # counts as the packaged starter. packaged_fallback = config_source != Path(args.config) + if args.apply and packaged_fallback and has_root_config and not args.packaged_starter: + print( + "error: " + + implicit_starter_apply_error( + profile_id=args.profile, output_dir=args.output_dir + ), + file=sys.stderr, + ) + return 1 rendered_config_path = ( str(config_source) if packaged_fallback else args.config ) diff --git a/tests/test_safe_init_1054.py b/tests/test_safe_init_1054.py new file mode 100644 index 00000000..d02a7ee4 --- /dev/null +++ b/tests/test_safe_init_1054.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import io +import json +import os +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +import tempfile +import unittest + +from code_mower import config as code_mower_config +from code_mower import init as code_mower_init + + +ROOT = Path(__file__).resolve().parents[1] +STARTER = ROOT / "src" / "code_mower" / "templates" / "code-mower.example.yml" + + +class SafeEasyInitTests(unittest.TestCase): + def _run_in(self, cwd: Path, argv: list[str]) -> tuple[int, str, str]: + previous = Path.cwd() + stdout = io.StringIO() + stderr = io.StringIO() + try: + os.chdir(cwd) + with redirect_stdout(stdout), redirect_stderr(stderr): + result = code_mower_init.main(argv) + finally: + os.chdir(previous) + return result, stdout.getvalue(), stderr.getvalue() + + def test_unconfigured_easy_preview_uses_packaged_starter(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + result, stdout, stderr = self._run_in(Path(tmp), ["--easy", "--json"]) + + self.assertEqual(result, 0, stderr) + payload = json.loads(stdout) + self.assertEqual(payload["mode"], "dry-run") + self.assertEqual(payload["config_source"]["kind"], "packaged_starter") + self.assertFalse(payload["config_source"]["root_config_present"]) + + def test_configured_easy_preview_uses_root_config(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + repository_text = STARTER.read_text(encoding="utf-8").replace( + "owner/example", "acme/configured" + ) + (repo / "code-mower.yml").write_text(repository_text, encoding="utf-8") + + result, stdout, stderr = self._run_in(repo, ["--easy", "--json"]) + + self.assertEqual(result, 0, stderr) + payload = json.loads(stdout) + self.assertEqual(payload["config_source"]["kind"], "explicit_repository_config") + self.assertEqual(payload["config_source"]["requested_path"], "code-mower.yml") + self.assertTrue(payload["config_source"]["root_config_present"]) + self.assertEqual(payload["repositories"][0]["slug"], "acme/configured") + + def test_configured_easy_apply_copies_root_config(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + repository_text = STARTER.read_text(encoding="utf-8").replace( + "owner/example", "acme/configured" + ) + (repo / "code-mower.yml").write_text(repository_text, encoding="utf-8") + output_dir = repo / "generated" + + result, _stdout, stderr = self._run_in( + repo, + [ + "--easy", + "--apply", + "--output-dir", + str(output_dir), + "--skip-actionlint", + "--skip-github-labels", + ], + ) + + generated_text = (output_dir / "code-mower.yml").read_text(encoding="utf-8") + manifest = json.loads( + (output_dir / code_mower_init.APPLY_MANIFEST_FILENAME).read_text(encoding="utf-8") + ) + + self.assertEqual(result, 0, stderr) + self.assertEqual(generated_text, repository_text) + self.assertEqual(manifest["config_source"]["kind"], "explicit_repository_config") + + def test_explicit_packaged_starter_preview_has_exact_upgrade_commands(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + (repo / "code-mower.yml").write_text( + STARTER.read_text(encoding="utf-8"), encoding="utf-8" + ) + + result, stdout, stderr = self._run_in(repo, ["--easy", "--packaged-starter", "--json"]) + + self.assertEqual(result, 0, stderr) + hint = json.loads(stdout)["setup_drift_hint"] + self.assertIn("`code-mower init code-mower.yml --profile recommended --dry-run`", hint) + self.assertIn( + "`code-mower init code-mower.yml --profile recommended --apply " + "--output-dir .code-mower.generated`", + hint, + ) + self.assertIn("`code-mower migration setup-drift --repo-path .`", hint) + + def test_explicit_packaged_starter_apply_is_allowed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + repository_text = STARTER.read_text(encoding="utf-8").replace( + "owner/example", "acme/configured" + ) + (repo / "code-mower.yml").write_text(repository_text, encoding="utf-8") + output_dir = repo / "generated" + + result, _stdout, stderr = self._run_in( + repo, + [ + "--easy", + "--packaged-starter", + "--apply", + "--output-dir", + str(output_dir), + "--skip-actionlint", + "--skip-github-labels", + ], + ) + manifest = json.loads( + (output_dir / code_mower_init.APPLY_MANIFEST_FILENAME).read_text(encoding="utf-8") + ) + + self.assertEqual(result, 0, stderr) + self.assertEqual(manifest["config_source"]["kind"], "packaged_starter") + + def test_implicit_packaged_apply_beside_root_config_is_refused(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + (repo / "code-mower.yml").write_text( + STARTER.read_text(encoding="utf-8"), encoding="utf-8" + ) + + result, stdout, stderr = self._run_in( + repo, + [ + "--apply", + "--output-dir", + "generated output", + "--skip-actionlint", + "--skip-github-labels", + ], + ) + + self.assertEqual(result, 1) + self.assertEqual(stdout, "") + self.assertIn("refusing to apply the packaged starter", stderr) + self.assertIn( + "`code-mower init code-mower.yml --profile recommended --apply " + "--output-dir 'generated output'`", + stderr, + ) + self.assertIn( + "`code-mower init --packaged-starter --profile recommended --apply " + "--output-dir 'generated output'`", + stderr, + ) + + +class GeneratedPathUniquenessTests(unittest.TestCase): + def test_recommended_plan_has_unique_real_lane_configs(self) -> None: + config = code_mower_config.load_config(STARTER) + plan = code_mower_init.render_init_plan( + config, + config_path=str(STARTER), + package_mode=True, + source_kind="packaged_starter", + ) + + paths = [entry["path"] for entry in plan.data["generated_files"]] + self.assertEqual(len(paths), len(set(paths))) + self.assertFalse( + any( + "lane-config" in warning and "collides" in warning + for warning in plan.data["warnings"] + ) + ) + for lane in ("claude", "codex"): + entry = next( + item + for item in plan.data["generated_files"] + if item["path"] == f"tools/lane_configs/{lane}.py" + ) + self.assertEqual(entry["source"], "lane-config-template") + self.assertEqual(entry["package_copy_from"], f"lane_configs/{lane}.py") + + with tempfile.TemporaryDirectory() as tmp: + result = code_mower_init.apply_init_plan(plan, Path(tmp) / "generated") + + lane_placeholders = { + path for path in result["placeholder_files"] if "tools/lane_configs/" in path + } + self.assertEqual(lane_placeholders, set()) + + +if __name__ == "__main__": + unittest.main() From ef981e63d6ea4a17b6b1da29acc55c7699dcb45c Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Sun, 20 Sep 2026 02:41:06 -0500 Subject: [PATCH 2/2] Init: scope real lane config copies --- src/code_mower/init.py | 24 +++++++++++++++--------- tests/test_safe_init_1054.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/code_mower/init.py b/src/code_mower/init.py index 3c67eefd..30fff560 100644 --- a/src/code_mower/init.py +++ b/src/code_mower/init.py @@ -2790,15 +2790,21 @@ def render_init_plan( warnings.append(f"{lane_id}: generated file {path} collides with another lane") else: generated_paths.add(path) - generated_files.append( - { - "path": path, - "source": "lane-config-template", - "copy_from": f"src/code_mower/lane_configs/{trailer_module}.py", - "package_copy_from": f"lane_configs/{trailer_module}.py", - "package_copy_first": True, - } - ) + entry = {"path": path, "source": "lane-config-template"} + # Claude and Codex used to be emitted twice: once here as a + # placeholder and once through PRODUCT_SUPPORT_FILES as the + # real package helper. Keep the fix deliberately scoped to + # those two duplicate entries; other lane templates retain + # their established placeholder behavior. + if trailer_module in {"claude", "codex"}: + entry.update( + { + "copy_from": f"src/code_mower/lane_configs/{trailer_module}.py", + "package_copy_from": f"lane_configs/{trailer_module}.py", + "package_copy_first": True, + } + ) + generated_files.append(entry) smoke_tests.extend(_lane_smoke_tests(lane_id, lane, package_mode=package_mode)) warnings.extend(_lane_warnings(lane_id, lane, package_mode=package_mode)) diff --git a/tests/test_safe_init_1054.py b/tests/test_safe_init_1054.py index d02a7ee4..5a8e3362 100644 --- a/tests/test_safe_init_1054.py +++ b/tests/test_safe_init_1054.py @@ -201,6 +201,37 @@ def test_recommended_plan_has_unique_real_lane_configs(self) -> None: } self.assertEqual(lane_placeholders, set()) + def test_research_lane_configs_keep_placeholder_behavior(self) -> None: + config = code_mower_config.load_config(STARTER) + plan = code_mower_init.render_init_plan( + config, + profile_id="cli_research", + config_path=str(STARTER), + package_mode=True, + source_kind="packaged_starter", + ) + + lane_entries = [ + entry + for entry in plan.data["generated_files"] + if entry["path"].startswith("tools/lane_configs/") + and entry["path"] != "tools/lane_configs/__init__.py" + ] + self.assertTrue(lane_entries) + self.assertTrue(all("copy_from" not in entry for entry in lane_entries)) + self.assertTrue(all("package_copy_from" not in entry for entry in lane_entries)) + + with tempfile.TemporaryDirectory() as tmp: + output_dir = Path(tmp) / "generated" + result = code_mower_init.apply_init_plan(plan, output_dir) + + placeholders = { + Path(path).relative_to(output_dir).as_posix() + for path in result["placeholder_files"] + if "tools/lane_configs/" in path + } + self.assertEqual(placeholders, {entry["path"] for entry in lane_entries}) + if __name__ == "__main__": unittest.main()