Skip to content

Commit 44470a3

Browse files
committed
git 0.1.0 — branch-flow workflow: init, ship, promote, release
Four commands over a promotion chain the plugin works out from the repository itself — branches, lockfile, workflow files — and that a project overrides key by key in .claude/git.json. Every key is optional and inferable, so an update never hard-fails a config written against an earlier shape, and a stale one surfaces as a warning rather than as silent mis-steering: the resolver says so when a referenced flow document disappears, and when a configured tier's branch no longer exists. Every mutation is split in two. A prep script digests the working tree, the gates and the pull-request state into one JSON document; an apply script performs the branch, commit, push and PR, and refuses to act on a protected branch. What is decidable is decided by a script and read by the command, which keeps the irreversible half small enough to read before it runs. Gates are diff-scoped rather than all-or-nothing: buildsFirst ordering, testChanged incremental runs with {base} substitution, forcesFullSuite escape paths, requiresService reachability probing that reads an address from the project's own .env and never a credential, and streamOutput for suites long enough that silence is indistinguishable from a hang. Ships a worked config corpus for a single-branch repository, a two-tier app and a three-tier monorepo, resolved through the real resolver in CI so a stale example fails the build instead of misleading a reader.
0 parents  commit 44470a3

27 files changed

Lines changed: 4126 additions & 0 deletions

.claude-plugin/marketplace.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "durchnull",
3+
"owner": {
4+
"name": "David Friedrich"
5+
},
6+
"metadata": {
7+
"description": "David Friedrich's Claude Code plugins."
8+
},
9+
"plugins": [
10+
{
11+
"name": "git",
12+
"source": ".",
13+
"description": "Branch-flow git workflow commands (/git:init, :ship, :promote, :release) for a protected promotion chain, configured per project."
14+
}
15+
]
16+
}

.claude-plugin/plugin.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "git",
3+
"description": "Branch-flow git workflow for a protected promotion chain, configured per project: commit + push + open a PR (ship, with --split for focused PRs), cut a promotion PR to the pre-production tier (promote), and cut a release PR to production then tag it (release). Run init once to teach it the repo's branch flow and quality gates.",
4+
"version": "0.1.0",
5+
"author": {
6+
"name": "David Friedrich"
7+
},
8+
"homepage": "https://github.com/durchnull/git#readme",
9+
"repository": "https://github.com/durchnull/git",
10+
"license": "MIT",
11+
"keywords": [
12+
"git",
13+
"workflow",
14+
"pull-request",
15+
"branch-flow",
16+
"release"
17+
]
18+
}

.github/workflows/ci.yml

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
name: ci
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
# The token every job below is handed. Without this block the run inherits the
9+
# repository default — a settings-page value that changes without a commit, and
10+
# these workflows run on pull_request, so a fork's code is what meets it. Read is
11+
# all any step here needs; declaring it in the file makes the file the guarantee,
12+
# and it travels with every clone and fork.
13+
permissions:
14+
contents: read
15+
16+
jobs:
17+
validate:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- uses: actions/checkout@v7
21+
22+
- uses: actions/setup-node@v7
23+
with:
24+
node-version: "22"
25+
26+
# `claude plugin validate <dir>` checks only ONE manifest — marketplace.json wins when
27+
# both are present — so plugin.json would never be schema-checked here. This asserts the
28+
# fields that matter plus the agreement `claude plugin tag` relies on, with no deps.
29+
# The self-entry source is compared as a path, not a string: "." and "./" both name the
30+
# marketplace root and both load, so a cosmetic trailing slash must not fail the build.
31+
- name: Manifests are valid and agree
32+
run: |
33+
node -e '
34+
const fs = require("fs");
35+
const read = (p) => JSON.parse(fs.readFileSync(p, "utf8"));
36+
const plugin = read(".claude-plugin/plugin.json");
37+
const market = read(".claude-plugin/marketplace.json");
38+
let bad = false;
39+
const fail = (m) => { console.error(`✗ ${m}`); bad = true; };
40+
for (const k of ["name", "version", "description"]) {
41+
if (!plugin[k]) fail(`plugin.json is missing "${k}"`);
42+
}
43+
if (!/^\d+\.\d+\.\d+/.test(plugin.version ?? "")) {
44+
fail(`plugin.json version "${plugin.version}" is not SemVer`);
45+
}
46+
const sources = (market.plugins ?? []).map((p) => p.source);
47+
const isSelf = (s) => typeof s === "string" && s.trim().replace(/\/+$/, "") === ".";
48+
const entry = (market.plugins ?? []).find((p) => isSelf(p.source));
49+
if (!entry) fail(`marketplace.json has no self-entry pointing at the repo root; sources found: ${JSON.stringify(sources)}`);
50+
else {
51+
if (entry.name !== plugin.name) fail(`name mismatch: plugin.json "${plugin.name}" vs marketplace entry "${entry.name}"`);
52+
if ("version" in entry) fail("marketplace entry pins a version — a second source of truth that will drift");
53+
}
54+
const top = fs.readFileSync("CHANGELOG.md", "utf8").match(/^## \[([^\]]+)\]/m);
55+
if (!top) fail("CHANGELOG.md has no version heading");
56+
else if (top[1] !== plugin.version) {
57+
fail(`CHANGELOG.md top entry is [${top[1]}] but plugin.json is ${plugin.version}`);
58+
}
59+
if (bad) process.exit(1);
60+
console.log(`✓ manifests valid and in agreement (${plugin.name} ${plugin.version})`);
61+
'
62+
63+
# Every command advertised by the README must actually ship, and vice versa. A README
64+
# documenting a command that does not exist is the failure the professionality bar names.
65+
- name: README matches the shipped command set
66+
run: |
67+
node -e '
68+
const fs = require("fs");
69+
const readme = fs.readFileSync("README.md", "utf8");
70+
const documented = new Set([...readme.matchAll(/\/git:([a-z-]+)/g)].map((m) => m[1]));
71+
const shipped = new Set(
72+
fs.readdirSync("skills", { withFileTypes: true })
73+
.filter((d) => d.isDirectory() && fs.existsSync(`skills/${d.name}/SKILL.md`))
74+
.map((d) => d.name)
75+
);
76+
let bad = false;
77+
for (const c of documented) if (!shipped.has(c)) { console.error(`✗ README documents /git:${c}, which does not ship`); bad = true; }
78+
for (const c of shipped) if (!documented.has(c)) { console.error(`✗ /git:${c} ships but is undocumented`); bad = true; }
79+
if (bad) process.exit(1);
80+
console.log(`✓ README and skills/ agree (${[...shipped].sort().join(", ")})`);
81+
'
82+
83+
# A bundled path must resolve via ${CLAUDE_PLUGIN_ROOT}. A bare-relative `scripts/x.mjs`
84+
# looks fine and silently resolves against the CONSUMING project once installed.
85+
- name: Bundled paths are plugin-root-relative
86+
run: |
87+
if grep -rnE '(^|[^/A-Z_}])scripts/[a-z-]+\.mjs' skills/ | grep -v 'CLAUDE_PLUGIN_ROOT'; then
88+
echo "✗ bare-relative script path above — use \${CLAUDE_PLUGIN_ROOT}/scripts/…"
89+
exit 1
90+
fi
91+
if grep -rnE '/Users/|~/' skills/ scripts/ .claude-plugin/; then
92+
echo "✗ absolute or home path above — breaks on another machine"
93+
exit 1
94+
fi
95+
echo "✓ no bare-relative or absolute bundled paths"
96+
97+
- name: Scripts parse
98+
run: |
99+
node --check scripts/resolve-git-config.mjs
100+
node --check scripts/check-config-examples.mjs
101+
node --check scripts/ship-prep.mjs
102+
node --check scripts/ship-apply.mjs
103+
node --check scripts/promote-prep.mjs
104+
node --check scripts/promote-apply.mjs
105+
node --check scripts/lib/classify.mjs
106+
node --check scripts/lib/commit-taxonomy.mjs
107+
node --check scripts/lib/env-file.mjs
108+
109+
- name: resolve-git-config self-test
110+
run: node scripts/resolve-git-config.mjs --self-test
111+
112+
# The prep scripts are top-level imperative — importing one runs git — so their pure
113+
# logic lives in lib/ and is tested there instead.
114+
- name: classify self-test
115+
run: node scripts/lib/classify.mjs --self-test
116+
117+
- name: commit-taxonomy self-test
118+
run: node scripts/lib/commit-taxonomy.mjs --self-test
119+
120+
- name: env-file self-test
121+
run: node scripts/lib/env-file.mjs --self-test
122+
123+
# The example configs are documentation for someone writing their own
124+
# .claude/git.json AND the fixtures proving each project shape still resolves.
125+
- name: Validate the example config corpus
126+
run: node scripts/check-config-examples.mjs
127+
128+
# End-to-end: a synthesized repo the scripts have never seen. Catches the failures
129+
# a pure self-test cannot — a script that crashes the moment it meets a real repo.
130+
- name: Prep scripts survive a real repo
131+
run: |
132+
set -e
133+
work="$(mktemp -d)"
134+
git -C "$work" init -q -b main .
135+
git -C "$work" config user.email ci@example.com
136+
git -C "$work" config user.name CI
137+
echo "# demo" > "$work/README.md"
138+
git -C "$work" add README.md
139+
git -C "$work" commit -q -m "chore: init"
140+
git -C "$work" checkout -q -b dev
141+
echo "export const x = 1;" > "$work/app.ts"
142+
git -C "$work" add app.ts
143+
git -C "$work" commit -q -m "feat(search): add ranking"
144+
git -C "$work" branch staging main
145+
mkdir -p "$work/.claude"
146+
echo '{"configVersion":1,"tiers":["dev","staging","main"]}' > "$work/.claude/git.json"
147+
git -C "$work" checkout -q -b feature/demo dev
148+
echo "dirty" >> "$work/app.ts"
149+
150+
scripts="$PWD/scripts"
151+
cd "$work"
152+
# No `origin` here, so both must report that as a clean blocker rather than crash.
153+
# A brief carrying a blocker exits 1 by contract — that is the reported blocker,
154+
# not a crash — so capture the status rather than letting `-e` abort the step.
155+
# Asserting the exact code keeps a genuine crash (any other status) failing.
156+
# Both scripts write their brief to stdout; capture it for the assertions below.
157+
blocked() {
158+
local out="$1"; shift
159+
local rc=0
160+
"$@" > "$out" || rc=$?
161+
test "$rc" -eq 1 || { echo "✗ expected exit 1 (blocked), got $rc: $*"; return 1; }
162+
}
163+
164+
blocked ship-stdout.json node "$scripts/ship-prep.mjs" --no-gates --out brief.json
165+
node -e '
166+
const b = require("./brief.json");
167+
const fail = (m) => { console.error(`✗ ${m}`); process.exit(1); };
168+
if (b.branch !== "feature/demo") fail(`branch: got ${b.branch}`);
169+
if (b.guesses.branchType !== "feature") fail(`branchType: got ${b.guesses.branchType}`);
170+
if (b.guesses.label !== "enhancement") fail(`label: got ${b.guesses.label}`);
171+
if (!Array.isArray(b.blockers) || !b.blockers.some((x) => /origin/.test(x))) {
172+
fail("a repo with no origin must be blocked on exactly that");
173+
}
174+
console.log("✓ ship-prep produced a coherent brief on a fresh repo");
175+
'
176+
blocked promote.json node "$scripts/promote-prep.mjs" --no-fetch
177+
node -e '
178+
const p = require("./promote.json");
179+
const fail = (m) => { console.error(`✗ ${m}`); process.exit(1); };
180+
if (!Array.isArray(p.blockers) || !p.blockers.some((x) => /origin\//.test(x))) {
181+
fail("a repo with no origin must block the promotion chain on exactly that");
182+
}
183+
console.log("✓ promote-prep reported the broken chain on a fresh repo");
184+
'
185+
186+
# The resolver must produce a usable chain with no .claude/git.json present —
187+
# a fresh install has to work on inference alone.
188+
- name: resolve-git-config works with no config file
189+
run: |
190+
test ! -f .claude/git.json
191+
node scripts/resolve-git-config.mjs --json | node -e '
192+
let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => {
193+
const r = JSON.parse(s);
194+
if (!Array.isArray(r.tiers) || !r.tiers.length) { console.error("✗ no tiers inferred"); process.exit(1); }
195+
if (!r.roles || !r.roles.integration || !r.roles.production) {
196+
console.error("✗ roles missing integration or production"); process.exit(1);
197+
}
198+
console.log(`✓ inference-only resolve OK (chain: ${r.tiers.join(" → ")})`);
199+
});'

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Local-only working files. One generic rule, so adding a file here never
2+
# publishes its name — a tracked .gitignore is public writing.
3+
.dev/
4+
5+
.DS_Store
6+
node_modules/

CHANGELOG.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Changelog
2+
3+
All notable changes to the `git` plugin are documented here. This project adheres to
4+
[Semantic Versioning](https://semver.org) and [Keep a Changelog](https://keepachangelog.com).
5+
6+
`git` is pre-1.0: while the major version is `0`, the command surface and the `.claude/git.json`
7+
config schema may change in a minor release. Pin a tag if you need stability.
8+
9+
## [0.1.0] — 2026-08-05
10+
11+
### Added
12+
13+
- Initial beta release under the `durchnull` marketplace.
14+
- Four commands: `/git:init`, `/git:ship`, `/git:promote`, `/git:release`.
15+
- Bundled `scripts/resolve-git-config.mjs` — infers the promotion chain, branch prefixes, labels,
16+
merge method, tag format, and lint gate from the repo, then merges `.claude/git.json` over the
17+
top. Carries a `--self-test` covering the pure inference and merge paths.
18+
- Bundled `scripts/ship-prep.mjs` / `scripts/ship-apply.mjs` — the two-phase ship flow: prep
19+
digests the working tree, gates, and PR state into one JSON document; apply performs the
20+
branch/commit/push/PR mutation and refuses to act on a protected branch.
21+
- Bundled `scripts/promote-prep.mjs` / `scripts/promote-apply.mjs` — the same split for a
22+
promotion PR from the integration tier into the pre-production tier.
23+
- Bundled `scripts/lib/env-file.mjs` — reads a service address out of a project's own `.env` for
24+
`requiresService` gate probing. Reachability only; no credential is ever read into output.
25+
Carries a `--self-test`.
26+
- Bundled `scripts/lib/classify.mjs` and `scripts/lib/commit-taxonomy.mjs` — the pure branch,
27+
path, and conventional-commit classification the prep scripts rely on. They live in `lib/`
28+
because the prep scripts are top-level imperative (importing one runs git), which leaves them
29+
unable to host a self-test; each of these carries one.
30+
- An `examples/configs/` corpus — worked `.claude/git.json` files for a single-branch repo, a
31+
two-tier app, and a three-tier monorepo, doubling as CI fixtures.
32+
- Bundled `scripts/check-config-examples.mjs` — resolves every example through the real resolver
33+
and asserts the result is coherent, so a stale example fails the build instead of misleading.
34+
- Project-local configuration via `.claude/git.json`, resolved against the consuming project's
35+
working directory. Every key is optional and inferable, so a plugin update never hard-fails an
36+
existing file.
37+
- `flowDocs` — a reference to where the consuming project documents its own git flow
38+
(`["CONTRIBUTING.md#branching"]`). The commands cite it when explaining the chain; the resolver
39+
warns when the referenced file disappears, and warns when a configured tier's branch no longer
40+
exists, so a stale config surfaces instead of silently steering the commands.
41+
- Diff-scoped quality gates through `layers`, including `buildsFirst` ordering, `testChanged`
42+
incremental runs with `{base}` substitution, `forcesFullSuite` escape paths, `requiresService`
43+
probing, and `streamOutput` for long suites.
44+
- `promoteSteps.beforePr` / `.afterPr` — project-specific work attached to a promotion without
45+
forking the command.

CONTRIBUTING.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Contributing
2+
3+
Thanks for considering it. This is a small, personally maintained project, so before a large
4+
change please **open an issue first** and check the direction. A focused pull request against
5+
an agreed shape gets merged; a large one that takes the project somewhere it was not going is
6+
a lot of wasted effort on both sides. Small fixes — a typo, a dead link, an obvious bug — need
7+
no preamble at all. Just send them.
8+
9+
## Running it locally
10+
11+
Point Claude Code at your clone. Nothing gets installed, and whatever version you already
12+
have stays untouched:
13+
14+
```bash
15+
claude --plugin-dir /path/to/git
16+
/reload-plugins
17+
```
18+
19+
Loading this way is also how you catch what a linter cannot see — malformed frontmatter or a
20+
broken hook surfaces at load time, not in review.
21+
22+
## Tests
23+
24+
Run what CI runs, from the repository root:
25+
26+
```bash
27+
node scripts/resolve-git-config.mjs --self-test
28+
node scripts/lib/classify.mjs --self-test
29+
```
30+
31+
Manifests are validated with three separate commands, not one: `claude plugin validate`
32+
against a directory checks only **one** manifest — `marketplace.json` wins when both are
33+
present, and `plugin.json` is silently skipped. All three must exit 0:
34+
35+
```bash
36+
claude plugin validate . --strict
37+
claude plugin validate ./.claude-plugin/plugin.json --strict
38+
claude plugin validate ./.claude-plugin/marketplace.json --strict
39+
```
40+
41+
## What gets merged
42+
43+
- **`main` is the published artifact.** An unpinned install resolves this repository's default
44+
branch, so every commit on it has to be installable and complete on its own. That is why pull
45+
requests get read closely rather than quickly.
46+
- **Leave `version` alone.** Bumping `plugin.json`'s version is part of cutting a release, and
47+
a bump inside a pull request collides with the next one. Describe the change in the pull
48+
request; the version and its changelog entry are set when it ships.
49+
- **This plugin must work as the only thing a user installs.** It may not reference, import
50+
from, or assume any other plugin — including by name, in documentation or examples. If a
51+
change only makes sense when something else is installed, it does not belong here.
52+
- **Nothing project-specific gets baked in.** Branch names, directory layouts and gate commands
53+
are resolved at runtime from the consuming project, never hardcoded. An installed plugin is
54+
one machine-global copy shared by every project on the machine, so a value that is right for
55+
one project is wrong for all the others.
56+
- **Nothing in the plugin's own tree stores user state.** It is replaced wholesale on update, so
57+
anything written there is lost. State belongs in the consuming project.
58+
- **Commit messages are public writing.** Write them as though the repository were already being
59+
read by strangers, because it is. No machine paths, no personal addresses, no credentials, and
60+
no `wip`-grade subjects.
61+
62+
## Licensing
63+
64+
By contributing, you agree that your contribution is licensed under the [MIT License](LICENSE),
65+
the same license this project ships under. You keep the copyright on what you write — this is
66+
the ordinary inbound-equals-outbound arrangement, and there is no contributor licence agreement
67+
to sign.
68+
69+
## Security
70+
71+
Please do not use a pull request or a public issue to report a security problem.
72+
[SECURITY.md](SECURITY.md) has the private channel.

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 David Friedrich
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.

0 commit comments

Comments
 (0)