Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
757903fdd5 | ||
|
|
c09e44af50 | ||
|
|
791761a239 | ||
|
|
3936125633 | ||
|
|
0d88c69c51 | ||
|
|
b0f9395c68 | ||
|
|
814322f8f5 |
No files matched your search
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## [2.5.0] - 2026-11-13
|
||||
|
||||
Added:
|
||||
|
||||
- Branch: *cleanup --local* deletes merged branches only where git would
|
||||
- Review: expand and collapse are icon buttons with labels
|
||||
|
||||
Fixed:
|
||||
|
||||
- GC: merged local-only branches are removed without a forge token
|
||||
|
||||
## [2.4.0] - 2026-11-07
|
||||
|
||||
Added:
|
||||
|
||||
@@ -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 (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 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 |
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
rec {
|
||||
pkgmgr = pyPkgs.buildPythonApplication {
|
||||
pname = "package-manager";
|
||||
version = "2.4.0";
|
||||
version = "2.5.0";
|
||||
|
||||
# Use the git repo as source
|
||||
src = ./.;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
||||
|
||||
pkgname=package-manager
|
||||
pkgver=2.4.0
|
||||
pkgver=2.5.0
|
||||
pkgrel=1
|
||||
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
||||
arch=('any')
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
package-manager (2.5.0-1) unstable; urgency=medium
|
||||
|
||||
Added:
|
||||
|
||||
- Branch: *cleanup --local* deletes merged branches only where git would
|
||||
- Review: expand and collapse are icon buttons with labels
|
||||
|
||||
Fixed:
|
||||
|
||||
- GC: merged local-only branches are removed without a forge token
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 13 Nov 2026 22:53:12 +0000
|
||||
|
||||
package-manager (2.4.0-1) unstable; urgency=medium
|
||||
|
||||
Added:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: package-manager
|
||||
Version: 2.4.0
|
||||
Version: 2.5.0
|
||||
Release: 1%{?dist}
|
||||
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
||||
|
||||
@@ -74,6 +74,16 @@ echo ">>> package-manager removed. Nix itself was not removed."
|
||||
/usr/lib/package-manager/
|
||||
|
||||
%changelog
|
||||
* Fri Nov 13 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.5.0-1
|
||||
- Added:
|
||||
|
||||
- - Branch: *cleanup --local* deletes merged branches only where git would
|
||||
- - Review: expand and collapse are icon buttons with labels
|
||||
|
||||
- Fixed:
|
||||
|
||||
- - GC: merged local-only branches are removed without a forge token
|
||||
|
||||
* Sat Nov 07 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.4.0-1
|
||||
- Added:
|
||||
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "kpmx"
|
||||
version = "2.4.0"
|
||||
version = "2.5.0"
|
||||
description = "Kevin's package-manager tool (pkgmgr)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
@@ -52,4 +52,4 @@ include = ["pkgmgr*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"pkgmgr.config" = ["*.yml", "*.yaml"]
|
||||
"pkgmgr.actions.review" = ["*.j2"]
|
||||
"pkgmgr.actions.review" = ["*.j2", "*.LICENSE"]
|
||||
+897
-27
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2019-2024 The Bootstrap Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -1,5 +1,9 @@
|
||||
{% set status_letters = {"added": "A", "deleted": "D", "modified": "M", "renamed": "R", "copied": "C", "untracked": "U"} %}
|
||||
{% set signs = {"add": "+", "del": "-", "ctx": "", "meta": ""} %}
|
||||
{% set icon_paths = {
|
||||
"arrows-expand": "M1 8a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13A.5.5 0 0 1 1 8M7.646.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 1.707V5.5a.5.5 0 0 1-1 0V1.707L6.354 2.854a.5.5 0 1 1-.708-.708zM8 10a.5.5 0 0 1 .5.5v3.793l1.146-1.147a.5.5 0 0 1 .708.708l-2 2a.5.5 0 0 1-.708 0l-2-2a.5.5 0 0 1 .708-.708L7.5 14.293V10.5A.5.5 0 0 1 8 10",
|
||||
"arrows-collapse": "M1 8a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13A.5.5 0 0 1 1 8m7-8a.5.5 0 0 1 .5.5v3.793l1.146-1.147a.5.5 0 0 1 .708.708l-2 2a.5.5 0 0 1-.708 0l-2-2a.5.5 0 1 1 .708-.708L7.5 4.293V.5A.5.5 0 0 1 8 0m-.5 11.707-1.146 1.147a.5.5 0 0 1-.708-.708l2-2a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 11.707V15.5a.5.5 0 0 1-1 0z",
|
||||
} %}
|
||||
{% set style %}
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
@@ -93,6 +97,7 @@ input[type="search"] { width: 260px; max-width: 100%; padding: 5px 10px; border:
|
||||
input[type="search"]:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
button { padding: 5px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--subtle); color: var(--text); font: 500 13px var(--sans); cursor: pointer; }
|
||||
button:hover { border-color: var(--muted); }
|
||||
button.icon { display: inline-flex; align-items: center; justify-content: center; padding: 6px 8px; }
|
||||
main { padding-top: 24px; padding-bottom: 80px; }
|
||||
.group { margin-bottom: 36px; }
|
||||
h2 { display: flex; flex-wrap: wrap; align-items: baseline; gap: 12px; margin: 0 0 12px; font-size: 17px; font-weight: 600; }
|
||||
@@ -187,6 +192,7 @@ details.commit > summary { padding: 10px 14px; }
|
||||
})();
|
||||
{% endset %}
|
||||
{% macro stat(added, removed) %}<span class="stat"><span class="add">+{{ added }}</span><span class="del">−{{ removed }}</span></span>{% endmacro %}
|
||||
{% macro icon(name) %}<svg class="bi bi-{{ name }}" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" focusable="false"><path fill-rule="evenodd" d="{{ icon_paths[name] }}"/></svg>{% endmacro %}
|
||||
{% macro plural(count, word) %}{{ count }} {{ word }}{{ "" if count == 1 else "s" }}{% endmacro %}
|
||||
{% macro file_block(file, open) %}
|
||||
<details class="file" id="{{ file.anchor }}" data-path="{{ file.search }}"{% if open %} open{% endif %}>
|
||||
@@ -282,8 +288,8 @@ details.commit > summary { padding: 10px 14px; }
|
||||
<div class="spacer"></div>
|
||||
<input id="filter" type="search" placeholder="Filter files by path ( / )" autocomplete="off" spellcheck="false" aria-label="Filter files by path">
|
||||
<span id="filter-count" class="muted"></span>
|
||||
<button type="button" id="expand-all">Expand all</button>
|
||||
<button type="button" id="collapse-all">Collapse all</button>
|
||||
<button type="button" id="expand-all" class="icon" title="Expand all" aria-label="Expand all">{{ icon("arrows-expand") }}</button>
|
||||
<button type="button" id="collapse-all" class="icon" title="Collapse all" aria-label="Collapse all">{{ icon("arrows-collapse") }}</button>
|
||||
</div>
|
||||
</nav>
|
||||
<main>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -6,17 +6,18 @@ import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from types import SimpleNamespace
|
||||
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.cli.commands.branch import handle_branch
|
||||
from pkgmgr.cli.parser import create_parser
|
||||
from pkgmgr.core.credentials.types import NoCredentialsError
|
||||
|
||||
GIT_ENV = {
|
||||
**os.environ,
|
||||
GITHUB_URL = "https://github.com/example/demo.git"
|
||||
AUTHOR = {
|
||||
"GIT_AUTHOR_NAME": "t",
|
||||
"GIT_AUTHOR_EMAIL": "t@example.org",
|
||||
"GIT_COMMITTER_NAME": "t",
|
||||
@@ -28,7 +29,7 @@ def git(cwd: str, *args: str) -> str:
|
||||
return subprocess.run(
|
||||
["git", "-c", "commit.gpgsign=false", *args],
|
||||
cwd=cwd,
|
||||
env=GIT_ENV,
|
||||
env={**os.environ, **AUTHOR},
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -46,7 +47,16 @@ def docker_available() -> bool:
|
||||
class GcFixture:
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
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.repo = os.path.join(self.root, "repo")
|
||||
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}
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _commit(self, name: str) -> None:
|
||||
with open(os.path.join(self.repo, f"{name}.txt"), "w") as fh:
|
||||
fh.write(name)
|
||||
@@ -241,6 +248,80 @@ class TestBranchGc(GcFixture, unittest.TestCase):
|
||||
self.assertEqual(items["merged"].skip, "forge unavailable")
|
||||
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:
|
||||
stub = os.path.join(self.repo, ".git", "worktrees", "stub")
|
||||
os.makedirs(stub)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -160,6 +161,27 @@ class TestReview(unittest.TestCase):
|
||||
self.assertNotIn("big 11</td>", html)
|
||||
self.assertIn("20 more lines not shown", html)
|
||||
|
||||
def test_toolbar_buttons_are_labelled_icons(self) -> None:
|
||||
html = self._render("page.html", None, [])
|
||||
|
||||
for key, label in (("expand", "Expand all"), ("collapse", "Collapse all")):
|
||||
with self.subTest(button=key):
|
||||
button = re.search(
|
||||
rf'<button type="button" id="{key}-all"([^>]*)>(.*?)</button>',
|
||||
html,
|
||||
re.DOTALL,
|
||||
)
|
||||
self.assertIsNotNone(button)
|
||||
attrs, inner = button.groups()
|
||||
self.assertIn(f'title="{label}"', attrs)
|
||||
self.assertIn(f'aria-label="{label}"', attrs)
|
||||
self.assertRegex(
|
||||
inner, rf'^<svg class="bi bi-arrows-{key}"[^>]* aria-hidden="true"'
|
||||
)
|
||||
self.assertEqual(re.sub(r"<[^>]+>", "", inner).strip(), "")
|
||||
self.assertNotIn("<link", html)
|
||||
self.assertNotIn("<img", html)
|
||||
|
||||
def test_errors_exit_two(self) -> None:
|
||||
plain = os.path.join(self.root, "plain")
|
||||
os.makedirs(plain)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user