@skitterbyte/skitterspec 12.0.0 → 14.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/README.md +15 -4
- package/assets/core/env.config.md +39 -2
- package/assets/rules/spec-planning.md +37 -16
- package/assets/skills/spec/SKILL.md +27 -0
- package/assets/skills/spec-bug/SKILL.md +20 -0
- package/assets/skills/spec-cancel/SKILL.md +5 -0
- package/assets/skills/spec-complete/SKILL.md +65 -11
- package/assets/skills/spec-go/SKILL.md +18 -3
- package/assets/skills/spec-hotfix/SKILL.md +181 -0
- package/assets/skills/spec-live/SKILL.md +4 -1
- package/assets/skills/spec-review/SKILL.md +10 -3
- package/assets/skills/spec-to-main/SKILL.md +99 -0
- package/package.json +1 -1
- package/src/cli.js +333 -6
- package/src/env/config.js +18 -0
- package/src/env/hotfix.js +133 -0
- package/src/env/live.js +12 -3
- package/src/env/provision.js +5 -1
- package/src/env/prune.js +119 -0
- package/src/env/resolve.js +28 -4
- package/src/env/teardown.js +20 -13
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure landing planner for `spec-env hotfix land`.
|
|
5
|
+
*
|
|
6
|
+
* A hotfix is built on an old release tag (its `Base version`), so it can't be
|
|
7
|
+
* fast-forwarded onto `main` like an ordinary spec (that's `integrate.js`).
|
|
8
|
+
* Instead `planHotfixLand` emits the exact side-effect-free, **never-pushing**
|
|
9
|
+
* commands to:
|
|
10
|
+
* 1. tag the hotfix branch head with the patch-bumped base tag (the prod deploy
|
|
11
|
+
* tag — the branch head already is baseRef + the fix);
|
|
12
|
+
* 2. for each extra target tag, cherry-pick the fix onto a throwaway worktree at
|
|
13
|
+
* that tag and re-tag it with its own patch bump (test/demo release lines);
|
|
14
|
+
* 3. cherry-pick the fix onto the base branch (main) for the next release.
|
|
15
|
+
*
|
|
16
|
+
* It performs no side effects — the caller (the CLI) probes git for `dirty` /
|
|
17
|
+
* `aheadOfBase` / `existingTags` / `mainRepoPath` and supplies them, keeping this
|
|
18
|
+
* deterministic and unit-testable with no live git. Conflict handling lives in the
|
|
19
|
+
* skill (run a cherry-pick, abort on non-zero exit, hand back), mirroring
|
|
20
|
+
* `integrate.js` — the planner never reasons about conflicts.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const path = require('node:path')
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse a semver-ish tag and return it with its PATCH bumped by one. Preserves any
|
|
27
|
+
* non-digit prefix (e.g. `v` → `v33.16.5`) and drops any pre-release/build suffix
|
|
28
|
+
* (`-rc1`, `+build`). Throws on a tag with no `MAJOR.MINOR.PATCH` core.
|
|
29
|
+
*/
|
|
30
|
+
function bumpPatch(tag) {
|
|
31
|
+
const m = /^(\D*)(\d+)\.(\d+)\.(\d+)(?:.*)$/.exec(String(tag || '').trim())
|
|
32
|
+
if (!m) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`hotfix: cannot bump a patch version from tag "${tag}" — need <prefix>MAJOR.MINOR.PATCH`,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
const [, prefix, major, minor, patch] = m
|
|
38
|
+
return `${prefix}${major}.${minor}.${Number(patch) + 1}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A filesystem/branch-safe slug derived from a tag (`v30.2.1` → `v30-2-1`).
|
|
42
|
+
function tagSlug(tag) {
|
|
43
|
+
return String(tag)
|
|
44
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
45
|
+
.replace(/^-+|-+$/g, '')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {object} spec resolved hotfix spec: { slug, branch, worktreePath, baseRef, ... }
|
|
50
|
+
* @param {object} config normalised env config (reads `config.hotfix`).
|
|
51
|
+
* @param {object} ctx { worktreeState:{dirty}, aheadOfBase, fixRange, mainRepoPath,
|
|
52
|
+
* base, extraTargets: string[], existingTags: string[] }
|
|
53
|
+
* @returns {object} { blocked, noop, reason, commands, prodTag, targets, branch, base }
|
|
54
|
+
*/
|
|
55
|
+
function planHotfixLand(spec, config, ctx) {
|
|
56
|
+
const c = ctx || {}
|
|
57
|
+
const branch = spec.branch
|
|
58
|
+
const baseRef = spec.baseRef
|
|
59
|
+
const hotfixCfg = (config && config.hotfix) || { cherryPickMain: true, targets: [] }
|
|
60
|
+
const result = {
|
|
61
|
+
blocked: false,
|
|
62
|
+
noop: false,
|
|
63
|
+
reason: null,
|
|
64
|
+
commands: [],
|
|
65
|
+
prodTag: null,
|
|
66
|
+
targets: [],
|
|
67
|
+
branch,
|
|
68
|
+
base: c.base,
|
|
69
|
+
}
|
|
70
|
+
const block = (reason) => ({ ...result, blocked: true, reason })
|
|
71
|
+
|
|
72
|
+
// A hotfix must carry the tag it forked from — everything below bumps from it.
|
|
73
|
+
if (!baseRef) {
|
|
74
|
+
return block('spec has no Base version — not a hotfix, or the header is missing')
|
|
75
|
+
}
|
|
76
|
+
// The completion edits (status flip, git mv) must be committed before landing.
|
|
77
|
+
if (c.worktreeState && c.worktreeState.dirty) {
|
|
78
|
+
return block('worktree has uncommitted changes — commit the completion first')
|
|
79
|
+
}
|
|
80
|
+
// Nothing on the branch beyond the base tag → nothing to land.
|
|
81
|
+
if (!c.aheadOfBase) {
|
|
82
|
+
return { ...result, noop: true, reason: `no commits on ${branch} beyond ${baseRef} — nothing to land` }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Track tags we'd create so we never plan a collision (with an existing tag, or
|
|
86
|
+
// between the prod tag and a target tag).
|
|
87
|
+
const taken = new Set(c.existingTags || [])
|
|
88
|
+
const fixRange = c.fixRange
|
|
89
|
+
const commands = []
|
|
90
|
+
const targets = []
|
|
91
|
+
|
|
92
|
+
// 1. Prod line: tag the hotfix branch head (already baseRef + fix). No push.
|
|
93
|
+
const prodTag = bumpPatch(baseRef)
|
|
94
|
+
if (taken.has(prodTag)) {
|
|
95
|
+
return block(`tag ${prodTag} already exists — bump the base version or delete the stale tag`)
|
|
96
|
+
}
|
|
97
|
+
taken.add(prodTag)
|
|
98
|
+
commands.push(`git -C ${spec.worktreePath} tag ${prodTag}`)
|
|
99
|
+
targets.push({ kind: 'prod', base: baseRef, tag: prodTag })
|
|
100
|
+
|
|
101
|
+
// 2. Extra targets: cherry-pick the fix onto a throwaway worktree at each tag,
|
|
102
|
+
// re-tag with its own patch bump, then remove the worktree + temp branch (the
|
|
103
|
+
// commits survive under the new tag, so `-D` is safe for the throwaway branch).
|
|
104
|
+
const wtRoot = path.dirname(spec.worktreePath)
|
|
105
|
+
for (const t of c.extraTargets || []) {
|
|
106
|
+
const tag = bumpPatch(t)
|
|
107
|
+
if (taken.has(tag)) {
|
|
108
|
+
return block(`tag ${tag} (for target ${t}) already exists — resolve it before landing`)
|
|
109
|
+
}
|
|
110
|
+
taken.add(tag)
|
|
111
|
+
const slug = `${spec.slug}-onto-${tagSlug(t)}`
|
|
112
|
+
const tmpBranch = `hotfix/${slug}`
|
|
113
|
+
const tmpPath = path.join(wtRoot, slug)
|
|
114
|
+
commands.push(
|
|
115
|
+
`git worktree add ${tmpPath} -b ${tmpBranch} ${t}`,
|
|
116
|
+
`git -C ${tmpPath} cherry-pick ${fixRange}`,
|
|
117
|
+
`git -C ${tmpPath} tag ${tag}`,
|
|
118
|
+
`git worktree remove ${tmpPath}`,
|
|
119
|
+
`git branch -D ${tmpBranch}`,
|
|
120
|
+
)
|
|
121
|
+
targets.push({ kind: 'extra', base: t, tag, worktreePath: tmpPath })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 3. Cherry-pick the fix onto the base branch (main) for the next release.
|
|
125
|
+
if (hotfixCfg.cherryPickMain !== false) {
|
|
126
|
+
commands.push(`git -C ${c.mainRepoPath} cherry-pick ${fixRange}`)
|
|
127
|
+
targets.push({ kind: 'main', base: c.base })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { ...result, commands, prodTag, targets, fixRange }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { bumpPatch, tagSlug, planHotfixLand }
|
package/src/env/live.js
CHANGED
|
@@ -179,21 +179,30 @@ function planTake(spec, config, ctx) {
|
|
|
179
179
|
if (!c.worktreeExists) {
|
|
180
180
|
return block(`${spec.folder} has no worktree — run \`/spec-go ${spec.folder}\` first`)
|
|
181
181
|
}
|
|
182
|
-
// 4.
|
|
182
|
+
// 4. A hotfix is built on an old release tag; checking its branch out under the
|
|
183
|
+
// running dev server risks schema/DB drift breaking the shared instance.
|
|
184
|
+
// Always refuse, regardless of Stack — test it in isolation via /spec-connect.
|
|
185
|
+
if (spec.type === 'hotfix') {
|
|
186
|
+
return block(
|
|
187
|
+
`${spec.folder} is a hotfix (built on an old release tag) — live overlay ` +
|
|
188
|
+
'could break the running instance; use `/spec-connect` to test it in isolation',
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
// 5. v1 is code-only: refuse a stateful spec (Stack: worktree + docker)…
|
|
183
192
|
if (spec.stack === 'docker') {
|
|
184
193
|
return block(
|
|
185
194
|
`${spec.folder} is stateful (Stack: worktree + docker) — live overlay is ` +
|
|
186
195
|
'code-only; use `/spec-connect` for a Docker-backed spec',
|
|
187
196
|
)
|
|
188
197
|
}
|
|
189
|
-
//
|
|
198
|
+
// 6. …and refuse a branch that changes migrations (would mutate the shared DB).
|
|
190
199
|
if (c.migrationsHit) {
|
|
191
200
|
return block(
|
|
192
201
|
`${spec.folder}'s branch changes migrations — live overlay is code-only; ` +
|
|
193
202
|
'use `/spec-connect`',
|
|
194
203
|
)
|
|
195
204
|
}
|
|
196
|
-
//
|
|
205
|
+
// 7. Verify-only: a dev server must be listening to hot-reload the switch.
|
|
197
206
|
if (c.serverUp === false) {
|
|
198
207
|
return block(
|
|
199
208
|
`no dev server listening on canonical port(s) ${(c.canonicalPorts || []).join(', ')} — ` +
|
package/src/env/provision.js
CHANGED
|
@@ -94,10 +94,14 @@ function planUp(spec, alloc, config) {
|
|
|
94
94
|
|
|
95
95
|
const commands = []
|
|
96
96
|
// Fresh branch → -b; attach an existing branch/slot → plain form (never clobber).
|
|
97
|
+
// A hotfix forks its fresh branch from a release tag (`spec.baseRef`, e.g.
|
|
98
|
+
// `v33.16.4`) instead of base HEAD — so the fix is built on the exact commit
|
|
99
|
+
// line prod runs. Attaching an existing branch ignores baseRef (already forked).
|
|
100
|
+
const forkPoint = !attached && spec.baseRef ? ` ${spec.baseRef}` : ''
|
|
97
101
|
commands.push(
|
|
98
102
|
attached
|
|
99
103
|
? `git worktree add ${spec.worktreePath} ${spec.branch}`
|
|
100
|
-
: `git worktree add ${spec.worktreePath} -b ${spec.branch}`,
|
|
104
|
+
: `git worktree add ${spec.worktreePath} -b ${spec.branch}${forkPoint}`,
|
|
101
105
|
)
|
|
102
106
|
if (wantsDocker) {
|
|
103
107
|
commands.push(`docker compose --project-name ${spec.projectName} up -d`)
|
package/src/env/prune.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure planner for `spec-env prune` — the orphaned-test-DB reaper.
|
|
5
|
+
*
|
|
6
|
+
* Per-spec isolation gives each Docker-escalated spec its own compose stack
|
|
7
|
+
* namespaced by `{repoSlug}_{slug}`; the DB storage is a Docker named volume
|
|
8
|
+
* (`{repoSlug}_{slug}_<volname>`). Those volumes only ever get dropped by an
|
|
9
|
+
* explicit single-spec `spec-env down`, so they leak whenever that path is
|
|
10
|
+
* skipped (declined/guard-aborted teardown, manual `git worktree remove`,
|
|
11
|
+
* `--keep-volumes`). This planner reconciles the live namespace volumes against
|
|
12
|
+
* the specs that are still live and returns the `docker volume rm` commands for
|
|
13
|
+
* the orphans.
|
|
14
|
+
*
|
|
15
|
+
* **Liveness = an existing worktree, NOT the slot registry.** The registry is
|
|
16
|
+
* exactly what goes stale (a declined teardown leaves both the slot and the
|
|
17
|
+
* volume behind), so the caller derives `liveSlugs` from real worktrees and the
|
|
18
|
+
* registry is only reconciled afterwards. See the spec Decisions.
|
|
19
|
+
*
|
|
20
|
+
* We never parse a slug out of an orphan. Instead we build a protected-prefix
|
|
21
|
+
* set from the live slugs (`{repoSlug}_{slug}_`) and keep any volume matching
|
|
22
|
+
* one — everything else in the `{repoSlug}_` namespace is an orphan. The
|
|
23
|
+
* trailing `_` makes the slug match exact: slugs are kebab-case (`[a-z0-9-]`)
|
|
24
|
+
* and `_` is the compose project/volume separator, so `add` never protects
|
|
25
|
+
* `add-widget`.
|
|
26
|
+
*
|
|
27
|
+
* Side-effect free and deterministic: no `Date.now()` — the caller supplies
|
|
28
|
+
* `now` (and per-volume `createdAt`) when age-gating.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const { splitPrefix } = require('./resolve.js')
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {Array<string|{name:string,createdAt?:number|null}>} volumes
|
|
35
|
+
* live Docker volume names (or objects carrying `createdAt` epoch-ms for age
|
|
36
|
+
* gating). Plain strings are treated as unknown-age.
|
|
37
|
+
* @param {Iterable<string>} liveSlugs slugs of specs that still have a worktree.
|
|
38
|
+
* @param {object} opts { repoSlug, olderThanDays?, now? }
|
|
39
|
+
* `repoSlug` (required) is the namespace prefix. `olderThanDays` (optional)
|
|
40
|
+
* keeps only orphans strictly older than the cutoff; when set, `now`
|
|
41
|
+
* (epoch-ms) is required and volumes of unknown age are conservatively kept.
|
|
42
|
+
* @returns {{ orphans: Array<{name:string,createdAt:number|null}>, commands: string[] }}
|
|
43
|
+
*/
|
|
44
|
+
function planPrune(volumes, liveSlugs, opts = {}) {
|
|
45
|
+
const { repoSlug, olderThanDays = null, now = null } = opts
|
|
46
|
+
if (!repoSlug) throw new Error('planPrune: opts.repoSlug is required')
|
|
47
|
+
|
|
48
|
+
const namespace = `${repoSlug}_`
|
|
49
|
+
const live = liveSlugs instanceof Set ? liveSlugs : new Set(liveSlugs || [])
|
|
50
|
+
const protectedPrefixes = [...live].map((slug) => `${repoSlug}_${slug}_`)
|
|
51
|
+
|
|
52
|
+
let orphans = (volumes || [])
|
|
53
|
+
.map((v) => (typeof v === 'string' ? { name: v, createdAt: null } : v))
|
|
54
|
+
.filter((v) => {
|
|
55
|
+
const name = v && v.name
|
|
56
|
+
if (!name || !name.startsWith(namespace)) return false
|
|
57
|
+
return !protectedPrefixes.some((p) => name.startsWith(p))
|
|
58
|
+
})
|
|
59
|
+
.map((v) => ({ name: v.name, createdAt: v.createdAt == null ? null : v.createdAt }))
|
|
60
|
+
|
|
61
|
+
if (olderThanDays != null) {
|
|
62
|
+
if (now == null) throw new Error('planPrune: olderThanDays requires opts.now')
|
|
63
|
+
const cutoff = now - olderThanDays * 24 * 60 * 60 * 1000
|
|
64
|
+
// Unknown age (createdAt == null) is kept — never drop what we can't date.
|
|
65
|
+
orphans = orphans.filter((v) => v.createdAt != null && v.createdAt <= cutoff)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const commands = orphans.map((v) => `docker volume rm ${v.name}`)
|
|
69
|
+
return { orphans, commands }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Derive the set of live spec slugs from the specs that still have a git
|
|
74
|
+
* worktree. The **worktree is the liveness signal** (see module header): the
|
|
75
|
+
* registry is not consulted here. Pure — the caller supplies the spec list and
|
|
76
|
+
* the set of live worktree paths (both gathered via IO).
|
|
77
|
+
*
|
|
78
|
+
* @param {Array<{slug:string, worktreePath:string}>} specs
|
|
79
|
+
* @param {Set<string>|Iterable<string>} liveWorktreePaths absolute paths.
|
|
80
|
+
* @returns {Set<string>} slugs whose worktree currently exists.
|
|
81
|
+
*/
|
|
82
|
+
function liveSlugsForSpecs(specs, liveWorktreePaths) {
|
|
83
|
+
const live = liveWorktreePaths instanceof Set ? liveWorktreePaths : new Set(liveWorktreePaths || [])
|
|
84
|
+
const slugs = new Set()
|
|
85
|
+
for (const spec of specs || []) {
|
|
86
|
+
if (spec && spec.worktreePath && live.has(spec.worktreePath)) slugs.add(spec.slug)
|
|
87
|
+
}
|
|
88
|
+
return slugs
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Reconcile the slot registry against the volumes we're about to reap. A stale
|
|
93
|
+
* slot is one whose spec's DB volume is an orphan — freeing it converges the
|
|
94
|
+
* registry back to what actually exists (the registry is keyed by spec *folder*,
|
|
95
|
+
* so we split each folder's prefix to get its slug and match it to a reaped
|
|
96
|
+
* volume by the same `{repoSlug}_{slug}_` prefix used for orphan detection).
|
|
97
|
+
* Pure: returns a new registry object plus the folders freed; never mutates.
|
|
98
|
+
*
|
|
99
|
+
* @param {{slots: Object<string,number>}} registry
|
|
100
|
+
* @param {Array<{name:string}>} orphans the volumes planPrune decided to reap.
|
|
101
|
+
* @param {string} repoSlug
|
|
102
|
+
* @returns {{ registry: {slots: Object<string,number>}, freed: string[] }}
|
|
103
|
+
*/
|
|
104
|
+
function reconcileRegistry(registry, orphans, repoSlug) {
|
|
105
|
+
const slots = { ...((registry && registry.slots) || {}) }
|
|
106
|
+
const reaped = (orphans || []).map((o) => o.name)
|
|
107
|
+
const freed = []
|
|
108
|
+
for (const folder of Object.keys(slots)) {
|
|
109
|
+
const { slug } = splitPrefix(folder)
|
|
110
|
+
const prefix = `${repoSlug}_${slug}_`
|
|
111
|
+
if (reaped.some((name) => name.startsWith(prefix))) {
|
|
112
|
+
delete slots[folder]
|
|
113
|
+
freed.push(folder)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { registry: { slots }, freed }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { planPrune, liveSlugsForSpecs, reconcileRegistry }
|
package/src/env/resolve.js
CHANGED
|
@@ -36,10 +36,10 @@ function findSpecFolder(specArg, dir, extraDirs = []) {
|
|
|
36
36
|
return null
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
// Split a `feat-`/`bug-` prefix. Unknown prefix → type defaults to
|
|
40
|
-
// the whole folder name is the slug.
|
|
39
|
+
// Split a `feat-`/`bug-`/`hotfix-` prefix. Unknown prefix → type defaults to
|
|
40
|
+
// `feat` and the whole folder name is the slug.
|
|
41
41
|
function splitPrefix(folder) {
|
|
42
|
-
const m = /^(feat|bug)-(.+)$/.exec(folder)
|
|
42
|
+
const m = /^(feat|bug|hotfix)-(.+)$/.exec(folder)
|
|
43
43
|
if (m) return { type: m[1], slug: m[2] }
|
|
44
44
|
return { type: 'feat', slug: folder }
|
|
45
45
|
}
|
|
@@ -104,6 +104,26 @@ function readStackField(specPath, config) {
|
|
|
104
104
|
return config.docker && config.docker.enabled ? 'docker' : 'worktree'
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Read a spec's `> **Base version:** <tag>` blockquote field from 00-overview.md.
|
|
109
|
+
* This is the release tag a hotfix forks its worktree from (e.g. `v33.16.4`),
|
|
110
|
+
* authored by the `/spec-hotfix` skill. Returns the trimmed tag, or null when the
|
|
111
|
+
* field / file is absent — so a non-hotfix spec resolves to `baseRef: null` and
|
|
112
|
+
* provisioning forks from base HEAD as before.
|
|
113
|
+
*/
|
|
114
|
+
function readBaseVersionField(specPath) {
|
|
115
|
+
const overview = path.join(specPath, '00-overview.md')
|
|
116
|
+
let raw
|
|
117
|
+
try {
|
|
118
|
+
raw = fs.readFileSync(overview, 'utf-8')
|
|
119
|
+
} catch {
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
const m = /^>\s*\*\*Base version:\*\*\s*(.+)$/m.exec(raw)
|
|
123
|
+
if (!m) return null
|
|
124
|
+
return m[1].trim().replace(/^["'`]|["'`]$/g, '') || null
|
|
125
|
+
}
|
|
126
|
+
|
|
107
127
|
/**
|
|
108
128
|
* Derive the git branch for a spec from the provider-neutral `branch.pattern`
|
|
109
129
|
* (`{type}`, `{slug}`, and optionally `{identifier}`). When the pattern uses
|
|
@@ -207,7 +227,10 @@ function resolveSpec(specArg, dir, config, opts = {}) {
|
|
|
207
227
|
const tokens = { repo, repoSlug, slug }
|
|
208
228
|
|
|
209
229
|
const stack = readStackField(found.path, config)
|
|
210
|
-
|
|
230
|
+
// A hotfix forks its worktree from a release tag (its `Base version`) instead
|
|
231
|
+
// of base HEAD; every other type resolves to baseRef:null (fork from HEAD).
|
|
232
|
+
const baseRef = type === 'hotfix' ? readBaseVersionField(found.path) : null
|
|
233
|
+
const spec = { folder: found.folder, bucket: found.bucket, path: found.path, type, slug, stack, baseRef }
|
|
211
234
|
const branch = branchFor(spec, config)
|
|
212
235
|
|
|
213
236
|
const worktreeRoot = expandTokens(config.worktree.root, tokens)
|
|
@@ -239,4 +262,5 @@ module.exports = {
|
|
|
239
262
|
expandTokens,
|
|
240
263
|
findSpecFolder,
|
|
241
264
|
readStackField,
|
|
265
|
+
readBaseVersionField,
|
|
242
266
|
}
|
package/src/env/teardown.js
CHANGED
|
@@ -21,7 +21,7 @@ const { expandTokens } = require('./resolve.js')
|
|
|
21
21
|
* @param {object} spec resolved spec: { slug, branch, worktreePath, projectName, ... }
|
|
22
22
|
* @param {object} config normalised env config.
|
|
23
23
|
* @param {object} flags { keepVolumes, force }
|
|
24
|
-
* @param {object} ctx { worktreeState: { dirty, unpushed, merged }, timestamp }
|
|
24
|
+
* @param {object} ctx { worktreeState: { dirty, unpushed, merged, reachableFromTag }, timestamp }
|
|
25
25
|
* @returns {object} { blocked, reason, commands, backupCommand, backupPath,
|
|
26
26
|
* volumesDropped }
|
|
27
27
|
*/
|
|
@@ -30,20 +30,22 @@ function planDown(spec, config, flags, ctx) {
|
|
|
30
30
|
const force = Boolean(flags && flags.force)
|
|
31
31
|
const keepVolumes = Boolean(flags && flags.keepVolumes)
|
|
32
32
|
|
|
33
|
+
// A hotfix lands by tag + cherry-pick, so its branch is never an ancestor of
|
|
34
|
+
// base — but once its head is captured by a tag (the deploy tag from
|
|
35
|
+
// `hotfix land`), the commits are recoverable and the branch is safe to drop.
|
|
36
|
+
// Treat "reachable from a tag" as landed, alongside merged.
|
|
37
|
+
const landed = Boolean(worktreeState.merged || worktreeState.reachableFromTag)
|
|
38
|
+
|
|
33
39
|
// --- guards (overridable with --force) ---
|
|
34
40
|
if (!force) {
|
|
35
41
|
if (config.guards.refuseTeardownIfDirty && worktreeState.dirty) {
|
|
36
42
|
return blocked('worktree has uncommitted changes')
|
|
37
43
|
}
|
|
38
44
|
// Unpushed commits are only unsafe when they aren't already integrated into
|
|
39
|
-
// the base branch. A branch
|
|
40
|
-
// with no remote — so /spec-complete's local land-then-teardown
|
|
41
|
-
// --force. Block only when
|
|
42
|
-
if (
|
|
43
|
-
config.guards.refuseTeardownIfUnpushed &&
|
|
44
|
-
worktreeState.unpushed &&
|
|
45
|
-
!worktreeState.merged
|
|
46
|
-
) {
|
|
45
|
+
// the base branch (or captured by a tag). A landed branch carries nothing to
|
|
46
|
+
// lose even with no remote — so /spec-complete's local land-then-teardown
|
|
47
|
+
// needs no --force. Block only when unpushed AND not landed.
|
|
48
|
+
if (config.guards.refuseTeardownIfUnpushed && worktreeState.unpushed && !landed) {
|
|
47
49
|
return blocked('worktree has unpushed commits not yet merged into the base branch')
|
|
48
50
|
}
|
|
49
51
|
}
|
|
@@ -85,11 +87,16 @@ function planDown(spec, config, flags, ctx) {
|
|
|
85
87
|
: `git worktree remove ${spec.worktreePath}`,
|
|
86
88
|
)
|
|
87
89
|
|
|
88
|
-
// --- delete the branch
|
|
89
|
-
// Runs after the worktree remove frees the branch.
|
|
90
|
-
// unmerged branch
|
|
90
|
+
// --- delete the branch ---
|
|
91
|
+
// Runs after the worktree remove frees the branch. Normally `-d` (safe: refuses
|
|
92
|
+
// an unmerged branch, never -D). A tag-landed hotfix branch is intentionally NOT
|
|
93
|
+
// an ancestor of base, so `-d` would refuse it — but its commits are captured by
|
|
94
|
+
// the deploy tag, so `-D` is safe *only* in that case. Everything else stays `-d`;
|
|
95
|
+
// on a forced teardown of a genuinely unmerged branch it fails loudly and the
|
|
96
|
+
// skill relays it rather than -D-ing.
|
|
91
97
|
if (spec.branch) {
|
|
92
|
-
|
|
98
|
+
const tagLanded = Boolean(worktreeState.reachableFromTag && !worktreeState.merged)
|
|
99
|
+
commands.push(`git branch ${tagLanded ? '-D' : '-d'} ${spec.branch}`)
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
return { blocked: false, reason: null, commands, backupCommand, backupPath, volumesDropped }
|