feat(branch): cleanup --local deletes merged local branches only when git itself would

pkgmgr branch cleanup --local [--apply] [--json] [--merged-into REF]
deletes local branches whose tip is an ancestor of the remote-tracking
default or a --merged-into ref, each with update-ref against the planned
sha. It keeps every branch git branch --delete-merged keeps: symbolic,
checked out or registered in any worktree, under a rebase or bisect,
deleteMerged=false in any worktree, system or global config, and the
local upstream of any branch that stays. Git runs without inherited
GIT_* variables except the ceiling; the keep reads run once with the
caller's config variables and once with the default system and
passwd-home config, so the environment can add keeps but never remove
one. Repositories with symlinked refs, foreign worktree registrations or
unreadable holds are refused with the plan still printed.

MEDUSA: MEDUSA-OUTPUT-SQL-003 at branch_cmd.py:121 accepted after an
independent review: the SQL comment+DELETE regex matched the help text
--merged-into and the git key deleteMerged; no SQL in the change.

🎯 Fix: 92 %
💥 Break: 5 %
💸 Cost: 100 %
⏱️ Time: 100 %
🛡️ Security: 100 %
🏆 Score: 87
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-13 08:01:06 +00:00
1 parent 814322f8f5
commit 0d88c69c51
7 files changed
+2780 -34

No files matched your search

+1
View File
@@ -80,6 +80,7 @@ versioning features it can drive **end-to-end workflows**:
| `pkgmgr worktree flow clear [PATH] [--json]` | Removes the merge flow | | `pkgmgr worktree flow clear [PATH] [--json]` | Removes the merge flow |
| `pkgmgr worktree cleanup [--apply]` | Removes clean worktrees whose commits are merged or pushed: pinned `worktree-fini` hook, compose stack, checkout; the branch stays | | `pkgmgr worktree cleanup [--apply]` | Removes clean worktrees whose commits are merged or pushed: pinned `worktree-fini` hook, compose stack, checkout; the branch stays |
| `pkgmgr branch cleanup [--apply]` | Removes merged branches that no worktree holds, locally and on the remotes | | `pkgmgr branch cleanup [--apply]` | Removes merged branches that no worktree holds, locally and on the remotes |
| `pkgmgr branch cleanup --local [--apply] [--json] [--merged-into REF]...` | Deletes only local branches whose tip is an ancestor of the remote-tracking default or of a `--merged-into` REF, each with `git update-ref --no-deref -d` against the planned sha and then its `branch.<name>` section in the repository's own config file (`git config --file <git common dir>/config`; a section it cannot remove is noted on the done item), also when the branch has an upstream or remote copy. Every git call of `--local` runs without any inherited `GIT_*` variable but `GIT_CEILING_DIRECTORIES`, which only narrows repository discovery, so a directory below a ceiling is never resolved to the repository above it; only the config reads of `deleteMerged` and of the upstreams run twice, both with the system config: once with the caller's `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG_PARAMETERS` and `GIT_CONFIG_COUNT` with its `GIT_CONFIG_KEY_<n>` and `GIT_CONFIG_VALUE_<n>`, once without any of them, without `XDG_CONFIG_HOME` and with `HOME` set to the passwd home, and a branch either read keeps stays, so the environment can add keeps but never remove one. A `--merged-into` REF must name exactly one branch or remote-tracking ref, as `git rev-parse --symbolic-full-name` resolves it, in every scanned repository git can read; `X~0`, `X^0`, `X@{0}`, a sha, a range or an unknown ref stops the run. The default comes from `origin`, or from the only remote when there is no `origin`: the ref `refs/remotes/<remote>/HEAD`, else `main`, else `master` resolves to, every symref step inside `refs/remotes/<remote>/`, so never a local branch or `HEAD`; without one, for example with several remotes and no `origin`, the repository is refused, also under `--merged-into`; so is one whose `refs`, `packed-refs`, `reftable`, `logs` or `logs/refs` is a symlink (git-new-workdir), one with a symlink anywhere below `refs/`, `reftable/` or `logs/refs/` (for example `refs/heads/team` linked to another clone's heads), one whose git dir is neither its git common dir nor directly under `<common>/worktrees/` (a registration moved elsewhere), or one whose git output is not UTF-8. The repository is the one the current directory belongs to, also in a submodule or with a separate git dir; one whose main worktree belongs to another repository (`core.worktree`), whose git dir is neither its git common dir nor directly under `<common>/worktrees/`, or whose path is not UTF-8 is refused with exit 2. Skips a symbolic branch, every branch a symbolic ref anywhere under `refs/` (also a tag or remote-tracking one) or under the main worktree's or any registration's `refs/worktree/` points to, a branch that is itself a `--merged-into` target, a branch a worktree holds or that the HEAD of the main repository or of any `worktrees/<id>` registration names (also one `git worktree list` omits or whose id is no valid ref name, read through `git --git-dir=<dir> symbolic-ref HEAD`, so also under reftable), one a rebase or bisect in any worktree works on, one merged only by patch or PR, and, as `git branch --delete-merged` keeps them, one whose `branch.<name>.deleteMerged` is false in the config of any worktree (also its `config.worktree` or an `includeIf "onbranch:..."` file) or in the system config file, and the local upstream, as git's `%(upstream)` resolves it in any worktree, of any branch that stays, also of one that `--branch` or `--exclude` leaves out, each shown as kept with its reason; these holds are read through the git common dir, whichever worktree is scanned, and a registration, symbolic ref, HEAD or state file they cannot read refuses the repository; when a repository vanishes, moves or stops being readable, or its default or a `--merged-into` target stops resolving mid-run, its later items are skipped with that reason and the plan is still printed. A repository git or the filesystem fails on while planning (for example a dangling `.git` file under `--all`) is refused with that reason and the run goes on. The plan, refused repositories included, is printed (as JSON with `--json`), and the run exits 1 when an item failed, else 2 when a repository was refused, else 0. It stops with a message on stderr, no plan and exit 2 in exactly these cases: without `--all`, the current directory is not inside a git repository, its git dir is neither its git common dir nor directly under `<common>/worktrees/`, its main worktree belongs to another repository (`core.worktree`), or its path or git output is not UTF-8; `--apply` outside the host PID namespace; a `--merged-into` REF refused as above; a `--branch` that matches no item. Meant for working clones and their worktrees, not for a bare server repository. Not mirrored from `git branch --delete-merged`: git also keeps a branch whose push would update its own upstream; `--local` does not. Known residuals: a worktree, rebase or symbolic ref that starts holding a branch between its last re-check and its `update-ref -d` is not seeLine truncated
| `pkgmgr branch tree [--format json\|yaml] [--output FILE]` | Writes every managed repository's branches and worktrees with their cleanup state, without fetching | | `pkgmgr branch tree [--format json\|yaml] [--output FILE]` | Writes every managed repository's branches and worktrees with their cleanup state, without fetching |
| `pkgmgr gc [--all] [--apply]` | Both cleanups in one pass, for the current repository or every managed one | | `pkgmgr gc [--all] [--apply]` | Both cleanups in one pass, for the current repository or every managed one |
| `pkgmgr gc --report [--all] [--root DIR] [--threshold-mb MB] [--docker] [--json]` | Read-only disk inventory: worktree sizes, changes a cleanup would keep, `--root` children, core dumps, docker usage, free space per filesystem | | `pkgmgr gc --report [--all] [--root DIR] [--threshold-mb MB] [--docker] [--json]` | Read-only disk inventory: worktree sizes, changes a cleanup would keep, `--root` children, core dumps, docker usage, free space per filesystem |
+807 -19
View File
@@ -10,7 +10,7 @@ from collections.abc import Callable
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from pkgmgr.actions.worktree.hooks import FINI_HOOK, hook_command, run_hook from pkgmgr.actions.worktree.hooks import FINI_HOOK, hook_command, run_hook
from pkgmgr.core.git.errors import GitRunError from pkgmgr.core.git.errors import GitBaseError, GitRunError
from pkgmgr.core.git.queries import ( from pkgmgr.core.git.queries import (
FILTER_CONFIG, FILTER_CONFIG,
UNSAFE_CONFIG, UNSAFE_CONFIG,
@@ -18,7 +18,13 @@ from pkgmgr.core.git.queries import (
local_config, local_config,
resolve_base_branch, resolve_base_branch,
) )
from pkgmgr.core.git.run import END_OF_OPTIONS, SCAN_FLAGS, run from pkgmgr.core.git.run import (
CONFIG_VIEWS,
END_OF_OPTIONS,
SCAN_FLAGS,
run,
strict_env,
)
from .gc_forge import ForgeFacts, fetch_forge_facts from .gc_forge import ForgeFacts, fetch_forge_facts
from .nofollow import canonical, remove_tree from .nofollow import canonical, remove_tree
@@ -53,8 +59,24 @@ REFUSAL = (
"Refusing: not in the host PID namespace (e.g. a sandbox), so running " "Refusing: not in the host PID namespace (e.g. a sandbox), so running "
"processes and worktree locks cannot be verified. Run it on the host." "processes and worktree locks cannot be verified. Run it on the host."
) )
SCOPES = ("worktree", "branch", "all", "purge") SCOPES = ("worktree", "branch", "all", "purge", "local")
NOT_LINKED = "not a linked worktree of this repository" NOT_LINKED = "not a linked worktree of this repository"
NO_REMOTE_DEFAULT = (
"refused: no remote-tracking default (origin, else the only remote: "
"refs/remotes/<remote>/HEAD, main or master, resolving inside "
"refs/remotes/<remote>/), so --local cannot tell which branches are merged"
)
NOT_UTF8 = "refused: git output (ref, remote or path names) is not UTF-8"
DEFAULT_NAMES = ("HEAD", "main", "master")
HELD_FILES = (
("rebase-merge/head-name", "rebase in progress"),
("rebase-merge/update-refs", "rebase in progress"),
("rebase-apply/head-name", "rebase in progress"),
("BISECT_START", "bisect in progress"),
)
DELETE_MERGED_KEY = r"^branch\..*\.deletemerged$"
SHARED_STORES = ("refs", "packed-refs", "reftable", "logs", "logs/refs")
WALKED_STORES = ("refs", "reftable", "logs/refs")
@dataclass @dataclass
@@ -136,6 +158,10 @@ class Repo:
warnings: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list)
items: list[Item] = field(default_factory=list) items: list[Item] = field(default_factory=list)
unsafe: list[str] = field(default_factory=list) unsafe: list[str] = field(default_factory=list)
refused: str | None = None
common: str = ""
opted_out: set[str] = field(default_factory=set)
upstreams: set[tuple[str, str]] = field(default_factory=set)
def host_pid_namespace() -> bool: def host_pid_namespace() -> bool:
@@ -149,10 +175,93 @@ def main_repo_root(cwd: str) -> str:
return os.path.dirname(_common_dir(cwd)) return os.path.dirname(_common_dir(cwd))
def local_repo_root(cwd: str) -> str:
"""
Resolve the main worktree of the repository cwd belongs to, for scope
`local`.
Args:
cwd: a directory inside a checkout, also of a submodule or a
repository with a separate git dir.
Returns:
`git rev-parse --show-toplevel` when cwd is in the main worktree,
else the first `git worktree list` entry; either way of cwd's own
repository.
Raises:
GitBaseError: when that root's git common dir is not cwd's, when
cwd's git dir is not its repository's own (see _own_git_dir),
when either is no repository, or when git output is not UTF-8.
"""
try:
with strict_env():
common, git_dir = _own_git_dir(cwd)
in_main = git_dir == common
root = (in_main and _toplevel(cwd)) or _worktrees(cwd)[0].path
root_common = os.path.realpath(_common_dir(root))
except UnicodeDecodeError as exc:
raise GitBaseError(f"{NOT_UTF8} for {cwd!r}") from exc
except OSError as exc:
raise GitBaseError(f"{cwd}: {exc}") from exc
if root_common != common:
raise GitBaseError(
f"{cwd}: main worktree {root} belongs to another repository than {common}"
)
return root
def _own_git_dir(cwd: str) -> tuple[str, str]:
"""
Resolve cwd's git common dir and git dir, refusing a foreign git dir.
Args:
cwd: a directory inside a checkout or a git dir.
Returns:
The realpaths of `git rev-parse --git-common-dir` and
`--absolute-git-dir`.
Raises:
GitBaseError: when the git dir is neither the common dir nor directly
under `<common>/worktrees/`.
"""
common = os.path.realpath(_common_dir(cwd))
git_dir = os.path.realpath(_git(["rev-parse", "--absolute-git-dir"], cwd))
if git_dir != common and os.path.dirname(git_dir) != os.path.join(
common, "worktrees"
):
raise GitBaseError(
f"{cwd}: git dir {git_dir} is neither {common} nor directly under "
f"{common}/worktrees"
)
return common, git_dir
def _toplevel(cwd: str) -> str:
"""
Read the work tree root of cwd's checkout.
Args:
cwd: a directory inside a checkout.
Returns:
`git rev-parse --show-toplevel`, or "" in a bare repository.
"""
try:
return _git(["rev-parse", "--show-toplevel"], cwd)
except GitRunError:
return ""
def _git(args: list[str], cwd: str) -> str: def _git(args: list[str], cwd: str) -> str:
return run(args, cwd=cwd, scan=True) return run(args, cwd=cwd, scan=True)
def _config_read(args: list[str], cwd: str, view: str) -> str:
return run(args, cwd=cwd, scan=True, config_view=view)
def _common_dir(cwd: str) -> str: def _common_dir(cwd: str) -> str:
return _git(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd) return _git(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd)
@@ -451,6 +560,8 @@ def _skip(
return None return None
if tree.main: if tree.main:
return "checked out in main worktree" return "checked out in main worktree"
if repo.scope == "local":
return f"checked out in worktree {tree.path}"
if not checkout: if not checkout:
return "worktree checkout missing" return "worktree checkout missing"
if repo.containers is None: if repo.containers is None:
@@ -661,6 +772,8 @@ def _actions(
dirty: int, dirty: int,
registrations: Registrations, registrations: Registrations,
) -> list[str]: ) -> list[str]:
if repo.scope == "local":
return _local_actions(repo, item, tree)
tree_part: list[str] = [] tree_part: list[str] = []
if tree: if tree:
if ( if (
@@ -699,6 +812,38 @@ def _actions(
return actions return actions
def _local_actions(repo: Repo, item: Item, tree: Worktree | None) -> list[str]:
"""
Settle the actions of an item planned with scope `local`.
Args:
repo: the planned repository; its default and merged-into lines are
the refs the branch tip must be an ancestor of.
item: the classified item; a merged-clean one whose tip is an
ancestor of none of them gets that as its skip.
tree: the worktree holding its branch, or None.
Returns:
`local-branch` for a merged-clean local branch that no worktree,
worktree registration, rebase, bisect or symbolic ref holds and whose
tip is an ancestor of the default or a merged-into line, else
nothing; never a worktree, stack or remote action.
"""
if item.state != "merged-clean" or not item.local_sha or tree is not None:
return []
held = _held_branches(repo.repo).get(item.branch)
if held:
item.skip = item.skip or held
return []
targets = [repo.default, *repo.lines]
if any(_is_ancestor(item.local_sha, ref, repo.repo) for ref in targets):
return ["local-branch"]
item.skip = item.skip or (
f"merged by {item.merged_by}, tip is no ancestor of {', '.join(targets)}"
)
return []
def _snapshot(repo: Repo) -> tuple[list[Worktree], Snapshot]: def _snapshot(repo: Repo) -> tuple[list[Worktree], Snapshot]:
local, remote, tracking = _refs(repo.repo, repo.remotes) local, remote, tracking = _refs(repo.repo, repo.remotes)
cwds = _process_cwds() if repo.host_ns else {} cwds = _process_cwds() if repo.host_ns else {}
@@ -715,12 +860,15 @@ 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 = ( names = {
sorted(by_branch) "worktree": set(by_branch),
if repo.scope == "worktree" "local": set(local) | set(by_branch),
else sorted(set(local) | set(remote) | set(by_branch)) }.get(repo.scope, set(local) | set(remote) | set(by_branch))
) items = [
items = [_build(repo, name, by_branch.get(name), snapshot) for name in names] _build(repo, name, by_branch.get(name), snapshot) for name in sorted(names)
]
if repo.scope == "local":
return items
items += [ items += [
_build(repo, None, tree, snapshot) _build(repo, None, tree, snapshot)
for tree in trees for tree in trees
@@ -747,6 +895,188 @@ def _stale(repo_root: str) -> list[Item]:
return items return items
def _default_remote(remotes: list[str]) -> str | None:
"""
Pick the one remote whose default scope `local` may measure against.
Args:
remotes: the configured remote names.
Returns:
origin when configured, else the only remote, else None.
"""
if "origin" in remotes:
return "origin"
return remotes[0] if len(remotes) == 1 else None
def _symbolic(repo_root: str, prefix: str, git_dir: str = "") -> dict[str, str]:
"""
List the resolvable refs under a prefix with their symref targets.
Args:
repo_root: main worktree root of the repository.
prefix: a ref prefix such as `refs/heads` or `refs/remotes/origin`.
git_dir: the git common dir (shared refs plus the main worktree's own)
or a `worktrees/<id>` dir whose refs to list, or "" for the refs
as repo_root's worktree sees them.
Returns:
Full ref name -> the ref it points to, or "" for a plain ref.
"""
args = ["for-each-ref", "--format=%(refname)%00%(symref)", prefix]
out = _git([f"--git-dir={git_dir}", *args] if git_dir else args, repo_root)
return dict(line.split("\x00", 1) for line in out.splitlines())
def _resolve_within(refs: dict[str, str], ref: str) -> str | None:
"""
Follow a ref's symref chain without leaving a set of refs.
Args:
refs: refs under one prefix, as _symbolic lists them.
ref: the full ref name to resolve.
Returns:
The plain ref the chain ends at, or None when a step leaves refs,
loops or ref is missing.
"""
seen: set[str] = set()
while ref in refs and ref not in seen:
seen.add(ref)
if not refs[ref]:
return ref
ref = refs[ref]
return None
def _remote_default(repo_root: str, remotes: list[str]) -> str | None:
"""
Pick the remote-tracking ref that scope `local` measures merges against.
Args:
repo_root: main worktree root of the repository.
remotes: the configured remote names.
Returns:
For the remote _default_remote picks, the plain ref its HEAD, else
main, else master resolves to while every step stays inside
`refs/remotes/<remote>/`; else None.
"""
remote = _default_remote(remotes)
if remote is None:
return None
refs = _symbolic(repo_root, f"refs/remotes/{remote}")
for name in DEFAULT_NAMES:
target = _resolve_within(refs, f"refs/remotes/{remote}/{name}")
if target:
return target
return None
def _worktree_heads(repo_root: str, git_dirs: list[str]) -> dict[str, str]:
"""
Read the branch every worktree's HEAD names, on any ref backend.
Args:
repo_root: main worktree root of the repository.
git_dirs: the git common dir first, then each `worktrees/<id>`.
Returns:
Branch name -> skip reason, from `git --git-dir=<dir> symbolic-ref
HEAD` for each of them, whatever its id.
Raises:
GitBaseError: when a HEAD is neither readable nor detached.
"""
heads: dict[str, str] = {}
for git_dir in git_dirs:
try:
target = _git(
[f"--git-dir={git_dir}", "symbolic-ref", "--quiet", "HEAD"], repo_root
)
except GitRunError as exc:
if exc.returncode == 1:
continue
raise
if target.startswith("refs/heads/"):
heads.setdefault(
target.removeprefix("refs/heads/"), f"checked out in {git_dir}"
)
return heads
def _git_dirs(repo_root: str) -> list[str]:
"""
List the git dirs of every worktree of the repository.
Args:
repo_root: main worktree root of the repository.
Returns:
The git common dir, then each entry of `<common>/worktrees/`, whatever
its id and whether `git worktree list` shows it.
"""
common = _common_dir(repo_root)
base = os.path.join(common, "worktrees")
entries = sorted(os.listdir(base)) if os.path.isdir(base) else []
return [common, *(os.path.join(base, entry) for entry in entries)]
def _held_branches(repo_root: str) -> dict[str, str]:
"""
Find the local branches that something besides a listed worktree holds.
Args:
repo_root: main worktree root of the repository.
Returns:
Branch name -> skip reason: a symbolic branch, the branch any symbolic
ref under `refs/` (also a tag or remote-tracking one) or under the
main worktree's or any registration's `refs/worktree/` names, the HEAD
of the main repository and of every `worktrees/<id>` registration,
also one `git worktree list` omits, each rebase-merge and rebase-apply
head-name, an interactive rebase's update-refs and BISECT_START; all
read through the git common dir, whichever worktree repo_root is.
Raises:
GitBaseError: when git cannot read a registration, symref or HEAD.
OSError: when a state file exists but cannot be read.
ValueError: when a state file is not UTF-8.
"""
git_dirs = _git_dirs(repo_root)
held: dict[str, str] = {}
symrefs = _symbolic(repo_root, "refs", git_dirs[0])
for git_dir in git_dirs[1:]:
for ref, target in _symbolic(repo_root, "refs/worktree", git_dir).items():
symrefs[f"worktrees/{os.path.basename(git_dir)}/{ref}"] = target
for ref, target in symrefs.items():
if target and ref.startswith("refs/heads/"):
held.setdefault(
ref.removeprefix("refs/heads/"), f"symbolic ref to {target}"
)
if target.startswith("refs/heads/"):
held.setdefault(
target.removeprefix("refs/heads/"), f"target of symbolic ref {ref}"
)
for branch, reason in _worktree_heads(repo_root, git_dirs).items():
held.setdefault(branch, reason)
for git_dir in git_dirs:
for name, operation in HELD_FILES:
try:
with open(os.path.join(git_dir, name), encoding="utf-8") as fh:
lines = fh.read().splitlines()
except FileNotFoundError:
continue
for line in lines:
ref = line.strip().removeprefix("ref: ")
if ref.startswith("refs/heads/") or name == "BISECT_START":
held.setdefault(
ref.removeprefix("refs/heads/"), f"{operation} in {git_dir}"
)
return held
def _lines( def _lines(
repo_root: str, refs: list[str], remotes: list[str], warnings: list[str] repo_root: str, refs: list[str], remotes: list[str], warnings: list[str]
) -> tuple[dict[str, tuple[str, set[str]]], set[str]]: ) -> tuple[dict[str, tuple[str, set[str]]], set[str]]:
@@ -778,6 +1108,116 @@ def _lines(
return lines, names return lines, names
def _branch_names(repo_root: str, ref: str, remotes: list[str]) -> set[str]:
"""
Name the branch a merged-into ref is, whatever form it was given in.
Args:
repo_root: main worktree root of the repository.
ref: the ref as given, e.g. `refs/heads/x`, `x` or `origin/x`.
remotes: the configured remote names.
Returns:
`x` for a ref that resolves to `refs/heads/x` or
`refs/remotes/<remote>/x`, else nothing.
"""
out = _git(["rev-parse", "--symbolic-full-name", END_OF_OPTIONS, ref], repo_root)
full = out.splitlines()[-1] if out else ""
prefixes = ["refs/heads/", *(f"refs/remotes/{r}/" for r in remotes)]
return {full.removeprefix(p) for p in prefixes if full.startswith(p)}
def _raise(exc: OSError) -> None:
raise exc
def _shared_refs(repo_root: str) -> str:
"""
Detect a ref store that other checkouts share through a symlink.
Args:
repo_root: main worktree root of the repository.
Returns:
The refusal reason when `refs`, `packed-refs`, `reftable`, `logs` or
`logs/refs` in the git common dir is a symlink (git-new-workdir), or
any entry below `refs/`, `reftable/` or `logs/refs/` is, else "".
Raises:
OSError: when a directory below them cannot be listed.
"""
common = _common_dir(repo_root)
shared = [
name for name in SHARED_STORES if os.path.islink(os.path.join(common, name))
]
stores = [] if shared else [os.path.join(common, s) for s in WALKED_STORES]
for store in filter(os.path.isdir, stores):
for parent, dirs, files in os.walk(store, onerror=_raise):
shared += [
os.path.relpath(os.path.join(parent, name), common)
for name in sorted(dirs + files)
if os.path.islink(os.path.join(parent, name))
]
if not shared:
return ""
return (
f"refused: {', '.join(shared)} in {common} is a symlink, so checkouts "
"sharing these refs hold branches git cannot list"
)
def _refused(
repo_root: str,
containers: list[Container] | None,
host_ns: bool,
reason: str,
warnings: list[str],
*,
scope: str,
sizes: bool,
min_age: float | None,
activity: bool,
**_options: object,
) -> Repo:
"""
Build a repository that scope `local` refuses: no items, nothing deleted.
Args:
repo_root: main worktree root of the repository.
containers: as plan_repo takes them.
host_ns: as plan_repo takes it.
reason: why the repository is refused; also appended to warnings.
warnings: warnings gathered so far.
scope: the planned scope.
sizes: as plan_repo takes it.
min_age: as plan_repo takes it.
activity: as plan_repo takes it.
_options: the other plan_repo options, unused.
Returns:
The refused repository.
"""
return Repo(
repo=repo_root,
default="",
remotes=[],
protected=set(),
first_parent=set(),
urls={},
forge=ForgeFacts(),
containers=containers,
host_ns=host_ns,
lines={},
sizes=sizes,
min_age=min_age,
scope=scope,
activity=activity,
hooks={},
warnings=[*warnings, reason],
refused=reason,
)
def plan_repo( def plan_repo(
repo_root: str, repo_root: str,
containers: list[Container] | None, containers: list[Container] | None,
@@ -815,6 +1255,8 @@ def plan_repo(
""" """
if scope not in SCOPES: if scope not in SCOPES:
raise ValueError(f"unknown scope {scope!r}") raise ValueError(f"unknown scope {scope!r}")
if scope == "local":
_own_git_dir(repo_root)
warnings: list[str] = [] warnings: list[str] = []
unsafe = local_config(repo_root, UNSAFE_CONFIG) unsafe = local_config(repo_root, UNSAFE_CONFIG)
if unsafe: if unsafe:
@@ -854,6 +1296,22 @@ def plan_repo(
) )
except GitRunError: except GitRunError:
continue continue
if scope == "local":
default = _remote_default(repo_root, remotes)
reason = _shared_refs(repo_root) or (NO_REMOTE_DEFAULT if not default else "")
if reason:
return _refused(
repo_root,
containers,
host_ns,
reason,
warnings,
scope=scope,
sizes=sizes,
min_age=min_age,
activity=activity,
)
else:
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, scan=True "main", "master", cwd=repo_root, scan=True
) )
@@ -864,6 +1322,10 @@ def plan_repo(
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)
if scope == "local":
line_names |= {
name for ref in lines for name in _branch_names(repo_root, ref, remotes)
}
repo = Repo( repo = Repo(
repo=repo_root, repo=repo_root,
@@ -895,9 +1357,123 @@ def plan_repo(
unsafe=unsafe, unsafe=unsafe,
) )
repo.items = _items(repo) repo.items = _items(repo)
if scope == "local":
repo.opted_out = _opted_out(repo_root)
repo.upstreams = _local_upstreams(repo_root)
return repo return repo
def _opted_out(repo_root: str) -> set[str]:
"""
Find the branches whose `branch.<name>.deleteMerged` is false.
Args:
repo_root: main worktree root of the repository.
Returns:
Each branch that `git --git-dir=<dir> config` sets false in, for each
of _git_dirs (so also config.worktree and includeIf onbranch) in each
of CONFIG_VIEWS, so the union of what the caller's config variables
and the default system and global config give.
Raises:
GitBaseError: when git cannot read one of these configs.
"""
names: set[str] = set()
for git_dir in _git_dirs(repo_root):
for view in CONFIG_VIEWS:
try:
out = _config_read(
[
f"--git-dir={git_dir}",
"config",
"--null",
"--type=bool",
"--get-regexp",
DELETE_MERGED_KEY,
],
repo_root,
view,
)
except GitRunError as exc:
if exc.returncode == 1:
continue
raise
for record in out.split("\0"):
key, _, value = record.partition("\n")
if value == "false":
names.add(key.removeprefix("branch.").removesuffix(".deletemerged"))
return names
def _local_upstreams(repo_root: str) -> set[tuple[str, str]]:
"""
Read each local branch's upstream as git resolves it in any worktree.
Args:
repo_root: main worktree root of the repository.
Returns:
(branch, upstream branch) for each `%(upstream)` under `refs/heads/`
that `git --git-dir=<dir> for-each-ref` gives for any of _git_dirs in
any of CONFIG_VIEWS.
"""
pairs: set[tuple[str, str]] = set()
for git_dir in _git_dirs(repo_root):
for view in CONFIG_VIEWS:
out = _config_read(
[
f"--git-dir={git_dir}",
"for-each-ref",
"--format=%(refname)%00%(upstream)",
"refs/heads",
],
repo_root,
view,
)
for line in out.splitlines():
ref, _, upstream = line.partition("\0")
if upstream.startswith("refs/heads/"):
pairs.add(
(
ref.removeprefix("refs/heads/"),
upstream.removeprefix("refs/heads/"),
)
)
return pairs
def _keep_by_config(repo: Repo, chosen: Callable[[Item], bool]) -> None:
"""
Keep, as `git branch --delete-merged` does, each branch whose
`branch.<name>.deleteMerged` is false and each local upstream of a branch
that stays, also of one kept so or one the filters leave out.
Args:
repo: a repository planned with scope `local`; each kept item that
would be deleted loses its actions and gets the reason as its skip.
chosen: the --branch and --exclude filter; only an eligible chosen
item is deleted, every other branch stays.
"""
reasons = {
name: f"kept: branch.{name}.deleteMerged is false" for name in repo.opted_out
}
pending = {
item.branch: item for item in repo.items if item.eligible and chosen(item)
}
while True:
for child, parent in sorted(repo.upstreams):
if child not in pending:
reasons.setdefault(parent, f"kept: local upstream of {child}")
kept = reasons.keys() & pending.keys()
if not kept:
return
for name in kept:
item = pending.pop(name)
item.actions = []
item.skip = item.skip or reasons[name]
def _pins( def _pins(
hooks: dict[str, dict[str, list[str]]], repo_root: str, warnings: list[str] hooks: dict[str, dict[str, list[str]]], repo_root: str, warnings: list[str]
) -> dict[str, list[str]]: ) -> dict[str, list[str]]:
@@ -1005,6 +1581,32 @@ def _changed_by_hook(repo: Repo, item: Item) -> str | None:
return _busy(_process_cwds(), path) if repo.host_ns else None return _busy(_process_cwds(), path) if repo.host_ns else None
def _drop_branch_config(repo_root: str, branch: str) -> str | None:
"""
Remove a deleted branch's `branch.<name>` section, as `branch -D` does,
from the config file in the repository's own git common dir.
Args:
repo_root: main worktree root of the repository.
branch: the deleted branch; a missing section is no error.
Returns:
None, or a note why the section was kept, e.g. a locked config.
"""
config = os.path.join(_common_dir(repo_root), "config")
try:
_git(
["config", "--file", config, "--remove-section", f"branch.{branch}"],
repo_root,
)
except GitRunError as exc:
stderr = (exc.stderr or "").strip()
if "no such section" in stderr.lower():
return None
return f"config branch.{branch} kept: {(stderr or str(exc)).splitlines()[0]}"
return None
def execute(repo: Repo, item: Item) -> None: def execute(repo: Repo, item: Item) -> None:
fresh = _rebuild(repo, item) fresh = _rebuild(repo, item)
if _fingerprint(fresh) != _fingerprint(item): if _fingerprint(fresh) != _fingerprint(item):
@@ -1036,7 +1638,20 @@ def execute(repo: Repo, item: Item) -> None:
notes.append(f"image kept: {image}") notes.append(f"image kept: {image}")
if removes_tree: if removes_tree:
_remove_worktree(repo.repo, item.worktree) _remove_worktree(repo.repo, item.worktree)
if "local-branch" in item.actions: if "local-branch" in item.actions and repo.scope == "local":
_git(
[
"update-ref",
"--no-deref",
"-d",
f"refs/heads/{item.branch}",
item.local_sha,
],
repo.repo,
)
note = _drop_branch_config(repo.repo, item.branch)
notes += [note] if note else []
elif "local-branch" in item.actions:
_git(["branch", "-D", "--", item.branch], repo.repo) _git(["branch", "-D", "--", item.branch], repo.repo)
for remote, sha in item.remotes.items(): for remote, sha in item.remotes.items():
if f"remote-branch:{remote}" not in item.actions: if f"remote-branch:{remote}" not in item.actions:
@@ -1120,6 +1735,99 @@ def _render(
) )
def _plan(
root: str, containers: list[Container] | None, host_ns: bool, options: dict
) -> Repo:
"""
Plan one repository; under scope `local` refuse, instead of raising, one
that git or the filesystem fails on or whose git output is not UTF-8.
Args:
root: main worktree root of the repository.
containers: as plan_repo takes them.
host_ns: as plan_repo takes it.
options: the plan_repo keyword options.
Returns:
The planned repository, under scope `local` with its realpath git
common dir, or the refused one.
"""
if options["scope"] != "local":
return plan_repo(root, containers, host_ns, **options)
try:
repo = plan_repo(root, containers, host_ns, **options)
repo.common = os.path.realpath(_common_dir(root))
return repo
except UnicodeDecodeError:
reason = NOT_UTF8
except (GitBaseError, OSError, ValueError) as exc:
reason = f"refused: {_describe(exc)}"
return _refused(root, containers, host_ns, reason, [], **options)
def _gone(repo: Repo) -> str:
"""
Check that a repository planned with scope `local` is still the one
planned and that its targets still resolve.
Args:
repo: the planned repository.
Returns:
Why its remaining items must be skipped, else "".
"""
try:
if os.path.realpath(_common_dir(repo.repo)) != repo.common:
return f"repository {repo.repo} is no longer the planned one"
gone = [
ref
for ref in (repo.default, *repo.lines)
if _sha(repo.repo, f"{ref}^{{commit}}") is None
]
except (GitBaseError, OSError, ValueError) as exc:
return f"repository {repo.repo} unreadable: {_describe(exc)}"
return f"target {', '.join(gone)} is gone" if gone else ""
def _apply(repo: Repo, item: Item) -> None:
"""
Execute one planned item; under scope `local` without raising.
Args:
repo: the planned repository.
item: an eligible item; under scope `local` it ends with a done,
failed or skipped result, skipped as _gone tells.
"""
if repo.scope != "local":
execute(repo, item)
return
gone = _gone(repo)
if gone:
item.result = f"skipped: {gone}"
return
try:
execute(repo, item)
except (RuntimeError, OSError, ValueError) as exc:
item.result = f"failed: {_describe(exc)}"
def _exit_code(repos: list[Repo], apply: bool) -> int:
"""
Settle the exit code of a cleanup run.
Args:
repos: the planned and, with apply, executed repositories.
apply: whether the plan was executed.
Returns:
1 when an applied item failed, else 2 when a repository was
refused, else 0.
"""
if apply and _finish(repos):
return 1
return 2 if any(repo.refused for repo in repos) else 0
def prepare( def prepare(
repo_roots: list[str], repo_roots: list[str],
include: list[str], include: list[str],
@@ -1127,8 +1835,8 @@ def prepare(
options: dict, options: dict,
) -> tuple[list[Repo], bool, Callable[[Item], bool]] | None: ) -> tuple[list[Repo], bool, Callable[[Item], bool]] | None:
host_ns = host_pid_namespace() host_ns = host_pid_namespace()
containers = list_compose_containers() containers = [] if options["scope"] == "local" else list_compose_containers()
repos = [plan_repo(root, containers, host_ns, **options) for root in repo_roots] repos = [_plan(root, containers, host_ns, options) for root in repo_roots]
unknown = [ unknown = [
name name
for name in include for name in include
@@ -1143,13 +1851,16 @@ def prepare(
item.matches(n) for n in exclude item.matches(n) for n in exclude
) )
for repo in repos:
if repo.scope == "local":
_keep_by_config(repo, chosen)
return repos, host_ns, chosen return repos, host_ns, chosen
def _finish(repos: list[Repo]) -> int: def _finish(repos: list[Repo]) -> int:
results = [item.result for repo in repos for item in repo.items if item.result] results = [item.result for repo in repos for item in repo.items if item.result]
if any(result.startswith("done") for result in results) and any( if any(result.startswith("done") for result in results) and any(
repo.containers is not None for repo in repos repo.containers is not None and repo.scope != "local" for repo in repos
): ):
for args in (["image", "prune", "-f"], ["builder", "prune", "-f"]): for args in (["image", "prune", "-f"], ["builder", "prune", "-f"]):
try: try:
@@ -1180,7 +1891,13 @@ def cleanup(
scope: `worktree` removes clean worktrees whose commits are merged or scope: `worktree` removes clean worktrees whose commits are merged or
pushed (hook, stack, checkout; the branch stays); `branch` removes pushed (hook, stack, checkout; the branch stays); `branch` removes
merged branches without a worktree, local and remote; `all` does merged branches without a worktree, local and remote; `all` does
both in one pass. both in one pass; `local` deletes, with `update-ref -d` against
the planned sha, each local branch that no worktree, rebase or
bisect holds, that _keep_by_config does not keep and whose tip
is an ancestor of the remote-tracking
default or a merged_into line, refuses a repository without such
a default, and reads no forge token, runs no fetch, push, docker,
hook or prune.
apply: execute the plan instead of only printing it. apply: execute the plan instead of only printing it.
as_json: print the plan as JSON instead of text lines. as_json: print the plan as JSON instead of text lines.
include: only items whose branch or worktree path matches one of these. include: only items whose branch or worktree path matches one of these.
@@ -1192,9 +1909,10 @@ def cleanup(
CLI; a worktree-fini hook runs only for a root pinned there. CLI; a worktree-fini hook runs only for a root pinned there.
Returns: Returns:
0 on success, 1 when an applied item failed, 2 when refused. As _exit_code settles it: 1 when an applied item failed, else 2 when
refused (also a repository scope `local` refused), else 0.
""" """
if scope not in ("worktree", "branch", "all"): if scope not in ("worktree", "branch", "all", "local"):
raise ValueError(f"unknown cleanup scope {scope!r}") raise ValueError(f"unknown cleanup scope {scope!r}")
if apply and not host_pid_namespace(): if apply and not host_pid_namespace():
print(REFUSAL, file=sys.stderr) print(REFUSAL, file=sys.stderr)
@@ -1206,7 +1924,76 @@ def cleanup(
"scope": scope, "scope": scope,
"activity": False, "activity": False,
"hooks": hooks, "hooks": hooks,
"fetch": scope != "local",
"forge_source": (lambda _urls: ForgeFacts()) if scope == "local" else None,
} }
if scope != "local":
return _run(repo_roots, options, include, exclude, apply, as_json)
with strict_env():
bad = _bad_lines(repo_roots, merged_into)
if bad:
print(f"Refusing: {bad}", file=sys.stderr)
return 2
return _run(repo_roots, options, include, exclude, apply, as_json)
def _bad_lines(repo_roots: list[str], merged_into: list[str]) -> str:
"""
Check that every merged-into ref of scope `local` names one branch.
Args:
repo_roots: main worktree roots of the repositories to scan; one git
cannot read is left to the planning, which refuses it.
merged_into: the refs as given.
Returns:
Why a ref is refused, when in some repository it is unknown or
`rev-parse --symbolic-full-name` does not name exactly one ref under
`refs/heads/` or `refs/remotes/`, else "".
"""
for root in repo_roots:
for ref in merged_into:
try:
out = _git(
["rev-parse", "--symbolic-full-name", END_OF_OPTIONS, ref], root
)
except GitRunError:
return f"--merged-into {ref} names no ref in {root}"
except (GitBaseError, OSError, ValueError):
break
names = [line for line in out.splitlines() if line != END_OF_OPTIONS]
if len(names) != 1 or not names[0].startswith(
("refs/heads/", "refs/remotes/")
):
return (
f"--merged-into {ref} is not exactly one branch or "
f"remote-tracking ref in {root}"
)
return ""
def _run(
repo_roots: list[str],
options: dict,
include: list[str],
exclude: list[str],
apply: bool,
as_json: bool,
) -> int:
"""
Plan, print and with apply execute a cleanup, as cleanup describes it.
Args:
repo_roots: main worktree roots of the repositories to scan.
options: the plan_repo keyword options.
include: as cleanup takes it.
exclude: as cleanup takes it.
apply: as cleanup takes it.
as_json: as cleanup takes it.
Returns:
2 for an unknown include, else as _exit_code settles it.
"""
prepared = prepare(repo_roots, include, exclude, options) prepared = prepare(repo_roots, include, exclude, options)
if prepared is None: if prepared is None:
return 2 return 2
@@ -1216,13 +2003,14 @@ def cleanup(
for repo in repos: for repo in repos:
for item in repo.items: for item in repo.items:
if item.eligible and chosen(item): if item.eligible and chosen(item):
execute(repo, item) _apply(repo, item)
print( print(
f"[{scope}] {item.branch or item.worktree}: {item.result}", f"[{options['scope']}] {item.branch or item.worktree}: "
f"{item.result}",
file=sys.stderr, file=sys.stderr,
) )
_render(repos, host_ns, chosen, as_json) _render(repos, host_ns, chosen, as_json)
return _finish(repos) if apply else 0 return _exit_code(repos, apply)
def branch_purge( def branch_purge(
+18 -8
View File
@@ -11,7 +11,7 @@ from pkgmgr.actions.branch import (
open_branch, open_branch,
tree, tree,
) )
from pkgmgr.actions.branch.gc import main_repo_root from pkgmgr.actions.branch.gc import local_repo_root, main_repo_root
from pkgmgr.cli.context import CLIContext from pkgmgr.cli.context import CLIContext
from pkgmgr.core.git.errors import GitBaseError 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
@@ -60,14 +60,23 @@ def refresh_tree(roots: list[str]) -> None:
print(f"WARN tree file not rewritten for {root}: {exc}", file=sys.stderr) print(f"WARN tree file not rewritten for {root}: {exc}", file=sys.stderr)
def scan_repo_roots(args, ctx: CLIContext) -> list[str]: def scan_repo_roots(args, ctx: CLIContext, local: bool = False) -> list[str]:
""" """
Resolve the repositories a scan covers: the current one, or every Resolve the repositories a scan covers: the current one, or every
pkgmgr-managed one with --all. Exits with 2 outside a repository. pkgmgr-managed one with --all. Exits with 2 outside a repository.
Args:
args: parsed arguments; `all` selects every managed repository.
ctx: CLI context.
local: resolve the current repository as scope `local` does, also in
a submodule or with a separate git dir, refusing a mismatch.
Returns:
The main worktree roots to scan.
""" """
if not args.all: if not args.all:
try: try:
return [main_repo_root(".")] return [(local_repo_root if local else main_repo_root)(".")]
except GitBaseError as exc: except GitBaseError as exc:
print(f"Not inside a git repository: {exc}", file=sys.stderr) print(f"Not inside a git repository: {exc}", file=sys.stderr)
sys.exit(2) sys.exit(2)
@@ -84,7 +93,7 @@ def scan_options(args) -> dict:
def run_cleanup(args, ctx: CLIContext, scope: str) -> None: def run_cleanup(args, ctx: CLIContext, scope: str) -> None:
roots = scan_repo_roots(args, ctx) roots = scan_repo_roots(args, ctx, local=scope == "local")
code = cleanup( code = cleanup(
roots, roots,
scope=scope, scope=scope,
@@ -94,7 +103,7 @@ def run_cleanup(args, ctx: CLIContext, scope: str) -> None:
hooks=pinned_hooks(ctx), hooks=pinned_hooks(ctx),
**scan_options(args), **scan_options(args),
) )
if args.apply: if args.apply and scope != "local":
refresh_tree(roots) refresh_tree(roots)
sys.exit(code) sys.exit(code)
@@ -107,8 +116,9 @@ def handle_branch(args, ctx: CLIContext) -> None:
- pkgmgr branch open [<name>] [--base <branch>] - pkgmgr branch open [<name>] [--base <branch>]
- pkgmgr branch close [<name>] [--base <branch>] [--force|-f] - pkgmgr branch close [<name>] [--base <branch>] [--force|-f]
- pkgmgr branch drop [<name>] [--base <branch>] [--force|-f] - pkgmgr branch drop [<name>] [--base <branch>] [--force|-f]
- pkgmgr branch cleanup [--apply] [--json] [--all] [--branch N]... [--exclude N]... - pkgmgr branch cleanup [--apply] [--json] [--local] [--all] [--branch N]...
[--merged-into REF]... [--sizes] [--min-age HOURS] [--exclude N]... [--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] - pkgmgr branch tree [--format json|yaml] [--output FILE]
@@ -124,7 +134,7 @@ def handle_branch(args, ctx: CLIContext) -> None:
) )
if args.subcommand == "cleanup": if args.subcommand == "cleanup":
run_cleanup(args, ctx, "branch") run_cleanup(args, ctx, "local" if args.local else "branch")
if args.subcommand == "purge": if args.subcommand == "purge":
roots = scan_repo_roots(args, ctx) roots = scan_repo_roots(args, ctx)
+35
View File
@@ -105,6 +105,41 @@ def add_branch_subparsers(
), ),
) )
add_apply_options(branch_cleanup) add_apply_options(branch_cleanup)
branch_cleanup.add_argument(
"--local",
action="store_true",
help=(
"Delete only local branches whose tip is an ancestor of origin's "
"remote-tracking default (else the only remote's) or a --merged-into "
"REF, against the planned sha; refuse a repository without such a "
"default, one git fails on, one whose holds cannot be read, one "
"whose git dir is neither its common dir nor directly under "
"<common>/worktrees/ and one with a symlink in or below refs, "
"packed-refs, reftable or logs/refs, and still print the plan; skip "
"a symbolic branch, a branch any symbolic ref of any worktree names, "
"a branch a worktree, worktree registration, rebase or bisect holds, "
"a --merged-into target, a branch with branch.<name>.deleteMerged "
"false in any worktree's or the system config and the local "
"upstream of a branch that stays, also of one --branch or --exclude "
"leaves out; "
"run git without inherited GIT_* variables but "
"GIT_CEILING_DIRECTORIES; the keep config reads run once with the "
"caller's GIT_CONFIG_* variables and once with the default system "
"and passwd-home config, and either read's keep counts; read no "
"forge token; "
"never fetch, push, delete a remote branch, run docker or hooks, or "
"refresh the branch tree file; exit 1 when an item failed, else 2 "
"when a repository was refused, else 0; exit 2 without a plan when, "
"without --all, the current directory is in no git repository, its "
"git dir is neither its common dir nor directly under "
"<common>/worktrees/, its main worktree belongs to another "
"repository or its path or git output is not UTF-8, on --apply "
"outside the host PID namespace, on a --merged-into REF that is not "
"exactly one branch or remote-tracking ref (X~0, X^0, X@{0}, a sha), "
"and on a --branch "
"that matches nothing; meant for working clones"
),
)
branch_purge = branch_subparsers.add_parser( branch_purge = branch_subparsers.add_parser(
"purge", "purge",
+87 -6
View File
@@ -1,7 +1,12 @@
from __future__ import annotations from __future__ import annotations
import contextvars
import os import os
import pwd
import re
import subprocess import subprocess
from collections.abc import Iterator
from contextlib import contextmanager
from .errors import GitNotRepositoryError, GitRunError from .errors import GitNotRepositoryError, GitRunError
@@ -52,10 +57,20 @@ SCAN_UNSET = (
"GIT_PAGER", "GIT_PAGER",
"PAGER", "PAGER",
) )
STRICT_KEEP = ("GIT_CEILING_DIRECTORIES",)
CONFIG_READ_KEEP = re.compile(
r"GIT_CONFIG_(GLOBAL|SYSTEM|PARAMETERS|COUNT|KEY_\d+|VALUE_\d+)"
)
CALLER_CONFIG = "caller"
DEFAULT_CONFIG = "default"
CONFIG_VIEWS = (CALLER_CONFIG, DEFAULT_CONFIG)
SCAN_TIMEOUT = 60 SCAN_TIMEOUT = 60
# ref and remote names come from repositories an agent may write: one named # ref and remote names come from repositories an agent may write: one named
# like --output=<path> would otherwise make rev-list create that file # like --output=<path> would otherwise make rev-list create that file
END_OF_OPTIONS = "--end-of-options" END_OF_OPTIONS = "--end-of-options"
_STRICT_ENV: contextvars.ContextVar[bool] = contextvars.ContextVar(
"pkgmgr_git_strict_env", default=False
)
def _is_not_repo_error(stderr: str) -> bool: def _is_not_repo_error(stderr: str) -> bool:
@@ -63,9 +78,61 @@ def _is_not_repo_error(stderr: str) -> bool:
return "not a git repository" in msg return "not a git repository" in msg
def _scan_env() -> dict[str, str]: @contextmanager
env = {key: value for key, value in os.environ.items() if key not in SCAN_UNSET} def strict_env() -> Iterator[None]:
return {**env, **SCAN_ENV} token = _STRICT_ENV.set(True)
try:
yield
finally:
_STRICT_ENV.reset(token)
def _strict_keeps(key: str, caller: bool) -> bool:
return key in STRICT_KEEP or (
caller and CONFIG_READ_KEEP.fullmatch(key) is not None
)
def _passwd_home() -> str | None:
try:
return pwd.getpwuid(os.getuid()).pw_dir
except KeyError:
return None
def _scan_env(strict: bool = False, view: str | None = None) -> dict[str, str]:
"""
Build the environment of a scan or strict git call.
Args:
strict: drop every inherited `GIT_*` variable but STRICT_KEEP.
view: None, CALLER_CONFIG or DEFAULT_CONFIG, as run takes it.
Returns:
The inherited environment without SCAN_UNSET, plus SCAN_ENV; for a
config view with the system config; for DEFAULT_CONFIG also without
any `GIT_*` but STRICT_KEEP and XDG_CONFIG_HOME, HOME set to the
passwd home (unset without a passwd entry).
"""
caller = view == CALLER_CONFIG
scrub = strict or view == DEFAULT_CONFIG
env = {
key: value
for key, value in os.environ.items()
if key not in SCAN_UNSET
and not (scrub and key.startswith("GIT_") and not _strict_keeps(key, caller))
}
env = {**env, **SCAN_ENV}
if view is None:
return env
del env["GIT_CONFIG_NOSYSTEM"]
if view == DEFAULT_CONFIG:
env.pop("XDG_CONFIG_HOME", None)
env.pop("HOME", None)
home = _passwd_home()
if home:
env["HOME"] = home
return env
def run( def run(
@@ -75,6 +142,7 @@ def run(
preview: bool = False, preview: bool = False,
scan: bool = False, scan: bool = False,
input_text: str | None = None, input_text: str | None = None,
config_view: str | None = None,
) -> 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.
@@ -85,9 +153,17 @@ def run(
preview: print the command instead of running it. preview: print the command instead of running it.
scan: read a repository an agent may have written: no fsmonitor, hook, scan: read a repository an agent may have written: no fsmonitor, hook,
pager, lazy fetch, replace object, system config or prompt, stdin pager, lazy fetch, replace object, system config or prompt, stdin
closed, a timeout. closed, a timeout. Inside strict_env() every call, scan or not,
also runs without any inherited `GIT_*` variable but
`GIT_CEILING_DIRECTORIES`, which only narrows discovery.
input_text: text fed to stdin, e.g. for `cat-file --batch-check`; input_text: text fed to stdin, e.g. for `cat-file --batch-check`;
replaces the closed stdin of scan mode. replaces the closed stdin of scan mode.
config_view: for a pure config read, the config it sees, both with
the system config: CALLER_CONFIG also keeps, inside strict_env(),
`GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG_PARAMETERS`,
`GIT_CONFIG_COUNT` and its `GIT_CONFIG_KEY_<n>` and
`GIT_CONFIG_VALUE_<n>`; DEFAULT_CONFIG reads the default system
and passwd-home global config whatever the environment says.
Raises GitRunError (or a subclass) if execution fails or times out. Raises GitRunError (or a subclass) if execution fails or times out.
""" """
@@ -98,10 +174,15 @@ def run(
print(f"[PREVIEW] Would run in {cwd!r}: {cmd_str}") print(f"[PREVIEW] Would run in {cwd!r}: {cmd_str}")
return "" return ""
strict = _STRICT_ENV.get()
hardened: dict = ( hardened: dict = (
{"env": _scan_env(), "stdin": subprocess.DEVNULL, "timeout": SCAN_TIMEOUT} {
"env": _scan_env(strict, config_view),
"stdin": subprocess.DEVNULL,
"timeout": SCAN_TIMEOUT,
}
if scan if scan
else {} else ({"env": _scan_env(True, config_view)} if strict else {})
) )
if input_text is not None: if input_text is not None:
hardened.pop("stdin", None) hardened.pop("stdin", None)
File diff suppressed because it is too large. Load diff
+97 -1
View File
@@ -1,8 +1,12 @@
from __future__ import annotations
import os
import pwd
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from pkgmgr.core.git.errors import GitRunError from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.run import run from pkgmgr.core.git.run import CALLER_CONFIG, DEFAULT_CONFIG, run, strict_env
class TestGitRun(unittest.TestCase): class TestGitRun(unittest.TestCase):
@@ -55,6 +59,98 @@ class TestGitRun(unittest.TestCase):
self.assertNotIn("stdin", kwargs) self.assertNotIn("stdin", kwargs)
self.assertIn("timeout", kwargs) self.assertIn("timeout", kwargs)
OVERRIDES = (
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_COMMON_DIR",
"GIT_CONFIG",
"GIT_CEILING_DIRECTORIES",
"GIT_CONFIG_GLOBAL",
"GIT_CONFIG_SYSTEM",
"GIT_CONFIG_PARAMETERS",
"GIT_CONFIG_COUNT",
"GIT_CONFIG_KEY_0",
"GIT_CONFIG_VALUE_0",
"GIT_CONFIG_KEY_X",
)
CONFIG_READ = (
"GIT_CEILING_DIRECTORIES",
"GIT_CONFIG_COUNT",
"GIT_CONFIG_GLOBAL",
"GIT_CONFIG_KEY_0",
"GIT_CONFIG_PARAMETERS",
"GIT_CONFIG_SYSTEM",
"GIT_CONFIG_VALUE_0",
)
def _env(
self, scan: bool, strict: bool, view: str | None = None
) -> dict[str, str] | None:
completed = MagicMock()
completed.stdout = ""
planted = {
**dict.fromkeys(self.OVERRIDES, "/elsewhere"),
"HOME": "/elsewhere",
"XDG_CONFIG_HOME": "/elsewhere",
}
with (
patch.dict("os.environ", planted),
patch(
"pkgmgr.core.git.run.subprocess.run", return_value=completed
) as mock_run,
):
if strict:
with strict_env():
run(["status"], scan=scan, config_view=view)
else:
run(["status"], scan=scan, config_view=view)
return mock_run.call_args.kwargs.get("env")
def _kept(self, env: dict[str, str]) -> list[str]:
return sorted(name for name in self.OVERRIDES if name in env)
def test_scan_drops_repository_overrides_from_the_environment(self) -> None:
unset = ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")
for view in (None, CALLER_CONFIG):
with self.subTest(view=view):
env = self._env(scan=True, strict=False, view=view)
self.assertEqual(
self._kept(env),
sorted(name for name in self.OVERRIDES if name not in unset),
)
self.assertEqual(env.get("GIT_CONFIG_NOSYSTEM"), None if view else "1")
self.assertIsNone(self._env(scan=False, strict=False, view=view))
def test_strict_env_keeps_only_the_ceiling(self) -> None:
for scan in (True, False):
with self.subTest(scan=scan):
env = self._env(scan=scan, strict=True)
self.assertEqual(self._kept(env), ["GIT_CEILING_DIRECTORIES"])
self.assertEqual(env.get("GIT_CONFIG_NOSYSTEM"), "1")
def test_strict_caller_config_also_keeps_the_config_variables(self) -> None:
for scan in (True, False):
with self.subTest(scan=scan):
env = self._env(scan=scan, strict=True, view=CALLER_CONFIG)
self.assertEqual(self._kept(env), list(self.CONFIG_READ))
self.assertIsNone(env.get("GIT_CONFIG_NOSYSTEM"))
self.assertEqual(env.get("HOME"), "/elsewhere")
def test_default_config_ignores_the_environment(self) -> None:
home = pwd.getpwuid(os.getuid()).pw_dir
for strict in (True, False):
with self.subTest(strict=strict):
env = self._env(scan=True, strict=strict, view=DEFAULT_CONFIG)
self.assertEqual(self._kept(env), ["GIT_CEILING_DIRECTORIES"])
self.assertIsNone(env.get("GIT_CONFIG_NOSYSTEM"))
self.assertEqual(env.get("HOME"), home)
self.assertFalse("XDG_CONFIG_HOME" in env)
def test_failure_raises_giterror_with_details(self) -> None: def test_failure_raises_giterror_with_details(self) -> None:
# Build a CalledProcessError with stdout/stderr populated # Build a CalledProcessError with stdout/stderr populated
import subprocess as sp import subprocess as sp