Skip to content
Closed
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
15 changes: 10 additions & 5 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1057,12 +1057,17 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
_active_worktree = None
return

# Unlock first so `remove` isn't blocked by the lock placed at creation. Fail-soft.
_git_quiet(["worktree", "unlock", wt_path], repo_root, log="git worktree unlock failed (non-fatal)")
_git_quiet(["worktree", "remove", wt_path, "--force"], repo_root, timeout=15, log="Failed to remove worktree")
_git_quiet(["branch", "-D", branch], repo_root, log=f"Failed to delete branch {branch}")

# Unlock, delete and drop the branch through the single removal owner
# (hermes_cli/worktree_removal.py) — never `git worktree remove`, whose
# recursive delete follows a Windows junction into whatever it points at.
from hermes_cli.worktree_removal import remove_worktree
outcome = remove_worktree(repo_root, wt_path, branch)
_active_worktree = None
if not outcome:
_cprint(f"\n\033[33m⚠ Keeping worktree: {wt_path}\033[0m")
print(f" {outcome.reason}")
return

_cprint(f"\033[32m✓ Worktree cleaned up: {wt_path}\033[0m")


Expand Down
22 changes: 13 additions & 9 deletions hermes_cli/kanban_db_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,19 +199,23 @@ def _cleanup_worktree_workspace(
task_id, wp,
)
return
# No --force: git's own dirty guard re-verifies at removal time, so if
# the tree became dirty since our check (TOCTOU) removal fails safe.
result = _git(repo_root, "worktree", "remove", str(wp), timeout=60)
if result.returncode != 0:
# The single removal owner (hermes_cli/worktree_removal.py) deletes the
# tree without traversing a reparse point. This is the site that caused
# the 2026-09-06 data loss: `git worktree remove` followed a
# `node_modules` junction out of the worktree and emptied two sibling
# source repositories, reporting only "worktree remove failed".
from hermes_cli.worktree_removal import remove_worktree
branch = (branch_name or "").strip() or f"wt/{task_id}"
outcome = remove_worktree(
repo_root, wp, branch if branch.startswith("wt/") else None
)
if not outcome:
_kb._log.warning(
"git worktree remove failed for task %s at %s: %s",
task_id, wp, (result.stderr or result.stdout or "").strip(),
"Preserving worktree for task %s at %s: %s",
task_id, wp, outcome.reason,
)
return
_kb._log.debug("Removed worktree workspace: %s", wp)
branch = (branch_name or "").strip() or f"wt/{task_id}"
if branch.startswith("wt/"):
_git(repo_root, "branch", "-D", branch, timeout=30)
except Exception:
pass # best-effort — never block completion

Expand Down
191 changes: 191 additions & 0 deletions hermes_cli/reparse_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
"""Delete a directory tree without ever traversing a Windows reparse point.

**Why this exists** (measured 2026-09-06, real data loss on a user's box):

A kanban worker needed node dependencies inside its git worktree, so it made
``<worktree>/app/node_modules`` a Windows **junction** pointing at the primary
checkout's ``node_modules``. The task finished; the dispatcher called
``git worktree remove``; git's recursive delete **followed the junction as if it
were an ordinary directory**, walked into the primary ``node_modules``, and from
there into ``@real3d/stage`` and ``@real3d/atoms`` — which npm materialises as
*further junctions* for ``file:`` dependencies, pointing at two sibling source
repositories. Both were emptied. They were only recoverable because they had
been pushed.

The failure is silent by construction. It surfaced as
``git worktree remove failed ...: Invalid argument`` — a warning about a
*worktree*, saying nothing about the two repositories it had just walked
through and deleted.

**Why nothing caught it.** ``os.path.islink()`` returns **False** for a
junction: only NTFS symlinks (tag ``IO_REPARSE_TAG_SYMLINK``) set the flag
Python reports. A junction (tag ``IO_REPARSE_TAG_MOUNT_POINT``) answers
``islink() == False`` and ``isdir() == True``, so every ordinary "don't follow
links" guard — in git, in ``shutil.rmtree``, in our own code — sees a plain
directory and descends. The only reliable test is the raw
``FILE_ATTRIBUTE_REPARSE_POINT`` bit, which is what this module reads.

## The safety model

Three states, not two. Every edge encountered during a removal is classified
``ordinary | reparse | unknown``:

- **ordinary** — a real directory or file; recurse into it / unlink it;
- **reparse** — a junction, symlink or other reparse point; unlink *the link
itself* and never look at what it points to. ``os.rmdir`` on a directory
reparse point detaches the link without recursing;
- **unknown** — anything we could not classify (``os.lstat``/``os.scandir``
raised). This **aborts the whole removal**. A metadata or access failure is
exactly how the junction class this module exists to stop would hide, so an
unclassified edge can never be treated as ordinary.

``remove_tree`` performs the deletion itself rather than classifying a tree and
then handing it to an external deleter. That is deliberate: a scan followed by
``git worktree remove`` is two generations, and a reparse point created in
between is still followable by the second one. Owning the walk means every edge
is traversed in the same step that classified it, so no *other* process gets a
turn between the check and the use.

The residual window is in-process and one edge wide: between ``lstat`` and the
``scandir`` of that same directory. Closing it completely needs handle-based
``O_NOFOLLOW``-style traversal, which CPython does not expose portably on
Windows; this module does not claim to have closed it, and the callers'
preconditions (the worktree is finished, its owning pid is dead) are what make
it acceptable in practice.

On non-Windows this is still correct but uninteresting: ``islink`` is honest
there and no deleter follows symlinks.
"""

from __future__ import annotations

import logging
import os
import stat
from pathlib import Path
from typing import List, Tuple

_log = logging.getLogger(__name__)

IS_WINDOWS = os.name == "nt"


class UnknownEdge(Exception):
"""An edge could not be classified, so the tree must not be deleted.

Carries the path that could not be classified and the underlying OS error,
because "we refused to delete" is only actionable if it names what stopped
it.
"""

def __init__(self, path: os.PathLike | str, cause: BaseException | None = None):
self.path = Path(path)
self.cause = cause
super().__init__(f"cannot classify {self.path}: {cause}")


def is_reparse_point(path: os.PathLike | str) -> bool:
"""True if *path* is a junction, symlink, or any other reparse point.

Deliberately NOT ``os.path.islink``: that answers False for a junction,
which is the exact blind spot that cost two repositories.

Raises ``UnknownEdge`` when the entry cannot be classified — callers on a
deletion path must treat that as "refuse", never as "ordinary". A missing
entry is not unknown: it is nothing, and answers False.
"""
try:
st = os.lstat(path)
except FileNotFoundError:
return False
except OSError as exc:
raise UnknownEdge(path, exc) from exc
if not IS_WINDOWS:
return stat.S_ISLNK(st.st_mode)
return bool(getattr(st, "st_file_attributes", 0) & stat.FILE_ATTRIBUTE_REPARSE_POINT)


def _unlink_edge(path: Path) -> None:
"""Remove one entry: the LINK if it is a link, the file if it is a file.

Never recurses and never touches a link's target. Raises ``OSError`` when
the entry survives, which the caller turns into a refusal.
"""
try:
os.remove(path)
return
except (IsADirectoryError, PermissionError, OSError):
# A directory (or a directory reparse point) needs rmdir; on Windows
# os.remove refuses one with PermissionError rather than IsADirectory.
os.rmdir(path)


def remove_tree(root: os.PathLike | str) -> int:
"""Delete *root* recursively, never traversing a reparse point.

Returns the number of entries removed. Raises ``UnknownEdge`` — having
deleted nothing further — when any edge cannot be classified.

A reparse point AT *root* is unlinked, not followed: asking to delete a
junction deletes the junction.
"""
root_path = Path(root)
if is_reparse_point(root_path):
_unlink_edge(root_path)
_log.info("Unlinked reparse point instead of descending: %s", root_path)
return 1
if not root_path.exists():
return 0

removed = 0
# Post-order: children before their parent, iteratively so a deep tree
# cannot blow the stack.
stack: List[Tuple[Path, bool]] = [(root_path, False)]
while stack:
current, children_done = stack.pop()
if children_done:
_unlink_edge(current)
removed += 1
continue

try:
entries = list(os.scandir(current))
except OSError as exc:
raise UnknownEdge(current, exc) from exc

stack.append((current, True))
for entry in entries:
entry_path = Path(entry.path)
# Classify FIRST — is_reparse_point raises UnknownEdge rather than
# letting an unreadable entry be mistaken for an ordinary one.
if is_reparse_point(entry_path):
Comment on lines +156 to +161

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — rechecking the final component does not bind its parent identity. In a deterministic Linux fixture, enumerate root, rename it aside, and replace the old pathname with a symlink to an external directory containing the same child filename. Path(entry.path) now resolves through that replacement; the fresh child lstat sees an ordinary file and _unlink_edge deletes the external file. The candidate returns success. The fd-based shutil.rmtree control under the same schedule preserves the victim. This extends the existing queued-directory finding: merely reclassifying current before scandir does not close replacement after enumeration. Child operations need retained fd/handle-relative identity, or an enforceable namespace-exclusion fence. Executed regression and control are in the full review.

_unlink_edge(entry_path) # the link, never its target
removed += 1
_log.info("Unlinked reparse point during removal: %s", entry_path)
continue
try:
is_dir = entry.is_dir(follow_symlinks=False)
except OSError as exc:
raise UnknownEdge(entry_path, exc) from exc
if is_dir:
stack.append((entry_path, False))
else:
_unlink_edge(entry_path)
removed += 1
return removed


def try_remove_tree(root: os.PathLike | str) -> Tuple[bool, str]:
"""``remove_tree`` for best-effort cleanup paths.

Returns ``(removed, reason)``. ``reason`` is empty on success and otherwise
names what stopped the removal, so the caller can log a sentence that says
which path refused — the thing the original incident's warning never did.
"""
try:
remove_tree(root)
return (True, "")
except UnknownEdge as exc:
return (False, f"unclassifiable entry {exc.path} ({exc.cause})")
except OSError as exc:
return (False, f"{type(exc).__name__}: {exc}")
10 changes: 9 additions & 1 deletion hermes_cli/web_git.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,15 @@ def worktree_add(cwd: str, options: dict) -> dict:


def worktree_remove(cwd: str, worktree_path: str, force: bool) -> dict:
_git_ok(_main_root(cwd), ["worktree", "remove", *(["--force"] if force else []), worktree_path])
# Goes through the single removal owner rather than `git worktree remove`:
# that command's recursive delete follows a Windows junction into whatever
# it points at (hermes_cli/reparse_guard.py). This route is reachable from
# the dashboard, so it is the same gun pointed by an HTTP request.
from hermes_cli.worktree_removal import remove_worktree

outcome = remove_worktree(_main_root(cwd), worktree_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

force is now accepted by this public/dashboard-facing function but discarded here. Because remove_worktree() has no dirty/force policy, force=False no longer means “Git must refuse a dirty worktree”; it means the custom walker deletes it anyway. Please carry this authority into the single removal owner and revalidate dirtiness at the mutation sink. The existing Kanban TOCTOU regression (test_tree_dirtied_between_check_and_removal_preserved) is the same missing sink-time check from another caller.

if not outcome:
raise RuntimeError(f"worktree not removed: {outcome.reason}")
return {"removed": worktree_path}


Expand Down
17 changes: 11 additions & 6 deletions hermes_cli/worktree_gc.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,15 +203,20 @@ def reclaim_worktrees(
with contextlib.suppress(Exception):
_git(["worktree", "unlock", record.path], cwd=repo_root, timeout=10)
try:
remove_result = _git(["worktree", "remove", record.path, "--force"], cwd=repo_root, timeout=30)
if remove_result.returncode != 0:
actions.append(f"failed to remove {record.name}: {remove_result.stderr.strip()}")
# The single removal owner: it never lets `git worktree remove` do
# the deleting, because that walk follows a Windows junction into
# whatever it points at. It prunes the admin entry too.
from hermes_cli.worktree_removal import remove_worktree
keep_branch = record.verdict == "reap-keep-branch"
drop = record.branch if (record.branch and not keep_branch
and record.branch not in _PROTECTED_BRANCHES) else None
outcome = remove_worktree(repo_root, record.path, drop, unlock=False)
if not outcome:
actions.append(f"kept {record.name}: {outcome.reason}")
continue
if record.verdict == "reap-keep-branch":
if keep_branch:
actions.append(f"removed {record.name} (branch {record.branch} kept — pushed open-PR lane)")
continue
if record.branch and record.branch not in _PROTECTED_BRANCHES:
_git(["branch", "-D", record.branch], cwd=repo_root, timeout=10)
actions.append(f"removed {record.name}")
except Exception as exc:
actions.append(f"failed to remove {record.name}: {exc}")
Expand Down
33 changes: 19 additions & 14 deletions hermes_cli/worktree_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,14 @@ def _cleanup_failed_worktree_add(repo_root: str, wt_path: Path, branch_name: str
sometimes the branch, so any retry of the same name fails.
"""
try:
# Unlock first: `worktree remove --force` refuses a locked tree.
_git_quiet(["worktree", "unlock", str(wt_path)], repo_root, timeout=15)
_git_quiet(["worktree", "remove", "--force", str(wt_path)], repo_root, timeout=15)
if wt_path.exists():
shutil.rmtree(wt_path, ignore_errors=True)
# `remove` needs the dir; `prune` drops the admin entry when it is already gone.
_git_quiet(["worktree", "prune"], repo_root, timeout=15)
_git_quiet(["branch", "-D", branch_name], repo_root, timeout=15)
# One owner does the removal (hermes_cli/worktree_removal.py): it never
# asks `git worktree remove` to delete, because that traversal follows a
# Windows junction into whatever it points at. It also prunes the admin
# entry and drops the branch.
from hermes_cli.worktree_removal import remove_worktree
outcome = remove_worktree(repo_root, wt_path, branch_name)
if not outcome:
logger.warning("Keeping partial worktree %s: %s", wt_path, outcome.reason)
except Exception as e:
logger.debug("cleanup after failed worktree add: %s", e)

Expand Down Expand Up @@ -761,14 +761,19 @@ def _reap_prune_verdicts(repo_root: str, verdicts: list, stale_work_cutoff: floa

try:
branch = _git(["branch", "--show-current"], str(entry), timeout=5).stdout.strip()
remove_result = _git(["worktree", "remove", str(entry), "--force"], repo_root, timeout=15)
if remove_result.returncode != 0:
logger.debug("Failed to remove worktree %s: %s", entry.name, remove_result.stderr.strip())
# The single removal owner: it deletes without traversing a reparse
# point and prunes the admin entry, so `git worktree remove` — whose
# walk follows a junction into its target — is never used here.
from hermes_cli.worktree_removal import remove_worktree
keep_branch = bool(branch) and verdict == "reap-keep-branch"
outcome = remove_worktree(
repo_root, entry, None if keep_branch else (branch or None), unlock=False
)
if not outcome:
logger.warning("Preserving worktree %s: %s", entry.name, outcome.reason)
continue
if branch and verdict == "reap-keep-branch":
if keep_branch:
kept_branches.add(branch)
elif branch:
_git(["branch", "-D", branch], repo_root)
logger.debug("Pruned stale worktree: %s (force=%s)", entry.name, force)
except Exception as e:
logger.debug("Failed to prune worktree %s: %s", entry.name, e)
Expand Down
Loading