From 4615143db9180931c24e7c0f8d6ad41787f19410 Mon Sep 17 00:00:00 2001 From: Luke Maj Date: Tue, 20 Oct 2026 11:50:30 +0200 Subject: [PATCH] fix: restore merged canonical clone branches to main --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .grok-plugin/plugin.json | 2 +- .muse-plugin/plugin.json | 2 +- CHANGELOG.md | 6 + VERSION | 2 +- bin/agentsmd_instructions.py | 65 +++++++++- bin/project-direction | 6 +- tests/test_live_checkout_guard.py | 204 ++++++++++++++++++++++++++++++ tools/versionctl/pyproject.toml | 2 +- 10 files changed, 279 insertions(+), 14 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 7971e3b7..74554877 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentsmd", - "version": "17.4.4", + "version": "17.4.5", "description": "Portable agent rules, Project Direction, Delivery System profiles, owned workflow Skills, and deterministic version control.", "author": { "name": "lukaszmaj" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 2dbab5b0..b5dd8d07 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentsmd", - "version": "17.4.4", + "version": "17.4.5", "description": "Portable agent rules, Project Direction, Delivery System profiles, owned workflow Skills, and deterministic version control.", "author": { "name": "lukaszmaj", diff --git a/.grok-plugin/plugin.json b/.grok-plugin/plugin.json index 4959b4ae..0d54df18 100644 --- a/.grok-plugin/plugin.json +++ b/.grok-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentsmd", - "version": "17.4.4", + "version": "17.4.5", "description": "Portable agent rules, Project Direction, Delivery System profiles, owned workflow Skills, and deterministic version control.", "author": { "name": "lukaszmaj" diff --git a/.muse-plugin/plugin.json b/.muse-plugin/plugin.json index 2ca3f166..479c78c8 100644 --- a/.muse-plugin/plugin.json +++ b/.muse-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "agentsmd", "displayName": "AgentsMD", - "version": "17.4.4", + "version": "17.4.5", "description": "Portable agent rules, Project Direction, Delivery System profiles, owned workflow Skills, and deterministic version control.", "compat": { "source": "native", diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bf7cd63..f7b7edd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All completed repository versions are recorded here. Release entries follow SemVer and identify one user-consumable repository state. +## [17.4.5] - 2026-10-20 + +### Changed + +- Restore clean merged canonical-clone branches to main without fetching (#260) + ## [17.4.4] - 2026-10-20 ### Changed diff --git a/VERSION b/VERSION index 57e56e3d..be5d1cdb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -17.4.4 +17.4.5 diff --git a/bin/agentsmd_instructions.py b/bin/agentsmd_instructions.py index 5da10ef3..03c6c2bf 100644 --- a/bin/agentsmd_instructions.py +++ b/bin/agentsmd_instructions.py @@ -123,7 +123,7 @@ def _git(root: Path, *arguments: str) -> subprocess.CompletedProcess[str] | None def guard_checkout(report: dict[str, Any]) -> dict[str, Any]: - """Mark a healthy report unhealthy when its source clone left main or a release tag. + """Restore a clean merged branch, otherwise flag an off-release source clone. A thread can check out another commit in the live clone; the link still resolves, so only the clone's Git state shows the damage. @@ -135,7 +135,7 @@ def guard_checkout(report: dict[str, Any]) -> dict[str, Any]: return report quoted = shlex.quote(str(root)) git = f"git -C {quoted}" - branch = _git(root, "symbolic-ref", "-q", "--short", "HEAD") + branch = _git(root, "symbolic-ref", "-q", "HEAD") deleted = _git(root, "ls-files", "--deleted") if (branch is None or branch.returncode > 1 or deleted is None or deleted.returncode != 0): @@ -147,13 +147,66 @@ def guard_checkout(report: dict[str, Any]) -> dict[str, Any]: f"`{git} status` and fix what it reports, then rerun inspect. " + REREAD_ACTION)) return report - if branch.stdout.strip() != "main": - tag = _git(root, "describe", "--tags", "--exact-match", "HEAD") - if tag is None or tag.returncode != 0: + # --short may produce heads/ when a tag shares the branch name; + # stripping the known namespace preserves a usable git switch argument. + branch_name = branch.stdout.strip().removeprefix("refs/heads/") + if branch_name != "main": + tag = _git(root, "tag", "--points-at", "HEAD") + if tag is None or tag.returncode != 0 or not tag.stdout.strip(): + previous_branch = branch_name + # Detached release installs and unreadable tag state stay untouched. + if previous_branch and tag is not None and tag.returncode == 0: + clean = _git(root, "status", "--porcelain", "--untracked-files=all", + "--ignore-submodules=none") + main = _git(root, "rev-parse", "--verify", "refs/heads/main") + upstream = _git(root, "rev-parse", "--verify", "refs/remotes/origin/main") + if (clean is not None and clean.returncode == 0 and not clean.stdout and + main is not None and main.returncode == 0 and + upstream is not None and upstream.returncode == 0): + contained = _git(root, "merge-base", "--is-ancestor", "HEAD", upstream.stdout.strip()) + forward = _git(root, "merge-base", "--is-ancestor", main.stdout.strip(), upstream.stdout.strip()) + if (contained is not None and contained.returncode == 0 and + forward is not None and forward.returncode == 0): + restored = False + try: + switched = _git(root, "switch", "main") + if switched is not None and switched.returncode == 0: + merged = _git(root, "merge", "--ff-only", "refs/remotes/origin/main") + if merged is not None and merged.returncode == 0: + # Switching can change the contract bytes and hashes. + refreshed = inspect_target( + Path(report["target"]), + Path(report["source"]) if "source" in report else None) + current = _git(root, "symbolic-ref", "-q", "HEAD") + head = _git(root, "rev-parse", "HEAD") + missing = _git(root, "ls-files", "--deleted") + if (refreshed.get("healthy") and current is not None and + current.returncode == 0 and current.stdout.strip() == "refs/heads/main" and + head is not None and head.returncode == 0 and + head.stdout.strip() == upstream.stdout.strip() and + missing is not None and missing.returncode == 0 and not missing.stdout): + report.update(refreshed, status="source-checkout-restored", + restored_from=previous_branch, + restored_from_sha256=report["sha256"]) + restored = True + return report + except (OSError, ValueError, RuntimeError): + # A failed re-inspection must also undo the switch. + pass + finally: + if not restored: + # A timeout may follow a successful Git mutation. Switch + # back before restoring main's ref. The original tree was + # clean, so discard only changes from our failed merge. + # Compare-and-swap protects refs; never delete a branch. + returned = _git(root, "switch", "--discard-changes", previous_branch) + if returned is not None and returned.returncode == 0: + _git(root, "update-ref", "refs/heads/main", + main.stdout.strip(), upstream.stdout.strip()) head = _git(root, "rev-parse", "--short", "HEAD") report.update( healthy=False, status="source-checkout-off-release", - checkout_head=(branch.stdout.strip() or + checkout_head=(branch_name or (head.stdout.strip() if head else "unknown")), action=( f"The canonical AgentsMD clone {root} is not on main or a release " diff --git a/bin/project-direction b/bin/project-direction index dc58ef6f..60a37319 100755 --- a/bin/project-direction +++ b/bin/project-direction @@ -1555,8 +1555,10 @@ def run_hook(harness: str = "codex", require_unambiguous: bool = False) -> int: first_only = grok or harness == "opencode" if event == start_event and (start_sha is None or not first_only): try: - write_cached(session_key, "global_sha256", current_sha) - start_sha = current_sha + # Native instructions loaded before this hook may have been + # replaced by checkout restoration. Record those loaded bytes. + start_sha = instructions.get("restored_from_sha256", current_sha) + write_cached(session_key, "global_sha256", start_sha) except OSError: start_sha = None instructions["action"] = session_action(start_sha, current_sha) diff --git a/tests/test_live_checkout_guard.py b/tests/test_live_checkout_guard.py index 8d400037..094bc212 100644 --- a/tests/test_live_checkout_guard.py +++ b/tests/test_live_checkout_guard.py @@ -10,10 +10,14 @@ import sys import tempfile import unittest +from unittest import mock from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "bin")) +import agentsmd_instructions as instructions + GLOBAL = ROOT / "bin/agentsmd-global-instructions" DIRECTION = ROOT / "bin/project-direction" GIT_ENV = { @@ -86,6 +90,206 @@ def test_other_branch_is_unhealthy(self) -> None: code, report = self.inspect() self.assertEqual((code, report["checkout_head"]), (2, "feature")) + def merged_branch(self) -> str: + """Leave main behind both a merged task and newer instruction contents.""" + self.git(self.clone, "switch", "-qc", "feature") + self.git(self.clone, "commit", "-q", "--allow-empty", "-m", "task") + task = self.git(self.clone, "rev-parse", "HEAD") + (self.clone / "global/AGENTS.md").write_text("# Latest contract\n") + self.git(self.clone, "commit", "-qam", "latest") + self.git(self.clone, "update-ref", "refs/remotes/origin/main", "HEAD") + self.git(self.clone, "reset", "--hard", task) + return task + + def snapshot(self) -> tuple[str, str, str, bytes]: + return (self.git(self.clone, "symbolic-ref", "-q", "HEAD"), + self.git(self.clone, "show-ref"), + self.git(self.clone, "status", "--porcelain", "--untracked-files=all"), + (self.clone / "global/AGENTS.md").read_bytes()) + + def test_merged_clean_branch_restores_and_refreshes_hash(self) -> None: + task = self.merged_branch() + code, report = self.inspect() + self.assertEqual((code, report["status"]), (0, "source-checkout-restored")) + self.assertTrue(report["healthy"]) + self.assertEqual(report["restored_from"], "feature") + self.assertEqual(self.git(self.clone, "branch", "--show-current"), "main") + self.assertEqual(self.git(self.clone, "rev-parse", "HEAD"), + self.git(self.clone, "rev-parse", "origin/main")) + self.assertEqual(self.git(self.clone, "rev-parse", "feature"), task) + self.assertEqual(report["sha256"], instructions.digest(self.clone / "global/AGENTS.md")) + self.assertEqual(self.inspect()[1]["status"], "valid-stable-link") + + def test_unsafe_branches_keep_report_and_checkout(self) -> None: + self.merged_branch() + baseline_action = None + for state in ("modified", "staged", "untracked", "unmerged", "diverged-main", "missing-main", "missing-origin"): + with self.subTest(state=state): + main = self.git(self.clone, "rev-parse", "main") + origin = self.git(self.clone, "rev-parse", "origin/main") + feature = self.git(self.clone, "rev-parse", "feature") + extra = self.clone / "extra" + if state in ("modified", "staged"): + (self.clone / "global/AGENTS.md").write_text("# Dirty\n") + if state == "staged": + self.git(self.clone, "add", ".") + elif state == "untracked": + extra.write_text("untracked\n") + elif state == "unmerged": + self.git(self.clone, "commit", "-q", "--allow-empty", "-m", "unmerged") + elif state == "diverged-main": + self.git(self.clone, "update-ref", "refs/heads/main", "v1.0.0") + self.git(self.clone, "switch", "-q", "main") + self.git(self.clone, "commit", "-q", "--allow-empty", "-m", "diverged") + self.git(self.clone, "switch", "-q", "feature") + else: + self.git(self.clone, "update-ref", "-d", "refs/heads/main" if state == "missing-main" else "refs/remotes/origin/main") + before = self.snapshot() + code, report = self.inspect() + self.assertEqual((code, report["status"]), (2, "source-checkout-off-release")) + self.assertEqual(report["checkout_head"], "feature") + baseline_action = baseline_action or report["action"] + self.assertEqual(report["action"], baseline_action) + self.assertEqual(self.snapshot(), before) + if extra.exists(): + extra.unlink() + self.git(self.clone, "reset", "--hard", feature) + self.git(self.clone, "update-ref", "refs/heads/main", main) + self.git(self.clone, "update-ref", "refs/remotes/origin/main", origin) + + def test_restore_git_errors_roll_back_checkout_and_refs(self) -> None: + self.merged_branch() + real_git = instructions._git + for command in ("tag", "status", "rev-parse", "merge-base", "switch", "merge", "ls-files", "symbolic-ref"): + for after_effect in (False, True): + with self.subTest(command=command, after_effect=after_effect): + before = self.snapshot() + failed = False + switched = False + + def git(root, *args): + nonlocal failed, switched + self.assertEqual(root, self.clone) + self.assertNotIn(args[0], ("fetch", "branch")) + should_fail = (args[0] == command and not failed and + (command not in ("ls-files", "symbolic-ref") or switched)) + if should_fail: + failed = True + if after_effect: + real_git(root, *args) + return None + result = real_git(root, *args) + if args[0] == "switch": + switched = True + return result + + with mock.patch.object(instructions, "_git", side_effect=git): + report = instructions.guard_checkout(instructions.inspect_target(self.target)) + self.assertTrue(failed) + self.assertEqual(report["status"], "source-checkout-off-release") + self.assertEqual(self.snapshot(), before) + + def test_hook_inspect_reports_restoration(self) -> None: + self.merged_branch() + result = subprocess.run([str(DIRECTION), "inspect", "--host", "codex"], + cwd=self.root, env=self.env, text=True, capture_output=True) + report = json.loads(result.stdout)["instructions"] + self.assertEqual(result.returncode, 0) + self.assertEqual(report["status"], "source-checkout-restored") + self.assertEqual(report["restored_from"], "feature") + + def test_merge_ref_lock_failure_leaves_checkout_unchanged(self) -> None: + self.merged_branch() + before = self.snapshot() + lock = self.clone / ".git/refs/heads/main.lock" + lock.write_text("locked\n") + code, report = self.inspect() + self.assertEqual((code, report["status"]), (2, "source-checkout-off-release")) + self.assertEqual(self.snapshot(), before) + + def test_branch_tag_collision_rolls_back(self) -> None: + self.merged_branch() + self.git(self.clone, "tag", "feature", "v1.0.0") + before = self.snapshot() + (self.clone / ".git/refs/heads/main.lock").write_text("locked\n") + code, report = self.inspect() + self.assertEqual((code, report["status"]), (2, "source-checkout-off-release")) + self.assertEqual(self.snapshot(), before) + + def test_branch_tag_collisions_restore_with_actual_branch_name(self) -> None: + self.merged_branch() + for name in ("main", "feature"): + self.git(self.clone, "tag", name, "v1.0.0") + code, report = self.inspect() + self.assertEqual((code, report["status"]), (0, "source-checkout-restored")) + self.assertEqual(report["restored_from"], "feature") + self.assertEqual(self.git(self.clone, "symbolic-ref", "HEAD"), "refs/heads/main") + + def test_restoring_at_session_start_requests_reread_on_every_host(self) -> None: + self.merged_branch() + for host in instructions.HARNESSES: + with self.subTest(host=host): + self.git(self.clone, "switch", "-q", "feature") + old_sha = instructions.digest(self.clone / "global/AGENTS.md") + env = dict(self.env, AGENTSMD_HOST=host, + CODEX_HOME=str(self.root / "codex"), + GROK_HOME=str(self.root / "grok"), + CLAUDE_CONFIG_DIR=str(self.root / "claude"), + OPENCODE_CONFIG_DIR=str(self.root / "opencode"), + XDG_CONFIG_HOME=str(self.root / "xdg"), + AGENTSMD_PROJECT_DIRECTION_DATA=str(self.root / "cache"), + GROK_HOOK_NAME="agentsmd") + target = {"codex": self.target, "grok": self.root / "grok/AGENTS.md", + "claude": self.root / "claude/CLAUDE.md", + "opencode": self.root / "opencode/AGENTS.md", + "muse": self.root / "xdg/muse/AGENTS.md"}[host] + target.parent.mkdir(parents=True, exist_ok=True) + if not target.exists(): + target.symlink_to(self.clone / "global/AGENTS.md") + events = {"grok": ("PreToolUse", "PreToolUse"), + "opencode": ("SessionStart", "SessionStart")}.get( + host, ("SessionStart", "SubagentStart")) + for index, event in enumerate(events): + event_input = {"cwd": str(self.root), "session_id": host, + "hook_event_name": event, "tool_name": "Bash", + "tool_input": {"command": "ls"}} + result = subprocess.run( + [str(DIRECTION), "hook", "--host", host], cwd=self.root, env=env, + input=json.dumps(event_input), text=True, capture_output=True, check=True) + context = json.loads(result.stdout)["hookSpecificOutput"]["additionalContext"] + report = json.loads(context.splitlines()[1])["instructions"] + self.assertEqual(report["status"], "source-checkout-restored" if index == 0 else "valid-stable-link") + self.assertNotEqual(report["sha256"], old_sha) + self.assertEqual(report["action"], instructions.CHANGED_ACTION) + + def test_detached_contained_non_release_is_unchanged(self) -> None: + self.merged_branch() + self.git(self.clone, "checkout", "-q", "--detach") + refs = self.git(self.clone, "show-ref") + head = self.git(self.clone, "rev-parse", "HEAD") + code, report = self.inspect() + self.assertEqual((code, report["status"]), (2, "source-checkout-off-release")) + self.assertEqual(self.git(self.clone, "branch", "--show-current"), "") + self.assertEqual(self.git(self.clone, "show-ref"), refs) + self.assertEqual(self.git(self.clone, "rev-parse", "HEAD"), head) + + def test_restore_when_main_already_matches_origin(self) -> None: + self.merged_branch() + self.git(self.clone, "update-ref", "refs/heads/main", "origin/main") + self.assertEqual(self.inspect()[1]["status"], "source-checkout-restored") + self.assertEqual(self.git(self.clone, "branch", "--show-current"), "main") + + def test_release_tag_with_contained_branch_is_not_restored(self) -> None: + self.merged_branch() + self.git(self.clone, "tag", "-am", "release", "v2.0.0") + before = self.snapshot() + self.assertEqual(self.inspect()[1]["status"], "valid-stable-link") + self.assertEqual(self.snapshot(), before) + self.git(self.clone, "checkout", "-q", "--detach", "v2.0.0") + refs = self.git(self.clone, "show-ref") + self.assertEqual(self.inspect()[1]["status"], "valid-stable-link") + self.assertEqual(self.git(self.clone, "show-ref"), refs) + def test_deleted_tracked_file_is_unhealthy_until_restored(self) -> None: (self.clone / "README.md").write_text("readme\n") self.git(self.clone, "add", "README.md") diff --git a/tools/versionctl/pyproject.toml b/tools/versionctl/pyproject.toml index 794088c0..379f3623 100644 --- a/tools/versionctl/pyproject.toml +++ b/tools/versionctl/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "toolboxmd-versionctl" -version = "17.4.4" +version = "17.4.5" description = "Deterministic repository version mechanics for coding agents" readme = "README.md" requires-python = ">=3.11"