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

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,