@acme-skunkworks/agent-skills 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/package.json +79 -0
- package/skills/README.md +11 -0
- package/skills/cleanup-repo/README.md +73 -0
- package/skills/cleanup-repo/SKILL.md +289 -0
- package/skills/cleanup-repo/config.example.json +5 -0
- package/skills/cleanup-repo/config.json +5 -0
- package/skills/cleanup-repo/package.json +31 -0
- package/skills/cleanup-repo/references/design-notes.md +56 -0
- package/skills/cleanup-repo/scripts/filesystem-hygiene.mjs +316 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rob Easthope
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# agent-skills
|
|
2
|
+
|
|
3
|
+
Shared agent skills for Claude Code and Cursor, distributed as [skills.sh](https://skills.sh)-compatible bundles. Each skill lives under `skills/<name>/` with a `SKILL.md` manifest at its root.
|
|
4
|
+
|
|
5
|
+
## Installing a skill
|
|
6
|
+
|
|
7
|
+
From any consumer repo:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx skills add https://github.com/acme-skunkworks/agent-skills --skill <name> --agent claude-code --agent cursor --copy
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`--copy` writes real files (not symlinks) so the skill is portable across machines. Don't use `-g` / `--global` — installs should live in the consumer repo.
|
|
14
|
+
|
|
15
|
+
## Repo layout
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
.
|
|
19
|
+
├── .changeset/ # pending changesets + config
|
|
20
|
+
├── .claude/commands/
|
|
21
|
+
│ └── send-it.md # all-in-one finisher (stopgap until the send-it skill ships)
|
|
22
|
+
├── .github/
|
|
23
|
+
│ ├── actions/
|
|
24
|
+
│ │ └── load-repo-config/ # infrastructure/repo-config.yaml → step outputs
|
|
25
|
+
│ └── workflows/
|
|
26
|
+
│ ├── release.yml # publish-only: build → npm (OIDC) → GitHub Packages (dormant)
|
|
27
|
+
│ └── validate.yml # PR gate: build & lint, changelog validation, infra tests
|
|
28
|
+
├── .husky/ # git hooks (block main pushes; lint-staged; strip Claude trailer)
|
|
29
|
+
├── architecture/ # ADRs (sequentially numbered, immutable)
|
|
30
|
+
├── changelog/ # dated per-change release-note entries (companion to CHANGELOG.md)
|
|
31
|
+
├── infrastructure/
|
|
32
|
+
│ ├── repo-config.yaml # non-secret CI/release knobs
|
|
33
|
+
│ ├── scripts/ # changelog .ts helpers + ensure-*.sh tool bootstraps
|
|
34
|
+
│ ├── send-it/ # deterministic helpers for /send-it
|
|
35
|
+
│ └── tests/ # bats (publish scripts) + vitest (changelog)
|
|
36
|
+
├── scripts/ # publish wrappers (npm OIDC + GitHub Packages)
|
|
37
|
+
├── skills/ # one folder per skill
|
|
38
|
+
├── CLAUDE.md
|
|
39
|
+
├── LICENSE
|
|
40
|
+
├── README.md
|
|
41
|
+
└── package.json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The `skills/<name>/` convention may be refined by ADR-0001 (tracked in [ASW-133](https://linear.app/goose-and-hobbes/issue/ASW-133)) once skills.sh's expected layout is double-checked.
|
|
45
|
+
|
|
46
|
+
## Architecture decisions
|
|
47
|
+
|
|
48
|
+
ADRs land under `architecture/` as `NNNN-<slug>.md`. ADR-0001 — the foundational decision record for skill layout, distribution conventions, and semver discipline — is forthcoming.
|
|
49
|
+
|
|
50
|
+
## Contributing
|
|
51
|
+
|
|
52
|
+
See [CLAUDE.md](./CLAUDE.md) for the conventions (Conventional Commits, draft PRs, Changesets per behavioural change).
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@acme-skunkworks/agent-skills",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Shared agent skills (Claude Code, Cursor) distributed as skills.sh-compatible bundles.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"agent-skills",
|
|
8
|
+
"claude-code",
|
|
9
|
+
"cursor",
|
|
10
|
+
"skills",
|
|
11
|
+
"skills.sh"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/acme-skunkworks/agent-skills#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/acme-skunkworks/agent-skills/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/acme-skunkworks/agent-skills.git"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"author": {
|
|
23
|
+
"name": "Rob Easthope",
|
|
24
|
+
"url": "https://github.com/RobEasthope"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"skills/"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"changelog:finalise": "tsx infrastructure/scripts/finalise-changelog.ts",
|
|
31
|
+
"changeset": "changeset",
|
|
32
|
+
"changeset:version": "changeset version && pnpm changelog:finalise",
|
|
33
|
+
"lint:sh": "bash -c 'if command -v shellcheck >/dev/null 2>&1; then shellcheck scripts/*.sh infrastructure/scripts/*.sh .husky/pre-commit .husky/pre-push .husky/commit-msg; elif [ \"$(uname -s)\" = \"Darwin\" ]; then echo \"⚠️ shellcheck not installed — skipping. Install: brew install shellcheck\"; else echo \"⚠️ shellcheck not installed — skipping. Install: apt-get install shellcheck\"; fi'",
|
|
34
|
+
"lint:workflows": "actionlint",
|
|
35
|
+
"lint:yaml": "yamllint .",
|
|
36
|
+
"prepare": "husky",
|
|
37
|
+
"release": "changeset publish",
|
|
38
|
+
"release:manual": "npm publish --access public --provenance=false",
|
|
39
|
+
"release:manual:dry": "npm publish --access public --provenance=false --dry-run",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"test:sh": "bash -c 'if command -v bats >/dev/null 2>&1; then bats infrastructure/tests/*.bats; elif [ \"$(uname -s)\" = \"Darwin\" ]; then echo \"⚠️ bats not installed — skipping. Install: brew install bats-core\"; else echo \"⚠️ bats not installed — skipping. Install: apt-get install bats\"; fi'",
|
|
42
|
+
"test:watch": "vitest",
|
|
43
|
+
"validate:changelog": "tsx infrastructure/scripts/validate-changelog.ts",
|
|
44
|
+
"version": "changeset version"
|
|
45
|
+
},
|
|
46
|
+
"lint-staged": {
|
|
47
|
+
"**/*.sh": [
|
|
48
|
+
"bash -c 'if command -v shellcheck >/dev/null 2>&1; then shellcheck \"$@\"; elif [ \"$(uname -s)\" = \"Darwin\" ]; then echo \"⚠️ shellcheck not installed — skipping. Install: brew install shellcheck\"; else echo \"⚠️ shellcheck not installed — skipping. Install: apt-get install shellcheck\"; fi' --"
|
|
49
|
+
],
|
|
50
|
+
"**/*.{yml,yaml}": [
|
|
51
|
+
"bash -c 'if command -v yamllint >/dev/null 2>&1; then yamllint \"$@\"; elif [ \"$(uname -s)\" = \"Darwin\" ]; then echo \"⚠️ yamllint not installed — skipping. Install: brew install yamllint\"; else echo \"⚠️ yamllint not installed — skipping. Install: pip install --user yamllint==1.37.1\"; fi' --"
|
|
52
|
+
],
|
|
53
|
+
".github/workflows/*.{yml,yaml}": [
|
|
54
|
+
"bash -c 'if command -v actionlint >/dev/null 2>&1; then actionlint \"$@\"; elif [ \"$(uname -s)\" = \"Darwin\" ]; then echo \"⚠️ actionlint not installed — skipping. Install: brew install actionlint\"; else echo \"⚠️ actionlint not installed — skipping. Install via the pinned bootstrap in infrastructure/scripts/ensure-actionlint.sh\"; fi' --"
|
|
55
|
+
],
|
|
56
|
+
"changelog/*.md": [
|
|
57
|
+
"bash -c 'pnpm validate:changelog' --"
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@changesets/cli": "^2.31.0",
|
|
62
|
+
"@types/node": "^25.6.0",
|
|
63
|
+
"gray-matter": "^4.0.3",
|
|
64
|
+
"husky": "^9.1.7",
|
|
65
|
+
"lint-staged": "^16.3.2",
|
|
66
|
+
"tsx": "^4.21.1",
|
|
67
|
+
"typescript": "^6.0.3",
|
|
68
|
+
"vitest": "^4.1.6"
|
|
69
|
+
},
|
|
70
|
+
"packageManager": "pnpm@10.33.0",
|
|
71
|
+
"engines": {
|
|
72
|
+
"node": ">=22"
|
|
73
|
+
},
|
|
74
|
+
"publishConfig": {
|
|
75
|
+
"access": "public",
|
|
76
|
+
"provenance": true,
|
|
77
|
+
"registry": "https://registry.npmjs.org"
|
|
78
|
+
}
|
|
79
|
+
}
|
package/skills/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Skills
|
|
2
|
+
|
|
3
|
+
Each skill lives in `skills/<name>/` as a [skills.sh](https://skills.sh)-compatible bundle with a `SKILL.md` manifest at its root.
|
|
4
|
+
|
|
5
|
+
Consumers install a skill from this repo with:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx skills add https://github.com/acme-skunkworks/agent-skills --skill <name> --agent claude-code --agent cursor --copy
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
No skills here yet — the first one (`cleanup-repo`) is tracked under [ASW-134](https://linear.app/goose-and-hobbes/issue/ASW-134). The exact bundle layout may be refined by ADR-0001 (ASW-133) once skills.sh's expected structure is double-checked.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# cleanup-repo
|
|
2
|
+
|
|
3
|
+
Clean up a Git repository's merged branches and worktrees, then prune filesystem
|
|
4
|
+
cruft (recursively-empty directories and orphaned `node_modules/`) — behind a
|
|
5
|
+
single confirmation gate, with a `--dry-run` preview.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
From any consumer repo:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx skills add https://github.com/acme-skunkworks/agent-skills --skill cleanup-repo --agent claude-code --agent cursor --copy
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`--copy` writes real files so the bundle is portable. Don't use `-g` / `--global`
|
|
16
|
+
— the install should live in the consumer repo.
|
|
17
|
+
|
|
18
|
+
## Configure
|
|
19
|
+
|
|
20
|
+
The shipped [`config.json`](config.json) carries **ACME Skunkworks defaults**
|
|
21
|
+
(`linearTeamName` and `issueKeys`) — update them for your organisation on install,
|
|
22
|
+
or the Linear lookups will target the wrong team and branch issue-IDs won't match.
|
|
23
|
+
A neutral [`config.example.json`](config.example.json) ships alongside it as a
|
|
24
|
+
template — copy it over `config.json` and fill in your values, or edit
|
|
25
|
+
`config.json` directly.
|
|
26
|
+
|
|
27
|
+
| Key | Meaning | Default |
|
|
28
|
+
| --- | --- | --- |
|
|
29
|
+
| `linearTeamName` | Linear team **name** used to resolve the live `Done` state. Stable across team-key renames. | `"ACME Skunkworks"` |
|
|
30
|
+
| `issueKeys` | Team-key prefixes that may appear in branch names; the issue-ID regex is built from these. Keep legacy keys so old branches still match. | `["ASW", "AKW", "SKW"]` |
|
|
31
|
+
| `protectedBranches` | Branches never deleted, locally or remotely. | `["main"]` |
|
|
32
|
+
|
|
33
|
+
> **Base branch.** v1 assumes the trunk is `origin/main` — merge detection
|
|
34
|
+
> (`git branch --merged origin/main`) is hard-coded to it. Repositories on
|
|
35
|
+
> `master` / `develop` aren't supported yet; a `mainBranch` config key is noted
|
|
36
|
+
> in [`references/design-notes.md`](references/design-notes.md) as a future
|
|
37
|
+
> extension.
|
|
38
|
+
|
|
39
|
+
## Requirements
|
|
40
|
+
|
|
41
|
+
- `git` and `gh` CLIs (`gh` authenticated for the squash-merge detection pass).
|
|
42
|
+
- Node.js ≥22 (per the package's `engines`), for the bundled filesystem-hygiene script.
|
|
43
|
+
- The Linear MCP server is **optional**: the issue-status check and the `Done`
|
|
44
|
+
writeback are skipped silently when it is unavailable.
|
|
45
|
+
|
|
46
|
+
## What it does
|
|
47
|
+
|
|
48
|
+
Two passes, one confirmation:
|
|
49
|
+
|
|
50
|
+
1. **Branch/worktree pass** — fetches and prunes, removes merged worktrees
|
|
51
|
+
(guarding ones with uncommitted changes), deletes merged local and remote
|
|
52
|
+
branches using two-pass detection (`git branch --merged origin/main` plus
|
|
53
|
+
`gh pr list … --state merged` for squash-merges), optionally writes linked
|
|
54
|
+
Linear issues back to `Done` (default no).
|
|
55
|
+
2. **Filesystem-hygiene pass** — removes top-most recursively-empty directories
|
|
56
|
+
(placeholder-only `.gitkeep` / `.gitignore` directories are left alone; `.git/`
|
|
57
|
+
is hard-protected) and orphaned `node_modules/` directories (those whose parent
|
|
58
|
+
has no `package.json`). The two filesystem groups are surfaced separately.
|
|
59
|
+
|
|
60
|
+
## Behaviour parity
|
|
61
|
+
|
|
62
|
+
The **branch/worktree pass** is a faithful port of the `/cleanup-branches` slash
|
|
63
|
+
command (canonical reference: Octavo's `.claude/commands/cleanup-branches.md`):
|
|
64
|
+
the same two-pass merge detection, the same uncommitted-changes worktree guard,
|
|
65
|
+
the same protected-branch handling, and the same opt-in, default-no Linear `Done`
|
|
66
|
+
transition.
|
|
67
|
+
|
|
68
|
+
The **filesystem-hygiene pass is new** to this skill — it has no equivalent in
|
|
69
|
+
`/cleanup-branches`. A single bundled script computes the removal set once, so the
|
|
70
|
+
`--dry-run` preview lists exactly what a real run removes.
|
|
71
|
+
|
|
72
|
+
See [`references/design-notes.md`](references/design-notes.md) for the `cleanup-repo`
|
|
73
|
+
naming rationale and the future extensions the name deliberately leaves room for.
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cleanup-repo
|
|
3
|
+
description: >-
|
|
4
|
+
Clean up a Git repository's merged branches and worktrees, then prune
|
|
5
|
+
filesystem cruft (recursively-empty directories and orphaned node_modules).
|
|
6
|
+
Use when asked to clean up / tidy / prune merged branches, remove stale or
|
|
7
|
+
finished worktrees, delete branches whose PRs have already merged (including
|
|
8
|
+
squash-merges), or sweep empty directories and leftover node_modules. Two-pass
|
|
9
|
+
merge detection (git ancestry plus merged GitHub PRs), an uncommitted-changes
|
|
10
|
+
guard on worktrees, an optional Linear "Done" writeback, a single confirmation
|
|
11
|
+
gate, and a --dry-run preview. Protected branches are never touched.
|
|
12
|
+
license: MIT
|
|
13
|
+
compatibility: >-
|
|
14
|
+
Requires the `git` and `gh` CLIs. The optional Linear status check needs the
|
|
15
|
+
Linear MCP server; if it is unavailable, skip that step silently. The
|
|
16
|
+
filesystem pass needs Node.js ≥22.
|
|
17
|
+
metadata:
|
|
18
|
+
version: 0.1.0
|
|
19
|
+
allowed-tools: Bash(git:*), Bash(gh:*), Bash(node:*), mcp__linear-server__get_issue, mcp__linear-server__save_issue, mcp__linear-server__list_issue_statuses
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# cleanup-repo
|
|
23
|
+
|
|
24
|
+
Remove merged Git worktrees and branches, then run a filesystem-hygiene pass —
|
|
25
|
+
all behind a single confirmation gate, with a `--dry-run` preview.
|
|
26
|
+
|
|
27
|
+
The branch/worktree pass mirrors the behaviour of the `/cleanup-branches`
|
|
28
|
+
slash command it was extracted from. The filesystem-hygiene pass (recursively-empty
|
|
29
|
+
directories and orphan `node_modules/`) is new to this skill. See
|
|
30
|
+
[`references/design-notes.md`](references/design-notes.md) for the naming
|
|
31
|
+
rationale and the deliberately-deferred future extensions.
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
Three knobs live in [`config.json`](config.json) beside this file. Read it at the
|
|
36
|
+
start of a run and use its values throughout. Edit your copied `config.json` to
|
|
37
|
+
match the consuming repo:
|
|
38
|
+
|
|
39
|
+
| Key | Meaning | Default |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| `linearTeamName` | Linear team **name** used to resolve the live `Done` state. Use the name, not the key — the key is renamed over time but the name is stable. | `"ACME Skunkworks"` |
|
|
42
|
+
| `issueKeys` | Team-key prefixes that may appear in branch names. The issue-ID regex is built from these. Keep legacy keys so old branches still match. | `["ASW", "AKW", "SKW"]` |
|
|
43
|
+
| `protectedBranches` | Branches that are **never** deleted, locally or remotely. | `["main"]` |
|
|
44
|
+
|
|
45
|
+
Build the issue-ID regex by joining `issueKeys` with `|`:
|
|
46
|
+
`\b((?:ASW|AKW|SKW)-\d+)\b` for the defaults above. Match it against the
|
|
47
|
+
**upper-cased** branch name (branches like `asw-7-as-acquired` carry the key in
|
|
48
|
+
lower case).
|
|
49
|
+
|
|
50
|
+
If the Linear MCP server is not available, skip the Linear status check and the
|
|
51
|
+
optional `Done` writeback silently — they are not required for branch cleanup.
|
|
52
|
+
|
|
53
|
+
## Usage modes
|
|
54
|
+
|
|
55
|
+
**Dry run** — preview everything, change nothing:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
cleanup-repo --dry-run
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Normal** — preview, then delete after a single confirmation:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
cleanup-repo
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
There is one bulk confirmation gate (Step 8) covering both the branch/worktree
|
|
68
|
+
pass and the filesystem pass. `--dry-run` short-circuits before that gate.
|
|
69
|
+
|
|
70
|
+
## Process
|
|
71
|
+
|
|
72
|
+
### Step 1 — Fetch latest from remote
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
git fetch --prune origin
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Step 2 — Identify worktrees to remove
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
git worktree list
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- List all worktrees except the main repository directory (the primary working
|
|
85
|
+
directory is never removed).
|
|
86
|
+
- Identify worktrees whose branch is fully merged into `main`.
|
|
87
|
+
- Identify worktrees in detached-HEAD state — treat as abandoned, safe to remove.
|
|
88
|
+
- Identify worktrees with uncommitted changes: `git -C <path> status --porcelain`
|
|
89
|
+
non-empty. These are surfaced separately in Step 6 and **never removed
|
|
90
|
+
automatically** — the user handles them manually (`git worktree remove --force
|
|
91
|
+
<path>` once they have moved or discarded the work).
|
|
92
|
+
- Worktree location is irrelevant to detection; `git worktree list` enumerates
|
|
93
|
+
them wherever they live (e.g. a gitignored `.claude/worktrees/<branch>/`).
|
|
94
|
+
|
|
95
|
+
### Step 3 — Identify merged branches (two-pass)
|
|
96
|
+
|
|
97
|
+
**Pass 1 — Git-merged branches:**
|
|
98
|
+
|
|
99
|
+
- Find local branches merged into `main`: `git branch --merged origin/main`.
|
|
100
|
+
- Exclude every branch in `protectedBranches`.
|
|
101
|
+
- Determine which of those branches also still exist on the remote.
|
|
102
|
+
|
|
103
|
+
**Pass 2 — Squash-merged branches:**
|
|
104
|
+
|
|
105
|
+
A squash merge lands a single new commit on `main`, so the branch's own commits
|
|
106
|
+
are never ancestors of `main` and `git branch --merged` misses it. For each local
|
|
107
|
+
branch **not** caught in Pass 1 (and not protected):
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
gh pr list --head <branch-name> --state merged --json number,title --limit 1
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`gh` auto-detects the repository from the current directory's remote, so no
|
|
114
|
+
`--repo` flag is needed.
|
|
115
|
+
|
|
116
|
+
- A non-empty result means the branch has a merged PR — treat it as merged and
|
|
117
|
+
add it to the cleanup list.
|
|
118
|
+
- An empty result means the branch is genuinely unmerged — leave it alone.
|
|
119
|
+
- Record the PR number and title for the summary so the user can verify.
|
|
120
|
+
|
|
121
|
+
### Step 4 — Check Linear issue status for merged branches
|
|
122
|
+
|
|
123
|
+
For each merged branch whose name contains an issue ID (extract with the regex
|
|
124
|
+
built from `issueKeys`, matched against the upper-cased branch name):
|
|
125
|
+
|
|
126
|
+
- Fetch the issue via `mcp__linear-server__get_issue`.
|
|
127
|
+
- Track any issue that is **not** in `Done` status.
|
|
128
|
+
|
|
129
|
+
Skip this step silently if the Linear MCP server is unavailable.
|
|
130
|
+
|
|
131
|
+
### Step 5 — Run the filesystem-hygiene detection
|
|
132
|
+
|
|
133
|
+
Run the bundled script against the repository root to get the candidate list.
|
|
134
|
+
It is read-only without `--apply`:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
node scripts/filesystem-hygiene.mjs <repo-root> --json
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
It prints `{ "emptyDirs": [...], "orphanNodeModules": [...] }`:
|
|
141
|
+
|
|
142
|
+
- **`emptyDirs`** — top-most recursively-empty directories (no files anywhere in
|
|
143
|
+
the subtree). Directories holding any file — including a `.gitkeep` /
|
|
144
|
+
`.gitignore` placeholder — are left alone. `.git/` is never traversed.
|
|
145
|
+
- **`orphanNodeModules`** — `node_modules/` directories whose immediate parent has
|
|
146
|
+
no `package.json` (strict; no workspace inference). Removing one re-installs is
|
|
147
|
+
needed if the parent was not actually meant to be gone — which is why these are
|
|
148
|
+
surfaced **separately**.
|
|
149
|
+
|
|
150
|
+
This detection is read-only and feeds the Step 6 preview. One subtlety: Step 9
|
|
151
|
+
removes worktrees **before** re-running the detection with `--apply`, so the apply
|
|
152
|
+
pass can additionally sweep a parent that becomes empty only once its worktrees are
|
|
153
|
+
gone (e.g. `.claude/worktrees/`). Such a directory won't appear in this pre-removal
|
|
154
|
+
detect output — predict it from the worktree-removal list and label it as a
|
|
155
|
+
post-removal sweep in the preview, so the user isn't surprised when `--apply`
|
|
156
|
+
removes it.
|
|
157
|
+
|
|
158
|
+
### Step 6 — Display everything to be deleted
|
|
159
|
+
|
|
160
|
+
Show clear, counted lists. Keep the filesystem groups separate so the user can
|
|
161
|
+
eyeball them:
|
|
162
|
+
|
|
163
|
+
```text
|
|
164
|
+
## Worktrees to Remove (3)
|
|
165
|
+
- /path/.claude/worktrees/ASW-7-as-acquired (merged)
|
|
166
|
+
- /path/.claude/worktrees/ASW-9-button-styling (squash-merged, PR #42)
|
|
167
|
+
- /path/.claude/worktrees/orphan-detached (detached HEAD)
|
|
168
|
+
|
|
169
|
+
## Worktrees Skipped — Uncommitted Changes (1)
|
|
170
|
+
- /path/.claude/worktrees/ASW-12-wip (merged, but `git status` is non-empty;
|
|
171
|
+
remove manually with `git worktree remove --force <path>`)
|
|
172
|
+
|
|
173
|
+
## Local Branches to Delete (3)
|
|
174
|
+
- ASW-7-as-acquired (merged)
|
|
175
|
+
- ASW-9-button-styling (squash-merged, PR #42 "Fix button styling")
|
|
176
|
+
- chore-update-deps (merged)
|
|
177
|
+
|
|
178
|
+
## Remote Branches to Delete (2)
|
|
179
|
+
- ASW-7-as-acquired
|
|
180
|
+
- ASW-9-button-styling
|
|
181
|
+
|
|
182
|
+
## Linear Issues Still Open (1)
|
|
183
|
+
- ASW-9 "Button styling" — currently In Review (branch: ASW-9-button-styling)
|
|
184
|
+
|
|
185
|
+
## Empty Directories to Remove (1)
|
|
186
|
+
- /path/.claude/worktrees (predicted: empty once the worktrees above are removed)
|
|
187
|
+
|
|
188
|
+
## Orphan node_modules to Remove (1)
|
|
189
|
+
- /path/old-package/node_modules (no sibling package.json)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Step 7 — Dry-run handling
|
|
193
|
+
|
|
194
|
+
If `--dry-run` is set, STOP here. Print `DRY RUN MODE - No changes were made` and
|
|
195
|
+
exit without changing anything.
|
|
196
|
+
|
|
197
|
+
### Step 8 — Confirmation (normal mode only)
|
|
198
|
+
|
|
199
|
+
Ask once: `Do you want to delete these worktrees, branches, and files? (yes/no)`.
|
|
200
|
+
Proceed only if the user answers `yes`; otherwise exit without deleting.
|
|
201
|
+
|
|
202
|
+
### Step 9 — Execute, in order
|
|
203
|
+
|
|
204
|
+
Order matters. Worktrees must go before their branches, and the filesystem pass
|
|
205
|
+
runs **after** worktree removal so a just-emptied worktree parent (e.g.
|
|
206
|
+
`.claude/worktrees/`) is swept in the same run.
|
|
207
|
+
|
|
208
|
+
1. **Remove worktrees** (skip the uncommitted-changes group from Step 6):
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
git worktree remove <path>
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
2. **Prune stale worktree references:**
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
git worktree prune
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
3. **Delete local branches:**
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
git branch -d <branch-name> # Pass 1 (git-merged) — safe delete
|
|
224
|
+
git branch -D <branch-name> # Pass 2 (squash-merged) — force is safe: a
|
|
225
|
+
# merged PR was confirmed via the GitHub API
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
4. **Delete remote branches** that still exist:
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
git push origin --delete <branch-name>
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
5. **Filesystem-hygiene removal** — re-run the bundled script with `--apply`. It
|
|
235
|
+
removes exactly the same set it detects and prints what it removed:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
node scripts/filesystem-hygiene.mjs <repo-root> --apply
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Removing an orphan `node_modules/` can leave its parent empty; that parent is
|
|
242
|
+
intentionally left for a follow-up run rather than swept in this snapshot.
|
|
243
|
+
|
|
244
|
+
### Step 10 — Optional Linear `Done` writeback
|
|
245
|
+
|
|
246
|
+
If any Linear issues from Step 4 are not `Done`:
|
|
247
|
+
|
|
248
|
+
- Ask: `These Linear issues are linked to merged branches but aren't Done. Set
|
|
249
|
+
them to Done? (yes/no)`. **Default no** — Linear's GitHub integration normally
|
|
250
|
+
handles this on PR merge, so this exists only for the rare case where it didn't
|
|
251
|
+
fire (e.g. the issue ID was added after the merge).
|
|
252
|
+
- If yes:
|
|
253
|
+
- Resolve the live `Done` state ID **once** via
|
|
254
|
+
`mcp__linear-server__list_issue_statuses` with `team: <linearTeamName>` —
|
|
255
|
+
state IDs are per-team and the team key changes over time, so pass the team
|
|
256
|
+
*name*.
|
|
257
|
+
- For each open issue, call `mcp__linear-server__save_issue` with
|
|
258
|
+
`state: <Done state ID>`.
|
|
259
|
+
- If no, skip without changes.
|
|
260
|
+
|
|
261
|
+
### Step 11 — Summary
|
|
262
|
+
|
|
263
|
+
Report counts: worktrees removed, local branches deleted, remote branches
|
|
264
|
+
deleted, empty directories removed, orphan `node_modules/` removed, Linear issues
|
|
265
|
+
set to `Done` (if any). List the names of deleted items.
|
|
266
|
+
|
|
267
|
+
## Important rules
|
|
268
|
+
|
|
269
|
+
- **Dry-run** previews without deleting (`--dry-run`).
|
|
270
|
+
- **Confirmation required** before any deletion (single bulk gate).
|
|
271
|
+
- **Protected branches** (`protectedBranches`) are never touched.
|
|
272
|
+
- **Merged only**: a branch is deleted only if merged into `main` via git
|
|
273
|
+
ancestry **or** a merged GitHub PR (squash merges).
|
|
274
|
+
- **Worktrees first**, then branches; filesystem pass last.
|
|
275
|
+
- **Uncommitted worktrees** are never force-removed automatically.
|
|
276
|
+
- **`.git/` and the main worktree** are never touched.
|
|
277
|
+
|
|
278
|
+
## Error handling
|
|
279
|
+
|
|
280
|
+
- Skip (and report) any worktree or branch that fails to remove; continue with
|
|
281
|
+
the rest.
|
|
282
|
+
- Skip remote branches that no longer exist (already deleted).
|
|
283
|
+
- If `gh pr list` fails for a branch (network, rate limit, auth), log a warning
|
|
284
|
+
and continue — do not treat the branch as merged.
|
|
285
|
+
- If the Linear MCP server is unavailable, skip the Linear steps silently.
|
|
286
|
+
|
|
287
|
+
## Arguments
|
|
288
|
+
|
|
289
|
+
$ARGUMENTS
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@acme-skunkworks/skill-cleanup-repo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Agent skill: clean up merged Git branches and worktrees, plus a filesystem-hygiene pass (empty directories and orphan node_modules).",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"agent-skill",
|
|
8
|
+
"claude-code",
|
|
9
|
+
"cursor",
|
|
10
|
+
"git",
|
|
11
|
+
"cleanup",
|
|
12
|
+
"worktree"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/acme-skunkworks/agent-skills/tree/main/skills/cleanup-repo#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/acme-skunkworks/agent-skills/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/acme-skunkworks/agent-skills.git",
|
|
21
|
+
"directory": "skills/cleanup-repo"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": {
|
|
25
|
+
"name": "Rob Easthope",
|
|
26
|
+
"url": "https://github.com/RobEasthope"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# cleanup-repo — design notes
|
|
2
|
+
|
|
3
|
+
## Why `cleanup-repo`, not `cleanup-branches`
|
|
4
|
+
|
|
5
|
+
The skill lifts an existing slash command called `/cleanup-branches`, but it is
|
|
6
|
+
named `cleanup-repo` deliberately:
|
|
7
|
+
|
|
8
|
+
- It already does more than branches — it removes **worktrees** (with an
|
|
9
|
+
uncommitted-changes guard and detached-HEAD handling) and runs a
|
|
10
|
+
**filesystem-hygiene** pass.
|
|
11
|
+
- The broader name leaves room for further repo-hygiene extensions without
|
|
12
|
+
another rename.
|
|
13
|
+
|
|
14
|
+
Per-repo slash-command names are a consumer choice. A consumer can expose this as
|
|
15
|
+
`/cleanup-repo`, or keep `/cleanup-branches` as a friendlier alias.
|
|
16
|
+
|
|
17
|
+
## Initial scope
|
|
18
|
+
|
|
19
|
+
- Merged-worktree and merged-branch cleanup (two-pass detection), an opt-in Linear
|
|
20
|
+
`Done` writeback, a single confirmation gate, and a `--dry-run` preview — at
|
|
21
|
+
parity with `/cleanup-branches`.
|
|
22
|
+
- **New:** a filesystem-hygiene pass — recursively-empty directory pruning and
|
|
23
|
+
orphan `node_modules/` pruning.
|
|
24
|
+
|
|
25
|
+
## Deliberate non-goals (v1)
|
|
26
|
+
|
|
27
|
+
- **The filesystem pass is not parameterised.** The placeholder allowlist
|
|
28
|
+
(`.gitkeep`, `.gitignore`) is fixed, and the orphan-`node_modules` rule is fixed
|
|
29
|
+
at the strict "no sibling `package.json`" check. Either can become a config knob
|
|
30
|
+
in a future minor bump if a real consumer case demands it.
|
|
31
|
+
- **No workspace-membership inference** for orphan `node_modules/` — strict
|
|
32
|
+
parent-`package.json` check only.
|
|
33
|
+
- **Single-snapshot removal within `apply()`.** `apply()` detects once and removes
|
|
34
|
+
that snapshot; removing an orphan `node_modules/` can leave its parent empty, but
|
|
35
|
+
that parent is left for a follow-up run rather than swept in the same snapshot.
|
|
36
|
+
Note the skill runs the filesystem detection twice — a read-only pass for the
|
|
37
|
+
preview, then `apply()` **after** worktree removal — so the apply pass may sweep a
|
|
38
|
+
worktree parent (e.g. `.claude/worktrees/`) that the pre-removal preview could not
|
|
39
|
+
yet see. The skill predicts these in the preview from the worktree-removal list.
|
|
40
|
+
- **Merge-detection base branch is hardcoded to `origin/main`.** Both passes (git
|
|
41
|
+
ancestry and merged-PR lookup) assume `main`; a consumer defaulting to `master` or
|
|
42
|
+
`develop` must edit the skill. A future minor bump should add a `mainBranch` config
|
|
43
|
+
key alongside `protectedBranches`.
|
|
44
|
+
|
|
45
|
+
## Future extensions (out of scope, enabled by the name)
|
|
46
|
+
|
|
47
|
+
Each would be a follow-up issue and a Changeset minor bump:
|
|
48
|
+
|
|
49
|
+
- Pruning stale local tags (`git tag --merged main` style).
|
|
50
|
+
- An optional reflog / garbage-collection trigger after large cleanups.
|
|
51
|
+
- Surfacing local branches with no upstream that have been stale for N days.
|
|
52
|
+
- Build-artifact cleanup (`dist/`, `build/`, `.next/`, `coverage/`, …) — only if a
|
|
53
|
+
generalisable heuristic emerges; tool-specific cleanup stays the user's job.
|
|
54
|
+
|
|
55
|
+
Explicitly **not** planned: lockfile / package-manager cache cleanup
|
|
56
|
+
(`pnpm store prune`, npm/yarn caches) — a different concern on a different cadence.
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Filesystem-hygiene pass for the cleanup-repo skill.
|
|
3
|
+
//
|
|
4
|
+
// Detects two kinds of cruft and (optionally) removes them:
|
|
5
|
+
// - emptyDirs : recursively-empty directories — a directory whose
|
|
6
|
+
// entire subtree contains no files at all. The top-most
|
|
7
|
+
// such directory is reported (removing it takes its
|
|
8
|
+
// empty descendants with it). A directory holding any
|
|
9
|
+
// file — including a `.gitkeep` / `.gitignore`
|
|
10
|
+
// placeholder — is NOT empty and is left alone.
|
|
11
|
+
// - orphanNodeModules : `node_modules/` directories whose immediate parent has
|
|
12
|
+
// no `package.json` (strict; no workspace inference).
|
|
13
|
+
//
|
|
14
|
+
// `.git/` is hard-protected: never traversed, and its presence marks the
|
|
15
|
+
// containing directory as non-empty (so a nested working tree is never swept).
|
|
16
|
+
// `node_modules/` is never traversed (huge, and handled by the orphan check).
|
|
17
|
+
//
|
|
18
|
+
// For a given filesystem state the SAME detection drives both the report and
|
|
19
|
+
// the removal, so `--apply` removes exactly what a plain run lists. (The skill
|
|
20
|
+
// runs detection twice — before and after worktree removal — so its end-to-end
|
|
21
|
+
// preview can still pick up parents emptied by that removal; see SKILL.md.)
|
|
22
|
+
//
|
|
23
|
+
// Usage:
|
|
24
|
+
// node filesystem-hygiene.mjs [root] # print detection JSON (default cwd)
|
|
25
|
+
// node filesystem-hygiene.mjs [root] --json # same, explicit
|
|
26
|
+
// node filesystem-hygiene.mjs [root] --apply # remove the detected set, print JSON
|
|
27
|
+
// node filesystem-hygiene.mjs --self-test # run built-in fixtures
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
readdirSync,
|
|
31
|
+
existsSync,
|
|
32
|
+
statSync,
|
|
33
|
+
rmSync,
|
|
34
|
+
mkdtempSync,
|
|
35
|
+
mkdirSync,
|
|
36
|
+
writeFileSync,
|
|
37
|
+
} from "node:fs";
|
|
38
|
+
import { join, dirname, resolve, sep } from "node:path";
|
|
39
|
+
import { tmpdir } from "node:os";
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
41
|
+
|
|
42
|
+
// Recurse a directory. Returns true when the directory is recursively empty
|
|
43
|
+
// (its subtree contains no files). Side effects: pushes top-most empty
|
|
44
|
+
// directories into `emptyDirs` and every node_modules path into `nodeModulesDirs`.
|
|
45
|
+
function scan(dir, emptyDirs, nodeModulesDirs) {
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
49
|
+
} catch {
|
|
50
|
+
// Unreadable directory — treat as content so we never remove it.
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let hasContent = false;
|
|
55
|
+
const emptyChildDirs = [];
|
|
56
|
+
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
const full = join(dir, entry.name);
|
|
59
|
+
|
|
60
|
+
if (entry.isSymbolicLink()) {
|
|
61
|
+
// Never follow or remove through symlinks.
|
|
62
|
+
hasContent = true;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (entry.isFile()) {
|
|
66
|
+
// Any file — placeholders included — counts as content.
|
|
67
|
+
hasContent = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (entry.isDirectory()) {
|
|
71
|
+
if (entry.name === ".git") {
|
|
72
|
+
hasContent = true; // protect working trees / nested repos
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (entry.name === "node_modules") {
|
|
76
|
+
nodeModulesDirs.push(full);
|
|
77
|
+
hasContent = true; // not traversed; handled by the orphan check
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const childEmpty = scan(full, emptyDirs, nodeModulesDirs);
|
|
81
|
+
if (childEmpty) {
|
|
82
|
+
emptyChildDirs.push(full);
|
|
83
|
+
} else {
|
|
84
|
+
hasContent = true;
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// Sockets, FIFOs, devices — treat as content.
|
|
89
|
+
hasContent = true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (hasContent) {
|
|
93
|
+
// This directory stays; its recursively-empty children are the top-most
|
|
94
|
+
// empties (their parent has content), so they are the ones to remove.
|
|
95
|
+
for (const child of emptyChildDirs) emptyDirs.push(child);
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
// No content anywhere in this subtree: this whole directory is removable.
|
|
99
|
+
// Don't record its children — the parent records this directory instead.
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function detect(root) {
|
|
104
|
+
if (!existsSync(root)) {
|
|
105
|
+
throw new Error(`Root path does not exist: ${root}`);
|
|
106
|
+
}
|
|
107
|
+
if (!statSync(root).isDirectory()) {
|
|
108
|
+
throw new Error(`Root path is not a directory: ${root}`);
|
|
109
|
+
}
|
|
110
|
+
const emptyDirs = [];
|
|
111
|
+
const nodeModulesDirs = [];
|
|
112
|
+
// The root itself is never a removal candidate.
|
|
113
|
+
scan(root, emptyDirs, nodeModulesDirs);
|
|
114
|
+
|
|
115
|
+
const orphanNodeModules = nodeModulesDirs.filter(
|
|
116
|
+
(nm) => !existsSync(join(dirname(nm), "package.json")),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
emptyDirs: emptyDirs.sort(),
|
|
121
|
+
orphanNodeModules: orphanNodeModules.sort(),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function apply(root) {
|
|
126
|
+
const result = detect(root);
|
|
127
|
+
const removed = [];
|
|
128
|
+
const failed = [];
|
|
129
|
+
// Isolate per-path failures: one un-removable entry (permissions, a race)
|
|
130
|
+
// must not abort the rest. Report what was removed and what wasn't.
|
|
131
|
+
for (const path of [...result.orphanNodeModules, ...result.emptyDirs]) {
|
|
132
|
+
try {
|
|
133
|
+
rmSync(path, { recursive: true, force: true });
|
|
134
|
+
removed.push(path);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
failed.push({ path, error: err.message });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { ...result, removed, failed };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseArgs(argv) {
|
|
143
|
+
const flags = new Set(argv.filter((a) => a.startsWith("--")));
|
|
144
|
+
const positional = argv.filter((a) => !a.startsWith("--"));
|
|
145
|
+
return { flags, root: positional[0] ?? process.cwd() };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---- self-test ----------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
function buildFixture() {
|
|
151
|
+
const root = mkdtempSync(join(tmpdir(), "cleanup-repo-fs-"));
|
|
152
|
+
const d = (...p) => {
|
|
153
|
+
const full = join(root, ...p);
|
|
154
|
+
mkdirSync(full, { recursive: true });
|
|
155
|
+
return full;
|
|
156
|
+
};
|
|
157
|
+
const f = (rel, body = "") => writeFileSync(join(root, rel), body);
|
|
158
|
+
|
|
159
|
+
// A repo-like root marker so the root always has content.
|
|
160
|
+
d(".git");
|
|
161
|
+
f(".git/HEAD", "ref: refs/heads/main\n");
|
|
162
|
+
|
|
163
|
+
// 1. A recursively-empty directory (nested, no files) → top-most reported.
|
|
164
|
+
d("empty-top", "a", "b");
|
|
165
|
+
|
|
166
|
+
// 2. A directory whose subtree holds only a placeholder → left alone.
|
|
167
|
+
d("placeholder-only");
|
|
168
|
+
f("placeholder-only/.gitkeep");
|
|
169
|
+
|
|
170
|
+
// 3. A directory with a real file → left alone.
|
|
171
|
+
d("has-file");
|
|
172
|
+
f("has-file/index.ts", "export {};\n");
|
|
173
|
+
|
|
174
|
+
// 4. Orphan node_modules (parent has no package.json).
|
|
175
|
+
d("orphan-pkg", "node_modules", "left-pad");
|
|
176
|
+
|
|
177
|
+
// 5. Legitimate node_modules (parent has package.json) → not orphan.
|
|
178
|
+
d("real-pkg", "node_modules", "left-pad");
|
|
179
|
+
f("real-pkg/package.json", "{}\n");
|
|
180
|
+
|
|
181
|
+
// 6. A nested .git (sub working tree) inside an otherwise file-free dir →
|
|
182
|
+
// must NOT be reported as empty.
|
|
183
|
+
d("nested-repo", ".git");
|
|
184
|
+
|
|
185
|
+
return root;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function selfTest() {
|
|
189
|
+
const cases = [];
|
|
190
|
+
const root = buildFixture();
|
|
191
|
+
let result;
|
|
192
|
+
try {
|
|
193
|
+
result = detect(root);
|
|
194
|
+
} catch (e) {
|
|
195
|
+
console.log(` FAIL detect threw (${e.message})`);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const rel = (p) => p.slice(root.length).split(sep).filter(Boolean).join("/");
|
|
200
|
+
const empties = result.emptyDirs.map(rel);
|
|
201
|
+
const orphans = result.orphanNodeModules.map(rel);
|
|
202
|
+
|
|
203
|
+
cases.push({
|
|
204
|
+
name: "top-most empty directory is reported",
|
|
205
|
+
ok: empties.includes("empty-top"),
|
|
206
|
+
});
|
|
207
|
+
cases.push({
|
|
208
|
+
name: "empty descendants are not reported individually",
|
|
209
|
+
ok: !empties.includes("empty-top/a") && !empties.includes("empty-top/a/b"),
|
|
210
|
+
});
|
|
211
|
+
cases.push({
|
|
212
|
+
name: "placeholder-only directory is left alone",
|
|
213
|
+
ok: !empties.includes("placeholder-only"),
|
|
214
|
+
});
|
|
215
|
+
cases.push({
|
|
216
|
+
name: "directory with a real file is left alone",
|
|
217
|
+
ok: !empties.includes("has-file"),
|
|
218
|
+
});
|
|
219
|
+
cases.push({
|
|
220
|
+
name: "orphan node_modules (no sibling package.json) is reported",
|
|
221
|
+
ok: orphans.includes("orphan-pkg/node_modules"),
|
|
222
|
+
});
|
|
223
|
+
cases.push({
|
|
224
|
+
name: "node_modules with a sibling package.json is NOT an orphan",
|
|
225
|
+
ok: !orphans.includes("real-pkg/node_modules"),
|
|
226
|
+
});
|
|
227
|
+
cases.push({
|
|
228
|
+
name: "node_modules never appears in emptyDirs",
|
|
229
|
+
ok: !empties.some((p) => p.split("/").includes("node_modules")),
|
|
230
|
+
});
|
|
231
|
+
cases.push({
|
|
232
|
+
name: "directory holding a nested .git is not reported empty",
|
|
233
|
+
ok: !empties.includes("nested-repo"),
|
|
234
|
+
});
|
|
235
|
+
cases.push({
|
|
236
|
+
name: ".git is never reported or traversed",
|
|
237
|
+
ok:
|
|
238
|
+
!empties.some((p) => p.split("/").includes(".git")) &&
|
|
239
|
+
!orphans.some((p) => p.split("/").includes(".git")),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// apply() removes exactly the detected snapshot and nothing else.
|
|
243
|
+
const before = detect(root);
|
|
244
|
+
const applied = apply(root);
|
|
245
|
+
const after = detect(root);
|
|
246
|
+
cases.push({
|
|
247
|
+
name: "apply removed the originally detected paths",
|
|
248
|
+
ok:
|
|
249
|
+
before.emptyDirs.every((p) => !existsSync(p)) &&
|
|
250
|
+
before.orphanNodeModules.every((p) => !existsSync(p)),
|
|
251
|
+
});
|
|
252
|
+
cases.push({
|
|
253
|
+
name: "apply reports every removed path and no failures on a clean run",
|
|
254
|
+
ok:
|
|
255
|
+
applied.failed.length === 0 &&
|
|
256
|
+
applied.removed.length ===
|
|
257
|
+
before.emptyDirs.length + before.orphanNodeModules.length,
|
|
258
|
+
});
|
|
259
|
+
cases.push({
|
|
260
|
+
name: "apply preserves placeholder-only and file-bearing directories",
|
|
261
|
+
ok: existsSync(join(root, "placeholder-only")) && existsSync(join(root, "has-file")),
|
|
262
|
+
});
|
|
263
|
+
// Removing orphan-pkg/node_modules empties its parent — a deliberate cascade
|
|
264
|
+
// that a *subsequent* run sweeps, never the same snapshot.
|
|
265
|
+
cases.push({
|
|
266
|
+
name: "removing an orphan node_modules leaves its parent for the next run",
|
|
267
|
+
ok:
|
|
268
|
+
after.emptyDirs.map(rel).join(",") === "orphan-pkg" &&
|
|
269
|
+
after.orphanNodeModules.length === 0,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// detect() fails fast on a root that exists but is not a directory, rather
|
|
273
|
+
// than silently reporting nothing (readdirSync would throw ENOTDIR in scan).
|
|
274
|
+
const fileRoot = join(tmpdir(), `cleanup-repo-fs-not-a-dir-${process.pid}`);
|
|
275
|
+
writeFileSync(fileRoot, "");
|
|
276
|
+
let threwOnFileRoot = false;
|
|
277
|
+
try {
|
|
278
|
+
detect(fileRoot);
|
|
279
|
+
} catch {
|
|
280
|
+
threwOnFileRoot = true;
|
|
281
|
+
}
|
|
282
|
+
rmSync(fileRoot, { force: true });
|
|
283
|
+
cases.push({
|
|
284
|
+
name: "detect throws on a non-directory root",
|
|
285
|
+
ok: threwOnFileRoot,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
rmSync(root, { recursive: true, force: true });
|
|
289
|
+
|
|
290
|
+
let failed = 0;
|
|
291
|
+
for (const { name, ok } of cases) {
|
|
292
|
+
if (ok) {
|
|
293
|
+
console.log(` ok ${name}`);
|
|
294
|
+
} else {
|
|
295
|
+
failed += 1;
|
|
296
|
+
console.log(` FAIL ${name}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
console.log(`\n${cases.length - failed}/${cases.length} passed`);
|
|
300
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function main() {
|
|
304
|
+
const argv = process.argv.slice(2);
|
|
305
|
+
if (argv.includes("--self-test")) {
|
|
306
|
+
selfTest();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const { flags, root } = parseArgs(argv);
|
|
310
|
+
const result = flags.has("--apply") ? apply(root) : detect(root);
|
|
311
|
+
console.log(JSON.stringify(result, null, 2));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
|
315
|
+
main();
|
|
316
|
+
}
|