Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 110 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1798,6 +1798,14 @@ def _worktree_has_unpushed_commits(worktree_path: str, timeout: int = 10) -> boo
``refs/remotes/*``. If a repo has no remote-tracking refs yet, there is no
usable remote baseline to compare against, so treat it as having no
"unpushed" commits.

SHALLOW-CLONE CAVEAT: in a shallow clone (the installer default) the
shallow boundary can disconnect an older worktree HEAD from origin/*,
making already-public commits look unpushed. The verdict here stays
conservative (True) on purpose — deleting on unverifiable history would
risk real work. Callers that can afford it should deepen first via
``_deepen_shallow_repo`` (the startup pruner does) or check
``_repo_is_shallow`` before presenting this verdict as fact.
"""
import subprocess

Expand Down Expand Up @@ -1843,6 +1851,88 @@ def _worktree_is_dirty(worktree_path: str, timeout: int = 10) -> bool:
return True


def _repo_is_shallow(repo_path: str, timeout: int = 5) -> bool:
"""Return whether *repo_path* belongs to a shallow clone.

Shallowness poisons every history-connectivity verdict the worktree
machinery relies on: an older worktree's HEAD (a past snapshot of main)
is disconnected from current ``origin/main`` by the shallow boundary, so
``git log HEAD --not --remotes`` misreports thousands of already-public
commits as "unpushed" and the worktree is preserved forever. The default
installer clones with ``--depth 1``, so this is the normal state of a
user install, not an edge case.

Fails toward False: if git can't be queried we don't want callers to
take shallow-specific branches on top of an unknown state.
"""
import subprocess

try:
result = subprocess.run(
["git", "rev-parse", "--is-shallow-repository"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, cwd=repo_path,
)
return result.returncode == 0 and result.stdout.strip() == "true"
except Exception:
return False


def _deepen_shallow_repo(repo_root: str, timeout: int = 600) -> bool:
"""One-time blobless unshallow so history-based verdicts become correct.

Fetches the full commit/tree graph (``--unshallow --filter=blob:none``)
without downloading historical file contents, which keeps the transfer a
small fraction of a full clone. Runs only from background paths (the
startup pruner thread), never on the interactive session-close path.

Falls back to a plain ``--unshallow`` if the server rejects partial-clone
filters. Fail-soft: returns whether the repo is actually non-shallow
afterwards; on failure (offline, no remote) callers keep today's
preserve-everything behavior.
"""
import subprocess

if not _repo_is_shallow(repo_root):
return True

try:
remotes = subprocess.run(
["git", "remote"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, cwd=repo_root,
)
names = [r.strip() for r in remotes.stdout.splitlines() if r.strip()]
if remotes.returncode != 0 or not names:
return False
remote = "origin" if "origin" in names else names[0]

for extra in (["--filter=blob:none"], []):
try:
result = subprocess.run(
["git", "fetch", remote, "--unshallow", *extra],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, cwd=repo_root,
)
except subprocess.TimeoutExpired:
return False
if result.returncode == 0:
break
logger.debug(
"git fetch --unshallow%s failed: %s",
" " + " ".join(extra) if extra else "",
result.stderr.strip()[-500:],
)
except Exception as e:
logger.debug("Deepening shallow repo failed (non-fatal): %s", e)
return False

deepened = not _repo_is_shallow(repo_root)
if deepened:
logger.info(
"Deepened shallow clone at %s so worktree cleanup can verify "
"push state", repo_root,
)
return deepened


# Upper bound on retained `git cherry` verdict entries (see
# _save_worktree_merge_cache). Each entry is ~90 bytes, so this caps the cache
# near 90 KB even on a repo that churns thousands of worktree branches.
Expand Down Expand Up @@ -2080,8 +2170,17 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
has_unpushed = _worktree_has_unpushed_commits(wt_path, timeout=10)

if has_unpushed:
print(f"\n\033[33m⚠ Worktree has unpushed commits, keeping: {wt_path}\033[0m")
print(f" To clean up manually: git worktree remove --force {wt_path}")
if _repo_is_shallow(repo_root):
# In a shallow clone the unpushed verdict is unreliable: the
# shallow boundary disconnects this worktree's history from
# origin/*, so already-public commits look "unpushed". Be honest
# about why we're keeping it — the startup pruner deepens the
# clone in the background and will reap it on a later startup.
print(f"\n\033[33m⚠ Shallow clone — cannot verify push state, keeping: {wt_path}\033[0m")
print(" The next `hermes -w` session deepens the clone and prunes merged worktrees automatically.")
else:
print(f"\n\033[33m⚠ Worktree has unpushed commits, keeping: {wt_path}\033[0m")
print(f" To clean up manually: git worktree remove --force {wt_path}")
_active_worktree = None
return

Expand Down Expand Up @@ -2274,6 +2373,15 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
_prune_orphaned_branches(repo_root)
return

# A shallow clone (the installer's default `--depth 1`) disconnects old
# worktree HEADs from current origin/main, so the unpushed-commits guard
# misclassifies every aged worktree as unpushed work and preserves it
# forever. Deepen once — bloblessly, in this background thread — so all
# history verdicts below (and the session-exit cleanup) become correct.
# Fail-soft: offline, we just keep today's preserve-everything behavior.
if _repo_is_shallow(repo_root):
_deepen_shallow_repo(repo_root)

now = time.time()
stale_work_cutoff = now - (7 * 24 * 3600)
preserved_stale: list = []
Expand Down
158 changes: 158 additions & 0 deletions tests/cli/test_worktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -1182,3 +1182,161 @@ def __init__(self, *a, **kw):
cli._prune_stale_worktrees(str(git_repo))
assert not wt.exists(), "serial fallback must still reap the merged tree"



class TestShallowCloneDeepening:
"""Shallow installer clones (`git clone --depth 1`) break the unpushed
guard: the shallow boundary disconnects an older worktree HEAD from
origin/*, so `git log HEAD --not --remotes` misreports already-public
commits as unpushed and every aged worktree is preserved forever
(real incident: 21 of 25 hermes-* trees stuck on a default install).

These build a REAL shallow clone over file:// and verify the pruner
deepens it and reaps the false-positive tree.
"""

@staticmethod
def _run(cmd, cwd):
return subprocess.run(
cmd, cwd=cwd, capture_output=True, text=True,
)

@classmethod
def _upstream(cls, tmp_path):
"""Upstream repo with one commit (A). Returns its path."""
up = tmp_path / "upstream"
up.mkdir()
cls._run(["git", "init", "-b", "main"], up)
cls._run(["git", "config", "user.email", "test@test.com"], up)
cls._run(["git", "config", "user.name", "Test"], up)
(up / "README.md").write_text("# upstream\n")
cls._run(["git", "add", "."], up)
cls._run(["git", "commit", "-m", "A"], up)
return up

@classmethod
def _advance_upstream(cls, up, name):
(up / f"{name}.txt").write_text(f"{name}\n")
cls._run(["git", "add", "."], up)
cls._run(["git", "commit", "-m", name], up)

@classmethod
def _shallow_clone(cls, tmp_path, up):
clone = tmp_path / "shallow-clone"
subprocess.run(
["git", "clone", "--depth", "1", f"file://{up}", str(clone)],
capture_output=True, text=True,
)
cls._run(["git", "config", "user.email", "test@test.com"], clone)
cls._run(["git", "config", "user.name", "Test"], clone)
return clone

@staticmethod
def _age(path, hours=100):
import time
t = time.time() - (hours * 3600)
os.utime(path, (t, t))

def _stuck_worktree(self, tmp_path):
"""Build the incident shape: shallow clone at A, worktree at A,
upstream advances to B, shallow fetch moves origin/main to B.
Worktree HEAD (A) is now disconnected from origin/main (B)."""
import cli

up = self._upstream(tmp_path)
clone = self._shallow_clone(tmp_path, up)
assert cli._repo_is_shallow(str(clone)), "fixture must start shallow"

wt = clone / ".worktrees" / "hermes-shallowstuck"
(clone / ".worktrees").mkdir()
self._run(
["git", "worktree", "add", str(wt), "-b", "hermes/hermes-shallowstuck", "HEAD"],
clone,
)

self._advance_upstream(up, "B")
# Same shape as the updater: shallow fetch of the new tip only.
self._run(["git", "fetch", "--depth", "1", "origin", "main"], clone)
self._run(
["git", "update-ref", "refs/remotes/origin/main", "FETCH_HEAD"], clone,
)
self._age(wt)
return up, clone, wt

def test_shallow_disconnect_reproduces_false_unpushed(self, tmp_path):
"""Sanity: without deepening, the primitive misreports unpushed."""
import cli

_, clone, wt = self._stuck_worktree(tmp_path)
assert cli._worktree_has_unpushed_commits(str(wt)), (
"expected the shallow disconnect to look like unpushed commits — "
"if this stops reproducing, the fixture no longer exercises the bug"
)

def test_repo_is_shallow_detection(self, tmp_path, git_repo):
import cli

up = self._upstream(tmp_path)
clone = self._shallow_clone(tmp_path, up)
assert cli._repo_is_shallow(str(clone)) is True
assert cli._repo_is_shallow(str(git_repo)) is False
assert cli._repo_is_shallow(str(tmp_path / "nonexistent")) is False

def test_deepen_connects_history_and_clears_false_unpushed(self, tmp_path):
import cli

_, clone, wt = self._stuck_worktree(tmp_path)
assert cli._worktree_has_unpushed_commits(str(wt))

assert cli._deepen_shallow_repo(str(clone)) is True
assert not cli._repo_is_shallow(str(clone))
assert not cli._worktree_has_unpushed_commits(str(wt)), (
"after deepening, the worktree's HEAD is an ancestor of "
"origin/main and must no longer count as unpushed"
)

def test_pruner_deepens_and_reaps_stuck_worktree(self, tmp_path):
"""E2E: the startup pruner itself unshallows and reaps the tree."""
import cli

_, clone, wt = self._stuck_worktree(tmp_path)
cli._prune_stale_worktrees(str(clone))
assert not cli._repo_is_shallow(str(clone)), "pruner should deepen"
assert not wt.exists(), (
"deepened history proves the tree is merged/public — reap it"
)

def test_deepen_offline_fails_soft_and_preserves(self, tmp_path):
"""Unreachable remote: deepen fails, verdicts stay conservative."""
import cli

_, clone, wt = self._stuck_worktree(tmp_path)
# Point origin somewhere that does not exist.
self._run(
["git", "remote", "set-url", "origin", f"file://{tmp_path}/gone"],
clone,
)
assert cli._deepen_shallow_repo(str(clone), timeout=30) is False
cli._prune_stale_worktrees(str(clone))
assert wt.exists(), (
"offline deepen failure must fall back to preserving the tree"
)

def test_deepen_noop_on_full_clone(self, git_repo):
import cli
assert cli._deepen_shallow_repo(str(git_repo)) is True

def test_real_unpushed_work_survives_deepening(self, tmp_path):
"""Deepening must not turn genuinely unpushed commits reapable."""
import cli

_, clone, wt = self._stuck_worktree(tmp_path)
(wt / "real-work.txt").write_text("novel\n")
self._run(["git", "add", "real-work.txt"], wt)
self._run(["git", "commit", "-m", "real unpushed work"], wt)
self._age(wt)

cli._prune_stale_worktrees(str(clone))
assert wt.exists(), (
"genuinely unpushed commit must survive even after deepening"
)
Loading