@skitterbyte/skitterspec 16.10.0 → 18.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/MIGRATION.md +88 -0
- package/README.md +3 -3
- package/assets/claude-md-section.md +20 -48
- package/assets/core/env.config.json.example +7 -1
- package/assets/core/env.config.md +56 -11
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/rules/spec-planning.md +53 -13
- package/assets/skills/spec/SKILL.md +44 -16
- package/assets/skills/spec-bug/SKILL.md +30 -25
- package/assets/skills/spec-cancel/SKILL.md +26 -0
- package/assets/skills/spec-complete/SKILL.md +70 -17
- package/assets/skills/spec-hotfix/SKILL.md +38 -17
- package/assets/skills/spec-init/SKILL.md +31 -8
- package/assets/skills/spec-next/SKILL.md +98 -0
- package/assets/skills/spec-review/SKILL.md +24 -11
- package/assets/skills/spec-start/SKILL.md +226 -0
- package/assets/skills/spec-to-main/SKILL.md +21 -19
- package/package.json +1 -1
- package/src/cli.js +405 -15
- package/src/env/classify.js +91 -0
- package/src/env/config.js +39 -1
- package/src/env/integrate.js +61 -1
- package/src/env/live.js +27 -3
- package/src/env/provision.js +196 -5
- package/src/env/resolve.js +1 -0
- package/src/env/teardown.js +41 -3
- package/src/gating.js +155 -0
- package/src/init.js +45 -9
- package/src/prompts.js +41 -4
- package/assets/skills/spec-go/SKILL.md +0 -190
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which uncommitted paths belong to ONE spec?
|
|
5
|
+
*
|
|
6
|
+
* `/spec-start` refuses a dirty tree because moving another spec's unfinished
|
|
7
|
+
* work is not ours to do. But the commonest dirty tree there is — the spec you
|
|
8
|
+
* just authored and are now starting — is not another spec's work at all, and
|
|
9
|
+
* refusing it costs a round trip through `/commit` on every single start.
|
|
10
|
+
*
|
|
11
|
+
* The distinction this module draws is deliberately NOT "does the dirt look
|
|
12
|
+
* important?" (a judgement, and the gate rightly refuses to make one) but
|
|
13
|
+
* membership in an exactly-known set: the spec's own folder, plus whatever the
|
|
14
|
+
* project declares in `spec.companionPaths`. Everything else is foreign, and one
|
|
15
|
+
* foreign path disqualifies the whole tree.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const path = require('node:path')
|
|
19
|
+
const { BUCKETS, expandTokens, readFrontmatterField } = require('./resolve.js')
|
|
20
|
+
|
|
21
|
+
// git reports repo-relative, forward-slashed paths; an untracked directory comes
|
|
22
|
+
// back with a trailing slash. Normalise both away so comparisons are exact.
|
|
23
|
+
function normalize(p) {
|
|
24
|
+
return String(p || '')
|
|
25
|
+
.trim()
|
|
26
|
+
.replace(/\\/g, '/')
|
|
27
|
+
.replace(/\/+$/, '')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Expand a `companionPaths` pattern for this spec, or return null when it cannot
|
|
32
|
+
* be expanded.
|
|
33
|
+
*
|
|
34
|
+
* A PATTERN THAT REFERENCES `{identifier}` WHEN NO IDENTIFIER RESOLVES MATCHES
|
|
35
|
+
* NOTHING, deliberately. Two ordinary situations produce a spec with no id — a
|
|
36
|
+
* project that never set `branch.identifierField`, and a spec deliberately kept
|
|
37
|
+
* local, never pushed to a tracker — and in both the file the pattern describes
|
|
38
|
+
* either does not exist or belongs to some OTHER spec. Expanding `{identifier}`
|
|
39
|
+
* to a placeholder, or dropping the token, would widen the owned set to a path
|
|
40
|
+
* this spec has no claim on and sweep another spec's snapshot into its commit.
|
|
41
|
+
* Being wrong this way costs a refusal the operator can fix with `/commit`; the
|
|
42
|
+
* other way costs them a file they never staged.
|
|
43
|
+
*/
|
|
44
|
+
function expandCompanion(pattern, spec, config) {
|
|
45
|
+
const tokens = { slug: spec.slug }
|
|
46
|
+
if (/\{identifier\}/.test(pattern)) {
|
|
47
|
+
const field = config && config.branch && config.branch.identifierField
|
|
48
|
+
const identifier = readFrontmatterField(spec.path, field)
|
|
49
|
+
if (!identifier) return null
|
|
50
|
+
tokens.identifier = identifier
|
|
51
|
+
}
|
|
52
|
+
return normalize(expandTokens(pattern, tokens))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Split `dirtyPaths` into the ones that belong to `spec` and the ones that do
|
|
57
|
+
* not. Repo-relative paths in, repo-relative paths out; nothing is read from git
|
|
58
|
+
* and nothing is written.
|
|
59
|
+
*
|
|
60
|
+
* Owned:
|
|
61
|
+
* - anything inside `specs/<bucket>/<folder>/` for ANY bucket. Every bucket is
|
|
62
|
+
* checked rather than the spec's current one because starting a spec MOVES it
|
|
63
|
+
* (backlog → in-progress), so a tree mid-move is dirty in two buckets at once
|
|
64
|
+
* and both halves are the same spec's.
|
|
65
|
+
* - each expandable `spec.companionPaths` entry.
|
|
66
|
+
*
|
|
67
|
+
* @returns {{owned: string[], foreign: string[]}}
|
|
68
|
+
*/
|
|
69
|
+
function classifyDirtyTree(spec, dirtyPaths, config) {
|
|
70
|
+
const owned = []
|
|
71
|
+
const foreign = []
|
|
72
|
+
if (!spec) return { owned, foreign: (dirtyPaths || []).map(normalize).filter(Boolean) }
|
|
73
|
+
|
|
74
|
+
const folders = BUCKETS.map((bucket) => `specs/${bucket}/${spec.folder}`)
|
|
75
|
+
const companions = new Set()
|
|
76
|
+
for (const pattern of (config && config.spec && config.spec.companionPaths) || []) {
|
|
77
|
+
const expanded = expandCompanion(pattern, spec, config)
|
|
78
|
+
if (expanded) companions.add(expanded)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const raw of dirtyPaths || []) {
|
|
82
|
+
const p = normalize(raw)
|
|
83
|
+
if (!p) continue
|
|
84
|
+
const inFolder = folders.some((f) => p === f || p.startsWith(`${f}/`))
|
|
85
|
+
if (inFolder || companions.has(p)) owned.push(p)
|
|
86
|
+
else foreign.push(p)
|
|
87
|
+
}
|
|
88
|
+
return { owned, foreign }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { classifyDirtyTree, expandCompanion }
|
package/src/env/config.js
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Shape (see specs/.core/env.config.md for field docs):
|
|
15
15
|
* {
|
|
16
|
+
* mode: "worktree" | "checkout", // where a spec's branch is built —
|
|
17
|
+
* // its own worktree (default), or the primary checkout in place.
|
|
18
|
+
* // NOTE: unrelated to `seedFiles.mode`, which is symlink|copy.
|
|
16
19
|
* worktree: { root, folderPattern },
|
|
17
20
|
* docker: { enabled, composeFile, projectNamePattern, portBase,
|
|
18
21
|
* portsPerSpec, envFile, backupCommand },
|
|
@@ -27,6 +30,9 @@
|
|
|
27
30
|
* open: { command }, // optional, editor/terminal-agnostic opener
|
|
28
31
|
* registry: ".spec-env/registry.json",
|
|
29
32
|
* branch: { pattern, identifierField }, // git branch naming (provider-neutral)
|
|
33
|
+
* spec: { companionPaths: [ "path", ... ] }, // paths that belong to a
|
|
34
|
+
* // spec alongside its own folder (provider-neutral; {slug} and
|
|
35
|
+
* // {identifier} expand); empty = the spec folder only
|
|
30
36
|
* baseBranch: "", // "" = auto-detect (origin/HEAD → main → master)
|
|
31
37
|
* guards: { refuseTeardownIfDirty, refuseTeardownIfUnpushed },
|
|
32
38
|
* teardown: { deleteRemoteBranch },
|
|
@@ -44,6 +50,16 @@ const { join } = require('node:path')
|
|
|
44
50
|
const CONFIG_FILE = join('specs', '.core', 'env.config.json')
|
|
45
51
|
|
|
46
52
|
const DEFAULT_CONFIG = Object.freeze({
|
|
53
|
+
// Where a spec's branch is built. "worktree" gives every spec its own checkout
|
|
54
|
+
// — parallel specs, `main` left free — at the cost of a terminal session per
|
|
55
|
+
// spec. "checkout" builds the branch in the primary checkout instead: one spec
|
|
56
|
+
// at a time, in the terminal you are already sitting in.
|
|
57
|
+
//
|
|
58
|
+
// Defaults to "worktree" so no installed repo changes behaviour on upgrade.
|
|
59
|
+
// Never inferred from whether `dev`/`docker` are configured: a repo with no
|
|
60
|
+
// dev servers may still want parallel specs, and absence of configuration is
|
|
61
|
+
// not evidence of intent (see .claude/rules/negative-checks.md).
|
|
62
|
+
mode: 'worktree',
|
|
47
63
|
worktree: Object.freeze({ root: '../{repo}-wt', folderPattern: '{slug}' }),
|
|
48
64
|
docker: Object.freeze({
|
|
49
65
|
enabled: true,
|
|
@@ -77,11 +93,18 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
77
93
|
// 00-overview.md frontmatter field a provider writes the ticket id into (empty
|
|
78
94
|
// = no identifier, so patterns referencing {identifier} fall back to type/slug).
|
|
79
95
|
branch: Object.freeze({ pattern: '{type}/{slug}', identifierField: '' }),
|
|
96
|
+
// Paths that belong to a spec ALONGSIDE its own `specs/<bucket>/<name>/` folder
|
|
97
|
+
// — a tracker provider's per-spec snapshot, for instance. Provider-neutral by
|
|
98
|
+
// design: the base engine must not know that any particular tracker exists, so
|
|
99
|
+
// the project declares the shape and `{slug}` / `{identifier}` expand exactly as
|
|
100
|
+
// they do in `branch.pattern` ({identifier} via `branch.identifierField`).
|
|
101
|
+
// Default: none, so a spec owns only its own folder.
|
|
102
|
+
spec: Object.freeze({ companionPaths: Object.freeze([]) }),
|
|
80
103
|
// Integration base branch. Empty = auto-detect (origin/HEAD → main → master).
|
|
81
104
|
baseBranch: '',
|
|
82
105
|
guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
|
|
83
106
|
// Teardown cleanup beyond this machine. `deleteRemoteBranch` decides what
|
|
84
|
-
// `spec-env down` does about the branch `/spec-
|
|
107
|
+
// `spec-env down` does about the branch `/spec-start` pushed: "prompt" (default)
|
|
85
108
|
// plans the delete in its own confirm-first section for the skill to ask about,
|
|
86
109
|
// "never" omits it, "always" folds it into the run-blind command list. Only ever
|
|
87
110
|
// planned for a LANDED branch — see teardown.js.
|
|
@@ -105,6 +128,7 @@ function isObject(value) {
|
|
|
105
128
|
// A fresh, deeply-mutable copy of the defaults to merge onto.
|
|
106
129
|
function defaults() {
|
|
107
130
|
return {
|
|
131
|
+
mode: DEFAULT_CONFIG.mode,
|
|
108
132
|
worktree: { ...DEFAULT_CONFIG.worktree },
|
|
109
133
|
docker: { ...DEFAULT_CONFIG.docker },
|
|
110
134
|
seedFiles: { mode: DEFAULT_CONFIG.seedFiles.mode, files: [] },
|
|
@@ -114,6 +138,7 @@ function defaults() {
|
|
|
114
138
|
open: { ...DEFAULT_CONFIG.open },
|
|
115
139
|
registry: DEFAULT_CONFIG.registry,
|
|
116
140
|
branch: { ...DEFAULT_CONFIG.branch },
|
|
141
|
+
spec: { companionPaths: [] },
|
|
117
142
|
baseBranch: DEFAULT_CONFIG.baseBranch,
|
|
118
143
|
guards: { ...DEFAULT_CONFIG.guards },
|
|
119
144
|
teardown: { ...DEFAULT_CONFIG.teardown },
|
|
@@ -257,6 +282,15 @@ function mergeConfig(base, parsed) {
|
|
|
257
282
|
assign(base, parsed, 'registry', 'string')
|
|
258
283
|
assign(base, parsed, 'baseBranch', 'string')
|
|
259
284
|
|
|
285
|
+
// Same treatment as `teardown.deleteRemoteBranch` below, for the same reason:
|
|
286
|
+
// an unrecognised value falls through to the default rather than erroring or
|
|
287
|
+
// being taken literally. The fallback direction matters — "worktree" is the
|
|
288
|
+
// conservative one, so a typo ("Checkout", "in-place") costs an extra terminal
|
|
289
|
+
// session, never a spec's work landing somewhere the author did not choose.
|
|
290
|
+
if (parsed.mode === 'worktree' || parsed.mode === 'checkout') {
|
|
291
|
+
base.mode = parsed.mode
|
|
292
|
+
}
|
|
293
|
+
|
|
260
294
|
if (isObject(parsed.guards)) {
|
|
261
295
|
assign(base.guards, parsed.guards, 'refuseTeardownIfDirty', 'boolean')
|
|
262
296
|
assign(base.guards, parsed.guards, 'refuseTeardownIfUnpushed', 'boolean')
|
|
@@ -273,6 +307,10 @@ function mergeConfig(base, parsed) {
|
|
|
273
307
|
}
|
|
274
308
|
}
|
|
275
309
|
|
|
310
|
+
if (isObject(parsed.spec) && Array.isArray(parsed.spec.companionPaths)) {
|
|
311
|
+
base.spec.companionPaths = normalizeFileList(parsed.spec.companionPaths)
|
|
312
|
+
}
|
|
313
|
+
|
|
276
314
|
if (isObject(parsed.live) && Array.isArray(parsed.live.migrations)) {
|
|
277
315
|
base.live.migrations = normalizeFileList(parsed.live.migrations)
|
|
278
316
|
}
|
package/src/env/integrate.js
CHANGED
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
*
|
|
17
17
|
* @param {object} spec resolved spec: { branch, worktreePath, folder, ... }
|
|
18
18
|
* @param {object} config normalised env config (unused today; kept for symmetry).
|
|
19
|
+
* In CHECKOUT mode there is no worktree: the branch is already checked out in the
|
|
20
|
+
* primary checkout, so landing is a rebase in place, a switch to base, and the
|
|
21
|
+
* fast-forward. `planIntegrateCheckout` below covers that; the shape of what it
|
|
22
|
+
* returns is identical so callers need no second code path.
|
|
23
|
+
*
|
|
19
24
|
* @param {object} ctx { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath }
|
|
20
25
|
* @returns {object} { blocked, noop, reason, commands, base, branch }
|
|
21
26
|
*/
|
|
@@ -43,4 +48,59 @@ function planIntegrate(spec, config, ctx) {
|
|
|
43
48
|
}
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Pure integrate planner for CHECKOUT mode.
|
|
53
|
+
*
|
|
54
|
+
* The branch lives in the primary checkout, so `git -C <worktree> rebase` — the
|
|
55
|
+
* worktree-mode plan — has nothing to address. Landing is three steps in one
|
|
56
|
+
* repo: rebase onto base, switch to base, fast-forward.
|
|
57
|
+
*
|
|
58
|
+
* The switch is what makes this safe to repeat. Leaving the checkout on the spec
|
|
59
|
+
* branch after landing would mean the next `spec-env up` refuses ("standing on
|
|
60
|
+
* another spec's branch") for a spec that is finished, and the operator would
|
|
61
|
+
* have to know to switch back by hand.
|
|
62
|
+
*
|
|
63
|
+
* ctx: { dirty, base, aheadOfBase, checkoutPath, onBranch }
|
|
64
|
+
*/
|
|
65
|
+
function planIntegrateCheckout(spec, config, ctx) {
|
|
66
|
+
const { dirty, base, aheadOfBase, checkoutPath, onBranch } = ctx || {}
|
|
67
|
+
const branch = spec.branch
|
|
68
|
+
const result = { blocked: false, noop: false, reason: null, commands: [], base, branch }
|
|
69
|
+
|
|
70
|
+
// "Already landed" is answered FIRST, before any refusal. A landed spec needs
|
|
71
|
+
// no action wherever the checkout happens to be standing — and after a
|
|
72
|
+
// successful land it is standing on base, so asking "not on the branch" here
|
|
73
|
+
// would refuse the very spec this just finished landing. /spec-complete calls
|
|
74
|
+
// integrate again on exactly that state.
|
|
75
|
+
if (!aheadOfBase) {
|
|
76
|
+
return { ...result, noop: true }
|
|
77
|
+
}
|
|
78
|
+
if (dirty) {
|
|
79
|
+
return {
|
|
80
|
+
...result,
|
|
81
|
+
blocked: true,
|
|
82
|
+
reason: 'the checkout has uncommitted changes — commit the completion first',
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// There IS something to land, so where you are standing now matters: the
|
|
86
|
+
// branch is the checkout in this mode, and landing from elsewhere would
|
|
87
|
+
// rebase and fast-forward a branch the operator is not looking at.
|
|
88
|
+
if (onBranch === false) {
|
|
89
|
+
return {
|
|
90
|
+
...result,
|
|
91
|
+
blocked: true,
|
|
92
|
+
reason: `the checkout is not on ${branch} — switch to it before landing`,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
...result,
|
|
98
|
+
commands: [
|
|
99
|
+
`git -C ${checkoutPath} rebase ${base}`,
|
|
100
|
+
`git -C ${checkoutPath} switch ${base}`,
|
|
101
|
+
`git -C ${checkoutPath} merge --ff-only ${branch}`,
|
|
102
|
+
],
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { planIntegrate, planIntegrateCheckout }
|
package/src/env/live.js
CHANGED
|
@@ -134,7 +134,11 @@ function migrationsHit(files, patterns) {
|
|
|
134
134
|
* ctx:
|
|
135
135
|
* primary { onBase, branch, baseBranch } — the guard result for the primary checkout
|
|
136
136
|
* primaryPath absolute path of the primary checkout (the checkout target)
|
|
137
|
+
* inFlight string|null — folder of the spec holding the primary, from
|
|
138
|
+
* its receipt; null when nothing is recorded (a hand-switched
|
|
139
|
+
* branch), which degrades the message to the branch name
|
|
137
140
|
* clean boolean — primary checkout working tree is clean
|
|
141
|
+
* worktreeClean boolean — the SPEC WORKTREE's tree is clean (the rebase runs there)
|
|
138
142
|
* worktreeExists boolean — the spec's worktree is on disk
|
|
139
143
|
* base resolved base branch name (rebase target)
|
|
140
144
|
* baseMainCommit primary HEAD before the switch (receipt / crash recovery)
|
|
@@ -166,9 +170,16 @@ function planTake(spec, config, ctx) {
|
|
|
166
170
|
// (or you) already holds the live instance.
|
|
167
171
|
if (!c.primary || !c.primary.onBase) {
|
|
168
172
|
const on = c.primary && c.primary.branch ? c.primary.branch : '(detached)'
|
|
173
|
+
// Name the SPEC, not just the branch, and name every way out. `/spec-start`
|
|
174
|
+
// relays this refusal verbatim as its gate, so whatever is missing here is
|
|
175
|
+
// missing from the operator's only explanation of why they are stuck —
|
|
176
|
+
// "release it with /spec-live main" alone reads as the sole option when
|
|
177
|
+
// completing or cancelling the held spec are usually the ones they want.
|
|
178
|
+
const held = c.inFlight ? `${c.inFlight} (branch ${on})` : on
|
|
169
179
|
return block(
|
|
170
|
-
`primary checkout is on ${on}, not ${base} —
|
|
171
|
-
'
|
|
180
|
+
`primary checkout is on ${on}, not ${base} — ${held} already holds it. ` +
|
|
181
|
+
'Free the workbench first: `/spec-complete` if it is finished, ' +
|
|
182
|
+
'`/spec-cancel` if it is not wanted, or `/spec-live main` to park it',
|
|
172
183
|
)
|
|
173
184
|
}
|
|
174
185
|
// 2. Never switch a dirty tree — the checkout is reset back to base on release.
|
|
@@ -177,7 +188,20 @@ function planTake(spec, config, ctx) {
|
|
|
177
188
|
}
|
|
178
189
|
// 3. Need a worktree holding the branch to detach and hand over.
|
|
179
190
|
if (!c.worktreeExists) {
|
|
180
|
-
return block(`${spec.folder} has no worktree — run \`/spec-
|
|
191
|
+
return block(`${spec.folder} has no worktree — run \`/spec-start ${spec.folder}\` first`)
|
|
192
|
+
}
|
|
193
|
+
// 3b. The WORKTREE's own tree, not the primary checkout's. Check 2 above reads
|
|
194
|
+
// the checkout we switch INTO; the rebase runs in the worktree, and git
|
|
195
|
+
// refuses to rebase over uncommitted changes. Checking only the primary
|
|
196
|
+
// let a dirty worktree through to fail at the rebase, where the failure
|
|
197
|
+
// was then reported as a merge conflict — a wrong cause for a real
|
|
198
|
+
// problem, which is worse than no message. Validate the tree the
|
|
199
|
+
// operation actually touches.
|
|
200
|
+
if (c.worktreeClean === false) {
|
|
201
|
+
return block(
|
|
202
|
+
`${spec.folder}'s worktree has uncommitted changes — commit or stash them in ` +
|
|
203
|
+
`${spec.worktreePath} first (the rebase cannot run over them)`,
|
|
204
|
+
)
|
|
181
205
|
}
|
|
182
206
|
// 4. A hotfix is built on an old release tag; checking its branch out under the
|
|
183
207
|
// running dev server risks schema/DB drift breaking the shared instance.
|
package/src/env/provision.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* no live git/docker.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
const { classifyDirtyTree } = require('./classify.js')
|
|
14
15
|
const { portOffset } = require('./registry.js')
|
|
15
16
|
const { renderEnvFile, expandOpenCommand } = require('./render.js')
|
|
16
17
|
const { expandTokens } = require('./resolve.js')
|
|
@@ -71,6 +72,126 @@ function seedCommandFor(file, mode) {
|
|
|
71
72
|
)
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
|
|
76
|
+
// Cap a path list in a message: enough to recognise, not a wall of text.
|
|
77
|
+
function listPaths(paths) {
|
|
78
|
+
const shown = paths.slice(0, 5).join(', ')
|
|
79
|
+
return paths.length > 5 ? `${shown}, and ${paths.length - 5} more` : shown
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Decide what an uncommitted tree means for a provisioning run: commit it, refuse
|
|
84
|
+
* it, or ignore it.
|
|
85
|
+
*
|
|
86
|
+
* `/spec-start` refuses a dirty tree so another spec's unfinished work is never
|
|
87
|
+
* moved without its author asking. But the commonest dirty tree of all is the
|
|
88
|
+
* spec you just wrote and are now starting, and refusing THAT costs a round trip
|
|
89
|
+
* through `/commit` on every start. So the question asked here is not "does this
|
|
90
|
+
* dirt look important?" — a judgement the gate rightly declines to make — but
|
|
91
|
+
* membership in an exactly-known set (see `classify.js`). One foreign path
|
|
92
|
+
* disqualifies the whole tree.
|
|
93
|
+
*
|
|
94
|
+
* WHAT WOULD FOOL THIS: `ctx.dirtyPaths` being absent. That is not "the tree is
|
|
95
|
+
* clean" — it is "nobody looked", which happens on every legacy caller and
|
|
96
|
+
* whenever git itself could not be read. Either way it is never permission to
|
|
97
|
+
* commit: with no classification there is no owned set, so no commit is planned.
|
|
98
|
+
*
|
|
99
|
+
* Whether it also REFUSES depends on `carriesChanges`, and the asymmetry is the
|
|
100
|
+
* two modes' actual risk, not an inconsistency:
|
|
101
|
+
*
|
|
102
|
+
* - checkout mode (`carriesChanges`) — `git switch -c` silently carries
|
|
103
|
+
* uncommitted work onto the new branch. Unable to see the tree means unable
|
|
104
|
+
* to rule that out, so it refuses, exactly as it did before this gate existed.
|
|
105
|
+
* - worktree mode — `git worktree add` carries nothing, and forks from a commit
|
|
106
|
+
* regardless. Refusing here would fire on a healthy repo whose git we merely
|
|
107
|
+
* could not read, which is a cost paid by someone who did nothing wrong. So
|
|
108
|
+
* it plans no commit and provisions as before.
|
|
109
|
+
*
|
|
110
|
+
* `specOnFork` is the other half, and a POSITIVE signal rather than an absence:
|
|
111
|
+
* the worktree forks from a specific commit, so a spec absent from it produces a
|
|
112
|
+
* branch missing the very spec it is for. A clean tree cannot detect that — the
|
|
113
|
+
* spec may be committed, just somewhere else. `null` means the caller could not
|
|
114
|
+
* tell (an unreadable git, or a hotfix, which forks from a tag predating its own
|
|
115
|
+
* spec), and routes to carrying on, never to refusing.
|
|
116
|
+
*
|
|
117
|
+
* ctx: { dirtyPaths?, clean?, specOnFork?, specFoundOn?, forkRef?, specUntracked? }
|
|
118
|
+
* @returns {{blocked: boolean, reason: string|null, commands: string[]}}
|
|
119
|
+
*/
|
|
120
|
+
function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
|
|
121
|
+
const ok = { blocked: false, reason: null, commands: [], owned: [], verb: null }
|
|
122
|
+
const c = ctx || {}
|
|
123
|
+
|
|
124
|
+
if (!Array.isArray(c.dirtyPaths)) {
|
|
125
|
+
// Nobody looked: never commit, and refuse only where switching could carry
|
|
126
|
+
// work we cannot see (see the asymmetry above).
|
|
127
|
+
if (carriesChanges && c.clean === false) {
|
|
128
|
+
return {
|
|
129
|
+
blocked: true,
|
|
130
|
+
reason:
|
|
131
|
+
'the primary checkout has uncommitted changes — commit or stash them first' +
|
|
132
|
+
(carriesChanges ? ' (switching would carry them onto the new branch)' : ''),
|
|
133
|
+
commands: [],
|
|
134
|
+
owned: [],
|
|
135
|
+
verb: null,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return ok
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const { owned, foreign } = classifyDirtyTree(spec, c.dirtyPaths, config)
|
|
142
|
+
|
|
143
|
+
if (foreign.length) {
|
|
144
|
+
return {
|
|
145
|
+
blocked: true,
|
|
146
|
+
reason:
|
|
147
|
+
`the primary checkout has uncommitted changes that are not ${spec.folder}'s — ` +
|
|
148
|
+
'commit or stash them first' +
|
|
149
|
+
(carriesChanges ? ' (switching would carry them onto the new branch)' : '') +
|
|
150
|
+
`: ${listPaths(foreign)}`,
|
|
151
|
+
commands: [],
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (owned.length) {
|
|
156
|
+
// `add` or `update`? Asked of git (`ctx.specUntracked`, from `git ls-files`)
|
|
157
|
+
// rather than inferred from the shape of the status output. The inference —
|
|
158
|
+
// "a bare directory entry means the folder is wholly untracked" — was wrong in
|
|
159
|
+
// the ordinary case of the FIRST spec in a bucket, where git reports the
|
|
160
|
+
// bucket as the untracked directory instead. The old inference stays as a
|
|
161
|
+
// fallback for callers that pass no fact; unknown yields `update`, which is
|
|
162
|
+
// never a false claim.
|
|
163
|
+
const isNew =
|
|
164
|
+
typeof c.specUntracked === 'boolean'
|
|
165
|
+
? c.specUntracked
|
|
166
|
+
: owned.includes(`specs/${spec.bucket}/${spec.folder}`)
|
|
167
|
+
const verb = isNew ? 'add' : 'update'
|
|
168
|
+
return {
|
|
169
|
+
blocked: false,
|
|
170
|
+
reason: null,
|
|
171
|
+
owned,
|
|
172
|
+
verb,
|
|
173
|
+
commands: [
|
|
174
|
+
`git add ${owned.map((p) => `"${p}"`).join(' ')}`,
|
|
175
|
+
`git commit -m "chore(spec): ${verb} ${spec.folder}"`,
|
|
176
|
+
],
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Tree is clean. The spec must already be in the base branch's tree, or the
|
|
181
|
+
// worktree forks without it.
|
|
182
|
+
if (c.specOnFork === false) {
|
|
183
|
+
return {
|
|
184
|
+
blocked: true,
|
|
185
|
+
reason:
|
|
186
|
+
`${spec.folder} is not committed in ${c.forkRef || 'the fork point'}` +
|
|
187
|
+
(c.specFoundOn ? ` — it is on ${c.specFoundOn}` : '') +
|
|
188
|
+
' — the worktree would fork without the spec it is for',
|
|
189
|
+
commands: [],
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return ok
|
|
193
|
+
}
|
|
194
|
+
|
|
74
195
|
/**
|
|
75
196
|
* Plan a provisioning run.
|
|
76
197
|
*
|
|
@@ -83,7 +204,7 @@ function seedCommandFor(file, mode) {
|
|
|
83
204
|
* envContents, openCommand, commands, seedCommands,
|
|
84
205
|
* setupCommands, attached }
|
|
85
206
|
*/
|
|
86
|
-
function planUp(spec, alloc, config) {
|
|
207
|
+
function planUp(spec, alloc, config, ctx) {
|
|
87
208
|
const { slot, attached } = alloc
|
|
88
209
|
|
|
89
210
|
// Per-spec escalation: bring Docker up only when this spec's Stack is `docker`,
|
|
@@ -143,7 +264,18 @@ function planUp(spec, alloc, config) {
|
|
|
143
264
|
commands.push(`docker compose --project-name ${spec.projectName} up -d`)
|
|
144
265
|
}
|
|
145
266
|
|
|
267
|
+
// The tree gate. A worktree forks from the base branch's tree, so an
|
|
268
|
+
// uncommitted spec would produce a branch missing the spec it is for — this is
|
|
269
|
+
// where that gets committed, or refused. Worktree mode had NO clean gate before
|
|
270
|
+
// this: `git worktree add` does not carry uncommitted changes anywhere, so the
|
|
271
|
+
// failure surfaced three steps later as a live-overlay refusal naming the wrong
|
|
272
|
+
// stage. An absent `ctx` means no caller looked, and changes nothing.
|
|
273
|
+
const gate = planSpecCommit(spec, ctx, config)
|
|
274
|
+
|
|
146
275
|
return {
|
|
276
|
+
blocked: gate.blocked,
|
|
277
|
+
reason: gate.reason,
|
|
278
|
+
specCommit: gate.owned && gate.owned.length ? { paths: gate.owned, verb: gate.verb } : null,
|
|
147
279
|
worktreePath: spec.worktreePath,
|
|
148
280
|
branch: spec.branch,
|
|
149
281
|
projectName: spec.projectName,
|
|
@@ -151,11 +283,70 @@ function planUp(spec, alloc, config) {
|
|
|
151
283
|
portOffset: offset,
|
|
152
284
|
envContents,
|
|
153
285
|
openCommand,
|
|
154
|
-
commands,
|
|
155
|
-
seedCommands,
|
|
156
|
-
setupCommands,
|
|
286
|
+
commands: gate.blocked ? [] : [...gate.commands, ...commands],
|
|
287
|
+
seedCommands: gate.blocked ? [] : seedCommands,
|
|
288
|
+
setupCommands: gate.blocked ? [] : setupCommands,
|
|
157
289
|
attached,
|
|
158
290
|
}
|
|
159
291
|
}
|
|
160
292
|
|
|
161
|
-
|
|
293
|
+
/**
|
|
294
|
+
* Plan `spec-env up` in CHECKOUT mode — the branch is built in the primary
|
|
295
|
+
* checkout and there is no worktree at all.
|
|
296
|
+
*
|
|
297
|
+
* Pure: every git fact it needs arrives in `ctx`, and it writes nothing.
|
|
298
|
+
*
|
|
299
|
+
* ctx: { current, base, onBase, clean, branchExists }
|
|
300
|
+
*
|
|
301
|
+
* What it refuses, and why each is a refusal rather than a warning:
|
|
302
|
+
* - a dirty tree, because `git switch -c` CARRIES uncommitted changes onto the
|
|
303
|
+
* new branch. That is silent and it is the operator's work, so it is theirs
|
|
304
|
+
* to place, not ours.
|
|
305
|
+
* - standing on another branch, because switching away from it is a decision
|
|
306
|
+
* about someone else's unfinished spec. Being on THIS spec's branch is not a
|
|
307
|
+
* refusal — it is the re-run, and the answer is "already attached".
|
|
308
|
+
*
|
|
309
|
+
* There is no bootstrap and no opener: the primary checkout already has its
|
|
310
|
+
* dependencies, and no new session is being opened.
|
|
311
|
+
*/
|
|
312
|
+
function planCheckoutUp(spec, ctx, config) {
|
|
313
|
+
const base = ctx.base || (config && config.baseBranch) || 'main'
|
|
314
|
+
const result = {
|
|
315
|
+
mode: 'checkout',
|
|
316
|
+
blocked: false,
|
|
317
|
+
reason: null,
|
|
318
|
+
attached: false,
|
|
319
|
+
branch: spec.branch,
|
|
320
|
+
checkoutPath: ctx.checkoutPath || null,
|
|
321
|
+
commands: [],
|
|
322
|
+
}
|
|
323
|
+
const block = (reason) => ({ ...result, blocked: true, reason })
|
|
324
|
+
|
|
325
|
+
// Order matters: report "already attached" before anything else, so a re-run
|
|
326
|
+
// on the spec's own branch is never refused for a dirty tree it legitimately
|
|
327
|
+
// has — you are mid-phase, with the phase's own edits in progress.
|
|
328
|
+
if (ctx.current && ctx.current === spec.branch) {
|
|
329
|
+
return { ...result, attached: true }
|
|
330
|
+
}
|
|
331
|
+
// Same gate as worktree mode, but `git switch -c` genuinely CARRIES uncommitted
|
|
332
|
+
// work onto the new branch, so the refusal keeps saying so.
|
|
333
|
+
const gate = planSpecCommit(spec, ctx, config, { carriesChanges: true })
|
|
334
|
+
if (gate.blocked) return block(gate.reason)
|
|
335
|
+
if (!ctx.onBase) {
|
|
336
|
+
return block(
|
|
337
|
+
`the primary checkout is on ${ctx.current || '(detached)'}, not ${base} — ` +
|
|
338
|
+
'finish or park that branch first; checkout mode holds one spec at a time',
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (gate.owned && gate.owned.length) {
|
|
343
|
+
result.specCommit = { paths: gate.owned, verb: gate.verb }
|
|
344
|
+
}
|
|
345
|
+
result.commands.push(...gate.commands)
|
|
346
|
+
result.commands.push(
|
|
347
|
+
ctx.branchExists ? `git switch ${spec.branch}` : `git switch -c ${spec.branch}`,
|
|
348
|
+
)
|
|
349
|
+
return result
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
module.exports = { planUp, planCheckoutUp, planSpecCommit, seedCommandFor, worktreeCd }
|
package/src/env/resolve.js
CHANGED
package/src/env/teardown.js
CHANGED
|
@@ -105,7 +105,7 @@ function planDown(spec, config, flags, ctx) {
|
|
|
105
105
|
// different question from ours: it also declines a branch that is ahead of its
|
|
106
106
|
// upstream ref, reporting `not yet merged to refs/remotes/origin/<branch>,
|
|
107
107
|
// even though it is merged to HEAD`. That fires on the ordinary spec flow —
|
|
108
|
-
// `/spec-
|
|
108
|
+
// `/spec-start` pushes the branch when it provisions, and the phase commits after
|
|
109
109
|
// it are landed locally rather than pushed — so teardown meets a branch whose
|
|
110
110
|
// every commit is on `main` and `-d` refuses it. `merged` (HEAD is an ancestor
|
|
111
111
|
// of base) already establishes what we actually care about, and establishes it
|
|
@@ -122,7 +122,7 @@ function planDown(spec, config, flags, ctx) {
|
|
|
122
122
|
|
|
123
123
|
// --- delete the branch on the remote (planned, never run here) ---
|
|
124
124
|
//
|
|
125
|
-
// `/spec-
|
|
125
|
+
// `/spec-start` pushes the branch at provision time, so a completed spec otherwise
|
|
126
126
|
// leaves a merged branch on the remote forever. Cleaning that up is the goal;
|
|
127
127
|
// doing it safely is the constraint.
|
|
128
128
|
//
|
|
@@ -185,4 +185,42 @@ function blocked(reason) {
|
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
/**
|
|
189
|
+
* Pure teardown planner for CHECKOUT mode.
|
|
190
|
+
*
|
|
191
|
+
* There is no worktree to remove, no slot and no volumes — the only thing a
|
|
192
|
+
* finished spec leaves behind is its branch, and the checkout standing on it.
|
|
193
|
+
*
|
|
194
|
+
* Order is load-bearing: git refuses to delete the branch you are on, so the
|
|
195
|
+
* switch to base must come first. It is also what returns the checkout to a
|
|
196
|
+
* state the next `spec-env up` will accept.
|
|
197
|
+
*
|
|
198
|
+
* The same "are these commits recoverable?" question decides `-d` vs `-D`, and
|
|
199
|
+
* it is asked exactly as worktree-mode teardown asks it — a landed branch is
|
|
200
|
+
* safe to force-delete because its commits are on base (or under a tag); an
|
|
201
|
+
* unlanded one is not, and is refused rather than quietly dropped.
|
|
202
|
+
*
|
|
203
|
+
* ctx: { dirty, landed, onBranch, base, checkoutPath }
|
|
204
|
+
*/
|
|
205
|
+
function planDownCheckout(spec, config, flags, ctx) {
|
|
206
|
+
const { dirty, landed, onBranch, base, checkoutPath } = ctx || {}
|
|
207
|
+
const force = Boolean(flags && flags.force)
|
|
208
|
+
const result = { mode: 'checkout', blocked: false, reason: null, commands: [], branch: spec.branch }
|
|
209
|
+
const block = (reason) => ({ ...result, blocked: true, reason })
|
|
210
|
+
|
|
211
|
+
if (!force) {
|
|
212
|
+
if (config.guards.refuseTeardownIfDirty && dirty) {
|
|
213
|
+
return block('the checkout has uncommitted changes')
|
|
214
|
+
}
|
|
215
|
+
if (!landed) {
|
|
216
|
+
return block(`${spec.branch} is not merged into ${base} — landing it first is what makes the delete safe`)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const commands = []
|
|
221
|
+
if (onBranch !== false) commands.push(`git -C ${checkoutPath} switch ${base}`)
|
|
222
|
+
commands.push(`git -C ${checkoutPath} branch ${landed ? '-D' : '-d'} ${spec.branch}`)
|
|
223
|
+
return { ...result, commands }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { planDown, planDownCheckout }
|