Merge branch 'feature/branch-cleanup-local' into unify/pkgmgr-20261113

This commit is contained in:
Kevin Veen-Birkenbach committed 2026-11-13 16:13:35 +00:00
commit 791761a239
8 files changed
+3189 -50

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 (also one that exists only on a remote). 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. A branch is deleted before its planned local upstream; right before each delete its holds and then itLine 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 |
File diff suppressed because it is too large. Load diff
+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
+89 -8
View File
@@ -6,17 +6,18 @@ import os
import subprocess import subprocess
import tempfile import tempfile
import unittest import unittest
from contextlib import redirect_stdout from contextlib import redirect_stderr, redirect_stdout
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from pkgmgr.actions.branch import gc from pkgmgr.actions.branch import gc, gc_forge
from pkgmgr.actions.branch.gc_forge import ForgeFacts from pkgmgr.actions.branch.gc_forge import ForgeFacts
from pkgmgr.cli.commands.branch import handle_branch from pkgmgr.cli.commands.branch import handle_branch
from pkgmgr.cli.parser import create_parser from pkgmgr.cli.parser import create_parser
from pkgmgr.core.credentials.types import NoCredentialsError
GIT_ENV = { GITHUB_URL = "https://github.com/example/demo.git"
**os.environ, AUTHOR = {
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@example.org", "GIT_AUTHOR_EMAIL": "t@example.org",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_NAME": "t",
@@ -28,7 +29,7 @@ def git(cwd: str, *args: str) -> str:
return subprocess.run( return subprocess.run(
["git", "-c", "commit.gpgsign=false", *args], ["git", "-c", "commit.gpgsign=false", *args],
cwd=cwd, cwd=cwd,
env=GIT_ENV, env={**os.environ, **AUTHOR},
check=True, check=True,
capture_output=True, capture_output=True,
text=True, text=True,
@@ -46,7 +47,16 @@ def docker_available() -> bool:
class GcFixture: class GcFixture:
def setUp(self) -> None: def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory() self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.root = os.path.realpath(self._tmp.name) self.root = os.path.realpath(self._tmp.name)
self.addCleanup(os.chdir, os.getcwd())
os.chdir(self.root)
home = os.path.join(self.root, "home")
os.makedirs(home)
scratch = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
patcher = patch.dict(os.environ, {**scratch, "HOME": home}, clear=True)
patcher.start()
self.addCleanup(patcher.stop)
self.origin = os.path.join(self.root, "origin.git") self.origin = os.path.join(self.root, "origin.git")
self.repo = os.path.join(self.root, "repo") self.repo = os.path.join(self.root, "repo")
git(self.root, "init", "--bare", "-b", "main", self.origin) git(self.root, "init", "--bare", "-b", "main", self.origin)
@@ -81,9 +91,6 @@ class GcFixture:
merged_heads={self.squashed_sha: 7}, open_heads={"wip": 3} merged_heads={self.squashed_sha: 7}, open_heads={"wip": 3}
) )
def tearDown(self) -> None:
self._tmp.cleanup()
def _commit(self, name: str) -> None: def _commit(self, name: str) -> None:
with open(os.path.join(self.repo, f"{name}.txt"), "w") as fh: with open(os.path.join(self.repo, f"{name}.txt"), "w") as fh:
fh.write(name) fh.write(name)
@@ -241,6 +248,80 @@ class TestBranchGc(GcFixture, unittest.TestCase):
self.assertEqual(items["merged"].skip, "forge unavailable") self.assertEqual(items["merged"].skip, "forge unavailable")
self.assertIsNone(items["squashed"].merged_by) self.assertIsNone(items["squashed"].merged_by)
def _github_without_token(self) -> None:
git(self.repo, "branch", "local-done", "merged")
git(self.repo, "checkout", "-b", "local-wip", "main")
self._commit("local-wip")
git(self.repo, "checkout", "main")
git(self.repo, "branch", "--track", "tracked", "origin/merged")
git(self.repo, "push", "origin", "merged:refs/heads/remote-done")
git(self.repo, "remote", "set-url", "origin", GITHUB_URL)
def _cleanup_without_token(self, apply: bool) -> tuple[int, dict]:
out = io.StringIO()
with (
patch.dict(os.environ, {"GIT_ALLOW_PROTOCOL": "file"}),
patch.object(gc, "host_pid_namespace", return_value=True),
patch.object(gc, "list_compose_containers", return_value=[]),
patch.object(gc, "_process_cwds", return_value={}),
patch.object(gc_forge, "TokenResolver") as resolver,
patch.object(gc_forge, "HttpClient") as http,
redirect_stdout(out),
redirect_stderr(io.StringIO()),
):
resolver.return_value.get_token.side_effect = NoCredentialsError("none")
code = gc.cleanup(
[self.repo],
scope="branch",
apply=apply,
as_json=True,
include=[],
exclude=[],
merged_into=[],
sizes=False,
min_age=None,
hooks={},
)
resolver.return_value.get_token.assert_called_once()
http.assert_not_called()
return code, json.loads(out.getvalue())["repos"][0]
def test_local_only_merged_branch_needs_no_forge(self) -> None:
self._github_without_token()
code, plan = self._cleanup_without_token(apply=False)
items = {i["branch"]: i for i in plan["items"] if i["branch"]}
self.assertEqual(code, 0)
self.assertIn("forge unavailable: no GitHub token (none)", plan["warnings"])
self.assertIsNone(items["local-done"]["skip"])
self.assertEqual(items["local-done"]["merged_by"], "ancestor")
self.assertEqual(items["local-done"]["actions"], ["local-branch"])
self.assertEqual(items["remote-done"]["state"], "merged-clean")
self.assertEqual(items["tracked"]["state"], "merged-clean")
self.assertEqual(items["local-wip"]["state"], "unmerged")
for name in ("merged", "remote-done", "tracked", "local-wip", "squashed"):
with self.subTest(branch=name):
self.assertEqual(items[name]["skip"], "forge unavailable")
def test_apply_without_token_deletes_only_the_local_only_merged_branch(
self,
) -> None:
self._github_without_token()
before = set(git(self.repo, "for-each-ref", "--format=%(refname)").split())
code, plan = self._cleanup_without_token(apply=True)
after = set(git(self.repo, "for-each-ref", "--format=%(refname)").split())
self.assertEqual(code, 0)
self.assertEqual(
[(i["branch"], i["result"]) for i in plan["items"] if i["result"]],
[("local-done", "done")],
)
self.assertEqual(before - after, {"refs/heads/local-done"})
self.assertEqual(after - before, set())
self.assertEqual(
git(self.origin, "branch", "--list", "remote-done"), "remote-done"
)
def test_stale_metadata_is_listed_not_pruned(self) -> None: def test_stale_metadata_is_listed_not_pruned(self) -> None:
stub = os.path.join(self.repo, ".git", "worktrees", "stub") stub = os.path.join(self.repo, ".git", "worktrees", "stub")
os.makedirs(stub) os.makedirs(stub)
+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