From f707d41d78ec9ffd5c95e7f17e9c6d0b2eafce28 Mon Sep 17 00:00:00 2001 From: Matt Norris Date: Fri, 25 Sep 2026 15:54:07 -0400 Subject: [PATCH] feat(skills): add git-tidy skill Audit local branches, remote branches, and worktrees, then delete only what is approved at a single gate. Branches identical to the integration branch stay out of the safe-to-delete set. --- skills/README.md | 1 + skills/ess/git-tidy/LICENSE | 202 ++++++ skills/ess/git-tidy/SKILL.md | 155 +++++ .../ess/git-tidy/references/classification.md | 168 +++++ skills/ess/git-tidy/references/safety.md | 120 ++++ skills/ess/git-tidy/scripts/audit.sh | 599 ++++++++++++++++++ skills/ess/git-tidy/scripts/lib.sh | 103 +++ skills/ess/git-tidy/scripts/test_audit.sh | 280 ++++++++ 8 files changed, 1628 insertions(+) create mode 100644 skills/ess/git-tidy/LICENSE create mode 100644 skills/ess/git-tidy/SKILL.md create mode 100644 skills/ess/git-tidy/references/classification.md create mode 100644 skills/ess/git-tidy/references/safety.md create mode 100755 skills/ess/git-tidy/scripts/audit.sh create mode 100755 skills/ess/git-tidy/scripts/lib.sh create mode 100755 skills/ess/git-tidy/scripts/test_audit.sh diff --git a/skills/README.md b/skills/README.md index ec8831d..43c9b97 100644 --- a/skills/README.md +++ b/skills/README.md @@ -42,6 +42,7 @@ npx skills add CiscoDevNet/essentials --skill '*' --full-depth | [`ess/pr-address-comments-all`](ess/pr-address-comments-all/) | Address review comments across one or more PRs in parallel, each in its own git worktree on the PR's branch. | | [`ess/mcp-hide-secrets`](ess/mcp-hide-secrets/) | Move Cursor MCP credentials out of `mcp.json` into a login-loaded `mcp.env` (macOS LaunchAgent). | | [`ess/summarize-change-log`](ess/summarize-change-log/) | Condense a long git log or squash message into 1–5 Conventional-Commit bullets. | +| [`ess/git-tidy`](ess/git-tidy/) | Audit local branches, remote branches, and worktrees, then delete only what you approve at a single gate. | | [`ess/essentials-sync`](ess/essentials-sync/) | Extract a jargon-free `ess-*` package out of a private tool and sync it to the open-source essentials repo. | ## Notes diff --git a/skills/ess/git-tidy/LICENSE b/skills/ess/git-tidy/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/skills/ess/git-tidy/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/ess/git-tidy/SKILL.md b/skills/ess/git-tidy/SKILL.md new file mode 100644 index 0000000..fcca366 --- /dev/null +++ b/skills/ess/git-tidy/SKILL.md @@ -0,0 +1,155 @@ +--- +name: git-tidy +description: >- + Audit and clean up a repo's local branches, remote branches, and git + worktrees. Classifies every branch and worktree into safe-to-delete, + ask-first, or keep by joining git ancestry with PR state, then executes only + what you approve at a single gate. Branches identical to main are treated as + fresh work starts and are never auto-deleted. Use for /git-tidy, "clean up my + branches", "which worktrees are stale", "what can I delete before I start", or + a branch/worktree status review. Requires git; gh adds PR state. +compatibility: >- + Any git repo, macOS or Linux, bash 3.2+. GitHub PR state needs gh + authenticated against the repo's remote; without it the audit still runs and + degrades conservatively. +metadata: + version: "1.0" +--- + +# Git Tidy + +Cleaning up branches by hand means running twenty git commands and holding the +answers in your head. This skill puts the inventory and the classification in a +script, so the only thing left is judgment: which of the ask-first items you +actually want gone. + +## Quick start + +```bash +skills/ess/git-tidy/scripts/audit.sh # read-only; writes a report +``` + +Then read `report.md` from the printed directory, present it, and act on what +the user approves. + +## What it decides + +Every local branch lands in exactly one category. The disposition column is what +you may act on without asking. + +| Category | Meaning | Disposition | +| -------- | ------- | ----------- | +| `base` | The local integration branch itself | keep | +| `fresh-start` | Tip is identical to base: work just begun | **ask** | +| `merged-ancestor` | Fully contained in base | safe, `git branch -d` | +| `pr-merged-verified` | Tip matches a merged PR's head commit | safe, `git branch -D` | +| `pr-merged-diverged` | PR merged, then commits were added | **ask** | +| `pr-open` | PR still open | keep | +| `unmerged-no-pr` | Work in progress, nothing upstream | keep | +| `superseded` | Contained in another local branch | **ask** | + +Worktrees: `primary`, `current`, `locked`, `dirty`, `active` and `active-open-pr` +are kept or asked about; `prunable` (directory gone) and `stale-merged` (clean, +work already in base) are safe. + +`fresh-start` is the one that most tools get wrong. A branch sitting exactly on +`main` looks empty and disposable, but it is almost always someone who just ran +`git checkout -b` and has not committed yet. Deleting it destroys intent, not +code, which is why it never enters the safe set. + +## Workflow + +### 1. Audit + +Run from inside the target repo. The script only reads; it deletes nothing. + +```bash +skills/ess/git-tidy/scripts/audit.sh [--base origin/main] [--remote origin] +``` + +| Flag | Use it when | +| ---- | ----------- | +| `--fetch` | Remote-tracking refs may be stale | +| `--no-gh` | Offline, or the repo is not on GitHub | +| `--no-size` | Worktrees are large and `du` is slow | +| `--base ` | The integration branch is not `main`/`master` | +| `--output DIR` | You want the report somewhere specific | + +It writes `report.md`, `report.json`, `branches.tsv`, and `worktrees.tsv`, and +prints the output directory as its last line. + +### 2. Present + +Show the summary counts and the branch table from `report.md`. Lead with the +numbers: how many are safe, how many need a decision, how much disk the stale +worktrees hold. Then walk through each ask-first item individually with the +evidence the report already gathered — the extra commits on a diverged branch, +the branch that supersedes another, the fresh start that may be today's work. + +Do not paste the whole report. Summarize, and link to the file. + +### 3. Gate + +Ask once, and get scope in the same question: + +- **Conservative** — safe items only: prunable worktrees, merged ancestors, + verified squash merges. +- **Conservative plus specific ask-first items** — name them. +- **Nothing** — report only. + +Nothing destructive runs before this answer. Never widen the scope the user +gave, and never fold remote branch deletion into a local cleanup: that is a +separate question, asked separately. + +### 4. Execute + +Run the commands from the `### Safe` block in `report.md` **in the order they +appear**. The script has already ordered them correctly. Add approved ask-first +items to the same run, using the same ordering rules. + +Verify afterward with a second audit and report what changed. + +## Safety rules + +These are ordering and correctness constraints, not preferences. Details and +failure modes are in [references/safety.md](references/safety.md). + +1. **Release a worktree before deleting its branch.** `git branch -d` refuses + while a worktree holds the ref. +2. **Never remove the worktree you are standing in.** Check out the base branch + in the primary tree first. +3. **Never remove a worktree with uncommitted changes.** The report marks these + `dirty` and keeps them; do not override. +4. **`-D` only against a verified PR head.** Squash-merged branches are never + ancestors of `main`, so `-d` refuses and `-D` is required. That capital `D` + skips git's safety check, so it is justified only by the tip matching the + merged PR's `headRefOid` — which the audit checks for you. +5. **Deleting a local branch never deletes the remote.** Remote deletion is a + separate step and needs its own approval. +6. **Do not trust `[gone]`.** The upstream marker is only as fresh as the last + prune; the audit asks the remote directly with `git ls-remote`. + +## When PR state is unavailable + +Without `gh`, branches that would be `pr-merged-verified` fall back to +`unmerged-no-pr` and are kept. That is the intended failure direction: the +audit's answer gets less useful, never less safe. Say so when presenting, so the +user knows the safe set is incomplete rather than empty. + +## Validation + +```bash +skills/ess/git-tidy/scripts/test_audit.sh +``` + +Builds a throwaway repo exhibiting every category, runs the audit against it, +and asserts the classification, the remediation ordering, and that the audit +mutated nothing. Fully offline: the remote is a local bare repo and PR state +comes from a fixture. + +## References + +- [references/classification.md](references/classification.md) — the decision + matrix and the git plumbing behind each signal +- [references/safety.md](references/safety.md) — ordering constraints, failure + modes, and recovery diff --git a/skills/ess/git-tidy/references/classification.md b/skills/ess/git-tidy/references/classification.md new file mode 100644 index 0000000..c63ba34 --- /dev/null +++ b/skills/ess/git-tidy/references/classification.md @@ -0,0 +1,168 @@ +# Classification + +How `audit.sh` turns raw git and GitHub signals into a category and a +disposition. Read this when a classification surprises you, or when you want to +extend the matrix. + +## Signals + +Each signal is collected once per audit, not once per branch, wherever the git +plumbing allows it. + +| Signal | Command | Notes | +| ------ | ------- | ----- | +| Tip commit | `git for-each-ref --format='%(objectname)' refs/heads` | One call for all branches | +| Ahead / behind | `git rev-list --left-right --count "$BASE...$branch"` | Left is behind, right is ahead; one call gives both | +| Ancestry | derived: `ahead == 0` | A branch with no commits outside base *is* an ancestor of base, so the `merge-base --is-ancestor` call is redundant | +| Live on remote | `git ls-remote --heads ` | One round trip for the whole repo | +| PR state | `gh pr list --author @me --state all` | One round trip; gaps filled per branch | +| PR for a commit | `gh api repos/{owner}/{repo}/commits//pulls` | Only for detached worktrees | +| Worktree occupancy | `git worktree list --porcelain` | Also yields `detached`, `locked`, `prunable` | +| Worktree cleanliness | `git -C status --porcelain` | One call per worktree | +| Worktree size | `du -sk ` | Skippable with `--no-size`; the slowest step | + +### Why not trust `[gone]` + +`git for-each-ref`'s `%(upstream:track)` reports `[gone]` from the local +remote-tracking refs, which are only as current as the last `git fetch --prune`. +A branch can be deleted on the remote for weeks and still look live locally, or +be marked gone after someone else re-pushed it. `git ls-remote` asks the remote +what exists right now, for the cost of one round trip, so the audit uses that +and ignores the marker entirely. + +### Why one batched PR query + +Calling `gh pr list --head ` once per branch costs a round trip each; +across twenty branches that dominates the runtime. A single +`gh pr list --author @me --state all` returns everything you authored, and the +join happens locally. Branches the batch misses — someone else's work you +checked out — get one targeted lookup each, so the common case stays at one +call while the uncommon case stays correct. + +When several PRs share a head branch (a reused branch name), the audit picks +one: `OPEN` wins, then the most recently merged, then closed. + +## Decision order + +The first matching rule wins. Order matters: `fresh-start` is checked before +ancestry because a branch identical to base is technically an ancestor of it, +and the ancestry rule would otherwise mark it safe to delete. + +```mermaid +flowchart TD + B[Local branch] --> Base{Is the local base branch?} + Base -->|yes| Kbase[base: keep] + Base -->|no| Id{ahead == 0 and behind == 0} + Id -->|yes| Fresh[fresh-start: ask] + Id -->|no| Anc{ahead == 0} + Anc -->|yes| Merged[merged-ancestor: safe -d] + Anc -->|no| PR{PR state} + PR -->|OPEN| Open[pr-open: keep] + PR -->|MERGED, tip == headRefOid| Ver[pr-merged-verified: safe -D] + PR -->|MERGED, tip != headRefOid| Div[pr-merged-diverged: ask] + PR -->|none or CLOSED| Sup{Ancestor of another local branch?} + Sup -->|yes| Super[superseded: ask] + Sup -->|no| Wip[unmerged-no-pr: keep] +``` + +## The categories + +### `base` + +The local branch matching the base ref, for example `main` when the base is +`origin/main`. Excluded from cleanup so a stale local `main` is never proposed +for deletion. + +### `fresh-start` + +Tip is identical to the base tip: zero ahead, zero behind. Nothing distinguishes +this branch from `main` except its name, which is exactly the point — the name +records an intention. It is the output of `git checkout -b` before the first +commit. + +Never auto-safe. Ask, and mention how recently it was created. + +### `merged-ancestor` + +Zero commits outside base, but behind it. Every commit is reachable from base, +so `git branch -d` succeeds and nothing can be lost. This is the only category +where deletion is unconditionally lossless. + +### `pr-merged-verified` + +The branch has commits outside base, but a merged PR claims it and the local tip +equals that PR's `headRefOid`. Under a squash merge the branch's commits never +enter main's history — main gets one new commit with a different SHA — so git +sees the branch as unmerged and `-d` refuses. + +The `headRefOid` check is what makes `-D` defensible: it proves the local tip is +byte-for-byte the commit GitHub squashed. Without that check, `-D` is a guess. + +### `pr-merged-diverged` + +A merged PR claims the branch, but the local tip has moved past the commit that +was merged. Someone committed after the merge — a follow-up fix, or a rebase +that was never pushed. Those commits exist nowhere else. + +Always ask. `report.md` includes the `git log --oneline base..branch` command +that shows exactly what would be lost. + +### `pr-open` + +An open PR points at this branch. Never offered for deletion at any scope. + +### `unmerged-no-pr` + +Commits outside base and no PR, or a PR that was closed without merging. Work in +progress. Kept by default; the report carries the ahead count, the last commit +date, and the subject so a human can decide whether it is still alive. + +### `superseded` + +The branch has no PR of its own and its tip is an ancestor of another local +branch — a checkpoint that later work grew past. Nothing is lost by deleting it +because the successor contains every commit, but the name may still be load +bearing, so it asks. + +This scan is O(n²) in branch count, so it runs only for branches that would +otherwise be `unmerged-no-pr`. + +## Worktrees + +Checked in order; the first match wins. + +| Category | Test | Disposition | +| -------- | ---- | ----------- | +| `prunable` | Porcelain says `prunable`, or the directory is gone | safe: `git worktree prune` | +| `primary` | First entry from `git worktree list` | keep | +| `current` | Path equals the toplevel you invoked from | keep | +| `locked` | Porcelain says `locked` | keep | +| `dirty` | `git status --porcelain` is non-empty | keep | +| `stale-merged` | Clean, and holds a branch whose disposition is safe | safe: `git worktree remove` | +| `ask` | Clean, and holds a branch whose disposition is ask | ask | +| `active` | Clean, and holds a branch that is kept | keep | +| `stale-merged` | Detached at a commit already in base, or at a merged/closed PR head | safe: `git worktree remove` | +| `active-open-pr` | Detached at an open PR's head commit | ask | +| `unknown` | Detached, not in base, no PR match | ask | + +Detached worktrees are classified from the commit, never from the directory +name. Review tooling names directories things like `pr-1280-91a76b76`, but that +string is a convention, not data — the commit is data. `git merge-base +--is-ancestor` answers whether the work landed, and the commits API answers +which PR it belongs to even when someone else opened it. + +## Extending the matrix + +To add a category: + +1. Add the branch to `test_audit.sh`'s scratch repo and assert the category you + expect. It will fail. +2. Add the rule to `classify_branches` in `audit.sh`, respecting the order + above. +3. Map it to a disposition (`safe-d`, `safe-D`, `ask`, `keep`) and, if it is + safe, emit its command in the remediation block in the correct position. +4. Document it here and in the SKILL.md table. + +Every field written to a TSV must be non-empty — use `-` as the placeholder. +With a tab `IFS`, bash collapses runs of whitespace delimiters, so an empty +middle field silently shifts every later column left. diff --git a/skills/ess/git-tidy/references/safety.md b/skills/ess/git-tidy/references/safety.md new file mode 100644 index 0000000..73d3cb2 --- /dev/null +++ b/skills/ess/git-tidy/references/safety.md @@ -0,0 +1,120 @@ +# Safety + +Ordering constraints, the failure modes they prevent, and how to recover when +something still goes wrong. + +## Execution order + +`report.md` emits its `### Safe` block in this order. It is not cosmetic — each +step removes a blocker for the next. + +```bash +git checkout # 1. only if you are on a branch being deleted +git worktree remove "" # 2. release worktrees holding those branches +git worktree prune # 3. drop registrations whose directory is gone +git branch -d # 4. lossless deletes +git branch -D # 5. forced deletes, each one verified +``` + +**1 before 4 and 5.** Git refuses to delete the branch you have checked out. +Without this, the last item in the list fails and you are left half done. + +**2 before 4 and 5.** A branch checked out in *any* worktree is protected the +same way: + +``` +error: Cannot delete branch 'ancestor' checked out at '/path/to/wt-ancestor' +``` + +**3 after 2.** `git worktree prune` only removes registrations whose directory +is already gone. Running it first is harmless but accomplishes nothing; running +it after keeps the bookkeeping in one place. + +**4 before 5.** Not a hard dependency, only sequencing that keeps the risky +operation last, where a mistake in the safe half is still recoverable. + +## Never remove the worktree you are standing in + +`git worktree remove` on your own working directory leaves the shell in a +deleted path, where nearly every subsequent command fails in a confusing way. +The audit marks that worktree `current` and keeps it. To remove it, `cd` into +the primary tree first, then re-run the audit so the classification reflects +where you now stand. + +## Never remove a dirty worktree + +`git worktree remove` refuses when there are uncommitted changes, and +`--force` overrides that refusal. Nothing in this skill emits `--force`. A dirty +worktree is someone's unsaved work; the recovery path is `git stash` or a +commit, both of which are the owner's decision, not the cleanup's. + +If a worktree is dirty only because of build artifacts, that is a `.gitignore` +bug worth fixing rather than forcing past. + +## `-d` versus `-D` + +`git branch -d` refuses to delete a branch holding commits not reachable from +HEAD or its upstream. That refusal is the safety net, and `-D` removes it. + +Squash merges force the issue. GitHub's squash creates a single new commit on +`main` with a different SHA and different parentage, so the original branch is +never an ancestor of `main` no matter how thoroughly its content landed. `-d` +cannot tell that branch apart from genuinely unmerged work, so it refuses, and +`-D` becomes the only way to delete it. + +That makes `-D` routine, which makes it dangerous. The audit only puts a branch +in the `-D` list when the local tip SHA equals the merged PR's `headRefOid` — +proof that the exact commit you are deleting is the one GitHub squashed. When +the tip has moved (`pr-merged-diverged`), the extra commits exist nowhere else +and the branch goes to the ask pile instead. + +Never hand-extend the `-D` list with a branch the audit did not verify. + +## Local and remote deletion are separate + +`git branch -d` touches only the local ref. The remote branch survives, along +with the PR and its history. This is deliberate: local cleanup is cheap and +reversible for a while, remote deletion affects everyone and can break links. + +If remote deletion is genuinely wanted, it is a separate question with its own +approval, and worth checking that no open PR targets the branch first. Note that +many repos delete the head branch automatically on merge, so the remote is often +already gone. + +## Failure modes + +| Symptom | Cause | Fix | +| ------- | ----- | --- | +| `Cannot delete branch 'x' checked out at ...` | A worktree still holds it | Remove that worktree first; re-read the ordering above | +| `error: the branch 'x' is not fully merged` | Squash merge, or genuinely unmerged | Only use `-D` if the audit classified it `pr-merged-verified` | +| `fatal: '' contains modified or untracked files` | Dirty worktree | Do not force. Commit, stash, or leave it | +| Audit reports every branch as `unmerged-no-pr` | `gh` missing or unauthenticated | `gh auth status`, then re-run; or accept the conservative result | +| A deleted remote branch still reads `live` | Stale local view | The audit uses `git ls-remote`, so this means the remote really does still have it | +| Report lists a worktree whose directory you deleted | Registration outlives the directory | That is `prunable`; `git worktree prune` clears it | + +## Recovery + +A deleted local branch is recoverable for as long as the reflog keeps its tip, +which defaults to 90 days: + +```bash +git reflog --no-abbrev | grep +git branch +``` + +The audit prints every branch's tip SHA into `branches.tsv` and `report.json` +before deleting anything, so the previous report is itself a recovery record. +Keep the output directory until you are satisfied with the result. + +Worktree removal is not recoverable in the same way, but nothing is lost: a +worktree is a checkout, and the commits live in the shared object store. Re-add +it with `git worktree add `. Only uncommitted changes are +genuinely gone, which is why dirty worktrees are never touched. + +## What the audit will not do + +`audit.sh` never deletes, never force-pushes, never rewrites history, and never +writes to the repository it inspects. It prints commands. The only writes it +performs are to its output directory, and to remote-tracking refs when you pass +`--fetch`. Keeping execution in the agent's hands, after an explicit approval, +is what makes the single gate meaningful. diff --git a/skills/ess/git-tidy/scripts/audit.sh b/skills/ess/git-tidy/scripts/audit.sh new file mode 100755 index 0000000..4c6c10b --- /dev/null +++ b/skills/ess/git-tidy/scripts/audit.sh @@ -0,0 +1,599 @@ +#!/usr/bin/env bash +# +# audit.sh +# Inventory a repo's local branches, remote branches, and worktrees, classify +# each one, and emit a report so cleanup decisions rest on evidence instead of +# on twenty ad-hoc git commands. +# +# Usage: +# audit.sh [--base ] [--remote ] [--output DIR] +# [--fetch] [--no-gh] [--no-size] [--author ] [--pr-limit N] +# +# --base - Integration ref every branch is compared against +# (default: first of origin/main, origin/master, main, master) +# --remote - Remote to check for live branches (default: origin) +# --output DIR - Output directory (default: /tmp/git-tidy--) +# --fetch - Run `git fetch --prune ` first (network write to +# remote-tracking refs; off by default so the audit is read-only) +# --no-gh - Skip all PR lookups; classification degrades conservatively +# --no-size - Skip `du` on worktrees (faster on large trees) +# --author - Author for the batched PR query (default: @me) +# --pr-limit N - Max PRs in the batched query (default: 200) +# +# Environment: +# GIT_TIDY_PR_FIXTURE - path to a prs.tsv that stands in for the gh queries, +# so test_audit.sh can exercise PR categories offline +# +# Writes into DIR: +# prs.tsv - batched PR metadata (headRefName, number, state, ...) +# remote_heads.tsv - live branches on the remote (from git ls-remote) +# worktrees.tsv - classified worktrees +# branches.tsv - classified local branches +# report.json - machine-readable classification +# report.md - human report, ending in ordered remediation commands +# +# This script only reads. It never deletes a branch or a worktree; it prints the +# commands that would. Portable to bash 3.2 (macOS): no associative arrays or +# mapfile. Depends on git, plus gh for PR state (optional). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +. "${SCRIPT_DIR}/lib.sh" + +# --- parse args --------------------------------------------------------------- +BASE=""; REMOTE="origin"; OUTPUT_DIR=""; DO_FETCH="false" +USE_GH="true"; DO_SIZE="true"; PR_AUTHOR="@me"; PR_LIMIT="200" + +while [[ $# -gt 0 ]]; do + case "$1" in + --base) BASE="${2:-}"; shift 2 ;; + --remote) REMOTE="${2:-}"; shift 2 ;; + --output) OUTPUT_DIR="${2:-}"; shift 2 ;; + --author) PR_AUTHOR="${2:-}"; shift 2 ;; + --pr-limit) PR_LIMIT="${2:-}"; shift 2 ;; + --fetch) DO_FETCH="true"; shift ;; + --no-gh) USE_GH="false"; shift ;; + --no-size) DO_SIZE="false"; shift ;; + -h|--help) sed -n '2,37p' "$0"; exit 0 ;; + *) error "Unknown argument: $1" ;; + esac +done + +have git || error "git not found on PATH" +require_git_repo + +REPO_ROOT="$(repo_root)" +CURRENT_TOPLEVEL="$REPO_ROOT" +cd "$REPO_ROOT" + +if [[ "$DO_FETCH" == "true" ]]; then + info "Fetching ${REMOTE} with --prune" + git fetch --prune "$REMOTE" >/dev/null 2>&1 || warn "fetch failed; continuing with local refs" +fi + +# --- resolve the base ref ----------------------------------------------------- +if [[ -n "$BASE" ]]; then + ref_exists "$BASE" || error "base ref not found: $BASE" +else + BASE="$(first_existing_ref "${REMOTE}/main" "${REMOTE}/master" "main" "master")" \ + || error "could not resolve a base ref; pass --base " +fi +BASE_TIP="$(git rev-parse "$BASE")" +BASE_LOCAL="$(strip_remote_prefix "$BASE")" +info "Base ref: ${BASE} (${BASE_TIP:0:9})" + +# --- output dir --------------------------------------------------------------- +if [[ -z "$OUTPUT_DIR" ]]; then + # Only the derived directory is ours to wipe; a caller-supplied one is merely + # written into, so `--output ~/notes` can never erase the caller's files. + OUTPUT_DIR="/tmp/git-tidy-$(basename "$REPO_ROOT")-$(hash_str "$REPO_ROOT")" + rm -rf "$OUTPUT_DIR" +fi +mkdir -p "$OUTPUT_DIR" + +PRS_TSV="${OUTPUT_DIR}/prs.tsv" +HEADS_TSV="${OUTPUT_DIR}/remote_heads.tsv" +WORKTREES_TSV="${OUTPUT_DIR}/worktrees.tsv" +BRANCHES_TSV="${OUTPUT_DIR}/branches.tsv" +: > "$PRS_TSV"; : > "$HEADS_TSV"; : > "$WORKTREES_TSV"; : > "$BRANCHES_TSV" + +# --- live remote branches (one round trip) ------------------------------------ +# `[gone]` in `git for-each-ref` upstream:track is only as fresh as the last +# prune, so ask the remote directly rather than trusting the local marker. +REMOTE_KNOWN="false" +if git remote get-url "$REMOTE" >/dev/null 2>&1; then + if git ls-remote --heads "$REMOTE" > "${OUTPUT_DIR}/.ls-remote.raw" 2>/dev/null; then + awk '{ sub(/^refs\/heads\//, "", $2); print $2 "\t" $1 }' \ + "${OUTPUT_DIR}/.ls-remote.raw" > "$HEADS_TSV" + REMOTE_KNOWN="true" + info "Live branches on ${REMOTE}: $(wc -l < "$HEADS_TSV" | tr -d ' ')" + else + warn "could not reach ${REMOTE}; remote branch state will be reported as unknown" + fi + rm -f "${OUTPUT_DIR}/.ls-remote.raw" +else + warn "remote '${REMOTE}' is not configured; skipping live remote check" +fi + +# --- PR metadata (one batched query, targeted fallbacks) ---------------------- +# One `gh pr list --author` call covers your own branches. Branches it misses +# (someone else's work you checked out) get a targeted --head lookup each, so +# the common case stays at a single round trip. +GH_OK="false" +PR_FIXTURE="${GIT_TIDY_PR_FIXTURE:-}" +if [[ -n "$PR_FIXTURE" ]]; then + # Test seam: a pre-built prs.tsv stands in for gh so test_audit.sh can + # exercise the PR-dependent categories offline. + [[ -f "$PR_FIXTURE" ]] || error "GIT_TIDY_PR_FIXTURE not found: $PR_FIXTURE" + cp "$PR_FIXTURE" "$PRS_TSV" + GH_OK="true" + info "PR fixture: ${PR_FIXTURE} ($(wc -l < "$PRS_TSV" | tr -d ' ') records)" +elif [[ "$USE_GH" == "true" ]] && have gh; then + if gh pr list --author "$PR_AUTHOR" --state all --limit "$PR_LIMIT" \ + --json number,headRefName,headRefOid,state,mergedAt,url \ + --jq '.[] | [.headRefName, (.number|tostring), .state, (.mergedAt // "-"), .headRefOid, .url] | @tsv' \ + > "$PRS_TSV" 2>/dev/null; then + GH_OK="true" + info "Batched PR records: $(wc -l < "$PRS_TSV" | tr -d ' ')" + else + warn "gh PR query failed; continuing without PR state" + : > "$PRS_TSV" + fi +elif [[ "$USE_GH" == "true" ]]; then + warn "gh not found on PATH; continuing without PR state" +fi + +# Echo the best PR row for a branch: OPEN wins, else newest MERGED, else CLOSED. +pr_row_for_branch() { + local branch="$1" + awk -F'\t' -v b="$branch" ' + $1 == b { + rank = ($3 == "OPEN") ? 3 : ($3 == "MERGED") ? 2 : 1 + key = rank "\t" $4 + if (rank > best_rank || (rank == best_rank && $4 > best_date)) { + best_rank = rank; best_date = $4; best = $0 + } + } + END { if (best != "") print best } + ' "$PRS_TSV" +} + +# Echo the PR row whose head commit is , for detached worktrees. +pr_row_for_commit() { + awk -F'\t' -v s="$1" '$5 == s { print; exit }' "$PRS_TSV" +} + +# Resolve a bare commit to its PR. Detached worktrees left behind by review +# tooling usually sit on someone else's PR head, which the --author query never +# sees. The commits API answers "which PR contains this commit" directly, so the +# worktree is classified from the commit rather than from its directory name. +fill_pr_gap_for_commit() { + local sha="$1" fields + [[ -n "$(pr_row_for_commit "$sha")" ]] && return 0 + fields="$(gh api "repos/{owner}/{repo}/commits/${sha}/pulls" --jq ' + map(select(.number)) + | sort_by(if .state == "open" then 0 else 1 end) + | if length > 0 then + .[0] | [.head.ref, (.number|tostring), + (if .merged_at then "MERGED" elif .state == "open" then "OPEN" else "CLOSED" end), + (.merged_at // "-"), .html_url] | @tsv + else empty end' 2>/dev/null)" || return 0 + [[ -n "$fields" ]] || return 0 + # Re-key on the commit we asked about: the PR head may have moved on since + # this worktree was created, and the lookup matches on that exact sha. + printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$(echo "$fields" | cut -f1)" "$(echo "$fields" | cut -f2)" \ + "$(echo "$fields" | cut -f3)" "$(echo "$fields" | cut -f4)" \ + "$sha" "$(echo "$fields" | cut -f5)" >> "$PRS_TSV" +} + +# Fill gaps in the batched query with one targeted lookup per unmatched branch. +fill_pr_gaps() { + local branch row + while IFS= read -r branch; do + [[ -n "$branch" ]] || continue + [[ "$branch" == "$BASE_LOCAL" ]] && continue + row="$(pr_row_for_branch "$branch")" + [[ -n "$row" ]] && continue + gh pr list --head "$branch" --state all --limit 10 \ + --json number,headRefName,headRefOid,state,mergedAt,url \ + --jq '.[] | [.headRefName, (.number|tostring), .state, (.mergedAt // "-"), .headRefOid, .url] | @tsv' \ + >> "$PRS_TSV" 2>/dev/null || true + done < <(git for-each-ref --format='%(refname:short)' refs/heads) +} +[[ "$GH_OK" == "true" && -z "$PR_FIXTURE" ]] && fill_pr_gaps + +# --- worktree inventory ------------------------------------------------------- +# Parse the porcelain records into: path, head, branch, detached, locked, prunable. +# The main working tree is always listed first. +# Every field is emitted non-empty ("-" when absent): with a tab IFS, bash +# collapses runs of whitespace delimiters, so an empty middle field would shift +# every later column left when the row is read back. +parse_worktrees() { + local path="" head="" branch="" detached=0 locked=0 prunable=0 line + while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + "worktree "*) path="${line#worktree }" ;; + "HEAD "*) head="${line#HEAD }" ;; + "branch "*) branch="${line#branch refs/heads/}" ;; + "detached") detached=1 ;; + "locked"*) locked=1 ;; + "prunable"*) prunable=1 ;; + "") + if [[ -n "$path" ]]; then + printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$path" "${head:--}" "${branch:--}" "$detached" "$locked" "$prunable" + fi + path=""; head=""; branch=""; detached=0; locked=0; prunable=0 + ;; + esac + done + if [[ -n "$path" ]]; then + printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$path" "${head:--}" "${branch:--}" "$detached" "$locked" "$prunable" + fi +} +git worktree list --porcelain | parse_worktrees > "${OUTPUT_DIR}/.worktrees.raw" + +PRIMARY_WORKTREE="$(head -1 "${OUTPUT_DIR}/.worktrees.raw" | cut -f1)" + +# Detached worktrees carry no branch name, so resolve their commits to PRs now, +# while the PR table is still being assembled. +if [[ "$GH_OK" == "true" && -z "$PR_FIXTURE" ]]; then + while IFS= read -r wt_head; do + [[ -n "$wt_head" ]] && fill_pr_gap_for_commit "$wt_head" + done < <(awk -F'\t' '$3 == "-" && $2 != "-" && $6 == "0" { print $2 }' \ + "${OUTPUT_DIR}/.worktrees.raw") +fi + +# Echo the worktree path holding , if any. +worktree_for_branch() { + awk -F'\t' -v b="$1" '$3 == b { print $1; exit }' "${OUTPUT_DIR}/.worktrees.raw" +} + +# --- classify branches -------------------------------------------------------- +# Columns: name tip category disposition ahead behind remote pr_number pr_state +# pr_match worktree superseded_by date notes +CURRENT_BRANCH="$(git symbolic-ref --short -q HEAD || echo "")" + +classify_branches() { + local name tip date subject + local counts ahead behind remote_state pr_row pr_number pr_state + local pr_head pr_match category disposition worktree superseded notes other + + while IFS=$'\t' read -r name tip date subject; do + [[ -n "$name" ]] || continue + + counts="$(git rev-list --left-right --count "${BASE}...${name}")" + behind="$(echo "$counts" | cut -f1)" + ahead="$(echo "$counts" | cut -f2)" + + if [[ "$REMOTE_KNOWN" == "true" ]]; then + if [[ -n "$(tsv_lookup "$HEADS_TSV" 1 "$name")" ]]; then + remote_state="live" + else + remote_state="absent" + fi + else + remote_state="unknown" + fi + + pr_row="$(pr_row_for_branch "$name")" + if [[ -n "$pr_row" ]]; then + pr_number="$(tsv_field "$pr_row" 2)" + pr_state="$(tsv_field "$pr_row" 3)" + pr_head="$(tsv_field "$pr_row" 5)" + if [[ "$pr_head" == "$tip" ]]; then pr_match="yes"; else pr_match="no"; fi + else + pr_number="-"; pr_state="-"; pr_head="-"; pr_match="-" + fi + + worktree="$(worktree_for_branch "$name")" + [[ -n "$worktree" ]] || worktree="-" + superseded="-" + notes="" + + # --- the decision ------------------------------------------------------- + if [[ "$name" == "$BASE_LOCAL" ]]; then + category="base"; disposition="keep" + notes="local base branch" + elif [[ "$ahead" -eq 0 && "$behind" -eq 0 ]]; then + # Identical to base: almost always a branch just created to start work. + category="fresh-start"; disposition="ask" + notes="identical to ${BASE}; likely new work not yet committed" + elif [[ "$ahead" -eq 0 ]]; then + category="merged-ancestor"; disposition="safe-d" + notes="fully contained in ${BASE}" + elif [[ "$pr_state" == "OPEN" ]]; then + category="pr-open"; disposition="keep" + notes="PR #${pr_number} is open" + elif [[ "$pr_state" == "MERGED" && "$pr_match" == "yes" ]]; then + category="pr-merged-verified"; disposition="safe-D" + notes="tip matches merged PR #${pr_number} head; squashed into ${BASE}" + elif [[ "$pr_state" == "MERGED" ]]; then + category="pr-merged-diverged"; disposition="ask" + notes="PR #${pr_number} merged but tip moved since; ${ahead} commit(s) not in ${BASE}" + else + # No PR, or a closed-unmerged one: the commits are not in base. + category="unmerged-no-pr"; disposition="keep" + if [[ "$pr_state" == "CLOSED" ]]; then + notes="PR #${pr_number} closed without merging; ${ahead} commit(s) not in ${BASE}" + else + notes="${ahead} commit(s) not in ${BASE}, no PR found" + fi + # Only worth the O(n^2) ancestor scan for branches we would otherwise keep. + while IFS= read -r other; do + [[ -n "$other" && "$other" != "$name" ]] || continue + if git merge-base --is-ancestor "$name" "$other" 2>/dev/null; then + superseded="$other" + category="superseded"; disposition="ask" + notes="fully contained in local branch '${other}'" + break + fi + done < <(git for-each-ref --format='%(refname:short)' refs/heads) + fi + + if [[ "$name" == "$CURRENT_BRANCH" ]]; then + notes="${notes}; current checkout" + fi + + # `notes` is always non-empty and `subject` is last, so no field can collapse. + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$name" "$tip" "$category" "$disposition" "$ahead" "$behind" \ + "$remote_state" "$pr_number" "$pr_state" "$pr_match" "$worktree" \ + "$superseded" "$date" "$notes" "$subject" + done < <(git for-each-ref --sort=refname \ + --format='%(refname:short)%09%(objectname)%09%(committerdate:short)%09%(subject)' \ + refs/heads) +} +classify_branches > "$BRANCHES_TSV" +info "Classified branches: $(wc -l < "$BRANCHES_TSV" | tr -d ' ')" + +# Echo the disposition recorded for . +branch_disposition() { + local row; row="$(tsv_lookup "$BRANCHES_TSV" 1 "$1")" + [[ -n "$row" ]] && tsv_field "$row" 4 || echo "-" +} + +# --- classify worktrees ------------------------------------------------------- +# Columns: path head branch category disposition size_kb notes +classify_worktrees() { + local path head branch detached locked prunable + local category disposition size_kb notes dispo pr_row pr_state pr_number + + while IFS=$'\t' read -r path head branch detached locked prunable; do + [[ -n "$path" ]] || continue + size_kb=0; notes="" + + if [[ "$DO_SIZE" == "true" && -d "$path" ]]; then + size_kb="$(du -sk "$path" 2>/dev/null | awk '{print $1}')" + [[ -n "$size_kb" ]] || size_kb=0 + fi + + if [[ "$prunable" == "1" || ! -d "$path" ]]; then + category="prunable"; disposition="safe-prune" + notes="directory is gone; only the registration remains" + elif [[ "$path" == "$PRIMARY_WORKTREE" ]]; then + category="primary"; disposition="keep" + notes="main working tree" + elif [[ "$path" == "$CURRENT_TOPLEVEL" ]]; then + category="current"; disposition="keep" + notes="you are standing in this worktree" + elif [[ "$locked" == "1" ]]; then + category="locked"; disposition="keep" + notes="locked; unlock deliberately before touching" + elif [[ -n "$(git -C "$path" status --porcelain 2>/dev/null)" ]]; then + category="dirty"; disposition="keep" + notes="uncommitted changes; never remove automatically" + elif [[ "$branch" != "-" ]]; then + dispo="$(branch_disposition "$branch")" + case "$dispo" in + safe-d|safe-D) + category="stale-merged"; disposition="safe-remove" + notes="holds '${branch}', whose work is already in ${BASE}" ;; + ask) + category="ask"; disposition="ask" + notes="holds '${branch}', which needs a decision first" ;; + *) + category="active"; disposition="keep" + notes="holds '${branch}', still in use" ;; + esac + else + # Detached: decide from the commit itself, not from the directory name. + if git merge-base --is-ancestor "$head" "$BASE" 2>/dev/null; then + category="stale-merged"; disposition="safe-remove" + notes="detached at a commit already in ${BASE}" + else + pr_row="$(pr_row_for_commit "$head")" + if [[ -n "$pr_row" ]]; then + pr_number="$(tsv_field "$pr_row" 2)" + pr_state="$(tsv_field "$pr_row" 3)" + if [[ "$pr_state" == "OPEN" ]]; then + category="active-open-pr"; disposition="ask" + notes="detached at the head of open PR #${pr_number}" + else + category="stale-merged"; disposition="safe-remove" + notes="detached at the head of ${pr_state} PR #${pr_number}" + fi + else + category="unknown"; disposition="ask" + notes="detached at a commit not in ${BASE} and not matched to a PR" + fi + fi + fi + + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$path" "${head:0:9}" "${branch:--}" "$category" "$disposition" "$size_kb" "$notes" + done < "${OUTPUT_DIR}/.worktrees.raw" +} +classify_worktrees > "$WORKTREES_TSV" +info "Classified worktrees: $(wc -l < "$WORKTREES_TSV" | tr -d ' ')" + +# --- helpers for reporting ---------------------------------------------------- +count_branches() { awk -F'\t' -v d="$1" '$4 == d' "$BRANCHES_TSV" | wc -l | tr -d ' '; } +count_worktrees() { awk -F'\t' -v d="$1" '$5 == d' "$WORKTREES_TSV" | wc -l | tr -d ' '; } +branches_with() { awk -F'\t' -v d="$1" '$4 == d { print $1 }' "$BRANCHES_TSV"; } +worktrees_with() { awk -F'\t' -v d="$1" '$5 == d { print $1 }' "$WORKTREES_TSV"; } + +RECLAIMABLE_KB="$(awk -F'\t' '$5 == "safe-remove" { s += $6 } END { print s + 0 }' "$WORKTREES_TSV")" +GENERATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +# --- report.md ---------------------------------------------------------------- +{ + echo "# git-tidy report" + echo + echo "- Repo: \`${REPO_ROOT}\`" + echo "- Base: \`${BASE}\` (\`${BASE_TIP:0:9}\`)" + echo "- Remote: \`${REMOTE}\` (live check: ${REMOTE_KNOWN})" + echo "- PR state: $([[ "$GH_OK" == "true" ]] && echo "from gh" || echo "unavailable - classification is conservative")" + echo "- Generated: ${GENERATED_AT}" + echo + echo "## Summary" + echo + echo "| Bucket | Branches | Worktrees |" + echo "| ------ | -------- | --------- |" + echo "| Safe to remove | $(( $(count_branches safe-d) + $(count_branches safe-D) )) | $(( $(count_worktrees safe-remove) + $(count_worktrees safe-prune) )) |" + echo "| Ask first | $(count_branches ask) | $(count_worktrees ask) |" + echo "| Keep | $(count_branches keep) | $(count_worktrees keep) |" + echo + [[ "$RECLAIMABLE_KB" -gt 0 ]] && echo "Reclaimable worktree disk: **$(human_size "$RECLAIMABLE_KB")**" && echo + + echo "## Branches" + echo + echo "| Branch | Category | Disposition | Ahead | Behind | Remote | PR | Notes |" + echo "| ------ | -------- | ----------- | ----- | ------ | ------ | -- | ----- |" + while IFS=$'\t' read -r name tip category disposition ahead behind remote pr_number pr_state pr_match worktree superseded date notes subject; do + [[ -n "$name" ]] || continue + if [[ "$pr_number" == "-" ]]; then pr_cell="-"; else pr_cell="#${pr_number} ${pr_state}"; fi + echo "| \`${name}\` | ${category} | ${disposition} | ${ahead} | ${behind} | ${remote} | ${pr_cell} | ${notes} |" + done < "$BRANCHES_TSV" + echo + + echo "## Worktrees" + echo + echo "| Path | Branch | Category | Disposition | Size | Notes |" + echo "| ---- | ------ | -------- | ----------- | ---- | ----- |" + while IFS=$'\t' read -r path head branch category disposition size_kb notes; do + [[ -n "$path" ]] || continue + echo "| \`${path}\` | ${branch} | ${category} | ${disposition} | $(human_size "$size_kb") | ${notes} |" + done < "$WORKTREES_TSV" + echo + + echo "## Remediation" + echo + echo "Run these in order. Worktrees must be released before the branches they" + echo "hold can be deleted, and you cannot remove the worktree you are standing in." + echo + + NEEDS_CHECKOUT="false" + if [[ -n "$CURRENT_BRANCH" ]]; then + cur_dispo="$(branch_disposition "$CURRENT_BRANCH")" + [[ "$cur_dispo" == "safe-d" || "$cur_dispo" == "safe-D" ]] && NEEDS_CHECKOUT="true" + fi + + echo "### Safe" + echo + if [[ "$(count_branches safe-d)" -eq 0 && "$(count_branches safe-D)" -eq 0 \ + && "$(count_worktrees safe-remove)" -eq 0 && "$(count_worktrees safe-prune)" -eq 0 ]]; then + echo "Nothing to do." + echo + else + echo '```bash' + if [[ "$NEEDS_CHECKOUT" == "true" ]]; then + printf 'git checkout %q # release the branch you are on\n' "$BASE_LOCAL" + fi + worktrees_with safe-remove | while IFS= read -r p; do + [[ -n "$p" ]] && echo "git worktree remove \"${p}\"" + done + if [[ "$(count_worktrees safe-prune)" -gt 0 ]]; then + echo "git worktree prune # drops $(count_worktrees safe-prune) registration(s) whose directory is gone" + fi + branches_with safe-d | while IFS= read -r branch; do + [[ -n "$branch" ]] || continue + printf 'git branch -d -- %q\n' "$branch" + done + branches_with safe-D | while IFS= read -r branch; do + [[ -n "$branch" ]] || continue + printf 'git branch -D -- %q # squash-merged: -d cannot verify these\n' "$branch" + done + echo '```' + echo + fi + + echo "### Ask first" + echo + if [[ "$(count_branches ask)" -eq 0 && "$(count_worktrees ask)" -eq 0 ]]; then + echo "Nothing pending." + else + while IFS=$'\t' read -r name tip category disposition ahead behind remote pr_number pr_state pr_match worktree superseded date notes subject; do + [[ "$disposition" == "ask" ]] || continue + echo "- \`${name}\` (${category}) - ${notes}" + echo " - last commit: ${date} \"${subject}\"" + echo " - inspect: \`git log --oneline ${BASE}..${name}\`" + [[ "$worktree" != "-" ]] && echo " - held by worktree: \`${worktree}\`" + done < "$BRANCHES_TSV" + while IFS=$'\t' read -r path head branch category disposition size_kb notes; do + [[ "$disposition" == "ask" ]] || continue + echo "- \`${path}\` (${category}) - ${notes}" + done < "$WORKTREES_TSV" + fi + echo + + echo "### Keep" + echo + branches_with keep | while IFS= read -r b; do + [[ -n "$b" ]] && echo "- \`${b}\`" + done + echo + echo "---" + echo + echo "Deleting a local branch never deletes its remote counterpart. Remote" + echo "deletion is a separate step and needs its own approval." +} > "${OUTPUT_DIR}/report.md" + +# --- report.json -------------------------------------------------------------- +{ + echo "{" + echo " \"generated_at\": \"${GENERATED_AT}\"," + echo " \"repo_root\": \"$(json_escape "$REPO_ROOT")\"," + echo " \"base\": \"$(json_escape "$BASE")\"," + echo " \"base_tip\": \"${BASE_TIP}\"," + echo " \"remote\": \"$(json_escape "$REMOTE")\"," + echo " \"remote_checked\": ${REMOTE_KNOWN}," + echo " \"pr_data\": ${GH_OK}," + echo " \"current_branch\": \"$(json_escape "$CURRENT_BRANCH")\"," + echo " \"reclaimable_kb\": ${RECLAIMABLE_KB}," + echo " \"branches\": [" + first=1 + while IFS=$'\t' read -r name tip category disposition ahead behind remote pr_number pr_state pr_match worktree superseded date notes subject; do + [[ -n "$name" ]] || continue + [[ $first -eq 0 ]] && echo "," + first=0 + printf ' {"name": "%s", "tip": "%s", "category": "%s", "disposition": "%s", "ahead": %s, "behind": %s, "remote": "%s", "pr_number": "%s", "pr_state": "%s", "pr_head_match": "%s", "worktree": "%s", "superseded_by": "%s", "last_commit": "%s", "notes": "%s", "subject": "%s"}' \ + "$(json_escape "$name")" "$tip" "$category" "$disposition" "$ahead" "$behind" \ + "$remote" "$pr_number" "$pr_state" "$pr_match" "$(json_escape "$worktree")" \ + "$(json_escape "$superseded")" "$date" "$(json_escape "$notes")" "$(json_escape "$subject")" + done < "$BRANCHES_TSV" + echo + echo " ]," + echo " \"worktrees\": [" + first=1 + while IFS=$'\t' read -r path head branch category disposition size_kb notes; do + [[ -n "$path" ]] || continue + [[ $first -eq 0 ]] && echo "," + first=0 + printf ' {"path": "%s", "head": "%s", "branch": "%s", "category": "%s", "disposition": "%s", "size_kb": %s, "notes": "%s"}' \ + "$(json_escape "$path")" "$head" "$(json_escape "$branch")" "$category" \ + "$disposition" "$size_kb" "$(json_escape "$notes")" + done < "$WORKTREES_TSV" + echo + echo " ]" + echo "}" +} > "${OUTPUT_DIR}/report.json" + +rm -f "${OUTPUT_DIR}/.worktrees.raw" + +info "Report: ${OUTPUT_DIR}/report.md" +echo "${OUTPUT_DIR}" diff --git a/skills/ess/git-tidy/scripts/lib.sh b/skills/ess/git-tidy/scripts/lib.sh new file mode 100755 index 0000000..de09b57 --- /dev/null +++ b/skills/ess/git-tidy/scripts/lib.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# +# lib.sh - shared helpers for the git-tidy skill scripts. +# +# Sourced (not executed) by the other scripts in this directory. It sets no +# shell options and owns no `set -e/-u`: each script keeps its own. Portable to +# bash 3.2 (macOS): no associative arrays / mapfile. +# +# Source it with, right after the `set ...` line: +# _LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# # shellcheck source=lib.sh +# . "${_LIB_DIR}/lib.sh" + +# --- logging (stderr, colorized) --------------------------------------------- +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +error() { echo -e "${RED}Error:${NC} $1" >&2; exit 1; } +info() { echo -e "${GREEN}=>${NC} $1" >&2; } +warn() { echo -e "${YELLOW}Warning:${NC} $1" >&2; } + +# --- git --------------------------------------------------------------------- +# Exit unless the cwd is inside a git work tree. Callers must cd into the target +# repo first; git-tidy always operates on the repo containing the cwd, never on +# the tree that happens to hold this skill. +require_git_repo() { + git rev-parse --git-dir >/dev/null 2>&1 || error "not inside a git repository" +} + +# Toplevel of the repo containing the cwd. Falls back to $PWD outside a repo. +repo_root() { git rev-parse --show-toplevel 2>/dev/null || pwd; } + +# True when resolves to a commit. +ref_exists() { git rev-parse --verify --quiet "$1^{commit}" >/dev/null 2>&1; } + +# Echo the first of the candidate refs that exists. Empty when none do. +first_existing_ref() { + local ref + for ref in "$@"; do + if ref_exists "$ref"; then echo "$ref"; return 0; fi + done + return 1 +} + +# Strip a leading "/" from a ref: origin/main -> main. +strip_remote_prefix() { echo "${1#*/}"; } + +# --- tool availability ------------------------------------------------------- +have() { command -v "$1" >/dev/null 2>&1; } + +# --- hashing ----------------------------------------------------------------- +# Short hex hash of a string (sha1, 8 chars), tolerant of hosts lacking shasum. +hash_str() { + if have shasum; then + printf '%s' "$1" | shasum | cut -c1-8 + elif have sha1sum; then + printf '%s' "$1" | sha1sum | cut -c1-8 + else + printf '%s' "$1" | cksum | tr -d ' ' | cut -c1-8 + fi +} + +# --- formatting -------------------------------------------------------------- +# Human-readable size from a kilobyte count: 1536 -> "1.5M". +human_size() { + local kb="${1:-0}" + if [[ -z "$kb" || "$kb" == "0" ]]; then + echo "-" + elif [[ "$kb" -lt 1024 ]]; then + echo "${kb}K" + elif [[ "$kb" -lt 1048576 ]]; then + awk -v k="$kb" 'BEGIN { printf "%.1fM", k / 1024 }' + else + awk -v k="$kb" 'BEGIN { printf "%.1fG", k / 1048576 }' + fi +} + +# Escape a string for embedding in a JSON double-quoted value. +json_escape() { + printf '%s' "$1" | awk ' + BEGIN { RS = "\n"; first = 1 } + { + gsub(/\\/, "\\\\") + gsub(/"/, "\\\"") + gsub(/\t/, "\\t") + gsub(/\r/, "\\r") + if (!first) printf "\\n" + printf "%s", $0 + first = 0 + } + ' +} + +# --- tsv --------------------------------------------------------------------- +# Look up a row in a TSV file by exact match on field , echoing the row. +# Empty output when no row matches. Branch names and paths cannot contain tabs, +# so exact field matching is safe. +tsv_lookup() { + local file="$1" col="$2" key="$3" + [[ -f "$file" ]] || return 0 + awk -F'\t' -v c="$col" -v k="$key" '$c == k { print; exit }' "$file" +} + +# Echo field of a tab-separated row. +tsv_field() { echo "$1" | cut -f"$2"; } diff --git a/skills/ess/git-tidy/scripts/test_audit.sh b/skills/ess/git-tidy/scripts/test_audit.sh new file mode 100755 index 0000000..d5929ef --- /dev/null +++ b/skills/ess/git-tidy/scripts/test_audit.sh @@ -0,0 +1,280 @@ +#!/usr/bin/env bash +# +# test_audit.sh +# Build a throwaway repo that exhibits every category audit.sh can assign, run +# the audit against it, and assert the classification. Runs entirely offline: +# the "remote" is a local bare repo and PR state comes from a fixture file via +# GIT_TIDY_PR_FIXTURE, so no GitHub access is needed. +# +# Usage: test_audit.sh [--keep] +# --keep - leave the scratch repo and report in place for inspection +# +# Exits non-zero on the first failed assertion. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +. "${SCRIPT_DIR}/lib.sh" + +KEEP="false" +[[ "${1:-}" == "--keep" ]] && KEEP="true" + +PASS=0 +FAIL=0 + +# --- assertions --------------------------------------------------------------- +assert_eq() { + local label="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + PASS=$((PASS + 1)) + echo " ok ${label}" + else + FAIL=$((FAIL + 1)) + echo " FAIL ${label}: expected '${expected}', got '${actual}'" + fi +} + +assert_contains() { + local label="$1" needle="$2" file="$3" + if grep -qF -- "$needle" "$file"; then + PASS=$((PASS + 1)) + echo " ok ${label}" + else + FAIL=$((FAIL + 1)) + echo " FAIL ${label}: '${needle}' not found in $(basename "$file")" + fi +} + +assert_absent() { + local label="$1" needle="$2" file="$3" + if grep -qF -- "$needle" "$file"; then + FAIL=$((FAIL + 1)) + echo " FAIL ${label}: '${needle}' should not appear in $(basename "$file")" + else + PASS=$((PASS + 1)) + echo " ok ${label}" + fi +} + +# --- scratch repo ------------------------------------------------------------- +# Resolve symlinks: on macOS mktemp hands back /var/... while git reports the +# real /private/var/..., and the assertions compare paths verbatim. +TMP_ROOT="$(cd "$(mktemp -d "${TMPDIR:-/tmp}/git-tidy-test.XXXXXX")" && pwd -P)" +cleanup() { + if [[ "$KEEP" == "true" ]]; then + echo "Scratch repo kept at: ${TMP_ROOT}" + else + chmod -R u+w "$TMP_ROOT" 2>/dev/null || true + rm -rf "$TMP_ROOT" + fi +} +trap cleanup EXIT + +ORIGIN="${TMP_ROOT}/origin.git" +WORK="${TMP_ROOT}/work" +OUT="${TMP_ROOT}/report" + +# Isolate from the caller's git identity, hooks, signing, and templates. +export GIT_CONFIG_GLOBAL="${TMP_ROOT}/gitconfig" +export GIT_CONFIG_SYSTEM=/dev/null +export GIT_AUTHOR_NAME="git-tidy test" GIT_AUTHOR_EMAIL="test@example.com" +export GIT_COMMITTER_NAME="git-tidy test" GIT_COMMITTER_EMAIL="test@example.com" +: > "$GIT_CONFIG_GLOBAL" + +g() { git -C "$WORK" "$@"; } + +commit() { + echo "$1" >> "${WORK}/log.txt" + g add -A + g commit -q -m "$1" +} + +info "Building scratch repo at ${TMP_ROOT}" + +git init -q --bare "$ORIGIN" +git init -q "$WORK" +git -C "$WORK" symbolic-ref HEAD refs/heads/main +g remote add origin "$ORIGIN" + +commit "c1" +commit "c2" +C2="$(g rev-parse HEAD)" +commit "c3" +C3="$(g rev-parse HEAD)" +g push -q origin main + +# fresh-start: created off the base tip, nothing committed yet. +g branch fresh main + +# merged-ancestor: sits at an older commit that main already contains. +g branch ancestor "$C2" +g branch 'ancestor;echo-pwned' "$C2" + +# pr-merged-verified: one commit, squash-merged upstream, tip still matches. +g checkout -q -b squashed "$C3" +commit "squashed work" +SQUASHED_TIP="$(g rev-parse HEAD)" +g push -q origin squashed + +# pr-merged-diverged: PR merged at the first commit, then a second was added. +g checkout -q -b diverged "$C3" +commit "diverged work" +DIVERGED_MERGED_AT="$(g rev-parse HEAD)" +commit "extra work after the merge" + +# pr-open: one commit with a PR still open. +g checkout -q -b open-pr "$C3" +commit "open work" +OPEN_TIP="$(g rev-parse HEAD)" +g push -q origin open-pr + +# unmerged-no-pr plus superseded: `sub` is an ancestor of `wip`. +g checkout -q -b wip "$C3" +commit "wip one" +WIP_ONE="$(g rev-parse HEAD)" +commit "wip two" +g branch sub "$WIP_ONE" + +g checkout -q main + +# --- worktrees ---------------------------------------------------------------- +# stale-merged: clean worktree holding a branch already contained in main. +g worktree add -q "${TMP_ROOT}/wt-ancestor" ancestor + +# dirty: uncommitted changes, must never be offered for removal. +g worktree add -q "${TMP_ROOT}/wt-dirty" wip +echo "scratch" > "${TMP_ROOT}/wt-dirty/uncommitted.txt" + +# prunable: registration survives after the directory is deleted by hand. +g worktree add -q --detach "${TMP_ROOT}/wt-gone" "$C2" +rm -rf "${TMP_ROOT}/wt-gone" + +# --- PR fixture --------------------------------------------------------------- +# Columns: headRefName, number, state, mergedAt, headRefOid, url +FIXTURE="${TMP_ROOT}/prs.tsv" +{ + printf 'squashed\t101\tMERGED\t2026-01-01T00:00:00Z\t%s\thttps://example.com/101\n' "$SQUASHED_TIP" + printf 'diverged\t102\tMERGED\t2026-01-02T00:00:00Z\t%s\thttps://example.com/102\n' "$DIVERGED_MERGED_AT" + printf 'open-pr\t103\tOPEN\t-\t%s\thttps://example.com/103\n' "$OPEN_TIP" +} > "$FIXTURE" + +# --- run ---------------------------------------------------------------------- +info "Running audit" +( cd "$WORK" && GIT_TIDY_PR_FIXTURE="$FIXTURE" \ + "${SCRIPT_DIR}/audit.sh" --output "$OUT" --no-size >/dev/null ) + +BRANCHES="${OUT}/branches.tsv" +WORKTREES="${OUT}/worktrees.tsv" +REPORT="${OUT}/report.md" + +category_of() { awk -F'\t' -v b="$1" '$1 == b { print $3 }' "$BRANCHES"; } +disposition_of() { awk -F'\t' -v b="$1" '$1 == b { print $4 }' "$BRANCHES"; } +wt_category_of() { awk -F'\t' -v p="$1" '$1 == p { print $4 }' "$WORKTREES"; } +wt_disposition_of() { awk -F'\t' -v p="$1" '$1 == p { print $5 }' "$WORKTREES"; } + +echo +echo "Branch classification" +assert_eq "main is the base" "base" "$(category_of main)" +assert_eq "fresh is fresh-start" "fresh-start" "$(category_of fresh)" +assert_eq "ancestor is merged-ancestor" "merged-ancestor" "$(category_of ancestor)" +assert_eq "squashed is pr-merged-verified" "pr-merged-verified" "$(category_of squashed)" +assert_eq "diverged is pr-merged-diverged" "pr-merged-diverged" "$(category_of diverged)" +assert_eq "open-pr is pr-open" "pr-open" "$(category_of open-pr)" +assert_eq "wip is unmerged-no-pr" "unmerged-no-pr" "$(category_of wip)" +assert_eq "sub is superseded" "superseded" "$(category_of sub)" + +echo +echo "Branch dispositions" +assert_eq "fresh is never auto-deleted" "ask" "$(disposition_of fresh)" +assert_eq "ancestor deletes with -d" "safe-d" "$(disposition_of ancestor)" +assert_eq "squashed needs -D" "safe-D" "$(disposition_of squashed)" +assert_eq "diverged asks first" "ask" "$(disposition_of diverged)" +assert_eq "open-pr is kept" "keep" "$(disposition_of open-pr)" +assert_eq "wip is kept" "keep" "$(disposition_of wip)" +assert_eq "sub asks first" "ask" "$(disposition_of sub)" + +echo +echo "Branch tips" +assert_eq "branches.tsv keeps full tip SHA" "$C2" \ + "$(awk -F'\t' '$1 == "ancestor" { print $2 }' "$BRANCHES")" + +echo +echo "Remote presence" +assert_eq "pushed branch reads live" "live" "$(awk -F'\t' '$1 == "squashed" { print $7 }' "$BRANCHES")" +assert_eq "local-only branch reads absent" "absent" "$(awk -F'\t' '$1 == "wip" { print $7 }' "$BRANCHES")" + +echo +echo "Worktree classification" +assert_eq "main tree is primary" "primary" "$(wt_category_of "$WORK")" +assert_eq "clean merged tree is stale" "stale-merged" "$(wt_category_of "${TMP_ROOT}/wt-ancestor")" +assert_eq "dirty tree is dirty" "dirty" "$(wt_category_of "${TMP_ROOT}/wt-dirty")" +assert_eq "deleted tree is prunable" "prunable" "$(wt_category_of "${TMP_ROOT}/wt-gone")" +assert_eq "dirty tree is kept" "keep" "$(wt_disposition_of "${TMP_ROOT}/wt-dirty")" + +echo +echo "Remediation block" +assert_contains "prunable registration is pruned" "git worktree prune" "$REPORT" +assert_contains "squash merge uses -D" "git branch -D -- squashed" "$REPORT" +assert_contains "ancestor uses -d" "git branch -d -- ancestor" "$REPORT" +assert_contains "branch name is shell-escaped" \ + 'git branch -d -- ancestor\;echo-pwned' "$REPORT" +assert_eq "each safe branch gets one delete command" "3" \ + "$(grep -cE '^git branch -[dD] -- ' "$REPORT")" +assert_absent "fresh-start is not in a delete command" "branch -d fresh" "$REPORT" +# The dirty tree belongs in the inventory table; what it must never appear in +# is a removal command. +assert_absent "dirty tree is never removed" \ + "git worktree remove \"${TMP_ROOT}/wt-dirty\"" "$REPORT" + +# The worktree holding `ancestor` must be released before the branch delete, +# or `git branch -d` refuses. +WT_LINE="$(grep -n "git worktree remove" "$REPORT" | head -1 | cut -d: -f1 || echo 0)" +BR_LINE="$(grep -n "git branch -d -- ancestor" "$REPORT" | head -1 | cut -d: -f1 || echo 0)" +if [[ "$WT_LINE" -gt 0 && "$BR_LINE" -gt 0 && "$WT_LINE" -lt "$BR_LINE" ]]; then + PASS=$((PASS + 1)); echo " ok worktree removal is ordered before branch deletion" +else + FAIL=$((FAIL + 1)); echo " FAIL worktree removal must precede branch deletion (${WT_LINE} vs ${BR_LINE})" +fi + +echo +echo "Report artifacts" +for f in report.md report.json branches.tsv worktrees.tsv; do + if [[ -s "${OUT}/${f}" ]]; then + PASS=$((PASS + 1)); echo " ok ${f} is non-empty" + else + FAIL=$((FAIL + 1)); echo " FAIL ${f} is missing or empty" + fi +done +assert_contains "report.json keeps full tip SHA" "\"tip\": \"${C2}\"" "${OUT}/report.json" +if have python3; then + if python3 -m json.tool "${OUT}/report.json" >/dev/null 2>&1; then + PASS=$((PASS + 1)); echo " ok report.json parses" + else + FAIL=$((FAIL + 1)); echo " FAIL report.json is not valid JSON" + fi +fi + +# The audit must not have mutated the repo it inspected. +BRANCH_COUNT="$(git -C "$WORK" for-each-ref --format='%(refname)' refs/heads | wc -l | tr -d ' ')" +assert_eq "audit deleted no branches" "9" "$BRANCH_COUNT" + +echo +echo "Checkout remediation" +g branch 'base;evil' main +g checkout -q 'ancestor;echo-pwned' +CHECKOUT_OUT="${OUT}-checkout" +( cd "$WORK" && GIT_TIDY_PR_FIXTURE="$FIXTURE" \ + "${SCRIPT_DIR}/audit.sh" --base 'base;evil' --output "$CHECKOUT_OUT" --no-size >/dev/null ) +assert_contains "checkout target is shell-escaped" \ + $'git checkout base\\;evil # release the branch you are on' "${CHECKOUT_OUT}/report.md" +g checkout -q main +g branch -D 'base;evil' >/dev/null 2>&1 || true + +echo +if [[ "$FAIL" -eq 0 ]]; then + echo "All ${PASS} assertions passed." + exit 0 +fi +echo "${FAIL} of $((PASS + FAIL)) assertions failed." +exit 1