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>
This commit is contained in:
1 parent
0d88c69c51
commit
3936125633
4 files changed
+337
-40
No files matched your search
@@ -80,7 +80,7 @@ versioning features it can drive **end-to-end workflows**:
|
|||||||
| `pkgmgr worktree flow clear [PATH] [--json]` | Removes the merge flow |
|
| `pkgmgr worktree flow clear [PATH] [--json]` | Removes the merge flow |
|
||||||
| `pkgmgr worktree cleanup [--apply]` | Removes clean worktrees whose commits are merged or pushed: pinned `worktree-fini` hook, compose stack, checkout; the branch stays |
|
| `pkgmgr worktree cleanup [--apply]` | Removes clean worktrees whose commits are merged or pushed: pinned `worktree-fini` hook, compose stack, checkout; the branch stays |
|
||||||
| `pkgmgr branch cleanup [--apply]` | Removes merged branches that no worktree holds, locally and on the remotes |
|
| `pkgmgr branch cleanup [--apply]` | Removes merged branches that no worktree holds, locally and on the remotes |
|
||||||
| `pkgmgr branch cleanup --local [--apply] [--json] [--merged-into REF]...` | Deletes only local branches whose tip is an ancestor of the remote-tracking default or of a `--merged-into` REF, each with `git update-ref --no-deref -d` against the planned sha and then its `branch.<name>` section in the repository's own config file (`git config --file <git common dir>/config`; a section it cannot remove is noted on the done item), also when the branch has an upstream or remote copy. Every git call of `--local` runs without any inherited `GIT_*` variable but `GIT_CEILING_DIRECTORIES`, which only narrows repository discovery, so a directory below a ceiling is never resolved to the repository above it; only the config reads of `deleteMerged` and of the upstreams run twice, both with the system config: once with the caller's `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG_PARAMETERS` and `GIT_CONFIG_COUNT` with its `GIT_CONFIG_KEY_<n>` and `GIT_CONFIG_VALUE_<n>`, once without any of them, without `XDG_CONFIG_HOME` and with `HOME` set to the passwd home, and a branch either read keeps stays, so the environment can add keeps but never remove one. A `--merged-into` REF must name exactly one branch or remote-tracking ref, as `git rev-parse --symbolic-full-name` resolves it, in every scanned repository git can read; `X~0`, `X^0`, `X@{0}`, a sha, a range or an unknown ref stops the run. The default comes from `origin`, or from the only remote when there is no `origin`: the ref `refs/remotes/<remote>/HEAD`, else `main`, else `master` resolves to, every symref step inside `refs/remotes/<remote>/`, so never a local branch or `HEAD`; without one, for example with several remotes and no `origin`, the repository is refused, also under `--merged-into`; so is one whose `refs`, `packed-refs`, `reftable`, `logs` or `logs/refs` is a symlink (git-new-workdir), one with a symlink anywhere below `refs/`, `reftable/` or `logs/refs/` (for example `refs/heads/team` linked to another clone's heads), one whose git dir is neither its git common dir nor directly under `<common>/worktrees/` (a registration moved elsewhere), or one whose git output is not UTF-8. The repository is the one the current directory belongs to, also in a submodule or with a separate git dir; one whose main worktree belongs to another repository (`core.worktree`), whose git dir is neither its git common dir nor directly under `<common>/worktrees/`, or whose path is not UTF-8 is refused with exit 2. Skips a symbolic branch, every branch a symbolic ref anywhere under `refs/` (also a tag or remote-tracking one) or under the main worktree's or any registration's `refs/worktree/` points to, a branch that is itself a `--merged-into` target, a branch a worktree holds or that the HEAD of the main repository or of any `worktrees/<id>` registration names (also one `git worktree list` omits or whose id is no valid ref name, read through `git --git-dir=<dir> symbolic-ref HEAD`, so also under reftable), one a rebase or bisect in any worktree works on, one merged only by patch or PR, and, as `git branch --delete-merged` keeps them, one whose `branch.<name>.deleteMerged` is false in the config of any worktree (also its `config.worktree` or an `includeIf "onbranch:..."` file) or in the system config file, and the local upstream, as git's `%(upstream)` resolves it in any worktree, of any branch that stays, also of one that `--branch` or `--exclude` leaves out, each shown as kept with its reason; these holds are read through the git common dir, whichever worktree is scanned, and a registration, symbolic ref, HEAD or state file they cannot read refuses the repository; when a repository vanishes, moves or stops being readable, or its default or a `--merged-into` target stops resolving mid-run, its later items are skipped with that reason and the plan is still printed. A repository git or the filesystem fails on while planning (for example a dangling `.git` file under `--all`) is refused with that reason and the run goes on. The plan, refused repositories included, is printed (as JSON with `--json`), and the run exits 1 when an item failed, else 2 when a repository was refused, else 0. It stops with a message on stderr, no plan and exit 2 in exactly these cases: without `--all`, the current directory is not inside a git repository, its git dir is neither its git common dir nor directly under `<common>/worktrees/`, its main worktree belongs to another repository (`core.worktree`), or its path or git output is not UTF-8; `--apply` outside the host PID namespace; a `--merged-into` REF refused as above; a `--branch` that matches no item. 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. Known residuals: a worktree, rebase or symbolic ref that starts holding a branch between its last re-check and its `update-ref -d` is not seeLine truncated
|
| `pkgmgr branch cleanup --local [--apply] [--json] [--merged-into REF]...` | Deletes only local branches whose tip is an ancestor of the remote-tracking default or of a `--merged-into` REF, each with `git update-ref --no-deref -d` against the planned sha and then its `branch.<name>` section in the repository's own config file (`git config --file <git common dir>/config`; a section it cannot remove is noted on the done item), also when the branch has an upstream or remote copy. Every git call of `--local` runs without any inherited `GIT_*` variable but `GIT_CEILING_DIRECTORIES`, which only narrows repository discovery, so a directory below a ceiling is never resolved to the repository above it; only the config reads of `deleteMerged` and of the upstreams run twice, both with the system config: once with the caller's `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG_PARAMETERS` and `GIT_CONFIG_COUNT` with its `GIT_CONFIG_KEY_<n>` and `GIT_CONFIG_VALUE_<n>`, once without any of them, without `XDG_CONFIG_HOME` and with `HOME` set to the passwd home, and a branch either read keeps stays, so the environment can add keeps but never remove one. A `--merged-into` REF must name exactly one branch or remote-tracking ref, as `git rev-parse --symbolic-full-name` resolves it, in every scanned repository git can read; `X~0`, `X^0`, `X@{0}`, a sha, a range or an unknown ref stops the run. The default comes from `origin`, or from the only remote when there is no `origin`: the ref `refs/remotes/<remote>/HEAD`, else `main`, else `master` resolves to, every symref step inside `refs/remotes/<remote>/`, so never a local branch or `HEAD`; without one, for example with several remotes and no `origin`, the repository is refused, also under `--merged-into`; so is one whose `refs`, `packed-refs`, `reftable`, `logs` or `logs/refs` is a symlink (git-new-workdir), one with a symlink anywhere below `refs/`, `reftable/` or `logs/refs/` (for example `refs/heads/team` linked to another clone's heads), one whose git dir is neither its git common dir nor directly under `<common>/worktrees/` (a registration moved elsewhere), or one whose git output is not UTF-8. The repository is the one the current directory belongs to, also in a submodule or with a separate git dir; one whose main worktree belongs to another repository (`core.worktree`), whose git dir is neither its git common dir nor directly under `<common>/worktrees/`, or whose path is not UTF-8 is refused with exit 2. Skips a symbolic branch, every branch a symbolic ref anywhere under `refs/` (also a tag or remote-tracking one) or under the main worktree's or any registration's `refs/worktree/` points to, a branch that is itself a `--merged-into` target, a branch a worktree holds or that the HEAD of the main repository or of any `worktrees/<id>` registration names (also one `git worktree list` omits or whose id is no valid ref name, read through `git --git-dir=<dir> symbolic-ref HEAD`, so also under reftable), one a rebase or bisect in any worktree works on, one merged only by patch or PR, and, as `git branch --delete-merged` keeps them, one whose `branch.<name>.deleteMerged` is false in the config of any worktree (also its `config.worktree` or an `includeIf "onbranch:..."` file) or in the system config file, and the local upstream, as git's `%(upstream)` resolves it in any worktree, of any branch that stays, also of one that `--branch` or `--exclude` leaves out, each shown as kept with its reason; these holds are read through the git common dir, whichever worktree is scanned, and a registration, symbolic ref, HEAD or state file they cannot read refuses the repository; when a repository vanishes, moves or stops being readable, or its default or a `--merged-into` target stops resolving mid-run, its later items are skipped with that reason and the plan is still printed. A repository git or the filesystem fails on while planning (for example a dangling `.git` file under `--all`) is refused with that reason and the run goes on. The plan, refused repositories included, is printed (as JSON with `--json`), and the run exits 1 when an item failed, else 2 when a repository was refused, else 0. It stops with a message on stderr, no plan and exit 2 in exactly these cases: without `--all`, the current directory is not inside a git repository, its git dir is neither its git common dir nor directly under `<common>/worktrees/`, its main worktree belongs to another repository (`core.worktree`), or its path or git output is not UTF-8; `--apply` outside the host PID namespace; a `--merged-into` REF refused as above; a `--branch` that matches no item (also one that exists only on a remote). Meant for working clones and their worktrees, not for a bare server repository. Not mirrored from `git branch --delete-merged`: git also keeps a branch whose push would update its own upstream; `--local` does not. A branch is deleted before its planned local upstream; right before each delete its holds and then itLine truncated
|
||||||
| `pkgmgr branch tree [--format json\|yaml] [--output FILE]` | Writes every managed repository's branches and worktrees with their cleanup state, without fetching |
|
| `pkgmgr branch tree [--format json\|yaml] [--output FILE]` | Writes every managed repository's branches and worktrees with their cleanup state, without fetching |
|
||||||
| `pkgmgr gc [--all] [--apply]` | Both cleanups in one pass, for the current repository or every managed one |
|
| `pkgmgr gc [--all] [--apply]` | Both cleanups in one pass, for the current repository or every managed one |
|
||||||
| `pkgmgr gc --report [--all] [--root DIR] [--threshold-mb MB] [--docker] [--json]` | Read-only disk inventory: worktree sizes, changes a cleanup would keep, `--root` children, core dumps, docker usage, free space per filesystem |
|
| `pkgmgr gc --report [--all] [--root DIR] [--threshold-mb MB] [--docker] [--json]` | Read-only disk inventory: worktree sizes, changes a cleanup would keep, `--root` children, core dumps, docker usage, free space per filesystem |
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import sys
|
|||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from graphlib import CycleError, TopologicalSorter
|
||||||
|
|
||||||
from pkgmgr.actions.worktree.hooks import FINI_HOOK, hook_command, run_hook
|
from pkgmgr.actions.worktree.hooks import FINI_HOOK, hook_command, run_hook
|
||||||
from pkgmgr.core.git.errors import GitBaseError, GitRunError
|
from pkgmgr.core.git.errors import GitBaseError, GitRunError
|
||||||
@@ -1455,23 +1456,40 @@ def _keep_by_config(repo: Repo, chosen: Callable[[Item], bool]) -> None:
|
|||||||
chosen: the --branch and --exclude filter; only an eligible chosen
|
chosen: the --branch and --exclude filter; only an eligible chosen
|
||||||
item is deleted, every other branch stays.
|
item is deleted, every other branch stays.
|
||||||
"""
|
"""
|
||||||
reasons = {
|
|
||||||
name: f"kept: branch.{name}.deleteMerged is false" for name in repo.opted_out
|
|
||||||
}
|
|
||||||
pending = {
|
pending = {
|
||||||
item.branch: item for item in repo.items if item.eligible and chosen(item)
|
item.branch: item for item in repo.items if item.eligible and chosen(item)
|
||||||
}
|
}
|
||||||
while True:
|
for name, reason in _config_keeps(
|
||||||
for child, parent in sorted(repo.upstreams):
|
repo.opted_out, repo.upstreams, set(pending)
|
||||||
if child not in pending:
|
).items():
|
||||||
reasons.setdefault(parent, f"kept: local upstream of {child}")
|
item = pending[name]
|
||||||
kept = reasons.keys() & pending.keys()
|
|
||||||
if not kept:
|
|
||||||
return
|
|
||||||
for name in kept:
|
|
||||||
item = pending.pop(name)
|
|
||||||
item.actions = []
|
item.actions = []
|
||||||
item.skip = item.skip or reasons[name]
|
item.skip = item.skip or reason
|
||||||
|
|
||||||
|
|
||||||
|
def _config_keeps(
|
||||||
|
opted_out: set[str], upstreams: set[tuple[str, str]], pending: set[str]
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
opted_out: branches whose `branch.<name>.deleteMerged` is false.
|
||||||
|
upstreams: (branch, local upstream branch) pairs.
|
||||||
|
pending: the branches that would be deleted; every other stays.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Pending branch -> keep reason, for each one opted out or the local
|
||||||
|
upstream of a branch that stays, also of one kept so.
|
||||||
|
"""
|
||||||
|
reasons = {name: f"kept: branch.{name}.deleteMerged is false" for name in opted_out}
|
||||||
|
leaving = set(pending)
|
||||||
|
while True:
|
||||||
|
for child, parent in sorted(upstreams):
|
||||||
|
if child not in leaving:
|
||||||
|
reasons.setdefault(parent, f"kept: local upstream of {child}")
|
||||||
|
kept = reasons.keys() & leaving
|
||||||
|
if not kept:
|
||||||
|
return {name: reasons[name] for name in pending - leaving}
|
||||||
|
leaving -= kept
|
||||||
|
|
||||||
|
|
||||||
def _pins(
|
def _pins(
|
||||||
@@ -1638,20 +1656,7 @@ def execute(repo: Repo, item: Item) -> None:
|
|||||||
notes.append(f"image kept: {image}")
|
notes.append(f"image kept: {image}")
|
||||||
if removes_tree:
|
if removes_tree:
|
||||||
_remove_worktree(repo.repo, item.worktree)
|
_remove_worktree(repo.repo, item.worktree)
|
||||||
if "local-branch" in item.actions and repo.scope == "local":
|
if "local-branch" in item.actions:
|
||||||
_git(
|
|
||||||
[
|
|
||||||
"update-ref",
|
|
||||||
"--no-deref",
|
|
||||||
"-d",
|
|
||||||
f"refs/heads/{item.branch}",
|
|
||||||
item.local_sha,
|
|
||||||
],
|
|
||||||
repo.repo,
|
|
||||||
)
|
|
||||||
note = _drop_branch_config(repo.repo, item.branch)
|
|
||||||
notes += [note] if note else []
|
|
||||||
elif "local-branch" in item.actions:
|
|
||||||
_git(["branch", "-D", "--", item.branch], repo.repo)
|
_git(["branch", "-D", "--", item.branch], repo.repo)
|
||||||
for remote, sha in item.remotes.items():
|
for remote, sha in item.remotes.items():
|
||||||
if f"remote-branch:{remote}" not in item.actions:
|
if f"remote-branch:{remote}" not in item.actions:
|
||||||
@@ -1789,6 +1794,60 @@ def _gone(repo: Repo) -> str:
|
|||||||
return f"target {', '.join(gone)} is gone" if gone else ""
|
return f"target {', '.join(gone)} is gone" if gone else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_order(repo: Repo, items: list[Item]) -> list[Item]:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
repo: a repository planned with scope `local`.
|
||||||
|
items: its eligible chosen items, in plan order.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The items with each branch before its local upstream, as the plan
|
||||||
|
read them; plan order on an upstream cycle, whose members the
|
||||||
|
re-check of _delete_local then keeps.
|
||||||
|
"""
|
||||||
|
by_name = {item.branch: item for item in items}
|
||||||
|
graph: dict[str, set[str]] = {name: set() for name in by_name}
|
||||||
|
for child, parent in sorted(repo.upstreams):
|
||||||
|
if child != parent and child in by_name and parent in by_name:
|
||||||
|
graph[parent].add(child)
|
||||||
|
try:
|
||||||
|
return [by_name[name] for name in TopologicalSorter(graph).static_order()]
|
||||||
|
except CycleError:
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_local(repo: Repo, item: Item) -> None:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
repo: a repository planned with scope `local`.
|
||||||
|
item: an eligible item, deleted after every planned branch whose
|
||||||
|
local upstream it is; it ends done, or skipped when its holds,
|
||||||
|
then its `deleteMerged` and the upstreams of every branch still
|
||||||
|
present, re-read right before the delete, keep it.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
GitBaseError: when git cannot re-read the repository or the sha moved.
|
||||||
|
OSError: when a state file cannot be read.
|
||||||
|
ValueError: when a state file is not UTF-8.
|
||||||
|
"""
|
||||||
|
fresh = _rebuild(repo, item)
|
||||||
|
if _fingerprint(fresh) != _fingerprint(item):
|
||||||
|
item.result = f"skipped: changed since plan ({fresh.skip or fresh.reason})"
|
||||||
|
return
|
||||||
|
kept = _config_keeps(
|
||||||
|
_opted_out(repo.repo), _local_upstreams(repo.repo), {item.branch}
|
||||||
|
).get(item.branch)
|
||||||
|
if kept:
|
||||||
|
item.result = f"skipped: changed since plan ({kept})"
|
||||||
|
return
|
||||||
|
_git(
|
||||||
|
["update-ref", "--no-deref", "-d", f"refs/heads/{item.branch}", item.local_sha],
|
||||||
|
repo.repo,
|
||||||
|
)
|
||||||
|
note = _drop_branch_config(repo.repo, item.branch)
|
||||||
|
item.result = "; ".join(["done", *([note] if note else [])])
|
||||||
|
|
||||||
|
|
||||||
def _apply(repo: Repo, item: Item) -> None:
|
def _apply(repo: Repo, item: Item) -> None:
|
||||||
"""
|
"""
|
||||||
Execute one planned item; under scope `local` without raising.
|
Execute one planned item; under scope `local` without raising.
|
||||||
@@ -1806,7 +1865,7 @@ def _apply(repo: Repo, item: Item) -> None:
|
|||||||
item.result = f"skipped: {gone}"
|
item.result = f"skipped: {gone}"
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
execute(repo, item)
|
_delete_local(repo, item)
|
||||||
except (RuntimeError, OSError, ValueError) as exc:
|
except (RuntimeError, OSError, ValueError) as exc:
|
||||||
item.result = f"failed: {_describe(exc)}"
|
item.result = f"failed: {_describe(exc)}"
|
||||||
|
|
||||||
@@ -1893,8 +1952,11 @@ def cleanup(
|
|||||||
merged branches without a worktree, local and remote; `all` does
|
merged branches without a worktree, local and remote; `all` does
|
||||||
both in one pass; `local` deletes, with `update-ref -d` against
|
both in one pass; `local` deletes, with `update-ref -d` against
|
||||||
the planned sha, each local branch that no worktree, rebase or
|
the planned sha, each local branch that no worktree, rebase or
|
||||||
bisect holds, that _keep_by_config does not keep and whose tip
|
bisect holds, that _keep_by_config does not keep, both re-checked
|
||||||
is an ancestor of the remote-tracking
|
right before its delete, which follows the deletes of the
|
||||||
|
branches it is the local upstream of, and whose tip is an
|
||||||
|
ancestor of the
|
||||||
|
remote-tracking
|
||||||
default or a merged_into line, refuses a repository without such
|
default or a merged_into line, refuses a repository without such
|
||||||
a default, and reads no forge token, runs no fetch, push, docker,
|
a default, and reads no forge token, runs no fetch, push, docker,
|
||||||
hook or prune.
|
hook or prune.
|
||||||
@@ -2001,8 +2063,8 @@ def _run(
|
|||||||
|
|
||||||
if apply:
|
if apply:
|
||||||
for repo in repos:
|
for repo in repos:
|
||||||
for item in repo.items:
|
todo = [item for item in repo.items if item.eligible and chosen(item)]
|
||||||
if item.eligible and chosen(item):
|
for item in _delete_order(repo, todo) if repo.scope == "local" else todo:
|
||||||
_apply(repo, item)
|
_apply(repo, item)
|
||||||
print(
|
print(
|
||||||
f"[{options['scope']}] {item.branch or item.worktree}: "
|
f"[{options['scope']}] {item.branch or item.worktree}: "
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from collections.abc import Iterator
|
from collections.abc import Callable, Iterator
|
||||||
from contextlib import contextmanager, redirect_stderr, redirect_stdout
|
from contextlib import contextmanager, redirect_stderr, redirect_stdout
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -74,6 +74,14 @@ class TestBranchCleanupLocal(unittest.TestCase):
|
|||||||
self.root = os.path.realpath(self._tmp.name)
|
self.root = os.path.realpath(self._tmp.name)
|
||||||
self.addCleanup(os.chdir, os.getcwd())
|
self.addCleanup(os.chdir, os.getcwd())
|
||||||
os.chdir(self.root)
|
os.chdir(self.root)
|
||||||
|
self.home = os.path.join(self.root, "home")
|
||||||
|
os.makedirs(self.home)
|
||||||
|
for patcher in (
|
||||||
|
patch.dict(os.environ, {"HOME": self.home}),
|
||||||
|
patch("pkgmgr.core.git.run._passwd_home", return_value=self.home),
|
||||||
|
):
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
self.origin = os.path.join(self.root, "origin.git")
|
self.origin = os.path.join(self.root, "origin.git")
|
||||||
self.repo = os.path.join(self.root, "repo")
|
self.repo = os.path.join(self.root, "repo")
|
||||||
git(self.root, "init", "--bare", "-b", "main", self.origin)
|
git(self.root, "init", "--bare", "-b", "main", self.origin)
|
||||||
@@ -1687,6 +1695,228 @@ class TestBranchCleanupLocal(unittest.TestCase):
|
|||||||
self.assertEqual(self._results(run), {"done": "done", "local-done": "done"})
|
self.assertEqual(self._results(run), {"done": "done", "local-done": "done"})
|
||||||
self.assertFalse(os.path.exists(marker))
|
self.assertFalse(os.path.exists(marker))
|
||||||
|
|
||||||
|
def test_config_keep_made_after_the_plan_skips_the_delete(self) -> None:
|
||||||
|
real = gc._rebuild
|
||||||
|
|
||||||
|
def late_keep(repo: gc.Repo, item: gc.Item) -> gc.Item:
|
||||||
|
fresh = real(repo, item)
|
||||||
|
if item.branch == "done":
|
||||||
|
git(self.repo, "branch", "kid", "wip")
|
||||||
|
git(self.repo, "config", "branch.kid.remote", ".")
|
||||||
|
git(self.repo, "config", "branch.kid.merge", "refs/heads/done")
|
||||||
|
if item.branch == "local-done":
|
||||||
|
git(self.repo, "config", "branch.local-done.deleteMerged", "false")
|
||||||
|
return fresh
|
||||||
|
|
||||||
|
with patch.object(gc, "_rebuild", side_effect=late_keep):
|
||||||
|
run = self._cleanup("local", apply=True)
|
||||||
|
|
||||||
|
self.assertEqual(run.code, 0)
|
||||||
|
self.assertEqual(
|
||||||
|
self._results(run),
|
||||||
|
{
|
||||||
|
"done": "skipped: changed since plan (kept: local upstream of kid)",
|
||||||
|
"local-done": (
|
||||||
|
"skipped: changed since plan "
|
||||||
|
"(kept: branch.local-done.deleteMerged is false)"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
heads = self._refs(self.repo, "refs/heads")
|
||||||
|
self.assertIn("refs/heads/done", heads)
|
||||||
|
self.assertIn("refs/heads/local-done", heads)
|
||||||
|
self.assertEqual(
|
||||||
|
self._branch_config("done"), ["branch.done.remote", "branch.done.merge"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _family(self) -> None:
|
||||||
|
self._set_head()
|
||||||
|
git(self.repo, "branch", "a-base", "done")
|
||||||
|
git(self.repo, "branch", "z-kid", "done")
|
||||||
|
git(self.repo, "config", "branch.z-kid.remote", ".")
|
||||||
|
git(self.repo, "config", "branch.z-kid.merge", "refs/heads/a-base")
|
||||||
|
|
||||||
|
def _apply_after(self, change: Callable[[], None]) -> SimpleNamespace:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
change: run once after the plan, before the first re-check of
|
||||||
|
`--local --apply --branch a-base --branch z-kid`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
As _cleanup returns it.
|
||||||
|
"""
|
||||||
|
real = gc._rebuild
|
||||||
|
changes = [change]
|
||||||
|
|
||||||
|
def late(repo: gc.Repo, item: gc.Item) -> gc.Item:
|
||||||
|
if changes:
|
||||||
|
changes.pop()()
|
||||||
|
return real(repo, item)
|
||||||
|
|
||||||
|
with patch.object(gc, "_rebuild", side_effect=late):
|
||||||
|
return self._cleanup("local", apply=True, include=("a-base", "z-kid"))
|
||||||
|
|
||||||
|
def assertFamilyKept(self, run: SimpleNamespace) -> None:
|
||||||
|
heads = self._refs(self.repo, "refs/heads")
|
||||||
|
self.assertIn("refs/heads/a-base", heads)
|
||||||
|
self.assertIn("refs/heads/z-kid", heads)
|
||||||
|
self.assertFalse((run.items["a-base"]["result"] or "").startswith("done"))
|
||||||
|
self.assertEqual(
|
||||||
|
git(self.repo, "config", "--get", "branch.z-kid.merge"), "refs/heads/a-base"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_kid_held_before_the_plan_keeps_its_base(self) -> None:
|
||||||
|
self._family()
|
||||||
|
git(self.repo, "worktree", "add", os.path.join(self.root, "wt-kid"), "z-kid")
|
||||||
|
|
||||||
|
run = self._cleanup("local", apply=True, include=("a-base", "z-kid"))
|
||||||
|
|
||||||
|
self.assertEqual(run.items["a-base"]["skip"], "kept: local upstream of z-kid")
|
||||||
|
self.assertFamilyKept(run)
|
||||||
|
|
||||||
|
def test_kid_held_after_the_plan_keeps_its_base(self) -> None:
|
||||||
|
self._family()
|
||||||
|
kid = os.path.join(self.root, "wt-kid")
|
||||||
|
|
||||||
|
run = self._apply_after(lambda: git(self.repo, "worktree", "add", kid, "z-kid"))
|
||||||
|
|
||||||
|
self.assertEqual(run.code, 0)
|
||||||
|
self.assertTrue(run.items["z-kid"]["result"].startswith("skipped: changed"))
|
||||||
|
self.assertEqual(
|
||||||
|
run.items["a-base"]["result"],
|
||||||
|
"skipped: changed since plan (kept: local upstream of z-kid)",
|
||||||
|
)
|
||||||
|
self.assertFamilyKept(run)
|
||||||
|
|
||||||
|
def test_kid_moved_after_the_plan_keeps_its_base(self) -> None:
|
||||||
|
self._family()
|
||||||
|
|
||||||
|
def move() -> None:
|
||||||
|
tree = os.path.join(self.root, "wt-tmp")
|
||||||
|
git(self.repo, "worktree", "add", "--detach", tree, "z-kid")
|
||||||
|
with open(os.path.join(tree, "kid.txt"), "w", encoding="utf-8") as fh:
|
||||||
|
fh.write("kid")
|
||||||
|
git(tree, "add", "kid.txt")
|
||||||
|
git(tree, "commit", "-m", "kid")
|
||||||
|
moved = git(tree, "rev-parse", "HEAD")
|
||||||
|
git(self.repo, "update-ref", "refs/heads/z-kid", moved)
|
||||||
|
git(self.repo, "worktree", "remove", "--force", tree)
|
||||||
|
|
||||||
|
run = self._apply_after(move)
|
||||||
|
|
||||||
|
self.assertTrue(run.items["z-kid"]["result"].startswith("skipped: changed"))
|
||||||
|
self.assertFamilyKept(run)
|
||||||
|
|
||||||
|
def test_unchanged_kid_is_deleted_before_its_base(self) -> None:
|
||||||
|
self._family()
|
||||||
|
|
||||||
|
run = self._cleanup("local", apply=True, include=("a-base", "z-kid"))
|
||||||
|
|
||||||
|
self.assertEqual(run.code, 0)
|
||||||
|
self.assertEqual(self._results(run), {"a-base": "done", "z-kid": "done"})
|
||||||
|
deleted = [
|
||||||
|
c.args[0][3]
|
||||||
|
for c in run.guard.git.call_args_list
|
||||||
|
if c.args[0][:3] == ["update-ref", "--no-deref", "-d"]
|
||||||
|
]
|
||||||
|
self.assertEqual(deleted, ["refs/heads/z-kid", "refs/heads/a-base"])
|
||||||
|
|
||||||
|
def test_local_upstream_cycle_is_kept_on_apply(self) -> None:
|
||||||
|
self._set_head()
|
||||||
|
for name in ("c1", "c2"):
|
||||||
|
git(self.repo, "branch", name, "done")
|
||||||
|
for name, upstream in (("c1", "c2"), ("c2", "c1")):
|
||||||
|
git(self.repo, "config", f"branch.{name}.remote", ".")
|
||||||
|
git(self.repo, "config", f"branch.{name}.merge", f"refs/heads/{upstream}")
|
||||||
|
|
||||||
|
run = self._cleanup("local", apply=True, include=("c1", "c2"))
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self._results(run),
|
||||||
|
{
|
||||||
|
name: f"skipped: changed since plan (kept: local upstream of {other})"
|
||||||
|
for name, other in (("c1", "c2"), ("c2", "c1"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
heads = self._refs(self.repo, "refs/heads")
|
||||||
|
self.assertIn("refs/heads/c1", heads)
|
||||||
|
self.assertIn("refs/heads/c2", heads)
|
||||||
|
|
||||||
|
def _hub_call(self, *branches: str) -> SimpleNamespace:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
branches: the --branch values of `branch cleanup --local --apply
|
||||||
|
--json`, run in the fixture repository under _guarded with a
|
||||||
|
compose stack listed in the held worktree.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The exit code, stdout, stderr, the _guarded spies and the
|
||||||
|
container listing and docker mocks, as attributes code, out,
|
||||||
|
err, guard, compose and docker.
|
||||||
|
"""
|
||||||
|
argv = ["branch", "cleanup", "--local", "--apply", "--json"]
|
||||||
|
args = create_parser("test").parse_args(
|
||||||
|
[*argv, *(f"--branch={name}" for name in branches)]
|
||||||
|
)
|
||||||
|
ctx = SimpleNamespace(config_merged={"worktree_hooks": {}, "repositories": []})
|
||||||
|
stack = [gc.Container("proj", os.path.join(self.held, "app"), "img")]
|
||||||
|
os.chdir(self.repo)
|
||||||
|
out, err = io.StringIO(), io.StringIO()
|
||||||
|
with (
|
||||||
|
self._guarded() as guard,
|
||||||
|
patch.object(gc, "list_compose_containers", return_value=stack) as ps,
|
||||||
|
patch.object(gc, "_docker", side_effect=AssertionError) as docker,
|
||||||
|
redirect_stdout(out),
|
||||||
|
redirect_stderr(err),
|
||||||
|
self.assertRaises(SystemExit) as stop,
|
||||||
|
):
|
||||||
|
handle_branch(args, ctx=ctx)
|
||||||
|
return SimpleNamespace(
|
||||||
|
code=stop.exception.code,
|
||||||
|
out=out.getvalue(),
|
||||||
|
err=err.getvalue(),
|
||||||
|
guard=guard,
|
||||||
|
compose=ps,
|
||||||
|
docker=docker,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hub_call_deletes_only_a_merged_branch_no_worktree_holds(self) -> None:
|
||||||
|
self._set_head()
|
||||||
|
heads = self._refs(self.repo, "refs/heads")
|
||||||
|
tracking = self._refs(self.repo, "refs/remotes")
|
||||||
|
origin = self._refs(self.origin, "refs")
|
||||||
|
trees = git(self.repo, "worktree", "list", "--porcelain")
|
||||||
|
|
||||||
|
refused = self._hub_call("remote-done")
|
||||||
|
run = self._hub_call("held-done", "wip", "local-done")
|
||||||
|
|
||||||
|
self.assertEqual(refused.code, 2)
|
||||||
|
self.assertEqual(refused.out, "")
|
||||||
|
self.assertIn("Unknown --branch: remote-done", refused.err)
|
||||||
|
self.assertEqual(run.code, 0)
|
||||||
|
items = {i["branch"]: i for i in json.loads(run.out)["repos"][0]["items"]}
|
||||||
|
self.assertEqual(set(items), {"held-done", "wip", "local-done"})
|
||||||
|
held = items["held-done"]
|
||||||
|
self.assertEqual(held["skip"], f"checked out in worktree {self.held}")
|
||||||
|
self.assertEqual(held["worktree"], self.held)
|
||||||
|
self.assertEqual(held["compose_projects"], [])
|
||||||
|
self.assertEqual(items["wip"]["state"], "unmerged")
|
||||||
|
for name in ("held-done", "wip"):
|
||||||
|
with self.subTest(keeps=name):
|
||||||
|
self.assertEqual(items[name]["actions"], [])
|
||||||
|
self.assertIsNone(items[name]["result"])
|
||||||
|
self.assertEqual(items["local-done"]["result"], "done")
|
||||||
|
after = self._refs(self.repo, "refs/heads")
|
||||||
|
self.assertEqual(set(heads) - set(after), {"refs/heads/local-done"})
|
||||||
|
self.assertEqual(self._refs(self.repo, "refs/remotes"), tracking)
|
||||||
|
self.assertEqual(self._refs(self.origin, "refs"), origin)
|
||||||
|
self.assertEqual(git(self.repo, "worktree", "list", "--porcelain"), trees)
|
||||||
|
self.assertTrue(os.path.isfile(os.path.join(self.held, "done.txt")))
|
||||||
|
for call in (refused, run):
|
||||||
|
call.compose.assert_not_called()
|
||||||
|
call.docker.assert_not_called()
|
||||||
|
self.assertLocalOnly(call.guard)
|
||||||
|
|
||||||
def test_branch_scope_still_needs_the_forge_for_a_remote_copy(self) -> None:
|
def test_branch_scope_still_needs_the_forge_for_a_remote_copy(self) -> None:
|
||||||
run = self._cleanup("branch", apply=False)
|
run = self._cleanup("branch", apply=False)
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ from pkgmgr.cli.parser import create_parser
|
|||||||
from pkgmgr.core.credentials.types import NoCredentialsError
|
from pkgmgr.core.credentials.types import NoCredentialsError
|
||||||
|
|
||||||
GITHUB_URL = "https://github.com/example/demo.git"
|
GITHUB_URL = "https://github.com/example/demo.git"
|
||||||
GIT_ENV = {
|
AUTHOR = {
|
||||||
**os.environ,
|
|
||||||
"GIT_AUTHOR_NAME": "t",
|
"GIT_AUTHOR_NAME": "t",
|
||||||
"GIT_AUTHOR_EMAIL": "t@example.org",
|
"GIT_AUTHOR_EMAIL": "t@example.org",
|
||||||
"GIT_COMMITTER_NAME": "t",
|
"GIT_COMMITTER_NAME": "t",
|
||||||
@@ -30,7 +29,7 @@ def git(cwd: str, *args: str) -> str:
|
|||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
["git", "-c", "commit.gpgsign=false", *args],
|
["git", "-c", "commit.gpgsign=false", *args],
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
env=GIT_ENV,
|
env={**os.environ, **AUTHOR},
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -48,7 +47,16 @@ def docker_available() -> bool:
|
|||||||
class GcFixture:
|
class GcFixture:
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self._tmp = tempfile.TemporaryDirectory()
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
self.root = os.path.realpath(self._tmp.name)
|
self.root = os.path.realpath(self._tmp.name)
|
||||||
|
self.addCleanup(os.chdir, os.getcwd())
|
||||||
|
os.chdir(self.root)
|
||||||
|
home = os.path.join(self.root, "home")
|
||||||
|
os.makedirs(home)
|
||||||
|
scratch = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
||||||
|
patcher = patch.dict(os.environ, {**scratch, "HOME": home}, clear=True)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
self.origin = os.path.join(self.root, "origin.git")
|
self.origin = os.path.join(self.root, "origin.git")
|
||||||
self.repo = os.path.join(self.root, "repo")
|
self.repo = os.path.join(self.root, "repo")
|
||||||
git(self.root, "init", "--bare", "-b", "main", self.origin)
|
git(self.root, "init", "--bare", "-b", "main", self.origin)
|
||||||
@@ -83,9 +91,6 @@ class GcFixture:
|
|||||||
merged_heads={self.squashed_sha: 7}, open_heads={"wip": 3}
|
merged_heads={self.squashed_sha: 7}, open_heads={"wip": 3}
|
||||||
)
|
)
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
|
||||||
self._tmp.cleanup()
|
|
||||||
|
|
||||||
def _commit(self, name: str) -> None:
|
def _commit(self, name: str) -> None:
|
||||||
with open(os.path.join(self.repo, f"{name}.txt"), "w") as fh:
|
with open(os.path.join(self.repo, f"{name}.txt"), "w") as fh:
|
||||||
fh.write(name)
|
fh.write(name)
|
||||||
|
|||||||
Reference in new issue
Block a user