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
+1216 -78

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 .drop_branch import drop_branch
from .gc import branch_gc, branch_purge from .gc import branch_gc, branch_purge
from .open_branch import open_branch from .open_branch import open_branch
from .tree import branch_tree, refresh_tree_entry
__all__ = [ __all__ = [
"branch_gc", "branch_gc",
"branch_purge", "branch_purge",
"branch_tree",
"close_branch", "close_branch",
"drop_branch", "drop_branch",
"open_branch", "open_branch",
"refresh_tree_entry",
] ]
+183 -66
View File
@@ -17,12 +17,25 @@ from pkgmgr.core.git.run import run
from .gc_forge import ForgeFacts, fetch_forge_facts 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. # PROC_PID_INIT_INO: only the initial PID namespace sees every process of the host.
INIT_PID_NAMESPACE = "pid:[4026531836]" INIT_PID_NAMESPACE = "pid:[4026531836]"
CLEANUP_IMAGE = "alpine:3.20" CLEANUP_IMAGE = "alpine:3.20"
COMPOSE_PROJECT = "com.docker.compose.project" COMPOSE_PROJECT = "com.docker.compose.project"
COMPOSE_WORKING_DIR = "com.docker.compose.project.working_dir" COMPOSE_WORKING_DIR = "com.docker.compose.project.working_dir"
LOCK_PID = re.compile(r"\bpid (\d+)") 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") ALWAYS_PROTECTED = ("main", "master")
LABELS = { LABELS = {
"merged-clean": "DELETE", "merged-clean": "DELETE",
@@ -58,6 +71,17 @@ class Container:
class Item: class Item:
branch: str | None branch: str | None
worktree: str | None = 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 local_sha: str | None = None
remotes: dict[str, str] = field(default_factory=dict) remotes: dict[str, str] = field(default_factory=dict)
compose_projects: list[str] = field(default_factory=list) compose_projects: list[str] = field(default_factory=list)
@@ -88,6 +112,7 @@ class Repo:
remotes: list[str] remotes: list[str]
protected: set[str] protected: set[str]
first_parent: set[str] first_parent: set[str]
urls: dict[str, str]
forge: ForgeFacts forge: ForgeFacts
containers: list[Container] | None containers: list[Container] | None
host_ns: bool host_ns: bool
@@ -109,8 +134,12 @@ def main_repo_root(cwd: str) -> str:
return os.path.dirname(_common_dir(cwd)) 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: 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: def _docker(args: list[str]) -> str:
@@ -151,52 +180,71 @@ def _inside(path: str, root: str) -> bool:
def _worktrees(repo_root: str) -> list[Worktree]: def _worktrees(repo_root: str) -> list[Worktree]:
trees: list[Worktree] = [] trees: list[Worktree] = []
for block in run(["worktree", "list", "--porcelain"], cwd=repo_root).split("\n\n"):
fields: dict[str, str] = {} fields: dict[str, str] = {}
for line in block.splitlines(): for part in _git(["worktree", "list", "--porcelain", "-z"], repo_root).split("\0"):
key, _, value = line.partition(" ") if part:
key, _, value = part.partition(" ")
fields[key] = value fields[key] = value
if "worktree" not in fields:
continue continue
branch = fields.get("branch") record, fields = fields, {}
if "worktree" not in record:
continue
branch = record.get("branch")
trees.append( trees.append(
Worktree( Worktree(
path=fields["worktree"], path=record["worktree"],
head=fields.get("HEAD"), head=record.get("HEAD"),
branch=branch.removeprefix("refs/heads/") if branch else None, branch=branch.removeprefix("refs/heads/") if branch else None,
locked=fields.get("locked"), locked=record.get("locked"),
prunable="prunable" in fields, prunable="prunable" in record,
main=not trees, main=not trees,
) )
) )
return 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( def _refs(
repo_root: str, remotes: list[str] repo_root: str, remotes: list[str]
) -> tuple[dict[str, str], dict[str, dict[str, str]]]: ) -> tuple[
out = run( dict[str, str],
dict[str, dict[str, str]],
dict[str, tuple[str | None, int | None, int | None, bool]],
]:
out = _git(
[ [
"for-each-ref", "for-each-ref",
"--format=%(refname) %(objectname)", "--format=%(refname)%00%(objectname)%00%(upstream:short)%00%(upstream:track,nobracket)",
"refs/heads", "refs/heads",
"refs/remotes", "refs/remotes",
], ],
cwd=repo_root, repo_root,
) )
by_length = sorted(remotes, key=len, reverse=True) by_length = sorted(remotes, key=len, reverse=True)
local: dict[str, str] = {} local: dict[str, str] = {}
remote: dict[str, 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(): for line in out.splitlines():
ref, sha = line.split(" ") ref, sha, upstream, track = line.split("\x00")
if ref.startswith("refs/heads/"): if ref.startswith("refs/heads/"):
local[ref.removeprefix("refs/heads/")] = sha local[ref.removeprefix("refs/heads/")] = sha
tracking[ref.removeprefix("refs/heads/")] = _tracking(upstream, track)
continue continue
rest = ref.removeprefix("refs/remotes/") rest = ref.removeprefix("refs/remotes/")
owner = next((r for r in by_length if rest.startswith(r + "/")), None) owner = next((r for r in by_length if rest.startswith(r + "/")), None)
if owner and rest[len(owner) + 1 :] != "HEAD": if owner and rest[len(owner) + 1 :] != "HEAD":
remote.setdefault(rest[len(owner) + 1 :], {})[owner] = sha remote.setdefault(rest[len(owner) + 1 :], {})[owner] = sha
return local, remote return local, remote, tracking
def _process_cwds() -> dict[int, str]: 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: def _is_ancestor(sha: str, ref: str, repo_root: str) -> bool:
try: 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: except GitRunError as exc:
if exc.returncode == 1: if exc.returncode == 1:
return False return False
@@ -261,15 +309,13 @@ def _merged_by(repo: Repo, sha: str) -> str | None:
def _du_bytes(path: str) -> int | None: def _du_bytes(path: str) -> int | None:
out = subprocess.run( 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() ).stdout.split()
return int(out[0]) * 1024 if out and out[0].isdigit() else None 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: def _age_hours(repo: Repo, shas: list[str], tree: Worktree | None) -> float:
stamps = [ stamps = [int(_git(["log", "-1", "--format=%ct", sha], repo.repo)) for sha in shas]
int(run(["log", "-1", "--format=%ct", sha], cwd=repo.repo)) for sha in shas
]
if tree and os.path.isfile(os.path.join(tree.path, ".git")): 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: with open(os.path.join(tree.path, ".git"), encoding="utf-8") as fh:
index = os.path.join(fh.read().strip().removeprefix("gitdir: "), "index") index = os.path.join(fh.read().strip().removeprefix("gitdir: "), "index")
@@ -316,19 +362,83 @@ def _skip(
return None 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( def _build(
repo: Repo, repo: Repo,
branch: str | None, branch: str | None,
tree: Worktree | None, tree: Worktree | None,
snapshot: tuple[dict[str, str], dict[str, dict[str, str]], dict[int, str]], snapshot: Snapshot,
) -> Item: ) -> Item:
local, remote, cwds = snapshot local, remote, cwds, tracking = snapshot
upstream, ahead, behind, gone = tracking.get(
branch or "", (None, None, None, False)
)
item = Item( item = Item(
branch=branch, branch=branch,
worktree=tree.path if tree else None, 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, local_sha=local.get(branch) if branch else None,
remotes=dict(remote.get(branch, {})) if branch else {}, 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: if branch in repo.protected:
item.state, item.reason = "protected", "default or protected branch" item.state, item.reason = "protected", "default or protected branch"
return item return item
@@ -341,37 +451,30 @@ def _build(
shas = [item.local_sha, *item.remotes.values(), tree.head if tree else None] 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} 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] 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: if unread:
dirty = ( item.state, item.reason = "dirty", unread
len( elif dirty > 0:
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:
item.state, item.reason = "dirty", f"dirty: {dirty} paths" item.state, item.reason = "dirty", f"dirty: {dirty} paths"
elif dirty == 0 and unmerged: elif unmerged:
ahead = int( own = int(
run( _git(
["rev-list", "--count", f"{repo.default}..{unmerged[0]}"], cwd=repo.repo [
"rev-list",
"--count",
END_OF_OPTIONS,
f"{repo.default}..{unmerged[0]}",
],
repo.repo,
) )
) )
item.state = "unmerged" item.state = "unmerged"
item.reason = ( item.reason = (
f"unmerged: {ahead} commits not in {repo.default}" f"unmerged: {own} commits not in {repo.default}"
if ahead if own
else "no own commits" else "no own commits"
) )
elif dirty == 0: else:
item.state = "merged-clean" item.state = "merged-clean"
item.merged_by = next( item.merged_by = next(
(verdict for verdict in verdicts.values() if verdict != "ancestor"), (verdict for verdict in verdicts.values() if verdict != "ancestor"),
@@ -401,19 +504,15 @@ def _build(
return item return item
def _snapshot( def _snapshot(repo: Repo) -> tuple[list[Worktree], Snapshot]:
repo: Repo, local, remote, tracking = _refs(repo.repo, repo.remotes)
) -> tuple[
list[Worktree], tuple[dict[str, str], dict[str, dict[str, str]], dict[int, str]]
]:
local, remote = _refs(repo.repo, repo.remotes)
cwds = _process_cwds() if repo.host_ns else {} 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]: def _items(repo: Repo) -> list[Item]:
trees, snapshot = _snapshot(repo) trees, snapshot = _snapshot(repo)
local, remote, _ = snapshot local, remote, _, _ = snapshot
by_branch = {t.branch: t for t in trees if t.branch} by_branch = {t.branch: t for t in trees if t.branch}
names = sorted(set(local) | set(remote) | set(by_branch)) names = sorted(set(local) | set(remote) | set(by_branch))
items = [_build(repo, name, by_branch.get(name), snapshot) for name in names] items = [_build(repo, name, by_branch.get(name), snapshot) for name in names]
@@ -450,14 +549,23 @@ def _lines(
names: set[str] = set() names: set[str] = set()
for ref in refs: for ref in refs:
try: try:
tip = run( tip = _git(
["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], cwd=repo_root [
"rev-parse",
"--verify",
"--quiet",
END_OF_OPTIONS,
f"{ref}^{{commit}}",
],
repo_root,
) )
except GitRunError: except GitRunError:
warnings.append(f"merged-into ref {ref} not found") warnings.append(f"merged-into ref {ref} not found")
continue continue
first_parent = set( 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) lines[ref] = (tip, first_parent)
owner = next((r for r in remotes if ref.startswith(r + "/")), None) owner = next((r for r in remotes if ref.startswith(r + "/")), None)
@@ -473,6 +581,8 @@ def plan_repo(
merged_into: list[str], merged_into: list[str],
sizes: bool, sizes: bool,
min_age: float | None, min_age: float | None,
fetch: bool = True,
forge_source: Callable[[dict[str, str]], ForgeFacts] | None = None,
) -> Repo: ) -> Repo:
""" """
Scan one repository and classify every branch and worktree. Scan one repository and classify every branch and worktree.
@@ -484,11 +594,14 @@ def plan_repo(
merged_into: integration lines (refs) that also count as merged; each is protected. merged_into: integration lines (refs) that also count as merged; each is protected.
sizes: measure each worktree's disk usage into size_bytes. sizes: measure each worktree's disk usage into size_bytes.
min_age: skip items whose newest commit or worktree index is younger (hours). 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: Returns:
The repository with its classified items. The repository with its classified items.
""" """
warnings: list[str] = [] warnings: list[str] = []
if fetch:
try: try:
# Auto-maintenance runs worktree-prune, which deletes the metadata of # Auto-maintenance runs worktree-prune, which deletes the metadata of
# sandboxed worktrees whose gitdir stub is missing. # sandboxed worktrees whose gitdir stub is missing.
@@ -501,24 +614,24 @@ def plan_repo(
if containers is None: if containers is None:
warnings.append("docker unavailable: worktree items are skipped") 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] = {} heads: dict[str, str] = {}
for remote in remotes: for remote in remotes:
try: try:
heads[remote] = run( heads[remote] = _git(
["symbolic-ref", "--short", f"refs/remotes/{remote}/HEAD"], ["symbolic-ref", "--short", f"refs/remotes/{remote}/HEAD"], repo_root
cwd=repo_root,
) )
except GitRunError: except GitRunError:
continue continue
default = next(iter(heads.values()), None) or resolve_base_branch( 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()} head_names = {ref[len(remote) + 1 :] for remote, ref in heads.items()}
forge = fetch_forge_facts( urls = {r: _git(["remote", "get-url", "--", r], repo_root) for r in remotes}
{r: run(["remote", "get-url", r], cwd=repo_root) for r in remotes} forge = (forge_source or fetch_forge_facts)(urls)
)
if forge.unavailable: if forge.unavailable:
warnings.append(f"forge unavailable: {forge.unavailable}") warnings.append(f"forge unavailable: {forge.unavailable}")
lines, line_names = _lines(repo_root, merged_into, remotes, warnings) lines, line_names = _lines(repo_root, merged_into, remotes, warnings)
@@ -535,8 +648,11 @@ def plan_repo(
*line_names, *line_names,
}, },
first_parent=set( 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, forge=forge,
containers=containers, containers=containers,
host_ns=host_ns, host_ns=host_ns,
@@ -637,6 +753,7 @@ def execute(repo: Repo, item: Item) -> None:
[ [
"push", "push",
f"--force-with-lease=refs/heads/{item.branch}:{sha}", f"--force-with-lease=refs/heads/{item.branch}:{sha}",
"--",
remote, remote,
f":refs/heads/{item.branch}", 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: except CredentialError as exc:
facts.unavailable = f"no GitHub token ({exc})" facts.unavailable = f"no GitHub token ({exc})"
return facts 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) http = HttpClient(timeout_s=30)
headers = { headers = {
"Authorization": f"Bearer {token}", "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, close_branch,
drop_branch, drop_branch,
open_branch, open_branch,
tree,
) )
from pkgmgr.actions.branch.gc import main_repo_root from pkgmgr.actions.branch.gc import main_repo_root
from pkgmgr.cli.context import CLIContext 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 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]: def _gc_repo_roots(args, ctx: CLIContext) -> list[str]:
if not args.all: if not args.all:
return [main_repo_root(".")] return [main_repo_root(".")]
dirs = [get_repo_dir(ctx.repositories_base_dir, r) for r in ctx.all_repositories] return [d for d in _known_dirs(ctx) if os.path.exists(os.path.join(d, ".git"))]
return [d for d in dirs 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: 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] [--merged-into REF]... [--sizes] [--min-age HOURS]
- pkgmgr branch purge [--all] [--branch N]... [--exclude N]... - pkgmgr branch purge [--all] [--branch N]... [--exclude N]...
[--merged-into REF]... [--sizes] [--min-age HOURS] [--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"): if args.subcommand in ("gc", "purge"):
try: try:
roots = _gc_repo_roots(args, ctx) 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) code = branch_gc(roots, apply=args.apply, as_json=args.json, **options)
else: else:
code = branch_purge(roots, **options) code = branch_purge(roots, **options)
if args.subcommand == "purge" or args.apply:
_refresh(roots)
sys.exit(code) sys.exit(code)
if args.subcommand == "open": if args.subcommand == "open":
@@ -61,6 +91,7 @@ def handle_branch(args, ctx: CLIContext) -> None:
base_branch=getattr(args, "base", "main"), base_branch=getattr(args, "base", "main"),
cwd=".", cwd=".",
) )
_refresh([main_repo_root(".")])
return return
if args.subcommand == "close": if args.subcommand == "close":
@@ -70,6 +101,7 @@ def handle_branch(args, ctx: CLIContext) -> None:
cwd=".", cwd=".",
force=getattr(args, "force", False), force=getattr(args, "force", False),
) )
_refresh([main_repo_root(".")])
return return
if args.subcommand == "drop": if args.subcommand == "drop":
@@ -79,6 +111,7 @@ def handle_branch(args, ctx: CLIContext) -> None:
cwd=".", cwd=".",
force=getattr(args, "force", False), force=getattr(args, "force", False),
) )
_refresh([main_repo_root(".")])
return return
print(f"Unknown branch subcommand: {args.subcommand}") print(f"Unknown branch subcommand: {args.subcommand}")
+19
View File
@@ -115,6 +115,25 @@ def add_branch_subparsers(
help="Print the plan as JSON", 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( branch_purge = branch_subparsers.add_parser(
"purge", "purge",
help="Delete unmerged or dirty branches after a confirmation per item", 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: def delete_local_branch(branch: str, cwd: str = ".", force: bool = False) -> None:
flag = "-D" if force else "-d" flag = "-D" if force else "-d"
try: try:
run(["branch", flag, branch], cwd=cwd) run(["branch", flag, "--", branch], cwd=cwd)
except GitRunError as exc: except GitRunError as exc:
raise GitDeleteLocalBranchError( raise GitDeleteLocalBranchError(
f"Failed to delete local branch {branch!r} (flag {flag}).", 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 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']). 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: if not output:
return [] return []
return [line.strip() for line in output.splitlines() if line.strip()] return [line.strip() for line in output.splitlines() if line.strip()]
@@ -37,12 +37,13 @@ def resolve_base_branch(
preferred: str = "main", preferred: str = "main",
fallback: str = "master", fallback: str = "master",
cwd: str = ".", cwd: str = ".",
scan: bool = False,
) -> str: ) -> str:
""" """
Resolve the base branch to use. Resolve the base branch to use.
Try `preferred` first (default: main), 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 GitBaseBranchNotFoundError if neither exists.
Raises GitRunError for other git failures (e.g., not a git repository). 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): for candidate in (preferred, fallback):
try: try:
run(["rev-parse", "--verify", candidate], cwd=cwd) run(["rev-parse", "--verify", candidate], cwd=cwd, scan=scan)
return candidate return candidate
except GitRunError as exc: except GitRunError as exc:
if _is_branch_missing_error(exc): if _is_branch_missing_error(exc):
+58 -3
View File
@@ -1,35 +1,84 @@
from __future__ import annotations from __future__ import annotations
import os
import subprocess import subprocess
from .errors import GitNotRepositoryError, GitRunError 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: def _is_not_repo_error(stderr: str) -> bool:
msg = (stderr or "").lower() msg = (stderr or "").lower()
return "not a git repository" in msg 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( def run(
args: list[str], args: list[str],
*, *,
cwd: str = ".", cwd: str = ".",
preview: bool = False, preview: bool = False,
scan: bool = False,
) -> str: ) -> str:
""" """
Run a Git command and return its stdout as a stripped string. 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) cmd_str = " ".join(cmd)
if preview: if preview:
print(f"[PREVIEW] Would run in {cwd!r}: {cmd_str}") print(f"[PREVIEW] Would run in {cwd!r}: {cmd_str}")
return "" return ""
hardened = (
{"env": _scan_env(), "stdin": subprocess.DEVNULL, "timeout": SCAN_TIMEOUT}
if scan
else {}
)
try: try:
result = subprocess.run( result = subprocess.run(
cmd, cmd,
@@ -37,7 +86,13 @@ def run(
check=True, check=True,
capture_output=True, capture_output=True,
text=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: except subprocess.CalledProcessError as exc:
stderr = exc.stderr or "" stderr = exc.stderr or ""
stdout = exc.stdout or "" stdout = exc.stdout or ""
+48
View File
@@ -331,6 +331,54 @@ class TestBranchGc(unittest.TestCase):
self.assertTrue(item.result.startswith("skipped: changed since plan")) self.assertTrue(item.result.startswith("skipped: changed since plan"))
self.assertTrue(os.path.isdir(self.merged_wt)) self.assertTrue(os.path.isdir(self.merged_wt))
def test_worktree_path_with_newlines_cannot_forge_a_record(self) -> None:
forged = os.path.join(self.root, "a\n\nworktree -evil")
git(self.repo, "worktree", "add", "-b", "forged", forged, "main")
paths = {tree.path: tree.branch for tree in gc._worktrees(self.repo)}
self.assertEqual(paths[forged], "forged")
self.assertNotIn("-evil", paths)
def test_ref_named_like_an_option_writes_no_file(self) -> None:
target = os.path.join(self.root, "pwn")
os.makedirs(target)
remote = f"--output={target}"
main = git(self.repo, "rev-parse", "main")
git(self.repo, "remote", "remove", "origin")
git(self.repo, "config", f"remote.{remote}.url", self.origin)
git(self.repo, "update-ref", f"refs/remotes/{remote}/main", main)
git(
self.repo,
"symbolic-ref",
f"refs/remotes/{remote}/HEAD",
f"refs/remotes/{remote}/main",
)
plan = self._plan(host_ns=True, cwds={})
self.assertEqual(plan.default, f"{remote}/main")
self.assertEqual(os.listdir(target), [])
def test_execute_deletes_branches_named_like_options(self) -> None:
sha = git(self.repo, "rev-parse", "merged")
other = os.path.join(self.root, "other.git")
git(self.root, "init", "--bare", other)
git(self.repo, "push", other, "merged:refs/heads/-evil")
git(self.repo, "config", "remote.-r.url", other)
git(self.repo, "update-ref", "refs/heads/-evil", sha)
git(self.repo, "update-ref", "refs/remotes/-r/-evil", sha)
plan = self._plan(host_ns=True, cwds={})
item = self._items(plan)["-evil"]
self.assertEqual(item.remotes, {"-r": sha})
with patch.object(gc, "_process_cwds", return_value={}):
gc.execute(plan, item)
self.assertEqual(item.result, "done")
self.assertEqual(git(self.repo, "for-each-ref", "refs/heads/-evil"), "")
self.assertEqual(git(other, "for-each-ref", "refs/heads/-evil"), "")
def test_branch_gc_json_and_refusal(self) -> None: def test_branch_gc_json_and_refusal(self) -> None:
out = io.StringIO() out = io.StringIO()
with ( with (
+440
View File
@@ -0,0 +1,440 @@
from __future__ import annotations
import io
import json
import os
import shutil
import stat
import subprocess
import tempfile
import time
import unittest
from contextlib import redirect_stdout
from unittest.mock import patch
import yaml
from pkgmgr.actions.branch import gc, tree
from pkgmgr.actions.branch.gc_forge import ForgeFacts
from pkgmgr.cli.commands.branch import handle_branch
from pkgmgr.cli.context import CLIContext
from pkgmgr.cli.parser import create_parser
from pkgmgr.core.git.run import run
GIT_ENV = {
**os.environ,
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@example.org",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@example.org",
}
VOLATILE = ("generated_at", "scanned_at")
def git(cwd: str, *args: str) -> str:
return subprocess.run(
["git", "-c", "commit.gpgsign=false", *args],
cwd=cwd,
env=GIT_ENV,
check=True,
capture_output=True,
text=True,
).stdout.strip()
def stable(data):
if isinstance(data, dict):
return {
key: stable(value) for key, value in data.items() if key not in VOLATILE
}
if isinstance(data, list):
return [stable(value) for value in data]
return data
class TestBranchTree(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.root = os.path.realpath(self._tmp.name)
self.origin = os.path.join(self.root, "origin.git")
self.repo = os.path.join(self.root, "repos", "repo")
self.shared = os.path.join(self.root, "shared-worktrees")
os.makedirs(self.shared)
git(self.root, "init", "--bare", "-b", "main", self.origin)
git(self.root, "init", "-b", "main", self.repo)
git(self.repo, "remote", "add", "origin", self.origin)
self._commit("init")
git(self.repo, "push", "-u", "origin", "main")
git(self.repo, "remote", "set-head", "origin", "main")
self._write(os.path.join(self.repo, ".git", "info"), "exclude", ".worktrees/\n")
self._pushed("merged")
git(self.repo, "merge", "--no-ff", "-m", "merge merged", "merged")
git(self.repo, "push", "origin", "main")
self._pushed("ahead")
git(self.repo, "checkout", "ahead")
self._commit("ahead-local")
git(self.repo, "checkout", "main")
self._pushed("behind", commits=2)
git(self.repo, "branch", "-f", "behind", "behind~1")
self._pushed("gone")
git(self.repo, "push", "origin", "--delete", "gone")
self.changed = os.path.join(self.root, "repos", "repo", ".worktrees", "changed")
git(self.repo, "worktree", "add", "-b", "changed", self.changed, "main")
self._write(self.changed, "staged.txt", "s")
git(self.changed, "add", "staged.txt")
self._write(self.changed, "init.txt", "edited")
self._write(self.changed, "loose.txt", "u")
self.detached = os.path.join(self.shared, "repo", "detached")
git(self.repo, "worktree", "add", "--detach", self.detached, "main")
self.missing = os.path.join(self.root, "wt-missing")
git(self.repo, "worktree", "add", "-b", "missing", self.missing, "main")
shutil.rmtree(self.missing)
self.facts = ForgeFacts(merged_heads={}, open_heads={})
self.tree_file = os.path.join(self.root, "state", "tree", "branch-tree.json")
self.env = patch.dict(
os.environ,
{
"PKGMGR_TREE_FILE": self.tree_file,
"XDG_CACHE_HOME": os.path.join(self.root, "cache"),
},
)
self.env.start()
self.addCleanup(self.env.stop)
self.patches = [
patch.object(gc, "host_pid_namespace", return_value=True),
patch.object(gc, "list_compose_containers", return_value=[]),
patch.object(gc, "_process_cwds", return_value={}),
patch.object(
tree,
"secret_service_token",
return_value=(None, "Secret Service locked"),
),
]
for patcher in self.patches:
patcher.start()
self.addCleanup(patcher.stop)
def _write(self, cwd: str, name: str, text: str) -> None:
with open(os.path.join(cwd, name), "w", encoding="utf-8") as fh:
fh.write(text)
def _commit(self, name: str, cwd: str | None = None) -> None:
self._write(cwd or self.repo, f"{name}.txt", name)
git(cwd or self.repo, "add", f"{name}.txt")
git(cwd or self.repo, "commit", "-m", name)
def _pushed(self, name: str, commits: int = 1) -> None:
git(self.repo, "checkout", "-b", name, "main")
for number in range(commits):
self._commit(f"{name}-{number}")
git(self.repo, "push", "-u", "origin", name)
git(self.repo, "checkout", "main")
def _tree(self, fmt: str = "json", repos: list[str] | None = None) -> str:
out = io.StringIO()
with redirect_stdout(out):
code = tree.branch_tree(
repos or [self.repo], fmt=fmt, output=None, worktrees_root=self.shared
)
self.assertEqual(code, 0)
return out.getvalue()
def _entry(self) -> dict:
data = json.loads(self._tree())
return next(repo for repo in data["repos"] if repo["path"] == self.repo)
def _by_branch(self, entry: dict) -> dict[str, dict]:
return {item["branch"]: item for item in entry["items"] if item["branch"]}
def test_json_carries_repository_branch_and_worktree_fields(self) -> None:
data = json.loads(self._tree())
entry = next(repo for repo in data["repos"] if repo["path"] == self.repo)
branches = self._by_branch(entry)
self.assertIn("generated_at", data)
self.assertEqual(
(entry["remote"], entry["default"], entry["missing"]),
(self.origin, "main", False),
)
self.assertEqual(
(
branches["ahead"]["upstream"],
branches["ahead"]["ahead"],
branches["ahead"]["behind"],
),
("origin/ahead", 1, 0),
)
self.assertEqual(
(branches["behind"]["ahead"], branches["behind"]["behind"]), (0, 1)
)
self.assertTrue(branches["gone"]["gone"])
self.assertFalse(branches["merged"]["gone"])
changed = branches["changed"]
self.assertEqual(
(changed["worktree"], changed["worktree_state"]), (self.changed, "changed")
)
self.assertEqual(
(changed["staged"], changed["unstaged"], changed["untracked"]), (1, 1, 1)
)
self.assertEqual(changed["head"], git(self.repo, "rev-parse", "main"))
self.assertEqual(changed["state"], "dirty")
self.assertEqual(branches["missing"]["worktree_state"], "missing")
self.assertEqual(branches["main"]["worktree_state"], "clean")
detached = [item for item in entry["items"] if item["detached"]]
self.assertEqual(
[(item["worktree"], item["worktree_state"]) for item in detached],
[(self.detached, "clean")],
)
self.assertEqual(branches["merged"]["merged_by"], "ancestor")
self.assertEqual(entry["forge"], "none")
self.assertEqual(
(data["delete_ready"], data["delete_reason"]),
(False, "Secret Service locked"),
)
def test_yaml_prints_the_same_data(self) -> None:
self.assertEqual(
stable(yaml.safe_load(self._tree("yaml"))),
stable(json.loads(self._tree("json"))),
)
def test_tree_and_gc_share_every_state(self) -> None:
out = io.StringIO()
with (
patch.object(gc, "fetch_forge_facts", return_value=ForgeFacts()),
redirect_stdout(out),
):
gc.branch_gc(
[self.repo],
apply=False,
as_json=True,
include=[],
exclude=[],
merged_into=[],
sizes=False,
min_age=None,
)
planned = {
(item["branch"], item["worktree"]): (
item["state"],
item["merged_by"],
item["skip"],
)
for item in json.loads(out.getvalue())["repos"][0]["items"]
}
listed = {
(item["branch"], item["worktree"]): (
item["state"],
item["merged_by"],
item["skip"],
)
for item in self._entry()["items"]
}
self.assertEqual(listed, planned)
def test_the_scan_runs_no_hook_fsmonitor_or_local_filter(self) -> None:
marker = os.path.join(self.root, "ran")
script = os.path.join(self.root, "plant.sh")
self._write(self.root, "plant.sh", f"#!/bin/sh\ntouch {marker}\n")
os.chmod(script, stat.S_IRWXU)
hooks = os.path.join(self.root, "hooks")
os.makedirs(hooks)
for hook in ("post-index-change", "reference-transaction", "post-checkout"):
shutil.copy(script, os.path.join(hooks, hook))
git(self.repo, "config", "core.fsmonitor", script)
git(self.repo, "config", "core.hooksPath", hooks)
git(self.repo, "config", "core.pager", script)
git(self.repo, "config", "filter.planted.clean", script)
self._write(self.changed, ".gitattributes", "* filter=planted\n")
with patch("subprocess.run", wraps=subprocess.run) as spy:
changed = self._by_branch(self._entry())["changed"]
self.assertFalse(os.path.exists(marker))
self.assertEqual(
(changed["worktree_state"], changed["staged"]), ("changed", None)
)
self.assertIn("filter.planted.clean", changed["reason"])
scans = [
call
for call in spy.call_args_list
if call.args and call.args[0][:1] == ["git"]
]
self.assertTrue(scans)
for call in scans:
with self.subTest(argv=call.args[0][:6]):
self.assertNotIn("fetch", call.args[0])
self.assertEqual(call.kwargs["env"]["GIT_NO_LAZY_FETCH"], "1")
self.assertIn("core.fsmonitor=false", call.args[0])
def test_a_worktree_with_submodules_is_not_read(self) -> None:
head = git(self.changed, "rev-parse", "HEAD")
git(self.changed, "update-index", "--add", "--cacheinfo", f"160000,{head},sub")
changed = self._by_branch(self._entry())["changed"]
self.assertEqual((changed["state"], changed["staged"]), ("dirty", None))
self.assertIn("submodules", changed["reason"])
def test_the_scan_never_runs_a_planted_gpg_program(self) -> None:
marker = os.path.join(self.root, "verified")
self._write(self.root, "gpg.sh", f"#!/bin/sh\ntouch {marker}\nexit 1\n")
os.chmod(os.path.join(self.root, "gpg.sh"), stat.S_IRWXU)
git(self.repo, "config", "log.showSignature", "true")
git(self.repo, "config", "gpg.program", os.path.join(self.root, "gpg.sh"))
body = git(self.repo, "cat-file", "commit", "HEAD").replace(
"\n\n",
"\ngpgsig -----BEGIN PGP SIGNATURE-----\n \n -----END PGP SIGNATURE-----\n\n",
1,
)
signed = subprocess.run(
["git", "hash-object", "-t", "commit", "-w", "--stdin"],
cwd=self.repo,
input=body,
capture_output=True,
text=True,
check=True,
).stdout.strip()
git(self.repo, "log", "-1", signed)
self.assertTrue(os.path.exists(marker))
os.unlink(marker)
run(["log", "-1", "--format=%ct", signed], cwd=self.repo, scan=True)
self.assertFalse(os.path.exists(marker))
def test_output_is_written_atomically(self) -> None:
target = os.path.join(self.root, "out", "branch-tree.json")
code = tree.branch_tree(
[self.repo], fmt="json", output=target, worktrees_root=self.shared
)
self.assertEqual(code, 0)
with open(target, encoding="utf-8") as fh:
self.assertEqual(json.load(fh)["repos"][0]["path"], self.repo)
self.assertEqual(
sorted(os.listdir(os.path.dirname(target))),
[".branch-tree.json.lock", "branch-tree.json"],
)
def test_lists_every_known_repository_also_plain_and_missing(self) -> None:
plain = os.path.join(self.root, "repos", "plain")
git(self.root, "init", "-b", "main", plain)
self._commit("plain", cwd=plain)
absent = os.path.join(self.root, "repos", "absent")
ctx = CLIContext(
config_merged={},
repositories_base_dir=os.path.join(self.root, "base"),
all_repositories=[
{
"provider": "x",
"account": "y",
"repository": "repo",
"directory": self.repo,
},
{
"provider": "x",
"account": "y",
"repository": "plain",
"directory": plain,
},
{
"provider": "x",
"account": "y",
"repository": "absent",
"directory": absent,
},
],
binaries_dir="",
user_config_path="",
)
args = create_parser("test").parse_args(["branch", "tree", "--format", "json"])
out = io.StringIO()
with (
patch.object(tree, "WORKTREES_ROOT", self.shared),
redirect_stdout(out),
self.assertRaises(SystemExit) as done,
):
handle_branch(args, ctx)
self.assertEqual(done.exception.code, 0)
repos = {repo["path"]: repo for repo in json.loads(out.getvalue())["repos"]}
self.assertEqual(set(repos), {self.repo, plain, absent})
self.assertEqual([item["branch"] for item in repos[plain]["items"]], ["main"])
self.assertEqual((repos[absent]["missing"], repos[absent]["items"]), (True, []))
def test_a_shared_worktree_of_an_unknown_repository_adds_it(self) -> None:
other = os.path.join(self.root, "elsewhere", "other")
git(self.root, "init", "-b", "main", other)
self._commit("other", cwd=other)
orphan = os.path.join(self.shared, "other", "feature")
git(other, "worktree", "add", "-b", "feature", orphan, "main")
repos = {repo["path"]: repo for repo in json.loads(self._tree())["repos"]}
self.assertEqual(
(repos[self.repo]["known"], repos[other]["known"]), (True, False)
)
self.assertIn(orphan, [item["worktree"] for item in repos[other]["items"]])
def test_forge_facts_come_from_the_secret_service_at_most_every_ten_minutes(
self,
) -> None:
git(self.repo, "remote", "set-url", "origin", "https://github.com/me/repo.git")
locked = self._entry()
self.assertEqual(locked["forge"], "unavailable: Secret Service locked")
self.assertEqual(self._by_branch(locked)["merged"]["merged_by"], "ancestor")
self.assertEqual(self._by_branch(locked)["ahead"]["skip"], "forge unavailable")
facts = ForgeFacts(
merged_heads={
git(self.repo, "rev-parse", ref): 9 for ref in ("ahead", "origin/ahead")
},
open_heads={},
)
with (
patch.object(tree, "secret_service_token", return_value=("t0ken", None)),
patch.object(tree, "facts_with_token", return_value=facts) as live,
):
first = self._by_branch(self._entry())
second = self._by_branch(self._entry())
self.assertEqual(live.call_count, 1)
with patch.object(tree, "now", return_value=time.time() + 601):
self._entry()
self.assertEqual(live.call_count, 2)
self.assertEqual(
(first["ahead"]["merged_by"], second["ahead"]["merged_by"]),
("pr#9", "pr#9"),
)
data = json.loads(self._tree())
self.assertEqual(
(data["delete_ready"], data["delete_reason"]),
(False, "Secret Service locked"),
)
self.assertEqual(data["repos"][0]["forge"], "cached")
with patch.object(tree, "secret_service_token", return_value=("t0ken", None)):
self.assertTrue(json.loads(self._tree())["delete_ready"])
def test_an_unwritable_cache_keeps_the_scan_and_names_it(self) -> None:
git(self.repo, "remote", "set-url", "origin", "https://github.com/me/repo.git")
blocker = os.path.join(self.root, "cache-is-a-file")
self._write(self.root, "cache-is-a-file", "")
with (
patch.dict(os.environ, {"XDG_CACHE_HOME": blocker}),
patch.object(tree, "secret_service_token", return_value=("t0ken", None)),
patch.object(tree, "facts_with_token", return_value=ForgeFacts()),
):
entry = self._entry()
self.assertTrue(entry["forge"].startswith("live: cache not written"))
self.assertIn("main", self._by_branch(entry))
def test_branch_actions_rewrite_the_tree_file_when_it_exists(self) -> None:
tree.branch_tree(
[self.repo], fmt="json", output=self.tree_file, worktrees_root=self.shared
)
args = create_parser("test").parse_args(["branch", "open", "fresh"])
cwd = os.getcwd()
os.chdir(self.repo)
self.addCleanup(os.chdir, cwd)
handle_branch(args, ctx=None)
with open(self.tree_file, encoding="utf-8") as fh:
entry = json.load(fh)["repos"][0]
self.assertIn("fresh", [item["branch"] for item in entry["items"]])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,64 @@
from __future__ import annotations
import sys
import types
import unittest
from unittest.mock import MagicMock, patch
from pkgmgr.actions.branch import tree
class SecretStorageError(Exception):
pass
def fake_secretstorage(items: list[MagicMock]) -> types.ModuleType:
module = types.ModuleType("secretstorage")
module.exceptions = types.SimpleNamespace(SecretStorageException=SecretStorageError)
module.connection = MagicMock()
module.dbus_init = MagicMock(return_value=module.connection)
module.search_items = MagicMock(return_value=iter(items))
return module
def item(locked: bool, secret: bytes = b"t0ken") -> MagicMock:
found = MagicMock()
found.is_locked.return_value = locked
found.get_secret.return_value = secret
return found
class TestSecretServiceToken(unittest.TestCase):
def _token(self, module: types.ModuleType) -> tuple[str | None, str | None]:
with patch.dict(sys.modules, {"secretstorage": module}):
return tree.secret_service_token()
def test_a_locked_item_is_never_unlocked(self) -> None:
locked = item(True)
module = fake_secretstorage([locked])
self.assertEqual(self._token(module), (None, "Secret Service locked"))
locked.get_secret.assert_not_called()
locked.unlock.assert_not_called()
module.connection.close.assert_called_once()
def test_an_unlocked_item_returns_its_token(self) -> None:
module = fake_secretstorage([item(True), item(False, b"live")])
self.assertEqual(self._token(module), ("live", None))
module.search_items.assert_called_once_with(
module.connection, {"service": "pkgmgr:github", "username": "github.com|-"}
)
def test_no_item_and_no_bus_name_their_reason(self) -> None:
self.assertEqual(
self._token(fake_secretstorage([])),
(None, "no GitHub token in the Secret Service"),
)
module = fake_secretstorage([])
module.dbus_init.side_effect = SecretStorageError("no bus")
self.assertEqual(
self._token(module), (None, "Secret Service unavailable: no bus")
)
if __name__ == "__main__":
unittest.main()
@@ -16,7 +16,9 @@ class TestResolveBaseBranch(unittest.TestCase):
mock_run.return_value = "dummy" mock_run.return_value = "dummy"
result = resolve_base_branch("main", "master", cwd=".") result = resolve_base_branch("main", "master", cwd=".")
self.assertEqual(result, "main") self.assertEqual(result, "main")
mock_run.assert_called_with(["rev-parse", "--verify", "main"], cwd=".") mock_run.assert_called_with(
["rev-parse", "--verify", "main"], cwd=".", scan=False
)
@patch("pkgmgr.core.git.queries.resolve_base_branch.run") @patch("pkgmgr.core.git.queries.resolve_base_branch.run")
def test_resolves_fallback(self, mock_run): def test_resolves_fallback(self, mock_run):