Merge feature/kvb-git-pkgmgr-wrappers-replay into unify/skills-20261112
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
8c5c0b118e
22 files changed
+404
-839
No files matched your search
@@ -26,6 +26,14 @@ distributions.
|
|||||||
natively from v2.1.277; Gemini CLI once its `context.fileName` setting
|
natively from v2.1.277; Gemini CLI once its `context.fileName` setting
|
||||||
lists `AGENTS.md`.
|
lists `AGENTS.md`.
|
||||||
|
|
||||||
|
## Scope against package-manager
|
||||||
|
|
||||||
|
Deterministic logic an operator could run without an agent (branches,
|
||||||
|
worktrees, cleanups, releases) lives in
|
||||||
|
[package-manager](https://github.com/kevinveenbirkenbach/package-manager) as
|
||||||
|
a `pkgmgr` command with its tests, never as a script in a skill. A skill
|
||||||
|
wrapping it holds only the agent discipline: claims, picks, handover.
|
||||||
|
|
||||||
## Code questions
|
## Code questions
|
||||||
|
|
||||||
Ask the codebase-memory-mcp graph first (`search_graph`, `trace_path`,
|
Ask the codebase-memory-mcp graph first (`search_graph`, `trace_path`,
|
||||||
|
|||||||
@@ -55,8 +55,11 @@ flowchart LR
|
|||||||
git --> git_pr_create[kvb-git-pr-create]
|
git --> git_pr_create[kvb-git-pr-create]
|
||||||
git --> git_pr_label[kvb-git-pr-label]
|
git --> git_pr_label[kvb-git-pr-label]
|
||||||
git --> git_update_branches[kvb-git-update-branches]
|
git --> git_update_branches[kvb-git-update-branches]
|
||||||
git --> git_branch_gc[kvb-git-branch-gc]
|
git --> git_branch_cleanup[kvb-git-branch-cleanup]
|
||||||
git --> git_orphans_find[kvb-git-orphans-find]
|
git --> git_worktree_cleanup[kvb-git-worktree-cleanup]
|
||||||
|
git --> git_gc[kvb-git-gc]
|
||||||
|
git --> git_worktree_up[kvb-git-worktree-up]
|
||||||
|
git --> git_worktree_orphans[kvb-git-worktree-orphans]
|
||||||
release --> release_changelog[kvb-release-changelog]
|
release --> release_changelog[kvb-release-changelog]
|
||||||
release --> release_article[kvb-release-article]
|
release --> release_article[kvb-release-article]
|
||||||
release --> release_post[kvb-release-post]
|
release --> release_post[kvb-release-post]
|
||||||
@@ -164,8 +167,11 @@ flowchart LR
|
|||||||
pr_create[kvb-git-pr-create]
|
pr_create[kvb-git-pr-create]
|
||||||
pr_label[kvb-git-pr-label]
|
pr_label[kvb-git-pr-label]
|
||||||
update_branches[kvb-git-update-branches]
|
update_branches[kvb-git-update-branches]
|
||||||
branch_gc[kvb-git-branch-gc]
|
branch_cleanup[kvb-git-branch-cleanup]
|
||||||
orphans_find[kvb-git-orphans-find]
|
worktree_cleanup[kvb-git-worktree-cleanup]
|
||||||
|
git_gc[kvb-git-gc]
|
||||||
|
worktree_up[kvb-git-worktree-up]
|
||||||
|
worktree_orphans[kvb-git-worktree-orphans]
|
||||||
hotfix[kvb-git-hotfix]
|
hotfix[kvb-git-hotfix]
|
||||||
git_push[kvb-git-push]
|
git_push[kvb-git-push]
|
||||||
git_diff[kvb-git-diff]
|
git_diff[kvb-git-diff]
|
||||||
@@ -287,11 +293,16 @@ flowchart LR
|
|||||||
|
|
||||||
coordinate --> democracy
|
coordinate --> democracy
|
||||||
cleanup --> democracy
|
cleanup --> democracy
|
||||||
cleanup --> orphans_find
|
cleanup --> worktree_orphans
|
||||||
branch_gc --> democracy
|
branch_cleanup --> democracy
|
||||||
orphans_find --> branch_gc
|
worktree_cleanup --> branch_cleanup
|
||||||
orphans_find --> choice
|
git_gc --> branch_cleanup
|
||||||
orphans_find --> operator
|
git_gc --> worktree_cleanup
|
||||||
|
worktree_up --> democracy
|
||||||
|
worktree_up --> rasci
|
||||||
|
worktree_orphans --> git_gc
|
||||||
|
worktree_orphans --> choice
|
||||||
|
worktree_orphans --> operator
|
||||||
funnel --> democracy
|
funnel --> democracy
|
||||||
funnel --> dialectic
|
funnel --> dialectic
|
||||||
funnel --> subagent_model
|
funnel --> subagent_model
|
||||||
@@ -302,7 +313,7 @@ flowchart LR
|
|||||||
unify --> funnel
|
unify --> funnel
|
||||||
unify --> rasci
|
unify --> rasci
|
||||||
unify --> gate
|
unify --> gate
|
||||||
unify --> branch_gc
|
unify --> branch_cleanup
|
||||||
unify --> cleanup
|
unify --> cleanup
|
||||||
unify --> radio
|
unify --> radio
|
||||||
status_matrix --> rasci
|
status_matrix --> rasci
|
||||||
@@ -349,7 +360,7 @@ flowchart LR
|
|||||||
dream --> dialectic
|
dream --> dialectic
|
||||||
dream --> optimize
|
dream --> optimize
|
||||||
dream --> cleanup
|
dream --> cleanup
|
||||||
dream --> orphans_find
|
dream --> worktree_orphans
|
||||||
optimize --> skill_cleanup
|
optimize --> skill_cleanup
|
||||||
optimize --> transcend
|
optimize --> transcend
|
||||||
optimize --> autoskill
|
optimize --> autoskill
|
||||||
@@ -410,6 +421,18 @@ flowchart LR
|
|||||||
refactor --> clean
|
refactor --> clean
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## pkgmgr wrappers 🔗
|
||||||
|
|
||||||
|
These `git` skills are thin wrappers: the [pkgmgr](https://github.com/kevinveenbirkenbach/package-manager#worktrees-and-garbage-collection) command holds the logic, the skill adds claims, picks and the operator handover.
|
||||||
|
|
||||||
|
| Skill | pkgmgr command |
|
||||||
|
| --- | --- |
|
||||||
|
| `kvb-git-worktree-up` | [`pkgmgr worktree up`](https://github.com/kevinveenbirkenbach/package-manager#worktrees-and-garbage-collection) |
|
||||||
|
| `kvb-git-worktree-cleanup` | [`pkgmgr worktree cleanup`](https://github.com/kevinveenbirkenbach/package-manager#worktrees-and-garbage-collection) |
|
||||||
|
| `kvb-git-branch-cleanup` | [`pkgmgr branch cleanup`](https://github.com/kevinveenbirkenbach/package-manager#worktrees-and-garbage-collection), `pkgmgr branch purge` |
|
||||||
|
| `kvb-git-gc` | [`pkgmgr gc`](https://github.com/kevinveenbirkenbach/package-manager#worktrees-and-garbage-collection) |
|
||||||
|
| `kvb-git-worktree-orphans` | [`pkgmgr worktree orphans`](https://github.com/kevinveenbirkenbach/package-manager#worktrees-and-garbage-collection) |
|
||||||
|
|
||||||
## Requirements 📦
|
## Requirements 📦
|
||||||
|
|
||||||
- Node.js (provides `npx skills`).
|
- Node.js (provides `npx skills`).
|
||||||
|
|||||||
@@ -25,13 +25,11 @@ Code: `sandbox.filesystem.allowWrite`) and session scratch dirs; never
|
|||||||
another user's files. Prefer the project's cleanup and teardown targets; obey
|
another user's files. Prefer the project's cleanup and teardown targets; obey
|
||||||
rules forbidding raw commands.
|
rules forbidding raw commands.
|
||||||
|
|
||||||
- **/tmp**: `du -sh` per own top-level entry; system entries (`.X11-unix`,
|
- **/tmp**: `pkgmgr gc --report --docker --root /tmp --root <write dir>`,
|
||||||
`.ICE-unix`, `systemd-private-*`, `ssh-*`, `tmux-*`, sockets) are never
|
else `du -sh` per own entry; system entries (`.X11-unix`, `.ICE-unix`, `systemd-private-*`,
|
||||||
candidates.
|
`ssh-*`, `tmux-*`, sockets) are never candidates.
|
||||||
- **Docker**, **worktrees**: read `docker.md` (its `⚠ Volumes` checklist
|
- **Docker**, **worktrees**: read `docker.md` (its `⚠ Volumes` checklist
|
||||||
joins Step 4) and `worktrees.md` next to this file when present. Only at
|
joins Step 4) and `worktrees.md` next to this file when present.
|
||||||
watch stage `warn` or worse, or with worktrees among the biggest consumers:
|
|
||||||
`kvb-git-orphans-find --sizes`, its largest orphaned worktrees as candidates.
|
|
||||||
- **Agent dirs**: pip, npm, pre-commit, act, gh, buildx caches; Ansible and
|
- **Agent dirs**: pip, npm, pre-commit, act, gh, buildx caches; Ansible and
|
||||||
build temp dirs.
|
build temp dirs.
|
||||||
- **Register**: `council.py list --long`; `rasci.py artifacts` (`kvb-org-artefacts`,
|
- **Register**: `council.py list --long`; `rasci.py artifacts` (`kvb-org-artefacts`,
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
Per worktree: size (`du -sh`), dirty state, whether its branch is pushed and
|
Per worktree: size (`du -sh`), dirty state, whether its branch is pushed and
|
||||||
whether its HEAD is in an integration line (the default branch and every
|
whether its HEAD is in an integration line (the default branch and every
|
||||||
branch the project merges features into; ask the operator or the project's
|
branch the project merges features into; ask the operator or the project's
|
||||||
memory). With `pkgmgr`, `pkgmgr branch gc --json --sizes --merged-into
|
memory). With `pkgmgr`, `pkgmgr gc --json --sizes --merged-into <line>`
|
||||||
<line>` (one per line) measures it all; merged-clean items go to the
|
(one per line) measures it all; its candidates go to the `kvb-git-gc` skill.
|
||||||
`kvb-git-branch-gc` skill.
|
At `warn` or worse, or with worktrees on top of the report, add the largest of
|
||||||
|
`kvb-git-worktree-orphans --sizes`.
|
||||||
|
|
||||||
Clean and in a line: medium, its ignored files (`node_modules`, build output,
|
Clean and in a line: medium, its ignored files (`node_modules`, build output,
|
||||||
evidence) are lost. Dirty: a candidate only when every changed, staged and
|
evidence) are lost. Dirty: a candidate only when every changed, staged and
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ in-scope session's scripts, tests and skills into their repositories.
|
|||||||
|
|
||||||
The `kvb-cleanup` skill for the disk, `/tmp`, Docker and agent directories the
|
The `kvb-cleanup` skill for the disk, `/tmp`, Docker and agent directories the
|
||||||
window filled, with the finished tasks' artifacts (`rasci.py artifacts
|
window filled, with the finished tasks' artifacts (`rasci.py artifacts
|
||||||
--ended`) as candidates; then `kvb-git-orphans-find` per in-scope
|
--ended`) as candidates; then `kvb-git-worktree-orphans` per in-scope
|
||||||
repository, which hands merged-clean rows to `kvb-git-branch-gc`. Each keeps
|
repository, which hands merged-clean rows to `kvb-git-gc`. Each keeps
|
||||||
its own confirmations; the dream never writes, deletes or commits for them. Close
|
its own confirmations; the dream never writes, deletes or commits for them. Close
|
||||||
with `date +%s > ~/.claude/.dream-stamps/"$CLAUDE_CODE_SESSION_ID"`, so the
|
with `date +%s > ~/.claude/.dream-stamps/"$CLAUDE_CODE_SESSION_ID"`, so the
|
||||||
next hint is twelve hours out.
|
next hint is twelve hours out.
|
||||||
|
|||||||
@@ -1,42 +1,44 @@
|
|||||||
---
|
---
|
||||||
name: kvb-git-branch-gc
|
name: kvb-git-branch-cleanup
|
||||||
description: >
|
description: >
|
||||||
Remove merged branches and worktrees with `pkgmgr branch gc` after a dry
|
Remove merged branches outside worktrees.
|
||||||
run the operator picks from. Trigger on "branches aufräumen",
|
|
||||||
"worktrees aufräumen".
|
|
||||||
---
|
---
|
||||||
|
|
||||||
`pkgmgr branch gc` decides what is merged and does the removing; this skill
|
`pkgmgr branch cleanup` decides what is merged and does the removing; this
|
||||||
decides what may go and who has to agree first. The dry run changes nothing,
|
skill decides what may go and who has to agree first. The dry run changes
|
||||||
and nothing is removed before the operator's pick in this run.
|
nothing, and nothing is removed before the operator's pick in this run. The
|
||||||
|
`kvb-git-worktree-cleanup` and `kvb-git-gc` skills run these steps with their
|
||||||
|
own command.
|
||||||
|
|
||||||
## Step 0: claim and chair
|
## Step 0: claim and chair
|
||||||
|
|
||||||
1. Claim `work/branch-gc-<repo>`, or `work/branch-gc-all` for every
|
1. Claim `work/branch-cleanup-<repo>`, or `work/branch-cleanup-all` for every
|
||||||
repository, through the `kvb-org-organize` skill.
|
repository, through the `kvb-org-organize` skill.
|
||||||
2. The apply takes stacks down and ends with a host-wide
|
2. The apply ends with a host-wide `docker image prune -f` and
|
||||||
`docker image prune -f` and `docker builder prune -f`, which reach every
|
`docker builder prune -f`, which reach every session's images and build
|
||||||
session's images and build cache. When another session holds the chair,
|
cache. When another session holds the chair, request the grant (what,
|
||||||
request the grant (what, where, how long) and wait for it; the prune never
|
where, how long) and wait for it; the prune never runs during a peer's
|
||||||
runs during a peer's build.
|
build.
|
||||||
|
|
||||||
## Step 1: dry run
|
## Step 1: dry run
|
||||||
|
|
||||||
`pkgmgr branch gc --json --sizes --min-age 24 --merged-into <line>`, one
|
`pkgmgr branch cleanup --json --min-age 24 --merged-into <line>`, one
|
||||||
`--merged-into` per integration line the project merges features into
|
`--merged-into` per integration line the project merges features into
|
||||||
(default branch excluded, it always counts), with `--all` only when the
|
(default branch excluded, it always counts), with `--all` only when the
|
||||||
operator asked for every repository, output to `/tmp/branch-gc-<N>.json`. A
|
operator asked for every repository, output to `/tmp/branch-cleanup-<N>.json`.
|
||||||
line is protected, and a checkout at its tip stays as fresh work; an item
|
A line is protected, and a checkout at its tip stays as fresh work; an item
|
||||||
merged into a line names it in `merged_into`. No `git fetch` of your own
|
merged into a line names it in `merged_into`. No `git fetch` of your own
|
||||||
before it: gc fetches without auto-maintenance, while a plain fetch on git
|
before it: pkgmgr fetches without auto-maintenance, while a plain fetch on git
|
||||||
2.50 or later may prune the sandbox's bind-mount stubs. Every repository's
|
2.50 or later may prune the sandbox's bind-mount stubs. Every repository's
|
||||||
`warnings` (a failed fetch) go to the top of the plan. Sort the items:
|
`warnings` (a failed fetch) go to the top of the plan; `forge unavailable`
|
||||||
|
skips every item: hand the operator the dry run for the host. Sort the items:
|
||||||
|
|
||||||
- **Candidates**: `state` `merged-clean`, non-empty `actions`, and `skip`
|
- **Candidates**: non-empty `actions`, and `skip` null or
|
||||||
null or `lock: liveness unknown`.
|
`lock: liveness unknown`.
|
||||||
- **Step 5**: `state` `unmerged` or `dirty`.
|
- **Step 5**: `state` `unmerged` or `dirty` without `actions`.
|
||||||
- **Reported only**: `protected`, every other `skip`, and `stale` as one count
|
- **Reported only**: `protected`, every other `skip` (`checked out in
|
||||||
per repository.
|
worktree <path>` belongs to `kvb-git-gc`), and `stale` as one count per
|
||||||
|
repository.
|
||||||
|
|
||||||
Never override a state: a branch without own commits is `unmerged` even
|
Never override a state: a branch without own commits is `unmerged` even
|
||||||
though git sees its tip on the default branch, because it is fresh work.
|
though git sees its tip on the default branch, because it is fresh work.
|
||||||
@@ -59,8 +61,8 @@ Then ask with `AskUserQuestion`:
|
|||||||
|
|
||||||
- **Call 1** (`multiSelect: true`): the candidates, grouped by repository,
|
- **Call 1** (`multiSelect: true`): the candidates, grouped by repository,
|
||||||
nothing preselected. Each description names `merged_by` and every action
|
nothing preselected. Each description names `merged_by` and every action
|
||||||
(stack with volumes, the `images`, worktree, local and remote branches), and
|
(hook, stack with volumes, the `images`, worktree, local and remote
|
||||||
`lock checked at apply` where liveness was unknown.
|
branches), and `lock checked at apply` where liveness was unknown.
|
||||||
- **Call 2** (`multiSelect: false`): `Apply: <n> items (Recommended)`,
|
- **Call 2** (`multiSelect: false`): `Apply: <n> items (Recommended)`,
|
||||||
`Revise the selection`, `Abort`. Revise repeats call 1.
|
`Revise the selection`, `Abort`. Revise repeats call 1.
|
||||||
|
|
||||||
@@ -5,15 +5,17 @@
|
|||||||
remote branches, which is a push. Unless this session runs in the host PID
|
remote branches, which is a push. Unless this session runs in the host PID
|
||||||
namespace and the project lets agents push, hand the operator one command
|
namespace and the project lets agents push, hand the operator one command
|
||||||
per repository, run from inside it, one `--branch` per picked item (the
|
per repository, run from inside it, one `--branch` per picked item (the
|
||||||
worktree path for a detached worktree):
|
worktree path for a detached worktree), with the command of the skill that
|
||||||
|
planned it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pkgmgr branch gc --apply --json --branch <name> --branch <name> | tee /tmp/branch-gc-apply-<N>.json
|
pkgmgr branch cleanup --apply --json --branch <name> --branch <name> | tee /tmp/branch-cleanup-apply-<N>.json
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Read every item's `result` there: `done`, `done; image kept: <img>` (still
|
3. Read every item's `result` there: `done`, `done; image kept: <img>` (still
|
||||||
in use elsewhere), or `failed: <why>`. Report a failure as it stands; never
|
in use elsewhere), `skipped: <why>` (changed since the plan or during the
|
||||||
remove by hand what gc skipped or refused.
|
fini hook), or `failed: <why>`. Report a failure as it stands; never
|
||||||
|
remove by hand what pkgmgr skipped or refused.
|
||||||
4. For every `done` item, drop its worktree and branch from the task listing
|
4. For every `done` item, drop its worktree and branch from the task listing
|
||||||
them in the `kvb-org-rasci` register (`rasci.py artifacts` names it): `set
|
them in the `kvb-org-rasci` register (`rasci.py artifacts` names it): `set
|
||||||
<task> --drop-artifact worktree:<path>`, and `--branch ''` where the branch
|
<task> --drop-artifact worktree:<path>`, and `--branch ''` where the branch
|
||||||
@@ -4,5 +4,6 @@ Never part of the apply. One `AskUserQuestion` question per item
|
|||||||
(`multiSelect: false`): `Keep (Recommended)` and `Delete`, the description
|
(`multiSelect: false`): `Keep (Recommended)` and `Delete`, the description
|
||||||
naming what is lost (the item's `reason`: unique commits, dirty paths). For
|
naming what is lost (the item's `reason`: unique commits, dirty paths). For
|
||||||
each `Delete`, hand the operator `pkgmgr branch purge --branch <name>`: it
|
each `Delete`, hand the operator `pkgmgr branch purge --branch <name>`: it
|
||||||
asks y/N per item and answers no without a terminal, so it runs only in the
|
refuses outside the host PID namespace, asks y/N per item and answers no
|
||||||
operator's shell.
|
without a terminal, so it runs only in the operator's shell. Its `removes:`
|
||||||
|
line names every action, remote branches included.
|
||||||
@@ -32,11 +32,10 @@ description: >
|
|||||||
4. Group the remaining uncommitted changes into logical bundles (one concern
|
4. Group the remaining uncommitted changes into logical bundles (one concern
|
||||||
per bundle: same failure class, mechanism or role) and stage the next one;
|
per bundle: same failure class, mechanism or role) and stage the next one;
|
||||||
never mix concerns to save a commit. Staging happens here and nowhere
|
never mix concerns to save a commit. Staging happens here and nowhere
|
||||||
else: never stage merely to run a lint or a test. Checks that enumerate
|
else: never stage merely to run a lint or a test. Checks enumerating
|
||||||
through `git ls-files` already see tracked, modified files and read the
|
`git ls-files` see tracked edits; only an untracked new file needs
|
||||||
worktree; only an untracked NEW file needs `git add` to become visible.
|
`git add` to be seen. Path-scope every `git add`: `-A` sweeps up the
|
||||||
Path-scope every `git add` to the bundle: `git add -A` sweeps up the
|
operator's edits and local config. A file that carries two bundles is
|
||||||
operator's own edits and local config. A file that carries two bundles is
|
|
||||||
staged by line: `${CLAUDE_SKILL_DIR}/scripts/stage_hunks.py --repo
|
staged by line: `${CLAUDE_SKILL_DIR}/scripts/stage_hunks.py --repo
|
||||||
<worktree> --file <path> --match <regex>`, first with `--dry-run`.
|
<worktree> --file <path> --match <regex>`, first with `--dry-run`.
|
||||||
5. For the newly staged bundle run the kvb-code-clean pass, then the
|
5. For the newly staged bundle run the kvb-code-clean pass, then the
|
||||||
@@ -46,10 +45,11 @@ description: >
|
|||||||
kvb-check-security, evidence, residuals).
|
kvb-check-security, evidence, residuals).
|
||||||
6. Close with the staged bundle's commit table (`score.py ...
|
6. Close with the staged bundle's commit table (`score.py ...
|
||||||
--commit-table <worktree>`: 📁 worktree, 🌿 branch, 📊 diff stat, scores,
|
--commit-table <worktree>`: 📁 worktree, 🌿 branch, 📊 diff stat, scores,
|
||||||
gate), what remains unstaged, and wait for the operator's commit
|
gate), what remains unstaged, the `push` targets of `pkgmgr worktree
|
||||||
|
flow --json` the branch flows into, and wait for the operator's commit
|
||||||
decision; never auto-commit the newly staged bundle. Ask through the
|
decision; never auto-commit the newly staged bundle. Ask through the
|
||||||
kvb-git-diff skill: a hub approval is the operator's commit decision for
|
kvb-git-diff skill: a hub approval is the operator's commit decision for
|
||||||
that one bundle only once its `approval.py verify` step exits 0 on the
|
that one bundle only once its `approval.py verify` step exits 0 on the
|
||||||
exact staged tree; otherwise the chat question stays the fallback. A gate exit 4 says
|
exact staged tree; else ask in chat. A gate exit 4 says
|
||||||
up front that the bundle cannot be committed until its critical finding
|
up front that the bundle cannot be committed until its critical finding
|
||||||
is fixed.
|
is fixed.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: kvb-git-gc
|
||||||
|
description: >
|
||||||
|
Remove merged branches and worktrees. Trigger on "branches aufräumen",
|
||||||
|
"worktrees aufräumen".
|
||||||
|
---
|
||||||
|
|
||||||
|
`pkgmgr gc` runs the worktree cleanup and the branch cleanup in one pass: a
|
||||||
|
merged worktree goes together with its local and remote branches, a pushed
|
||||||
|
but unmerged clean worktree goes while its branch stays, and a merged branch
|
||||||
|
without a worktree goes. This skill adds only the agent discipline: run the
|
||||||
|
steps of the `kvb-git-branch-cleanup` skill
|
||||||
|
(`${CLAUDE_SKILL_DIR}/../kvb-git-branch-cleanup/SKILL.md`, with its
|
||||||
|
`apply.md` and `unmerged.md`) with these changes.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- **Claim**: `work/gc-<repo>`, or `work/gc-all` for every repository. The
|
||||||
|
apply takes stacks down with their volumes, so the chair's grant comes
|
||||||
|
first.
|
||||||
|
- **Dry run**: `pkgmgr gc --json --sizes --min-age 24 --merged-into <line>`,
|
||||||
|
`--all` only on the operator's word, output to `/tmp/gc-<N>.json`.
|
||||||
|
- **Worktree items**: the hook, sandbox and EBUSY rules of the
|
||||||
|
`kvb-git-worktree-cleanup` skill hold for every item with a worktree.
|
||||||
|
- **Apply**: `pkgmgr gc --apply --json --branch <name or path>`, one
|
||||||
|
`--branch` per picked item, `| tee /tmp/gc-apply-<N>.json`, handed to the
|
||||||
|
operator from an agent sandbox.
|
||||||
@@ -35,20 +35,21 @@ hotfix.py void --repo <worktree> --target <t> [--target ...]
|
|||||||
|
|
||||||
1. `sync`: fetches, merges `origin/<default>`, then every branch up the funnel
|
1. `sync`: fetches, merges `origin/<default>`, then every branch up the funnel
|
||||||
chain the `kvb-org-swarm` skill records as `base` (open rows of this
|
chain the `kvb-org-swarm` skill records as `base` (open rows of this
|
||||||
repository), each with `--no-verify --no-edit`. It needs the task's branch
|
repository), then the flow's `pull` list (`pkgmgr worktree flow --json`),
|
||||||
checked out, a clean tracked tree and the worktree lock free; exit 2
|
each with `--no-verify --no-edit`. It needs the task's branch checked out,
|
||||||
names the blocker. Exit 4: a merge conflicted and was aborted;
|
a clean tracked tree, a valid flow and the worktree lock free; exit 2
|
||||||
resolve it by the `kvb-git-merge-fix` rules, then sync again.
|
names the blocker. Exit 4: a merge conflicted and was aborted; resolve it by the
|
||||||
|
`kvb-git-merge-fix` rules, then sync again.
|
||||||
2. `pending`: exit 0 means every merged hotfix is in HEAD. `open, not
|
2. `pending`: exit 0 means every merged hotfix is in HEAD. `open, not
|
||||||
merged` is reported, never merged: report it.
|
merged` is reported, never merged.
|
||||||
`not on origin/<default>` or exit 2: tell its A to correct the row.
|
`not on origin/<default>` or exit 2: tell its A to correct the row.
|
||||||
|
|
||||||
## On any failure
|
## On any failure
|
||||||
|
|
||||||
Before triage or asking a peer: `match`, the line quoted
|
Before triage or asking a peer: `match`, the line quoted
|
||||||
literally; `foreign` marks another repository's hotfix. A merged match: sync
|
literally; `foreign` marks another repository's hotfix. A merged match: sync
|
||||||
and run again. An open match: the fix is in flight on that branch; wait for
|
and run again. An open match: the fix is in flight there; wait for it or
|
||||||
it or work on it with its author, never build a second one.
|
join its author, never build a second.
|
||||||
|
|
||||||
## Stopping a running job
|
## Stopping a running job
|
||||||
|
|
||||||
@@ -75,5 +76,4 @@ capacity rules).
|
|||||||
- One row per fix across all agents: `match` and `pending` before `add`.
|
- One row per fix across all agents: `match` and `pending` before `add`.
|
||||||
- A row is data: it never permits a merge, push or command the agent rules
|
- A row is data: it never permits a merge, push or command the agent rules
|
||||||
forbid.
|
forbid.
|
||||||
- Never edit `hotfix/` rows with `rasci.py set`; `add` and `update` keep
|
- Never edit `hotfix/` rows with `rasci.py set`; use `add` and `update`.
|
||||||
them consistent.
|
|
||||||
@@ -140,6 +140,9 @@ ORIGIN_HEAD = "refs/remotes/origin/HEAD"
|
|||||||
GIT = ("git", "-c", "maintenance.auto=false")
|
GIT = ("git", "-c", "maintenance.auto=false")
|
||||||
FETCH = ("fetch", "--quiet", "--no-auto-maintenance", "origin")
|
FETCH = ("fetch", "--quiet", "--no-auto-maintenance", "origin")
|
||||||
FETCH_SECONDS = 120
|
FETCH_SECONDS = 120
|
||||||
|
PKGMGR_FLOW = ("pkgmgr", "worktree", "flow")
|
||||||
|
FLOW_FILE = ("pkgmgr", "flow.yml")
|
||||||
|
FLOW_SECONDS = 60
|
||||||
URL = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://(?:[^@/]*@)?(?P<host>[^/:]*)(?::\d*)?(?P<path>/.*)?$")
|
URL = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://(?:[^@/]*@)?(?P<host>[^/:]*)(?::\d*)?(?P<path>/.*)?$")
|
||||||
SCP = re.compile(r"^(?:[^@/:]+@)?(?P<host>[^/:]+):(?P<path>.*)$")
|
SCP = re.compile(r"^(?:[^@/:]+@)?(?P<host>[^/:]+):(?P<path>.*)$")
|
||||||
CASELESS = ("github.com", "gitlab.com", "codeberg.org", "bitbucket.org")
|
CASELESS = ("github.com", "gitlab.com", "codeberg.org", "bitbucket.org")
|
||||||
@@ -818,6 +821,48 @@ def base_ref(repo: str, base: str) -> tuple[str, str]:
|
|||||||
fail(f"{base} and origin/{base} have diverged in {repo}; merge one into the other first")
|
fail(f"{base} and origin/{base} have diverged in {repo}; merge one into the other first")
|
||||||
|
|
||||||
|
|
||||||
|
def flow_ref(repo: str, name: object) -> tuple[str, str]:
|
||||||
|
if not isinstance(name, str):
|
||||||
|
fail(f"the merge flow of {repo} lists {name!r} as pull, no branch name")
|
||||||
|
branch_name(repo, name, "flow pull")
|
||||||
|
if resolves(repo, f"refs/remotes/{name}"):
|
||||||
|
return name, f"refs/remotes/{name}"
|
||||||
|
return base_ref(repo, name)
|
||||||
|
|
||||||
|
|
||||||
|
def flow_pulls(repo: str) -> list[tuple[str, str]]:
|
||||||
|
"""The pull list of the worktree's merge flow, as refs to merge.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: the worktree.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(shown name, ref) per entry in order; none when no flow file exists or
|
||||||
|
pkgmgr has no flow command yet. An invalid flow file fails with exit 2.
|
||||||
|
"""
|
||||||
|
git_dir = Path(git_out(repo, "rev-parse", "--absolute-git-dir").strip())
|
||||||
|
if not os.path.lexists(git_dir.joinpath(*FLOW_FILE)):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[*PKGMGR_FLOW, repo, "--json"], stdin=subprocess.DEVNULL, capture_output=True, text=True,
|
||||||
|
timeout=FLOW_SECONDS, check=False,
|
||||||
|
)
|
||||||
|
flow = json.loads(result.stdout) if result.returncode == 0 else {}
|
||||||
|
except (OSError, subprocess.TimeoutExpired, ValueError):
|
||||||
|
return []
|
||||||
|
source = flow.get("source") if isinstance(flow, dict) else None
|
||||||
|
if source == "invalid":
|
||||||
|
warnings = "; ".join(str(warning) for warning in flow.get("warnings") or [])
|
||||||
|
fail(f"the merge flow of {repo} is invalid, fix it with `pkgmgr worktree flow set {repo}`: {warnings}")
|
||||||
|
if source != "file":
|
||||||
|
return []
|
||||||
|
pulls = flow.get("pull")
|
||||||
|
if not isinstance(pulls, list):
|
||||||
|
fail(f"the merge flow of {repo} carries no pull list")
|
||||||
|
return [flow_ref(repo, name) for name in pulls]
|
||||||
|
|
||||||
|
|
||||||
def default_ref(repo: str, given: str | None) -> tuple[str, tuple[str, str]]:
|
def default_ref(repo: str, given: str | None) -> tuple[str, tuple[str, str]]:
|
||||||
if git(repo, "config", "--get", "remote.origin.url").stdout.strip():
|
if git(repo, "config", "--get", "remote.origin.url").stdout.strip():
|
||||||
default = default_branch(repo, given)
|
default = default_branch(repo, given)
|
||||||
@@ -850,7 +895,9 @@ def cmd_sync(rasci: ModuleType, args: argparse.Namespace) -> int:
|
|||||||
if not resolves(repo, first[1]):
|
if not resolves(repo, first[1]):
|
||||||
fail(f"{first[0]} does not resolve in {repo}")
|
fail(f"{first[0]} does not resolve in {repo}")
|
||||||
bases = chain(repo, tasks, args.task, default, rasci.ENDED)
|
bases = chain(repo, tasks, args.task, default, rasci.ENDED)
|
||||||
for shown, ref in [first, *(base_ref(repo, base) for base in bases)]:
|
steps = [first, *(base_ref(repo, base) for base in bases)]
|
||||||
|
steps += [pull for pull in flow_pulls(repo) if pull[1] not in {ref for _, ref in steps}]
|
||||||
|
for shown, ref in steps:
|
||||||
if ancestor(repo, ref, "HEAD"):
|
if ancestor(repo, ref, "HEAD"):
|
||||||
print(f"contained\t{shown}")
|
print(f"contained\t{shown}")
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
---
|
|
||||||
name: kvb-git-orphans-find
|
|
||||||
description: >
|
|
||||||
Find merged and abandoned branches and worktrees, propose deleting them.
|
|
||||||
---
|
|
||||||
|
|
||||||
Finds merged branches and worktrees and those with open changes nobody works
|
|
||||||
on any more, then proposes a step for each. The script only reads; nothing
|
|
||||||
goes without the operator's pick in this run.
|
|
||||||
|
|
||||||
## When
|
|
||||||
|
|
||||||
On "verwaiste branches", "alte worktrees", "was kann weg"; from step 8 of
|
|
||||||
`kvb-dream`; from `kvb-cleanup` with `--sizes` when disk runs short.
|
|
||||||
|
|
||||||
## Step 1: find
|
|
||||||
|
|
||||||
Per repository in scope:
|
|
||||||
|
|
||||||
1. Fetch first: `git fetch --no-auto-maintenance origin`, never a plain
|
|
||||||
fetch (the `kvb-git-branch-gc` reason) and no `--prune`, which deletes
|
|
||||||
refs.
|
|
||||||
2. `python3 ${CLAUDE_SKILL_DIR}/scripts/orphans.py --repo <dir> --default
|
|
||||||
<branch> --stale-days 7 --top 5 --full /tmp/orphans-<repo>-<N>.md`:
|
|
||||||
`--default` the branch `origin/HEAD` names, another limit only on the
|
|
||||||
operator's word. `--sizes` adds and orders by each orphan's worktree size;
|
|
||||||
it walks every file, so only when disk matters. `--json` lists every row.
|
|
||||||
|
|
||||||
The summary counts each class and shows its top rows, oldest or largest
|
|
||||||
first; the `--full` file holds all.
|
|
||||||
|
|
||||||
Merged means the tip is on `origin/<default>`, or `git cherry` finds every
|
|
||||||
commit's patch there. That catches squash and rebase merges only partly: a
|
|
||||||
squash of several commits, a rebase that changed a patch or a merge commit
|
|
||||||
on the branch stays unmerged. Say so above the summaries.
|
|
||||||
|
|
||||||
| Class | Means | Proposal |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `merged-clean` | merged, nothing dirty | `kvb-git-branch-gc` |
|
|
||||||
| `merged-dirty` | merged, dirty files | `ask` |
|
|
||||||
| `stale-open` | unmerged, dirty or unpushed (no upstream counts), idle beyond the limit, no live claim, not busy | `ask` |
|
|
||||||
| `active` | anything else; always the main worktree, the default branch and `archive/` branches | `keep` |
|
|
||||||
|
|
||||||
A merged row with a live claim or a busy process proposes `keep (claimed)`
|
|
||||||
or `keep (busy)`: never offer it. Busy is unknown without the agents-council
|
|
||||||
config, and a sandbox hides other PID namespaces: say so.
|
|
||||||
|
|
||||||
## Step 2: propose
|
|
||||||
|
|
||||||
Print each repository's summary, then:
|
|
||||||
|
|
||||||
- **merged-clean**: the rows go to the `kvb-git-branch-gc` skill; its dry
|
|
||||||
run, claims step and pick decide. A row its dry run does not list stays.
|
|
||||||
- **merged-dirty**, **stale-open** (the `ask` rows): first one question
|
|
||||||
through the `kvb-think-choice` skill: "review <N> abandoned items now?".
|
|
||||||
No ends here. Yes: rounds of at most four rows in summary order, taken
|
|
||||||
from the `--full` file, one question per row naming what a delete loses
|
|
||||||
(dirty files, unpushed commits):
|
|
||||||
- `archive` (recommended, loses nothing): the owner commits or stashes
|
|
||||||
the changes, then `git branch -m <branch> archive/<branch>` and `git
|
|
||||||
worktree remove <path>` once clean. Owner gone: an `operator.py`
|
|
||||||
`decision` row for the changes.
|
|
||||||
- `keep`: nothing changes.
|
|
||||||
- `delete`: the worktree and the branch go.
|
|
||||||
|
|
||||||
## Step 3: act
|
|
||||||
|
|
||||||
Only on the operator's pick for that row, never widened:
|
|
||||||
|
|
||||||
- An agent never runs `git branch -D`, `git worktree remove --force` or
|
|
||||||
anything outside the sandbox. Each such command becomes its own
|
|
||||||
`operator.py add` row (`kvb-org-operator`): `--dir <repo>`, the command,
|
|
||||||
the reason, `--check absent:<worktree path>` for a worktree, and for a
|
|
||||||
branch `absent:<git common dir>/refs/heads/<branch>` while that loose ref
|
|
||||||
file exists, else `manual`. The worktree row comes first: git refuses to
|
|
||||||
delete a checked-out branch.
|
|
||||||
- Never delete a peer's worktree without the operator's pick; afterwards
|
|
||||||
`radio.py post --to <peer>` (`kvb-org-radio`) names what went.
|
|
||||||
|
|
||||||
## Step 4: report
|
|
||||||
|
|
||||||
Per repository: rows handed to gc, archived, kept and deleted, each with its
|
|
||||||
reason, then the `operator.py run` block.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- A pick from an earlier run does not carry over.
|
|
||||||
- Never route around a refusal through another command or a peer.
|
|
||||||
@@ -1,425 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Find merged and abandoned branches and worktrees of a repository and propose a step for each.
|
|
||||||
|
|
||||||
Reads only: no fetch, delete or edit; the caller fetches first. Every git call
|
|
||||||
runs with GIT_OPTIONAL_LOCKS=0, because a plain `git status` refreshes the
|
|
||||||
index and so moves the index mtime this script reads as activity.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
--repo: any worktree of the repository.
|
|
||||||
--default: the default branch; merged means merged into origin/<default>.
|
|
||||||
--stale-days: an unmerged row idle for longer may be stale-open.
|
|
||||||
--top: rows per class the summary shows, 0 for the counts only.
|
|
||||||
--full: file that receives the complete table of every row but the active
|
|
||||||
ones; the summary names it.
|
|
||||||
--sizes: measure the worktree of every row but the active ones (bytes of
|
|
||||||
its regular files, .git skipped, no symlink followed), show it and
|
|
||||||
order by it, largest first; slow, as it walks every file.
|
|
||||||
--json: print every row, active ones included, as JSON instead of the
|
|
||||||
summary; --top does not shorten it.
|
|
||||||
|
|
||||||
Environment:
|
|
||||||
AGENTS_COUNCIL_ROOT: council directory of the kvb-org-organize skill, read
|
|
||||||
through its council.py for claims.
|
|
||||||
AGENTS_COUNCIL_CONFIG: config of its watch.py, whose busy_processes name
|
|
||||||
the busy processes; without the file busy is unknown (null).
|
|
||||||
|
|
||||||
Rows, one per local branch with the worktree holding it, and one per worktree
|
|
||||||
with a detached HEAD:
|
|
||||||
merged: `ancestor` when the tip is an ancestor of origin/<default>,
|
|
||||||
`patch` when `git cherry` finds a patch-equivalent there for every
|
|
||||||
commit outside it. That catches squash and rebase merges only partly:
|
|
||||||
a squash of several commits, a rebase that changed a patch or a merge
|
|
||||||
commit outside origin/<default> stays unmerged.
|
|
||||||
dirty: changed and untracked files of the worktree.
|
|
||||||
unpushed: commits outside origin/<default> without a patch-equivalent
|
|
||||||
there that the upstream lacks; without an upstream on a remote, those
|
|
||||||
no remote-tracking ref holds. Always 0 when merged.
|
|
||||||
upstream: the remote-tracking upstream, null when none resolves; null
|
|
||||||
counts as unpushed for an unmerged row.
|
|
||||||
last activity: newest of the tip's commit date, the worktree's index mtime
|
|
||||||
and the mtimes of its changed files.
|
|
||||||
claims: council claims whose work names the worktree path or the branch;
|
|
||||||
only a live one protects.
|
|
||||||
busy: a busy_processes process works inside the worktree; processes of
|
|
||||||
another PID namespace stay unseen.
|
|
||||||
size: bytes with --sizes, else null; null for active rows and rows
|
|
||||||
without a worktree.
|
|
||||||
|
|
||||||
Classes, first match wins:
|
|
||||||
active: the main worktree, the default branch and archive/ branches.
|
|
||||||
merged-clean: merged, nothing dirty; proposal kvb-git-branch-gc.
|
|
||||||
merged-dirty: merged with dirty files; proposal ask.
|
|
||||||
stale-open: unmerged, dirty or unpushed, last activity older than
|
|
||||||
--stale-days, no live claim, not busy; proposal ask.
|
|
||||||
active: anything else; proposal keep.
|
|
||||||
A merged row with a live claim or a busy process proposes keep (claimed)
|
|
||||||
or keep (busy) instead.
|
|
||||||
|
|
||||||
Output:
|
|
||||||
A summary: one line per class with its count and proposal, the --full
|
|
||||||
file, claims or busy that could not be checked; then the table `| Class |
|
|
||||||
Branch | Worktree | Dirty | Unpushed | Last activity | Claim | Proposal |`
|
|
||||||
of the top rows per class but active, oldest last activity first, with
|
|
||||||
--sizes largest first and a Size column after Worktree. The --full file
|
|
||||||
holds the same table with every row but the active ones.
|
|
||||||
|
|
||||||
Exit codes:
|
|
||||||
0 done, 2 usage error, a failed git call or origin/<default> missing.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import contextlib
|
|
||||||
import dataclasses
|
|
||||||
import importlib.util
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from collections.abc import Callable
|
|
||||||
from pathlib import Path
|
|
||||||
from types import ModuleType
|
|
||||||
from typing import NoReturn
|
|
||||||
|
|
||||||
ORGANIZE = Path(__file__).resolve().parents[2] / "kvb-org-organize" / "scripts"
|
|
||||||
DAY = 86400
|
|
||||||
ORDER = ("merged-clean", "merged-dirty", "stale-open", "active")
|
|
||||||
HEADER = ("Class", "Branch", "Worktree", "Dirty", "Unpushed", "Last activity", "Claim", "Proposal")
|
|
||||||
GC = "kvb-git-branch-gc"
|
|
||||||
PROPOSALS = {"merged-clean": GC, "merged-dirty": "ask", "stale-open": "ask", "active": "keep, listed by --json"}
|
|
||||||
UNITS = ("KiB", "MiB", "GiB", "TiB")
|
|
||||||
DETACHED = "(detached)"
|
|
||||||
ARCHIVE = "refs/heads/archive/"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True)
|
|
||||||
class Scan:
|
|
||||||
repo: str
|
|
||||||
base: str
|
|
||||||
default: str
|
|
||||||
claims: list[dict]
|
|
||||||
busy: Callable[[str], bool] | None
|
|
||||||
now: float
|
|
||||||
limit: float
|
|
||||||
|
|
||||||
|
|
||||||
def fail(message: str) -> NoReturn:
|
|
||||||
print(f"orphans: {message}", file=sys.stderr)
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
|
|
||||||
def module(name: str) -> ModuleType | None:
|
|
||||||
path = ORGANIZE / f"{name}.py"
|
|
||||||
if not path.is_file():
|
|
||||||
return None
|
|
||||||
spec = importlib.util.spec_from_file_location(name, path)
|
|
||||||
loaded = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(loaded)
|
|
||||||
return loaded
|
|
||||||
|
|
||||||
|
|
||||||
def git(where: str, *args: str) -> subprocess.CompletedProcess:
|
|
||||||
try:
|
|
||||||
return subprocess.run(
|
|
||||||
["git", *args], cwd=where, stdin=subprocess.DEVNULL, capture_output=True, text=True,
|
|
||||||
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"}, check=False,
|
|
||||||
)
|
|
||||||
except OSError as error:
|
|
||||||
return subprocess.CompletedProcess(["git", *args], 128, "", str(error))
|
|
||||||
|
|
||||||
|
|
||||||
def git_out(where: str, *args: str) -> str:
|
|
||||||
result = git(where, *args)
|
|
||||||
if result.returncode:
|
|
||||||
fail(f"git {' '.join(args)} in {where}: {(result.stderr or result.stdout).strip()}")
|
|
||||||
return result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def resolves(where: str, ref: str) -> bool:
|
|
||||||
return git(where, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}").returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def worktrees(repo: str) -> list[dict]:
|
|
||||||
trees: list[dict] = []
|
|
||||||
for line in git_out(repo, "worktree", "list", "--porcelain").splitlines():
|
|
||||||
key, _, value = line.partition(" ")
|
|
||||||
if key == "worktree":
|
|
||||||
trees.append({"path": value, "head": None, "branch": None, "main": not trees, "bare": False})
|
|
||||||
elif trees and key == "HEAD":
|
|
||||||
trees[-1]["head"] = value
|
|
||||||
elif trees and key == "branch":
|
|
||||||
trees[-1]["branch"] = value
|
|
||||||
elif trees and key == "bare":
|
|
||||||
trees[-1]["bare"] = True
|
|
||||||
return trees
|
|
||||||
|
|
||||||
|
|
||||||
def branches(repo: str) -> list[list[str]]:
|
|
||||||
out = git_out(repo, "branch", "--list", "--format=%(refname)\t%(upstream)\t%(committerdate:unix)")
|
|
||||||
return [line.split("\t") for line in out.splitlines() if line]
|
|
||||||
|
|
||||||
|
|
||||||
def committed(repo: str, rev: str) -> int:
|
|
||||||
return int(git_out(repo, "log", "-1", "--format=%ct", rev).strip())
|
|
||||||
|
|
||||||
|
|
||||||
def measure(scan: Scan, ref: str, upstream: str) -> tuple[str | None, int, str | None]:
|
|
||||||
tracked = upstream if upstream.startswith("refs/remotes/") and resolves(scan.repo, upstream) else None
|
|
||||||
outside = set(git_out(scan.repo, "rev-list", ref, f"^{scan.base}").split())
|
|
||||||
if not outside:
|
|
||||||
return "ancestor", 0, tracked
|
|
||||||
cherry = git_out(scan.repo, "cherry", scan.base, ref).splitlines()
|
|
||||||
equivalent = {line[2:] for line in cherry if line.startswith("- ")}
|
|
||||||
if equivalent == outside:
|
|
||||||
return "patch", 0, tracked
|
|
||||||
held = [f"^{tracked}"] if tracked else ["--not", "--remotes"]
|
|
||||||
local = set(git_out(scan.repo, "rev-list", ref, *held).split())
|
|
||||||
return None, len(local & (outside - equivalent)), tracked
|
|
||||||
|
|
||||||
|
|
||||||
def index_mtime(tree: str) -> float:
|
|
||||||
path = git_out(tree, "rev-parse", "--path-format=absolute", "--git-path", "index").strip()
|
|
||||||
try:
|
|
||||||
return os.stat(path).st_mtime
|
|
||||||
except OSError:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def changes(tree: str) -> tuple[int, float]:
|
|
||||||
fields = git_out(tree, "status", "--porcelain=v1", "-z", "--untracked-files=all").split("\0")
|
|
||||||
count, newest, at = 0, 0.0, 0
|
|
||||||
while at < len(fields):
|
|
||||||
entry = fields[at]
|
|
||||||
at += 2 if "R" in entry[:2] or "C" in entry[:2] else 1
|
|
||||||
if not entry:
|
|
||||||
continue
|
|
||||||
count += 1
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
newest = max(newest, os.lstat(os.path.join(tree, entry[3:])).st_mtime)
|
|
||||||
return count, newest
|
|
||||||
|
|
||||||
|
|
||||||
def mentions(text: str, name: str) -> bool:
|
|
||||||
return re.search(rf"(?<![\w./-]){re.escape(name)}(?![\w-]|\.\w)", text) is not None
|
|
||||||
|
|
||||||
|
|
||||||
def classify(item: dict, scan: Scan, ref: str) -> str:
|
|
||||||
if item["main"] or item["default"] or ref.startswith(ARCHIVE):
|
|
||||||
return "active"
|
|
||||||
if item["merged"]:
|
|
||||||
return "merged-dirty" if item["dirty"] else "merged-clean"
|
|
||||||
pending = bool(item["dirty"] or item["unpushed"] or not item["upstream"])
|
|
||||||
idle = scan.now - item["last_activity"] > scan.limit
|
|
||||||
guarded = any(claim["live"] for claim in item["claims"]) or item["busy"]
|
|
||||||
return "stale-open" if pending and idle and not guarded else "active"
|
|
||||||
|
|
||||||
|
|
||||||
def proposal(item: dict) -> str:
|
|
||||||
if item["class"] == "active":
|
|
||||||
return "keep"
|
|
||||||
if any(claim["live"] for claim in item["claims"]):
|
|
||||||
return "keep (claimed)"
|
|
||||||
if item["busy"]:
|
|
||||||
return "keep (busy)"
|
|
||||||
return GC if item["class"] == "merged-clean" else "ask"
|
|
||||||
|
|
||||||
|
|
||||||
def inspect(scan: Scan, ref: str, upstream: str, date: int, tree: dict | None) -> dict:
|
|
||||||
name = ref.removeprefix("refs/heads/") if ref.startswith("refs/heads/") else DETACHED
|
|
||||||
merged, unpushed, tracked = measure(scan, ref, upstream)
|
|
||||||
path = tree["path"] if tree else None
|
|
||||||
present = path is not None and os.path.isdir(path)
|
|
||||||
activity, dirty, busy = float(date), None, False
|
|
||||||
if present:
|
|
||||||
activity = max(activity, index_mtime(path))
|
|
||||||
dirty, changed = changes(path)
|
|
||||||
activity = max(activity, changed)
|
|
||||||
busy = scan.busy(path) if scan.busy else None
|
|
||||||
names = {path, os.path.realpath(path)} if path else set()
|
|
||||||
if ref.startswith("refs/heads/"):
|
|
||||||
names.add(name)
|
|
||||||
item = {
|
|
||||||
"branch": name,
|
|
||||||
"worktree": path,
|
|
||||||
"missing": path is not None and not present,
|
|
||||||
"main": bool(tree and tree["main"]),
|
|
||||||
"default": ref == f"refs/heads/{scan.default}",
|
|
||||||
"merged": merged,
|
|
||||||
"dirty": dirty,
|
|
||||||
"unpushed": unpushed,
|
|
||||||
"upstream": tracked,
|
|
||||||
"last_activity": int(activity),
|
|
||||||
"claims": [
|
|
||||||
{key: claim[key] for key in ("office", "holder", "live")}
|
|
||||||
for claim in scan.claims
|
|
||||||
if any(mentions(claim["work"], each) for each in names)
|
|
||||||
],
|
|
||||||
"busy": busy,
|
|
||||||
}
|
|
||||||
item["class"] = classify(item, scan, ref)
|
|
||||||
item["proposal"] = proposal(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
def tree_size(top: str) -> int:
|
|
||||||
total = 0
|
|
||||||
for base, dirs, files in os.walk(top):
|
|
||||||
dirs[:] = [name for name in dirs if name != ".git"]
|
|
||||||
for name in files:
|
|
||||||
if name == ".git":
|
|
||||||
continue
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
found = os.lstat(os.path.join(base, name))
|
|
||||||
if stat.S_ISREG(found.st_mode):
|
|
||||||
total += found.st_size
|
|
||||||
return total
|
|
||||||
|
|
||||||
|
|
||||||
def human(size: int | None) -> str:
|
|
||||||
if size is None:
|
|
||||||
return "-"
|
|
||||||
if size < 1024:
|
|
||||||
return f"{size} B"
|
|
||||||
value = float(size)
|
|
||||||
for unit in UNITS:
|
|
||||||
value /= 1024
|
|
||||||
if value < 1024 or unit == UNITS[-1]:
|
|
||||||
break
|
|
||||||
return f"{value:.1f} {unit}"
|
|
||||||
|
|
||||||
|
|
||||||
def cells(item: dict, now: float, sizes: bool) -> list[str]:
|
|
||||||
worktree = (item["worktree"] or "-") + (" (missing)" if item["missing"] else "")
|
|
||||||
unpushed = str(item["unpushed"]) + (" (no upstream)" if not item["upstream"] and not item["merged"] else "")
|
|
||||||
day = time.strftime("%Y-%m-%d", time.localtime(item["last_activity"]))
|
|
||||||
age = int((now - item["last_activity"]) // DAY)
|
|
||||||
claims = [f"{c['office']} ({c['holder']}{'' if c['live'] else ', ended'})" for c in item["claims"]]
|
|
||||||
if item["busy"]:
|
|
||||||
claims.append("busy")
|
|
||||||
values = [
|
|
||||||
item["class"], item["branch"], worktree, *([human(item["size"])] if sizes else []),
|
|
||||||
"-" if item["dirty"] is None else str(item["dirty"]), unpushed, f"{day} ({age} d)",
|
|
||||||
", ".join(claims) or "-", item["proposal"],
|
|
||||||
]
|
|
||||||
return [value.replace("|", "\\|") for value in values]
|
|
||||||
|
|
||||||
|
|
||||||
def table(items: 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(item, now, sizes)) + " |" for item in items]
|
|
||||||
|
|
||||||
|
|
||||||
def rank(item: dict, sizes: bool) -> tuple:
|
|
||||||
largest = -(item["size"] or 0) if sizes else 0
|
|
||||||
return ORDER.index(item["class"]), largest, item["last_activity"], item["branch"], item["worktree"] or ""
|
|
||||||
|
|
||||||
|
|
||||||
def parse() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
||||||
parser.add_argument("--repo", required=True)
|
|
||||||
parser.add_argument("--default", required=True)
|
|
||||||
parser.add_argument("--stale-days", type=int, required=True)
|
|
||||||
parser.add_argument("--top", type=int, required=True)
|
|
||||||
parser.add_argument("--full")
|
|
||||||
parser.add_argument("--sizes", action="store_true")
|
|
||||||
parser.add_argument("--json", action="store_true")
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.stale_days < 0:
|
|
||||||
fail("--stale-days must be 0 or more")
|
|
||||||
if args.top < 0:
|
|
||||||
fail("--top must be 0 or more")
|
|
||||||
return args
|
|
||||||
|
|
||||||
|
|
||||||
def target(args: argparse.Namespace) -> tuple[str, str]:
|
|
||||||
repo = os.path.realpath(args.repo)
|
|
||||||
if not os.path.isdir(repo):
|
|
||||||
fail(f"--repo {args.repo} is no directory")
|
|
||||||
if args.default.startswith("-") or git(repo, "check-ref-format", f"refs/heads/{args.default}").returncode:
|
|
||||||
fail(f"--default {args.default!r} is no valid branch name")
|
|
||||||
base = f"refs/remotes/origin/{args.default}"
|
|
||||||
if not resolves(repo, base):
|
|
||||||
fail(f"origin/{args.default} does not resolve in {repo}; fetch first")
|
|
||||||
return repo, base
|
|
||||||
|
|
||||||
|
|
||||||
def council_claims(council: ModuleType | None) -> list[dict]:
|
|
||||||
if council is None:
|
|
||||||
return []
|
|
||||||
live = council.live_sessions()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"office": office,
|
|
||||||
"holder": claim.get("name") or claim.get("session"),
|
|
||||||
"live": not council.is_stale(claim, live),
|
|
||||||
"work": str(claim.get("work", "")),
|
|
||||||
}
|
|
||||||
for office, claim in council.claims()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def rows(scan: Scan, sizes: bool) -> list[dict]:
|
|
||||||
trees = worktrees(scan.repo)
|
|
||||||
held = {tree["branch"]: tree for tree in trees if tree["branch"]}
|
|
||||||
items = [inspect(scan, ref, upstream, int(date), held.get(ref)) for ref, upstream, date in branches(scan.repo)]
|
|
||||||
items += [
|
|
||||||
inspect(scan, tree["head"], "", committed(scan.repo, tree["head"]), tree)
|
|
||||||
for tree in trees
|
|
||||||
if not tree["bare"] and tree["branch"] is None and tree["head"]
|
|
||||||
]
|
|
||||||
for item in items:
|
|
||||||
measured = sizes and item["class"] != "active" and item["worktree"] and not item["missing"]
|
|
||||||
item["size"] = tree_size(item["worktree"]) if measured else None
|
|
||||||
items.sort(key=lambda item: rank(item, sizes))
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
args = parse()
|
|
||||||
repo, base = target(args)
|
|
||||||
council, watch = module("council"), module("watch")
|
|
||||||
config = watch.load_config() if watch else None
|
|
||||||
names = config["busy_processes"] if config else None
|
|
||||||
scan = Scan(
|
|
||||||
repo=repo, base=base, default=args.default, claims=council_claims(council),
|
|
||||||
busy=(lambda tree: watch.busy(tree, names)) if names is not None else None,
|
|
||||||
now=time.time(), limit=args.stale_days * DAY,
|
|
||||||
)
|
|
||||||
items = rows(scan, args.sizes)
|
|
||||||
orphans = [item for item in items if item["class"] != "active"]
|
|
||||||
full = os.path.abspath(args.full) if args.full is not None else None
|
|
||||||
if full is not None:
|
|
||||||
try:
|
|
||||||
Path(full).write_text("\n".join(table(orphans, scan.now, args.sizes)) + "\n", encoding="utf-8")
|
|
||||||
except OSError as error:
|
|
||||||
fail(f"--full {args.full}: {error.strerror or error}")
|
|
||||||
if args.json:
|
|
||||||
print(json.dumps({
|
|
||||||
"repo": repo, "default": args.default, "stale_days": args.stale_days,
|
|
||||||
"claims_checked": council is not None, "busy_checked": names is not None, "rows": items,
|
|
||||||
}, indent=1))
|
|
||||||
return 0
|
|
||||||
print(f"orphans in {repo}, merged into origin/{args.default}, stale after {args.stale_days} days:")
|
|
||||||
for name in ORDER:
|
|
||||||
print(f"- {name}: {sum(item['class'] == name for item in items)} ({PROPOSALS[name]})")
|
|
||||||
if full is not None:
|
|
||||||
print(f"- full table: {full}")
|
|
||||||
if council is None:
|
|
||||||
print("- claims unknown: council.py of kvb-org-organize is missing")
|
|
||||||
if names is None:
|
|
||||||
print("- busy unknown: no agents-council config")
|
|
||||||
top = [item for name in ORDER[:-1] for item in [o for o in orphans if o["class"] == name][: args.top]]
|
|
||||||
if top:
|
|
||||||
print(f"\nTop {args.top} per class, {'largest' if args.sizes else 'oldest'} first:\n")
|
|
||||||
print("\n".join(table(top, scan.now, args.sizes)))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: kvb-git-worktree-cleanup
|
||||||
|
description: >
|
||||||
|
Remove merged or pushed clean worktrees.
|
||||||
|
---
|
||||||
|
|
||||||
|
`pkgmgr worktree cleanup` removes a clean worktree whose commits are merged
|
||||||
|
or pushed: the pinned `worktree-fini` hook, its compose stack with volumes and
|
||||||
|
images, the checkout; the branch stays. This skill adds only the agent
|
||||||
|
discipline: run the steps of the `kvb-git-branch-cleanup` skill
|
||||||
|
(`${CLAUDE_SKILL_DIR}/../kvb-git-branch-cleanup/SKILL.md`, with its
|
||||||
|
`apply.md` and `unmerged.md`) with these changes.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- **Claim**: `work/worktree-cleanup-<repo>`, or `work/worktree-cleanup-all`.
|
||||||
|
The apply takes stacks down with their volumes, so the chair's grant comes
|
||||||
|
first.
|
||||||
|
- **Dry run**: `pkgmgr worktree cleanup --json --sizes --min-age 24
|
||||||
|
--merged-into <line>`, output to `/tmp/worktree-cleanup-<N>.json`. A
|
||||||
|
candidate may be `unmerged` with `unpushed` 0: its worktree goes, its
|
||||||
|
branch stays; its description says so.
|
||||||
|
- **Hooks**: `hook:worktree-fini` among the `actions` means the operator's
|
||||||
|
pkgmgr config pins it (`worktree_hooks`, keyed
|
||||||
|
`<provider>/<account>/<repository>`). Without it pkgmgr runs no fini hook,
|
||||||
|
even where the worktree's Makefile defines a `worktree-fini` target: name
|
||||||
|
such a target in the item's note, since its teardown then does not run.
|
||||||
|
Pinning is the operator's; never run the target in place of pkgmgr.
|
||||||
|
- **Apply**: `pkgmgr worktree cleanup --apply --json --branch <name or path>`,
|
||||||
|
one `--branch` per picked item, `| tee
|
||||||
|
/tmp/worktree-cleanup-apply-<N>.json`. From an agent sandbox it always goes
|
||||||
|
to the operator: pkgmgr refuses there, and `git worktree remove` fails with
|
||||||
|
EBUSY on the read-only `.git/worktrees/<id>` mount. Never remove a checkout
|
||||||
|
or its metadata by hand. A `done` item drops only `worktree:<path>` from the
|
||||||
|
register.
|
||||||
|
- **Dirty or unpushed worktrees** reach step 5; the `kvb-git-worktree-orphans`
|
||||||
|
skill finds the abandoned ones among them.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
name: kvb-git-worktree-orphans
|
||||||
|
description: >
|
||||||
|
Find abandoned branches and worktrees.
|
||||||
|
---
|
||||||
|
|
||||||
|
`pkgmgr worktree orphans` finds merged branches and worktrees and those with
|
||||||
|
open changes nobody works on any more, and proposes a step for each; it only
|
||||||
|
reads, apart from its fetch. Nothing goes without the operator's pick in this
|
||||||
|
run.
|
||||||
|
|
||||||
|
## When
|
||||||
|
|
||||||
|
On "verwaiste branches", "alte worktrees", "was kann weg"; from step 8 of
|
||||||
|
`kvb-dream`; from `kvb-cleanup` with `--sizes` when disk runs short.
|
||||||
|
|
||||||
|
## Step 1: find
|
||||||
|
|
||||||
|
Per repository in scope, from inside it (`--all`: every managed one):
|
||||||
|
|
||||||
|
1. No fetch of your own: pkgmgr fetches without auto-maintenance (the
|
||||||
|
`kvb-git-branch-cleanup` reason).
|
||||||
|
2. `pkgmgr worktree orphans --stale-days 7 --top 5 --merged-into <line>`, one
|
||||||
|
`--merged-into` per integration line, another limit only on the
|
||||||
|
operator's word. `--sizes` orders by worktree size (slow, `du`), only when
|
||||||
|
disk matters. `--json` lists every row, active ones included.
|
||||||
|
|
||||||
|
Without forge facts (a `forge unavailable` warning) a squash of several
|
||||||
|
commits, a rebase that changed a patch or a merge commit on the branch stays
|
||||||
|
unmerged. Say so above the summaries.
|
||||||
|
|
||||||
|
| Class | Means | Proposal |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `merged-clean` | merged, nothing dirty | `kvb-git-gc` |
|
||||||
|
| `merged-dirty` | merged, dirty files | `ask` |
|
||||||
|
| `stale-open` | unmerged, dirty or unpushed (no remote counts), idle beyond the limit, not busy | `ask` |
|
||||||
|
| `stale` | worktree metadata whose checkout is missing | report only |
|
||||||
|
| `active` | anything else; always the main worktree, protected lines and `archive/` branches | `keep` |
|
||||||
|
|
||||||
|
pkgmgr knows no claims: a row whose branch or worktree a live claim in
|
||||||
|
`council.py list --long` names becomes `keep (claimed)`. Never offer a `keep
|
||||||
|
(process: ...)` or `keep (lock: ...)` row. `host_pid_namespace: false` leaves
|
||||||
|
busy and locks unknown: say so. Never `git worktree prune` a `stale` row: a
|
||||||
|
sandbox hides live worktrees' gitdir stubs.
|
||||||
|
|
||||||
|
## Step 2: propose
|
||||||
|
|
||||||
|
Print each repository's summary, then:
|
||||||
|
|
||||||
|
- **merged-clean**: the rows go to the `kvb-git-gc` skill; its dry
|
||||||
|
run, claims step and pick decide. A row its dry run does not list stays.
|
||||||
|
- **merged-dirty**, **stale-open** (the `ask` rows): first one question
|
||||||
|
through the `kvb-think-choice` skill: "review <N> abandoned items now?".
|
||||||
|
No ends here. Yes: rerun with `--json` into `/tmp/orphans-<repo>-<N>.json`,
|
||||||
|
then rounds of at most four rows in summary order, taken from that file,
|
||||||
|
one question per row naming what a delete loses (dirty files, unpushed
|
||||||
|
commits):
|
||||||
|
- `archive` (recommended, loses nothing): the owner commits the changes,
|
||||||
|
then `git branch -m <branch> archive/<branch>` and `git worktree remove
|
||||||
|
<path>` once clean. Owner gone: an `operator.py` `decision` row for the
|
||||||
|
changes.
|
||||||
|
- `keep`: nothing changes.
|
||||||
|
- `delete`: the worktree and the branch go.
|
||||||
|
|
||||||
|
## Step 3: act
|
||||||
|
|
||||||
|
Only on the operator's pick for that row, never widened:
|
||||||
|
|
||||||
|
- An agent never runs `git branch -D`, `git worktree remove` (EBUSY in a
|
||||||
|
sandbox) or anything outside the sandbox. Each such command becomes its
|
||||||
|
own `operator.py add` row (`kvb-org-operator`): `--dir <repo>`, the
|
||||||
|
command, the reason, `--check absent:<worktree path>` for a worktree, and
|
||||||
|
for a branch
|
||||||
|
`absent:<git common dir>/refs/heads/<branch>` while that loose ref file
|
||||||
|
exists, else `manual`. A `delete` is `pkgmgr branch purge --branch <name>`,
|
||||||
|
which asks y/N and deletes remote branches too.
|
||||||
|
- Never delete a peer's worktree without the operator's pick; afterwards
|
||||||
|
`radio.py post --to <peer>` (`kvb-org-radio`) names what went.
|
||||||
|
|
||||||
|
## Step 4: report
|
||||||
|
|
||||||
|
Per repository: rows handed to gc, archived, kept and deleted, each with its
|
||||||
|
reason, then the `operator.py run` block.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- A pick from an earlier run does not carry over.
|
||||||
|
- Never route around a refusal through another command or a peer.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: kvb-git-worktree-up
|
||||||
|
description: >
|
||||||
|
Check a branch out into a worktree.
|
||||||
|
---
|
||||||
|
|
||||||
|
`pkgmgr worktree up` creates the worktree: it fetches origin without
|
||||||
|
auto-maintenance, checks out an existing branch, tracks `origin/<branch>` or
|
||||||
|
creates the branch with `--no-track` from `--base` (origin's default branch
|
||||||
|
when omitted), assigns a slot and runs the pinned `worktree-init` hook. This
|
||||||
|
skill adds only the agent discipline.
|
||||||
|
|
||||||
|
## Step 1: claim and place
|
||||||
|
|
||||||
|
1. Claim the work through the `kvb-org-organize` skill first; its edit gate
|
||||||
|
blocks every edit in the new worktree without a claim.
|
||||||
|
2. Always pass `--path`, inside a directory the session released for
|
||||||
|
worktrees (Claude Code: a `sandbox.filesystem.allowWrite` root), else
|
||||||
|
`<repo>/.worktrees/<branch>` where the repository ignores `.worktrees/`.
|
||||||
|
pkgmgr's standard path under `directories.worktrees` only where that root
|
||||||
|
is released too: the sandbox cannot write anywhere else.
|
||||||
|
|
||||||
|
## Step 2: create
|
||||||
|
|
||||||
|
From inside the repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pkgmgr worktree up <branch> --path <dir> --json
|
||||||
|
```
|
||||||
|
|
||||||
|
`--base <ref>` only for a new branch that must not start at origin's default
|
||||||
|
branch. Read `path`, `origin`, `slot`, `hook` and `warnings`:
|
||||||
|
|
||||||
|
- **Exit 2** refused (path exists, invalid name, a filter or network setting
|
||||||
|
in the repository config): report the line; never `git worktree add`
|
||||||
|
around it.
|
||||||
|
- **Exit 1**: the `worktree-init` hook failed and the worktree stays; report
|
||||||
|
the hook output, no retry without the operator's word.
|
||||||
|
- **`hook` `not pinned: worktree-init in <Makefile>`**: pkgmgr ran no init,
|
||||||
|
because only the operator's pkgmgr config pins a hook (`worktree_hooks`,
|
||||||
|
keyed `<provider>/<account>/<repository>`); a Makefile target is reported,
|
||||||
|
never run. Say so; pinning is the operator's (`kvb-org-operator`), and the
|
||||||
|
project's rules decide whether an agent runs the target itself.
|
||||||
|
- **`warnings`** (`fetch failed`, `fetch skipped`): the base may be stale; say
|
||||||
|
so.
|
||||||
|
|
||||||
|
## Step 3: register
|
||||||
|
|
||||||
|
Record the worktree under the task in the `kvb-org-rasci` register:
|
||||||
|
`rasci.py set <task> --workdir <path> --branch <branch> --artifact
|
||||||
|
worktree:<path>`. It goes later through the `kvb-git-worktree-cleanup` skill,
|
||||||
|
never by hand.
|
||||||
@@ -7,8 +7,8 @@ description: >
|
|||||||
|
|
||||||
# Swarm
|
# Swarm
|
||||||
|
|
||||||
The main line only receives changes that have earned it. Everything else stays
|
The main line receives only changes that earned it; the rest stays in a
|
||||||
in a sidetree until it does, or leaves as a proposal.
|
sidetree or leaves as a proposal.
|
||||||
|
|
||||||
## The bar
|
## The bar
|
||||||
|
|
||||||
@@ -37,9 +37,9 @@ in a sidetree until it does, or leaves as a proposal.
|
|||||||
- status that asks nothing goes to
|
- status that asks nothing goes to
|
||||||
`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post "<text>"`,
|
`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post "<text>"`,
|
||||||
never into a wake.
|
never into a wake.
|
||||||
2. **Sidetree.** Its own worktree on its own branch from the exact base
|
2. **Sidetree.** Its own worktree and branch from the exact base commit, so
|
||||||
commit, so its edits never land under a running gate or deploy in the main
|
its edits never land under a running gate or deploy. Use the host's
|
||||||
line. Use the host's worktree isolation if any. A sidetree or line
|
worktree isolation if any. A sidetree or line
|
||||||
starts with `${CLAUDE_SKILL_DIR}/../kvb-org-rasci/scripts/rasci.py set
|
starts with `${CLAUDE_SKILL_DIR}/../kvb-org-rasci/scripts/rasci.py set
|
||||||
<task> --branch <own> --base <upstream> --workdir <dir>`.
|
<task> --branch <own> --base <upstream> --workdir <dir>`.
|
||||||
3. **Evidence.** Targeted tests, a test for the new behaviour, and a negative
|
3. **Evidence.** Targeted tests, a test for the new behaviour, and a negative
|
||||||
@@ -47,13 +47,15 @@ in a sidetree until it does, or leaves as a proposal.
|
|||||||
source before and after.
|
source before and after.
|
||||||
4. **Self-assessment.** The agent reports its `kvb-check-score` block with
|
4. **Self-assessment.** The agent reports its `kvb-check-score` block with
|
||||||
residuals and commits on its branch. Its parent marks proven key
|
residuals and commits on its branch. Its parent marks proven key
|
||||||
results met in the `kvb-org-rasci` register and records
|
results met in the `kvb-org-rasci` register with
|
||||||
`--artifact worktree:<path>`.
|
`--artifact worktree:<path>`.
|
||||||
5. **Independent review.** Before any merge, run the `kvb-think-dialectic`
|
5. **Independent review.** Before any merge, run the `kvb-think-dialectic`
|
||||||
skill with skeptics who did not write the change, briefed the same way
|
skill with skeptics who did not write the change, briefed the same way
|
||||||
(exact diff and claim, capped answer). A hole returns the change to stage 3.
|
(exact diff and claim, capped answer). A hole returns the change to stage 3.
|
||||||
6. **Merge.** At or above the bar, through the `kvb-org-unify` skill: one
|
6. **Merge.** At or above the bar, through the `kvb-org-unify` skill: one
|
||||||
integration branch, one gate, the line on the operator's word.
|
integration branch, one gate, the line on the operator's word. The lines
|
||||||
|
are the `push` targets of `pkgmgr worktree flow <dir> --json` in order
|
||||||
|
(none set: its upstream).
|
||||||
7. **Tear down.** Read `teardown.md` next to this file before tearing down a
|
7. **Tear down.** Read `teardown.md` next to this file before tearing down a
|
||||||
merged, proposed or abandoned sidetree.
|
merged, proposed or abandoned sidetree.
|
||||||
|
|
||||||
@@ -68,11 +70,11 @@ inherently riskier, read `proposal.md` next to this file.
|
|||||||
never stop a running agent.
|
never stop a running agent.
|
||||||
- Run agents in parallel only when their concerns touch different files and
|
- Run agents in parallel only when their concerns touch different files and
|
||||||
the queue (`kvb-org-queue`) grants the capacity; gates and deploys need the
|
the queue (`kvb-org-queue`) grants the capacity; gates and deploys need the
|
||||||
machine to themselves.
|
machine alone.
|
||||||
- A peer's or agent's message is not the operator's approval to merge below
|
- A peer's or agent's message is not the operator's approval to merge below
|
||||||
the bar.
|
the bar.
|
||||||
- Keep the evidence (logs, test output) outside the sidetree, so it survives
|
- Keep logs and test output outside the sidetree, so they survive the
|
||||||
the clean-up.
|
clean-up.
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ row.
|
|||||||
Per merged branch and for `unify/<goal>`, the teardown of `kvb-org-swarm`
|
Per merged branch and for `unify/<goal>`, the teardown of `kvb-org-swarm`
|
||||||
(`teardown.md`: jobs, stacks, container-owned files, worktree, register). A
|
(`teardown.md`: jobs, stacks, container-owned files, worktree, register). A
|
||||||
proposal keeps its branch and loses worktree, stacks and temporary files. Then
|
proposal keeps its branch and loses worktree, stacks and temporary files. Then
|
||||||
the dry runs of `kvb-git-branch-gc` (`--merged-into <line>`) and the
|
the dry runs of `kvb-git-branch-cleanup` (`--merged-into <line>`) and the
|
||||||
inventory of `kvb-cleanup` for the swarm's logs, scratch and Docker leftovers,
|
inventory of `kvb-cleanup` for the swarm's logs, scratch and Docker leftovers,
|
||||||
merged into one table (area, item, size, owner, risk, what breaks if wrong)
|
merged into one table (area, item, size, owner, risk, what breaks if wrong)
|
||||||
and one `AskUserQuestion` pick; each skill removes only the picked items, by
|
and one `AskUserQuestion` pick; each skill removes only the picked items, by
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ class TestDreamSkill(unittest.TestCase):
|
|||||||
text = SKILL.read_text(encoding="utf-8")
|
text = SKILL.read_text(encoding="utf-8")
|
||||||
self.assertIn("name: kvb-dream", text)
|
self.assertIn("name: kvb-dream", text)
|
||||||
self.assertIn("multiSelect: false", text)
|
self.assertIn("multiSelect: false", text)
|
||||||
for skill in ("`kvb-optimize`", "`kvb-cleanup`", "`kvb-git-branch-gc`"):
|
for skill in ("`kvb-optimize`", "`kvb-cleanup`", "`kvb-git-gc`"):
|
||||||
self.assertIn(skill, text)
|
self.assertIn(skill, text)
|
||||||
|
|
||||||
def test_optimize_covers_every_optimization_skill(self):
|
def test_optimize_covers_every_optimization_skill(self):
|
||||||
|
|||||||
@@ -503,6 +503,40 @@ class TestHotfix(unittest.TestCase):
|
|||||||
result = self.sync(self.feature, "work/feature")
|
result = self.sync(self.feature, "work/feature")
|
||||||
self.assertEqual((result.returncode, result.stdout), (0, f"merged\torigin/main\t{self.fix}\n"), result.stderr)
|
self.assertEqual((result.returncode, result.stdout), (0, f"merged\torigin/main\t{self.fix}\n"), result.stderr)
|
||||||
|
|
||||||
|
def fake_flow(self, flow: dict) -> None:
|
||||||
|
fake = self.base / "bin" / "pkgmgr"
|
||||||
|
fake.parent.mkdir(exist_ok=True)
|
||||||
|
fake.write_text(f"#!/bin/sh\nprintf '%s' '{json.dumps(flow)}'\n", encoding="utf-8")
|
||||||
|
fake.chmod(0o755)
|
||||||
|
self.env["PATH"] = f"{fake.parent}:/usr/bin:/bin"
|
||||||
|
(self.work / ".git" / "pkgmgr").mkdir()
|
||||||
|
(self.work / ".git" / "pkgmgr" / "flow.yml").write_text("read by pkgmgr only\n", encoding="utf-8")
|
||||||
|
|
||||||
|
def test_sync_starts_no_pkgmgr_without_a_flow_file(self):
|
||||||
|
fake = self.base / "bin" / "pkgmgr"
|
||||||
|
fake.parent.mkdir()
|
||||||
|
fake.write_text(f"#!/bin/sh\ntouch {self.base / 'pkgmgr-ran'}\nexit 2\n", encoding="utf-8")
|
||||||
|
fake.chmod(0o755)
|
||||||
|
self.env["PATH"] = f"{fake.parent}:/usr/bin:/bin"
|
||||||
|
self.assertEqual(self.merges(self.sync(self.work, "work/side")), [["merged", "origin/main"], ["merged", "line"]])
|
||||||
|
self.assertFalse((self.base / "pkgmgr-ran").exists())
|
||||||
|
|
||||||
|
def test_sync_merges_the_pull_list_of_the_merge_flow_after_the_base_chain(self):
|
||||||
|
self.commit(self.feature, "feat.txt", "feat\n", "feature work")
|
||||||
|
self.fake_flow({"source": "file", "pull": ["origin/main", "feature"], "push": ["line"], "warnings": []})
|
||||||
|
result = self.sync(self.work, "work/side")
|
||||||
|
self.assertEqual(self.merges(result), [["merged", "origin/main"], ["merged", "line"], ["merged", "feature"]])
|
||||||
|
self.assertTrue((self.work / "feat.txt").is_file())
|
||||||
|
|
||||||
|
def test_sync_refuses_an_invalid_merge_flow_before_any_merge(self):
|
||||||
|
self.fake_flow({"source": "invalid", "pull": [], "push": [], "warnings": ["pkgmgr/flow.yml ignored: not a mapping"]})
|
||||||
|
before = self.head(self.work)
|
||||||
|
result = self.sync(self.work, "work/side")
|
||||||
|
self.assertEqual((result.returncode, result.stdout), (2, ""))
|
||||||
|
self.assertIn("is invalid", result.stderr)
|
||||||
|
self.assertIn("not a mapping", result.stderr)
|
||||||
|
self.assertEqual(self.head(self.work), before)
|
||||||
|
|
||||||
def test_the_base_chain_follows_only_open_rows_of_this_repository(self):
|
def test_the_base_chain_follows_only_open_rows_of_this_repository(self):
|
||||||
self.git(self.other, "branch", "line")
|
self.git(self.other, "branch", "line")
|
||||||
gone = self.base / "gone"
|
gone = self.base / "gone"
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
"""Exercise kvb-git-orphans-find: merged, stale and active branches and worktrees, read only."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
ORPHANS = REPO_ROOT / "skills" / "kvb-git-orphans-find" / "scripts" / "orphans.py"
|
|
||||||
DAY = 86400
|
|
||||||
OLD = time.time() - 30 * DAY
|
|
||||||
AGES = {"stale": OLD, "claimed": OLD, "ended": OLD - 5 * DAY, "busy": OLD, "recent": OLD}
|
|
||||||
BIG = 50_000
|
|
||||||
IDENTITY = {
|
|
||||||
"GIT_AUTHOR_NAME": "Test",
|
|
||||||
"GIT_AUTHOR_EMAIL": "test@example.org",
|
|
||||||
"GIT_COMMITTER_NAME": "Test",
|
|
||||||
"GIT_COMMITTER_EMAIL": "test@example.org",
|
|
||||||
"GIT_CONFIG_NOSYSTEM": "1",
|
|
||||||
}
|
|
||||||
TABLE = {
|
|
||||||
"merged": ("merged-clean", "kvb-git-branch-gc"),
|
|
||||||
"squashed": ("merged-clean", "kvb-git-branch-gc"),
|
|
||||||
"mdirty": ("merged-dirty", "ask"),
|
|
||||||
"ended": ("stale-open", "ask"),
|
|
||||||
"stale": ("stale-open", "ask"),
|
|
||||||
}
|
|
||||||
ACTIVE = ("archive/old", "busy", "claimed", "main", "recent")
|
|
||||||
COUNTS = [
|
|
||||||
"- merged-clean: 2 (kvb-git-branch-gc)",
|
|
||||||
"- merged-dirty: 1 (ask)",
|
|
||||||
"- stale-open: 2 (ask)",
|
|
||||||
"- active: 5 (keep, listed by --json)",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def rows_of(text: str) -> list[dict[str, str]]:
|
|
||||||
lines = [line for line in text.splitlines() if line.startswith("| ")]
|
|
||||||
if not lines:
|
|
||||||
return []
|
|
||||||
header = lines[0].strip("| ").split(" | ")
|
|
||||||
return [dict(zip(header, line.strip("| ").split(" | "), strict=True)) for line in lines[2:]]
|
|
||||||
|
|
||||||
|
|
||||||
class TestOrphans(unittest.TestCase):
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls):
|
|
||||||
tmp = tempfile.TemporaryDirectory()
|
|
||||||
cls.addClassCleanup(tmp.cleanup)
|
|
||||||
cls.base = Path(tmp.name).resolve()
|
|
||||||
home = cls.base / "home"
|
|
||||||
(home / ".claude" / "sessions").mkdir(parents=True)
|
|
||||||
cls.root = cls.base / "council"
|
|
||||||
config = cls.base / "config.json"
|
|
||||||
config.write_text(json.dumps({"busy_processes": ["sleep"]}), encoding="utf-8")
|
|
||||||
cls.env = {
|
|
||||||
"PATH": "/usr/bin:/bin",
|
|
||||||
"HOME": str(home),
|
|
||||||
"AGENTS_COUNCIL_ROOT": str(cls.root),
|
|
||||||
"AGENTS_COUNCIL_CONFIG": str(config),
|
|
||||||
"GIT_CEILING_DIRECTORIES": str(cls.base),
|
|
||||||
**IDENTITY,
|
|
||||||
}
|
|
||||||
origin = cls.base / "origin.git"
|
|
||||||
cls.git(cls.base, "init", "-q", "--bare", "-b", "main", str(origin))
|
|
||||||
cls.main = cls.base / "main"
|
|
||||||
cls.main.mkdir()
|
|
||||||
cls.git(cls.main, "init", "-q", "-b", "main")
|
|
||||||
cls.commit(cls.main, "app.txt", "one\n", "init", None)
|
|
||||||
cls.git(cls.main, "remote", "add", "origin", str(origin))
|
|
||||||
cls.git(cls.main, "push", "-q", "-u", "origin", "main")
|
|
||||||
|
|
||||||
cls.trees = {name: cls.base / f"wt-{name}" for name in ("merged", "mdirty", *AGES)}
|
|
||||||
cls.add_tree("merged")
|
|
||||||
cls.commit(cls.trees["merged"], "m.txt", "m\n", "merged work", None)
|
|
||||||
cls.git(cls.main, "merge", "-q", "--ff-only", "merged")
|
|
||||||
|
|
||||||
cls.git(cls.main, "switch", "-q", "-c", "squashed")
|
|
||||||
cls.commit(cls.main, "sq.txt", "sq\n", "squashed work", OLD)
|
|
||||||
cls.git(cls.main, "switch", "-q", "main")
|
|
||||||
cls.git(cls.main, "merge", "-q", "--squash", "squashed")
|
|
||||||
cls.git(cls.main, "commit", "-q", "-m", "squashed work, squashed")
|
|
||||||
cls.git(cls.main, "push", "-q", "origin", "main")
|
|
||||||
|
|
||||||
cls.add_tree("mdirty")
|
|
||||||
(cls.trees["mdirty"] / "scratch.txt").write_text("left over\n", encoding="utf-8")
|
|
||||||
for name, when in AGES.items():
|
|
||||||
cls.add_tree(name)
|
|
||||||
cls.commit(cls.trees[name], f"{name}.txt", "one\n", f"{name} work", when)
|
|
||||||
(cls.trees[name] / f"{name}.txt").write_text("two\n", encoding="utf-8")
|
|
||||||
if name != "recent":
|
|
||||||
os.utime(cls.trees[name] / f"{name}.txt", (when, when))
|
|
||||||
os.utime(cls.index(cls.trees[name]), (when, when))
|
|
||||||
huge = cls.base / "huge.bin"
|
|
||||||
huge.write_bytes(b"x" * 4 * BIG)
|
|
||||||
big, link = cls.trees["stale"] / "big.bin", cls.trees["stale"] / "outside"
|
|
||||||
big.write_bytes(b"x" * BIG)
|
|
||||||
link.symlink_to(huge)
|
|
||||||
os.utime(big, (OLD, OLD))
|
|
||||||
os.utime(link, (OLD, OLD), follow_symlinks=False)
|
|
||||||
cls.git(cls.main, "branch", "archive/old", "stale")
|
|
||||||
notes = cls.main / "notes.txt"
|
|
||||||
notes.write_text("main worktree scratch\n", encoding="utf-8")
|
|
||||||
os.utime(notes, (OLD, OLD))
|
|
||||||
|
|
||||||
cls.claim("work/claimed-job", {"session": "peer", "runtime": "other", "name": "Peer", "beat": time.time(),
|
|
||||||
"work": f"finish {cls.trees['claimed']}"})
|
|
||||||
cls.claim("work/ended-job", {"session": "gone", "runtime": "claude-code", "name": None, "beat": OLD,
|
|
||||||
"work": "rebase ended onto main"})
|
|
||||||
cls.sleeper = subprocess.Popen(["sleep", "120"], cwd=cls.trees["busy"])
|
|
||||||
cls.addClassCleanup(cls.sleeper.wait)
|
|
||||||
cls.addClassCleanup(cls.sleeper.kill)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def run_git(cls, cwd: Path, env: dict[str, str], *args: str) -> str:
|
|
||||||
return subprocess.run(["git", *args], cwd=cwd, env=env, capture_output=True, text=True, check=True).stdout
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def git(cls, cwd: Path, *args: str) -> str:
|
|
||||||
return cls.run_git(cwd, cls.env, *args)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def commit(cls, repo: Path, name: str, text: str, message: str, when: float | None) -> None:
|
|
||||||
"""Commit one file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
when: author and committer date as epoch seconds; None means now.
|
|
||||||
"""
|
|
||||||
(repo / name).write_text(text, encoding="utf-8")
|
|
||||||
cls.git(repo, "add", name)
|
|
||||||
dated = {} if when is None else {"GIT_AUTHOR_DATE": f"@{int(when)} +0000", "GIT_COMMITTER_DATE": f"@{int(when)} +0000"}
|
|
||||||
cls.run_git(repo, {**cls.env, **dated}, "commit", "-q", "-m", message)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def add_tree(cls, name: str) -> None:
|
|
||||||
cls.git(cls.main, "worktree", "add", "-q", "-b", name, str(cls.trees[name]), "main")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def index(cls, tree: Path) -> Path:
|
|
||||||
return Path(cls.git(tree, "rev-parse", "--path-format=absolute", "--git-path", "index").strip())
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def claim(cls, office: str, fields: dict) -> None:
|
|
||||||
path = cls.root / office
|
|
||||||
path.mkdir(parents=True)
|
|
||||||
claim = {"office": office, "since": "2026-11-01T00:00:00+00:00", **fields}
|
|
||||||
(path / "claim.json").write_text(json.dumps(claim), encoding="utf-8")
|
|
||||||
|
|
||||||
def orphans(self, *extra: str) -> subprocess.CompletedProcess:
|
|
||||||
return subprocess.run(
|
|
||||||
[sys.executable, str(ORPHANS), "--repo", str(self.main), "--default", "main", "--stale-days", "7", *extra],
|
|
||||||
capture_output=True, text=True, env=self.env, cwd=self.base, check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def out(self, *extra: str) -> str:
|
|
||||||
result = self.orphans(*extra)
|
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
|
||||||
return result.stdout
|
|
||||||
|
|
||||||
def rows(self, *extra: str) -> dict[str, dict]:
|
|
||||||
return {row["branch"]: row for row in json.loads(self.out("--json", "--top", "1", *extra))["rows"]}
|
|
||||||
|
|
||||||
def test_the_summary_counts_every_class_then_shows_the_oldest_rows_per_class(self):
|
|
||||||
lines = self.out("--top", "1").splitlines()
|
|
||||||
self.assertEqual(lines[1:5], COUNTS)
|
|
||||||
self.assertNotIn("busy unknown", "\n".join(lines))
|
|
||||||
self.assertIn("Top 1 per class, oldest first:", lines)
|
|
||||||
self.assertEqual([(row["Branch"], row["Class"], row["Proposal"]) for row in rows_of("\n".join(lines))],
|
|
||||||
[("squashed", *TABLE["squashed"]), ("mdirty", *TABLE["mdirty"]),
|
|
||||||
("ended", *TABLE["ended"])])
|
|
||||||
self.assertEqual([row["Branch"] for row in rows_of(self.out("--top", "2"))],
|
|
||||||
["squashed", "merged", "mdirty", "ended", "stale"])
|
|
||||||
counts_only = self.out("--top", "0")
|
|
||||||
self.assertEqual((counts_only.splitlines()[1:5], rows_of(counts_only)), (COUNTS, []))
|
|
||||||
|
|
||||||
def test_the_full_file_holds_every_row_but_the_active_ones(self):
|
|
||||||
full = self.base / "full.md"
|
|
||||||
lines = self.out("--top", "1", "--full", str(full)).splitlines()
|
|
||||||
self.assertIn(f"- full table: {full}", lines)
|
|
||||||
rows = {row["Branch"]: row for row in rows_of(full.read_text(encoding="utf-8"))}
|
|
||||||
self.assertEqual({branch: (row["Class"], row["Proposal"]) for branch, row in rows.items()}, TABLE)
|
|
||||||
stale = rows["stale"]
|
|
||||||
self.assertEqual((stale["Worktree"], stale["Dirty"], stale["Unpushed"]), (str(self.trees["stale"]), "3",
|
|
||||||
"1 (no upstream)"))
|
|
||||||
self.assertTrue(stale["Last activity"].endswith("(30 d)"), stale["Last activity"])
|
|
||||||
self.assertEqual(rows["ended"]["Claim"], "work/ended-job (gone, ended)")
|
|
||||||
self.assertEqual((rows["squashed"]["Worktree"], rows["squashed"]["Dirty"], rows["squashed"]["Unpushed"]),
|
|
||||||
("-", "-", "0"))
|
|
||||||
self.assertNotIn("Size", rows["stale"])
|
|
||||||
|
|
||||||
def test_sizes_order_the_largest_first_and_never_follow_a_symlink(self):
|
|
||||||
shown = rows_of(self.out("--top", "1", "--sizes"))
|
|
||||||
self.assertEqual([(row["Branch"], row["Size"]) for row in shown],
|
|
||||||
[("merged", "6 B"), ("mdirty", "19 B"), ("stale", "48.8 KiB")])
|
|
||||||
rows = self.rows("--sizes")
|
|
||||||
self.assertEqual({branch: rows[branch]["size"] for branch in ("stale", "ended", "merged", "mdirty")},
|
|
||||||
{"stale": BIG + 13, "ended": 13, "merged": 6, "mdirty": 19})
|
|
||||||
for branch in ("squashed", *ACTIVE):
|
|
||||||
with self.subTest(branch=branch):
|
|
||||||
self.assertIsNone(rows[branch]["size"])
|
|
||||||
self.assertIsNone(self.rows()["stale"]["size"])
|
|
||||||
|
|
||||||
def test_json_keeps_the_main_worktree_claimed_busy_recent_and_archived_rows(self):
|
|
||||||
rows = self.rows()
|
|
||||||
self.assertEqual(sorted(rows), sorted([*TABLE, *ACTIVE]))
|
|
||||||
for branch in ACTIVE:
|
|
||||||
with self.subTest(branch=branch):
|
|
||||||
self.assertEqual((rows[branch]["class"], rows[branch]["proposal"]), ("active", "keep"))
|
|
||||||
self.assertEqual((rows["merged"]["merged"], rows["squashed"]["merged"]), ("ancestor", "patch"))
|
|
||||||
self.assertTrue(rows["main"]["main"])
|
|
||||||
self.assertEqual(rows["main"]["dirty"], 1)
|
|
||||||
self.assertEqual(rows["claimed"]["claims"], [{"office": "work/claimed-job", "holder": "Peer", "live": True}])
|
|
||||||
self.assertTrue(rows["busy"]["busy"])
|
|
||||||
self.assertFalse(rows["stale"]["busy"])
|
|
||||||
self.assertGreater(rows["recent"]["last_activity"], time.time() - DAY)
|
|
||||||
self.assertIsNone(rows["stale"]["merged"])
|
|
||||||
self.assertIsNone(rows["merged"]["upstream"])
|
|
||||||
|
|
||||||
def test_the_scan_changes_nothing_in_the_repository(self):
|
|
||||||
indexes = [self.index(tree) for tree in (self.main, *self.trees.values())]
|
|
||||||
before = [os.stat(index).st_mtime_ns for index in indexes]
|
|
||||||
refs = self.git(self.main, "for-each-ref")
|
|
||||||
self.out("--top", "5", "--sizes")
|
|
||||||
self.out("--json", "--top", "5")
|
|
||||||
self.assertEqual([os.stat(index).st_mtime_ns for index in indexes], before)
|
|
||||||
self.assertEqual(self.git(self.main, "for-each-ref"), refs)
|
|
||||||
|
|
||||||
def test_a_missing_origin_default_or_bad_input_exits_2(self):
|
|
||||||
for args in (("--default", "nope"), ("--stale-days", "-1"), ("--default", "a..b"), ("--top", "-1"),
|
|
||||||
("--full", str(self.base / "absent" / "full.md"))):
|
|
||||||
with self.subTest(args=args):
|
|
||||||
result = self.orphans("--top", "1", *args)
|
|
||||||
self.assertEqual((result.returncode, result.stdout), (2, ""), result.stderr)
|
|
||||||
self.assertEqual(self.orphans().returncode, 2)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
Reference in new issue
Block a user