Merge branch 'feature/worktree-commands-v2' into merge/main-20261107
Both sides register CLI subparsers: review, worktree and gc are all kept. The review package now takes END_OF_OPTIONS from core.git.run and the repository filter keys from core.git.queries.local_config, where B moved them out of branch.gc; the semantics are unchanged (local and worktree scope, scan mode). 🎯 Fix: 93 % 💥 Break: 3 % 💸 Cost: 114 % ⏱️ Time: 129 % 🛡️ Security: 100 % 🏆 Score: 61 🚦 Gate: cost 114 % and time 129 %: accepted by the operator for B (2026-11-07); unit 457 OK, integration 93 OK on the merge Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1b9b93a542
35 files changed
+2599
-213
No files matched your search
@@ -4,14 +4,14 @@ Public API for branch actions.
|
||||
|
||||
from .close_branch import close_branch
|
||||
from .drop_branch import drop_branch
|
||||
from .gc import branch_gc, branch_purge
|
||||
from .gc import branch_purge, cleanup
|
||||
from .open_branch import open_branch
|
||||
from .tree import branch_tree, refresh_tree_entry
|
||||
|
||||
__all__ = [
|
||||
"branch_gc",
|
||||
"branch_purge",
|
||||
"branch_tree",
|
||||
"cleanup",
|
||||
"close_branch",
|
||||
"drop_branch",
|
||||
"open_branch",
|
||||
|
||||
+346
-80
@@ -3,25 +3,33 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
|
||||
from pkgmgr.actions.worktree.hooks import FINI_HOOK, hook_command, run_hook
|
||||
from pkgmgr.core.git.commands import delete_local_branch
|
||||
from pkgmgr.core.git.errors import GitRunError
|
||||
from pkgmgr.core.git.queries import list_remotes, resolve_base_branch
|
||||
from pkgmgr.core.git.run import run
|
||||
from pkgmgr.core.git.queries import (
|
||||
FILTER_CONFIG,
|
||||
list_remotes,
|
||||
local_config,
|
||||
resolve_base_branch,
|
||||
)
|
||||
from pkgmgr.core.git.run import END_OF_OPTIONS, run
|
||||
|
||||
from .gc_forge import ForgeFacts, fetch_forge_facts
|
||||
from .nofollow import canonical, remove_tree
|
||||
|
||||
Registrations = tuple[str, dict[str, list[str]]]
|
||||
Snapshot = tuple[
|
||||
dict[str, str],
|
||||
dict[str, dict[str, str]],
|
||||
dict[int, str],
|
||||
dict[str, tuple[str | None, int | None, int | None, bool]],
|
||||
Registrations,
|
||||
]
|
||||
|
||||
# PROC_PID_INIT_INO: only the initial PID namespace sees every process of the host.
|
||||
@@ -31,11 +39,8 @@ COMPOSE_PROJECT = "com.docker.compose.project"
|
||||
COMPOSE_WORKING_DIR = "com.docker.compose.project.working_dir"
|
||||
LOCK_PID = re.compile(r"\bpid (\d+)")
|
||||
TRACK = re.compile(r"(ahead|behind) (\d+)")
|
||||
UNSAFE_LOCAL_CONFIG = re.compile(r"^(local|worktree)\t(filter\.\S+)")
|
||||
GITLINK = "160000"
|
||||
# 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"
|
||||
STATUS_PATH_FIELD = {"1": 8, "2": 9, "u": 10, "?": 1}
|
||||
ALWAYS_PROTECTED = ("main", "master")
|
||||
LABELS = {
|
||||
"merged-clean": "DELETE",
|
||||
@@ -48,6 +53,8 @@ REFUSAL = (
|
||||
"Refusing: not in the host PID namespace (e.g. a sandbox), so running "
|
||||
"processes and worktree locks cannot be verified. Run it on the host."
|
||||
)
|
||||
SCOPES = ("worktree", "branch", "all", "purge")
|
||||
NOT_LINKED = "not a linked worktree of this repository"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -87,8 +94,12 @@ class Item:
|
||||
compose_projects: list[str] = field(default_factory=list)
|
||||
images: list[str] = field(default_factory=list)
|
||||
state: str = ""
|
||||
merged: bool = False
|
||||
dirty: int = 0
|
||||
merged_by: str | None = None
|
||||
merged_into: str | None = None
|
||||
unpushed: int | None = None
|
||||
last_activity: int | None = None
|
||||
size_bytes: int | None = None
|
||||
skip: str | None = None
|
||||
actions: list[str] = field(default_factory=list)
|
||||
@@ -97,7 +108,7 @@ class Item:
|
||||
|
||||
@property
|
||||
def eligible(self) -> bool:
|
||||
return self.state == "merged-clean" and self.skip is None and bool(self.actions)
|
||||
return self.skip is None and bool(self.actions)
|
||||
|
||||
def matches(self, name: str) -> bool:
|
||||
return name == self.branch or (
|
||||
@@ -119,6 +130,9 @@ class Repo:
|
||||
lines: dict[str, tuple[str, set[str]]]
|
||||
sizes: bool
|
||||
min_age: float | None
|
||||
scope: str
|
||||
activity: bool
|
||||
hooks: dict[str, list[str]]
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
items: list[Item] = field(default_factory=list)
|
||||
|
||||
@@ -178,6 +192,54 @@ def _inside(path: str, root: str) -> bool:
|
||||
return path == root or path.startswith(root.rstrip("/") + "/")
|
||||
|
||||
|
||||
def _registrations(repo_root: str) -> Registrations:
|
||||
base = os.path.join(_common_dir(repo_root), "worktrees")
|
||||
claims: dict[str, list[str]] = {}
|
||||
for entry in sorted(os.listdir(base)) if os.path.isdir(base) else []:
|
||||
entry_dir = os.path.join(base, entry)
|
||||
target = _pointer(os.path.join(entry_dir, "gitdir"), entry_dir)
|
||||
if target:
|
||||
claims.setdefault(target, []).append(entry)
|
||||
return base, claims
|
||||
|
||||
|
||||
def _linked_meta(registrations: Registrations, path: str) -> str | None:
|
||||
"""
|
||||
Resolve a worktree's metadata directory through its `.git` file.
|
||||
|
||||
Args:
|
||||
registrations: `<common>/worktrees` and, per gitdir target, the
|
||||
registrations naming it, from _registrations.
|
||||
path: worktree path as `git worktree list` derives it from the
|
||||
writable `<common>/worktrees/<id>/gitdir`, so possibly forged.
|
||||
|
||||
Returns:
|
||||
`<common>/worktrees/<id>` when path, the registration and
|
||||
`<common>/worktrees` are canonical, path's `.git` resolves to that
|
||||
registration and it is the only registration whose gitdir names
|
||||
`<path>/.git`, else None.
|
||||
"""
|
||||
base, claims = registrations
|
||||
if not (canonical(path) and canonical(base)):
|
||||
return None
|
||||
git_file = os.path.join(path, ".git")
|
||||
meta = _pointer(git_file, path)
|
||||
if meta is None or os.path.dirname(meta) != base or not canonical(meta):
|
||||
return None
|
||||
return meta if claims.get(git_file) == [os.path.basename(meta)] else None
|
||||
|
||||
|
||||
def _pointer(file: str, relative_to: str) -> str | None:
|
||||
if not os.path.isfile(file):
|
||||
return None
|
||||
try:
|
||||
with open(file, encoding="utf-8") as fh:
|
||||
target = fh.read().strip().removeprefix("gitdir: ")
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return os.path.normpath(os.path.join(relative_to, target))
|
||||
|
||||
|
||||
def _worktrees(repo_root: str) -> list[Worktree]:
|
||||
trees: list[Worktree] = []
|
||||
fields: dict[str, str] = {}
|
||||
@@ -304,7 +366,19 @@ def _merged_by(repo: Repo, sha: str) -> str | None:
|
||||
if number:
|
||||
return f"pr#{number}"
|
||||
line = _in_line(repo, sha)
|
||||
return f"line:{line}" if line else None
|
||||
if line:
|
||||
return f"line:{line}"
|
||||
return "patch" if _patch_merged(repo, sha) else None
|
||||
|
||||
|
||||
def _patch_merged(repo: Repo, sha: str) -> bool:
|
||||
cherry = _git(["cherry", END_OF_OPTIONS, repo.default, sha], repo.repo).splitlines()
|
||||
if not cherry or any(not line.startswith("- ") for line in cherry):
|
||||
return False
|
||||
outside = set(
|
||||
_git(["rev-list", END_OF_OPTIONS, f"{repo.default}..{sha}"], repo.repo).split()
|
||||
)
|
||||
return outside == {line[2:] for line in cherry}
|
||||
|
||||
|
||||
def _du_bytes(path: str) -> int | None:
|
||||
@@ -314,17 +388,24 @@ def _du_bytes(path: str) -> int | None:
|
||||
return int(out[0]) * 1024 if out and out[0].isdigit() else None
|
||||
|
||||
|
||||
def _age_hours(repo: Repo, shas: list[str], tree: Worktree | None) -> float:
|
||||
def _last_activity(
|
||||
repo: Repo, shas: list[str], tree: Worktree | None, changed: list[str]
|
||||
) -> int:
|
||||
stamps = [int(_git(["log", "-1", "--format=%ct", sha], repo.repo)) for sha in shas]
|
||||
if tree and os.path.isfile(os.path.join(tree.path, ".git")):
|
||||
with open(os.path.join(tree.path, ".git"), encoding="utf-8") as fh:
|
||||
index = os.path.join(fh.read().strip().removeprefix("gitdir: "), "index")
|
||||
if os.path.isfile(index):
|
||||
stamps.append(int(os.path.getmtime(index)))
|
||||
return (time.time() - max(stamps)) / 3600
|
||||
for path in changed:
|
||||
try:
|
||||
stamps.append(int(os.lstat(os.path.join(tree.path, path)).st_mtime))
|
||||
except OSError:
|
||||
continue
|
||||
return max(stamps)
|
||||
|
||||
|
||||
def _human(size: int) -> str:
|
||||
def human_size(size: int) -> str:
|
||||
value = float(size)
|
||||
for unit in ("K", "M", "G"):
|
||||
value /= 1024
|
||||
@@ -356,20 +437,12 @@ def _skip(
|
||||
lock = _lock_skip(tree.locked, repo.host_ns)
|
||||
if lock:
|
||||
return lock
|
||||
busy = sorted(pid for pid, cwd in cwds.items() if _inside(cwd, tree.path))
|
||||
if busy:
|
||||
return f"process: pid {busy[0]} ({_comm(busy[0])})"
|
||||
return None
|
||||
return _busy(cwds, tree.path)
|
||||
|
||||
|
||||
def _unsafe_config(path: str) -> list[str]:
|
||||
try:
|
||||
out = _git(["config", "--show-scope", "--get-regexp", r"^filter\."], path)
|
||||
except GitRunError as exc:
|
||||
if exc.returncode == 1:
|
||||
return []
|
||||
raise
|
||||
return [m.group(2) for m in map(UNSAFE_LOCAL_CONFIG.match, out.splitlines()) if m]
|
||||
def _busy(cwds: dict[int, str], path: str) -> str | None:
|
||||
busy = sorted(pid for pid, cwd in cwds.items() if _inside(cwd, path))
|
||||
return f"process: pid {busy[0]} ({_comm(busy[0])})" if busy else None
|
||||
|
||||
|
||||
def _has_submodules(path: str) -> bool:
|
||||
@@ -379,39 +452,59 @@ def _has_submodules(path: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _status(path: str) -> tuple[int, int, int, int]:
|
||||
lines = _git(
|
||||
["status", "--porcelain=v2", "--untracked-files=all"], path
|
||||
).splitlines()
|
||||
codes = [line.split(" ", 2)[1] for line in lines if line[:1] in "12u"]
|
||||
def _status(path: str) -> tuple[list[str], int, int, int]:
|
||||
records: list[str] = []
|
||||
paths: list[str] = []
|
||||
fields = iter(
|
||||
_git(["status", "--porcelain=v2", "-z", "--untracked-files=all"], path).split(
|
||||
"\0"
|
||||
)
|
||||
)
|
||||
for record in fields:
|
||||
kind = record[:1]
|
||||
if kind not in STATUS_PATH_FIELD:
|
||||
continue
|
||||
records.append(record)
|
||||
paths.append(record.split(" ", STATUS_PATH_FIELD[kind])[-1])
|
||||
if kind == "2":
|
||||
next(fields, None)
|
||||
codes = [record.split(" ", 2)[1] for record in records if record[:1] in "12u"]
|
||||
return (
|
||||
len(lines),
|
||||
paths,
|
||||
sum(code[0] != "." for code in codes),
|
||||
sum(code[1] != "." for code in codes),
|
||||
sum(line.startswith("? ") for line in lines),
|
||||
sum(record.startswith("? ") for record in records),
|
||||
)
|
||||
|
||||
|
||||
def _read_worktree(item: Item, checkout: bool) -> tuple[int, str | None]:
|
||||
def _read_worktree(item: Item, checkout: bool) -> tuple[int, str | None, list[str]]:
|
||||
if item.worktree is None:
|
||||
return 0, None
|
||||
return 0, None, []
|
||||
if not checkout:
|
||||
item.worktree_state = "missing"
|
||||
return 0, None
|
||||
return 0, None, []
|
||||
try:
|
||||
if _has_submodules(item.worktree):
|
||||
item.worktree_state = "changed"
|
||||
return -1, "status skipped: submodules, whose config the scan cannot vet"
|
||||
unsafe = _unsafe_config(item.worktree)
|
||||
return (
|
||||
-1,
|
||||
"status skipped: submodules, whose config the scan cannot vet",
|
||||
[],
|
||||
)
|
||||
unsafe = local_config(item.worktree, FILTER_CONFIG)
|
||||
if unsafe:
|
||||
item.worktree_state = "changed"
|
||||
return -1, f"status skipped: {', '.join(unsafe)} in the repository config"
|
||||
dirty, item.staged, item.unstaged, item.untracked = _status(item.worktree)
|
||||
return (
|
||||
-1,
|
||||
f"status skipped: {', '.join(unsafe)} in the repository config",
|
||||
[],
|
||||
)
|
||||
paths, item.staged, item.unstaged, item.untracked = _status(item.worktree)
|
||||
except GitRunError as exc:
|
||||
item.worktree_state = "changed"
|
||||
return -1, f"status failed: {_describe(exc)}"
|
||||
item.worktree_state = "changed" if dirty else "clean"
|
||||
return dirty, None
|
||||
return -1, f"status failed: {_describe(exc)}", []
|
||||
item.worktree_state = "changed" if paths else "clean"
|
||||
return len(paths), None, paths
|
||||
|
||||
|
||||
def _build(
|
||||
@@ -420,7 +513,7 @@ def _build(
|
||||
tree: Worktree | None,
|
||||
snapshot: Snapshot,
|
||||
) -> Item:
|
||||
local, remote, cwds, tracking = snapshot
|
||||
local, remote, cwds, tracking, registrations = snapshot
|
||||
upstream, ahead, behind, gone = tracking.get(
|
||||
branch or "", (None, None, None, False)
|
||||
)
|
||||
@@ -438,7 +531,8 @@ def _build(
|
||||
remotes=dict(remote.get(branch, {})) if branch else {},
|
||||
)
|
||||
checkout = tree is not None and not tree.prunable and os.path.isdir(tree.path)
|
||||
dirty, unread = _read_worktree(item, checkout)
|
||||
dirty, unread, changed = _read_worktree(item, checkout)
|
||||
item.dirty = max(dirty, 0)
|
||||
if branch in repo.protected:
|
||||
item.state, item.reason = "protected", "default or protected branch"
|
||||
return item
|
||||
@@ -451,6 +545,18 @@ def _build(
|
||||
shas = [item.local_sha, *item.remotes.values(), tree.head if tree else None]
|
||||
verdicts = {sha: _merged_by(repo, sha) for sha in dict.fromkeys(shas) if sha}
|
||||
unmerged = [sha for sha, verdict in verdicts.items() if verdict is None]
|
||||
item.merged = bool(verdicts) and not unmerged
|
||||
|
||||
head = tree.head if checkout else item.local_sha
|
||||
if unmerged and head:
|
||||
tips = {sha for shas in remote.values() for sha in shas.values()}
|
||||
item.unpushed = (
|
||||
0
|
||||
if head in tips
|
||||
else int(
|
||||
_git(["rev-list", "--count", head, "--not", "--remotes"], repo.repo)
|
||||
)
|
||||
)
|
||||
|
||||
if unread:
|
||||
item.state, item.reason = "dirty", unread
|
||||
@@ -489,32 +595,89 @@ def _build(
|
||||
|
||||
if repo.sizes and checkout and not tree.main:
|
||||
item.size_bytes = _du_bytes(tree.path)
|
||||
if verdicts and (repo.activity or repo.min_age is not None):
|
||||
item.last_activity = _last_activity(
|
||||
repo, list(verdicts), tree if checkout else None, changed
|
||||
)
|
||||
item.skip = _skip(repo, item, tree, checkout, cwds)
|
||||
if item.skip is None and repo.min_age is not None and verdicts:
|
||||
age = _age_hours(repo, list(verdicts), tree if checkout else None)
|
||||
if age < repo.min_age:
|
||||
item.skip = f"younger than {repo.min_age:g} h"
|
||||
for project in item.compose_projects:
|
||||
item.actions += [f"compose-down:{project}", f"images:{project}"]
|
||||
if tree:
|
||||
item.actions.append("worktree")
|
||||
if item.local_sha:
|
||||
item.actions.append("local-branch")
|
||||
item.actions += [f"remote-branch:{name}" for name in item.remotes]
|
||||
if (
|
||||
item.skip is None
|
||||
and repo.min_age is not None
|
||||
and item.last_activity
|
||||
and (time.time() - item.last_activity) / 3600 < repo.min_age
|
||||
):
|
||||
item.skip = f"younger than {repo.min_age:g} h"
|
||||
item.actions = _actions(repo, item, tree, checkout, dirty, registrations)
|
||||
return item
|
||||
|
||||
|
||||
def _actions(
|
||||
repo: Repo,
|
||||
item: Item,
|
||||
tree: Worktree | None,
|
||||
checkout: bool,
|
||||
dirty: int,
|
||||
registrations: Registrations,
|
||||
) -> list[str]:
|
||||
tree_part: list[str] = []
|
||||
if tree:
|
||||
if (
|
||||
checkout
|
||||
and not tree.main
|
||||
and _linked_meta(registrations, tree.path) is None
|
||||
):
|
||||
item.skip = NOT_LINKED
|
||||
return []
|
||||
if checkout and FINI_HOOK in repo.hooks:
|
||||
tree_part.append(f"hook:{FINI_HOOK}")
|
||||
for project in item.compose_projects:
|
||||
tree_part += [f"compose-down:{project}", f"images:{project}"]
|
||||
tree_part.append("worktree")
|
||||
branch_part = ["local-branch"] if item.local_sha else []
|
||||
branch_part += [f"remote-branch:{name}" for name in item.remotes]
|
||||
|
||||
if repo.scope == "purge":
|
||||
return tree_part + branch_part
|
||||
tree_ok = (
|
||||
tree is not None
|
||||
and not tree.main
|
||||
and dirty == 0
|
||||
and (item.merged or item.unpushed == 0)
|
||||
)
|
||||
branch_ok = item.state == "merged-clean"
|
||||
if repo.scope == "worktree":
|
||||
return tree_part if tree_ok else []
|
||||
if repo.scope == "branch":
|
||||
if branch_ok and tree is not None and item.skip is None:
|
||||
item.skip = f"checked out in worktree {tree.path}"
|
||||
return branch_part if branch_ok and tree is None else []
|
||||
actions = tree_part if tree_ok else []
|
||||
if branch_ok and (tree is None or tree_ok):
|
||||
actions += branch_part
|
||||
return actions
|
||||
|
||||
|
||||
def _snapshot(repo: Repo) -> tuple[list[Worktree], Snapshot]:
|
||||
local, remote, tracking = _refs(repo.repo, repo.remotes)
|
||||
cwds = _process_cwds() if repo.host_ns else {}
|
||||
return _worktrees(repo.repo), (local, remote, cwds, tracking)
|
||||
return _worktrees(repo.repo), (
|
||||
local,
|
||||
remote,
|
||||
cwds,
|
||||
tracking,
|
||||
_registrations(repo.repo),
|
||||
)
|
||||
|
||||
|
||||
def _items(repo: Repo) -> list[Item]:
|
||||
trees, snapshot = _snapshot(repo)
|
||||
local, remote, _, _ = snapshot
|
||||
local, remote, *_ = snapshot
|
||||
by_branch = {t.branch: t for t in trees if t.branch}
|
||||
names = sorted(set(local) | set(remote) | set(by_branch))
|
||||
names = (
|
||||
sorted(by_branch)
|
||||
if repo.scope == "worktree"
|
||||
else sorted(set(local) | set(remote) | set(by_branch))
|
||||
)
|
||||
items = [_build(repo, name, by_branch.get(name), snapshot) for name in names]
|
||||
items += [
|
||||
_build(repo, None, tree, snapshot)
|
||||
@@ -581,6 +744,9 @@ def plan_repo(
|
||||
merged_into: list[str],
|
||||
sizes: bool,
|
||||
min_age: float | None,
|
||||
scope: str,
|
||||
activity: bool,
|
||||
hooks: dict[str, dict[str, list[str]]],
|
||||
fetch: bool = True,
|
||||
forge_source: Callable[[dict[str, str]], ForgeFacts] | None = None,
|
||||
) -> Repo:
|
||||
@@ -593,13 +759,20 @@ def plan_repo(
|
||||
host_ns: whether this process runs in the host PID namespace.
|
||||
merged_into: integration lines (refs) that also count as merged; each is protected.
|
||||
sizes: measure each worktree's disk usage into size_bytes.
|
||||
min_age: skip items whose newest commit or worktree index is younger (hours).
|
||||
min_age: skip items whose newest commit, worktree index or changed file is younger (hours).
|
||||
scope: one of SCOPES; decides which actions an item gets.
|
||||
activity: record last_activity on every item, not only under min_age.
|
||||
hooks: realpath of a managed repository's root -> hook name -> argv,
|
||||
as the CLI binds `worktree_hooks`; only repo_root's entry is used,
|
||||
and only while repo_root's git directory is its own.
|
||||
fetch: fetch every remote first; the tree scan reads the known refs only.
|
||||
forge_source: remote name -> URL to forge facts; fetch_forge_facts by default.
|
||||
|
||||
Returns:
|
||||
The repository with its classified items.
|
||||
"""
|
||||
if scope not in SCOPES:
|
||||
raise ValueError(f"unknown scope {scope!r}")
|
||||
warnings: list[str] = []
|
||||
if fetch:
|
||||
try:
|
||||
@@ -659,12 +832,29 @@ def plan_repo(
|
||||
lines=lines,
|
||||
sizes=sizes,
|
||||
min_age=min_age,
|
||||
scope=scope,
|
||||
activity=activity,
|
||||
hooks=_pins(hooks, repo_root, warnings),
|
||||
warnings=warnings,
|
||||
)
|
||||
repo.items = _items(repo)
|
||||
return repo
|
||||
|
||||
|
||||
def _pins(
|
||||
hooks: dict[str, dict[str, list[str]]], repo_root: str, warnings: list[str]
|
||||
) -> dict[str, list[str]]:
|
||||
root = os.path.realpath(repo_root)
|
||||
pinned = hooks.get(root, {})
|
||||
if not pinned:
|
||||
return {}
|
||||
owner = os.path.realpath(os.path.dirname(_common_dir(repo_root)))
|
||||
if owner != root:
|
||||
warnings.append(f"pinned hooks skipped: its git directory belongs to {owner}")
|
||||
return {}
|
||||
return pinned
|
||||
|
||||
|
||||
def _foreign_owned(path: str) -> bool:
|
||||
uid = os.getuid()
|
||||
if os.lstat(path).st_uid != uid:
|
||||
@@ -677,12 +867,9 @@ def _foreign_owned(path: str) -> bool:
|
||||
|
||||
|
||||
def _remove_worktree(repo_root: str, path: str) -> None:
|
||||
with open(os.path.join(path, ".git"), encoding="utf-8") as fh:
|
||||
meta = os.path.normpath(
|
||||
os.path.join(path, fh.read().strip().removeprefix("gitdir: "))
|
||||
)
|
||||
if os.path.dirname(meta) != os.path.join(_common_dir(repo_root), "worktrees"):
|
||||
raise RuntimeError(f"unexpected worktree metadata path {meta}")
|
||||
meta = _linked_meta(_registrations(repo_root), path)
|
||||
if meta is None:
|
||||
raise RuntimeError(f"{path}: {NOT_LINKED}")
|
||||
|
||||
if _foreign_owned(path):
|
||||
parent, name = os.path.split(path.rstrip("/"))
|
||||
@@ -699,10 +886,10 @@ def _remove_worktree(repo_root: str, path: str) -> None:
|
||||
]
|
||||
)
|
||||
else:
|
||||
shutil.rmtree(path)
|
||||
remove_tree(path)
|
||||
if os.path.lexists(path):
|
||||
raise RuntimeError(f"worktree {path} still exists; metadata {meta} kept")
|
||||
shutil.rmtree(meta)
|
||||
remove_tree(meta)
|
||||
|
||||
|
||||
def _rebuild(repo: Repo, item: Item) -> Item:
|
||||
@@ -726,9 +913,41 @@ def _fingerprint(item: Item) -> tuple:
|
||||
item.remotes,
|
||||
item.worktree,
|
||||
item.compose_projects,
|
||||
item.actions,
|
||||
)
|
||||
|
||||
|
||||
def _run_fini_hook(repo: Repo, path: str) -> None:
|
||||
run_hook(
|
||||
FINI_HOOK,
|
||||
hook_command(FINI_HOOK, repo.hooks[FINI_HOOK], path, None),
|
||||
path,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _sha(cwd: str, ref: str) -> str | None:
|
||||
try:
|
||||
return _git(["rev-parse", "--verify", "--quiet", END_OF_OPTIONS, ref], cwd)
|
||||
except GitRunError:
|
||||
return None
|
||||
|
||||
|
||||
def _changed_by_hook(repo: Repo, item: Item) -> str | None:
|
||||
path = item.worktree
|
||||
dirty, unread, _ = _read_worktree(Item(branch=None, worktree=path), True)
|
||||
if unread or dirty:
|
||||
return unread or f"dirty: {dirty} paths"
|
||||
if _sha(path, "HEAD") != item.head:
|
||||
return "HEAD moved"
|
||||
if item.branch and _sha(repo.repo, f"refs/heads/{item.branch}") != item.local_sha:
|
||||
return f"branch {item.branch} moved"
|
||||
return _busy(_process_cwds(), path) if repo.host_ns else None
|
||||
|
||||
|
||||
def execute(repo: Repo, item: Item) -> None:
|
||||
fresh = _rebuild(repo, item)
|
||||
if _fingerprint(fresh) != _fingerprint(item):
|
||||
@@ -736,19 +955,31 @@ def execute(repo: Repo, item: Item) -> None:
|
||||
return
|
||||
|
||||
notes: list[str] = []
|
||||
removes_tree = "worktree" in item.actions
|
||||
if removes_tree and _linked_meta(_registrations(repo.repo), item.worktree) is None:
|
||||
item.result = f"skipped: {NOT_LINKED}"
|
||||
return
|
||||
try:
|
||||
for project in item.compose_projects:
|
||||
if f"hook:{FINI_HOOK}" in item.actions:
|
||||
_run_fini_hook(repo, item.worktree)
|
||||
changed = _changed_by_hook(repo, item)
|
||||
if changed:
|
||||
item.result = f"skipped: changed during {FINI_HOOK} ({changed})"
|
||||
return
|
||||
for project in item.compose_projects if removes_tree else []:
|
||||
_docker(["compose", "-p", project, "down", "-v", "--remove-orphans"])
|
||||
for image in item.images:
|
||||
for image in item.images if removes_tree else []:
|
||||
try:
|
||||
_docker(["image", "rm", image])
|
||||
except subprocess.CalledProcessError:
|
||||
notes.append(f"image kept: {image}")
|
||||
if item.worktree:
|
||||
if removes_tree:
|
||||
_remove_worktree(repo.repo, item.worktree)
|
||||
if item.local_sha:
|
||||
if "local-branch" in item.actions:
|
||||
delete_local_branch(item.branch, cwd=repo.repo, force=True)
|
||||
for remote, sha in item.remotes.items():
|
||||
if f"remote-branch:{remote}" not in item.actions:
|
||||
continue
|
||||
run(
|
||||
[
|
||||
"push",
|
||||
@@ -765,15 +996,23 @@ def execute(repo: Repo, item: Item) -> None:
|
||||
item.result = "; ".join(["done", *notes])
|
||||
|
||||
|
||||
def _label(item: Item) -> str:
|
||||
if item.skip and (item.actions or item.state == "merged-clean"):
|
||||
return "SKIP"
|
||||
if item.actions:
|
||||
return "REMOVE" if item.state in ("unmerged", "dirty") else "DELETE"
|
||||
return "KEEP" if item.state == "merged-clean" else LABELS[item.state]
|
||||
|
||||
|
||||
def _line(item: Item) -> str:
|
||||
label = "SKIP" if item.state == "merged-clean" and item.skip else LABELS[item.state]
|
||||
label = _label(item)
|
||||
parts = [item.reason]
|
||||
if item.skip and item.state != "protected":
|
||||
parts.append(item.skip)
|
||||
if item.worktree and item.state != "stale":
|
||||
parts.append(f"worktree {item.worktree}")
|
||||
if item.size_bytes is not None:
|
||||
parts.append(f"size {_human(item.size_bytes)}")
|
||||
parts.append(f"size {human_size(item.size_bytes)}")
|
||||
if item.compose_projects:
|
||||
parts.append("stack " + ", ".join(item.compose_projects))
|
||||
if item.remotes:
|
||||
@@ -819,7 +1058,7 @@ def _render(
|
||||
)
|
||||
|
||||
|
||||
def _prepare(
|
||||
def prepare(
|
||||
repo_roots: list[str],
|
||||
include: list[str],
|
||||
exclude: list[str],
|
||||
@@ -858,9 +1097,10 @@ def _finish(repos: list[Repo]) -> int:
|
||||
return 1 if any(result.startswith("failed") for result in results) else 0
|
||||
|
||||
|
||||
def branch_gc(
|
||||
def cleanup(
|
||||
repo_roots: list[str],
|
||||
*,
|
||||
scope: str,
|
||||
apply: bool,
|
||||
as_json: bool,
|
||||
include: list[str],
|
||||
@@ -868,12 +1108,17 @@ def branch_gc(
|
||||
merged_into: list[str],
|
||||
sizes: bool,
|
||||
min_age: float | None,
|
||||
hooks: dict[str, dict[str, list[str]]],
|
||||
) -> int:
|
||||
"""
|
||||
Plan, and with apply=True execute, the cleanup of merged and clean branches.
|
||||
Plan, and with apply=True execute, a cleanup of worktrees, branches or both.
|
||||
|
||||
Args:
|
||||
repo_roots: main worktree roots of the repositories to scan.
|
||||
scope: `worktree` removes clean worktrees whose commits are merged or
|
||||
pushed (hook, stack, checkout; the branch stays); `branch` removes
|
||||
merged branches without a worktree, local and remote; `all` does
|
||||
both in one pass.
|
||||
apply: execute the plan instead of only printing it.
|
||||
as_json: print the plan as JSON instead of text lines.
|
||||
include: only items whose branch or worktree path matches one of these.
|
||||
@@ -881,15 +1126,26 @@ def branch_gc(
|
||||
merged_into: integration lines (refs) that also count as merged.
|
||||
sizes: measure each worktree's disk usage into the plan.
|
||||
min_age: skip items younger than this many hours.
|
||||
hooks: pinned hooks by managed repository root realpath, from the
|
||||
CLI; a worktree-fini hook runs only for a root pinned there.
|
||||
|
||||
Returns:
|
||||
0 on success, 1 when an applied item failed, 2 when refused.
|
||||
"""
|
||||
if scope not in ("worktree", "branch", "all"):
|
||||
raise ValueError(f"unknown cleanup scope {scope!r}")
|
||||
if apply and not host_pid_namespace():
|
||||
print(REFUSAL, file=sys.stderr)
|
||||
return 2
|
||||
options = {"merged_into": merged_into, "sizes": sizes, "min_age": min_age}
|
||||
prepared = _prepare(repo_roots, include, exclude, options)
|
||||
options = {
|
||||
"merged_into": merged_into,
|
||||
"sizes": sizes,
|
||||
"min_age": min_age,
|
||||
"scope": scope,
|
||||
"activity": False,
|
||||
"hooks": hooks,
|
||||
}
|
||||
prepared = prepare(repo_roots, include, exclude, options)
|
||||
if prepared is None:
|
||||
return 2
|
||||
repos, host_ns, chosen = prepared
|
||||
@@ -900,7 +1156,7 @@ def branch_gc(
|
||||
if item.eligible and chosen(item):
|
||||
execute(repo, item)
|
||||
print(
|
||||
f"[gc] {item.branch or item.worktree}: {item.result}",
|
||||
f"[{scope}] {item.branch or item.worktree}: {item.result}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
_render(repos, host_ns, chosen, as_json)
|
||||
@@ -915,6 +1171,7 @@ def branch_purge(
|
||||
merged_into: list[str],
|
||||
sizes: bool,
|
||||
min_age: float | None,
|
||||
hooks: dict[str, dict[str, list[str]]],
|
||||
) -> int:
|
||||
"""
|
||||
Delete unmerged or dirty items after a y/N confirmation per item.
|
||||
@@ -926,6 +1183,8 @@ def branch_purge(
|
||||
merged_into: integration lines (refs) that also count as merged.
|
||||
sizes: measure each worktree's disk usage into the prompt line.
|
||||
min_age: skip items younger than this many hours.
|
||||
hooks: pinned hooks by managed repository root realpath, from the
|
||||
CLI; a worktree-fini hook runs only for a root pinned there.
|
||||
|
||||
Returns:
|
||||
0 on success, 1 when a confirmed item failed, 2 when refused.
|
||||
@@ -933,8 +1192,15 @@ def branch_purge(
|
||||
if not host_pid_namespace():
|
||||
print(REFUSAL, file=sys.stderr)
|
||||
return 2
|
||||
options = {"merged_into": merged_into, "sizes": sizes, "min_age": min_age}
|
||||
prepared = _prepare(repo_roots, include, exclude, options)
|
||||
options = {
|
||||
"merged_into": merged_into,
|
||||
"sizes": sizes,
|
||||
"min_age": min_age,
|
||||
"scope": "purge",
|
||||
"activity": False,
|
||||
"hooks": hooks,
|
||||
}
|
||||
prepared = prepare(repo_roots, include, exclude, options)
|
||||
if prepared is None:
|
||||
return 2
|
||||
repos, _, chosen = prepared
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
||||
|
||||
|
||||
def canonical(path: str) -> bool:
|
||||
"""
|
||||
Args:
|
||||
path: a path as git or a plan names it.
|
||||
|
||||
Returns:
|
||||
True when path is absolute and already its own realpath: no symbolic
|
||||
link and no `..` component that the kernel could resolve elsewhere.
|
||||
"""
|
||||
return os.path.isabs(path) and os.path.realpath(path) == path
|
||||
|
||||
|
||||
def remove_tree(path: str) -> None:
|
||||
"""
|
||||
Delete a directory tree without following a symbolic link anywhere on
|
||||
the way to it, even one swapped in after the caller checked the path.
|
||||
|
||||
Args:
|
||||
path: absolute, canonical directory to delete.
|
||||
|
||||
Raises:
|
||||
OSError: a component of path is a symbolic link or missing, or the
|
||||
deletion failed.
|
||||
"""
|
||||
parent, name = os.path.split(path)
|
||||
fd = os.open(os.sep, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
for component in [c for c in parent.split(os.sep) if c]:
|
||||
child = os.open(component, DIR_FLAGS, dir_fd=fd)
|
||||
os.close(fd)
|
||||
fd = child
|
||||
# Trip-wire: rmtree(dir_fd=) needs Python 3.11; the fd link pins the
|
||||
# opened parent on every version, and rmtree refuses a symlinked name.
|
||||
shutil.rmtree(f"/proc/self/fd/{fd}/{name}")
|
||||
finally:
|
||||
os.close(fd)
|
||||
@@ -221,6 +221,9 @@ def _scan(
|
||||
merged_into=[],
|
||||
sizes=False,
|
||||
min_age=None,
|
||||
scope="all",
|
||||
activity=True,
|
||||
hooks={},
|
||||
fetch=False,
|
||||
forge_source=source,
|
||||
)
|
||||
|
||||
@@ -2,8 +2,9 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pkgmgr.actions.branch.gc import GITLINK, _unsafe_config
|
||||
from pkgmgr.actions.branch.gc import GITLINK
|
||||
from pkgmgr.core.git.errors import GitBaseError, GitRunError
|
||||
from pkgmgr.core.git.queries import FILTER_CONFIG, local_config
|
||||
from pkgmgr.core.git.run import run
|
||||
|
||||
from .model import FileView, ReviewError
|
||||
@@ -90,7 +91,7 @@ def _submodule_configs(top: str, prefix: str) -> list[str]:
|
||||
):
|
||||
continue
|
||||
try:
|
||||
keys = _unsafe_config(full)
|
||||
keys = local_config(full, FILTER_CONFIG)
|
||||
nested = _submodule_configs(top, relative)
|
||||
except (GitBaseError, ReviewError):
|
||||
reasons.append(
|
||||
@@ -107,6 +108,6 @@ def _submodule_configs(top: str, prefix: str) -> list[str]:
|
||||
|
||||
|
||||
def unsafe_configs(top: str) -> list[str]:
|
||||
keys = _unsafe_config(top)
|
||||
keys = local_config(top, FILTER_CONFIG)
|
||||
own = [f"the repository config defines {', '.join(keys)}, {RUNS}"] if keys else []
|
||||
return own + _submodule_configs(os.path.realpath(top), "")
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pkgmgr.actions.branch.gc import END_OF_OPTIONS
|
||||
from pkgmgr.core.git.run import END_OF_OPTIONS
|
||||
|
||||
from .checks import attribute_binary, hidden_entries, unsafe_configs
|
||||
from .diff import parse_patch
|
||||
|
||||
@@ -2,9 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from pkgmgr.actions.branch.gc import END_OF_OPTIONS
|
||||
from pkgmgr.core.git.errors import GitBaseError
|
||||
from pkgmgr.core.git.run import SCAN_FLAGS, SCAN_TIMEOUT, _scan_env, run
|
||||
from pkgmgr.core.git.run import END_OF_OPTIONS, SCAN_FLAGS, SCAN_TIMEOUT, _scan_env, run
|
||||
|
||||
from .model import ReviewError
|
||||
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from .trust import root_resolved, trusted_path
|
||||
|
||||
INIT_HOOK = "worktree-init"
|
||||
FINI_HOOK = "worktree-fini"
|
||||
MAKEFILES = ("GNUmakefile", "makefile", "Makefile")
|
||||
MAKEFILE_LIMIT = 1 << 20
|
||||
HOOK_TIMEOUT = 1800
|
||||
HOOK_GRACE = 5
|
||||
PLACEHOLDER = re.compile(r"\{(path|slot)\}")
|
||||
|
||||
|
||||
def defined_in(path: str, target: str) -> str | None:
|
||||
"""
|
||||
Name the makefile in path that defines target, read as text and never run:
|
||||
make expands $(shell) and include lines even under -n.
|
||||
|
||||
Args:
|
||||
path: directory that may hold a makefile.
|
||||
target: make target name.
|
||||
|
||||
Returns:
|
||||
The first makefile, in make's lookup order, with a `<target>:` rule;
|
||||
None when none has one.
|
||||
"""
|
||||
rule = re.compile(rf"^{re.escape(target)}\s*:", re.MULTILINE)
|
||||
for name in MAKEFILES:
|
||||
file = os.path.join(path, name)
|
||||
if os.path.islink(file) or not os.path.isfile(file):
|
||||
continue
|
||||
with open(file, encoding="utf-8", errors="replace") as fh:
|
||||
if rule.search(fh.read(MAKEFILE_LIMIT)):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def hook_command(name: str, argv: list[str], path: str, slot: int | None) -> list[str]:
|
||||
"""
|
||||
Fill a pinned hook command in one pass and resolve its program.
|
||||
|
||||
Args:
|
||||
name: the hook, for error messages.
|
||||
argv: the command pinned in the `worktree_hooks` config.
|
||||
path: the worktree, replacing `{path}`.
|
||||
slot: the worktree's slot, replacing `{slot}`; None leaves it as is.
|
||||
|
||||
Returns:
|
||||
The argv to run; its program is an absolute path, for a bare name the
|
||||
resolved file that was checked, so a multi-call binary sees that
|
||||
file's name; pin the link's absolute path to keep the link's name.
|
||||
|
||||
Raises:
|
||||
RuntimeError: the program is neither absolute nor a bare name found,
|
||||
as a root-controlled file, on a root-owned PATH entry no group or
|
||||
other user can write; anything relative resolves in the worktree.
|
||||
"""
|
||||
|
||||
def fill(match: re.Match[str]) -> str:
|
||||
if match.group(1) == "path":
|
||||
return path
|
||||
return match.group(0) if slot is None else str(slot)
|
||||
|
||||
filled = [PLACEHOLDER.sub(fill, arg) for arg in argv]
|
||||
program = filled[0] if filled else ""
|
||||
if not os.path.isabs(program):
|
||||
found = (
|
||||
shutil.which(program, path=trusted_path())
|
||||
if program and "/" not in program
|
||||
else None
|
||||
)
|
||||
resolved = root_resolved(found) if found else None
|
||||
if resolved is None:
|
||||
raise RuntimeError(
|
||||
f"{name} pin {program!r} is neither an absolute path nor a "
|
||||
"root-owned command on a root-owned PATH entry"
|
||||
)
|
||||
program = resolved
|
||||
return [program, *filled[1:]]
|
||||
|
||||
|
||||
def run_hook(
|
||||
name: str, command: list[str], path: str, *, check: bool, **kwargs
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""
|
||||
Run a resolved hook command in the worktree.
|
||||
|
||||
Args:
|
||||
name: the hook, for error messages.
|
||||
command: the argv from hook_command.
|
||||
path: the worktree to run in.
|
||||
check: raise CalledProcessError on a non-zero exit.
|
||||
kwargs: further subprocess.Popen arguments, such as output handling.
|
||||
|
||||
Returns:
|
||||
The completed process; stdin was closed, PATH held only its
|
||||
root-controlled entries and HOOK_TIMEOUT applied to its whole process
|
||||
group.
|
||||
|
||||
Raises:
|
||||
RuntimeError: the hook ran longer than HOOK_TIMEOUT, or no PATH entry
|
||||
is root-controlled.
|
||||
"""
|
||||
with subprocess.Popen(
|
||||
command,
|
||||
cwd=path,
|
||||
stdin=subprocess.DEVNULL,
|
||||
env={**os.environ, "PATH": trusted_path()},
|
||||
start_new_session=True,
|
||||
**kwargs,
|
||||
) as process:
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=HOOK_TIMEOUT)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
_stop_group(process)
|
||||
raise RuntimeError(f"{name} timed out after {HOOK_TIMEOUT} s") from exc
|
||||
if check and process.returncode:
|
||||
raise subprocess.CalledProcessError(
|
||||
process.returncode, command, output=stdout, stderr=stderr
|
||||
)
|
||||
return subprocess.CompletedProcess(command, process.returncode, stdout, stderr)
|
||||
|
||||
|
||||
def _stop_group(process: subprocess.Popen) -> None:
|
||||
for sig in (signal.SIGTERM, signal.SIGKILL):
|
||||
try:
|
||||
os.killpg(process.pid, sig)
|
||||
except OSError:
|
||||
return
|
||||
deadline = time.monotonic() + HOOK_GRACE
|
||||
while time.monotonic() < deadline:
|
||||
process.poll()
|
||||
try:
|
||||
os.killpg(process.pid, 0)
|
||||
except OSError:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
|
||||
from pkgmgr.actions.branch.gc import Item, human_size, prepare
|
||||
|
||||
DAY = 86400
|
||||
ORDER = ("merged-clean", "merged-dirty", "stale-open", "stale", "active")
|
||||
PROPOSALS = {
|
||||
"merged-clean": "pkgmgr gc",
|
||||
"merged-dirty": "ask",
|
||||
"stale-open": "ask",
|
||||
"stale": "git worktree prune",
|
||||
"active": "keep",
|
||||
}
|
||||
GUARDS = ("process:", "lock:")
|
||||
HEADER = (
|
||||
"Class",
|
||||
"Branch",
|
||||
"Worktree",
|
||||
"Dirty",
|
||||
"Unpushed",
|
||||
"Last activity",
|
||||
"Proposal",
|
||||
)
|
||||
|
||||
|
||||
def classify(item: Item, now: float, limit: float) -> str:
|
||||
"""
|
||||
Sort one scanned item into an orphan class.
|
||||
|
||||
Args:
|
||||
item: item from a scan with activity recorded.
|
||||
now: current epoch seconds.
|
||||
limit: seconds of inactivity after which unfinished work is stale.
|
||||
|
||||
Returns:
|
||||
One of ORDER.
|
||||
"""
|
||||
if item.state == "stale":
|
||||
return "stale"
|
||||
if (
|
||||
item.state == "protected"
|
||||
or (item.branch or "").startswith("archive/")
|
||||
or item.skip == "checked out in main worktree"
|
||||
):
|
||||
return "active"
|
||||
if item.merged:
|
||||
return "merged-dirty" if item.state == "dirty" else "merged-clean"
|
||||
pending = item.state == "dirty" or bool(item.unpushed) or not item.remotes
|
||||
idle = item.last_activity is not None and now - item.last_activity > limit
|
||||
guarded = bool(item.skip) and item.skip.startswith(GUARDS)
|
||||
return "stale-open" if pending and idle and not guarded else "active"
|
||||
|
||||
|
||||
def proposal(item: Item, cls: str) -> str:
|
||||
if cls == "active":
|
||||
return "keep"
|
||||
if item.skip and item.skip.startswith(GUARDS):
|
||||
return f"keep ({item.skip})"
|
||||
return PROPOSALS[cls]
|
||||
|
||||
|
||||
def _cells(row: dict, now: float, sizes: bool) -> list[str]:
|
||||
activity = row["last_activity"]
|
||||
when = (
|
||||
f"{time.strftime('%Y-%m-%d', time.localtime(activity))} "
|
||||
f"({int((now - activity) // DAY)} d)"
|
||||
if activity
|
||||
else "-"
|
||||
)
|
||||
values = [
|
||||
row["class"],
|
||||
row["branch"] or "(detached)",
|
||||
row["worktree"] or "-",
|
||||
*(
|
||||
[human_size(row["size_bytes"]) if row["size_bytes"] else "-"]
|
||||
if sizes
|
||||
else []
|
||||
),
|
||||
str(row["dirty"]),
|
||||
"-" if row["unpushed"] is None else str(row["unpushed"]),
|
||||
when,
|
||||
row["proposal"],
|
||||
]
|
||||
return [value.replace("|", "\\|") for value in values]
|
||||
|
||||
|
||||
def _table(rows: list[dict], now: float, sizes: bool) -> list[str]:
|
||||
header = [*HEADER[:3], *(["Size"] if sizes else []), *HEADER[3:]]
|
||||
lines = [
|
||||
"| " + " | ".join(header) + " |",
|
||||
"| " + " | ".join("---" for _ in header) + " |",
|
||||
]
|
||||
return lines + ["| " + " | ".join(_cells(r, now, sizes)) + " |" for r in rows]
|
||||
|
||||
|
||||
def worktree_orphans(
|
||||
repo_roots: list[str],
|
||||
*,
|
||||
stale_days: int,
|
||||
top: int,
|
||||
as_json: bool,
|
||||
include: list[str],
|
||||
exclude: list[str],
|
||||
merged_into: list[str],
|
||||
sizes: bool,
|
||||
) -> int:
|
||||
"""
|
||||
List merged and abandoned branches and worktrees with a proposal each.
|
||||
|
||||
Read-only apart from the fetch the scan does. Merged covers ancestry, a
|
||||
merged pull request, an integration line and patch equivalence.
|
||||
|
||||
Args:
|
||||
repo_roots: main worktree roots of the repositories to scan.
|
||||
stale_days: unfinished work idle for longer is stale-open.
|
||||
top: rows per class in the text table; 0 shows the counts only.
|
||||
as_json: print every row, active ones included, as JSON.
|
||||
include: only items whose branch or worktree path matches one of these.
|
||||
exclude: drop items whose branch or worktree path matches one of these.
|
||||
merged_into: integration lines (refs) that also count as merged.
|
||||
sizes: measure each worktree's disk usage and order by it.
|
||||
|
||||
Returns:
|
||||
0 on success, 2 when refused.
|
||||
"""
|
||||
options = {
|
||||
"merged_into": merged_into,
|
||||
"sizes": sizes,
|
||||
"min_age": None,
|
||||
"scope": "all",
|
||||
"activity": True,
|
||||
"hooks": {},
|
||||
}
|
||||
prepared = prepare(repo_roots, include, exclude, options)
|
||||
if prepared is None:
|
||||
return 2
|
||||
repos, host_ns, chosen = prepared
|
||||
now = time.time()
|
||||
limit = stale_days * DAY
|
||||
|
||||
report = []
|
||||
for repo in repos:
|
||||
rows = []
|
||||
for item in repo.items:
|
||||
if not chosen(item):
|
||||
continue
|
||||
cls = classify(item, now, limit)
|
||||
rows.append({**asdict(item), "class": cls, "proposal": proposal(item, cls)})
|
||||
rows.sort(
|
||||
key=lambda r: (
|
||||
ORDER.index(r["class"]),
|
||||
-(r["size_bytes"] or 0) if sizes else 0,
|
||||
r["last_activity"] or 0,
|
||||
)
|
||||
)
|
||||
report.append({"repo": repo.repo, "warnings": repo.warnings, "rows": rows})
|
||||
|
||||
if as_json:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"host_pid_namespace": host_ns,
|
||||
"stale_days": stale_days,
|
||||
"repos": report,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
for entry in report:
|
||||
print(f"== {entry['repo']} (stale after {stale_days} days)")
|
||||
for warning in entry["warnings"]:
|
||||
print(f"WARN {warning}")
|
||||
for cls in ORDER:
|
||||
count = sum(r["class"] == cls for r in entry["rows"])
|
||||
print(f"- {cls}: {count} ({PROPOSALS[cls]})")
|
||||
shown = [
|
||||
row
|
||||
for cls in ORDER[:-1]
|
||||
for row in [r for r in entry["rows"] if r["class"] == cls][:top]
|
||||
]
|
||||
if shown:
|
||||
print()
|
||||
print("\n".join(_table(shown, now, sizes)))
|
||||
if not host_ns:
|
||||
print(
|
||||
"WARN busy and lock checks unavailable: not in the host PID namespace"
|
||||
)
|
||||
return 0
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
SLOT_FILE = "pkgmgr-slot"
|
||||
|
||||
|
||||
def used_slots(common_dir: str) -> set[int]:
|
||||
base = os.path.join(common_dir, "worktrees")
|
||||
slots: set[int] = set()
|
||||
for entry in os.listdir(base) if os.path.isdir(base) else []:
|
||||
try:
|
||||
with open(os.path.join(base, entry, SLOT_FILE), encoding="utf-8") as fh:
|
||||
slots.add(int(fh.read().strip()))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return slots
|
||||
|
||||
|
||||
def next_slot(used: set[int]) -> int:
|
||||
slot = 1
|
||||
while slot in used:
|
||||
slot += 1
|
||||
return slot
|
||||
|
||||
|
||||
def write_slot(path: str, slot: int) -> None:
|
||||
"""
|
||||
Record a slot in the metadata of a worktree this process just added.
|
||||
|
||||
Args:
|
||||
path: the new worktree, whose `.git` file names its metadata.
|
||||
slot: the slot to record.
|
||||
"""
|
||||
with open(os.path.join(path, ".git"), encoding="utf-8") as fh:
|
||||
meta = os.path.normpath(
|
||||
os.path.join(path, fh.read().strip().removeprefix("gitdir: "))
|
||||
)
|
||||
with open(os.path.join(meta, SLOT_FILE), "w", encoding="utf-8") as fh:
|
||||
fh.write(f"{slot}\n")
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
|
||||
ROOT_UID = 0
|
||||
UID_MAP = "/proc/self/uid_map"
|
||||
INITIAL_MAP = [[0, 0, 4294967295]]
|
||||
OVERFLOW_UID = "/proc/sys/kernel/overflowuid"
|
||||
SHARED_WRITE = stat.S_IWGRP | stat.S_IWOTH
|
||||
|
||||
|
||||
def root_uids() -> frozenset[int]:
|
||||
"""
|
||||
Returns:
|
||||
The owner uids that stand for root. In the initial user namespace,
|
||||
whose uid_map is the identity over every uid, that is 0. In any other
|
||||
namespace a mapped uid 0 may be any user, so only the overflow uid
|
||||
counts: the real root's files show up under it there, as do those of
|
||||
every other unmapped owner. Empty when a file it needs is unreadable
|
||||
or this process's own uid is unmapped, since its own files then show
|
||||
up as the overflow uid too.
|
||||
"""
|
||||
try:
|
||||
with open(UID_MAP, encoding="ascii") as fh:
|
||||
ranges = [[int(n) for n in line.split()] for line in fh if line.strip()]
|
||||
if ranges == INITIAL_MAP:
|
||||
return frozenset({ROOT_UID})
|
||||
with open(OVERFLOW_UID, encoding="ascii") as fh:
|
||||
overflow = int(fh.read())
|
||||
except (OSError, ValueError):
|
||||
return frozenset()
|
||||
if os.geteuid() == overflow:
|
||||
return frozenset()
|
||||
return frozenset({overflow})
|
||||
|
||||
|
||||
def root_resolved(path: str) -> str | None:
|
||||
"""
|
||||
Args:
|
||||
path: an absolute file or directory.
|
||||
|
||||
Returns:
|
||||
The path, resolved once, when it and every directory above it are
|
||||
owned by root and writable by neither group nor others, so no other
|
||||
user can replace what it names; None otherwise. Callers keep and use
|
||||
the returned path: resolving path again can follow a link swapped in
|
||||
after this check. In the initial user namespace a component on a
|
||||
nosuid mount also yields None: a desktop user can mount an image of
|
||||
root-owned files through udisks2 or FUSE, always nosuid, while the
|
||||
system's own directories never are; every bubblewrap mount is nosuid,
|
||||
so the rule cannot apply inside one.
|
||||
"""
|
||||
owners = root_uids()
|
||||
initial = owners == {ROOT_UID}
|
||||
resolved = os.path.realpath(path)
|
||||
current = resolved
|
||||
while True:
|
||||
try:
|
||||
info = os.stat(current)
|
||||
nosuid = initial and os.statvfs(current).f_flag & os.ST_NOSUID
|
||||
except OSError:
|
||||
return None
|
||||
if info.st_uid not in owners or info.st_mode & SHARED_WRITE or nosuid:
|
||||
return None
|
||||
parent = os.path.dirname(current)
|
||||
if parent == current:
|
||||
return resolved
|
||||
current = parent
|
||||
|
||||
|
||||
def trusted_path() -> str:
|
||||
"""
|
||||
Returns:
|
||||
The PATH entries that resolve to root-controlled directories, else
|
||||
those of os.defpath, each in its resolved form and joined by
|
||||
os.pathsep, so a symbolic link another user can retarget never stays
|
||||
on it.
|
||||
|
||||
Raises:
|
||||
RuntimeError: no entry of either qualifies; an empty PATH would make
|
||||
the hook search its working directory, the worktree.
|
||||
"""
|
||||
for source in (os.get_exec_path(), os.defpath.split(os.pathsep)):
|
||||
resolved = (root_resolved(entry) for entry in source if os.path.isabs(entry))
|
||||
entries = dict.fromkeys(
|
||||
entry for entry in resolved if entry and os.path.isdir(entry)
|
||||
)
|
||||
if entries:
|
||||
return os.pathsep.join(entries)
|
||||
raise RuntimeError(
|
||||
"no PATH entry is a root-owned directory closed to other writers"
|
||||
)
|
||||
@@ -0,0 +1,226 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from pkgmgr.core.git.errors import GitRunError
|
||||
from pkgmgr.core.git.queries import FILTER_CONFIG, local_config
|
||||
from pkgmgr.core.git.run import END_OF_OPTIONS, run
|
||||
|
||||
from .hooks import INIT_HOOK, defined_in, hook_command, run_hook
|
||||
from .slots import next_slot, used_slots, write_slot
|
||||
|
||||
PORT_PREFIX = re.compile(r"^\d+/")
|
||||
HOST_AND_PATH = re.compile(r"([^:/]+)[:/](.*)")
|
||||
NETWORK_CONFIG = (
|
||||
r"^(core\.(sshcommand|gitproxy)|remote\..*\.(uploadpack|receivepack)"
|
||||
r"|credential\..*helper|url\..*\.insteadof|protocol\..*allow)$"
|
||||
)
|
||||
|
||||
|
||||
def worktree_location(url: str) -> str:
|
||||
"""
|
||||
Map a remote URL to `<host>/<owner>/<repo>`.
|
||||
|
||||
Args:
|
||||
url: remote URL in https, ssh or scp form.
|
||||
|
||||
Returns:
|
||||
The host followed by the repository path, without `.git` or a port;
|
||||
a local path loses its leading slash, so it always stays relative.
|
||||
"""
|
||||
rest = url.removesuffix(".git").split("://", 1)[-1].split("@", 1)[-1]
|
||||
match = HOST_AND_PATH.match(rest)
|
||||
if not match:
|
||||
return rest.strip("/")
|
||||
host, path = match.groups()
|
||||
return f"{host}/{PORT_PREFIX.sub('', path).strip('/')}"
|
||||
|
||||
|
||||
def _common_dir(cwd: str) -> str:
|
||||
return run(
|
||||
["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd=cwd, scan=True
|
||||
)
|
||||
|
||||
|
||||
def _inside(path: str, root: str) -> bool:
|
||||
real, base = os.path.realpath(path), os.path.realpath(root)
|
||||
return os.path.commonpath([real, base]) == base
|
||||
|
||||
|
||||
def _ref_exists(repo: str, ref: str) -> bool:
|
||||
try:
|
||||
run(
|
||||
["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
|
||||
cwd=repo,
|
||||
scan=True,
|
||||
)
|
||||
except GitRunError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _commit_of(repo: str, ref: str) -> str:
|
||||
# worktree add reads a start point that begins with "-" as HEAD even after
|
||||
# "--" or --end-of-options, so it only ever gets the resolved commit id
|
||||
return run(
|
||||
["rev-parse", "--verify", "--quiet", END_OF_OPTIONS, f"{ref}^{{commit}}"],
|
||||
cwd=repo,
|
||||
scan=True,
|
||||
)
|
||||
|
||||
|
||||
def _default_base(repo: str) -> str:
|
||||
try:
|
||||
return run(
|
||||
["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], cwd=repo, scan=True
|
||||
)
|
||||
except GitRunError:
|
||||
return "origin/main" if _ref_exists(repo, "origin/main") else "origin/master"
|
||||
|
||||
|
||||
def worktree_up(
|
||||
repo_dir: str,
|
||||
branch: str,
|
||||
*,
|
||||
base: str | None,
|
||||
worktrees_dir: str,
|
||||
path: str | None,
|
||||
hooks: dict[str, dict[str, list[str]]],
|
||||
as_json: bool,
|
||||
) -> int:
|
||||
"""
|
||||
Check a branch out into its own worktree under the standard path.
|
||||
|
||||
Args:
|
||||
repo_dir: any directory inside the repository.
|
||||
branch: branch to check out; created from base when it exists nowhere.
|
||||
base: start point of a new branch; None takes origin's default branch.
|
||||
worktrees_dir: root of the standard path
|
||||
`<worktrees_dir>/<host>/<owner>/<repo>/<branch>`.
|
||||
path: exact checkout path instead of the standard one, e.g. a
|
||||
directory an agent sandbox releases; None keeps the standard path.
|
||||
hooks: pinned hooks by managed repository root realpath, from the
|
||||
CLI; the worktree-init hook runs only when the repository root is
|
||||
pinned there, never from the Makefile or by origin's URL.
|
||||
as_json: print the result as JSON instead of text.
|
||||
|
||||
Returns:
|
||||
0 when the worktree is ready, 1 when the worktree-init hook failed
|
||||
(the worktree stays), 2 when refused.
|
||||
"""
|
||||
try:
|
||||
common = _common_dir(repo_dir)
|
||||
root = os.path.dirname(common)
|
||||
url = run(["remote", "get-url", "--", "origin"], cwd=root, scan=True)
|
||||
run(["check-ref-format", "--branch", branch], cwd=root, scan=True)
|
||||
filters = local_config(root, FILTER_CONFIG)
|
||||
except GitRunError as exc:
|
||||
print(f"Refusing: {str(exc).splitlines()[0]}", file=sys.stderr)
|
||||
return 2
|
||||
if filters:
|
||||
print(
|
||||
f"Refusing: {', '.join(filters)} in the repository config would run "
|
||||
"on the checkout",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
location = worktree_location(url)
|
||||
if path:
|
||||
path = os.path.abspath(os.path.expanduser(path))
|
||||
else:
|
||||
standard = os.path.expanduser(worktrees_dir)
|
||||
path = os.path.join(standard, location, branch)
|
||||
if not _inside(path, standard):
|
||||
print(f"Refusing: {path} lies outside {standard}", file=sys.stderr)
|
||||
return 2
|
||||
if os.path.lexists(path):
|
||||
print(f"Refusing: {path} already exists", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
slot = next_slot(used_slots(common))
|
||||
pinned = hooks.get(os.path.realpath(root), {}).get(INIT_HOOK)
|
||||
try:
|
||||
command = hook_command(INIT_HOOK, pinned, path, slot) if pinned else None
|
||||
except RuntimeError as exc:
|
||||
print(f"Refusing: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
warnings: list[str] = []
|
||||
network = local_config(root, NETWORK_CONFIG)
|
||||
if network:
|
||||
warnings.append(f"fetch skipped: {', '.join(network)} in the repository config")
|
||||
else:
|
||||
try:
|
||||
run(
|
||||
["fetch", "--quiet", "--no-auto-maintenance", "origin"],
|
||||
cwd=root,
|
||||
scan=True,
|
||||
)
|
||||
except GitRunError as exc:
|
||||
warnings.append(f"fetch failed: {str(exc).splitlines()[0]}")
|
||||
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
start: str | None = None
|
||||
if _ref_exists(root, f"refs/heads/{branch}"):
|
||||
origin, add = "existing", ["worktree", "add", "--", path, branch]
|
||||
elif _ref_exists(root, f"refs/remotes/origin/{branch}"):
|
||||
origin = f"tracking origin/{branch}"
|
||||
add = [
|
||||
"worktree",
|
||||
"add",
|
||||
"--track",
|
||||
"-b",
|
||||
branch,
|
||||
"--",
|
||||
path,
|
||||
f"origin/{branch}",
|
||||
]
|
||||
else:
|
||||
start = base or _default_base(root)
|
||||
origin = f"new from {start}"
|
||||
add = ["worktree", "add", "--no-track", "-b", branch, "--", path]
|
||||
try:
|
||||
if start is not None:
|
||||
add.append(_commit_of(root, start))
|
||||
run(add, cwd=root, scan=True)
|
||||
except GitRunError as exc:
|
||||
print(f"Refusing: {str(exc).splitlines()[0]}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
write_slot(path, slot)
|
||||
|
||||
hook = "none"
|
||||
code = 0
|
||||
if command:
|
||||
try:
|
||||
ran = run_hook(INIT_HOOK, command, path, check=False, stdout=sys.stderr)
|
||||
failure = None if ran.returncode == 0 else str(ran.returncode)
|
||||
except (RuntimeError, OSError) as exc:
|
||||
failure = str(exc)
|
||||
hook = INIT_HOOK if failure is None else f"{INIT_HOOK} failed ({failure})"
|
||||
code = 0 if failure is None else 1
|
||||
else:
|
||||
makefile = defined_in(path, INIT_HOOK)
|
||||
if makefile:
|
||||
hook = f"not pinned: {INIT_HOOK} in {makefile}"
|
||||
|
||||
result = {
|
||||
"path": path,
|
||||
"branch": branch,
|
||||
"origin": origin,
|
||||
"slot": slot,
|
||||
"hook": hook,
|
||||
"warnings": warnings,
|
||||
}
|
||||
if as_json:
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
for warning in warnings:
|
||||
print(f"WARN {warning}")
|
||||
for key in ("path", "branch", "origin", "slot", "hook"):
|
||||
print(f"{key:<8} {result[key]}")
|
||||
return code
|
||||
@@ -11,6 +11,7 @@ from .repos import handle_repos_command
|
||||
from .review import handle_review
|
||||
from .tools import handle_tools_command
|
||||
from .version import handle_version
|
||||
from .worktree import handle_gc, handle_worktree
|
||||
|
||||
__all__ = [
|
||||
"handle_archive",
|
||||
@@ -18,6 +19,7 @@ __all__ = [
|
||||
"handle_changelog",
|
||||
"handle_code_scanning",
|
||||
"handle_config",
|
||||
"handle_gc",
|
||||
"handle_make",
|
||||
"handle_mirror_command",
|
||||
"handle_publish",
|
||||
@@ -26,4 +28,5 @@ __all__ = [
|
||||
"handle_review",
|
||||
"handle_tools_command",
|
||||
"handle_version",
|
||||
"handle_worktree",
|
||||
]
|
||||
@@ -4,8 +4,8 @@ import os
|
||||
import sys
|
||||
|
||||
from pkgmgr.actions.branch import (
|
||||
branch_gc,
|
||||
branch_purge,
|
||||
cleanup,
|
||||
close_branch,
|
||||
drop_branch,
|
||||
open_branch,
|
||||
@@ -17,22 +17,42 @@ from pkgmgr.core.git.errors import GitBaseError
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
|
||||
|
||||
def _repo_dir(ctx: CLIContext, repo: dict) -> str:
|
||||
return (
|
||||
os.path.expanduser(str(repo["directory"]))
|
||||
if repo.get("directory")
|
||||
else get_repo_dir(ctx.repositories_base_dir, repo)
|
||||
)
|
||||
|
||||
|
||||
def _known_dirs(ctx: CLIContext) -> list[str]:
|
||||
return [
|
||||
os.path.expanduser(str(r["directory"]))
|
||||
if r.get("directory")
|
||||
else get_repo_dir(ctx.repositories_base_dir, r)
|
||||
for r in ctx.all_repositories
|
||||
]
|
||||
return [_repo_dir(ctx, r) for r in ctx.all_repositories]
|
||||
|
||||
|
||||
def _gc_repo_roots(args, ctx: CLIContext) -> list[str]:
|
||||
if not args.all:
|
||||
return [main_repo_root(".")]
|
||||
return [d for d in _known_dirs(ctx) if os.path.exists(os.path.join(d, ".git"))]
|
||||
def pinned_hooks(ctx: CLIContext) -> dict[str, dict[str, list[str]]]:
|
||||
"""
|
||||
Bind the `worktree_hooks` config to managed repositories, never to a
|
||||
remote URL, which the repository's own config sets.
|
||||
|
||||
Args:
|
||||
ctx: CLI context; each `worktree_hooks` key names the
|
||||
`<provider>/<account>/<repository>` of a `repositories` entry.
|
||||
|
||||
Returns:
|
||||
Realpath of each pinned managed repository's directory -> hook name
|
||||
-> argv; keys that match no managed repository are dropped.
|
||||
"""
|
||||
pins = ctx.config_merged["worktree_hooks"]
|
||||
return {
|
||||
os.path.realpath(_repo_dir(ctx, repo)): pins[key]
|
||||
for repo in ctx.config_merged["repositories"]
|
||||
if all(repo.get(part) for part in ("provider", "account", "repository"))
|
||||
and (key := f"{repo['provider']}/{repo['account']}/{repo['repository']}")
|
||||
in pins
|
||||
}
|
||||
|
||||
|
||||
def _refresh(roots: list[str]) -> None:
|
||||
def refresh_tree(roots: list[str]) -> None:
|
||||
for root in roots:
|
||||
try:
|
||||
tree.refresh_tree_entry(root)
|
||||
@@ -40,19 +60,58 @@ def _refresh(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]:
|
||||
"""
|
||||
Resolve the repositories a scan covers: the current one, or every
|
||||
pkgmgr-managed one with --all. Exits with 2 outside a repository.
|
||||
"""
|
||||
if not args.all:
|
||||
try:
|
||||
return [main_repo_root(".")]
|
||||
except GitBaseError as exc:
|
||||
print(f"Not inside a git repository: {exc}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
return [d for d in _known_dirs(ctx) if os.path.exists(os.path.join(d, ".git"))]
|
||||
|
||||
|
||||
def scan_options(args) -> dict:
|
||||
return {
|
||||
"include": args.branch,
|
||||
"exclude": args.exclude,
|
||||
"merged_into": args.merged_into,
|
||||
"sizes": args.sizes,
|
||||
}
|
||||
|
||||
|
||||
def run_cleanup(args, ctx: CLIContext, scope: str) -> None:
|
||||
roots = scan_repo_roots(args, ctx)
|
||||
code = cleanup(
|
||||
roots,
|
||||
scope=scope,
|
||||
apply=args.apply,
|
||||
as_json=args.json,
|
||||
min_age=args.min_age,
|
||||
hooks=pinned_hooks(ctx),
|
||||
**scan_options(args),
|
||||
)
|
||||
if args.apply:
|
||||
refresh_tree(roots)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def handle_branch(args, ctx: CLIContext) -> None:
|
||||
"""
|
||||
Handle `pkgmgr branch` subcommands.
|
||||
|
||||
Currently supported:
|
||||
- pkgmgr branch open [<name>] [--base <branch>]
|
||||
- pkgmgr branch close [<name>] [--base <branch>] [--force|-f]
|
||||
- pkgmgr branch drop [<name>] [--base <branch>] [--force|-f]
|
||||
- pkgmgr branch gc [--apply] [--json] [--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]
|
||||
- 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 purge [--all] [--branch N]... [--exclude N]...
|
||||
[--merged-into REF]... [--sizes] [--min-age HOURS]
|
||||
- pkgmgr branch tree [--format json|yaml] [--output FILE]
|
||||
"""
|
||||
if args.subcommand == "tree":
|
||||
sys.exit(
|
||||
@@ -64,25 +123,18 @@ def handle_branch(args, ctx: CLIContext) -> None:
|
||||
)
|
||||
)
|
||||
|
||||
if args.subcommand in ("gc", "purge"):
|
||||
try:
|
||||
roots = _gc_repo_roots(args, ctx)
|
||||
except GitBaseError as exc:
|
||||
print(f"Not inside a git repository: {exc}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
options = {
|
||||
"include": args.branch,
|
||||
"exclude": args.exclude,
|
||||
"merged_into": args.merged_into,
|
||||
"sizes": args.sizes,
|
||||
"min_age": args.min_age,
|
||||
}
|
||||
if args.subcommand == "gc":
|
||||
code = branch_gc(roots, apply=args.apply, as_json=args.json, **options)
|
||||
else:
|
||||
code = branch_purge(roots, **options)
|
||||
if args.subcommand == "purge" or args.apply:
|
||||
_refresh(roots)
|
||||
if args.subcommand == "cleanup":
|
||||
run_cleanup(args, ctx, "branch")
|
||||
|
||||
if args.subcommand == "purge":
|
||||
roots = scan_repo_roots(args, ctx)
|
||||
code = branch_purge(
|
||||
roots,
|
||||
min_age=args.min_age,
|
||||
hooks=pinned_hooks(ctx),
|
||||
**scan_options(args),
|
||||
)
|
||||
refresh_tree(roots)
|
||||
sys.exit(code)
|
||||
|
||||
if args.subcommand == "open":
|
||||
@@ -91,7 +143,7 @@ def handle_branch(args, ctx: CLIContext) -> None:
|
||||
base_branch=getattr(args, "base", "main"),
|
||||
cwd=".",
|
||||
)
|
||||
_refresh([main_repo_root(".")])
|
||||
refresh_tree([main_repo_root(".")])
|
||||
return
|
||||
|
||||
if args.subcommand == "close":
|
||||
@@ -101,7 +153,7 @@ def handle_branch(args, ctx: CLIContext) -> None:
|
||||
cwd=".",
|
||||
force=getattr(args, "force", False),
|
||||
)
|
||||
_refresh([main_repo_root(".")])
|
||||
refresh_tree([main_repo_root(".")])
|
||||
return
|
||||
|
||||
if args.subcommand == "drop":
|
||||
@@ -111,7 +163,7 @@ def handle_branch(args, ctx: CLIContext) -> None:
|
||||
cwd=".",
|
||||
force=getattr(args, "force", False),
|
||||
)
|
||||
_refresh([main_repo_root(".")])
|
||||
refresh_tree([main_repo_root(".")])
|
||||
return
|
||||
|
||||
print(f"Unknown branch subcommand: {args.subcommand}")
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from pkgmgr.actions.branch.gc import main_repo_root
|
||||
from pkgmgr.actions.worktree.orphans import worktree_orphans
|
||||
from pkgmgr.actions.worktree.up import worktree_up
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
|
||||
from .branch import (
|
||||
pinned_hooks,
|
||||
refresh_tree,
|
||||
run_cleanup,
|
||||
scan_options,
|
||||
scan_repo_roots,
|
||||
)
|
||||
|
||||
|
||||
def handle_worktree(args, ctx: CLIContext) -> None:
|
||||
"""
|
||||
Handle `pkgmgr worktree` subcommands.
|
||||
|
||||
- pkgmgr worktree up <branch> [--base REF] [--path DIR] [--json]
|
||||
- pkgmgr worktree cleanup [--apply] [--json] [scan options] [--min-age HOURS]
|
||||
- pkgmgr worktree orphans --stale-days N --top N [--json] [scan options]
|
||||
"""
|
||||
if args.subcommand == "up":
|
||||
code = worktree_up(
|
||||
".",
|
||||
args.name,
|
||||
base=args.base,
|
||||
worktrees_dir=ctx.config_merged["directories"]["worktrees"],
|
||||
path=args.path,
|
||||
hooks=pinned_hooks(ctx),
|
||||
as_json=args.json,
|
||||
)
|
||||
refresh_tree([main_repo_root(".")])
|
||||
sys.exit(code)
|
||||
|
||||
if args.subcommand == "cleanup":
|
||||
run_cleanup(args, ctx, "worktree")
|
||||
|
||||
if args.subcommand == "orphans":
|
||||
sys.exit(
|
||||
worktree_orphans(
|
||||
scan_repo_roots(args, ctx),
|
||||
stale_days=args.stale_days,
|
||||
top=args.top,
|
||||
as_json=args.json,
|
||||
**scan_options(args),
|
||||
)
|
||||
)
|
||||
|
||||
print(f"Unknown worktree subcommand: {args.subcommand}")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def handle_gc(args, ctx: CLIContext) -> None:
|
||||
run_cleanup(args, ctx, "all")
|
||||
@@ -10,6 +10,7 @@ from pkgmgr.cli.commands import (
|
||||
handle_changelog,
|
||||
handle_code_scanning,
|
||||
handle_config,
|
||||
handle_gc,
|
||||
handle_make,
|
||||
handle_mirror_command,
|
||||
handle_publish,
|
||||
@@ -18,6 +19,7 @@ from pkgmgr.cli.commands import (
|
||||
handle_review,
|
||||
handle_tools_command,
|
||||
handle_version,
|
||||
handle_worktree,
|
||||
)
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.cli.proxy import maybe_handle_proxy
|
||||
@@ -168,6 +170,14 @@ def dispatch_command(args, ctx: CLIContext) -> None:
|
||||
handle_branch(args, ctx)
|
||||
return
|
||||
|
||||
if args.command == "worktree":
|
||||
handle_worktree(args, ctx)
|
||||
return
|
||||
|
||||
if args.command == "gc":
|
||||
handle_gc(args, ctx)
|
||||
return
|
||||
|
||||
if args.command == "mirror":
|
||||
handle_mirror_command(ctx, args, selected)
|
||||
return
|
||||
|
||||
@@ -10,6 +10,7 @@ from .changelog_cmd import add_changelog_subparser
|
||||
from .code_scanning_cmd import add_code_scanning_subparser
|
||||
from .common import SortedSubParsersAction
|
||||
from .config_cmd import add_config_subparsers
|
||||
from .gc_cmd import add_gc_subparser
|
||||
from .install_update import add_install_update_subparsers
|
||||
from .list_cmd import add_list_subparser
|
||||
from .make_cmd import add_make_subparsers
|
||||
@@ -19,6 +20,7 @@ from .publish_cmd import add_publish_subparser
|
||||
from .release_cmd import add_release_subparser
|
||||
from .review_cmd import add_review_subparser
|
||||
from .version_cmd import add_version_subparser
|
||||
from .worktree_cmd import add_worktree_subparsers
|
||||
|
||||
|
||||
def create_parser(description_text: str) -> argparse.ArgumentParser:
|
||||
@@ -60,6 +62,8 @@ def create_parser(description_text: str) -> argparse.ArgumentParser:
|
||||
|
||||
add_branch_subparsers(subparsers)
|
||||
add_review_subparser(subparsers)
|
||||
add_worktree_subparsers(subparsers)
|
||||
add_gc_subparser(subparsers)
|
||||
add_release_subparser(subparsers)
|
||||
add_publish_subparser(subparsers)
|
||||
|
||||
|
||||
@@ -97,29 +97,29 @@ def add_branch_subparsers(
|
||||
help="Skip confirmation prompt and drop the branch directly",
|
||||
)
|
||||
|
||||
branch_gc = branch_subparsers.add_parser(
|
||||
"gc",
|
||||
branch_cleanup = branch_subparsers.add_parser(
|
||||
"cleanup",
|
||||
help=(
|
||||
"Remove merged and clean branches with their worktrees and compose "
|
||||
"stacks (dry-run unless --apply)"
|
||||
"Remove merged branches that no worktree holds, locally and on the "
|
||||
"remotes (dry-run unless --apply)"
|
||||
),
|
||||
)
|
||||
branch_gc.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Execute the plan instead of only printing it",
|
||||
)
|
||||
branch_gc.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print the plan as JSON",
|
||||
add_apply_options(branch_cleanup)
|
||||
|
||||
branch_purge = branch_subparsers.add_parser(
|
||||
"purge",
|
||||
help="Delete unmerged or dirty branches after a confirmation per item",
|
||||
)
|
||||
|
||||
for sub in (branch_cleanup, branch_purge):
|
||||
add_scan_options(sub)
|
||||
add_min_age_option(sub)
|
||||
|
||||
branch_tree = branch_subparsers.add_parser(
|
||||
"tree",
|
||||
help=(
|
||||
"Print the branches and worktrees of every pkgmgr-managed repository "
|
||||
"with their gc state, without fetching"
|
||||
"with their cleanup state, without fetching"
|
||||
),
|
||||
)
|
||||
branch_tree.add_argument(
|
||||
@@ -134,50 +134,62 @@ def add_branch_subparsers(
|
||||
help="Write atomically to FILE instead of stdout",
|
||||
)
|
||||
|
||||
branch_purge = branch_subparsers.add_parser(
|
||||
"purge",
|
||||
help="Delete unmerged or dirty branches after a confirmation per item",
|
||||
|
||||
def add_apply_options(sub: argparse.ArgumentParser) -> None:
|
||||
sub.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Execute the plan instead of only printing it",
|
||||
)
|
||||
sub.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print the plan as JSON",
|
||||
)
|
||||
|
||||
for sub in (branch_gc, branch_purge):
|
||||
sub.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Scan every pkgmgr-managed repository instead of the current one",
|
||||
)
|
||||
sub.add_argument(
|
||||
"--branch",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="NAME",
|
||||
help="Only this branch (or worktree path); repeatable",
|
||||
)
|
||||
sub.add_argument(
|
||||
"--exclude",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="NAME",
|
||||
help="Skip this branch (or worktree path); repeatable",
|
||||
)
|
||||
sub.add_argument(
|
||||
"--merged-into",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="REF",
|
||||
help=(
|
||||
"Also count an item as merged when its commits are in REF (an "
|
||||
"integration line); REF is protected and a checkout at its tip "
|
||||
"counts as fresh; repeatable"
|
||||
),
|
||||
)
|
||||
sub.add_argument(
|
||||
"--sizes",
|
||||
action="store_true",
|
||||
help="Measure each worktree's disk usage (du) into the plan",
|
||||
)
|
||||
sub.add_argument(
|
||||
"--min-age",
|
||||
type=float,
|
||||
metavar="HOURS",
|
||||
help="Skip items whose newest commit or worktree index is younger",
|
||||
)
|
||||
|
||||
def add_scan_options(sub: argparse.ArgumentParser) -> None:
|
||||
sub.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Scan every pkgmgr-managed repository instead of the current one",
|
||||
)
|
||||
sub.add_argument(
|
||||
"--branch",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="NAME",
|
||||
help="Only this branch (or worktree path); repeatable",
|
||||
)
|
||||
sub.add_argument(
|
||||
"--exclude",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="NAME",
|
||||
help="Skip this branch (or worktree path); repeatable",
|
||||
)
|
||||
sub.add_argument(
|
||||
"--merged-into",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="REF",
|
||||
help=(
|
||||
"Also count an item as merged when its commits are in REF (an "
|
||||
"integration line); REF is protected and a checkout at its tip "
|
||||
"counts as fresh; repeatable"
|
||||
),
|
||||
)
|
||||
sub.add_argument(
|
||||
"--sizes",
|
||||
action="store_true",
|
||||
help="Measure each worktree's disk usage (du) into the plan",
|
||||
)
|
||||
|
||||
|
||||
def add_min_age_option(sub: argparse.ArgumentParser) -> None:
|
||||
sub.add_argument(
|
||||
"--min-age",
|
||||
type=float,
|
||||
metavar="HOURS",
|
||||
help="Skip items whose newest commit, worktree index or changed file is younger",
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .branch_cmd import add_apply_options, add_min_age_option, add_scan_options
|
||||
|
||||
|
||||
def add_gc_subparser(subparsers: argparse._SubParsersAction) -> None:
|
||||
gc = subparsers.add_parser(
|
||||
"gc",
|
||||
help=(
|
||||
"Garbage-collect the current repository, or every one with --all: "
|
||||
"worktree cleanup and branch cleanup in one pass (dry-run unless --apply)"
|
||||
),
|
||||
)
|
||||
add_apply_options(gc)
|
||||
add_scan_options(gc)
|
||||
add_min_age_option(gc)
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .branch_cmd import add_apply_options, add_min_age_option, add_scan_options
|
||||
|
||||
|
||||
def add_worktree_subparsers(subparsers: argparse._SubParsersAction) -> None:
|
||||
worktree_parser = subparsers.add_parser(
|
||||
"worktree",
|
||||
help="Worktree utilities: create, clean up and find abandoned worktrees",
|
||||
)
|
||||
worktree_subparsers = worktree_parser.add_subparsers(
|
||||
dest="subcommand",
|
||||
help="Worktree subcommands",
|
||||
required=True,
|
||||
)
|
||||
|
||||
up = worktree_subparsers.add_parser(
|
||||
"up",
|
||||
help=(
|
||||
"Check a branch out under <directories.worktrees>/<host>/<owner>/"
|
||||
"<repo>/<branch>, assign a slot and run `make worktree-init SLOT=<n>` "
|
||||
"when the project defines it"
|
||||
),
|
||||
)
|
||||
up.add_argument("name", help="Branch to check out; created when it exists nowhere")
|
||||
up.add_argument(
|
||||
"--base",
|
||||
metavar="REF",
|
||||
help="Start point of a new branch (origin's default branch when omitted)",
|
||||
)
|
||||
up.add_argument(
|
||||
"--path",
|
||||
metavar="DIR",
|
||||
help=(
|
||||
"Exact checkout path instead of the standard one, e.g. a directory "
|
||||
"an agent sandbox releases"
|
||||
),
|
||||
)
|
||||
up.add_argument("--json", action="store_true", help="Print the result as JSON")
|
||||
|
||||
cleanup = worktree_subparsers.add_parser(
|
||||
"cleanup",
|
||||
help=(
|
||||
"Remove clean worktrees whose commits are merged or pushed: "
|
||||
"`make worktree-fini`, compose stack, checkout; the branch stays "
|
||||
"(dry-run unless --apply)"
|
||||
),
|
||||
)
|
||||
add_apply_options(cleanup)
|
||||
add_scan_options(cleanup)
|
||||
add_min_age_option(cleanup)
|
||||
|
||||
orphans = worktree_subparsers.add_parser(
|
||||
"orphans",
|
||||
help="List merged and abandoned branches and worktrees with a proposal each",
|
||||
)
|
||||
add_scan_options(orphans)
|
||||
orphans.add_argument(
|
||||
"--stale-days",
|
||||
type=int,
|
||||
required=True,
|
||||
metavar="DAYS",
|
||||
help="Unfinished work idle for longer counts as stale-open",
|
||||
)
|
||||
orphans.add_argument(
|
||||
"--top",
|
||||
type=int,
|
||||
required=True,
|
||||
metavar="N",
|
||||
help="Rows per class in the text table; 0 prints the counts only",
|
||||
)
|
||||
orphans.add_argument("--json", action="store_true", help="Print every row as JSON")
|
||||
@@ -4,6 +4,13 @@ directories:
|
||||
repositories: ~/Repositories/
|
||||
workspaces: ~/Workspaces/
|
||||
binaries: ~/.local/bin/
|
||||
worktrees: ~/.local/share/worktrees/
|
||||
# worktree_hooks: <provider>/<account>/<repository> of an entry under
|
||||
# repositories -> worktree-init / worktree-fini -> argv, with {path} and {slot}
|
||||
# replaced; it runs only for that entry's directory, never selected by origin's
|
||||
# URL, and without an entry no hook runs, whatever the repository's Makefile
|
||||
# defines
|
||||
worktree_hooks: {}
|
||||
repositories:
|
||||
- account: kevinveenbirkenbach
|
||||
description: A configurable Python package manager that automates repository tasks—including cloning, installation, updates, and status reporting—based on a YAML configuration file for streamlined software management which gives you access to the Kevin Veen-Birkenbach Code Universe.
|
||||
|
||||
@@ -144,6 +144,7 @@ def _load_layer_dir(
|
||||
{
|
||||
"directories": {...},
|
||||
"repositories": [...],
|
||||
<other key>: dicts deep-merged, other values from the last file,
|
||||
}
|
||||
"""
|
||||
defaults: dict[str, Any] = {"directories": {}, "repositories": []}
|
||||
@@ -165,21 +166,16 @@ def _load_layer_dir(
|
||||
|
||||
for path in yaml_files:
|
||||
data = _load_yaml_file(path)
|
||||
category_name = path.stem
|
||||
|
||||
dirs = data.get("directories")
|
||||
if isinstance(dirs, dict):
|
||||
defaults.setdefault("directories", {})
|
||||
_deep_merge(defaults["directories"], dirs)
|
||||
|
||||
repos = data.get("repositories")
|
||||
repos = data.pop("repositories", None)
|
||||
if isinstance(repos, list):
|
||||
defaults.setdefault("repositories", [])
|
||||
_merge_repo_lists(
|
||||
defaults["repositories"],
|
||||
repos,
|
||||
category_name=category_name,
|
||||
category_name=path.stem,
|
||||
)
|
||||
if not isinstance(data.get("directories", {}), dict):
|
||||
del data["directories"]
|
||||
_deep_merge(defaults, data)
|
||||
|
||||
return defaults
|
||||
|
||||
@@ -229,7 +225,8 @@ def load_config(user_config_path: str) -> dict[str, Any]:
|
||||
Steps:
|
||||
1. Determine ~/.config/pkgmgr/ (or dir of user_config_path).
|
||||
2. Load all *.yml/*.yaml in that dir (except the user config file) as defaults.
|
||||
3. If nothing found, fall back to package defaults.
|
||||
3. If nothing found, fall back to package defaults. Package keys other
|
||||
than directories and repositories always lie under the layers.
|
||||
4. Load the user config file (if present).
|
||||
5. Merge:
|
||||
- directories: deep-merge (defaults <- user)
|
||||
@@ -247,13 +244,14 @@ def load_config(user_config_path: str) -> dict[str, Any]:
|
||||
|
||||
# 1+2) Defaults from user directory
|
||||
defaults = _load_layer_dir(config_dir, skip_filename=user_cfg_name)
|
||||
package = _load_defaults_from_package_or_project()
|
||||
|
||||
# 3) Fallback to package defaults
|
||||
if not defaults["directories"] and not defaults["repositories"]:
|
||||
defaults = _load_defaults_from_package_or_project()
|
||||
|
||||
defaults.setdefault("directories", {})
|
||||
defaults.setdefault("repositories", [])
|
||||
defaults["directories"] = package["directories"]
|
||||
defaults["repositories"] = package["repositories"]
|
||||
del package["directories"], package["repositories"]
|
||||
defaults = _deep_merge(package, defaults)
|
||||
|
||||
# 4) User config
|
||||
user_cfg: dict[str, Any] = {}
|
||||
|
||||
@@ -20,6 +20,7 @@ from .get_tags_at_ref import GitTagsAtRefQueryError, get_tags_at_ref
|
||||
from .get_upstream_ref import get_upstream_ref
|
||||
from .list_remotes import list_remotes
|
||||
from .list_tags import list_tags
|
||||
from .local_config import FILTER_CONFIG, local_config
|
||||
from .probe_remote_reachable import (
|
||||
probe_remote_reachable,
|
||||
probe_remote_reachable_detail,
|
||||
@@ -27,6 +28,7 @@ from .probe_remote_reachable import (
|
||||
from .resolve_base_branch import GitBaseBranchNotFoundError, resolve_base_branch
|
||||
|
||||
__all__ = [
|
||||
"FILTER_CONFIG",
|
||||
"GitBaseBranchNotFoundError",
|
||||
"GitChangelogQueryError",
|
||||
"GitLatestSigningKeyQueryError",
|
||||
@@ -46,6 +48,7 @@ __all__ = [
|
||||
"get_upstream_ref",
|
||||
"list_remotes",
|
||||
"list_tags",
|
||||
"local_config",
|
||||
"probe_remote_reachable",
|
||||
"probe_remote_reachable_detail",
|
||||
"resolve_base_branch",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitRunError
|
||||
from ..run import run
|
||||
|
||||
FILTER_CONFIG = r"^filter\."
|
||||
LOCAL_SCOPES = ("local", "worktree")
|
||||
|
||||
|
||||
def local_config(path: str, pattern: str) -> list[str]:
|
||||
"""
|
||||
List the config keys matching pattern that the repository itself sets.
|
||||
|
||||
Args:
|
||||
path: any directory inside the repository.
|
||||
pattern: `git config --get-regexp` pattern over the canonical key names.
|
||||
|
||||
Returns:
|
||||
Each matching key set in the local or worktree scope, in config order;
|
||||
global values never count, system config is not read.
|
||||
"""
|
||||
try:
|
||||
out = run(
|
||||
["config", "--show-scope", "--get-regexp", pattern], cwd=path, scan=True
|
||||
)
|
||||
except GitRunError as exc:
|
||||
if exc.returncode == 1:
|
||||
return []
|
||||
raise
|
||||
keys: list[str] = []
|
||||
for line in out.splitlines():
|
||||
scope, _, rest = line.partition("\t")
|
||||
if scope in LOCAL_SCOPES:
|
||||
keys.append(rest.split(" ", 1)[0])
|
||||
return keys
|
||||
@@ -37,6 +37,9 @@ SCAN_UNSET = (
|
||||
"PAGER",
|
||||
)
|
||||
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"
|
||||
|
||||
|
||||
def _is_not_repo_error(stderr: str) -> bool:
|
||||
|
||||
Reference in new issue
Block a user