feat(gc): add pkgmgr gc --report, a read-only disk inventory

pkgmgr gc --report lists each linked worktree and orphaned .claude/worktrees/agent-* dir with its size and last activity, the changes a cleanup would keep, the biggest children of every --root, ELF core dumps, docker system df with --docker, and free versus attributed space per filesystem; --json prints the same as JSON. It deletes and writes nothing and refuses --apply. git run() gains input_text for cat-file --batch-check.

🎯 Fix: 90 %
💥 Break: 2 %
💸 Cost: new
⏱️ Time: new
🛡️ Security: 100 %
🏆 Score: 88
🚦 Gate: cost new, time new: cleared by the operator's commit word for pkgmgr gc --report (2026-11-07)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-07 11:42:26 +00:00
1 parent 1b9b93a542
commit c78e0a34a3
26 files changed
+1699 -2

No files matched your search

+1
View File
@@ -79,6 +79,7 @@ versioning features it can drive **end-to-end workflows**:
| `pkgmgr branch cleanup [--apply]` | Removes merged branches that no worktree holds, locally and on the remotes |
| `pkgmgr branch tree [--format json\|yaml] [--output FILE]` | Writes every managed repository's branches and worktrees with their cleanup state, without fetching |
| `pkgmgr gc [--all] [--apply]` | Both cleanups in one pass, for the current repository or every managed one |
| `pkgmgr gc --report [--all] [--root DIR] [--threshold-mb MB] [--docker] [--json]` | Read-only disk inventory: worktree sizes, changes a cleanup would keep, `--root` children, core dumps, docker usage, free space per filesystem |
| `pkgmgr worktree orphans --stale-days N --top N` | Lists merged and abandoned branches and worktrees with a proposal each |
A hook runs only when the `worktree_hooks` config pins its command under the
View File
Whitespace-only changes.
+4
View File
@@ -0,0 +1,4 @@
from .render import render_text
from .report import disk_report
__all__ = ["disk_report", "render_text"]
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import os
from pkgmgr.actions.branch.gc import _inside
def _mount(path: str) -> str:
path = os.path.realpath(path)
while not os.path.ismount(path):
path = os.path.dirname(path)
return path
def balance(paths: list[str], items: list[tuple[str, int]]) -> list[dict]:
"""
Set what the report attributes against each filesystem's capacity.
Args:
paths: the roots and repository roots; each one's filesystem gets a row.
items: (path, allocated bytes) of every listed entry; an entry inside
another listed entry is counted only through the outer one.
Returns:
Per filesystem (grouped by st_dev): its mount point, total and free
bytes (as available to unprivileged users) and the attributed bytes.
"""
filesystems: dict[int, dict] = {}
for path in paths:
try:
dev = os.stat(path).st_dev
if dev in filesystems:
continue
vfs = os.statvfs(path)
except OSError:
continue
filesystems[dev] = {
"filesystem": _mount(path),
"total_bytes": vfs.f_blocks * vfs.f_frsize,
"free_bytes": vfs.f_bavail * vfs.f_frsize,
"attributed_bytes": 0,
}
kept: list[str] = []
for path, size in sorted((os.path.realpath(p), s) for p, s in items):
if any(_inside(path, outer) for outer in kept):
continue
kept.append(path)
try:
dev = os.lstat(path).st_dev
except OSError:
continue
if dev in filesystems:
filesystems[dev]["attributed_bytes"] += size
return sorted(filesystems.values(), key=lambda fs: fs["filesystem"])
@@ -0,0 +1,58 @@
from __future__ import annotations
import os
from .walk import BLOCK
ELF_MAGIC = b"\x7fELF"
ELF_HEADER = 18
ET_CORE = 4
BYTE_ORDER = {1: "little", 2: "big"}
def is_core_dump(path: str) -> bool:
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except OSError:
return False
try:
head = os.read(fd, ELF_HEADER)
except OSError:
return False
finally:
os.close(fd)
order = BYTE_ORDER.get(head[5]) if len(head) == ELF_HEADER else None
return (
head[:4] == ELF_MAGIC
and order is not None
and int.from_bytes(head[16:18], order) == ET_CORE
)
def core_dumps(candidates: list[str]) -> list[dict]:
"""
Keep the candidates whose ELF header says ET_CORE.
Args:
candidates: paths of regular files named like core dumps.
Returns:
One entry per dump with its allocated and apparent size and mtime,
biggest first.
"""
dumps: dict[str, dict] = {}
for path in candidates:
real = os.path.realpath(path)
if real in dumps or not is_core_dump(path):
continue
try:
st = os.lstat(path)
except OSError:
continue
dumps[real] = {
"path": path,
"size_bytes": st.st_blocks * BLOCK,
"apparent_bytes": st.st_size,
"mtime": st.st_mtime,
}
return sorted(dumps.values(), key=lambda d: (-d["size_bytes"], d["path"]))
@@ -0,0 +1,50 @@
from __future__ import annotations
import json
import subprocess
from pkgmgr.actions.branch.gc import _describe, _docker
DF_ARGS = ["system", "df", "--format", "{{json .}}"]
def _count(value: object) -> int | None:
text = str(value)
return int(text) if text.isdigit() else None
def docker_usage() -> tuple[list[dict] | None, str | None]:
"""
Read `docker system df` per type.
Returns:
The rows (type, total and active counts, size and reclaimable as docker
prints them) and None, or None and a warning when docker is missing,
fails or prints something else than JSON lines.
"""
try:
out = _docker(DF_ARGS)
except FileNotFoundError:
return None, "docker unavailable: not installed"
except subprocess.CalledProcessError as exc:
return None, f"docker unavailable: {_describe(exc)}"
rows: list[dict] = []
for line in out.splitlines():
if not line.strip():
continue
try:
data = json.loads(line)
except ValueError:
data = None
if not isinstance(data, dict):
return None, "docker system df: output is not JSON lines"
rows.append(
{
"type": data.get("Type"),
"total": _count(data.get("TotalCount")),
"active": _count(data.get("Active")),
"size": data.get("Size"),
"reclaimable": data.get("Reclaimable"),
}
)
return rows, None
+186
View File
@@ -0,0 +1,186 @@
from __future__ import annotations
import os
import stat
from pkgmgr.actions.branch.gc import _describe, _git, _has_submodules
from pkgmgr.core.git.errors import GitBaseError
from pkgmgr.core.git.queries import FILTER_CONFIG, local_config
from pkgmgr.core.git.run import run
REF_NAMESPACES = ("refs/heads", "refs/remotes", "refs/tags")
PEELED = "%(if)%(*objectname)%(then)%(*objectname)%(else)%(objectname)%(end)"
UNMERGED = "unmerged"
DELETED = "deleted"
DIRECTORY = "directory"
SPECIAL = "special"
UNHASHABLE = {
DIRECTORY: "untracked directory or gitlink",
SPECIAL: "not a regular file",
}
MAX_REASONS = 5
def _tips(repo: str) -> list[str]:
out = _git(["for-each-ref", f"--format={PEELED}", *REF_NAMESPACES], repo)
return sorted(set(out.split()))
def _status_entries(tree: str) -> list[tuple[str, str | None]]:
raw = _git(
[
"status",
"--porcelain=v2",
"-z",
"--untracked-files=all",
"--no-renames",
],
tree,
)
entries: list[tuple[str, str | None]] = []
for record in raw.split("\0"):
kind = record[:1]
if kind == "1":
parts = record.split(" ", 8)
entries.append((parts[8], parts[7]))
elif kind == "u":
entries.append((record.split(" ", 10)[10], UNMERGED))
elif kind == "?":
entries.append((record[2:], None))
return entries
def _hashes(tree: str, paths: list[str]) -> dict[str, str]:
hashes: dict[str, str] = {}
files: list[str] = []
for path in paths:
full = os.path.join(tree, path)
try:
mode = os.lstat(full).st_mode
except FileNotFoundError:
hashes[path] = DELETED
continue
if stat.S_ISREG(mode):
files.append(path)
elif stat.S_ISLNK(mode):
hashes[path] = run(
["hash-object", "-t", "blob", "--no-filters", "--stdin"],
cwd=tree,
scan=True,
input_text=os.readlink(full),
)
else:
hashes[path] = DIRECTORY if stat.S_ISDIR(mode) else SPECIAL
if files:
out = run(
["hash-object", "--no-filters", "--stdin-paths"],
cwd=tree,
scan=True,
input_text="".join(f"{path}\n" for path in files),
)
hashes.update(zip(files, out.split("\n")))
return hashes
def _absent(
repo: str, tips: list[str], wanted: list[tuple[str, set[str]]]
) -> list[str]:
query = "".join(f"{tip}:{path}\n" for path, _ in wanted for tip in tips)
if not query:
return [path for path, _ in wanted]
found = run(
["cat-file", "--batch-check=%(objectname)"],
cwd=repo,
scan=True,
input_text=query,
).split("\n")
width = len(tips)
return [
path
for index, (path, blobs) in enumerate(wanted)
if blobs - set(found[index * width : (index + 1) * width])
]
def _reachable(repo: str, head: str | None) -> bool:
if not head:
return False
return bool(
_git(["for-each-ref", "--count=1", f"--contains={head}", *REF_NAMESPACES], repo)
)
def _unpreserved(
repo: str, record: dict, tips: list[str]
) -> tuple[int | None, list[str]]:
tree = record["path"]
if _has_submodules(tree):
return None, ["submodules, whose config the scan cannot vet"]
unsafe = local_config(tree, FILTER_CONFIG)
if unsafe:
return None, [f"{', '.join(unsafe)} in the repository config"]
entries = _status_entries(tree)
reasons: list[str] = []
if entries and record["branch"] is None and not _reachable(repo, record["head"]):
reasons.append("detached HEAD reachable from no ref")
hashable: list[tuple[str, str | None]] = []
for path, index_blob in entries:
if "\n" in path:
reasons.append(f"{path!r}: path with a newline")
elif index_blob == UNMERGED:
reasons.append(f"{path}: unmerged")
else:
hashable.append((path, index_blob))
hashes = _hashes(tree, [path for path, _ in hashable])
wanted: list[tuple[str, set[str]]] = []
for path, index_blob in hashable:
current = hashes[path]
if current in UNHASHABLE:
reasons.append(f"{path}: {UNHASHABLE[current]}")
continue
blobs = {current} - {DELETED}
if index_blob and set(index_blob) != {"0"}:
blobs.add(index_blob)
if blobs:
wanted.append((path, blobs))
reasons += [f"{path}: content in no ref" for path in _absent(repo, tips, wanted)]
return len(entries), reasons
def preserved(repo: str, records: list[dict]) -> list[dict]:
"""
Decide for each dirty worktree whether all its work survives its deletion.
Args:
repo: main worktree root of the repository.
records: its worktree records from list_worktrees.
Returns:
One entry per dirty or unreadable worktree; `preserved` is true only
when every changed, staged and untracked file's content is a blob at
the same path of some branch, remote or tag tip, and a detached HEAD is
reachable from a ref. `reasons` lists what is not, capped.
"""
live = [r for r in records if not r["orphaned"] and not r["missing"]]
tips = _tips(repo) if live else []
results: list[dict] = []
for record in live:
try:
paths, reasons = _unpreserved(repo, record, tips)
except (GitBaseError, OSError, UnicodeError) as exc:
paths, reasons = None, [f"status failed: {_describe(exc)}"]
if paths == 0:
continue
extra = len(reasons) - MAX_REASONS
results.append(
{
"repo": repo,
"path": record["path"],
"branch": record["branch"],
"paths": paths,
"preserved": not reasons,
"reasons": reasons[:MAX_REASONS]
+ ([f"... {extra} more"] if extra > 0 else []),
}
)
return results
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
from pkgmgr.actions.branch.gc import human_size
UNITS = (("d", 86400), ("h", 3600), ("m", 60))
DOCKER_COLUMNS = ("type", "total", "active", "size", "reclaimable")
def _size(value: int | None) -> str:
return "-" if value is None else human_size(value)
def _age(now: float, stamp: float | None) -> str:
if not stamp:
return "-"
seconds = max(0.0, now - stamp)
for unit, length in UNITS:
if seconds >= length:
return f"{seconds / length:.1f}{unit}"
return f"{seconds:.0f}s"
def _section(title: str, headers: list[str], rows: list[list[str]]) -> list[str]:
if not rows:
return [f"== {title}", "(none)"]
widths = [max(len(cell) for cell in column) for column in zip(headers, *rows)]
return [f"== {title}"] + [
" ".join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip()
for row in (headers, *rows)
]
def _branch(tree: dict) -> str:
if tree["orphaned"]:
return "-"
return tree["branch"] or "(detached)"
def _state(tree: dict) -> str:
if tree["orphaned"]:
return "orphaned"
return "missing" if tree["missing"] else "linked"
def render_text(report: dict) -> str:
"""
Render a disk_report result as aligned text tables.
Args:
report: the dict disk_report returned.
Returns:
The tables, one section per report key, newline-joined.
"""
now = report["generated_at"]
lines = _section(
"worktrees",
["SIZE", "ACTIVE", "FOREIGN", "STATE", "BRANCH", "PATH"],
[
[
_size(t["size_bytes"]),
_age(now, t["last_activity"]),
str(t["foreign_entries"]),
_state(t),
_branch(t),
t["path"],
]
for t in report["worktrees"]
],
)
lines += _section(
"preserved (dirty worktrees)",
["PRESERVED", "PATHS", "BRANCH", "PATH", "REASONS"],
[
[
"yes" if p["preserved"] else "no",
"-" if p["paths"] is None else str(p["paths"]),
p["branch"] or "(detached)",
p["path"],
"; ".join(p["reasons"]),
]
for p in report["preserved"]
],
)
for root in report["roots"]:
lines += _section(
f"root {root['root']} (over {human_size(report['threshold_bytes'])})",
["SIZE", "AGE", "KIND", "PATH"],
[
[
_size(c["size_bytes"]),
_age(now, c["newest_mtime"]),
c["kind"],
c["path"],
]
for c in root["children"]
],
)
lines += _section(
"core dumps",
["SIZE", "APPARENT", "AGE", "PATH"],
[
[
_size(d["size_bytes"]),
_size(d["apparent_bytes"]),
_age(now, d["mtime"]),
d["path"],
]
for d in report["core_dumps"]
],
)
if report["docker"] is not None:
lines += _section(
"docker",
["TYPE", "TOTAL", "ACTIVE", "SIZE", "RECLAIMABLE"],
[[str(row[key]) for key in DOCKER_COLUMNS] for row in report["docker"]],
)
lines += _section(
"balance",
["FILESYSTEM", "TOTAL", "FREE", "ATTRIBUTED"],
[
[
fs["filesystem"],
_size(fs["total_bytes"]),
_size(fs["free_bytes"]),
_size(fs["attributed_bytes"]),
]
for fs in report["balance"]
],
)
lines += [f"WARN {warning}" for warning in report["warnings"]]
return "\n".join(lines)
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
import time
from pkgmgr.actions.branch.gc import _describe
from pkgmgr.core.git.errors import GitBaseError
from .balance import balance
from .core_dumps import core_dumps
from .docker_usage import docker_usage
from .preserved import preserved
from .roots import scan_root
from .worktrees import list_worktrees
def disk_report(
roots: list[str], repo_roots: list[str], threshold_bytes: int, docker: bool
) -> dict:
"""
Inventory disk usage read-only: it deletes and writes nothing.
Args:
roots: directories whose direct children are measured.
repo_roots: main worktree roots of the repositories whose linked
worktrees are listed (pkgmgr passes every managed repository).
threshold_bytes: list only root children larger than this.
docker: also read `docker system df`.
Returns:
A JSON-serialisable dict. Sizes are allocated bytes (st_blocks * 512,
sparse files and hard links counted as stored), measured without
following symlinks or leaving the filesystem a walk starts on; times
are epoch seconds.
generated_at: when the scan started.
threshold_bytes: as given.
worktrees: per linked worktree and orphaned `.claude/worktrees/agent-*`
dir: repo, path, branch, head, orphaned, missing, size_bytes,
last_activity (newest mtime inside or of its index/HEAD),
foreign_entries (not owned by the current uid, pruned at the first
per subtree), unreadable (directories that could not be listed).
preserved: per dirty worktree: repo, path, branch, paths (changed,
staged and untracked; None when status was not read), preserved,
reasons.
roots: per root: root, children over the threshold with path, kind
(worktree, git-repo, venv, node_modules, cache, core-dump, dir,
file), size_bytes, newest_mtime, unreadable; biggest first.
core_dumps: files named `core` or `core.<digits>` under the roots and
worktrees whose ELF e_type is ET_CORE: path, size_bytes,
apparent_bytes (st_size), mtime; biggest first.
docker: rows of type, total, active, size, reclaimable; None when not
asked for or unavailable.
balance: per filesystem of the roots and repository roots: filesystem
(mount point), total_bytes, free_bytes, attributed_bytes.
warnings: what could not be read.
"""
started = time.time()
warnings: list[str] = []
cores: list[str] = []
trees: list[dict] = []
kept: list[dict] = []
for repo in repo_roots:
try:
records = list_worktrees(repo, cores)
except (GitBaseError, OSError) as exc:
warnings.append(f"{repo}: worktrees not listed: {_describe(exc)}")
continue
trees += records
try:
kept += preserved(repo, records)
except (GitBaseError, OSError) as exc:
warnings.append(f"{repo}: preservation not checked: {_describe(exc)}")
scanned: list[dict] = []
for root in roots:
try:
scanned.append(scan_root(root, threshold_bytes, cores))
except OSError as exc:
warnings.append(f"{root}: not scanned: {_describe(exc)}")
dumps = core_dumps(cores)
usage = None
if docker:
usage, warning = docker_usage()
if warning:
warnings.append(warning)
items = [(t["path"], t["size_bytes"]) for t in trees if t["size_bytes"]]
items += [(c["path"], c["size_bytes"]) for s in scanned for c in s["children"]]
items += [(d["path"], d["size_bytes"]) for d in dumps]
return {
"generated_at": started,
"threshold_bytes": threshold_bytes,
"worktrees": trees,
"preserved": kept,
"roots": scanned,
"core_dumps": dumps,
"docker": usage,
"balance": balance([*roots, *repo_roots], items),
"warnings": warnings,
}
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
import os
import stat
from .core_dumps import is_core_dump
from .walk import walk
CACHE_TAG = "CACHEDIR.TAG"
NODE_MODULES = "node_modules"
def _has(path: str, name: str, check=os.path.isfile) -> bool:
return check(os.path.join(path, name))
def _kind(path: str, st: os.stat_result) -> str:
name = os.path.basename(path)
if not stat.S_ISDIR(st.st_mode):
regular = stat.S_ISREG(st.st_mode)
return "core-dump" if regular and is_core_dump(path) else "file"
if name == NODE_MODULES:
return "node_modules"
if _has(path, ".git"):
return "worktree"
if _has(path, ".git", os.path.isdir) or (
_has(path, "HEAD") and _has(path, "objects", os.path.isdir)
):
return "git-repo"
if _has(path, "pyvenv.cfg"):
return "venv"
if _has(path, CACHE_TAG) or name.lower().endswith("cache"):
return "cache"
return "dir"
def scan_root(root: str, threshold_bytes: int, cores: list[str]) -> dict:
"""
Measure the direct children of one root on the root's own filesystem.
Args:
root: the directory to inventory.
threshold_bytes: keep only children whose allocated size is above it.
cores: receives the core dump candidates found while measuring.
Returns:
The root with its children over the threshold: path, kind, allocated
size, newest mtime inside and unlistable directories, biggest first.
Raises:
OSError: when the root itself cannot be listed.
"""
dev = os.stat(root).st_dev
children: list[dict] = []
with os.scandir(root) as entries:
listing = sorted(entries, key=lambda entry: entry.name)
for entry in listing:
try:
st = entry.stat(follow_symlinks=False)
except OSError:
continue
if st.st_dev != dev:
continue
stats = walk(entry.path, dev)
cores.extend(stats.cores)
if stats.size_bytes > threshold_bytes:
children.append(
{
"path": entry.path,
"kind": _kind(entry.path, st),
"size_bytes": stats.size_bytes,
"newest_mtime": stats.newest_mtime,
"unreadable": stats.unreadable,
}
)
children.sort(key=lambda child: (-child["size_bytes"], child["path"]))
return {"root": root, "children": children}
+86
View File
@@ -0,0 +1,86 @@
from __future__ import annotations
import os
import re
import stat
from dataclasses import dataclass, field
BLOCK = 512
CORE_NAME = re.compile(r"core(\.\d+)?")
@dataclass
class TreeStats:
size_bytes: int = 0
newest_mtime: float = 0.0
foreign_entries: int = 0
unreadable: int = 0
cores: list[str] = field(default_factory=list)
def is_foreign(path: str, st: os.stat_result) -> bool:
return st.st_uid != os.getuid()
def _account(
stats: TreeStats, path: str, st: os.stat_result, seen: set[tuple[int, int]]
) -> None:
if st.st_nlink > 1 and not stat.S_ISDIR(st.st_mode):
if (st.st_dev, st.st_ino) in seen:
return
seen.add((st.st_dev, st.st_ino))
stats.size_bytes += st.st_blocks * BLOCK
stats.newest_mtime = max(stats.newest_mtime, st.st_mtime)
if stat.S_ISREG(st.st_mode) and CORE_NAME.fullmatch(os.path.basename(path)):
stats.cores.append(path)
def _children(path: str, stats: TreeStats) -> list[os.DirEntry]:
try:
with os.scandir(path) as entries:
return list(entries)
except OSError:
stats.unreadable += 1
return []
def walk(path: str, dev: int | None = None) -> TreeStats:
"""
Measure a tree like `du -x`: allocated blocks, symlinks never followed.
Args:
path: the tree root, measured itself as well.
dev: the filesystem (st_dev) to stay on; that of path when None.
Returns:
Allocated bytes (st_blocks * 512, each hard-linked inode once), the
newest mtime, entries not owned by the current uid (none counted below a
foreign one), directories that could not be listed, and the regular
files named `core` or `core.<digits>`.
"""
stats = TreeStats()
try:
top = os.lstat(path)
except OSError:
stats.unreadable += 1
return stats
dev = top.st_dev if dev is None else dev
if top.st_dev != dev:
return stats
seen: set[tuple[int, int]] = set()
stack: list[tuple[str, os.stat_result, bool]] = [(path, top, False)]
while stack:
current, st, pruned = stack.pop()
_account(stats, current, st, seen)
foreign = not pruned and is_foreign(current, st)
stats.foreign_entries += foreign
if not stat.S_ISDIR(st.st_mode):
continue
for entry in _children(current, stats):
try:
child = entry.stat(follow_symlinks=False)
except OSError:
continue
if child.st_dev == dev:
stack.append((entry.path, child, pruned or foreign))
return stats
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import os
from pkgmgr.actions.branch.gc import _worktrees
from .walk import TreeStats, walk
AGENT_DIR = os.path.join(".claude", "worktrees")
AGENT_PREFIX = "agent-"
METADATA_FILES = ("index", "HEAD")
def _metadata_mtime(path: str) -> float:
try:
with open(os.path.join(path, ".git"), encoding="utf-8") as fh:
gitdir = fh.read().strip().removeprefix("gitdir: ")
except OSError:
return 0.0
meta = os.path.normpath(os.path.join(path, gitdir))
stamps = [0.0]
for name in METADATA_FILES:
try:
stamps.append(os.stat(os.path.join(meta, name)).st_mtime)
except OSError:
continue
return max(stamps)
def _record(
repo: str,
path: str,
branch: str | None,
head: str | None,
orphaned: bool,
cores: list[str],
) -> dict:
present = os.path.isdir(path)
stats = walk(path) if present else TreeStats()
cores.extend(stats.cores)
activity = max(stats.newest_mtime, _metadata_mtime(path)) if present else 0.0
return {
"repo": repo,
"path": path,
"branch": branch,
"head": head,
"orphaned": orphaned,
"missing": not present,
"size_bytes": stats.size_bytes if present else None,
"last_activity": activity or None,
"foreign_entries": stats.foreign_entries,
"unreadable": stats.unreadable,
}
def _agent_dirs(repo: str) -> list[str]:
base = os.path.join(repo, AGENT_DIR)
try:
names = sorted(os.listdir(base))
except OSError:
return []
return [
os.path.join(base, name)
for name in names
if name.startswith(AGENT_PREFIX)
and os.path.isdir(os.path.join(base, name))
and not os.path.islink(os.path.join(base, name))
]
def list_worktrees(repo: str, cores: list[str]) -> list[dict]:
"""
Inventory the linked worktrees of one repository, orphaned agent dirs included.
Args:
repo: main worktree root of the repository.
cores: receives the core dump candidates found while measuring.
Returns:
One record per linked worktree git lists and per `.claude/worktrees/agent-*`
directory it does not list (orphaned), sorted by path.
Raises:
GitBaseError: when `git worktree list` fails.
"""
trees = [tree for tree in _worktrees(repo) if not tree.main]
listed = {os.path.realpath(tree.path) for tree in trees}
records = [
_record(repo, tree.path, tree.branch, tree.head, False, cores) for tree in trees
]
records += [
_record(repo, path, None, None, True, cores)
for path in _agent_dirs(repo)
if os.path.realpath(path) not in listed
]
return sorted(records, key=lambda record: record["path"])
+20 -1
View File
@@ -1,8 +1,11 @@
from __future__ import annotations
import json
import os
import sys
from pkgmgr.actions.branch.gc import main_repo_root
from pkgmgr.actions.gc.report import disk_report, render_text
from pkgmgr.actions.worktree.orphans import worktree_orphans
from pkgmgr.actions.worktree.up import worktree_up
from pkgmgr.cli.context import CLIContext
@@ -15,6 +18,8 @@ from .branch import (
scan_repo_roots,
)
MEBIBYTE = 1 << 20
def handle_worktree(args, ctx: CLIContext) -> None:
"""
@@ -56,4 +61,18 @@ def handle_worktree(args, ctx: CLIContext) -> None:
def handle_gc(args, ctx: CLIContext) -> None:
run_cleanup(args, ctx, "all")
if not args.report:
run_cleanup(args, ctx, "all")
if args.apply:
print(
"--report only reads; it cannot be combined with --apply", file=sys.stderr
)
sys.exit(2)
report = disk_report(
[os.path.abspath(os.path.expanduser(root)) for root in args.root],
scan_repo_roots(args, ctx),
args.threshold_mb * MEBIBYTE,
args.docker,
)
print(json.dumps(report, indent=2) if args.json else render_text(report))
sys.exit(0)
+28
View File
@@ -16,3 +16,31 @@ def add_gc_subparser(subparsers: argparse._SubParsersAction) -> None:
add_apply_options(gc)
add_scan_options(gc)
add_min_age_option(gc)
gc.add_argument(
"--report",
action="store_true",
help=(
"Print a read-only disk inventory instead of the cleanup plan: "
"worktrees, preserved changes, --root children, core dumps, "
"docker usage and free space per filesystem"
),
)
gc.add_argument(
"--root",
action="append",
default=[],
metavar="DIR",
help="With --report: also measure the direct children of DIR; repeatable",
)
gc.add_argument(
"--threshold-mb",
type=int,
default=0,
metavar="MB",
help="With --report: list only --root children larger than MB (0: all)",
)
gc.add_argument(
"--docker",
action="store_true",
help="With --report: also read `docker system df`",
)
+7 -1
View File
@@ -58,6 +58,7 @@ def run(
cwd: str = ".",
preview: bool = False,
scan: bool = False,
input_text: str | None = None,
) -> str:
"""
Run a Git command and return its stdout as a stripped string.
@@ -69,6 +70,8 @@ def run(
scan: read a repository an agent may have written: no fsmonitor, hook,
pager, lazy fetch, replace object, system config or prompt, stdin
closed, a timeout.
input_text: text fed to stdin, e.g. for `cat-file --batch-check`;
replaces the closed stdin of scan mode.
Raises GitRunError (or a subclass) if execution fails or times out.
"""
@@ -79,11 +82,14 @@ def run(
print(f"[PREVIEW] Would run in {cwd!r}: {cmd_str}")
return ""
hardened = (
hardened: dict = (
{"env": _scan_env(), "stdin": subprocess.DEVNULL, "timeout": SCAN_TIMEOUT}
if scan
else {}
)
if input_text is not None:
hardened.pop("stdin", None)
hardened["input"] = input_text
try:
result = subprocess.run(
cmd,
+215
View File
@@ -0,0 +1,215 @@
from __future__ import annotations
import io
import json
import os
import subprocess
import tempfile
import unittest
from contextlib import redirect_stdout
from unittest.mock import patch
from pkgmgr.actions.gc.report import disk_report, render_text
from pkgmgr.actions.gc.report import docker_usage as docker_module
from pkgmgr.actions.gc.report import walk as walk_module
from pkgmgr.cli.commands.worktree import handle_gc
from pkgmgr.cli.parser import create_parser
GIT_ENV = {
**os.environ,
"GIT_CONFIG_GLOBAL": os.devnull,
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@example.org",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@example.org",
}
THRESHOLD = 32 * 1024
ELF_CORE = b"\x7fELF\x02\x01\x01" + bytes(9) + (4).to_bytes(2, "little")
ELF_EXEC = b"\x7fELF\x02\x01\x01" + bytes(9) + (2).to_bytes(2, "little")
DOCKER_DF = (
'{"Active":"1","Reclaimable":"0B","Size":"1GB","TotalCount":"3","Type":"Images"}\n'
)
def git(cwd: str, *args: str) -> str:
return subprocess.run(
["git", *args], cwd=cwd, env=GIT_ENV, check=True, capture_output=True, text=True
).stdout
def write(path: str, data: bytes | str, size: int | None = None) -> str:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as fh:
fh.write(data.encode() if isinstance(data, str) else data)
if size is not None:
os.truncate(path, size)
return path
class TestDiskReport(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.tmp = os.path.realpath(self._tmp.name)
self.repo = os.path.join(self.tmp, "repo")
git(self.tmp, "init", "-q", "-b", "main", self.repo)
write(os.path.join(self.repo, "a.txt"), "base")
git(self.repo, "add", "a.txt")
git(self.repo, "commit", "-q", "-m", "base")
git(self.repo, "checkout", "-q", "-b", "other")
write(os.path.join(self.repo, "a.txt"), "kept")
write(os.path.join(self.repo, "b.txt"), "kept-b")
git(self.repo, "add", "a.txt", "b.txt")
git(self.repo, "commit", "-q", "-m", "other")
git(self.repo, "checkout", "-q", "main")
self.clean = self._worktree("wt-clean", "-b", "clean")
self.kept = self._worktree("wt-kept", "-b", "kept")
write(os.path.join(self.kept, "a.txt"), "kept")
git(self.kept, "add", "a.txt")
write(os.path.join(self.kept, "b.txt"), "kept-b")
self.lost = self._worktree("wt-lost", "-b", "lost")
write(os.path.join(self.lost, "new.txt"), "only here")
self.detached = self._worktree("wt-detached", "--detach")
write(os.path.join(self.detached, "c.txt"), "c")
git(self.detached, "add", "c.txt")
git(self.detached, "commit", "-q", "-m", "unreachable")
write(os.path.join(self.detached, "a.txt"), "kept")
agents = os.path.join(self.repo, ".claude", "worktrees")
self.agent = self._worktree(os.path.join(agents, "agent-y"), "-b", "agent-y")
self.orphan = os.path.join(agents, "agent-x")
write(os.path.join(self.orphan, "notes.txt"), "left behind")
self.root = os.path.join(self.tmp, "root")
self.dump = write(
os.path.join(self.root, "core"), ELF_CORE + b"c" * 64 * 1024, 1 << 30
)
self.binary = write(
os.path.join(self.root, "core.12"), ELF_EXEC + b"e" * 64 * 1024
)
write(os.path.join(self.root, "bigdir", "blob"), b"b" * 128 * 1024)
write(os.path.join(self.root, "small"), "s")
def tearDown(self) -> None:
self._tmp.cleanup()
def _worktree(self, name: str, *args: str) -> str:
path = os.path.join(self.tmp, name)
git(self.repo, "worktree", "add", "-q", *args, path, "main")
return path
def _report(self) -> dict:
with patch.object(docker_module, "_docker", return_value=DOCKER_DF):
return disk_report([self.root], [self.repo], THRESHOLD, docker=True)
def _cli(self, *argv: str) -> tuple[int, str]:
args = create_parser("test").parse_args(["gc", *argv])
out = io.StringIO()
with (
patch("pkgmgr.cli.commands.branch.main_repo_root", return_value=self.repo),
patch(
"pkgmgr.cli.commands.branch.cleanup",
side_effect=AssertionError("the report must not run a cleanup"),
),
patch.object(docker_module, "_docker", return_value=DOCKER_DF),
redirect_stdout(out),
self.assertRaises(SystemExit) as stop,
):
handle_gc(args, None)
return stop.exception.code, out.getvalue()
def test_cli_report_prints_the_json_inventory(self) -> None:
code, out = self._cli("--report", "--json", "--root", self.root, "--docker")
report = json.loads(out)
self.assertEqual(code, 0)
self.assertIn(self.dump, [dump["path"] for dump in report["core_dumps"]])
self.assertIn(self.lost, [tree["path"] for tree in report["worktrees"]])
self.assertIsNotNone(report["docker"])
def test_cli_report_refuses_apply(self) -> None:
self.assertEqual(self._cli("--report", "--apply"), (2, ""))
def test_worktrees_and_orphaned_agent_dir(self) -> None:
trees = {t["path"]: t for t in self._report()["worktrees"]}
expected = {
self.clean,
self.kept,
self.lost,
self.detached,
self.agent,
self.orphan,
}
self.assertEqual(set(trees), expected)
self.assertTrue(trees[self.orphan]["orphaned"])
self.assertFalse(trees[self.agent]["orphaned"])
self.assertEqual(trees[self.kept]["branch"], "kept")
self.assertIsNone(trees[self.detached]["branch"])
for tree in trees.values():
self.assertGreater(tree["size_bytes"], 0)
self.assertIsNotNone(tree["last_activity"])
self.assertEqual(tree["foreign_entries"], 0)
def test_foreign_entries_through_patched_owner_check(self) -> None:
with patch.object(
walk_module,
"is_foreign",
side_effect=lambda path, st: os.path.basename(path) == "new.txt",
):
trees = {t["path"]: t for t in self._report()["worktrees"]}
self.assertEqual(trees[self.lost]["foreign_entries"], 1)
self.assertEqual(trees[self.clean]["foreign_entries"], 0)
def test_preserved_verdicts_and_nothing_written(self) -> None:
before = git(self.kept, "status", "--porcelain=v2", "--untracked-files=all")
verdicts = {p["path"]: p for p in self._report()["preserved"]}
self.assertEqual(set(verdicts), {self.kept, self.lost, self.detached})
self.assertTrue(verdicts[self.kept]["preserved"])
self.assertEqual(verdicts[self.kept]["paths"], 2)
self.assertFalse(verdicts[self.lost]["preserved"])
self.assertEqual(verdicts[self.lost]["reasons"], ["new.txt: content in no ref"])
self.assertFalse(verdicts[self.detached]["preserved"])
self.assertEqual(
verdicts[self.detached]["reasons"], ["detached HEAD reachable from no ref"]
)
after = git(self.kept, "status", "--porcelain=v2", "--untracked-files=all")
self.assertEqual(before, after)
def test_roots_core_dumps_docker_and_balance(self) -> None:
report = self._report()
children = report["roots"][0]["children"]
self.assertEqual(
[(os.path.basename(c["path"]), c["kind"]) for c in children],
[("bigdir", "dir"), ("core", "core-dump"), ("core.12", "file")],
)
self.assertEqual([d["path"] for d in report["core_dumps"]], [self.dump])
dump = report["core_dumps"][0]
self.assertEqual(dump["apparent_bytes"], 1 << 30)
self.assertLess(dump["size_bytes"], dump["apparent_bytes"])
self.assertEqual(report["docker"][0]["total"], 3)
self.assertEqual(len(report["balance"]), 1)
listed = sum(c["size_bytes"] for c in children)
listed += sum(t["size_bytes"] for t in report["worktrees"])
self.assertEqual(report["balance"][0]["attributed_bytes"], listed)
self.assertEqual(report["warnings"], [])
json.dumps(report)
text = render_text(report)
self.assertIn("orphaned", text)
self.assertIn("core-dump", text)
def test_unknown_repo_and_root_are_warnings(self) -> None:
missing = os.path.join(self.tmp, "absent")
report = disk_report([missing], [self.root], THRESHOLD, docker=False)
self.assertEqual(report["worktrees"], [])
self.assertIsNone(report["docker"])
self.assertEqual(len(report["warnings"]), 2)
if __name__ == "__main__":
unittest.main()
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,47 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pkgmgr.actions.gc.report.balance import balance
class TestBalance(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.root = os.path.realpath(self._tmp.name)
for rel in ("a/b", "c", "a-x"):
os.makedirs(os.path.join(self.root, rel))
def tearDown(self) -> None:
self._tmp.cleanup()
def _path(self, rel: str) -> str:
return os.path.join(self.root, rel)
def test_nested_and_repeated_items_count_once(self) -> None:
items = [
(self._path("a/b"), 50),
(self._path("a"), 100),
(self._path("a-x"), 7),
(self._path("c"), 10),
(self._path("c"), 10),
(self._path("gone"), 1000),
]
rows = balance([self.root, self.root], items)
self.assertEqual(len(rows), 1)
row = rows[0]
self.assertEqual(row["attributed_bytes"], 117)
self.assertTrue(os.path.ismount(row["filesystem"]))
self.assertGreater(row["total_bytes"], 0)
self.assertLessEqual(row["free_bytes"], row["total_bytes"])
def test_unreadable_paths_get_no_row(self) -> None:
self.assertEqual(balance([self._path("gone")], []), [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,79 @@
from __future__ import annotations
import os
import tempfile
import unittest
from unittest.mock import ANY, patch
from pkgmgr.actions.gc.report.core_dumps import (
ELF_HEADER,
ET_CORE,
core_dumps,
is_core_dump,
)
ET_EXEC = 2
def elf(e_type: int, order: str = "little") -> bytes:
data = 1 if order == "little" else 2
return b"\x7fELF" + bytes([2, data, 1]) + bytes(9) + e_type.to_bytes(2, order)
class TestCoreDumps(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.root = os.path.realpath(self._tmp.name)
def tearDown(self) -> None:
self._tmp.cleanup()
def _file(self, name: str, data: bytes, size: int | None = None) -> str:
path = os.path.join(self.root, name)
with open(path, "wb") as fh:
fh.write(data + bytes(46))
if size is not None:
os.truncate(path, size)
return path
def test_et_core_is_detected_in_both_byte_orders(self) -> None:
self.assertTrue(is_core_dump(self._file("core", elf(ET_CORE))))
self.assertTrue(is_core_dump(self._file("core.1", elf(ET_CORE, "big"))))
def test_other_elf_types_and_non_elf_are_ignored(self) -> None:
self.assertFalse(is_core_dump(self._file("core", elf(ET_EXEC))))
self.assertFalse(is_core_dump(self._file("core.2", b"#!/bin/sh\n")))
short = os.path.join(self.root, "core.3")
with open(short, "wb") as fh:
fh.write(b"\x7fELF")
self.assertFalse(is_core_dump(short))
def test_symlink_is_not_followed(self) -> None:
target = self._file("real", elf(ET_CORE))
link = os.path.join(self.root, "core")
os.symlink(target, link)
self.assertFalse(is_core_dump(link))
def test_reads_no_more_than_the_elf_header(self) -> None:
path = self._file("core", elf(ET_CORE), size=1 << 20)
with patch("os.read", wraps=os.read) as read:
is_core_dump(path)
read.assert_called_once_with(ANY, ELF_HEADER)
def test_sparse_dump_is_sized_by_blocks_and_others_dropped(self) -> None:
dump = self._file("core", elf(ET_CORE), size=1 << 30)
binary = self._file("core.7", elf(ET_EXEC))
dumps = core_dumps([dump, binary, dump])
self.assertEqual([d["path"] for d in dumps], [dump])
self.assertEqual(dumps[0]["apparent_bytes"], 1 << 30)
self.assertLess(dumps[0]["size_bytes"], dumps[0]["apparent_bytes"])
self.assertEqual(dumps[0]["mtime"], os.lstat(dump).st_mtime)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,62 @@
from __future__ import annotations
import subprocess
import unittest
from unittest.mock import patch
from pkgmgr.actions.gc.report import docker_usage as module
DF_OUTPUT = (
'{"Active":"2","Reclaimable":"1.2GB (50%)","Size":"2.4GB",'
'"TotalCount":"10","Type":"Images"}\n'
'{"Active":"0","Reclaimable":"0B","Size":"0B","TotalCount":"0",'
'"Type":"Build Cache"}\n'
)
class TestDockerUsage(unittest.TestCase):
def test_rows_are_parsed_from_json_lines(self) -> None:
with patch.object(module, "_docker", return_value=DF_OUTPUT) as docker:
rows, warning = module.docker_usage()
docker.assert_called_once_with(["system", "df", "--format", "{{json .}}"])
self.assertIsNone(warning)
self.assertEqual(
rows[0],
{
"type": "Images",
"total": 10,
"active": 2,
"size": "2.4GB",
"reclaimable": "1.2GB (50%)",
},
)
self.assertEqual(rows[1]["type"], "Build Cache")
def test_missing_docker_is_a_warning(self) -> None:
with patch.object(module, "_docker", side_effect=FileNotFoundError):
rows, warning = module.docker_usage()
self.assertIsNone(rows)
self.assertIn("not installed", warning)
def test_failing_docker_is_a_warning(self) -> None:
error = subprocess.CalledProcessError(
1, ["docker", "system", "df"], stderr="daemon down"
)
with patch.object(module, "_docker", side_effect=error):
rows, warning = module.docker_usage()
self.assertIsNone(rows)
self.assertIn("daemon down", warning)
def test_unparsable_output_is_a_warning(self) -> None:
with patch.object(module, "_docker", return_value="TYPE TOTAL\n[1]\n"):
rows, warning = module.docker_usage()
self.assertIsNone(rows)
self.assertIn("not JSON lines", warning)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,70 @@
from __future__ import annotations
import os
import tempfile
import unittest
from unittest.mock import patch
from pkgmgr.actions.gc.report import preserved as module
HEAD_BLOB = "1" * 40
INDEX_BLOB = "2" * 40
STATUS = "\0".join(
[
f"1 .M N... 100644 100644 100644 {HEAD_BLOB} {INDEX_BLOB} dir/a file.txt",
" ".join(
["u UU N...", *["100644"] * 4, HEAD_BLOB, INDEX_BLOB, HEAD_BLOB, "c.txt"]
),
"? new file",
"",
]
)
class TestPreservedParsing(unittest.TestCase):
def test_status_records_keep_paths_with_spaces(self) -> None:
with patch.object(module, "_git", return_value=STATUS):
entries = module._status_entries("/wt")
self.assertEqual(
entries,
[
("dir/a file.txt", INDEX_BLOB),
("c.txt", module.UNMERGED),
("new file", None),
],
)
def test_blob_must_sit_at_the_same_path_of_some_tip(self) -> None:
wanted = [("a", {HEAD_BLOB}), ("b", {HEAD_BLOB, INDEX_BLOB})]
answer = f"{HEAD_BLOB}\nt2:a missing\n{HEAD_BLOB}\nt2:b missing"
with patch.object(module, "run", return_value=answer) as run:
absent = module._absent("/repo", ["t1", "t2"], wanted)
self.assertEqual(absent, ["b"])
self.assertEqual(run.call_args.kwargs["input_text"], "t1:a\nt2:a\nt1:b\nt2:b\n")
def test_special_files_are_never_opened(self) -> None:
with tempfile.TemporaryDirectory() as tree:
os.mkfifo(os.path.join(tree, "fifo"))
os.mkdir(os.path.join(tree, "sub"))
with patch.object(module, "run") as run:
hashes = module._hashes(tree, ["fifo", "gone", "sub"])
run.assert_not_called()
self.assertEqual(
hashes,
{"fifo": module.SPECIAL, "gone": module.DELETED, "sub": module.DIRECTORY},
)
def test_no_tips_means_nothing_is_preserved(self) -> None:
with patch.object(module, "run") as run:
self.assertEqual(module._absent("/repo", [], [("a", {HEAD_BLOB})]), ["a"])
run.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,123 @@
from __future__ import annotations
import unittest
from pkgmgr.actions.gc.report.render import render_text
NOW = 1_000_000.0
def report(**overrides: object) -> dict:
base = {
"generated_at": NOW,
"threshold_bytes": 1 << 20,
"worktrees": [
{
"path": "/r/.worktrees/x",
"branch": None,
"orphaned": False,
"missing": False,
"size_bytes": 3 << 30,
"last_activity": NOW - 2 * 86400,
"foreign_entries": 4,
},
{
"path": "/r/.claude/worktrees/agent-1",
"branch": None,
"orphaned": True,
"missing": False,
"size_bytes": 5 << 20,
"last_activity": NOW - 90,
"foreign_entries": 0,
},
],
"preserved": [
{
"path": "/r/.worktrees/x",
"branch": None,
"paths": 2,
"preserved": False,
"reasons": [
"detached HEAD reachable from no ref",
"a: content in no ref",
],
}
],
"roots": [
{
"root": "/tmp",
"children": [
{
"path": "/tmp/core",
"kind": "core-dump",
"size_bytes": 2 << 20,
"newest_mtime": NOW - 3 * 3600,
}
],
}
],
"core_dumps": [
{
"path": "/tmp/core",
"size_bytes": 2 << 20,
"apparent_bytes": 1 << 30,
"mtime": NOW - 3 * 3600,
}
],
"docker": None,
"balance": [
{
"filesystem": "/",
"total_bytes": 100 << 30,
"free_bytes": 10 << 30,
"attributed_bytes": 3 << 30,
}
],
"warnings": ["docker unavailable: not installed"],
}
return {**base, **overrides}
class TestRenderText(unittest.TestCase):
def test_sections_and_rows(self) -> None:
lines = render_text(report()).splitlines()
self.assertIn("== worktrees", lines)
worktree = next(line for line in lines if line.endswith("/r/.worktrees/x"))
self.assertEqual(
worktree.split(),
["3.0G", "2.0d", "4", "linked", "(detached)", "/r/.worktrees/x"],
)
orphan = next(line for line in lines if "agent-1" in line)
self.assertEqual(orphan.split()[1:5], ["1.5m", "0", "orphaned", "-"])
verdict = next(line for line in lines if line.startswith("no "))
self.assertIn(
"detached HEAD reachable from no ref; a: content in no ref", verdict
)
self.assertIn("== root /tmp (over 1.0M)", lines)
rows = [line.split() for line in lines]
self.assertIn(["2.0M", "1.0G", "3.0h", "/tmp/core"], rows)
self.assertIn(["/", "100.0G", "10.0G", "3.0G"], rows)
self.assertEqual(lines[-1], "WARN docker unavailable: not installed")
self.assertNotIn("== docker", lines)
def test_docker_rows_and_empty_sections(self) -> None:
docker = [
{
"type": "Images",
"total": 3,
"active": 1,
"size": "1GB",
"reclaimable": "0B",
}
]
text = render_text(report(docker=docker, core_dumps=[], warnings=[]))
self.assertIn("== core dumps\n(none)", text)
self.assertIn("== docker", text)
self.assertIn("Images 3 1 1GB 0B", text)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,79 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pkgmgr.actions.gc.report.roots import scan_root
THRESHOLD = 16 * 1024
ET_CORE_HEADER = b"\x7fELF\x02\x01\x01" + bytes(9) + (4).to_bytes(2, "little")
class TestScanRoot(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.root = os.path.realpath(self._tmp.name)
def tearDown(self) -> None:
self._tmp.cleanup()
def _write(self, rel: str, size: int = 0, head: bytes = b"") -> str:
path = os.path.join(self.root, rel)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as fh:
fh.write(head + b"d" * size)
return path
def _big(self, rel: str, size: int = 64 * 1024) -> str:
return self._write(rel, size)
def test_children_over_threshold_get_kinds_biggest_first(self) -> None:
self._big("node_modules/pkg/index.js", 200 * 1024)
self._big("wt/data", 180 * 1024)
self._write("wt/.git", head=b"gitdir: /elsewhere\n")
self._big("clone/.git/objects/pack", 160 * 1024)
self._big("bare.git/objects/blob", 140 * 1024)
self._write("bare.git/HEAD", head=b"ref: refs/heads/main\n")
self._big("venv/lib/site.py", 120 * 1024)
self._write("venv/pyvenv.cfg", head=b"home = /usr\n")
self._big(".cache/blob", 100 * 1024)
self._big("tagged/blob", 90 * 1024)
self._write("tagged/CACHEDIR.TAG", head=b"Signature: x\n")
self._big("plain/blob", 80 * 1024)
self._write("core", 70 * 1024, head=ET_CORE_HEADER)
self._big("loose.bin", 60 * 1024)
self._write("small/file", 10)
cores: list[str] = []
scanned = scan_root(self.root, THRESHOLD, cores)
kinds = [(os.path.basename(c["path"]), c["kind"]) for c in scanned["children"]]
self.assertEqual(
kinds,
[
("node_modules", "node_modules"),
("wt", "worktree"),
("clone", "git-repo"),
("bare.git", "git-repo"),
("venv", "venv"),
(".cache", "cache"),
("tagged", "cache"),
("plain", "dir"),
("core", "core-dump"),
("loose.bin", "file"),
],
)
self.assertEqual(scanned["root"], self.root)
self.assertEqual(cores, [os.path.join(self.root, "core")])
sizes = [c["size_bytes"] for c in scanned["children"]]
self.assertEqual(sizes, sorted(sizes, reverse=True))
self.assertTrue(all(c["newest_mtime"] > 0 for c in scanned["children"]))
def test_missing_root_raises(self) -> None:
with self.assertRaises(OSError):
scan_root(os.path.join(self.root, "absent"), THRESHOLD, [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,113 @@
from __future__ import annotations
import os
import tempfile
import unittest
from unittest.mock import patch
from pkgmgr.actions.gc.report import walk as walk_module
from pkgmgr.actions.gc.report.walk import BLOCK, walk
def allocated(*paths: str) -> int:
return sum(os.lstat(path).st_blocks * BLOCK for path in paths)
class TestWalk(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.root = os.path.realpath(self._tmp.name)
def tearDown(self) -> None:
self._tmp.cleanup()
def _write(self, rel: str, data: bytes = b"x") -> str:
path = os.path.join(self.root, rel)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as fh:
fh.write(data)
return path
def test_sparse_file_counts_allocated_blocks_only(self) -> None:
path = self._write("sparse")
os.truncate(path, 1 << 30)
stats = walk(self.root)
self.assertEqual(stats.size_bytes, allocated(self.root, path))
self.assertLess(stats.size_bytes, 1 << 30)
def test_symlink_target_is_not_followed(self) -> None:
with tempfile.TemporaryDirectory() as outside:
target = os.path.join(outside, "big")
with open(target, "wb") as fh:
fh.write(b"y" * (1 << 20))
link = os.path.join(self.root, "link")
os.symlink(target, link)
stats = walk(self.root)
self.assertEqual(stats.size_bytes, allocated(self.root, link))
self.assertLess(stats.size_bytes, 1 << 20)
def test_hard_link_is_counted_once(self) -> None:
first = self._write("a", b"z" * 65536)
os.link(first, os.path.join(self.root, "b"))
self.assertEqual(walk(self.root).size_bytes, allocated(self.root, first))
def test_foreign_entries_are_pruned_below_the_first_foreign_one(self) -> None:
self._write("foreign_dir/one")
self._write("foreign_dir/sub/two")
self._write("foreign_file")
self._write("mine")
plain = walk(self.root)
with patch.object(
walk_module,
"is_foreign",
side_effect=lambda path, st: os.path.relpath(path, self.root).startswith(
"foreign"
),
):
stats = walk(self.root)
self.assertEqual(plain.foreign_entries, 0)
self.assertEqual(stats.foreign_entries, 2)
self.assertEqual(stats.size_bytes, plain.size_bytes)
def test_files_named_like_core_dumps_are_candidates(self) -> None:
expected = {self._write("core"), self._write("deep/core.123")}
self._write("core.abc")
self._write("not-core")
os.makedirs(os.path.join(self.root, "dir", "core"))
self.assertEqual(set(walk(self.root).cores), expected)
@unittest.skipIf(os.getuid() == 0, "root lists every directory")
def test_unlistable_directory_is_counted(self) -> None:
locked = os.path.join(self.root, "locked")
os.mkdir(locked)
os.chmod(locked, 0)
try:
stats = walk(self.root)
finally:
os.chmod(locked, 0o700)
self.assertEqual(stats.unreadable, 1)
def test_missing_path_reports_unreadable(self) -> None:
stats = walk(os.path.join(self.root, "absent"))
self.assertEqual((stats.size_bytes, stats.unreadable), (0, 1))
def test_other_filesystem_is_skipped(self) -> None:
self._write("file")
stats = walk(self.root, dev=os.lstat(self.root).st_dev + 1)
self.assertEqual(stats.size_bytes, 0)
if __name__ == "__main__":
unittest.main()
+15
View File
@@ -40,6 +40,21 @@ class TestGitRun(unittest.TestCase):
self.assertTrue(kwargs["text"])
self.assertTrue(kwargs["capture_output"])
def test_scan_input_text_replaces_closed_stdin(self) -> None:
completed = MagicMock()
completed.stdout = "abc\n"
with patch(
"pkgmgr.core.git.run.subprocess.run", return_value=completed
) as mock_run:
out = run(["cat-file", "--batch-check"], scan=True, input_text="x\n")
self.assertEqual(out, "abc")
kwargs = mock_run.call_args.kwargs
self.assertEqual(kwargs["input"], "x\n")
self.assertNotIn("stdin", kwargs)
self.assertIn("timeout", kwargs)
def test_failure_raises_giterror_with_details(self) -> None:
# Build a CalledProcessError with stdout/stderr populated
import subprocess as sp