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
@@ -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