mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
d413d8b1b7
commit
ab1c29d8bb
31 files changed
+549
-112
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Reference in new issue
Block a user