Merge branch 'feature/branch-cleanup-local' into unify/pkgmgr-20261113
This commit is contained in:
commit
791761a239
8 files changed
+3183
-44
No files matched your search
@@ -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 |
|
||||||
|
|||||||
+891
-21
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user