fix: worktree sweep keeps any tree holding work an archive cannot restore

The second review found trees deleted with bytes or commits that were in
neither git nor the archive: untracked files over archive_max_file_mb
(skipped from the snapshot, tree still removed), gitignored embedded
repositories (ls-files --exclude-standard never lists them) and every
ignored file (.env edits, *.secret, info/exclude notes). The classic
pruner had three older gaps of the same class: status.showUntrackedFiles=no
made new files invisible, skip-worktree/assume-unchanged edits read clean,
and commits held only by refs/worktree/* or refs/bisect/* died with the
tree's admin dir.

Rule now enforced at one decision point (worktree_retention.removal_blocker)
used by archive_preflight, the post-snapshot removal boundary in
archive_then_remove (sweep, exit Archive, subagent finalize), the classic
clean-tree pruner, the attended `hermes worktree prune` and the clean exit
path: a tree is removed only when everything irreproducible is in git or the
archive. Kept: keep-large-untracked (archive ref stays), keep-unarchivable
(embedded repo, ignored or not), keep-worktree-refs, keep-ignored-work
(ignored files outside a regenerable-dir allowlist that are not
byte-identical to the main checkout's copy, so .worktreeinclude .env copies
never block). Exit Archive and the subagent note name the reason. Every
dirty check forces --untracked-files=normal and counts flagged-index edits.
The ignored-path walk skips dependency stores without descending and is
capped per tree (unverifiable -> keep).
This commit is contained in:
Teknium committed 2026-10-17 21:49:14 -07:00
1 parent d413d8b1b7
commit ab1c29d8bb
31 files changed
+549 -112

No files matched your search

+1 -1
View File
@@ -397,7 +397,7 @@ model:
# refs/hermes/archive/<tree> and removed; `hermes worktree restore <name>` brings one back.
# worktrees:
# archive_dirty: true # false = never remove dirty/unpushed trees (old behaviour)
# archive_max_file_mb: 50 # untracked files above this are listed, not archived
# archive_max_file_mb: 50 # untracked files above this are not archived; their tree is kept
# archive_retention_days: 30 # archive refs older than this are deleted (0 = keep)
# max_total_gb: 0 # optional budget for idle trees; oldest evicted first
+6
View File
@@ -577,6 +577,12 @@ def _cleanup_worktree(info: dict[str, str] | None = None) -> None:
print(f" {_t('cli.worktree.clean_up_manually', path=wt_path)}")
return
from hermes_cli.worktree_ops import removal_blocked
blocked = removal_blocked(wt_path) # an embedded repo, private ref or ignored file dies with it
if blocked:
_cprint(f"\n\033[33m{_t('cli.worktree.kept_reason', path=wt_path, reason=blocked[1])}\033[0m")
return
# Release the tree's language servers while the path still exists, then unlock so `remove`
# isn't blocked by the lock placed at creation. Fail-soft.
release_lsp_clients(wt_path)
+1 -1
View File
@@ -500,7 +500,7 @@ DEFAULT_CONFIG = {
"worktrees": {
# false = never remove a dirty/unpushed tree (the pre-archive behaviour, exactly).
"archive_dirty": True,
# Untracked files larger than this (MB) are left out of the archive and listed in it.
# Untracked files larger than this (MB) are not archived, so a tree holding one is kept.
"archive_max_file_mb": 50,
# The sweep deletes archive refs older than this many days. 0 keeps them forever.
"archive_retention_days": 30,
+12 -9
View File
@@ -11,8 +11,10 @@ Bytes are stored raw: every tracked/untracked regular file is re-hashed with
``hash-object --no-filters`` and restored by writing the blob bytes directly, so no attribute
source (``.gitattributes``, ``$GIT_COMMON_DIR/info/attributes``, ``core.attributesFile``) and no
clean/smudge/eol conversion can change them; a restore writes back exactly the archived bytes. Untracked files over a size cap are skipped and listed
(so ``.git`` never balloons with build artefacts the ignore rules missed); an untracked embedded
repository cannot be captured at all and fails the snapshot, which keeps the tree.
(so ``.git`` never balloons with build artefacts the ignore rules missed), and a tree with a
skipped file is never removed; an untracked embedded repository cannot be captured at all and
fails the snapshot, which keeps the tree. Ignored files are not captured: removal is allowed only
when every one is reproducible (``worktree_retention``).
"""
from __future__ import annotations
@@ -213,16 +215,17 @@ def _digest_index(wt: Path, env) -> str:
return hashlib.sha256(_ok(["ls-files", "--stage", "-z"], wt, env).encode("utf-8", "surrogateescape")).hexdigest()
def archive_preflight(wt_path) -> Optional[str]:
"""Why *wt_path* cannot be snapshotted losslessly, or None. Read-only."""
def archive_preflight(wt_path, max_untracked_mb: float = 50) -> Optional[tuple[str, str]]:
"""``(keep-action, reason)`` when archiving and removing *wt_path* would lose something
(``worktree_retention.removal_blocker``: submodule, embedded repository, per-worktree ref,
an untracked file over the size cap, a non-reproducible ignored file), else None. Read-only."""
from hermes_cli.worktree_retention import KEEP_UNARCHIVABLE, removal_blocker
try:
wt = Path(wt_path)
embedded = [p for p in _untracked(wt, _env(wt)) if p.endswith("/")]
if not embedded:
return submodule_work(wt)
skipped = _split_oversized(wt, _untracked(wt, _env(wt)), max_untracked_mb)
except (ArchiveError, OSError, subprocess.SubprocessError) as exc:
return str(exc)
return f"untracked embedded repository {embedded[0].rstrip('/')} cannot be archived"
return KEEP_UNARCHIVABLE, str(exc)
return removal_blocker(wt, skipped=skipped, gitlinks=True)
def _split_oversized(wt: Path, untracked: list[str], max_mb: float) -> list[dict]:
+3 -2
View File
@@ -95,8 +95,9 @@ def _sweep(repo_root: str, dry_run: bool, older_than: Optional[float] = None) ->
from hermes_cli.worktree_archive import prune_expired_archives
expired = prune_expired_archives(repo_root, settings["archive_retention_days"])
from agent.i18n import t
saved = [t("cli.worktree.sweep_archived_removed" if removed else "cli.worktree.sweep_archived_kept", ref=ref)
for ref, removed in archived]
saved = [t("cli.worktree.sweep_archived_removed", ref=ref) if removed
else t("cli.worktree.sweep_archived_kept", ref=ref, reason=why or "?")
for ref, removed, why in archived]
return plan, saved + [f"deleted expired archive {r}" for r in expired]
+5 -2
View File
@@ -94,14 +94,17 @@ def apply_exit_choice(info: dict, echo) -> bool:
from hermes_cli.worktree_sweep import archive_then_remove, sweep_settings
echo(t("cli.worktree.archiving", path=path))
try:
ref, _kept, removed = archive_then_remove(
ref, _kept, removed, why = archive_then_remove(
repo_root, path, reason="session exit", unlock=True, exclude_pids=(os.getpid(),),
max_untracked_mb=sweep_settings()["archive_max_file_mb"])
except (ArchiveError, OSError, subprocess.SubprocessError, KeyboardInterrupt) as exc:
echo(f"\033[31m{t('cli.worktree.archive_failed', path=path, error=str(exc) or 'interrupted')}\033[0m")
return True
name = ref.rsplit("/", 1)[-1]
echo(f"\033[32m{t('cli.worktree.archived' if removed else 'cli.worktree.archived_kept', path=path, ref=ref)}\033[0m")
if removed:
echo(f"\033[32m{t('cli.worktree.archived', path=path, ref=ref)}\033[0m")
else:
echo(f"\033[33m{t('cli.worktree.archived_kept', path=path, ref=ref, reason=why or '?')}\033[0m")
echo(f" {t('cli.worktree.restore_hint', name=name)}")
return True
from hermes_cli._subprocess_compat import noninteractive_git_env
+11 -1
View File
@@ -97,11 +97,15 @@ def _tree_size_mb(path: Path, timeout: int = 30) -> Optional[int]:
def _dirty_split(path: str) -> tuple[bool, list[str]]:
"""(has_tracked_modifications, untracked_paths) — tracked = real work, untracked = archivable."""
try:
result = _git(["status", "--porcelain"], cwd=path, timeout=10)
result = _git(["status", "--porcelain", "--untracked-files=normal"], cwd=path, timeout=10)
if result.returncode != 0:
return True, [] # fail safe: treat as real work
lines = [line for line in result.stdout.splitlines() if line.strip()]
untracked = [line[3:].strip() for line in lines if line.startswith("??")]
if len(untracked) == len(lines):
from hermes_cli.worktree_retention import hidden_index_edits
if hidden_index_edits(path): # skip-worktree / assume-unchanged edits
return True, []
return len(untracked) != len(lines), untracked
except Exception:
return True, []
@@ -146,9 +150,15 @@ def _classify_tree(_ops, repo_root: str, entry: Path, merge_cache, remote_heads)
# as unpushed forever. A head EXACTLY matching the remote branch has nothing origin lacks.
if not _ops._worktree_branch_pushed_exact(path, remote_heads, timeout=10):
return "keep", "unpushed commits not found upstream", []
blocked = _ops.removal_blocked(path)
if blocked:
return "keep", blocked[1], []
if untracked:
return "reap-keep-branch", f"pushed to origin (open-PR lane); branch kept; {archive_note}", untracked
return "reap-keep-branch", "pushed to origin (open-PR lane); branch kept", []
blocked = _ops.removal_blocked(path)
if blocked:
return "keep", blocked[1], []
if untracked:
return "reap-archive", f"merged/pushed; {archive_note}", untracked
return "reap", "clean and fully merged/pushed", []
+17 -15
View File
@@ -558,7 +558,9 @@ def _worktree_is_dirty(worktree_path: str, repo_root, timeout: int = 10) -> bool
Untracked ``.worktreeinclude`` directory symlinks back to the main checkout *repo_root* are
ignored: they are our own scaffolding, and counting them would keep every worktree of such a
repo forever.
repo forever. ``status.showUntrackedFiles=no`` is overridden (it would hide new files), and
skip-worktree / assume-unchanged files whose bytes differ from the index count as changes
(``git status`` trusts both flags).
"""
from hermes_cli._subprocess_compat import noninteractive_repo_git_env
try:
@@ -566,13 +568,14 @@ def _worktree_is_dirty(worktree_path: str, repo_root, timeout: int = 10) -> bool
env = noninteractive_repo_git_env(worktree_path)
if env is None:
return True
result = _git(["status", "--porcelain", "-z"], worktree_path, timeout=timeout,
stdin=subprocess.DEVNULL, env=env)
result = _git(["status", "--porcelain", "-z", "--untracked-files=normal"], worktree_path,
timeout=timeout, stdin=subprocess.DEVNULL, env=env)
if result.returncode != 0:
return True
entries = [e for e in result.stdout.split("\0") if e]
if not entries:
return False
from hermes_cli.worktree_retention import hidden_index_edits
return bool(hidden_index_edits(worktree_path))
if any(not e.startswith("?? ") for e in entries):
return True
include_links = _include_symlink_paths(worktree_path, repo_root)
@@ -1013,14 +1016,13 @@ def _classify_prune_candidates(repo_root: str, candidates: list) -> list:
_PRESERVE_REASONS = {"dirty": "uncommitted changes", "unpushed": "unpushed commits"}
def submodule_blocks_removal(path: str, gitlinks: bool = False) -> Optional[str]:
"""Why a submodule keeps the tree at *path* (``worktree_archive.submodule_work``); an
unreadable tree counts as blocked."""
from hermes_cli.worktree_archive import ArchiveError, submodule_work
try:
return submodule_work(path, gitlinks=gitlinks)
except (ArchiveError, OSError, subprocess.SubprocessError) as exc:
return f"submodule check failed: {exc}"
def removal_blocked(path: str) -> Optional[tuple[str, str]]:
"""``(keep-action, reason)`` when deleting the clean tree at *path* would lose something no
branch holds (``worktree_retention.removal_blocker``: a checked-out submodule, an embedded
repository, a per-worktree ref, an ignored file that is not reproducible); unverifiable
counts as blocked."""
from hermes_cli.worktree_retention import removal_blocker
return removal_blocker(path)
def _reap_prune_verdicts(repo_root: str, verdicts: list, stale_work_cutoff: float) -> tuple[list, set]:
@@ -1040,9 +1042,9 @@ def _reap_prune_verdicts(repo_root: str, verdicts: list, stale_work_cutoff: floa
if verdict == "locked-live":
logger.debug("Skipping live-locked worktree: %s", entry.name)
continue
why = submodule_blocks_removal(str(entry))
if why:
preserved_stale.append(f"{entry.name} ({why})")
blocked = removal_blocked(str(entry))
if blocked:
preserved_stale.append(f"{entry.name} ({blocked[1]})")
continue
if lock_state == "dead":
+246
View File
@@ -0,0 +1,246 @@
"""What a worktree holds that neither git nor an archive can bring back.
Every path that deletes a tree (the sweep, the classic clean-tree pruner, exit-time Archive,
subagent finalization, the attended ``hermes worktree prune``) asks :func:`removal_blocker`
first; anything it names keeps the tree. The rule: a tree may only be removed when everything
that is not reproducible is in git or in the archive. Not reproducible:
* an embedded repository anywhere below the tree, ignored or not (``build/tool/.git``, a nested
worktree): its private commits live only on disk (``keep-unarchivable``);
* a commit held only by a per-worktree ref (``refs/worktree/*``, ``refs/bisect/*``,
``refs/rewritten/*``), which dies with the tree's admin dir (``keep-worktree-refs``);
* an untracked file the snapshot skipped for size (``keep-large-untracked``);
* an ignored file, unless it lies under a well-known regenerable directory
(:data:`REGENERABLE_DIRS`) or is a byte-identical copy of the same path in the main checkout
(what ``.worktreeinclude`` copies, e.g. ``.env``) (``keep-ignored-work``).
:func:`hidden_index_edits` finds skip-worktree / assume-unchanged files whose bytes differ from
the index: ``git status`` never reports them, so dirty checks must add them.
"""
from __future__ import annotations
import filecmp
import os
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
# Ignored content under these directory names is regenerated by a build/install/test run.
REGENERABLE_DIRS = frozenset({
"node_modules", ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache",
".tox", "dist", "build", "target", ".next", ".turbo", ".cache", "coverage", ".gradle",
})
# Dependency stores and caches: never descended, not even to look for an embedded repository.
_OPAQUE_DIRS = frozenset({
"node_modules", ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache",
".tox", ".next", ".turbo", ".cache", ".gradle",
})
_PER_WORKTREE_REFS = ("refs/worktree/", "refs/bisect/", "refs/rewritten/")
_MAX_ENTRIES = 200_000 # per tree; past this the ignored content is unverifiable -> keep
_NAMED = 5 # offenders named in a reason
KEEP_UNARCHIVABLE = "keep-unarchivable"
KEEP_WORKTREE_REFS = "keep-worktree-refs"
KEEP_LARGE_UNTRACKED = "keep-large-untracked"
KEEP_IGNORED_WORK = "keep-ignored-work"
KEEP_SUBMODULE_WORK = "keep-submodule-work"
def _regenerable(rel: str) -> bool:
return any(part in REGENERABLE_DIRS or part.endswith(".egg-info") for part in rel.split("/"))
def _env(wt: Path) -> dict:
from hermes_cli.worktree_archive import _env as archive_env
return {**archive_env(wt), "GIT_OPTIONAL_LOCKS": "0"}
def _git(args, wt: Path, env, timeout: float = 120) -> str:
from hermes_cli.worktree_archive import _ok
return _ok(args, wt, env, timeout=timeout)
def main_checkout(wt: Path, env) -> Optional[Path]:
"""The repository's main working tree (``<common-dir>/..``), or None for a bare repo."""
common = Path(_git(["rev-parse", "--path-format=absolute", "--git-common-dir"], wt, env).strip())
return common.parent if common.name == ".git" and common.parent.is_dir() else None
def _same_as_main(path: Path, rel: str, main: Optional[Path]) -> bool:
"""*path* is byte-identical to (or a symlink equal to / pointing at) *rel* in *main*."""
if main is None:
return False
twin = main / rel
try:
if path.is_symlink():
if twin.is_symlink() and os.readlink(path) == os.readlink(twin):
return True
return path.resolve() == twin.resolve() # a .worktreeinclude link back to the main checkout
return twin.is_file() and not twin.is_symlink() and filecmp.cmp(path, twin, shallow=False)
except OSError:
return False
@dataclass
class _Scan:
embedded: list = field(default_factory=list)
ignored: list = field(default_factory=list)
entries: int = 0
truncated: bool = False
def _walk(scan: _Scan, wt: Path, rel_dir: str, main: Optional[Path], *, files: bool) -> None:
"""Below ignored *rel_dir*: record nested ``.git`` entries and (when *files*) every file
that is neither regenerable nor identical to the main checkout. Symlinks are not followed."""
stack = [rel_dir]
while stack and not scan.truncated:
rel = stack.pop()
try:
entries = list(os.scandir(wt / rel))
except OSError:
continue
for entry in entries:
scan.entries += 1
if scan.entries > _MAX_ENTRIES:
scan.truncated = True
return
child = f"{rel}/{entry.name}"
if entry.name == ".git":
scan.embedded.append(rel)
continue
if entry.is_dir(follow_symlinks=False):
if entry.name not in _OPAQUE_DIRS:
stack.append(child)
elif files and not _regenerable(child) and not _same_as_main(Path(entry.path), child, main):
scan.ignored.append(child)
def scan_tree(wt_path) -> _Scan:
"""Embedded repositories and non-reproducible ignored files of *wt_path*. Read-only.
Untracked non-ignored repositories are reported by ``ls-files`` itself (``dir/``); ignored
ones hide inside collapsed ignored directories, so those are walked (bounded; dependency
stores are never descended). Raises ``ArchiveError`` when git fails.
"""
wt = Path(wt_path)
env = _env(wt)
scan = _Scan()
# Without --directory git descends untracked dirs and lists each embedded repository as "dir/".
untracked = _git(["ls-files", "--others", "--exclude-standard", "-z"], wt, env)
scan.embedded += [p.rstrip("/") for p in untracked.split("\0") if p.endswith("/")]
out = _git(["ls-files", "--others", "--ignored", "--exclude-standard", "--directory",
"--no-empty-directory", "-z"], wt, env)
main = main_checkout(wt, env)
for rel in (p for p in out.split("\0") if p):
rel_clean = rel.rstrip("/")
path = wt / rel_clean
if rel.endswith("/") and not path.is_symlink():
if rel_clean.rsplit("/", 1)[-1] in _OPAQUE_DIRS:
continue
if (path / ".git").exists():
scan.embedded.append(rel_clean)
continue
_walk(scan, wt, rel_clean, main, files=not _regenerable(rel_clean))
elif not _regenerable(rel_clean) and not _same_as_main(path, rel_clean, main):
scan.ignored.append(rel_clean)
if scan.truncated:
break
return scan
def hidden_index_edits(wt_path, env=None) -> list[str]:
"""Skip-worktree / assume-unchanged paths whose disk state differs from the index.
``git status`` trusts both flags and reports nothing. A skip-worktree file missing from disk
is a sparse checkout, not an edit; a missing assume-unchanged file is a deletion. Raises
``ArchiveError`` when git fails.
"""
wt = Path(wt_path)
env = env or _env(wt)
flagged = []
for record in _git(["ls-files", "-v", "-s", "-z"], wt, env).split("\0"):
meta, _, rel = record.partition("\t")
parts = meta.split()
if not rel or len(parts) != 4 or not (parts[0] == "S" or parts[0].islower()):
continue
path = wt / rel
if not os.path.lexists(path):
if parts[0] != "S":
flagged.append(rel) # assume-unchanged file deleted
continue
flagged.append((rel, parts[1], parts[2]))
edits = [f for f in flagged if isinstance(f, str)]
for rel, mode, sha in (f for f in flagged if not isinstance(f, str)):
path = wt / rel
if mode == "120000":
data = os.readlink(path).encode("utf-8", "surrogateescape") if path.is_symlink() else None
else:
data = None if path.is_symlink() or not path.is_file() else path.read_bytes()
if data is None:
edits.append(rel) # type changed
continue
if _hash(wt, env, data, rel, raw=True) != sha and _hash(wt, env, data, rel, raw=False) != sha:
edits.append(rel)
return edits
def _hash(wt: Path, env, data: bytes, rel: str, *, raw: bool) -> str:
args = ["hash-object", "--stdin"] + (["--no-filters"] if raw else [f"--path={rel}"])
result = subprocess.run(["git", *args], cwd=str(wt), env=env, input=data, capture_output=True,
timeout=60, check=False)
return result.stdout.decode("ascii", "replace").strip() if result.returncode == 0 else ""
def private_worktree_refs(wt_path, env=None) -> list[str]:
"""Per-worktree refs of *wt_path* holding commits no branch, tag, remote or archive reaches.
They live in the tree's admin dir and vanish with it. Raises ``ArchiveError`` when git fails.
"""
wt = Path(wt_path)
env = env or _env(wt)
out = _git(["for-each-ref", "--format=%(refname)%00%(objectname)", *_PER_WORKTREE_REFS], wt, env)
private = []
for line in out.splitlines():
name, _, sha = line.partition("\0")
if sha and _git(["rev-list", "--max-count=1", sha, "--not", "--branches", "--tags", "--remotes",
"--glob=refs/hermes/archive/*"], wt, env).strip():
private.append(name)
return private
def _named(paths: list, more: bool = False) -> str:
extra = len(paths) - _NAMED
tail = f" (+{extra} more)" if extra > 0 else (" (+more)" if more else "")
return ", ".join(paths[:_NAMED]) + tail
def removal_blocker(wt_path, *, skipped: Optional[list] = None, gitlinks: bool = False
) -> Optional[tuple[str, str]]:
"""``(keep-action, reason)`` when removing *wt_path* would lose something, else None.
*skipped* is the snapshot's ``skipped_untracked`` list (or the oversize files a preflight
would skip). *gitlinks* refuses any submodule (archive paths); without it only a checked-out
or retained module repository blocks. Any failure to verify blocks. Read-only.
"""
from hermes_cli.worktree_archive import ArchiveError, submodule_work
try:
why = submodule_work(wt_path, gitlinks=gitlinks)
if why:
return KEEP_SUBMODULE_WORK, why
scan = scan_tree(wt_path)
if scan.embedded:
return KEEP_UNARCHIVABLE, f"embedded repository {scan.embedded[0]} cannot be archived"
refs = private_worktree_refs(wt_path)
if refs:
return KEEP_WORKTREE_REFS, f"per-worktree ref {_named(refs)} holds commits no branch has"
if skipped:
return KEEP_LARGE_UNTRACKED, f"untracked file(s) over the archive size cap: {_named([s['path'] for s in skipped])}"
if scan.truncated:
return KEEP_IGNORED_WORK, f"over {_MAX_ENTRIES} ignored entries; cannot verify they are reproducible"
if scan.ignored:
return KEEP_IGNORED_WORK, f"ignored file(s) not reproducible: {_named(scan.ignored)}"
except (ArchiveError, OSError, subprocess.SubprocessError) as exc:
return KEEP_UNARCHIVABLE, f"could not verify the tree is safe to remove: {exc}"
return None
+36 -37
View File
@@ -6,9 +6,10 @@ tiers and classifier, then removes an idle dirty/unpushed tree only after archiv
working state to ``refs/hermes/archive/<tree>`` (``worktree_archive``); a failed archive keeps
the tree. Never touched, at any age or budget: Kanban ``t_<hex>`` trees, trees whose ``hermes``
lock owner still runs, trees any live process sits in (cwd or open file), and trees with file or
ref activity younger than their tier, and trees with a submodule (``keep-submodule-work``: an
archive records only the gitlink; see ``worktree_archive.submodule_work``). With ``archive_dirty: false`` and no size budget the
classic pruner runs unchanged.
ref activity younger than their tier, and trees holding anything an archive cannot carry
(``worktree_retention.removal_blocker``: a submodule, an embedded repository, a per-worktree ref,
an untracked file over the size cap, an ignored file that is not reproducible). With
``archive_dirty: false`` and no size budget the classic pruner runs unchanged.
``plan_sweep`` is read-only (``hermes worktree prune --dry-run`` prints it); ``execute_plan``
applies a frozen plan.
@@ -196,12 +197,6 @@ def _tier_hours(name: str, max_age_hours: float) -> float:
return prune_tier_hours(name, max_age_hours)
def _unarchivable_action(why: str) -> str:
"""``keep-submodule-work`` for a submodule a gitlink cannot carry, else ``keep-unarchivable``."""
from hermes_cli.worktree_archive import SUBMODULE_REASON
return "keep-submodule-work" if why.startswith(SUBMODULE_REASON) else "keep-unarchivable"
def _plan_survivor(repo_root: str, entry: Path, verdict: str, lock_state, settings: dict,
tier_hours: float, now: float) -> tuple[str, str, Optional[str], Optional[float]]:
"""(action, reason, lock_state, last_activity) for a dirty/unpushed tree past its tier."""
@@ -216,13 +211,13 @@ def _plan_survivor(repo_root: str, entry: Path, verdict: str, lock_state, settin
return "skip-young", f"{what}; last activity {(now - active) / 3600:.1f}h ago", lock_state, active
if not settings["archive_dirty"]:
return f"keep-{verdict}", what, lock_state, active # idle: only the size budget may evict it
unsafe = archive_preflight(str(entry))
unsafe = archive_preflight(str(entry), settings["archive_max_file_mb"])
if unsafe:
return _unarchivable_action(unsafe), unsafe, lock_state, active
return unsafe[0], unsafe[1], lock_state, active
return "archive-remove", what, lock_state, active
def _apply_budget(decisions: list[Decision], max_total_gb: float) -> None:
def _apply_budget(decisions: list[Decision], max_total_gb: float, max_file_mb: float = 50) -> None:
"""Evict the oldest idle kept trees (archive first) until they fit *max_total_gb*.
Only idle past-tier trees count or move; live, young and kanban trees are invisible here.
@@ -237,9 +232,9 @@ def _apply_budget(decisions: list[Decision], max_total_gb: float) -> None:
for d in sorted(eligible, key=lambda x: x.mtime):
if total <= budget_mb:
break
unsafe = archive_preflight(d.path)
unsafe = archive_preflight(d.path, max_file_mb)
if unsafe:
d.action, d.reason = _unarchivable_action(unsafe), unsafe
d.action, d.reason = unsafe
continue
d.action, d.reason = "archive-remove", f"{d.reason}; over worktrees.max_total_gb={max_total_gb:g}"
total -= d.size_mb or 0
@@ -294,14 +289,14 @@ def plan_sweep(repo_root: str, max_age_hours: int = 24, settings: Optional[dict]
repo_root, entry, verdict, lock_state, settings, tier, now)
if active:
d.age_hours = min(d.age_hours, round((now - active) / 3600, 1))
elif why := ops.submodule_blocks_removal(str(entry)):
d.action, d.reason = "keep-submodule-work", why
elif blocked := ops.removal_blocked(str(entry)):
d.action, d.reason = blocked
else:
d.action = "remove-keep-branch" if verdict == "reap-keep-branch" else "remove"
d.reason = "pushed to origin; branch kept" if verdict == "reap-keep-branch" else "clean and merged/pushed"
decisions.append(d)
if settings["max_total_gb"]:
_apply_budget(decisions, settings["max_total_gb"])
_apply_budget(decisions, settings["max_total_gb"], settings["archive_max_file_mb"])
if older_than_days is not None:
for d in decisions:
if d.action in _DESTRUCTIVE and d.age_hours < older_than_days * 24:
@@ -355,45 +350,49 @@ def _index_moved(path: str, captured: str) -> Optional[str]:
def archive_then_remove(repo_root: str, path: str, *, reason: str, max_untracked_mb: float,
unlock: bool, remote_heads_fn=None, keep_branch: bool = False,
exclude_pids=(),
) -> tuple[str, Optional[str], bool]:
"""Snapshot *path*, then remove it -> ``(archive_ref, kept_branch, removed)``.
) -> tuple[str, Optional[str], bool, Optional[str]]:
"""Snapshot *path*, then remove it -> ``(archive_ref, kept_branch, removed, why_kept)``.
Raises ``ArchiveError`` (tree untouched) when the snapshot fails. Right after the snapshot the
tree is checked again: a process (other than *exclude_pids*) now working in it, any edit
made while the snapshot ran, staged entries that differ from the captured index, or a submodule
that appeared meanwhile keep the tree (``removed`` False; the archive stays). The branch
made while the snapshot ran, staged entries that differ from the captured index, or anything
the archive does not hold (``worktree_retention.removal_blocker``: a file the snapshot skipped
for size, an embedded repository, a per-worktree ref, a non-reproducible ignored file, a
submodule) keep the tree (``removed`` False, *why_kept* says why; the archive stays). The branch
survives when it is protected or published on a remote (an open PR may still need it);
otherwise the archive ref anchors its commits and the local branch is deleted.
"""
from hermes_cli.worktree_archive import snapshot_worktree
from hermes_cli.worktree_ops import _git, _git_quiet, release_lsp_clients, submodule_blocks_removal
from hermes_cli.worktree_ops import _git, _git_quiet, release_lsp_clients
from hermes_cli.worktree_retention import removal_blocker
from hermes_cli._subprocess_compat import noninteractive_git_env
started = time.time()
arc = snapshot_worktree(repo_root, path, reason=reason, max_untracked_mb=max_untracked_mb)
blocked = removal_blocker(path, skipped=arc.meta.get("skipped_untracked"), gitlinks=True)
busy = (_in_use_since(path, started, exclude=exclude_pids) or _index_moved(path, arc.staged)
or submodule_blocks_removal(path, gitlinks=True)) # a submodule set up during capture
or (blocked and blocked[1]))
if busy:
logger.warning("Archived %s to %s but keeping it: %s", path, arc.ref, busy)
return arc.ref, None, False
return arc.ref, None, False, busy
release_lsp_clients(path)
if unlock:
_git_quiet(["worktree", "unlock", path], repo_root, log=f"unlock {path}")
removed = _git(["worktree", "remove", "--force", path], repo_root, timeout=60)
if removed.returncode != 0:
logger.warning("Archived %s to %s but could not remove it: %s", path, arc.ref, removed.stderr.strip())
return arc.ref, None, False
return arc.ref, None, False, removed.stderr.strip() or "git worktree remove failed"
branch = arc.meta.get("branch")
if not branch or keep_branch or branch in _PROTECTED_BRANCHES:
return arc.ref, branch, True
return arc.ref, branch, True, None
if remote_heads_fn is None:
from hermes_cli.worktree_ops import _fetch_remote_branch_heads
heads = _fetch_remote_branch_heads(repo_root, timeout=10)
else:
heads = remote_heads_fn()
if _branch_published(repo_root, branch, heads):
return arc.ref, branch, True
return arc.ref, branch, True, None
_git(["branch", "-D", branch], repo_root, stdin=subprocess.DEVNULL, env=noninteractive_git_env())
return arc.ref, None, True
return arc.ref, None, True, None
def _recheck(repo_root: str, d: Decision) -> tuple[Optional[str], Optional[str]]:
@@ -418,10 +417,10 @@ def _recheck(repo_root: str, d: Decision) -> tuple[Optional[str], Optional[str]]
def execute_plan(repo_root: str, plan: list[Decision], settings: dict
) -> tuple[list[str], set, list[tuple[str, bool]]]:
"""Apply a frozen plan -> ``(preserved_stale, kept_branches, [(archive_ref, removed)])``. Each
tree is re-gated (lock, live process, fresh activity) right before it is archived or removed;
``removed`` is False when the archive was saved but the tree was kept (changed meanwhile)."""
) -> tuple[list[str], set, list[tuple[str, bool, Optional[str]]]]:
"""Apply a frozen plan -> ``(preserved_stale, kept_branches, [(archive_ref, removed, why_kept)])``.
Each tree is re-gated (lock, live process, fresh activity) right before it is archived or
removed; ``removed`` is False when the archive was saved but the tree was kept (*why_kept*)."""
from hermes_cli import worktree_ops as ops
from hermes_cli.worktree_archive import ArchiveError
memo: dict = {}
@@ -432,7 +431,7 @@ def execute_plan(repo_root: str, plan: list[Decision], settings: dict
return memo["heads"]
kept_branches: set = set()
archived: list[tuple[str, bool]] = []
archived: list[tuple[str, bool, Optional[str]]] = []
preserved: list[str] = [f"{d.name} ({d.reason})" for d in plan if d.action.startswith("keep-")]
for d in (d for d in plan if d.action in _DESTRUCTIVE):
why_keep, lock_state = _recheck(repo_root, d)
@@ -445,16 +444,16 @@ def execute_plan(repo_root: str, plan: list[Decision], settings: dict
kept_branches |= kept
continue
try:
ref, kept, removed = archive_then_remove(
ref, kept, removed, why = archive_then_remove(
repo_root, d.path, reason=f"sweep: {d.reason}", max_untracked_mb=settings["archive_max_file_mb"],
unlock=lock_state == "dead", remote_heads_fn=remote_heads)
except (ArchiveError, OSError, subprocess.SubprocessError) as exc:
logger.warning("Keeping worktree %s: archive failed (%s)", d.name, exc)
preserved.append(f"{d.name} (archive failed)")
continue
archived.append((ref, removed))
archived.append((ref, removed, why))
if not removed:
preserved.append(f"{d.name} (archived to {ref}, kept: changed during the sweep)")
preserved.append(f"{d.name} (archived to {ref}, kept: {why})")
if kept:
kept_branches.add(kept)
return preserved, kept_branches, archived
@@ -466,7 +465,7 @@ def run_sweep(repo_root: str, max_age_hours: int, settings: dict) -> None:
from hermes_cli.worktree_archive import prune_expired_archives
plan = plan_sweep(repo_root, max_age_hours, settings)
preserved, kept_branches, archived = execute_plan(repo_root, plan, settings)
removed = [ref for ref, gone in archived if gone]
removed = [ref for ref, gone, _why in archived if gone]
if removed:
logger.warning("Archived and removed %d idle worktree(s) with unmerged work; restore any with "
"`hermes worktree restore <name>` (`hermes worktree list` shows archives).", len(removed))
+3 -2
View File
@@ -3297,6 +3297,7 @@ cli:
next_session_deepens: "Die volgende `hermes -w`-sessie verdiep die kloon en snoei saamgevoegde werkbome outomaties."
unpushed_keeping: "⚠ Werkboom het ongestootte commits, word behou: {path}"
uncommitted_keeping: "⚠ Werkboom het ongecommitte veranderinge, word behou: {path}"
kept_reason: "⚠ Werkboom word behou ({reason}): {path}"
clean_up_manually: "Om met die hand op te ruim: git worktree remove --force {path}"
cleaned_up: "✓ Werkboom opgeruim: {path}"
exit_question_dirty: "Hierdie werkboom het ongecommitte veranderinge: {path}"
@@ -3308,9 +3309,9 @@ cli:
archived: "✓ Werkboom geargiveer na {ref} en verwyder: {path}"
restore_hint: "Herstel dit met: hermes worktree restore {name}"
archive_failed: "✗ Kon nie {path} argiveer nie, word behou: {error}"
archived_kept: "✓ Werkboom geargiveer na {ref} maar behou (dit het verander, is in gebruik, of kon nie verwyder word nie): {path}"
archived_kept: "✓ Werkboom geargiveer na {ref} maar behou ({reason}): {path}"
sweep_archived_removed: "geargiveer + verwyder → {ref}"
sweep_archived_kept: "geargiveer, behou (verander tydens die opruiming) → {ref}"
sweep_archived_kept: "geargiveer, behou ({reason}) → {ref}"
discarded: "Werkboom verwyder en sy veranderinge weggegooi: {path}"
display:
verb:
+3 -2
View File
@@ -3297,6 +3297,7 @@ cli:
next_session_deepens: "جلسة `hermes -w` التالية تعمّق الاستنساخ وتحذف worktrees المدمجة تلقائيًا."
unpushed_keeping: "⚠ يحتوي worktree على إيداعات غير مدفوعة، سيُحتفظ به: {path}"
uncommitted_keeping: "⚠ يحتوي worktree على تغييرات غير مودعة، سيُحتفظ به: {path}"
kept_reason: "⚠ سيُحتفظ بالـ worktree ({reason}): {path}"
clean_up_manually: "للتنظيف يدويًا: git worktree remove --force {path}"
cleaned_up: "✓ تم تنظيف worktree: {path}"
exit_question_dirty: "يحتوي هذا الـ worktree على تغييرات غير مودعة: {path}"
@@ -3308,9 +3309,9 @@ cli:
archived: "✓ تمت أرشفة الـ worktree في {ref} وإزالته: {path}"
restore_hint: "لاستعادته: hermes worktree restore {name}"
archive_failed: "✗ تعذّرت أرشفة {path}، سيُحتفظ به: {error}"
archived_kept: "✓ تمت أرشفة الـ worktree في {ref} لكن احتُفظ به (تغيّر، أو قيد الاستخدام، أو تعذّرت إزالته): {path}"
archived_kept: "✓ تمت أرشفة الـ worktree في {ref} لكن احتُفظ به ({reason}): {path}"
sweep_archived_removed: "أُرشف + أُزيل → {ref}"
sweep_archived_kept: "أُرشف، واحتُفظ به (تغيّر أثناء التنظيف) → {ref}"
sweep_archived_kept: "أُرشف، واحتُفظ به ({reason}) → {ref}"
discarded: "تمت إزالة الـ worktree وتجاهل تغييراته: {path}"
display:
verb:
+3 -2
View File
@@ -3297,6 +3297,7 @@ cli:
next_session_deepens: "Die nächste `hermes -w`-Sitzung vertieft den Klon und entfernt gemergte Worktrees automatisch."
unpushed_keeping: "⚠ Worktree hat nicht gepushte Commits, wird behalten: {path}"
uncommitted_keeping: "⚠ Worktree hat nicht committete Änderungen, wird behalten: {path}"
kept_reason: "⚠ Worktree wird behalten ({reason}): {path}"
clean_up_manually: "Manuell aufräumen mit: git worktree remove --force {path}"
cleaned_up: "✓ Worktree aufgeräumt: {path}"
exit_question_dirty: "Dieser Worktree hat nicht committete Änderungen: {path}"
@@ -3308,9 +3309,9 @@ cli:
archived: "✓ Worktree nach {ref} archiviert und entfernt: {path}"
restore_hint: "Wiederherstellen mit: hermes worktree restore {name}"
archive_failed: "✗ {path} konnte nicht archiviert werden, wird behalten: {error}"
archived_kept: "✓ Worktree nach {ref} archiviert, aber behalten (geändert, in Benutzung oder nicht entfernbar): {path}"
archived_kept: "✓ Worktree nach {ref} archiviert, aber behalten ({reason}): {path}"
sweep_archived_removed: "archiviert + entfernt → {ref}"
sweep_archived_kept: "archiviert, behalten (während der Bereinigung geändert) → {ref}"
sweep_archived_kept: "archiviert, behalten ({reason}) → {ref}"
discarded: "Worktree entfernt und Änderungen verworfen: {path}"
display:
verb:
+3 -2
View File
@@ -3398,6 +3398,7 @@ cli:
next_session_deepens: "The next `hermes -w` session deepens the clone and prunes merged worktrees automatically."
unpushed_keeping: "⚠ Worktree has unpushed commits, keeping: {path}"
uncommitted_keeping: "⚠ Worktree has uncommitted changes, keeping: {path}"
kept_reason: "⚠ Keeping worktree ({reason}): {path}"
clean_up_manually: "To clean up manually: git worktree remove --force {path}"
cleaned_up: "✓ Worktree cleaned up: {path}"
exit_question_dirty: "This worktree has uncommitted changes: {path}"
@@ -3409,9 +3410,9 @@ cli:
archived: "✓ Worktree archived to {ref} and removed: {path}"
restore_hint: "Restore it with: hermes worktree restore {name}"
archive_failed: "✗ Could not archive {path}, keeping it: {error}"
archived_kept: "✓ Worktree archived to {ref} but kept (it changed, is in use, or could not be removed): {path}"
archived_kept: "✓ Worktree archived to {ref} but kept ({reason}): {path}"
sweep_archived_removed: "archived + removed → {ref}"
sweep_archived_kept: "archived, kept (changed during the sweep) → {ref}"
sweep_archived_kept: "archived, kept ({reason}) → {ref}"
discarded: "Worktree removed and its changes discarded: {path}"
display:
+3 -2
View File
@@ -3297,6 +3297,7 @@ cli:
next_session_deepens: "La próxima sesión `hermes -w` profundiza el clon y elimina automáticamente los worktrees fusionados."
unpushed_keeping: "⚠ El worktree tiene commits sin push, se conserva: {path}"
uncommitted_keeping: "⚠ El worktree tiene cambios sin commit, se conserva: {path}"
kept_reason: "⚠ Se conserva el worktree ({reason}): {path}"
clean_up_manually: "Para limpiar manualmente: git worktree remove --force {path}"
cleaned_up: "✓ Worktree limpiado: {path}"
exit_question_dirty: "Este worktree tiene cambios sin commit: {path}"
@@ -3308,9 +3309,9 @@ cli:
archived: "✓ Worktree archivado en {ref} y eliminado: {path}"
restore_hint: "Restáuralo con: hermes worktree restore {name}"
archive_failed: "✗ No se pudo archivar {path}, se conserva: {error}"
archived_kept: "✓ Worktree archivado en {ref} pero conservado (cambió, está en uso o no se pudo eliminar): {path}"
archived_kept: "✓ Worktree archivado en {ref} pero conservado ({reason}): {path}"
sweep_archived_removed: "archivado + eliminado → {ref}"
sweep_archived_kept: "archivado, conservado (cambió durante la limpieza) → {ref}"
sweep_archived_kept: "archivado, conservado ({reason}) → {ref}"
discarded: "Worktree eliminado y cambios descartados: {path}"
display:
verb:
+3 -2
View File
@@ -3614,6 +3614,7 @@ cli:
next_session_deepens: "La prochaine session `hermes -w` approfondira le clone et supprimera automatiquement les worktrees fusionnés."
unpushed_keeping: "⚠ Le worktree a des commits non poussés, conservé : {path}"
uncommitted_keeping: "⚠ Le worktree a des modifications non commitées, conservé : {path}"
kept_reason: "⚠ Worktree conservé ({reason}) : {path}"
clean_up_manually: "Pour nettoyer manuellement : git worktree remove --force {path}"
cleaned_up: "✓ Worktree nettoyé : {path}"
exit_question_dirty: "Ce worktree contient des modifications non commitées : {path}"
@@ -3625,9 +3626,9 @@ cli:
archived: "✓ Worktree archivé dans {ref} et supprimé : {path}"
restore_hint: "Pour le restaurer : hermes worktree restore {name}"
archive_failed: "✗ Impossible d'archiver {path}, conservé : {error}"
archived_kept: "✓ Worktree archivé dans {ref} mais conservé (modifié, en cours d'utilisation ou impossible à supprimer) : {path}"
archived_kept: "✓ Worktree archivé dans {ref} mais conservé ({reason}) : {path}"
sweep_archived_removed: "archivé + supprimé → {ref}"
sweep_archived_kept: "archivé, conservé (modifié pendant le nettoyage) → {ref}"
sweep_archived_kept: "archivé, conservé ({reason}) → {ref}"
discarded: "Worktree supprimé et modifications abandonnées : {path}"
display:
verb:
+3 -2
View File
@@ -3297,6 +3297,7 @@ cli:
next_session_deepens: "Déanann an chéad seisiún `hermes -w` eile an clón a dhoimhniú agus bearrann sé worktrees cumaiscthe go huathoibríoch."
unpushed_keeping: "⚠ Tá tiomantais gan bhrú sa worktree, á choinneáil: {path}"
uncommitted_keeping: "⚠ Tá athruithe gan tiomantas sa worktree, á choinneáil: {path}"
kept_reason: "⚠ Worktree á choinneáil ({reason}): {path}"
clean_up_manually: "Chun glanadh de láimh: git worktree remove --force {path}"
cleaned_up: "✓ Worktree glanta: {path}"
exit_question_dirty: "Tá athruithe gan tiomantas sa worktree seo: {path}"
@@ -3308,9 +3309,9 @@ cli:
archived: "✓ Worktree cartlannaithe go {ref} agus bainte: {path}"
restore_hint: "Athchóirigh é le: hermes worktree restore {name}"
archive_failed: "✗ Níorbh fhéidir {path} a chartlannú, á choinneáil: {error}"
archived_kept: "✓ Worktree cartlannaithe go {ref} ach coinnithe (athraíodh é, tá sé in úsáid, nó níorbh fhéidir é a bhaint): {path}"
archived_kept: "✓ Worktree cartlannaithe go {ref} ach coinnithe ({reason}): {path}"
sweep_archived_removed: "cartlannaithe + bainte → {ref}"
sweep_archived_kept: "cartlannaithe, coinnithe (athraithe le linn an scuabtha) → {ref}"
sweep_archived_kept: "cartlannaithe, coinnithe ({reason}) → {ref}"
discarded: "Worktree bainte agus a athruithe caite uait: {path}"
display:
verb:
+3 -2
View File
@@ -3314,6 +3314,7 @@ cli:
next_session_deepens: "A következő `hermes -w` munkamenet mélyíti a klónt, és automatikusan eltávolítja a beolvasztott worktree-ket."
unpushed_keeping: "⚠ A worktree-ben feltöltetlen commitok vannak, megtartva: {path}"
uncommitted_keeping: "⚠ A worktree-ben nem commitolt változások vannak, megtartva: {path}"
kept_reason: "⚠ Worktree megtartva ({reason}): {path}"
clean_up_manually: "Kézi takarításhoz: git worktree remove --force {path}"
cleaned_up: "✓ Worktree eltakarítva: {path}"
exit_question_dirty: "Ebben a worktree-ben nem commitolt változások vannak: {path}"
@@ -3325,9 +3326,9 @@ cli:
archived: "✓ Worktree archiválva ide: {ref}, és eltávolítva: {path}"
restore_hint: "Visszaállítás: hermes worktree restore {name}"
archive_failed: "✗ {path} archiválása sikertelen, megtartva: {error}"
archived_kept: "✓ Worktree archiválva ide: {ref}, de megtartva (változott, használatban van, vagy nem távolítható el): {path}"
archived_kept: "✓ Worktree archiválva ide: {ref}, de megtartva ({reason}): {path}"
sweep_archived_removed: "archiválva + eltávolítva → {ref}"
sweep_archived_kept: "archiválva, megtartva (a takarítás közben változott) → {ref}"
sweep_archived_kept: "archiválva, megtartva ({reason}) → {ref}"
discarded: "Worktree eltávolítva, változásai elvetve: {path}"
display:
verb:
+3 -2
View File
@@ -3297,6 +3297,7 @@ cli:
next_session_deepens: "La prossima sessione `hermes -w` approfondisce il clone ed elimina automaticamente i worktree già uniti."
unpushed_keeping: "⚠ Il worktree ha commit non inviati, mantengo: {path}"
uncommitted_keeping: "⚠ Il worktree ha modifiche non committate, mantengo: {path}"
kept_reason: "⚠ Worktree mantenuto ({reason}): {path}"
clean_up_manually: "Per pulire manualmente: git worktree remove --force {path}"
cleaned_up: "✓ Worktree pulito: {path}"
exit_question_dirty: "Questo worktree ha modifiche non committate: {path}"
@@ -3308,9 +3309,9 @@ cli:
archived: "✓ Worktree archiviato in {ref} e rimosso: {path}"
restore_hint: "Ripristinalo con: hermes worktree restore {name}"
archive_failed: "✗ Impossibile archiviare {path}, lo mantengo: {error}"
archived_kept: "✓ Worktree archiviato in {ref} ma mantenuto (modificato, in uso o non rimovibile): {path}"
archived_kept: "✓ Worktree archiviato in {ref} ma mantenuto ({reason}): {path}"
sweep_archived_removed: "archiviato + rimosso → {ref}"
sweep_archived_kept: "archiviato, mantenuto (modificato durante la pulizia) → {ref}"
sweep_archived_kept: "archiviato, mantenuto ({reason}) → {ref}"
discarded: "Worktree rimosso e modifiche scartate: {path}"
display:
verb:
+3 -2
View File
@@ -3297,6 +3297,7 @@ cli:
next_session_deepens: "次回の `hermes -w` セッションでクローンを深化し、マージ済みのワークツリーを自動的に削除します。"
unpushed_keeping: "⚠ ワークツリーに未プッシュのコミットがあるため保持します: {path}"
uncommitted_keeping: "⚠ ワークツリーに未コミットの変更があるため保持します: {path}"
kept_reason: "⚠ ワークツリーを保持します ({reason}): {path}"
clean_up_manually: "手動でクリーンアップするには: git worktree remove --force {path}"
cleaned_up: "✓ ワークツリーをクリーンアップしました: {path}"
exit_question_dirty: "このワークツリーには未コミットの変更があります: {path}"
@@ -3308,9 +3309,9 @@ cli:
archived: "✓ ワークツリーを {ref} にアーカイブして削除しました: {path}"
restore_hint: "復元するには: hermes worktree restore {name}"
archive_failed: "✗ {path} をアーカイブできなかったため保持します: {error}"
archived_kept: "✓ ワークツリーを {ref} にアーカイブしましたが保持しました (変更された、使用中、または削除できませんでした): {path}"
archived_kept: "✓ ワークツリーを {ref} にアーカイブしましたが保持しました ({reason}): {path}"
sweep_archived_removed: "アーカイブして削除 → {ref}"
sweep_archived_kept: "アーカイブ済み、保持 (掃除中に変更されました) → {ref}"
sweep_archived_kept: "アーカイブ済み、保持 ({reason}) → {ref}"
discarded: "ワークツリーを削除し、変更を破棄しました: {path}"
display:
verb:
+3 -2
View File
@@ -3297,6 +3297,7 @@ cli:
next_session_deepens: "다음 `hermes -w` 세션에서 클론을 깊게 만들고 병합된 워크트리를 자동으로 정리해요."
unpushed_keeping: "⚠ 워크트리에 푸시되지 않은 커밋이 있어 유지해요: {path}"
uncommitted_keeping: "⚠ 워크트리에 커밋되지 않은 변경 사항이 있어 유지해요: {path}"
kept_reason: "⚠ 워크트리를 유지해요 ({reason}): {path}"
clean_up_manually: "직접 정리하려면: git worktree remove --force {path}"
cleaned_up: "✓ 워크트리를 정리했어요: {path}"
exit_question_dirty: "이 워크트리에 커밋되지 않은 변경 사항이 있어요: {path}"
@@ -3308,9 +3309,9 @@ cli:
archived: "✓ 워크트리를 {ref}에 보관하고 제거했어요: {path}"
restore_hint: "복원하려면: hermes worktree restore {name}"
archive_failed: "✗ {path}을(를) 보관하지 못해 유지해요: {error}"
archived_kept: "✓ 워크트리를 {ref}에 보관했지만 유지했어요 (변경됐거나, 사용 중이거나, 제거할 수 없음): {path}"
archived_kept: "✓ 워크트리를 {ref}에 보관했지만 유지했어요 ({reason}): {path}"
sweep_archived_removed: "보관 후 제거 → {ref}"
sweep_archived_kept: "보관됨, 유지 (정리 중 변경됨) → {ref}"
sweep_archived_kept: "보관됨, 유지 ({reason}) → {ref}"
discarded: "워크트리를 제거하고 변경 사항을 버렸어요: {path}"
display:
verb:
+3 -2
View File
@@ -3297,6 +3297,7 @@ cli:
next_session_deepens: "A próxima sessão `hermes -w` aprofunda o clone e remove worktrees já mescladas automaticamente."
unpushed_keeping: "⚠ A worktree tem commits não enviados (push); mantendo: {path}"
uncommitted_keeping: "⚠ A worktree tem alterações não commitadas; mantendo: {path}"
kept_reason: "⚠ Mantendo a worktree ({reason}): {path}"
clean_up_manually: "Para limpar manualmente: git worktree remove --force {path}"
cleaned_up: "✓ Worktree limpa: {path}"
exit_question_dirty: "Esta worktree tem alterações não commitadas: {path}"
@@ -3308,9 +3309,9 @@ cli:
archived: "✓ Worktree arquivada em {ref} e removida: {path}"
restore_hint: "Restaure com: hermes worktree restore {name}"
archive_failed: "✗ Não foi possível arquivar {path}; mantendo: {error}"
archived_kept: "✓ Worktree arquivada em {ref}, mas mantida (foi alterada, está em uso ou não pôde ser removida): {path}"
archived_kept: "✓ Worktree arquivada em {ref}, mas mantida ({reason}): {path}"
sweep_archived_removed: "arquivada + removida → {ref}"
sweep_archived_kept: "arquivada, mantida (alterada durante a limpeza) → {ref}"
sweep_archived_kept: "arquivada, mantida ({reason}) → {ref}"
discarded: "Worktree removida e alterações descartadas: {path}"
display:
verb:
+3 -2
View File
@@ -3297,6 +3297,7 @@ cli:
next_session_deepens: "Следующий сеанс `hermes -w` углубит клон и автоматически удалит слитые worktree."
unpushed_keeping: "⚠ В worktree есть неотправленные коммиты, оставляю: {path}"
uncommitted_keeping: "⚠ В worktree есть незакоммиченные изменения, оставляю: {path}"
kept_reason: "⚠ Worktree сохранён ({reason}): {path}"
clean_up_manually: "Чтобы очистить вручную: git worktree remove --force {path}"
cleaned_up: "✓ Worktree очищен: {path}"
exit_question_dirty: "В этом worktree есть незакоммиченные изменения: {path}"
@@ -3308,9 +3309,9 @@ cli:
archived: "✓ Worktree заархивирован в {ref} и удалён: {path}"
restore_hint: "Восстановить: hermes worktree restore {name}"
archive_failed: "✗ Не удалось заархивировать {path}, оставляю: {error}"
archived_kept: "✓ Worktree заархивирован в {ref}, но сохранён (изменился, используется или не удалось удалить): {path}"
archived_kept: "✓ Worktree заархивирован в {ref}, но сохранён ({reason}): {path}"
sweep_archived_removed: "заархивирован + удалён → {ref}"
sweep_archived_kept: "заархивирован, сохранён (изменился во время очистки) → {ref}"
sweep_archived_kept: "заархивирован, сохранён ({reason}) → {ref}"
discarded: "Worktree удалён, изменения отброшены: {path}"
display:
verb:
+3 -2
View File
@@ -3297,6 +3297,7 @@ cli:
next_session_deepens: "Bir sonraki `hermes -w` oturumu klonu derinleştirir ve birleştirilmiş worktree'leri otomatik olarak temizler."
unpushed_keeping: "⚠ Worktree'de push edilmemiş commit'ler var, korunuyor: {path}"
uncommitted_keeping: "⚠ Worktree'de commit edilmemiş değişiklikler var, korunuyor: {path}"
kept_reason: "⚠ Worktree tutuluyor ({reason}): {path}"
clean_up_manually: "Elle temizlemek için: git worktree remove --force {path}"
cleaned_up: "✓ Worktree temizlendi: {path}"
exit_question_dirty: "Bu worktree'de commit edilmemiş değişiklikler var: {path}"
@@ -3308,9 +3309,9 @@ cli:
archived: "✓ Worktree {ref} konumuna arşivlendi ve kaldırıldı: {path}"
restore_hint: "Geri yüklemek için: hermes worktree restore {name}"
archive_failed: "✗ {path} arşivlenemedi, korunuyor: {error}"
archived_kept: "✓ Worktree {ref} konumuna arşivlendi ama tutuldu (değişti, kullanımda veya kaldırılamadı): {path}"
archived_kept: "✓ Worktree {ref} konumuna arşivlendi ama tutuldu ({reason}): {path}"
sweep_archived_removed: "arşivlendi + kaldırıldı → {ref}"
sweep_archived_kept: "arşivlendi, tutuldu (temizlik sırasında değişti) → {ref}"
sweep_archived_kept: "arşivlendi, tutuldu ({reason}) → {ref}"
discarded: "Worktree kaldırıldı ve değişiklikleri atıldı: {path}"
display:
verb:
+3 -2
View File
@@ -3297,6 +3297,7 @@ cli:
next_session_deepens: "Наступний сеанс `hermes -w` поглибить клон і автоматично видалить злиті робочі дерева."
unpushed_keeping: "⚠ У робочому дереві є невідправлені коміти, залишаємо: {path}"
uncommitted_keeping: "⚠ У робочому дереві є незафіксовані зміни, залишаємо: {path}"
kept_reason: "⚠ Робоче дерево збережено ({reason}): {path}"
clean_up_manually: "Щоб очистити вручну: git worktree remove --force {path}"
cleaned_up: "✓ Робоче дерево очищено: {path}"
exit_question_dirty: "У цьому робочому дереві є незафіксовані зміни: {path}"
@@ -3308,9 +3309,9 @@ cli:
archived: "✓ Робоче дерево заархівовано в {ref} і видалено: {path}"
restore_hint: "Відновити: hermes worktree restore {name}"
archive_failed: "✗ Не вдалося заархівувати {path}, залишаємо: {error}"
archived_kept: "✓ Робоче дерево заархівовано в {ref}, але збережено (змінилося, використовується або не вдалося видалити): {path}"
archived_kept: "✓ Робоче дерево заархівовано в {ref}, але збережено ({reason}): {path}"
sweep_archived_removed: "заархівовано + видалено → {ref}"
sweep_archived_kept: "заархівовано, збережено (змінилося під час очищення) → {ref}"
sweep_archived_kept: "заархівовано, збережено ({reason}) → {ref}"
discarded: "Робоче дерево видалено, зміни відкинуто: {path}"
display:
verb:
+3 -2
View File
@@ -3297,6 +3297,7 @@ cli:
next_session_deepens: "下一次 `hermes -w` 工作階段會加深複製並自動清理已合併的工作樹。"
unpushed_keeping: "⚠ 工作樹有未推送的提交,保留:{path}"
uncommitted_keeping: "⚠ 工作樹有未提交的變更,保留:{path}"
kept_reason: "⚠ 保留工作樹({reason}):{path}"
clean_up_manually: "手動清理方式:git worktree remove --force {path}"
cleaned_up: "✓ 工作樹已清理:{path}"
exit_question_dirty: "此工作樹有未提交的變更:{path}"
@@ -3308,9 +3309,9 @@ cli:
archived: "✓ 工作樹已封存至 {ref} 並移除:{path}"
restore_hint: "還原方式:hermes worktree restore {name}"
archive_failed: "✗ 無法封存 {path},保留:{error}"
archived_kept: "✓ 工作樹已封存至 {ref},但已保留(有變更、使用中或無法移除):{path}"
archived_kept: "✓ 工作樹已封存至 {ref},但已保留({reason}):{path}"
sweep_archived_removed: "已封存並移除 → {ref}"
sweep_archived_kept: "已封存,保留(清理期間有變更)→ {ref}"
sweep_archived_kept: "已封存,保留({reason})→ {ref}"
discarded: "工作樹已移除,變更已捨棄:{path}"
display:
verb:
+3 -2
View File
@@ -3297,6 +3297,7 @@ cli:
next_session_deepens: "下一次 `hermes -w` 会话会自动加深克隆并清理已合并的工作树。"
unpushed_keeping: "⚠ 工作树有未推送的提交,保留:{path}"
uncommitted_keeping: "⚠ 工作树有未提交的更改,保留:{path}"
kept_reason: "⚠ 保留工作树({reason}):{path}"
clean_up_manually: "手动清理:git worktree remove --force {path}"
cleaned_up: "✓ 工作树已清理:{path}"
exit_question_dirty: "此工作树有未提交的更改:{path}"
@@ -3308,9 +3309,9 @@ cli:
archived: "✓ 工作树已归档到 {ref} 并移除:{path}"
restore_hint: "恢复方式:hermes worktree restore {name}"
archive_failed: "✗ 无法归档 {path},保留:{error}"
archived_kept: "✓ 工作树已归档到 {ref},但已保留(有变更、使用中或无法移除):{path}"
archived_kept: "✓ 工作树已归档到 {ref},但已保留({reason}):{path}"
sweep_archived_removed: "已归档并移除 → {ref}"
sweep_archived_kept: "已归档,保留(清理期间有变更)→ {ref}"
sweep_archived_kept: "已归档,保留({reason})→ {ref}"
discarded: "工作树已移除,更改已丢弃:{path}"
display:
verb:
@@ -8,6 +8,7 @@ visits the repos the ACTIVE profile registered (I7); a non-interactive exit neve
import hashlib
import os
import shutil
import subprocess
import sys
import time
@@ -202,8 +203,8 @@ def test_a_tree_resumed_mid_sweep_is_never_removed(repo, monkeypatch, capsys):
assert second.exists() and (second / "notes.md").read_text(encoding="utf-8-sig") == "uncommitted\n"
assert staged.exists() and _git(["show", ":b.py"], staged) == "NEW STAGED CONTENT"
assert sorted(ref.rsplit("/", 1)[-1] for ref in _archives(repo)) == ["lane-a", "lane-staged"]
assert sorted(actions) == ["archived, kept (changed during the sweep) → refs/hermes/archive/lane-a",
"archived, kept (changed during the sweep) → refs/hermes/archive/lane-staged"]
assert sorted(actions) == ["archived, kept (changed 0s ago) → refs/hermes/archive/lane-a",
"archived, kept (staged changes since the snapshot) → refs/hermes/archive/lane-staged"]
def test_dirty_submodule_work_is_never_archived_away(repo, tmp_path, monkeypatch):
@@ -266,6 +267,113 @@ def test_dirty_submodule_work_is_never_archived_away(repo, tmp_path, monkeypatch
snapshot_worktree(str(repo), tree, reason="session exit")
def _big(path, mb=2):
with open(path, "wb") as f:
f.truncate(mb * 1024 * 1024)
f.seek(0)
f.write(b"UNIQUE BYTES")
def _private_commit(repo_dir):
_git(["init", "-q", "-b", "main", str(repo_dir)], repo_dir.parent)
(repo_dir / "x").write_text("PRIVATE\n", encoding="utf-8")
_git(["add", "-A"], repo_dir)
_git(["commit", "-qm", "private"], repo_dir)
return _git(["rev-parse", "HEAD"], repo_dir)
def _case_large(repo, wt):
(wt / "notes.md").write_text("dirty\n", encoding="utf-8")
_big(wt / "dataset.bin")
return {"dataset.bin": None}, None
def _case_ignored_repo(repo, wt):
(wt / "notes.md").write_text("dirty\n", encoding="utf-8")
return {}, (wt / "build" / "tool", _private_commit(_mkdirs(wt / "build" / "tool")))
def _case_ignored_files(repo, wt):
(wt / "notes.md").write_text("dirty\n", encoding="utf-8")
(wt / "prod.secret").write_text("API KEY WORK\n", encoding="utf-8")
(wt / ".env").write_text("LOCAL ONLY=1\n", encoding="utf-8") # differs from the main checkout
return {"prod.secret": b"API KEY WORK\n", ".env": b"LOCAL ONLY=1\n"}, None
def _case_untracked_hidden(repo, wt):
_git(["config", "status.showUntrackedFiles", "no"], repo)
(wt / "new_work.py").write_text("UNTRACKED WORK\n", encoding="utf-8")
return {"new_work.py": b"UNTRACKED WORK\n"}, None
def _case_skip_worktree(repo, wt):
_git(["update-index", "--skip-worktree", "b.py"], wt)
_git(["update-index", "--assume-unchanged", "a.txt"], wt)
(wt / "b.py").write_bytes(b"SKIP-WORKTREE EDIT\n")
return {"b.py": b"SKIP-WORKTREE EDIT\n"}, None
def _case_worktree_ref(repo, wt):
(wt / "b.py").write_text("private\n", encoding="utf-8")
_git(["commit", "-qam", "private"], wt)
sha = _git(["rev-parse", "HEAD"], wt)
_git(["update-ref", "refs/worktree/keep", sha], wt)
_git(["update-ref", "refs/bisect/bad", sha], wt)
_git(["reset", "-q", "--hard", "origin/main"], wt)
return {}, (repo, sha)
def _mkdirs(path):
path.mkdir(parents=True)
return path
@pytest.mark.parametrize("mode", ["archive", "classic"])
@pytest.mark.parametrize("setup", [_case_large, _case_ignored_repo, _case_ignored_files, _case_untracked_hidden,
_case_skip_worktree, _case_worktree_ref], ids=lambda f: f.__name__[6:])
def test_the_pruner_removes_a_tree_only_when_everything_irreproducible_is_archived(repo, mode, setup):
"""R2: through the real pruner, every byte and commit that git cannot regenerate survives,
in the tree or in its archive: an untracked file over the size cap, a gitignored embedded
repository, ignored files that are not reproducible, untracked files hidden by
``status.showUntrackedFiles=no``, skip-worktree / assume-unchanged edits, and commits held
only by per-worktree refs. Regenerable ignored content (``node_modules``, ``__pycache__``) and
``.worktreeinclude`` copies identical to the main checkout never hold a tree back."""
if mode == "classic":
_set_worktrees_config("worktrees:\n archive_dirty: false\n")
else:
_set_worktrees_config("worktrees:\n archive_max_file_mb: 1\n")
(repo / ".git" / "info").mkdir(exist_ok=True)
(repo / ".git" / "info" / "exclude").write_text("build/\n*.secret\n.env\n__pycache__/\n", encoding="utf-8")
(repo / ".env").write_text("SHARED=1\n", encoding="utf-8")
wt, regen = _tree(repo, "lane-case"), _tree(repo, "lane-regen", dirty=True)
for tree in (wt, regen):
shutil.copy2(repo / ".env", tree / ".env") # what .worktreeinclude copies
(_mkdirs(tree / "node_modules" / "dep") / "index.js").write_text("x\n", encoding="utf-8")
(_mkdirs(tree / "pkg" / "__pycache__") / "m.pyc").write_bytes(b"\0")
precious, commit = setup(repo, wt)
sizes = {rel: (wt / rel).read_bytes() for rel, data in precious.items() if data is None}
precious = {**precious, **sizes}
for tree in (wt, regen):
_idle(tree)
worktree_ops._prune_stale_worktrees(str(repo))
archives = _archives(repo)
for rel, data in precious.items():
in_tree = (wt / rel).is_file() and (wt / rel).read_bytes() == data
in_archive = any(subprocess.run(["git", "cat-file", "blob", f"{ref}:{rel}"], cwd=repo, capture_output=True,
check=False)
.stdout == data for ref in archives if ref.endswith("/lane-case"))
assert in_tree or in_archive, f"{rel} lost"
if commit:
where, sha = commit
assert where.exists() and _git(["cat-file", "-t", sha], where) == "commit"
if where == repo:
assert wt.exists()
if mode == "archive":
assert not regen.exists(), "regenerable / main-identical ignored files must not hold a tree back"
def test_prune_older_than_bounds_what_the_sweep_removes(repo, capsys):
"""`hermes worktree prune --older-than N` never archives or removes a tree idle < N days."""
import argparse
+16 -6
View File
@@ -164,7 +164,7 @@ def finalize_subagent_worktree(info: dict[str, str], *, prune: bool = True) -> d
failed, unmeasured = [], []
probes = (("commits", "rev-list", ["rev-list", "--count", f"{base_commit}..HEAD"],
lambda s: int(s or 0)),
("dirty", "status", ["status", "--porcelain"], bool))
("dirty", "status", ["status", "--porcelain", "--untracked-files=normal"], bool))
try:
for field, label, args, parse in probes:
res = _run_git(args, cwd=path, env=probe_env)
@@ -180,13 +180,23 @@ def finalize_subagent_worktree(info: dict[str, str], *, prune: bool = True) -> d
if failed:
# Destructive cleanup requires affirmative proof; defaults prove nothing.
return mark_worktree_payload_unproven(payload, "; ".join(failed), unmeasured="/".join(unmeasured))
if not payload["dirty"]:
# status trusts skip-worktree / assume-unchanged: edits behind them are invisible to it.
from hermes_cli.worktree_archive import ArchiveError
from hermes_cli.worktree_retention import hidden_index_edits
try:
payload["dirty"] = bool(hidden_index_edits(path))
except (ArchiveError, OSError, subprocess.SubprocessError) as exc:
return mark_worktree_payload_unproven(payload, f"index flag probe failed: {exc}", unmeasured="dirty")
if prune and (payload["commits"] or payload["dirty"]):
return _archive_finished_worktree(info, payload)
if prune and payload["commits"] == 0 and not payload["dirty"]:
cwd = info.get("repo_root", "") or path
try:
from hermes_cli.worktree_ops import release_lsp_clients, submodule_blocks_removal
if submodule_blocks_removal(path): # a module repository dies with the tree
from hermes_cli.worktree_ops import release_lsp_clients, removal_blocked
blocked = removal_blocked(path) # an embedded repo / ignored file / ref dies with the tree
if blocked:
payload["note"] = f"Worktree kept at {path}: {blocked[1]}."
return payload
release_lsp_clients(path) # the child ran in-process: its language servers are ours
removed = _run_git(["worktree", "remove", "--force", path], cwd=cwd)
@@ -218,7 +228,7 @@ def _archive_finished_worktree(info: dict[str, str], payload: dict[str, Any]) ->
logger.info("subagent worktree: a live process works in %s; keeping it", path)
return payload
try:
ref, _kept, removed = archive_then_remove(
ref, _kept, removed, why = archive_then_remove(
info.get("repo_root", "") or path, path, reason="subagent finished",
max_untracked_mb=settings["archive_max_file_mb"], unlock=False, keep_branch=True,
exclude_pids=(os.getpid(),))
@@ -232,8 +242,8 @@ def _archive_finished_worktree(info: dict[str, str], payload: dict[str, Any]) ->
f"Restore the full tree (including uncommitted edits) with "
f"`hermes worktree restore {ref.rsplit('/', 1)[-1]}`.")
else:
payload["note"] = (f"Worktree archived to {ref} but kept at {path} (it changed after the snapshot "
f"or could not be removed); branch {info.get('branch')} kept.")
payload["note"] = (f"Worktree archived to {ref} but kept at {path} ({why}); "
f"branch {info.get('branch')} kept.")
return payload
+7
View File
@@ -121,6 +121,13 @@ Safety guarantees (all modes, any age):
- **Untracked-only scratch** (PR body drafts, notes) is archived to
`~/.hermes/archive/worktree-prune/` before its tree is removed — never
destroyed.
- **Nothing irreproducible is deleted unarchived**: a tree is kept when it holds
an embedded repository (even a gitignored one), a commit only a per-worktree
ref (`refs/worktree/*`, `refs/bisect/*`) reaches, an untracked file over the
archive size cap, or an ignored file that is neither under a build/dependency
directory (`node_modules`, `.venv`, `__pycache__`, `dist`, `build`, …) nor a
byte-identical copy of the main checkout's file (`.worktreeinclude`'s `.env`).
See [ignored files](./git-worktrees.md#stale-worktree-sweep-and-restore).
- Branch deletion is content-gated, not name-gated: any local branch whose
commits are all on upstream is safe to delete; branches with unique work,
checked-out branches, and `main`/`master`/`develop` are always kept.
+27 -2
View File
@@ -214,6 +214,30 @@ repositories' `.worktrees/`:
| File, `HEAD` or reflog activity younger than its tier (a deleted file counts through its folder) | never touched |
| Kanban task trees (`t_<hex>`) | never touched (the Kanban GC owns them) |
| Any submodule: a gitlink in the index, or a submodule repository kept under the tree's `modules/` (even after `git submodule deinit`) | kept (`keep-submodule-work`: an archive records only the submodule pointer). A clean merged tree is removed only if its submodules were never initialized |
| An embedded repository anywhere inside, gitignored or not (e.g. `build/tool/.git`) | kept (`keep-unarchivable`) |
| A commit held only by a per-worktree ref (`refs/worktree/*`, `refs/bisect/*`, `refs/rewritten/*`) | kept (`keep-worktree-refs`: those refs are deleted with the tree) |
| An untracked file larger than `worktrees.archive_max_file_mb` | kept (`keep-large-untracked`) |
| An ignored file that is not reproducible (see below) | kept (`keep-ignored-work`; `--dry-run` names the files) |
**Ignored files.** An archive does not capture files your ignore rules
(`.gitignore`, `.git/info/exclude`, `core.excludesFile`) exclude, so a tree is
removed only when every ignored file in it can be regenerated:
- it lies under a build or dependency directory: `node_modules`, `.venv`,
`venv`, `__pycache__`, `.pytest_cache`, `.mypy_cache`, `.ruff_cache`, `.tox`,
`dist`, `build`, `target`, `.next`, `.turbo`, `.cache`, `coverage`, `.gradle`,
`*.egg-info`; or
- it is a byte-identical copy of the same path in the main checkout (what
`.worktreeinclude` copies, such as `.env`).
Any other ignored file (a local `.env` edit, `*.secret`, notes excluded in
`info/exclude`, test output) keeps the tree, on every path: the sweep, exit
**Archive** and subagent cleanup. Commit, move or delete the file and the next
sweep reclaims the tree.
The same checks run on clean merged trees, and every dirty check counts files
that `status.showUntrackedFiles=no` would hide and skip-worktree /
assume-unchanged files whose bytes differ from the index.
An archive is a commit stored at `refs/hermes/archive/<tree>` holding the
tree's `HEAD`, staged changes, unstaged changes and untracked files (files your
@@ -224,7 +248,8 @@ staged changes move (`git add`, `git apply --cached`) while the archive is
being built, the archive is saved and the tree is kept too. File bytes are stored and
restored exactly: no `.gitattributes`, `info/attributes`, `core.attributesFile`
line-ending or filter rule is applied. Untracked files larger than
`worktrees.archive_max_file_mb` are left out and listed in the archive. A
`worktrees.archive_max_file_mb` are left out and listed in the archive, and the
tree is then kept: exit **Archive** and the subagent result say so. A
branch that exists on a remote (an open PR) is kept; otherwise the archive
holds its commits and the local branch is deleted.
@@ -243,7 +268,7 @@ sweep.
```yaml
worktrees:
archive_dirty: true # false = never remove dirty/unpushed trees (the old behaviour)
archive_max_file_mb: 50 # untracked files above this are listed, not archived
archive_max_file_mb: 50 # untracked files above this are not archived; their tree is kept
archive_retention_days: 30 # 0 keeps archives forever
max_total_gb: 0 # optional budget for idle trees; oldest are archived + removed first
```