Compare commits

...

1 Commits

Author SHA1 Message Date
harry-yao_data 3fa34e6173 fix(runtime): ignore stray .git entries that are not repositories
_find_git_root treated any ancestor .git entry as a repository, so a
workspace below a leftover .git dir (e.g. ~/.git holding only an
untracked-cache lock file) got a GitFilesystemRegistry rooted at a
non-repo. Every git command then failed, and the working-folder panel's
changed-files request surfaced as a 502 in the UI for sessions started
in non-git folders.

Validate candidates the way git's own discovery does — HEAD plus object
and ref stores (the latter via commondir for linked worktrees) — skip
invalid .git directories, and stop at broken gitlink files, so non-git
workspaces fall back to the agent-edit registry.

Co-authored-by: Isaac
2026-08-13 23:39:19 +00:00
2 changed files with 156 additions and 8 deletions
+58 -6
View File
@@ -182,20 +182,27 @@ def _net_operation(first: str, last: str) -> str | None:
def _find_git_root(path: Path) -> Path | None:
"""Walk up the directory tree to find the nearest ``.git`` entry.
"""Walk up the directory tree to find the nearest valid git repository.
Handles both normal clones (``.git/`` directory) and git worktrees
(``.git`` file, a gitlink pointing at the real git dir).
(``.git`` file, a gitlink pointing at the real git dir). Directories
named ``.git`` that are not repositories (a stray leftover holding
unrelated files) are skipped, mirroring git's own discovery; a broken
gitlink file stops the search, as it does for git.
:param path: Starting directory (will be resolved to an absolute path).
:returns: The directory that contains ``.git``, or ``None`` if *path*
is not inside a git repository.
:returns: The directory that contains a valid ``.git``, or ``None`` if
*path* is not inside a git repository.
"""
current = path.resolve()
while True:
git_entry = current / ".git"
if git_entry.is_dir() or git_entry.is_file():
return current
if git_entry.is_dir():
if _is_git_repo(current):
return current
elif git_entry.is_file():
# A broken gitlink is fatal to git, not skipped — stop here.
return current if _is_git_repo(current) else None
parent = current.parent
if parent == current:
return None
@@ -227,6 +234,51 @@ def _git_common_dir(git_root: Path) -> Path:
return common_dir.resolve()
def _resolve_gitfile(git_entry: Path) -> Path | None:
"""Resolve a ``.git`` gitlink file to the git directory it points at.
:returns: The resolved git directory, or ``None`` when the file is not
a readable ``gitdir:`` link or its target is not a directory.
"""
try:
marker = git_entry.read_text(encoding="utf-8").strip()
except OSError:
return None
if not marker.startswith("gitdir:"):
return None
git_dir = Path(marker.removeprefix("gitdir:").strip())
if not git_dir.is_absolute():
git_dir = git_entry.parent / git_dir
git_dir = git_dir.resolve()
return git_dir if git_dir.is_dir() else None
def _is_git_repo(git_root: Path) -> bool:
"""Return True when *git_root*'s ``.git`` entry forms a working repository.
A directory named ``.git`` alone is not proof of a repository — it may
be a stray leftover holding unrelated files — so require what git's own
discovery checks: a HEAD plus object and ref stores. Linked worktrees
keep objects and refs in the common dir, so those are looked up there.
:param git_root: Directory containing the ``.git`` entry to validate.
"""
git_entry = git_root / ".git"
if git_entry.is_dir():
git_dir = git_entry
elif git_entry.is_file():
resolved = _resolve_gitfile(git_entry)
if resolved is None:
return False
git_dir = resolved
else:
return False
if not (git_dir / "HEAD").exists():
return False
common_dir = _git_common_dir(git_root)
return (common_dir / "objects").is_dir() and (common_dir / "refs").is_dir()
@contextlib.contextmanager
def _untracked_cache_repo_lock(git_root: Path) -> Iterator[None]:
"""Serialize the optional untracked-cache setup across runner processes."""
+98 -2
View File
@@ -1113,6 +1113,14 @@ def test_normalize_path_relative_dotdot_within_cwd_is_normalized(tmp_path: Path)
# ── create_filesystem_registry factory ───────────────────────────────────────
def _init_fake_git_repo(repo: Path) -> None:
"""Give *repo* the minimal .git layout repo validation requires."""
git_dir = repo / ".git"
(git_dir / "objects").mkdir(parents=True)
(git_dir / "refs").mkdir()
(git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
def test_create_filesystem_registry_git_workspace(tmp_path: Path) -> None:
"""A directory with a .git subdirectory yields :class:`GitFilesystemRegistry`.
@@ -1120,7 +1128,7 @@ def test_create_filesystem_registry_git_workspace(tmp_path: Path) -> None:
workspaces would fall back to the plain agent-edit registry, losing
git-backed baseline support.
"""
(tmp_path / ".git").mkdir()
_init_fake_git_repo(tmp_path)
registry = create_filesystem_registry(tmp_path)
assert isinstance(registry, GitFilesystemRegistry), (
f"Expected GitFilesystemRegistry for a git workspace, got {type(registry).__name__}. "
@@ -1147,7 +1155,7 @@ def test_create_filesystem_registry_nested_git_workspace(tmp_path: Path) -> None
workspaces (agent sandboxes inside a repo) would incorrectly use the
plain agent-edit registry and lose git-backed baseline support.
"""
(tmp_path / ".git").mkdir()
_init_fake_git_repo(tmp_path)
nested = tmp_path / "subdir" / "workspace"
nested.mkdir(parents=True)
registry = create_filesystem_registry(nested)
@@ -1158,6 +1166,94 @@ def test_create_filesystem_registry_nested_git_workspace(tmp_path: Path) -> None
)
def test_create_filesystem_registry_bogus_git_dir(tmp_path: Path) -> None:
"""A stray .git directory that is not a repository must not count as one.
Regression test: a workspace under an ancestor with a leftover ``.git``
dir (e.g. one holding only an untracked-cache lock file) was given a
:class:`GitFilesystemRegistry` rooted at that ancestor; every git command
then failed, and the working-folder changed-files view returned 502.
Failure means _find_git_root is accepting directories that lack the
HEAD/objects/refs layout git itself requires.
"""
bogus_home = tmp_path / "home"
(bogus_home / ".git").mkdir(parents=True)
(bogus_home / ".git" / "omnigent-untracked-cache.lock").write_text("", encoding="utf-8")
workspace = bogus_home / "project"
workspace.mkdir()
registry = create_filesystem_registry(workspace)
assert isinstance(registry, AgentEditFilesystemRegistry), (
f"Expected AgentEditFilesystemRegistry under a bogus .git ancestor, "
f"got {type(registry).__name__}. "
"_find_git_root may be treating any .git directory as a repository."
)
def test_create_filesystem_registry_bogus_git_dir_below_real_repo(tmp_path: Path) -> None:
"""An invalid .git dir between the workspace and a real repo is skipped.
Mirrors git's own discovery: a ``.git`` directory that fails validation
is treated as absent and the walk continues upward.
Failure means a stray .git dir shadows the real repository above it.
"""
_init_fake_git_repo(tmp_path)
nested = tmp_path / "subdir" / "workspace"
nested.mkdir(parents=True)
(nested / ".git").mkdir()
registry = create_filesystem_registry(nested)
assert isinstance(registry, GitFilesystemRegistry), (
f"Expected GitFilesystemRegistry rooted at the real repo above a bogus "
f".git dir, got {type(registry).__name__}. "
"_find_git_root may be stopping at invalid .git directories."
)
def test_create_filesystem_registry_broken_gitlink(tmp_path: Path) -> None:
"""A .git file pointing at a nonexistent git dir yields the plain registry.
Git itself treats a broken gitlink as fatal rather than walking past it,
so the workspace must be handled as non-git.
Failure means _find_git_root accepts gitlinks whose target is missing.
"""
(tmp_path / ".git").write_text("gitdir: /nonexistent/gitdir\n", encoding="utf-8")
registry = create_filesystem_registry(tmp_path)
assert isinstance(registry, AgentEditFilesystemRegistry), (
f"Expected AgentEditFilesystemRegistry for a broken gitlink, "
f"got {type(registry).__name__}. "
"_find_git_root may be accepting gitlinks without validating the target."
)
def test_create_filesystem_registry_linked_worktree(tmp_path: Path) -> None:
"""A valid worktree gitlink still yields :class:`GitFilesystemRegistry`.
Worktree git dirs carry HEAD but keep objects/refs in the common dir, so
validation must follow ``commondir`` rather than demanding them inline.
Failure means worktree workspaces lose git-backed baseline support.
"""
common_dir = tmp_path / "repo" / ".git"
(common_dir / "objects").mkdir(parents=True)
(common_dir / "refs").mkdir()
(common_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
worktree_git_dir = common_dir / "worktrees" / "feature"
worktree_git_dir.mkdir(parents=True)
(worktree_git_dir / "commondir").write_text("../..\n", encoding="utf-8")
(worktree_git_dir / "HEAD").write_text("ref: refs/heads/feature\n", encoding="utf-8")
workspace = tmp_path / "feature"
workspace.mkdir()
(workspace / ".git").write_text(f"gitdir: {worktree_git_dir}\n", encoding="utf-8")
registry = create_filesystem_registry(workspace)
assert isinstance(registry, GitFilesystemRegistry), (
f"Expected GitFilesystemRegistry for a linked worktree, "
f"got {type(registry).__name__}. "
"_is_git_repo may not be resolving commondir for worktree git dirs."
)
# ── _parse_git_porcelain_line ─────────────────────────────────────────────────