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:
1 parent
1b9b93a542
commit
c78e0a34a3
26 files changed
+1699
-2
No files matched your search
@@ -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
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,4 @@
|
||||
from .render import render_text
|
||||
from .report import disk_report
|
||||
|
||||
__all__ = ["disk_report", "render_text"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
@@ -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`",
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user