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
+2783 -37

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 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 --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 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 |
+810 -22
View File
@@ -10,7 +10,7 @@ from collections.abc import Callable
from dataclasses import asdict, dataclass, field
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 (
FILTER_CONFIG,
UNSAFE_CONFIG,
@@ -18,7 +18,13 @@ from pkgmgr.core.git.queries import (
local_config,
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 .nofollow import canonical, remove_tree
@@ -53,8 +59,24 @@ REFUSAL = (
"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."
)
SCOPES = ("worktree", "branch", "all", "purge")
SCOPES = ("worktree", "branch", "all", "purge", "local")
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
@@ -136,6 +158,10 @@ class Repo:
warnings: list[str] = field(default_factory=list)
items: list[Item] = 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:
@@ -149,10 +175,93 @@ def main_repo_root(cwd: str) -> str:
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:
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:
return _git(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd)
@@ -451,6 +560,8 @@ def _skip(
return None
if tree.main:
return "checked out in main worktree"
if repo.scope == "local":
return f"checked out in worktree {tree.path}"
if not checkout:
return "worktree checkout missing"
if repo.containers is None:
@@ -661,6 +772,8 @@ def _actions(
dirty: int,
registrations: Registrations,
) -> list[str]:
if repo.scope == "local":
return _local_actions(repo, item, tree)
tree_part: list[str] = []
if tree:
if (
@@ -699,6 +812,38 @@ def _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]:
local, remote, tracking = _refs(repo.repo, repo.remotes)
cwds = _process_cwds() if repo.host_ns else {}
@@ -715,12 +860,15 @@ def _items(repo: Repo) -> list[Item]:
trees, snapshot = _snapshot(repo)
local, remote, *_ = snapshot
by_branch = {t.branch: t for t in trees if t.branch}
names = (
sorted(by_branch)
if repo.scope == "worktree"
else sorted(set(local) | set(remote) | set(by_branch))
)
items = [_build(repo, name, by_branch.get(name), snapshot) for name in names]
names = {
"worktree": set(by_branch),
"local": set(local) | set(by_branch),
}.get(repo.scope, set(local) | set(remote) | set(by_branch))
items = [
_build(repo, name, by_branch.get(name), snapshot) for name in sorted(names)
]
if repo.scope == "local":
return items
items += [
_build(repo, None, tree, snapshot)
for tree in trees
@@ -747,6 +895,188 @@ def _stale(repo_root: str) -> list[Item]:
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(
repo_root: str, refs: list[str], remotes: list[str], warnings: list[str]
) -> tuple[dict[str, tuple[str, set[str]]], set[str]]:
@@ -778,6 +1108,116 @@ def _lines(
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(
repo_root: str,
containers: list[Container] | None,
@@ -815,6 +1255,8 @@ def plan_repo(
"""
if scope not in SCOPES:
raise ValueError(f"unknown scope {scope!r}")
if scope == "local":
_own_git_dir(repo_root)
warnings: list[str] = []
unsafe = local_config(repo_root, UNSAFE_CONFIG)
if unsafe:
@@ -854,9 +1296,25 @@ def plan_repo(
)
except GitRunError:
continue
default = next(iter(heads.values()), None) or resolve_base_branch(
"main", "master", cwd=repo_root, scan=True
)
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(
"main", "master", cwd=repo_root, scan=True
)
head_names = {ref[len(remote) + 1 :] for remote, ref in heads.items()}
urls = {r: _git(["remote", "get-url", "--", r], repo_root) for r in remotes}
@@ -864,6 +1322,10 @@ def plan_repo(
if forge.unavailable:
warnings.append(f"forge unavailable: {forge.unavailable}")
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_root,
@@ -895,9 +1357,123 @@ def plan_repo(
unsafe=unsafe,
)
repo.items = _items(repo)
if scope == "local":
repo.opted_out = _opted_out(repo_root)
repo.upstreams = _local_upstreams(repo_root)
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(
hooks: dict[str, dict[str, list[str]]], repo_root: str, warnings: 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
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:
fresh = _rebuild(repo, item)
if _fingerprint(fresh) != _fingerprint(item):
@@ -1036,7 +1638,20 @@ def execute(repo: Repo, item: Item) -> None:
notes.append(f"image kept: {image}")
if removes_tree:
_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)
for remote, sha in item.remotes.items():
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(
repo_roots: list[str],
include: list[str],
@@ -1127,8 +1835,8 @@ def prepare(
options: dict,
) -> tuple[list[Repo], bool, Callable[[Item], bool]] | None:
host_ns = host_pid_namespace()
containers = list_compose_containers()
repos = [plan_repo(root, containers, host_ns, **options) for root in repo_roots]
containers = [] if options["scope"] == "local" else list_compose_containers()
repos = [_plan(root, containers, host_ns, options) for root in repo_roots]
unknown = [
name
for name in include
@@ -1143,13 +1851,16 @@ def prepare(
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
def _finish(repos: list[Repo]) -> int:
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(
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"]):
try:
@@ -1180,7 +1891,13 @@ def cleanup(
scope: `worktree` removes clean worktrees whose commits are merged or
pushed (hook, stack, checkout; the branch stays); `branch` removes
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.
as_json: print the plan as JSON instead of text lines.
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.
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}")
if apply and not host_pid_namespace():
print(REFUSAL, file=sys.stderr)
@@ -1206,7 +1924,76 @@ def cleanup(
"scope": scope,
"activity": False,
"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)
if prepared is None:
return 2
@@ -1216,13 +2003,14 @@ def cleanup(
for repo in repos:
for item in repo.items:
if item.eligible and chosen(item):
execute(repo, item)
_apply(repo, item)
print(
f"[{scope}] {item.branch or item.worktree}: {item.result}",
f"[{options['scope']}] {item.branch or item.worktree}: "
f"{item.result}",
file=sys.stderr,
)
_render(repos, host_ns, chosen, as_json)
return _finish(repos) if apply else 0
return _exit_code(repos, apply)
def branch_purge(
+18 -8
View File
@@ -11,7 +11,7 @@ from pkgmgr.actions.branch import (
open_branch,
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.core.git.errors import GitBaseError
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)
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
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:
try:
return [main_repo_root(".")]
return [(local_repo_root if local else main_repo_root)(".")]
except GitBaseError as exc:
print(f"Not inside a git repository: {exc}", file=sys.stderr)
sys.exit(2)
@@ -84,7 +93,7 @@ def scan_options(args) -> dict:
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(
roots,
scope=scope,
@@ -94,7 +103,7 @@ def run_cleanup(args, ctx: CLIContext, scope: str) -> None:
hooks=pinned_hooks(ctx),
**scan_options(args),
)
if args.apply:
if args.apply and scope != "local":
refresh_tree(roots)
sys.exit(code)
@@ -107,8 +116,9 @@ def handle_branch(args, ctx: CLIContext) -> None:
- pkgmgr branch open [<name>] [--base <branch>]
- pkgmgr branch close [<name>] [--base <branch>] [--force|-f]
- pkgmgr branch drop [<name>] [--base <branch>] [--force|-f]
- pkgmgr branch cleanup [--apply] [--json] [--all] [--branch N]... [--exclude N]...
[--merged-into REF]... [--sizes] [--min-age HOURS]
- pkgmgr branch cleanup [--apply] [--json] [--local] [--all] [--branch N]...
[--exclude N]... [--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]
@@ -124,7 +134,7 @@ def handle_branch(args, ctx: CLIContext) -> None:
)
if args.subcommand == "cleanup":
run_cleanup(args, ctx, "branch")
run_cleanup(args, ctx, "local" if args.local else "branch")
if args.subcommand == "purge":
roots = scan_repo_roots(args, ctx)
+35
View File
@@ -105,6 +105,41 @@ def add_branch_subparsers(
),
)
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(
"purge",
+87 -6
View File
@@ -1,7 +1,12 @@
from __future__ import annotations
import contextvars
import os
import pwd
import re
import subprocess
from collections.abc import Iterator
from contextlib import contextmanager
from .errors import GitNotRepositoryError, GitRunError
@@ -52,10 +57,20 @@ SCAN_UNSET = (
"GIT_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
# 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"
_STRICT_ENV: contextvars.ContextVar[bool] = contextvars.ContextVar(
"pkgmgr_git_strict_env", default=False
)
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
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}
@contextmanager
def strict_env() -> Iterator[None]:
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(
@@ -75,6 +142,7 @@ def run(
preview: bool = False,
scan: bool = False,
input_text: str | None = None,
config_view: str | None = None,
) -> str:
"""
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.
scan: read a repository an agent may have written: no fsmonitor, hook,
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`;
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.
"""
@@ -98,10 +174,15 @@ def run(
print(f"[PREVIEW] Would run in {cwd!r}: {cmd_str}")
return ""
strict = _STRICT_ENV.get()
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
else {}
else ({"env": _scan_env(True, config_view)} if strict else {})
)
if input_text is not 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
from unittest.mock import MagicMock, patch
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):
@@ -55,6 +59,98 @@ class TestGitRun(unittest.TestCase):
self.assertNotIn("stdin", 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:
# Build a CalledProcessError with stdout/stderr populated
import subprocess as sp