fix(worktree): retry the plain remove with --force for clean submodule trees

Gate finding (Medium, 2c + quality convergent): plain `git worktree
remove` always refuses a tree with a checked-out submodule ("working
trees containing submodules cannot be moved or removed"). Once --force
was dropped, clean merged trees like that got a reap verdict on every
startup and every `hermes worktree prune`, and were never removed.

Both paths still try the plain remove first. Only when its stderr names
submodules and an immediate re-check finds the tree still clean
(_worktree_is_dirty False on startup; _dirty_split == (False, []) in the
attended path) do they retry with --force, under the same safe env.
Adds a submodule row to the existing parametrized startup test.
This commit is contained in:
kshitijk4poor
2026-10-03 19:46:20 +05:30
committed by kshitij
parent e55816a8a4
commit f0cacd11e8
3 changed files with 17 additions and 2 deletions

View File

@@ -330,6 +330,10 @@ def reclaim_worktrees(
if record.untracked:
remove_args.append("--force") # only after the audited scratch was archived
remove_result = _git(remove_args, cwd=repo_root, timeout=30)
# Plain remove always refuses trees with submodules; --force only after a fresh clean check.
if (remove_result.returncode != 0 and not record.untracked
and "submodules" in remove_result.stderr and _dirty_split(record.path) == (False, [])):
remove_result = _git([*remove_args, "--force"], cwd=repo_root, timeout=30)
if remove_result.returncode != 0:
actions.append(f"failed to remove {record.name}: {remove_result.stderr.strip()}")
continue

View File

@@ -967,6 +967,11 @@ def _reap_prune_verdicts(repo_root: str, verdicts: list, stale_work_cutoff: floa
continue
remove_result = _git(["worktree", "remove", str(entry)], repo_root, timeout=15,
stdin=subprocess.DEVNULL, env=remove_env)
# Plain remove always refuses trees with submodules; --force only after a fresh clean check.
if (remove_result.returncode != 0 and "submodules" in remove_result.stderr
and not _worktree_is_dirty(str(entry), repo_root)):
remove_result = _git(["worktree", "remove", "--force", str(entry)], repo_root,
timeout=15, stdin=subprocess.DEVNULL, env=remove_env)
if remove_result.returncode != 0:
logger.debug("Failed to remove worktree %s: %s", entry.name, remove_result.stderr.strip())
continue

View File

@@ -52,7 +52,7 @@ def test_attended_reclaim_preserves_work_added_after_audit(repository, kind):
assert _git(root, "rev-parse", "--verify", "scratch")
@pytest.mark.parametrize("kind", ["edit", "commit", "include-symlink"])
@pytest.mark.parametrize("kind", ["edit", "commit", "include-symlink", "submodule"])
def test_startup_reclaim_preserves_work_added_after_classification(repository, kind):
from hermes_cli.worktree_ops import _classify_prune_candidates, _reap_prune_verdicts
@@ -62,6 +62,12 @@ def test_startup_reclaim_preserves_work_added_after_classification(repository, k
(root / ".worktreeinclude").write_text("node_modules\n", encoding="utf-8")
(root / ".gitignore").write_text("node_modules/\n.worktrees/\n", encoding="utf-8")
os.symlink(root / "node_modules", tree / "node_modules")
if kind == "submodule": # plain `worktree remove` always refuses these; clean + merged still reclaims
_git(root.parent, "init", "-b", "main", "sub")
_git(root.parent / "sub", "-c", "user.name=t", "-c", "user.email=t@x", "commit", "--allow-empty", "-m", "s")
_git(tree, "-c", "protocol.file.allow=always", "submodule", "add", str(root.parent / "sub"), "sub")
_git(tree, "-c", "commit.gpgsign=false", "commit", "-m", "add submodule")
_git(root, "merge", "--ff-only", "scratch")
verdicts = _classify_prune_candidates(str(root), [(tree, time.time() - 86400, False)])
assert verdicts[0][3] == "reap"
added_work = kind in {"edit", "commit"}
@@ -74,5 +80,5 @@ def test_startup_reclaim_preserves_work_added_after_classification(repository, k
if added_work:
assert (tree / "README.md").read_text(encoding="utf-8") == "new work after audit\n"
assert _git(root, "rev-parse", "--verify", "scratch")
else:
elif kind == "include-symlink":
assert (root / "node_modules").is_dir()