diff --git a/README.md b/README.md index fd08045..5f9a46e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/pkgmgr/actions/gc/__init__.py b/src/pkgmgr/actions/gc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pkgmgr/actions/gc/report/__init__.py b/src/pkgmgr/actions/gc/report/__init__.py new file mode 100644 index 0000000..640f66b --- /dev/null +++ b/src/pkgmgr/actions/gc/report/__init__.py @@ -0,0 +1,4 @@ +from .render import render_text +from .report import disk_report + +__all__ = ["disk_report", "render_text"] diff --git a/src/pkgmgr/actions/gc/report/balance.py b/src/pkgmgr/actions/gc/report/balance.py new file mode 100644 index 0000000..7c4a9e1 --- /dev/null +++ b/src/pkgmgr/actions/gc/report/balance.py @@ -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"]) diff --git a/src/pkgmgr/actions/gc/report/core_dumps.py b/src/pkgmgr/actions/gc/report/core_dumps.py new file mode 100644 index 0000000..de5cfdf --- /dev/null +++ b/src/pkgmgr/actions/gc/report/core_dumps.py @@ -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"])) diff --git a/src/pkgmgr/actions/gc/report/docker_usage.py b/src/pkgmgr/actions/gc/report/docker_usage.py new file mode 100644 index 0000000..4eb6d67 --- /dev/null +++ b/src/pkgmgr/actions/gc/report/docker_usage.py @@ -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 diff --git a/src/pkgmgr/actions/gc/report/preserved.py b/src/pkgmgr/actions/gc/report/preserved.py new file mode 100644 index 0000000..78698cb --- /dev/null +++ b/src/pkgmgr/actions/gc/report/preserved.py @@ -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 diff --git a/src/pkgmgr/actions/gc/report/render.py b/src/pkgmgr/actions/gc/report/render.py new file mode 100644 index 0000000..a776fa4 --- /dev/null +++ b/src/pkgmgr/actions/gc/report/render.py @@ -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) diff --git a/src/pkgmgr/actions/gc/report/report.py b/src/pkgmgr/actions/gc/report/report.py new file mode 100644 index 0000000..f555437 --- /dev/null +++ b/src/pkgmgr/actions/gc/report/report.py @@ -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.` 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, + } diff --git a/src/pkgmgr/actions/gc/report/roots.py b/src/pkgmgr/actions/gc/report/roots.py new file mode 100644 index 0000000..45b2d19 --- /dev/null +++ b/src/pkgmgr/actions/gc/report/roots.py @@ -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} diff --git a/src/pkgmgr/actions/gc/report/walk.py b/src/pkgmgr/actions/gc/report/walk.py new file mode 100644 index 0000000..11c6595 --- /dev/null +++ b/src/pkgmgr/actions/gc/report/walk.py @@ -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.`. + """ + 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 diff --git a/src/pkgmgr/actions/gc/report/worktrees.py b/src/pkgmgr/actions/gc/report/worktrees.py new file mode 100644 index 0000000..1748b7a --- /dev/null +++ b/src/pkgmgr/actions/gc/report/worktrees.py @@ -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"]) diff --git a/src/pkgmgr/cli/commands/worktree.py b/src/pkgmgr/cli/commands/worktree.py index 64c9ff0..049afce 100644 --- a/src/pkgmgr/cli/commands/worktree.py +++ b/src/pkgmgr/cli/commands/worktree.py @@ -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) diff --git a/src/pkgmgr/cli/parser/gc_cmd.py b/src/pkgmgr/cli/parser/gc_cmd.py index a8e30c7..e2bb44f 100644 --- a/src/pkgmgr/cli/parser/gc_cmd.py +++ b/src/pkgmgr/cli/parser/gc_cmd.py @@ -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`", + ) diff --git a/src/pkgmgr/core/git/run.py b/src/pkgmgr/core/git/run.py index 80d0a94..1a26280 100644 --- a/src/pkgmgr/core/git/run.py +++ b/src/pkgmgr/core/git/run.py @@ -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, diff --git a/tests/integration/test_gc_report.py b/tests/integration/test_gc_report.py new file mode 100644 index 0000000..38ef99c --- /dev/null +++ b/tests/integration/test_gc_report.py @@ -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() diff --git a/tests/unit/pkgmgr/actions/gc/__init__.py b/tests/unit/pkgmgr/actions/gc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/pkgmgr/actions/gc/report/__init__.py b/tests/unit/pkgmgr/actions/gc/report/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/pkgmgr/actions/gc/report/test_balance.py b/tests/unit/pkgmgr/actions/gc/report/test_balance.py new file mode 100644 index 0000000..08aa5aa --- /dev/null +++ b/tests/unit/pkgmgr/actions/gc/report/test_balance.py @@ -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() diff --git a/tests/unit/pkgmgr/actions/gc/report/test_core_dumps.py b/tests/unit/pkgmgr/actions/gc/report/test_core_dumps.py new file mode 100644 index 0000000..0739dcd --- /dev/null +++ b/tests/unit/pkgmgr/actions/gc/report/test_core_dumps.py @@ -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() diff --git a/tests/unit/pkgmgr/actions/gc/report/test_docker_usage.py b/tests/unit/pkgmgr/actions/gc/report/test_docker_usage.py new file mode 100644 index 0000000..8ab6e0f --- /dev/null +++ b/tests/unit/pkgmgr/actions/gc/report/test_docker_usage.py @@ -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() diff --git a/tests/unit/pkgmgr/actions/gc/report/test_preserved.py b/tests/unit/pkgmgr/actions/gc/report/test_preserved.py new file mode 100644 index 0000000..2eb3e89 --- /dev/null +++ b/tests/unit/pkgmgr/actions/gc/report/test_preserved.py @@ -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() diff --git a/tests/unit/pkgmgr/actions/gc/report/test_render.py b/tests/unit/pkgmgr/actions/gc/report/test_render.py new file mode 100644 index 0000000..b5da198 --- /dev/null +++ b/tests/unit/pkgmgr/actions/gc/report/test_render.py @@ -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() diff --git a/tests/unit/pkgmgr/actions/gc/report/test_roots.py b/tests/unit/pkgmgr/actions/gc/report/test_roots.py new file mode 100644 index 0000000..1aaea04 --- /dev/null +++ b/tests/unit/pkgmgr/actions/gc/report/test_roots.py @@ -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() diff --git a/tests/unit/pkgmgr/actions/gc/report/test_walk.py b/tests/unit/pkgmgr/actions/gc/report/test_walk.py new file mode 100644 index 0000000..cc4e037 --- /dev/null +++ b/tests/unit/pkgmgr/actions/gc/report/test_walk.py @@ -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() diff --git a/tests/unit/pkgmgr/core/git/test_run.py b/tests/unit/pkgmgr/core/git/test_run.py index efd365c..a10ea6e 100644 --- a/tests/unit/pkgmgr/core/git/test_run.py +++ b/tests/unit/pkgmgr/core/git/test_run.py @@ -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