diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4c8178913dd..72f8a91c342 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -275,6 +275,25 @@ def _is_termux_startup_environment(env: dict[str, str] | None = None) -> bool: ) +def _read_packed_ref(common_dir: Path, ref: str) -> str | None: + """Look up a ref in .git/packed-refs without spawning git. + + packed-refs lines look like `` `` with optional ``^`` + peel lines and ``#``-prefixed comments / ``# pack-refs with:`` header. + """ + try: + text = (common_dir / "packed-refs").read_text(encoding="utf-8", errors="replace") + except OSError: + return None + for line in text.splitlines(): + if not line or line.startswith("#") or line.startswith("^"): + continue + parts = line.split(" ", 1) + if len(parts) == 2 and parts[1].strip() == ref: + return parts[0].strip() + return None + + def _read_git_revision_fingerprint(repo_root: Path) -> str | None: """Return a cheap checkout fingerprint without spawning git.""" git_dir = repo_root / ".git" @@ -285,13 +304,36 @@ def _read_git_revision_fingerprint(repo_root: Path) -> str | None: if key.strip() == "gitdir" and value.strip(): git_dir = (repo_root / value.strip()).resolve() break + # Worktrees point HEAD at a per-worktree gitdir but pack their refs + # in the main repo's gitdir (referenced via ``commondir``). Resolve + # that up front so packed-refs lookups hit the right file. + common_dir = git_dir + commondir_file = git_dir / "commondir" + if commondir_file.exists(): + try: + rel = commondir_file.read_text(encoding="utf-8", errors="replace").strip() + if rel: + common_dir = (git_dir / rel).resolve() + except OSError: + pass head_file = git_dir / "HEAD" head = head_file.read_text(encoding="utf-8", errors="replace").strip() if head.startswith("ref:"): ref = head.split(":", 1)[1].strip() - ref_file = git_dir / ref - if ref_file.exists(): - return f"git:{ref}:{ref_file.read_text(encoding='utf-8', errors='replace').strip()}" + # Loose refs may live in the worktree gitdir OR the common dir + # (branches created via `git worktree add` typically live in the + # common dir's refs/heads/). + for candidate in (git_dir, common_dir): + ref_file = candidate / ref + if ref_file.exists(): + return f"git:{ref}:{ref_file.read_text(encoding='utf-8', errors='replace').strip()}" + packed_sha = _read_packed_ref(common_dir, ref) + if packed_sha: + return f"git:{ref}:{packed_sha}" + # Ref name is known but unresolved — still stable across launches, + # and the version/release fallback in the caller will invalidate + # after `hermes update`. + return f"git:{ref}:unresolved" return f"git:HEAD:{head}" except OSError: return None diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index 272266cea1c..7e6ccc05927 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -428,6 +428,88 @@ def test_termux_forced_bundled_skill_sync_runs(monkeypatch, tmp_path, main_mod): assert calls == [True] +def test_read_git_revision_fingerprint_resolves_packed_refs(tmp_path, main_mod): + repo = tmp_path / "repo" + git_dir = repo / ".git" + git_dir.mkdir(parents=True) + (git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8") + packed_sha = "1234567890abcdef1234567890abcdef12345678" + (git_dir / "packed-refs").write_text( + "# pack-refs with: peeled fully-peeled sorted\n" + f"{packed_sha} refs/heads/main\n" + "abcdef0000000000000000000000000000000000 refs/tags/v1.0\n" + "^99999999aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", + encoding="utf-8", + ) + + fingerprint = main_mod._read_git_revision_fingerprint(repo) + + assert fingerprint == f"git:refs/heads/main:{packed_sha}" + + +def test_read_git_revision_fingerprint_packed_refs_in_worktree_common_dir( + tmp_path, main_mod +): + main_repo = tmp_path / "repo" + common_git = main_repo / ".git" + common_git.mkdir(parents=True) + packed_sha = "fedcba9876543210fedcba9876543210fedcba98" + (common_git / "packed-refs").write_text( + f"{packed_sha} refs/heads/main\n", + encoding="utf-8", + ) + + worktree = tmp_path / "wt" + worktree.mkdir() + wt_gitdir = common_git / "worktrees" / "wt" + wt_gitdir.mkdir(parents=True) + (wt_gitdir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8") + (wt_gitdir / "commondir").write_text("../..\n", encoding="utf-8") + (worktree / ".git").write_text(f"gitdir: {wt_gitdir}\n", encoding="utf-8") + + fingerprint = main_mod._read_git_revision_fingerprint(worktree) + + assert fingerprint == f"git:refs/heads/main:{packed_sha}" + + +def test_read_git_revision_fingerprint_loose_ref_in_worktree_common_dir( + tmp_path, main_mod +): + """`git worktree add -b NAME` writes the new branch ref to the common dir, + not the per-worktree gitdir. The fingerprint must still resolve it.""" + main_repo = tmp_path / "repo" + common_git = main_repo / ".git" + common_git.mkdir(parents=True) + loose_sha = "0123456789abcdef0123456789abcdef01234567" + (common_git / "refs" / "heads").mkdir(parents=True) + (common_git / "refs" / "heads" / "feature").write_text( + loose_sha + "\n", encoding="utf-8" + ) + + worktree = tmp_path / "wt" + worktree.mkdir() + wt_gitdir = common_git / "worktrees" / "wt" + wt_gitdir.mkdir(parents=True) + (wt_gitdir / "HEAD").write_text("ref: refs/heads/feature\n", encoding="utf-8") + (wt_gitdir / "commondir").write_text("../..\n", encoding="utf-8") + (worktree / ".git").write_text(f"gitdir: {wt_gitdir}\n", encoding="utf-8") + + fingerprint = main_mod._read_git_revision_fingerprint(worktree) + + assert fingerprint == f"git:refs/heads/feature:{loose_sha}" + + +def test_read_git_revision_fingerprint_unresolved_ref_is_stable(tmp_path, main_mod): + repo = tmp_path / "repo" + git_dir = repo / ".git" + git_dir.mkdir(parents=True) + (git_dir / "HEAD").write_text("ref: refs/heads/missing\n", encoding="utf-8") + + fingerprint = main_mod._read_git_revision_fingerprint(repo) + + assert fingerprint == "git:refs/heads/missing:unresolved" + + def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod): captured = {}