Compare commits

...
7 Commits
Author SHA1 Message Date
Kevin Veen-Birkenbach 757903fdd5 Release version 2.5.0
CI / security-codeql (push) Blocked by required conditions
CI / test-unit (push) Blocked by required conditions
CI / test-integration (push) Blocked by required conditions
CI / test-env-virtual (push) Blocked by required conditions
CI / test-env-nix (push) Blocked by required conditions
CI / test-e2e (push) Blocked by required conditions
CI / test-virgin-user (push) Blocked by required conditions
CI / test-virgin-root (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (arch) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (centos) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (debian) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (fedora) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (ubuntu) (push) Blocked by required conditions
CI / lint-shell (push) Blocked by required conditions
CI / lint-python (push) Blocked by required conditions
Ruff (Python code sniffer) / lint-python (push) Blocked by required conditions
CI / lint-docker (push) Blocked by required conditions
CodeQL Advanced / Check security (none, actions) (push) Blocked by required conditions
CodeQL Advanced / Check security (none, python) (push) Blocked by required conditions
Test Units / test-unit (push) Blocked by required conditions
Test Code Integration / test-integration (push) Blocked by required conditions
Test OS Containers / test-env-virtual (arch) (push) Blocked by required conditions
Test OS Containers / test-env-virtual (centos) (push) Blocked by required conditions
Test OS Containers / test-env-virtual (debian) (push) Blocked by required conditions
Test OS Containers / test-env-virtual (fedora) (push) Blocked by required conditions
Test OS Containers / test-env-virtual (ubuntu) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (arch) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (centos) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (debian) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (fedora) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (ubuntu) (push) Blocked by required conditions
Test End-To-End / test-e2e (arch) (push) Blocked by required conditions
Test End-To-End / test-e2e (centos) (push) Blocked by required conditions
Test End-To-End / test-e2e (debian) (push) Blocked by required conditions
Test End-To-End / test-e2e (fedora) (push) Blocked by required conditions
Test End-To-End / test-e2e (ubuntu) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (arch) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (centos) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (debian) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (fedora) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (ubuntu) (push) Blocked by required conditions
ShellCheck / lint-shell (push) Blocked by required conditions
Docker Linter / Lint Dockerfile (push) Blocked by required conditions
Mark stable commit / mark-stable (push) Waiting to run
2026-11-13 22:53:12 +00:00
Kevin Veen-Birkenbach c09e44af50 Merge branch 'feature/review-page-icons' into unify/pkgmgr-20261113 2026-11-13 16:13:36 +00:00
Kevin Veen-Birkenbach 791761a239 Merge branch 'feature/branch-cleanup-local' into unify/pkgmgr-20261113 2026-11-13 16:13:35 +00:00
Kevin Veen-BirkenbachandClaude Opus 5.5 3936125633 fix(branch): cleanup --local keeps branches whose sibling upstream was deleted
A local branch whose upstream sibling was deleted on the remote counted as merged when its tip was reachable from the deleted ref's last known commit, so cleanup --local removed it although git branch -d would refuse. The plan now asks git itself for each candidate, in the same order the delete runs, and skips a branch git would not delete; the README states the rule.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 16:10:50 +00:00
Kevin Veen-BirkenbachandClaude Opus 5.5 0d88c69c51 feat(branch): cleanup --local deletes merged local branches only when git itself would
pkgmgr branch cleanup --local [--apply] [--json] [--merged-into REF]
deletes local branches whose tip is an ancestor of the remote-tracking
default or a --merged-into ref, each with update-ref against the planned
sha. It keeps every branch git branch --delete-merged keeps: symbolic,
checked out or registered in any worktree, under a rebase or bisect,
deleteMerged=false in any worktree, system or global config, and the
local upstream of any branch that stays. Git runs without inherited
GIT_* variables except the ceiling; the keep reads run once with the
caller's config variables and once with the default system and
passwd-home config, so the environment can add keeps but never remove
one. Repositories with symlinked refs, foreign worktree registrations or
unreadable holds are refused with the plan still printed.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:01:06 +00:00
Kevin Veen-BirkenbachandClaude Opus 5.5 b0f9395c68 feat(review): icon buttons with labels for expand and collapse
The review page toolbar showed Expand all and Collapse all as text. They are now Bootstrap Icons (arrows-expand, arrows-collapse) inlined as SVG under the page's strict CSP, with title and aria-label carrying the words; the Bootstrap Icons MIT licence ships next to the template as package data. A test checks the labels, the icon classes, no visible text and no external resource.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:48:19 +00:00
Kevin Veen-BirkenbachandClaude Opus 5.5 814322f8f5 fix(branch): gc local-only merged branches without a forge token
Without a forge token every gc item was skipped before any local check. _needs_forge now decides which items still need PR and protection facts: a branch no worktree holds, without upstream or remote branch, merged into the default by ancestry is decided locally and removed without the forge. Two integration tests fail on 2.4.0 and assert the HTTP client is never called.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:11:31 +00:00
17 changed files with 3279 additions and 57 deletions

No files matched your search

+11
View File
@@ -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:
+1
View File
@@ -80,6 +80,7 @@ versioning features it can drive **end-to-end workflows**:
| `pkgmgr worktree flow clear [PATH] [--json]` | Removes the merge flow |
| `pkgmgr worktree cleanup [--apply]` | Removes clean worktrees whose commits are merged or pushed: pinned `worktree-fini` hook, compose stack, checkout; the branch stays |
| `pkgmgr branch cleanup [--apply]` | Removes merged branches that no worktree holds, locally and on the remotes |
| `pkgmgr branch cleanup --local [--apply] [--json] [--merged-into REF]...` | Deletes only local branches whose tip is an ancestor of the remote-tracking default or of a `--merged-into` REF, each with `git update-ref --no-deref -d` against the planned sha and then its `branch.<name>` section in the repository's own config file (`git config --file <git common dir>/config`; a section it cannot remove is noted on the done item), also when the branch has an upstream or remote copy. Every git call of `--local` runs without any inherited `GIT_*` variable but `GIT_CEILING_DIRECTORIES`, which only narrows repository discovery, so a directory below a ceiling is never resolved to the repository above it; only the config reads of `deleteMerged` and of the upstreams run twice, both with the system config: once with the caller's `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG_PARAMETERS` and `GIT_CONFIG_COUNT` with its `GIT_CONFIG_KEY_<n>` and `GIT_CONFIG_VALUE_<n>`, once without any of them, without `XDG_CONFIG_HOME` and with `HOME` set to the passwd home, and a branch either read keeps stays, so the environment can add keeps but never remove one. A `--merged-into` REF must name exactly one branch or remote-tracking ref, as `git rev-parse --symbolic-full-name` resolves it, in every scanned repository git can read; `X~0`, `X^0`, `X@{0}`, a sha, a range or an unknown ref stops the run. The default comes from `origin`, or from the only remote when there is no `origin`: the ref `refs/remotes/<remote>/HEAD`, else `main`, else `master` resolves to, every symref step inside `refs/remotes/<remote>/`, so never a local branch or `HEAD`; without one, for example with several remotes and no `origin`, the repository is refused, also under `--merged-into`; so is one whose `refs`, `packed-refs`, `reftable`, `logs` or `logs/refs` is a symlink (git-new-workdir), one with a symlink anywhere below `refs/`, `reftable/` or `logs/refs/` (for example `refs/heads/team` linked to another clone's heads), one whose git dir is neither its git common dir nor directly under `<common>/worktrees/` (a registration moved elsewhere), or one whose git output is not UTF-8. The repository is the one the current directory belongs to, also in a submodule or with a separate git dir; one whose main worktree belongs to another repository (`core.worktree`), whose git dir is neither its git common dir nor directly under `<common>/worktrees/`, or whose path is not UTF-8 is refused with exit 2. Skips a symbolic branch, every branch a symbolic ref anywhere under `refs/` (also a tag or remote-tracking one) or under the main worktree's or any registration's `refs/worktree/` points to, a branch that is itself a `--merged-into` target, a branch a worktree holds or that the HEAD of the main repository or of any `worktrees/<id>` registration names (also one `git worktree list` omits or whose id is no valid ref name, read through `git --git-dir=<dir> symbolic-ref HEAD`, so also under reftable), one a rebase or bisect in any worktree works on, one merged only by patch or PR, and, as `git branch --delete-merged` keeps them, one whose `branch.<name>.deleteMerged` is false in the config of any worktree (also its `config.worktree` or an `includeIf "onbranch:..."` file) or in the system config file, and the local upstream, as git's `%(upstream)` resolves it in any worktree, of any branch that stays, also of one that `--branch` or `--exclude` leaves out, each shown as kept with its reason; these holds are read through the git common dir, whichever worktree is scanned, and a registration, symbolic ref, HEAD or state file they cannot read refuses the repository; when a repository vanishes, moves or stops being readable, or its default or a `--merged-into` target stops resolving mid-run, its later items are skipped with that reason and the plan is still printed. A repository git or the filesystem fails on while planning (for example a dangling `.git` file under `--all`) is refused with that reason and the run goes on. The plan, refused repositories included, is printed (as JSON with `--json`), and the run exits 1 when an item failed, else 2 when a repository was refused, else 0. It stops with a message on stderr, no plan and exit 2 in exactly these cases: without `--all`, the current directory is not inside a git repository, its git dir is neither its git common dir nor directly under `<common>/worktrees/`, its main worktree belongs to another repository (`core.worktree`), or its path or git output is not UTF-8; `--apply` outside the host PID namespace; a `--merged-into` REF refused as above; a `--branch` that matches no item (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 |
+1 -1
View File
@@ -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 -1
View File
@@ -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')
+13
View File
@@ -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:
+11 -1
View File
@@ -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
View File
@@ -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"]
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.
+8 -2
View File
@@ -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">&minus;{{ 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>
+18 -8
View File
@@ -11,7 +11,7 @@ from pkgmgr.actions.branch import (
open_branch,
tree,
)
from pkgmgr.actions.branch.gc import main_repo_root
from pkgmgr.actions.branch.gc import local_repo_root, main_repo_root
from pkgmgr.cli.context import CLIContext
from pkgmgr.core.git.errors import GitBaseError
from pkgmgr.core.repository.dir import get_repo_dir
@@ -60,14 +60,23 @@ def refresh_tree(roots: list[str]) -> None:
print(f"WARN tree file not rewritten for {root}: {exc}", file=sys.stderr)
def scan_repo_roots(args, ctx: CLIContext) -> list[str]:
def scan_repo_roots(args, ctx: CLIContext, local: bool = False) -> list[str]:
"""
Resolve the repositories a scan covers: the current one, or every
pkgmgr-managed one with --all. Exits with 2 outside a repository.
Args:
args: parsed arguments; `all` selects every managed repository.
ctx: CLI context.
local: resolve the current repository as scope `local` does, also in
a submodule or with a separate git dir, refusing a mismatch.
Returns:
The main worktree roots to scan.
"""
if not args.all:
try:
return [main_repo_root(".")]
return [(local_repo_root if local else main_repo_root)(".")]
except GitBaseError as exc:
print(f"Not inside a git repository: {exc}", file=sys.stderr)
sys.exit(2)
@@ -84,7 +93,7 @@ def scan_options(args) -> dict:
def run_cleanup(args, ctx: CLIContext, scope: str) -> None:
roots = scan_repo_roots(args, ctx)
roots = scan_repo_roots(args, ctx, local=scope == "local")
code = cleanup(
roots,
scope=scope,
@@ -94,7 +103,7 @@ def run_cleanup(args, ctx: CLIContext, scope: str) -> None:
hooks=pinned_hooks(ctx),
**scan_options(args),
)
if args.apply:
if args.apply and scope != "local":
refresh_tree(roots)
sys.exit(code)
@@ -107,8 +116,9 @@ def handle_branch(args, ctx: CLIContext) -> None:
- pkgmgr branch open [<name>] [--base <branch>]
- pkgmgr branch close [<name>] [--base <branch>] [--force|-f]
- pkgmgr branch drop [<name>] [--base <branch>] [--force|-f]
- pkgmgr branch cleanup [--apply] [--json] [--all] [--branch N]... [--exclude N]...
[--merged-into REF]... [--sizes] [--min-age HOURS]
- pkgmgr branch cleanup [--apply] [--json] [--local] [--all] [--branch N]...
[--exclude N]... [--merged-into REF]... [--sizes]
[--min-age HOURS]
- pkgmgr branch purge [--all] [--branch N]... [--exclude N]...
[--merged-into REF]... [--sizes] [--min-age HOURS]
- pkgmgr branch tree [--format json|yaml] [--output FILE]
@@ -124,7 +134,7 @@ def handle_branch(args, ctx: CLIContext) -> None:
)
if args.subcommand == "cleanup":
run_cleanup(args, ctx, "branch")
run_cleanup(args, ctx, "local" if args.local else "branch")
if args.subcommand == "purge":
roots = scan_repo_roots(args, ctx)
+35
View File
@@ -105,6 +105,41 @@ def add_branch_subparsers(
),
)
add_apply_options(branch_cleanup)
branch_cleanup.add_argument(
"--local",
action="store_true",
help=(
"Delete only local branches whose tip is an ancestor of origin's "
"remote-tracking default (else the only remote's) or a --merged-into "
"REF, against the planned sha; refuse a repository without such a "
"default, one git fails on, one whose holds cannot be read, one "
"whose git dir is neither its common dir nor directly under "
"<common>/worktrees/ and one with a symlink in or below refs, "
"packed-refs, reftable or logs/refs, and still print the plan; skip "
"a symbolic branch, a branch any symbolic ref of any worktree names, "
"a branch a worktree, worktree registration, rebase or bisect holds, "
"a --merged-into target, a branch with branch.<name>.deleteMerged "
"false in any worktree's or the system config and the local "
"upstream of a branch that stays, also of one --branch or --exclude "
"leaves out; "
"run git without inherited GIT_* variables but "
"GIT_CEILING_DIRECTORIES; the keep config reads run once with the "
"caller's GIT_CONFIG_* variables and once with the default system "
"and passwd-home config, and either read's keep counts; read no "
"forge token; "
"never fetch, push, delete a remote branch, run docker or hooks, or "
"refresh the branch tree file; exit 1 when an item failed, else 2 "
"when a repository was refused, else 0; exit 2 without a plan when, "
"without --all, the current directory is in no git repository, its "
"git dir is neither its common dir nor directly under "
"<common>/worktrees/, its main worktree belongs to another "
"repository or its path or git output is not UTF-8, on --apply "
"outside the host PID namespace, on a --merged-into REF that is not "
"exactly one branch or remote-tracking ref (X~0, X^0, X@{0}, a sha), "
"and on a --branch "
"that matches nothing; meant for working clones"
),
)
branch_purge = branch_subparsers.add_parser(
"purge",
+87 -6
View File
@@ -1,7 +1,12 @@
from __future__ import annotations
import contextvars
import os
import pwd
import re
import subprocess
from collections.abc import Iterator
from contextlib import contextmanager
from .errors import GitNotRepositoryError, GitRunError
@@ -52,10 +57,20 @@ SCAN_UNSET = (
"GIT_PAGER",
"PAGER",
)
STRICT_KEEP = ("GIT_CEILING_DIRECTORIES",)
CONFIG_READ_KEEP = re.compile(
r"GIT_CONFIG_(GLOBAL|SYSTEM|PARAMETERS|COUNT|KEY_\d+|VALUE_\d+)"
)
CALLER_CONFIG = "caller"
DEFAULT_CONFIG = "default"
CONFIG_VIEWS = (CALLER_CONFIG, DEFAULT_CONFIG)
SCAN_TIMEOUT = 60
# ref and remote names come from repositories an agent may write: one named
# like --output=<path> would otherwise make rev-list create that file
END_OF_OPTIONS = "--end-of-options"
_STRICT_ENV: contextvars.ContextVar[bool] = contextvars.ContextVar(
"pkgmgr_git_strict_env", default=False
)
def _is_not_repo_error(stderr: str) -> bool:
@@ -63,9 +78,61 @@ def _is_not_repo_error(stderr: str) -> bool:
return "not a git repository" in msg
def _scan_env() -> dict[str, str]:
env = {key: value for key, value in os.environ.items() if key not in SCAN_UNSET}
return {**env, **SCAN_ENV}
@contextmanager
def strict_env() -> Iterator[None]:
token = _STRICT_ENV.set(True)
try:
yield
finally:
_STRICT_ENV.reset(token)
def _strict_keeps(key: str, caller: bool) -> bool:
return key in STRICT_KEEP or (
caller and CONFIG_READ_KEEP.fullmatch(key) is not None
)
def _passwd_home() -> str | None:
try:
return pwd.getpwuid(os.getuid()).pw_dir
except KeyError:
return None
def _scan_env(strict: bool = False, view: str | None = None) -> dict[str, str]:
"""
Build the environment of a scan or strict git call.
Args:
strict: drop every inherited `GIT_*` variable but STRICT_KEEP.
view: None, CALLER_CONFIG or DEFAULT_CONFIG, as run takes it.
Returns:
The inherited environment without SCAN_UNSET, plus SCAN_ENV; for a
config view with the system config; for DEFAULT_CONFIG also without
any `GIT_*` but STRICT_KEEP and XDG_CONFIG_HOME, HOME set to the
passwd home (unset without a passwd entry).
"""
caller = view == CALLER_CONFIG
scrub = strict or view == DEFAULT_CONFIG
env = {
key: value
for key, value in os.environ.items()
if key not in SCAN_UNSET
and not (scrub and key.startswith("GIT_") and not _strict_keeps(key, caller))
}
env = {**env, **SCAN_ENV}
if view is None:
return env
del env["GIT_CONFIG_NOSYSTEM"]
if view == DEFAULT_CONFIG:
env.pop("XDG_CONFIG_HOME", None)
env.pop("HOME", None)
home = _passwd_home()
if home:
env["HOME"] = home
return env
def run(
@@ -75,6 +142,7 @@ def run(
preview: bool = False,
scan: bool = False,
input_text: str | None = None,
config_view: str | None = None,
) -> str:
"""
Run a Git command and return its stdout as a stripped string.
@@ -85,9 +153,17 @@ def run(
preview: print the command instead of running it.
scan: read a repository an agent may have written: no fsmonitor, hook,
pager, lazy fetch, replace object, system config or prompt, stdin
closed, a timeout.
closed, a timeout. Inside strict_env() every call, scan or not,
also runs without any inherited `GIT_*` variable but
`GIT_CEILING_DIRECTORIES`, which only narrows discovery.
input_text: text fed to stdin, e.g. for `cat-file --batch-check`;
replaces the closed stdin of scan mode.
config_view: for a pure config read, the config it sees, both with
the system config: CALLER_CONFIG also keeps, inside strict_env(),
`GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG_PARAMETERS`,
`GIT_CONFIG_COUNT` and its `GIT_CONFIG_KEY_<n>` and
`GIT_CONFIG_VALUE_<n>`; DEFAULT_CONFIG reads the default system
and passwd-home global config whatever the environment says.
Raises GitRunError (or a subclass) if execution fails or times out.
"""
@@ -98,10 +174,15 @@ def run(
print(f"[PREVIEW] Would run in {cwd!r}: {cmd_str}")
return ""
strict = _STRICT_ENV.get()
hardened: dict = (
{"env": _scan_env(), "stdin": subprocess.DEVNULL, "timeout": SCAN_TIMEOUT}
{
"env": _scan_env(strict, config_view),
"stdin": subprocess.DEVNULL,
"timeout": SCAN_TIMEOUT,
}
if scan
else {}
else ({"env": _scan_env(True, config_view)} if strict else {})
)
if input_text is not None:
hardened.pop("stdin", None)
File diff suppressed because it is too large. Load diff
+89 -8
View File
@@ -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)
+22
View File
@@ -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)
+97 -1
View File
@@ -1,8 +1,12 @@
from __future__ import annotations
import os
import pwd
import unittest
from unittest.mock import MagicMock, patch
from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.run import run
from pkgmgr.core.git.run import CALLER_CONFIG, DEFAULT_CONFIG, run, strict_env
class TestGitRun(unittest.TestCase):
@@ -55,6 +59,98 @@ class TestGitRun(unittest.TestCase):
self.assertNotIn("stdin", kwargs)
self.assertIn("timeout", kwargs)
OVERRIDES = (
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_COMMON_DIR",
"GIT_CONFIG",
"GIT_CEILING_DIRECTORIES",
"GIT_CONFIG_GLOBAL",
"GIT_CONFIG_SYSTEM",
"GIT_CONFIG_PARAMETERS",
"GIT_CONFIG_COUNT",
"GIT_CONFIG_KEY_0",
"GIT_CONFIG_VALUE_0",
"GIT_CONFIG_KEY_X",
)
CONFIG_READ = (
"GIT_CEILING_DIRECTORIES",
"GIT_CONFIG_COUNT",
"GIT_CONFIG_GLOBAL",
"GIT_CONFIG_KEY_0",
"GIT_CONFIG_PARAMETERS",
"GIT_CONFIG_SYSTEM",
"GIT_CONFIG_VALUE_0",
)
def _env(
self, scan: bool, strict: bool, view: str | None = None
) -> dict[str, str] | None:
completed = MagicMock()
completed.stdout = ""
planted = {
**dict.fromkeys(self.OVERRIDES, "/elsewhere"),
"HOME": "/elsewhere",
"XDG_CONFIG_HOME": "/elsewhere",
}
with (
patch.dict("os.environ", planted),
patch(
"pkgmgr.core.git.run.subprocess.run", return_value=completed
) as mock_run,
):
if strict:
with strict_env():
run(["status"], scan=scan, config_view=view)
else:
run(["status"], scan=scan, config_view=view)
return mock_run.call_args.kwargs.get("env")
def _kept(self, env: dict[str, str]) -> list[str]:
return sorted(name for name in self.OVERRIDES if name in env)
def test_scan_drops_repository_overrides_from_the_environment(self) -> None:
unset = ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")
for view in (None, CALLER_CONFIG):
with self.subTest(view=view):
env = self._env(scan=True, strict=False, view=view)
self.assertEqual(
self._kept(env),
sorted(name for name in self.OVERRIDES if name not in unset),
)
self.assertEqual(env.get("GIT_CONFIG_NOSYSTEM"), None if view else "1")
self.assertIsNone(self._env(scan=False, strict=False, view=view))
def test_strict_env_keeps_only_the_ceiling(self) -> None:
for scan in (True, False):
with self.subTest(scan=scan):
env = self._env(scan=scan, strict=True)
self.assertEqual(self._kept(env), ["GIT_CEILING_DIRECTORIES"])
self.assertEqual(env.get("GIT_CONFIG_NOSYSTEM"), "1")
def test_strict_caller_config_also_keeps_the_config_variables(self) -> None:
for scan in (True, False):
with self.subTest(scan=scan):
env = self._env(scan=scan, strict=True, view=CALLER_CONFIG)
self.assertEqual(self._kept(env), list(self.CONFIG_READ))
self.assertIsNone(env.get("GIT_CONFIG_NOSYSTEM"))
self.assertEqual(env.get("HOME"), "/elsewhere")
def test_default_config_ignores_the_environment(self) -> None:
home = pwd.getpwuid(os.getuid()).pw_dir
for strict in (True, False):
with self.subTest(strict=strict):
env = self._env(scan=True, strict=strict, view=DEFAULT_CONFIG)
self.assertEqual(self._kept(env), ["GIT_CEILING_DIRECTORIES"])
self.assertIsNone(env.get("GIT_CONFIG_NOSYSTEM"))
self.assertEqual(env.get("HOME"), home)
self.assertFalse("XDG_CONFIG_HOME" in env)
def test_failure_raises_giterror_with_details(self) -> None:
# Build a CalledProcessError with stdout/stderr populated
import subprocess as sp