feat(branch): add pkgmgr branch tree, a hardened meta-tree of all repos

pkgmgr branch tree --format json|yaml --output FILE scans every known repository with gc's own classification (plan_repo with fetch=False) through the hardened git runner and writes one document atomically (flock, mkstemp, fsync, os.replace): per repo path, remote, default, missing, forge status, scanned_at, warnings and every branch and worktree item, plus orphan shared worktrees under ~/.local/share/worktrees. Forge facts take the token only from an unlocked Secret Service item, never unlocking, and cache per repository for 600 s. open, close, drop and gc --apply/purge refresh only their repository's entry and never overwrite a newer scan.

Scan hardening for repositories an agent may write: log.showSignature=false joins the scan flags; status is skipped for local or worktree filter.* config and for worktrees with gitlinks; --end-of-options precedes every ref argument and -- every remote, branch and du path, and worktree list runs with -z. Before, a remote named --output=<path> made rev-list create that file on the host, and a worktree path with blank lines forged a record.

MEDUSA acceptances (kvb-think-criticize 2026-11-07, 1 skeptic): EXA004 cli/parser/branch_cmd.py:68 by design (-f/--force help text on an operator CLI); B603 actions/branch/gc.py:146 false positive (docker argv list, no shell); B603 actions/branch/gc.py:311 fixed (du gets --, paths from worktree list -z); B603 core/git/run.py:83 by design (argv list, scan callers guard names).

🎯 Fix: 92 %
💥 Break: 4 %
💸 Cost: 104 %
⏱️ Time: 108 %
🛡️ Security: 100 %
🏆 Score: 79
🚦 Gate: ⚠️ cost 104 % > 100, time 108 % > 100: ask the operator before the commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-07 06:35:50 +00:00
1 parent c2f0a56699
commit 54f0cbb37b
14 files changed
+1226 -88

No files matched your search

+3
View File
@@ -6,11 +6,14 @@ from .close_branch import close_branch
from .drop_branch import drop_branch
from .gc import branch_gc, branch_purge
from .open_branch import open_branch
from .tree import branch_tree, refresh_tree_entry
__all__ = [
"branch_gc",
"branch_purge",
"branch_tree",
"close_branch",
"drop_branch",
"open_branch",
"refresh_tree_entry",
]
+193 -76
View File
@@ -17,12 +17,25 @@ from pkgmgr.core.git.run import run
from .gc_forge import ForgeFacts, fetch_forge_facts
Snapshot = tuple[
dict[str, str],
dict[str, dict[str, str]],
dict[int, str],
dict[str, tuple[str | None, int | None, int | None, bool]],
]
# PROC_PID_INIT_INO: only the initial PID namespace sees every process of the host.
INIT_PID_NAMESPACE = "pid:[4026531836]"
CLEANUP_IMAGE = "alpine:3.20"
COMPOSE_PROJECT = "com.docker.compose.project"
COMPOSE_WORKING_DIR = "com.docker.compose.project.working_dir"
LOCK_PID = re.compile(r"\bpid (\d+)")
TRACK = re.compile(r"(ahead|behind) (\d+)")
UNSAFE_LOCAL_CONFIG = re.compile(r"^(local|worktree)\t(filter\.\S+)")
GITLINK = "160000"
# ref and remote names come from repositories an agent may write: one named
# like --output=<path> would otherwise make rev-list create that file
END_OF_OPTIONS = "--end-of-options"
ALWAYS_PROTECTED = ("main", "master")
LABELS = {
"merged-clean": "DELETE",
@@ -58,6 +71,17 @@ class Container:
class Item:
branch: str | None
worktree: str | None = None
head: str | None = None
detached: bool = False
locked: str | None = None
worktree_state: str | None = None
staged: int | None = None
unstaged: int | None = None
untracked: int | None = None
upstream: str | None = None
ahead: int | None = None
behind: int | None = None
gone: bool = False
local_sha: str | None = None
remotes: dict[str, str] = field(default_factory=dict)
compose_projects: list[str] = field(default_factory=list)
@@ -88,6 +112,7 @@ class Repo:
remotes: list[str]
protected: set[str]
first_parent: set[str]
urls: dict[str, str]
forge: ForgeFacts
containers: list[Container] | None
host_ns: bool
@@ -109,8 +134,12 @@ def main_repo_root(cwd: str) -> str:
return os.path.dirname(_common_dir(cwd))
def _git(args: list[str], cwd: str) -> str:
return run(args, cwd=cwd, scan=True)
def _common_dir(cwd: str) -> str:
return run(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd=cwd)
return _git(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd)
def _docker(args: list[str]) -> str:
@@ -151,52 +180,71 @@ def _inside(path: str, root: str) -> bool:
def _worktrees(repo_root: str) -> list[Worktree]:
trees: list[Worktree] = []
for block in run(["worktree", "list", "--porcelain"], cwd=repo_root).split("\n\n"):
fields: dict[str, str] = {}
for line in block.splitlines():
key, _, value = line.partition(" ")
fields: dict[str, str] = {}
for part in _git(["worktree", "list", "--porcelain", "-z"], repo_root).split("\0"):
if part:
key, _, value = part.partition(" ")
fields[key] = value
if "worktree" not in fields:
continue
branch = fields.get("branch")
record, fields = fields, {}
if "worktree" not in record:
continue
branch = record.get("branch")
trees.append(
Worktree(
path=fields["worktree"],
head=fields.get("HEAD"),
path=record["worktree"],
head=record.get("HEAD"),
branch=branch.removeprefix("refs/heads/") if branch else None,
locked=fields.get("locked"),
prunable="prunable" in fields,
locked=record.get("locked"),
prunable="prunable" in record,
main=not trees,
)
)
return trees
def _tracking(
upstream: str, track: str
) -> tuple[str | None, int | None, int | None, bool]:
if not upstream:
return None, None, None, False
if track == "gone":
return upstream, None, None, True
counts = dict(TRACK.findall(track))
return upstream, int(counts.get("ahead", 0)), int(counts.get("behind", 0)), False
def _refs(
repo_root: str, remotes: list[str]
) -> tuple[dict[str, str], dict[str, dict[str, str]]]:
out = run(
) -> tuple[
dict[str, str],
dict[str, dict[str, str]],
dict[str, tuple[str | None, int | None, int | None, bool]],
]:
out = _git(
[
"for-each-ref",
"--format=%(refname) %(objectname)",
"--format=%(refname)%00%(objectname)%00%(upstream:short)%00%(upstream:track,nobracket)",
"refs/heads",
"refs/remotes",
],
cwd=repo_root,
repo_root,
)
by_length = sorted(remotes, key=len, reverse=True)
local: dict[str, str] = {}
remote: dict[str, dict[str, str]] = {}
tracking: dict[str, tuple[str | None, int | None, int | None, bool]] = {}
for line in out.splitlines():
ref, sha = line.split(" ")
ref, sha, upstream, track = line.split("\x00")
if ref.startswith("refs/heads/"):
local[ref.removeprefix("refs/heads/")] = sha
tracking[ref.removeprefix("refs/heads/")] = _tracking(upstream, track)
continue
rest = ref.removeprefix("refs/remotes/")
owner = next((r for r in by_length if rest.startswith(r + "/")), None)
if owner and rest[len(owner) + 1 :] != "HEAD":
remote.setdefault(rest[len(owner) + 1 :], {})[owner] = sha
return local, remote
return local, remote, tracking
def _process_cwds() -> dict[int, str]:
@@ -232,7 +280,7 @@ def _lock_skip(reason: str | None, host_ns: bool) -> str | None:
def _is_ancestor(sha: str, ref: str, repo_root: str) -> bool:
try:
run(["merge-base", "--is-ancestor", sha, ref], cwd=repo_root)
_git(["merge-base", "--is-ancestor", END_OF_OPTIONS, sha, ref], repo_root)
except GitRunError as exc:
if exc.returncode == 1:
return False
@@ -261,15 +309,13 @@ def _merged_by(repo: Repo, sha: str) -> str | None:
def _du_bytes(path: str) -> int | None:
out = subprocess.run(
["du", "-sk", path], check=False, capture_output=True, text=True
["du", "-sk", "--", path], check=False, capture_output=True, text=True
).stdout.split()
return int(out[0]) * 1024 if out and out[0].isdigit() else None
def _age_hours(repo: Repo, shas: list[str], tree: Worktree | None) -> float:
stamps = [
int(run(["log", "-1", "--format=%ct", sha], cwd=repo.repo)) for sha in shas
]
stamps = [int(_git(["log", "-1", "--format=%ct", sha], repo.repo)) for sha in shas]
if tree and os.path.isfile(os.path.join(tree.path, ".git")):
with open(os.path.join(tree.path, ".git"), encoding="utf-8") as fh:
index = os.path.join(fh.read().strip().removeprefix("gitdir: "), "index")
@@ -316,19 +362,83 @@ def _skip(
return None
def _unsafe_config(path: str) -> list[str]:
try:
out = _git(["config", "--show-scope", "--get-regexp", r"^filter\."], path)
except GitRunError as exc:
if exc.returncode == 1:
return []
raise
return [m.group(2) for m in map(UNSAFE_LOCAL_CONFIG.match, out.splitlines()) if m]
def _has_submodules(path: str) -> bool:
return any(
line.startswith(f"{GITLINK} ")
for line in _git(["ls-files", "--stage"], path).splitlines()
)
def _status(path: str) -> tuple[int, int, int, int]:
lines = _git(
["status", "--porcelain=v2", "--untracked-files=all"], path
).splitlines()
codes = [line.split(" ", 2)[1] for line in lines if line[:1] in "12u"]
return (
len(lines),
sum(code[0] != "." for code in codes),
sum(code[1] != "." for code in codes),
sum(line.startswith("? ") for line in lines),
)
def _read_worktree(item: Item, checkout: bool) -> tuple[int, str | None]:
if item.worktree is None:
return 0, None
if not checkout:
item.worktree_state = "missing"
return 0, None
try:
if _has_submodules(item.worktree):
item.worktree_state = "changed"
return -1, "status skipped: submodules, whose config the scan cannot vet"
unsafe = _unsafe_config(item.worktree)
if unsafe:
item.worktree_state = "changed"
return -1, f"status skipped: {', '.join(unsafe)} in the repository config"
dirty, item.staged, item.unstaged, item.untracked = _status(item.worktree)
except GitRunError as exc:
item.worktree_state = "changed"
return -1, f"status failed: {_describe(exc)}"
item.worktree_state = "changed" if dirty else "clean"
return dirty, None
def _build(
repo: Repo,
branch: str | None,
tree: Worktree | None,
snapshot: tuple[dict[str, str], dict[str, dict[str, str]], dict[int, str]],
snapshot: Snapshot,
) -> Item:
local, remote, cwds = snapshot
local, remote, cwds, tracking = snapshot
upstream, ahead, behind, gone = tracking.get(
branch or "", (None, None, None, False)
)
item = Item(
branch=branch,
worktree=tree.path if tree else None,
head=tree.head if tree else None,
detached=tree is not None and branch is None,
locked=tree.locked if tree else None,
upstream=upstream,
ahead=ahead,
behind=behind,
gone=gone,
local_sha=local.get(branch) if branch else None,
remotes=dict(remote.get(branch, {})) if branch else {},
)
checkout = tree is not None and not tree.prunable and os.path.isdir(tree.path)
dirty, unread = _read_worktree(item, checkout)
if branch in repo.protected:
item.state, item.reason = "protected", "default or protected branch"
return item
@@ -341,37 +451,30 @@ def _build(
shas = [item.local_sha, *item.remotes.values(), tree.head if tree else None]
verdicts = {sha: _merged_by(repo, sha) for sha in dict.fromkeys(shas) if sha}
unmerged = [sha for sha, verdict in verdicts.items() if verdict is None]
checkout = tree is not None and not tree.prunable and os.path.isdir(tree.path)
try:
dirty = (
len(
run(
["status", "--porcelain", "--untracked-files=all"], cwd=tree.path
).splitlines()
)
if checkout and not tree.main
else 0
)
except GitRunError as exc:
item.state, item.reason = "dirty", f"status failed: {_describe(exc)}"
dirty = -1
if dirty > 0:
if unread:
item.state, item.reason = "dirty", unread
elif dirty > 0:
item.state, item.reason = "dirty", f"dirty: {dirty} paths"
elif dirty == 0 and unmerged:
ahead = int(
run(
["rev-list", "--count", f"{repo.default}..{unmerged[0]}"], cwd=repo.repo
elif unmerged:
own = int(
_git(
[
"rev-list",
"--count",
END_OF_OPTIONS,
f"{repo.default}..{unmerged[0]}",
],
repo.repo,
)
)
item.state = "unmerged"
item.reason = (
f"unmerged: {ahead} commits not in {repo.default}"
if ahead
f"unmerged: {own} commits not in {repo.default}"
if own
else "no own commits"
)
elif dirty == 0:
else:
item.state = "merged-clean"
item.merged_by = next(
(verdict for verdict in verdicts.values() if verdict != "ancestor"),
@@ -401,19 +504,15 @@ def _build(
return item
def _snapshot(
repo: Repo,
) -> tuple[
list[Worktree], tuple[dict[str, str], dict[str, dict[str, str]], dict[int, str]]
]:
local, remote = _refs(repo.repo, repo.remotes)
def _snapshot(repo: Repo) -> tuple[list[Worktree], Snapshot]:
local, remote, tracking = _refs(repo.repo, repo.remotes)
cwds = _process_cwds() if repo.host_ns else {}
return _worktrees(repo.repo), (local, remote, cwds)
return _worktrees(repo.repo), (local, remote, cwds, tracking)
def _items(repo: Repo) -> list[Item]:
trees, snapshot = _snapshot(repo)
local, remote, _ = snapshot
local, remote, _, _ = snapshot
by_branch = {t.branch: t for t in trees if t.branch}
names = sorted(set(local) | set(remote) | set(by_branch))
items = [_build(repo, name, by_branch.get(name), snapshot) for name in names]
@@ -450,14 +549,23 @@ def _lines(
names: set[str] = set()
for ref in refs:
try:
tip = run(
["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], cwd=repo_root
tip = _git(
[
"rev-parse",
"--verify",
"--quiet",
END_OF_OPTIONS,
f"{ref}^{{commit}}",
],
repo_root,
)
except GitRunError:
warnings.append(f"merged-into ref {ref} not found")
continue
first_parent = set(
run(["rev-list", "--first-parent", ref], cwd=repo_root).splitlines()
_git(
["rev-list", "--first-parent", END_OF_OPTIONS, ref], repo_root
).splitlines()
)
lines[ref] = (tip, first_parent)
owner = next((r for r in remotes if ref.startswith(r + "/")), None)
@@ -473,6 +581,8 @@ def plan_repo(
merged_into: list[str],
sizes: bool,
min_age: float | None,
fetch: bool = True,
forge_source: Callable[[dict[str, str]], ForgeFacts] | None = None,
) -> Repo:
"""
Scan one repository and classify every branch and worktree.
@@ -484,41 +594,44 @@ def plan_repo(
merged_into: integration lines (refs) that also count as merged; each is protected.
sizes: measure each worktree's disk usage into size_bytes.
min_age: skip items whose newest commit or worktree index is younger (hours).
fetch: fetch every remote first; the tree scan reads the known refs only.
forge_source: remote name -> URL to forge facts; fetch_forge_facts by default.
Returns:
The repository with its classified items.
"""
warnings: list[str] = []
try:
# Auto-maintenance runs worktree-prune, which deletes the metadata of
# sandboxed worktrees whose gitdir stub is missing.
run(
["fetch", "--all", "--prune", "--quiet", "--no-auto-maintenance"],
cwd=repo_root,
)
except GitRunError as exc:
warnings.append(f"fetch failed: {_describe(exc)}")
if fetch:
try:
# Auto-maintenance runs worktree-prune, which deletes the metadata of
# sandboxed worktrees whose gitdir stub is missing.
run(
["fetch", "--all", "--prune", "--quiet", "--no-auto-maintenance"],
cwd=repo_root,
)
except GitRunError as exc:
warnings.append(f"fetch failed: {_describe(exc)}")
if containers is None:
warnings.append("docker unavailable: worktree items are skipped")
remotes = sorted(list_remotes(cwd=repo_root), key=lambda r: (r != "origin", r))
remotes = sorted(
list_remotes(cwd=repo_root, scan=True), key=lambda r: (r != "origin", r)
)
heads: dict[str, str] = {}
for remote in remotes:
try:
heads[remote] = run(
["symbolic-ref", "--short", f"refs/remotes/{remote}/HEAD"],
cwd=repo_root,
heads[remote] = _git(
["symbolic-ref", "--short", f"refs/remotes/{remote}/HEAD"], repo_root
)
except GitRunError:
continue
default = next(iter(heads.values()), None) or resolve_base_branch(
"main", "master", cwd=repo_root
"main", "master", cwd=repo_root, scan=True
)
head_names = {ref[len(remote) + 1 :] for remote, ref in heads.items()}
forge = fetch_forge_facts(
{r: run(["remote", "get-url", r], cwd=repo_root) for r in remotes}
)
urls = {r: _git(["remote", "get-url", "--", r], repo_root) for r in remotes}
forge = (forge_source or fetch_forge_facts)(urls)
if forge.unavailable:
warnings.append(f"forge unavailable: {forge.unavailable}")
lines, line_names = _lines(repo_root, merged_into, remotes, warnings)
@@ -535,8 +648,11 @@ def plan_repo(
*line_names,
},
first_parent=set(
run(["rev-list", "--first-parent", default], cwd=repo_root).splitlines()
_git(
["rev-list", "--first-parent", END_OF_OPTIONS, default], repo_root
).splitlines()
),
urls=urls,
forge=forge,
containers=containers,
host_ns=host_ns,
@@ -637,6 +753,7 @@ def execute(repo: Repo, item: Item) -> None:
[
"push",
f"--force-with-lease=refs/heads/{item.branch}:{sha}",
"--",
remote,
f":refs/heads/{item.branch}",
],
+14
View File
@@ -79,7 +79,21 @@ def fetch_forge_facts(remote_urls: dict[str, str]) -> ForgeFacts:
except CredentialError as exc:
facts.unavailable = f"no GitHub token ({exc})"
return facts
return facts_with_token(slugs, token)
def facts_with_token(slugs: list[tuple[str, str]], token: str) -> ForgeFacts:
"""
Fetch the pull-request and protection facts of GitHub repositories.
Args:
slugs: (owner, name) of each repository.
token: the GitHub token sent as bearer.
Returns:
The facts, or facts whose unavailable names the failing repository.
"""
facts = ForgeFacts()
http = HttpClient(timeout_s=30)
headers = {
"Authorization": f"Bearer {token}",
+342
View File
@@ -0,0 +1,342 @@
from __future__ import annotations
import fcntl
import json
import os
import re
import sys
import tempfile
import time
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import asdict
from datetime import datetime, timezone
import yaml
from pkgmgr.core.credentials.store_keys import build_keyring_key
from pkgmgr.core.git.errors import GitBaseError
from . import gc
from .gc_forge import GITHUB_HOST, ForgeFacts, facts_with_token, github_slugs
WORKTREES_ROOT = os.path.join(
os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share"),
"worktrees",
)
WORKTREE_DEPTH = 6
CACHE_TTL = 600
SLUG = re.compile(r"^(?!\.\.?$)[\w.-]+$")
def now() -> float:
return time.time()
def _iso(stamp: float) -> str:
return datetime.fromtimestamp(stamp, timezone.utc).isoformat(timespec="seconds")
def tree_file() -> str:
state = os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state")
return os.environ.get("PKGMGR_TREE_FILE") or os.path.join(
state, "pkgmgr", "tree", "branch-tree.json"
)
def _cache_path(owner: str, name: str) -> str:
base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
return os.path.join(base, "pkgmgr", "forge", owner, f"{name}.json")
def secret_service_token() -> tuple[str | None, str | None]:
"""
Read the GitHub token from the Secret Service without unlocking it.
Returns:
(token, None), or (None, why no token is available).
"""
try:
import secretstorage
except ImportError:
return None, "secretstorage not installed"
key = build_keyring_key("github", GITHUB_HOST, None)
try:
connection = secretstorage.dbus_init()
try:
items = list(
secretstorage.search_items(
connection, {"service": key.service, "username": key.username}
)
)
unlocked = [item for item in items if not item.is_locked()]
if not items:
return None, "no GitHub token in the Secret Service"
if not unlocked:
return None, "Secret Service locked"
return unlocked[0].get_secret().decode(), None
finally:
connection.close()
except (secretstorage.exceptions.SecretStorageException, OSError) as exc:
return None, f"Secret Service unavailable: {exc}"
@contextmanager
def _locked(path: str) -> Iterator[None]:
folder, name = os.path.split(os.path.abspath(path))
os.makedirs(folder, exist_ok=True)
with open(os.path.join(folder, f".{name}.lock"), "w", encoding="utf-8") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
yield
def _replace(path: str, text: str) -> None:
folder, name = os.path.split(os.path.abspath(path))
fd, tmp = tempfile.mkstemp(dir=folder, prefix=f".{name}.")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(text)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
def _write(path: str, text: str) -> None:
with _locked(path):
_replace(path, text)
def _cached(path: str) -> ForgeFacts | None:
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
if now() - data["fetched_at"] >= CACHE_TTL:
return None
return ForgeFacts(
merged_heads=data["merged_heads"],
open_heads=data["open_heads"],
protected=set(data["protected"]),
)
except (OSError, ValueError, KeyError, TypeError):
return None
def _store(path: str, facts: ForgeFacts) -> None:
data = {
"fetched_at": now(),
"merged_heads": facts.merged_heads,
"open_heads": facts.open_heads,
"protected": sorted(facts.protected),
}
_write(path, json.dumps(data))
def _unavailable(reason: str | None) -> tuple[ForgeFacts, str]:
return ForgeFacts(unavailable=reason), f"unavailable: {reason}"
def forge_facts(
urls: dict[str, str], token: str | None, reason: str | None
) -> tuple[ForgeFacts, str]:
"""
Forge facts of one repository from the cache, or live once the cache is stale.
Args:
urls: remote name -> URL.
token: GitHub token from the Secret Service, or None.
reason: why the token is None.
Returns:
The facts and their source: none, cached, live or unavailable: <why>.
"""
slugs = github_slugs(urls)
if not slugs:
return ForgeFacts(), "none"
invalid = [f"{o}/{n}" for o, n in slugs if not (SLUG.match(o) and SLUG.match(n))]
if invalid:
return _unavailable(f"invalid GitHub slug {invalid[0]}")
merged, label = ForgeFacts(), "cached"
for owner, name in slugs:
path = _cache_path(owner, name)
facts = _cached(path)
if facts is None:
if token is None:
return _unavailable(reason)
facts = facts_with_token([(owner, name)], token)
if facts.unavailable:
return _unavailable(facts.unavailable)
try:
_store(path, facts)
label = "live" if label == "cached" else label
except OSError as exc:
label = f"live: cache not written ({exc})"
merged.merged_heads.update(facts.merged_heads)
merged.open_heads.update(facts.open_heads)
merged.protected |= facts.protected
return merged, label
def _missing(root: str, known: bool, scanned_at: str) -> dict:
return {
"path": root,
"remote": None,
"default": None,
"missing": True,
"known": known,
"forge": None,
"scanned_at": scanned_at,
"warnings": [],
"items": [],
}
def _scan(
root: str,
known: bool,
token: str | None,
reason: str | None,
containers: list[gc.Container] | None,
host_ns: bool,
) -> dict:
scanned_at = _iso(now())
entry = _missing(root, known, scanned_at)
if not os.path.exists(os.path.join(root, ".git")):
return entry
sources: list[str] = []
def source(urls: dict[str, str]) -> ForgeFacts:
facts, label = forge_facts(urls, token, reason)
sources.append(label)
return facts
entry["missing"] = False
try:
repo = gc.plan_repo(
root,
containers,
host_ns,
merged_into=[],
sizes=False,
min_age=None,
fetch=False,
forge_source=source,
)
except GitBaseError as exc:
entry["warnings"] = [f"scan failed: {str(exc).strip().splitlines()[0]}"]
return entry
owner = next((r for r in repo.remotes if repo.default.startswith(f"{r}/")), None)
entry.update(
remote=repo.urls[repo.remotes[0]] if repo.remotes else None,
default=repo.default.removeprefix(f"{owner}/") if owner else repo.default,
forge=sources[0],
warnings=repo.warnings,
items=[asdict(item) for item in repo.items],
)
return entry
def _owner_root(checkout: str) -> str | None:
try:
with open(os.path.join(checkout, ".git"), encoding="utf-8") as fh:
gitdir = fh.read().strip().removeprefix("gitdir: ")
except OSError:
return None
parent = os.path.dirname(os.path.realpath(os.path.join(checkout, gitdir)))
if os.path.basename(parent) != "worktrees":
return None
common = os.path.dirname(parent)
return os.path.dirname(common) if os.path.basename(common) == ".git" else common
def _shared_owners(worktrees_root: str) -> list[str]:
owners: set[str] = set()
base = worktrees_root.rstrip("/").count("/")
for path, dirs, files in os.walk(worktrees_root):
dirs.sort()
owner = _owner_root(path) if ".git" in files else None
if owner:
owners.add(owner)
if (
".git" in files
or ".git" in dirs
or path.count("/") - base >= WORKTREE_DEPTH
):
dirs.clear()
return sorted(owners)
def branch_tree(
repo_dirs: list[str], *, fmt: str, output: str | None, worktrees_root: str
) -> int:
"""
Print or write the branch and worktree state of every repository, without fetching.
Args:
repo_dirs: directories of the repositories pkgmgr knows.
fmt: json or yaml.
output: file to write atomically; None prints to stdout.
worktrees_root: shared worktree root whose unknown owners are added.
Returns:
0 on success, 1 when the output could not be written.
"""
known = list(dict.fromkeys(os.path.realpath(d) for d in repo_dirs))
orphans = [d for d in _shared_owners(worktrees_root) if d not in known]
token, reason = secret_service_token()
host_ns = gc.host_pid_namespace()
containers = gc.list_compose_containers()
data = {
"generated_at": _iso(now()),
"host_pid_namespace": host_ns,
"delete_ready": token is not None,
"delete_reason": reason,
"repos": [
_scan(root, root in known, token, reason, containers, host_ns)
for root in known + orphans
],
}
text = (
yaml.safe_dump(data, sort_keys=False)
if fmt == "yaml"
else json.dumps(data, indent=2) + "\n"
)
if output is None:
sys.stdout.write(text)
return 0
try:
_write(output, text)
except OSError as exc:
print(f"Cannot write {output}: {exc}", file=sys.stderr)
return 1
return 0
def refresh_tree_entry(repo_root: str) -> None:
"""
Rescan one repository into the tree file, if the file exists and holds it.
Args:
repo_root: main worktree root of the repository.
Skipped outside the host PID namespace and when the file's entry is newer.
"""
path = tree_file()
if not os.path.isfile(path) or not gc.host_pid_namespace():
return
root = os.path.realpath(repo_root)
token, reason = secret_service_token()
fresh = _scan(root, True, token, reason, gc.list_compose_containers(), True)
with _locked(path):
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
index = next(
(i for i, entry in enumerate(data["repos"]) if entry["path"] == root), None
)
if index is None or data["repos"][index]["scanned_at"] > fresh["scanned_at"]:
return
fresh["known"] = data["repos"][index]["known"]
data["repos"][index] = fresh
_replace(path, json.dumps(data, indent=2) + "\n")
+35 -2
View File
@@ -9,6 +9,7 @@ from pkgmgr.actions.branch import (
close_branch,
drop_branch,
open_branch,
tree,
)
from pkgmgr.actions.branch.gc import main_repo_root
from pkgmgr.cli.context import CLIContext
@@ -16,11 +17,27 @@ from pkgmgr.core.git.errors import GitBaseError
from pkgmgr.core.repository.dir import get_repo_dir
def _known_dirs(ctx: CLIContext) -> list[str]:
return [
os.path.expanduser(str(r["directory"]))
if r.get("directory")
else get_repo_dir(ctx.repositories_base_dir, r)
for r in ctx.all_repositories
]
def _gc_repo_roots(args, ctx: CLIContext) -> list[str]:
if not args.all:
return [main_repo_root(".")]
dirs = [get_repo_dir(ctx.repositories_base_dir, r) for r in ctx.all_repositories]
return [d for d in dirs if os.path.exists(os.path.join(d, ".git"))]
return [d for d in _known_dirs(ctx) if os.path.exists(os.path.join(d, ".git"))]
def _refresh(roots: list[str]) -> None:
for root in roots:
try:
tree.refresh_tree_entry(root)
except (OSError, ValueError, KeyError, GitBaseError) as exc:
print(f"WARN tree file not rewritten for {root}: {exc}", file=sys.stderr)
def handle_branch(args, ctx: CLIContext) -> None:
@@ -35,7 +52,18 @@ def handle_branch(args, ctx: CLIContext) -> None:
[--merged-into REF]... [--sizes] [--min-age HOURS]
- pkgmgr branch purge [--all] [--branch N]... [--exclude N]...
[--merged-into REF]... [--sizes] [--min-age HOURS]
- pkgmgr branch tree [--format json|yaml] [--output FILE]
"""
if args.subcommand == "tree":
sys.exit(
tree.branch_tree(
_known_dirs(ctx),
fmt=args.format,
output=args.output,
worktrees_root=tree.WORKTREES_ROOT,
)
)
if args.subcommand in ("gc", "purge"):
try:
roots = _gc_repo_roots(args, ctx)
@@ -53,6 +81,8 @@ def handle_branch(args, ctx: CLIContext) -> None:
code = branch_gc(roots, apply=args.apply, as_json=args.json, **options)
else:
code = branch_purge(roots, **options)
if args.subcommand == "purge" or args.apply:
_refresh(roots)
sys.exit(code)
if args.subcommand == "open":
@@ -61,6 +91,7 @@ def handle_branch(args, ctx: CLIContext) -> None:
base_branch=getattr(args, "base", "main"),
cwd=".",
)
_refresh([main_repo_root(".")])
return
if args.subcommand == "close":
@@ -70,6 +101,7 @@ def handle_branch(args, ctx: CLIContext) -> None:
cwd=".",
force=getattr(args, "force", False),
)
_refresh([main_repo_root(".")])
return
if args.subcommand == "drop":
@@ -79,6 +111,7 @@ def handle_branch(args, ctx: CLIContext) -> None:
cwd=".",
force=getattr(args, "force", False),
)
_refresh([main_repo_root(".")])
return
print(f"Unknown branch subcommand: {args.subcommand}")
+19
View File
@@ -115,6 +115,25 @@ def add_branch_subparsers(
help="Print the plan as JSON",
)
branch_tree = branch_subparsers.add_parser(
"tree",
help=(
"Print the branches and worktrees of every pkgmgr-managed repository "
"with their gc state, without fetching"
),
)
branch_tree.add_argument(
"--format",
choices=("json", "yaml"),
default="json",
help="Output format (default: json)",
)
branch_tree.add_argument(
"--output",
metavar="FILE",
help="Write atomically to FILE instead of stdout",
)
branch_purge = branch_subparsers.add_parser(
"purge",
help="Delete unmerged or dirty branches after a confirmation per item",
@@ -11,7 +11,7 @@ class GitDeleteLocalBranchError(GitCommandError):
def delete_local_branch(branch: str, cwd: str = ".", force: bool = False) -> None:
flag = "-D" if force else "-d"
try:
run(["branch", flag, branch], cwd=cwd)
run(["branch", flag, "--", branch], cwd=cwd)
except GitRunError as exc:
raise GitDeleteLocalBranchError(
f"Failed to delete local branch {branch!r} (flag {flag}).",
+3 -3
View File
@@ -3,13 +3,13 @@ from __future__ import annotations
from ..run import run
def list_remotes(cwd: str = ".") -> list[str]:
def list_remotes(cwd: str = ".", scan: bool = False) -> list[str]:
"""
Return a list of configured git remotes (e.g. ['origin', 'upstream']).
Raises GitBaseError if the command fails.
scan=True runs git hardened. Raises GitBaseError if the command fails.
"""
output = run(["remote"], cwd=cwd)
output = run(["remote"], cwd=cwd, scan=scan)
if not output:
return []
return [line.strip() for line in output.splitlines() if line.strip()]
@@ -37,12 +37,13 @@ def resolve_base_branch(
preferred: str = "main",
fallback: str = "master",
cwd: str = ".",
scan: bool = False,
) -> str:
"""
Resolve the base branch to use.
Try `preferred` first (default: main),
fall back to `fallback` (default: master).
fall back to `fallback` (default: master); scan=True runs git hardened.
Raises GitBaseBranchNotFoundError if neither exists.
Raises GitRunError for other git failures (e.g., not a git repository).
@@ -51,7 +52,7 @@ def resolve_base_branch(
for candidate in (preferred, fallback):
try:
run(["rev-parse", "--verify", candidate], cwd=cwd)
run(["rev-parse", "--verify", candidate], cwd=cwd, scan=scan)
return candidate
except GitRunError as exc:
if _is_branch_missing_error(exc):
+58 -3
View File
@@ -1,35 +1,84 @@
from __future__ import annotations
import os
import subprocess
from .errors import GitNotRepositoryError, GitRunError
SCAN_FLAGS = (
"-c",
"core.fsmonitor=false",
"-c",
"core.hooksPath=/dev/null",
"-c",
"core.pager=cat",
"-c",
"gc.auto=0",
"-c",
"maintenance.auto=false",
"-c",
"log.showSignature=false",
)
SCAN_ENV = {
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_NO_LAZY_FETCH": "1",
"GIT_OPTIONAL_LOCKS": "0",
"GIT_TERMINAL_PROMPT": "0",
"GIT_ASKPASS": "/bin/false",
"LC_ALL": "C",
}
SCAN_UNSET = (
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_EXTERNAL_DIFF",
"GIT_PAGER",
"PAGER",
)
SCAN_TIMEOUT = 60
def _is_not_repo_error(stderr: str) -> bool:
msg = (stderr or "").lower()
return "not a git repository" in msg
def _scan_env() -> dict[str, str]:
env = {key: value for key, value in os.environ.items() if key not in SCAN_UNSET}
return {**env, **SCAN_ENV}
def run(
args: list[str],
*,
cwd: str = ".",
preview: bool = False,
scan: bool = False,
) -> str:
"""
Run a Git command and return its stdout as a stripped string.
If preview=True, the command is printed but NOT executed.
Args:
args: the git arguments.
cwd: directory to run in.
preview: print the command instead of running it.
scan: read a repository an agent may have written: no fsmonitor, hook,
pager, lazy fetch, system config or prompt, stdin closed, a timeout.
Raises GitRunError (or a subclass) if execution fails.
Raises GitRunError (or a subclass) if execution fails or times out.
"""
cmd = ["git"] + args
cmd = ["git", *SCAN_FLAGS, *args] if scan else ["git"] + args
cmd_str = " ".join(cmd)
if preview:
print(f"[PREVIEW] Would run in {cwd!r}: {cmd_str}")
return ""
hardened = (
{"env": _scan_env(), "stdin": subprocess.DEVNULL, "timeout": SCAN_TIMEOUT}
if scan
else {}
)
try:
result = subprocess.run(
cmd,
@@ -37,7 +86,13 @@ def run(
check=True,
capture_output=True,
text=True,
**hardened,
)
except subprocess.TimeoutExpired as exc:
err = GitRunError(f"Git command timed out in {cwd!r}: {cmd_str}")
err.cwd, err.cmd, err.cmd_str, err.returncode = cwd, cmd, cmd_str, -1
err.stdout, err.stderr = "", ""
raise err from exc
except subprocess.CalledProcessError as exc:
stderr = exc.stderr or ""
stdout = exc.stdout or ""