diff --git a/hermes_cli/gitlock.py b/hermes_cli/gitlock.py index 4d4fe8816a..0ebb0aca09 100644 --- a/hermes_cli/gitlock.py +++ b/hermes_cli/gitlock.py @@ -126,6 +126,36 @@ def mark_unmarked_packs_promisor(repo_root: Path) -> int: return marked +# A tree:0 partial clone that lets detached git maintenance auto write a +# commit-graph lazy-fetches every missing tree on demand, and each fetch +# re-triggers maintenance, looping without bound (#127711). A repo cloned or +# converted to tree:0 never needs automatic maintenance; explicit git gc and +# git maintenance run still work. +_TREE0_MAINTENANCE_OFF = ( + ("maintenance.auto", "false"), + ("gc.auto", "0"), + ("fetch.writeCommitGraph", "false"), +) + + +def disable_tree0_auto_maintenance(repo_root: Path) -> None: + """Turn off automatic maintenance commit-graph writes in a tree:0 clone. + + Never raises: a read-only config must not turn fetch recovery into a + traceback, matching mark_unmarked_packs_promisor above. + """ + for key, value in _TREE0_MAINTENANCE_OFF: + try: + subprocess.run( + ["git", "config", key, value], + cwd=str(repo_root), check=True, + capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=30, + ) + except Exception: + logger.warning("Could not set %s=%s in %s", key, value, repo_root) + + def clear_stale_tmp_packs(repo_root: Path, *, min_age_seconds: Optional[int] = None) -> List[str]: """Remove aborted-transfer temp pack files; same contract as clear_stale_git_locks. @@ -482,6 +512,7 @@ def fetch_full_commit_graph(repo_root: Path, *extra_refspecs: str, **run_kwargs) # git writes the partial-clone config before it fetches, so a failed fetch converts too. if converts: mark_unmarked_packs_promisor(repo_root) + disable_tree0_auto_maintenance(repo_root) return shallow diff --git a/scripts/install.sh b/scripts/install.sh index 502c882fda..78b0b0a5bf 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -473,6 +473,13 @@ stage_repository() { : > "${pack%.pack}.promisor" || log_warn "could not mark $pack as a partial-clone pack" fi done + # Existing treeless checkout: same commit-graph lazy-fetch loop guard (#127711). + git -C "$INSTALL_DIR" config maintenance.auto false \ + || log_warn "could not disable maintenance.auto in $INSTALL_DIR" + git -C "$INSTALL_DIR" config gc.auto 0 \ + || log_warn "could not disable gc.auto in $INSTALL_DIR" + git -C "$INSTALL_DIR" config fetch.writeCommitGraph false \ + || log_warn "could not disable fetch.writeCommitGraph in $INSTALL_DIR" fi run_logged "Fetching origin/$BRANCH" git -C "$INSTALL_DIR" fetch origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH" \ || fail "git fetch failed" @@ -589,6 +596,15 @@ stage_repository() { fail "cannot publish cloned checkout" fi rmdir "$staged" + # A treeless checkout plus detached git maintenance auto commit-graph + # writes lazy-fetch every missing tree in a loop (#127711). Automatic + # maintenance is never needed here; explicit git gc still works. + git -C "$INSTALL_DIR" config maintenance.auto false \ + || log_warn "could not disable maintenance.auto in $INSTALL_DIR" + git -C "$INSTALL_DIR" config gc.auto 0 \ + || log_warn "could not disable gc.auto in $INSTALL_DIR" + git -C "$INSTALL_DIR" config fetch.writeCommitGraph false \ + || log_warn "could not disable fetch.writeCommitGraph in $INSTALL_DIR" log_success "Hermes Agent cloned" fi if [ -n "$INSTALL_COMMIT" ]; then diff --git a/tests/hermes_cli/test_gitlock.py b/tests/hermes_cli/test_gitlock.py index 69b473cfc6..6264b68a02 100644 --- a/tests/hermes_cli/test_gitlock.py +++ b/tests/hermes_cli/test_gitlock.py @@ -296,3 +296,66 @@ def test_update_check_debris_cleanup_folds_lazy_fetch_packs( lambda root: seen.setdefault("root", root) or 0) check.clear_git_debris(partial_clone) assert seen.get("root") == partial_clone + + + + +# ---- tree:0 auto-maintenance commit-graph loop (#127711) ---- +# +# A tree:0 partial clone that lets detached ``git maintenance --auto`` write a +# commit-graph lazy-fetches every missing tree on demand; each fetch +# re-triggers maintenance, looping unboundedly (2000+ git procs observed). +# Repos we clone or convert to tree:0 must therefore disable the automatic +# maintenance paths. These pin the helper and its wiring into the conversion. + +from hermes_cli.gitlock import ( # noqa: E402 + disable_tree0_auto_maintenance, + fetch_full_commit_graph, +) + + +def _git_config(repo: Path, key: str) -> str: + result = subprocess.run( + ["git", "config", "--get", key], cwd=str(repo), + capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + return result.stdout.strip() + + +def test_tree0_maintenance_off_lands_config(repo: Path) -> None: + disable_tree0_auto_maintenance(repo) + assert _git_config(repo, "maintenance.auto") == "false" + assert _git_config(repo, "gc.auto") == "0" + assert _git_config(repo, "fetch.writeCommitGraph") == "false" + + +def test_tree0_conversion_disables_auto_maintenance( + repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Tree:0 conversion still lands maintenance config.""" + import hermes_cli.gitlock as gitlock + + shallow = tmp_path / "shallow" + shallow.write_text("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n") + monkeypatch.setattr(gitlock, "_shallow_file_path", lambda _root: shallow) + monkeypatch.setattr(gitlock, "_partial_clone_filter", lambda _root, **_kw: None) + monkeypatch.setattr(gitlock, "_batch_missing_parents", lambda _root, _c: {"deadbeef"}) + marked = [] + monkeypatch.setattr(gitlock, "mark_unmarked_packs_promisor", lambda root: marked.append(root) or 0) + + real_run = subprocess.run + + def fake_run(argv, **kwargs): + if "fetch" in argv: + raise subprocess.CalledProcessError(1, argv, stderr="boom") + return real_run(argv, **kwargs) + + monkeypatch.setattr(gitlock.subprocess, "run", fake_run) + + with pytest.raises(subprocess.CalledProcessError): + fetch_full_commit_graph(repo, "origin", "main") + + assert marked == [repo] + assert _git_config(repo, "maintenance.auto") == "false" + assert _git_config(repo, "gc.auto") == "0" + assert _git_config(repo, "fetch.writeCommitGraph") == "false"