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:
1 parent
c2f0a56699
commit
54f0cbb37b
14 files changed
+1226
-88
No files matched your search
@@ -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",
|
||||||
]
|
]
|
||||||
+193
-76
@@ -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 part in _git(["worktree", "list", "--porcelain", "-z"], repo_root).split("\0"):
|
||||||
for line in block.splitlines():
|
if part:
|
||||||
key, _, value = line.partition(" ")
|
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,41 +594,44 @@ 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] = []
|
||||||
try:
|
if fetch:
|
||||||
# Auto-maintenance runs worktree-prune, which deletes the metadata of
|
try:
|
||||||
# sandboxed worktrees whose gitdir stub is missing.
|
# Auto-maintenance runs worktree-prune, which deletes the metadata of
|
||||||
run(
|
# sandboxed worktrees whose gitdir stub is missing.
|
||||||
["fetch", "--all", "--prune", "--quiet", "--no-auto-maintenance"],
|
run(
|
||||||
cwd=repo_root,
|
["fetch", "--all", "--prune", "--quiet", "--no-auto-maintenance"],
|
||||||
)
|
cwd=repo_root,
|
||||||
except GitRunError as exc:
|
)
|
||||||
warnings.append(f"fetch failed: {_describe(exc)}")
|
except GitRunError as exc:
|
||||||
|
warnings.append(f"fetch failed: {_describe(exc)}")
|
||||||
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}",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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,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):
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in new issue
Block a user