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