@skitterbyte/skitterspec-jira 0.0.0-stage → 2.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 +1266 -0
- package/README.md +44 -2
- package/assets/claude-md-section.md +85 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/commands/spec-remote-review.md +37 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/SETUP.md +192 -0
- package/assets/core/env.config.json.example +56 -0
- package/assets/core/env.config.md +606 -0
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/jira.config.json.example +18 -0
- package/assets/core/jira.config.md +88 -0
- package/assets/hooks/main-guard.cjs +162 -0
- package/assets/hooks/review-gate.cjs +146 -0
- package/assets/review/page.html +3384 -0
- package/assets/rules/commit-trailers.md +96 -0
- package/assets/rules/negative-checks.md +69 -0
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +702 -0
- package/assets/rules/spec-reports.md +572 -0
- package/assets/skills/no-spec/SKILL.md +249 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +585 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +451 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +607 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +221 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +678 -0
- package/assets/skills/spec-push/SKILL.md +306 -0
- package/assets/skills/spec-review/SKILL.md +256 -0
- package/assets/skills/spec-reviewed/SKILL.md +284 -0
- package/assets/skills/spec-start/SKILL.md +496 -0
- package/assets/skills/spec-status/SKILL.md +114 -0
- package/assets/skills/spec-sync/SKILL.md +220 -0
- package/assets/skills/spec-to-main/SKILL.md +141 -0
- package/bin/skitterspec-jira.js +68 -0
- package/package.json +40 -4
- package/src/cli.js +5967 -0
- package/src/deprecate.js +138 -0
- package/src/env/bootstrap.js +188 -0
- package/src/env/building.js +143 -0
- package/src/env/classify.js +176 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +850 -0
- package/src/env/dev.js +63 -0
- package/src/env/glob.js +34 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +413 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +167 -0
- package/src/env/live.js +479 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +225 -0
- package/src/env/prune.js +119 -0
- package/src/env/registry.js +159 -0
- package/src/env/render.js +17 -0
- package/src/env/resolve.js +716 -0
- package/src/env/review.js +2622 -0
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +1044 -0
- package/src/env/supervise.js +157 -0
- package/src/env/teardown.js +248 -0
- package/src/env/trust.js +87 -0
- package/src/gating.js +155 -0
- package/src/init.js +1150 -0
- package/src/lines-diff.js +114 -0
- package/src/prompts.js +129 -0
- package/src/vendor/jira/adf.js +414 -0
- package/src/vendor/jira/api.js +295 -0
- package/src/vendor/jira/cli-sync.js +1252 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +286 -0
- package/src/vendor/jira/credentials.js +108 -0
- package/src/vendor/jira/dialect.js +45 -0
- package/src/vendor/jira/mcp.js +129 -0
- package/src/vendor/provider-kit/index.js +41 -0
- package/src/vendor/provider-kit/src/adapter.js +23 -0
- package/src/vendor/provider-kit/src/apply.js +207 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/assignment.js +277 -0
- package/src/vendor/provider-kit/src/config.js +251 -0
- package/src/vendor/provider-kit/src/credentials.js +404 -0
- package/src/vendor/provider-kit/src/doctor.js +35 -0
- package/src/vendor/provider-kit/src/http.js +63 -0
- package/src/vendor/provider-kit/src/identity.js +93 -0
- package/src/vendor/provider-kit/src/list-assignee.js +71 -0
- package/src/vendor/provider-kit/src/list-rows.js +35 -0
- package/src/vendor/provider-kit/src/md-compare.js +39 -0
- package/src/vendor/provider-kit/src/md-tables.js +94 -0
- package/src/vendor/provider-kit/src/prompt.js +50 -0
- package/src/vendor/provider-kit/src/released.js +219 -0
- package/src/vendor/provider-kit/src/specs.js +248 -0
- package/src/vendor/provider-kit/src/transport.js +22 -0
- package/src/vendor/sync-core/index.js +92 -0
- package/src/vendor/sync-core/src/base.js +81 -0
- package/src/vendor/sync-core/src/compare.js +338 -0
- package/src/vendor/sync-core/src/dialect.js +51 -0
- package/src/vendor/sync-core/src/fields.js +36 -0
- package/src/vendor/sync-core/src/legacy.js +100 -0
- package/src/vendor/sync-core/src/normalize.js +971 -0
- package/src/vendor/sync-core/src/push.js +85 -0
- package/src/vendor/sync-core/src/retarget.js +278 -0
- package/src/vendor/sync-core/src/task-block.js +308 -0
- package/src/vendor/sync-core/src/verify.js +67 -0
- package/src/vendor/sync-core/src/write.js +191 -0
|
@@ -0,0 +1,413 @@
|
|
|
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
|
+
const { afterLandFor, rangeOf } = require('./integrate.js')
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse a semver-ish tag and return it with its PATCH bumped by one. Preserves any
|
|
28
|
+
* non-digit prefix (e.g. `v` → `v33.16.5`) and drops any pre-release/build suffix
|
|
29
|
+
* (`-rc1`, `+build`). Throws on a tag with no `MAJOR.MINOR.PATCH` core.
|
|
30
|
+
*/
|
|
31
|
+
function bumpPatch(tag) {
|
|
32
|
+
const m = /^(\D*)(\d+)\.(\d+)\.(\d+)(?:.*)$/.exec(String(tag || '').trim())
|
|
33
|
+
if (!m) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`hotfix: cannot bump a patch version from tag "${tag}" — need <prefix>MAJOR.MINOR.PATCH`,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
const [, prefix, major, minor, patch] = m
|
|
39
|
+
return `${prefix}${major}.${minor}.${Number(patch) + 1}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A release tag's parts: `<prefix><major>.<minor>.<patch>[-<suffix>]`. A
|
|
43
|
+
// `hotfix.N` suffix is kept apart from every other one, because the two sort on
|
|
44
|
+
// opposite sides of their base (see compareTags). Null for anything else.
|
|
45
|
+
const TAG = /^(\D*)(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/
|
|
46
|
+
function parseTag(tag) {
|
|
47
|
+
const m = TAG.exec(String(tag || '').trim())
|
|
48
|
+
if (!m) return null
|
|
49
|
+
const [, prefix, major, minor, patch, suffix = null] = m
|
|
50
|
+
const hotfix = suffix && /^hotfix\.(\d+)$/.exec(suffix)
|
|
51
|
+
return {
|
|
52
|
+
prefix,
|
|
53
|
+
core: [Number(major), Number(minor), Number(patch)],
|
|
54
|
+
root: `${prefix}${major}.${minor}.${patch}`,
|
|
55
|
+
hotfix: hotfix ? Number(hotfix[1]) : null,
|
|
56
|
+
suffix: hotfix ? null : suffix,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Order two release tags: X.Y.Z-rc.N < X.Y.Z < X.Y.Z-hotfix.N < X.Y.(Z+1).
|
|
62
|
+
*
|
|
63
|
+
* This is skittership's rule and git's `version:refname` with a hotfix suffix
|
|
64
|
+
* configured to sort after its base, so the two tools never disagree about
|
|
65
|
+
* which tag is newer. A hotfix is a later build OF X.Y.Z; every other suffix is
|
|
66
|
+
* a pre-release BEFORE it, as in semver. Hotfix numbers compare numerically
|
|
67
|
+
* (`-hotfix.10` after `-hotfix.9`); other suffixes compare as strings. An
|
|
68
|
+
* unparseable tag sorts first, so it can never be mistaken for the newest.
|
|
69
|
+
*/
|
|
70
|
+
function compareTags(a, b) {
|
|
71
|
+
const x = parseTag(a)
|
|
72
|
+
const y = parseTag(b)
|
|
73
|
+
if (!x || !y) return (x ? 1 : 0) - (y ? 1 : 0)
|
|
74
|
+
for (let i = 0; i < 3; i++) {
|
|
75
|
+
if (x.core[i] !== y.core[i]) return x.core[i] - y.core[i]
|
|
76
|
+
}
|
|
77
|
+
// Rank within one core: pre-release (0) < the release itself (1) < hotfix (2).
|
|
78
|
+
const rank = (t) => (t.suffix ? 0 : t.hotfix === null ? 1 : 2)
|
|
79
|
+
if (rank(x) !== rank(y)) return rank(x) - rank(y)
|
|
80
|
+
if (x.hotfix !== null) return x.hotfix - y.hotfix
|
|
81
|
+
if (x.suffix) return x.suffix < y.suffix ? -1 : x.suffix > y.suffix ? 1 : 0
|
|
82
|
+
return 0
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The next `<root>-hotfix.<N>` for a base tag: N is one above the highest
|
|
87
|
+
* `<root>-hotfix.*` among `existingTags`, else 1. The root is the base with any
|
|
88
|
+
* hotfix suffix stripped, so a hotfix on `v36.4.0-hotfix.2` continues that
|
|
89
|
+
* line. A later `X.Y.(Z+1)` is neither a collision nor counted — it is another
|
|
90
|
+
* release line entirely. Same rule as skittership's release-hotfix.
|
|
91
|
+
*/
|
|
92
|
+
function nextHotfixTag(baseTag, existingTags) {
|
|
93
|
+
const base = parseTag(baseTag)
|
|
94
|
+
if (!base) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`hotfix: cannot derive a hotfix tag from "${baseTag}" — need <prefix>MAJOR.MINOR.PATCH`,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
if (base.suffix) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`hotfix: base tag "${baseTag}" is a pre-release — a hotfix needs a released version`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
let n = base.hotfix || 0
|
|
105
|
+
for (const t of existingTags || []) {
|
|
106
|
+
const p = parseTag(t)
|
|
107
|
+
if (p && p.root === base.root && p.hotfix !== null && p.hotfix > n) n = p.hotfix
|
|
108
|
+
}
|
|
109
|
+
return `${base.root}-hotfix.${n + 1}`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const BUMPS = ['patch', 'hotfix-suffix']
|
|
113
|
+
|
|
114
|
+
// The tag `hotfix.bump` asks for. An unknown strategy throws rather than
|
|
115
|
+
// falling back to `patch`: a typo there would otherwise cut the wrong release
|
|
116
|
+
// line and say nothing.
|
|
117
|
+
//
|
|
118
|
+
// Under `patch`, a base that is ITSELF a `-hotfix.N` tag continues that line.
|
|
119
|
+
// Bumping its patch takes X.Y.(Z+1), a number the mainline owns: refused when
|
|
120
|
+
// the mainline already cut it, and minted silently when it had not. Only a
|
|
121
|
+
// parsed hotfix suffix routes here; an rc or any other suffix keeps the patch
|
|
122
|
+
// bump, so a base we cannot read as a hotfix line is never treated as one.
|
|
123
|
+
function nextTag(baseTag, existingTags, bump = 'patch') {
|
|
124
|
+
if (bump === 'patch') {
|
|
125
|
+
const base = parseTag(baseTag)
|
|
126
|
+
if (base && base.hotfix !== null) return nextHotfixTag(baseTag, existingTags)
|
|
127
|
+
return bumpPatch(baseTag)
|
|
128
|
+
}
|
|
129
|
+
if (bump === 'hotfix-suffix') return nextHotfixTag(baseTag, existingTags)
|
|
130
|
+
throw new Error(`unknown hotfix.bump "${bump}" — use one of: ${BUMPS.join(', ')}`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// What a collision refusal offers: the free tag on the same base's hotfix
|
|
134
|
+
// line, which is never taken by a mainline release. Never a deletion and never
|
|
135
|
+
// another base — the first destroys a released tag, the second forks from a
|
|
136
|
+
// version prod is not running. Empty where the suffix line cannot be derived
|
|
137
|
+
// (an rc base) or is already the strategy, so the refusal just names the tag.
|
|
138
|
+
function freeTagHint(baseTag, existingTags, bump) {
|
|
139
|
+
if (bump !== 'patch') return ''
|
|
140
|
+
let free
|
|
141
|
+
try {
|
|
142
|
+
free = nextHotfixTag(baseTag, existingTags)
|
|
143
|
+
} catch {
|
|
144
|
+
return ''
|
|
145
|
+
}
|
|
146
|
+
return ` — set hotfix.bump "hotfix-suffix" in env.config.json to cut ${free} instead`
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// A filesystem/branch-safe slug derived from a tag (`v30.2.1` → `v30-2-1`).
|
|
150
|
+
function tagSlug(tag) {
|
|
151
|
+
return String(tag)
|
|
152
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
153
|
+
.replace(/^-+|-+$/g, '')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @param {object} spec resolved hotfix spec: { slug, branch, worktreePath, baseRef, ... }
|
|
158
|
+
* @param {object} config normalised env config (reads `config.hotfix`).
|
|
159
|
+
* @param {object} ctx { worktreeState:{dirty}, aheadOfBase, fixRange, mainRepoPath,
|
|
160
|
+
* base, extraTargets: string[], existingTags: string[],
|
|
161
|
+
* released?, push?: boolean, pushed?: boolean, remote?,
|
|
162
|
+
* fixCommits?: string[],
|
|
163
|
+
* carry?: { strategy: 'inplace'|'worktree'|'dirty', path,
|
|
164
|
+
* tmpPath?, commits: string[] } }
|
|
165
|
+
* @returns {object} { blocked, noop, reason, commands, afterLand, prodTag, targets, branch, base }
|
|
166
|
+
*/
|
|
167
|
+
function planHotfixLand(spec, config, ctx) {
|
|
168
|
+
const c = ctx || {}
|
|
169
|
+
const branch = spec.branch
|
|
170
|
+
const baseRef = spec.baseRef
|
|
171
|
+
const hotfixCfg = (config && config.hotfix) || { cherryPickMain: true, targets: [] }
|
|
172
|
+
const result = {
|
|
173
|
+
blocked: false,
|
|
174
|
+
noop: false,
|
|
175
|
+
reason: null,
|
|
176
|
+
commands: [],
|
|
177
|
+
steps: [],
|
|
178
|
+
afterLand: afterLandFor(null),
|
|
179
|
+
prodTag: null,
|
|
180
|
+
targets: [],
|
|
181
|
+
branch,
|
|
182
|
+
base: c.base,
|
|
183
|
+
}
|
|
184
|
+
const block = (reason) => ({ ...result, blocked: true, reason })
|
|
185
|
+
|
|
186
|
+
// A hotfix must carry the tag it forked from — everything below bumps from it.
|
|
187
|
+
if (!baseRef) {
|
|
188
|
+
return block('spec has no Base version — not a hotfix, or the header is missing')
|
|
189
|
+
}
|
|
190
|
+
// The completion edits (status flip, git mv) must be committed before landing.
|
|
191
|
+
if (c.worktreeState && c.worktreeState.dirty) {
|
|
192
|
+
return block('worktree has uncommitted changes — commit the completion first')
|
|
193
|
+
}
|
|
194
|
+
// Nothing on the branch beyond the base tag → nothing to land.
|
|
195
|
+
if (!c.aheadOfBase) {
|
|
196
|
+
return { ...result, noop: true, reason: `no commits on ${branch} beyond ${baseRef} — nothing to land` }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Track tags we'd create so we never plan a collision (with an existing tag, or
|
|
200
|
+
// between the prod tag and a target tag).
|
|
201
|
+
const taken = new Set(c.existingTags || [])
|
|
202
|
+
const fixRange = c.fixRange
|
|
203
|
+
// What a target cherry-picks: the fix commits by sha when the caller listed
|
|
204
|
+
// them (the version commit already left out), else the bare range. The
|
|
205
|
+
// range form is what this planner always emitted and what pure callers that
|
|
206
|
+
// list nothing still get.
|
|
207
|
+
const fixPick = Array.isArray(c.fixCommits) && c.fixCommits.length ? c.fixCommits : [fixRange]
|
|
208
|
+
const commands = []
|
|
209
|
+
const targets = []
|
|
210
|
+
// What the engine runs (hotfixland.js). `commands` stays beside it as the
|
|
211
|
+
// human-readable spelling of the same thing, for `--plan` and the tests.
|
|
212
|
+
const steps = []
|
|
213
|
+
const bump = hotfixCfg.bump || 'patch'
|
|
214
|
+
const releaseWith = String(hotfixCfg.releaseWith || '').trim()
|
|
215
|
+
|
|
216
|
+
// 1. Prod line: release the hotfix branch head (already baseRef + fix). No push.
|
|
217
|
+
// Three ways, decided here so the runner never has to: a release already
|
|
218
|
+
// cut on an earlier run and still at HEAD is reused, never re-cut; a
|
|
219
|
+
// project's `releaseWith` command cuts it and says which tag it made; or
|
|
220
|
+
// the built-in tagger tags HEAD with the `hotfix.bump` strategy's tag.
|
|
221
|
+
let prodTag = null
|
|
222
|
+
if (c.released && c.released.tag) {
|
|
223
|
+
prodTag = c.released.tag
|
|
224
|
+
taken.add(prodTag)
|
|
225
|
+
steps.push({ kind: 'release', via: 'recorded', tag: prodTag, release: c.released })
|
|
226
|
+
} else if (releaseWith) {
|
|
227
|
+
// The tag is the command's to choose and is unknown until it has run, so
|
|
228
|
+
// there is nothing to check a collision against here — the command refuses
|
|
229
|
+
// a tag that exists, and the runner refuses one a target also planned.
|
|
230
|
+
commands.push(`(cd ${spec.worktreePath} && ${releaseWith})`)
|
|
231
|
+
steps.push({ kind: 'release', via: 'command', command: releaseWith, cwd: spec.worktreePath })
|
|
232
|
+
} else {
|
|
233
|
+
prodTag = nextTag(baseRef, c.existingTags, bump)
|
|
234
|
+
if (taken.has(prodTag)) {
|
|
235
|
+
return block(`tag ${prodTag} already exists${freeTagHint(baseRef, c.existingTags, bump)}`)
|
|
236
|
+
}
|
|
237
|
+
taken.add(prodTag)
|
|
238
|
+
commands.push(`git -C ${spec.worktreePath} tag ${prodTag}`)
|
|
239
|
+
steps.push({
|
|
240
|
+
kind: 'release',
|
|
241
|
+
via: 'tag',
|
|
242
|
+
tag: prodTag,
|
|
243
|
+
cwd: spec.worktreePath,
|
|
244
|
+
baseTag: baseRef,
|
|
245
|
+
branch,
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
targets.push({ kind: 'prod', base: baseRef, tag: prodTag })
|
|
249
|
+
|
|
250
|
+
// 2. Publish the prod line: the branch first, then the tag, because the tag
|
|
251
|
+
// push is what starts the deploy. BEFORE anything is carried to main, so a
|
|
252
|
+
// declined or failed push never leaves main carrying a fix whose tag was
|
|
253
|
+
// never published. Three cases: the tag is already on the remote (a
|
|
254
|
+
// hand-run push, honoured on its positive signal); the operator consented
|
|
255
|
+
// (`--push`); or neither, and the run holds here with the commands printed.
|
|
256
|
+
const remote = c.remote || 'origin'
|
|
257
|
+
if (c.pushed) {
|
|
258
|
+
steps.push({ kind: 'push', via: 'remote', remote, branch, cwd: spec.worktreePath })
|
|
259
|
+
} else if (c.push) {
|
|
260
|
+
steps.push({ kind: 'push', via: 'push', remote, branch, cwd: spec.worktreePath })
|
|
261
|
+
} else {
|
|
262
|
+
steps.push({ kind: 'push', via: 'hold', remote, branch, cwd: spec.worktreePath })
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 3. Extra targets: cherry-pick the fix onto a throwaway worktree at each tag,
|
|
266
|
+
// re-tag with its own bump, then remove the worktree + temp branch (the
|
|
267
|
+
// commits survive under the new tag, so `-D` is safe for the throwaway branch).
|
|
268
|
+
// TAG-ONLY, deliberately: `releaseWith` is the prod line's. Running a
|
|
269
|
+
// project's release per target would need a hotfix/ branch and a clean
|
|
270
|
+
// tree for each one, and one failing half-way leaves a line half-released.
|
|
271
|
+
const wtRoot = path.dirname(spec.worktreePath)
|
|
272
|
+
for (const t of c.extraTargets || []) {
|
|
273
|
+
// Counted against `taken`, so two targets on one root get successive
|
|
274
|
+
// hotfix numbers rather than colliding on the same one.
|
|
275
|
+
const tag = nextTag(t, [...taken], bump)
|
|
276
|
+
if (taken.has(tag)) {
|
|
277
|
+
return block(`tag ${tag} (for target ${t}) already exists${freeTagHint(t, [...taken], bump)}`)
|
|
278
|
+
}
|
|
279
|
+
taken.add(tag)
|
|
280
|
+
const slug = `${spec.slug}-onto-${tagSlug(t)}`
|
|
281
|
+
const tmpBranch = `hotfix/${slug}`
|
|
282
|
+
const tmpPath = path.join(wtRoot, slug)
|
|
283
|
+
commands.push(
|
|
284
|
+
`git worktree add ${tmpPath} -b ${tmpBranch} ${t}`,
|
|
285
|
+
`git -C ${tmpPath} cherry-pick ${fixPick.join(' ')}`,
|
|
286
|
+
`git -C ${tmpPath} tag ${tag}`,
|
|
287
|
+
`git worktree remove ${tmpPath}`,
|
|
288
|
+
`git branch -D ${tmpBranch}`,
|
|
289
|
+
)
|
|
290
|
+
const root = c.mainRepoPath
|
|
291
|
+
steps.push({
|
|
292
|
+
kind: 'target',
|
|
293
|
+
base: t,
|
|
294
|
+
tag,
|
|
295
|
+
worktreePath: tmpPath,
|
|
296
|
+
argvs: [
|
|
297
|
+
['-C', root, 'worktree', 'add', tmpPath, '-b', tmpBranch, t],
|
|
298
|
+
['-C', tmpPath, 'cherry-pick', ...fixPick],
|
|
299
|
+
['-C', tmpPath, 'tag', tag],
|
|
300
|
+
['-C', root, 'worktree', 'remove', tmpPath],
|
|
301
|
+
['-C', root, 'branch', '-D', tmpBranch],
|
|
302
|
+
],
|
|
303
|
+
})
|
|
304
|
+
targets.push({ kind: 'extra', base: t, tag, worktreePath: tmpPath })
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 4. Cherry-pick the fix onto the base branch (main) for the next release.
|
|
308
|
+
// Only then does the primary checkout gain anything, so only then do the
|
|
309
|
+
// after-land commands apply — a prod-only land leaves main untouched.
|
|
310
|
+
let afterLand = result.afterLand
|
|
311
|
+
if (hotfixCfg.cherryPickMain !== false) {
|
|
312
|
+
// THE CARRY IS A LIST OF COMMITS, NOT A RANGE. A releasing command leaves
|
|
313
|
+
// a version commit on the branch (package.json, CHANGELOG, RELEASES), and
|
|
314
|
+
// main's version line never moves on a hotfix — so the caller hands over
|
|
315
|
+
// `baseRef..branch` minus that commit and minus what `git cherry` says main
|
|
316
|
+
// already has. The spec's own completion commits stay in it: that is how
|
|
317
|
+
// the completed spec reaches main. Without a list (pure callers), the old
|
|
318
|
+
// range form, run in place.
|
|
319
|
+
const carry = c.carry || { strategy: 'inplace', path: c.mainRepoPath, commits: null }
|
|
320
|
+
const picks = carry.commits || [fixRange]
|
|
321
|
+
commands.push(
|
|
322
|
+
carry.strategy === 'worktree'
|
|
323
|
+
? `git worktree add ${carry.tmpPath} ${c.base} && git -C ${carry.tmpPath} cherry-pick ${picks.join(' ')}`
|
|
324
|
+
: `git -C ${carry.path} cherry-pick ${picks.join(' ')}`,
|
|
325
|
+
)
|
|
326
|
+
steps.push({
|
|
327
|
+
kind: 'carry',
|
|
328
|
+
base: c.base,
|
|
329
|
+
strategy: carry.strategy,
|
|
330
|
+
cwd: carry.path || c.mainRepoPath,
|
|
331
|
+
root: c.mainRepoPath,
|
|
332
|
+
tmpPath: carry.tmpPath || null,
|
|
333
|
+
commits: picks,
|
|
334
|
+
})
|
|
335
|
+
targets.push({ kind: 'main', base: c.base })
|
|
336
|
+
// The range is the files the carried fix touches — `carry.files`, read by
|
|
337
|
+
// the caller from the fix commits; absent or unreadable → unknown → run.
|
|
338
|
+
afterLand = afterLandFor(config, c.mainRepoPath, rangeOf({ changedFiles: carry.files }))
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { ...result, commands, steps, afterLand, prodTag, targets, fixRange }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Plan `spec-env hotfix finish`: the teardown a hotfix waits for until its
|
|
346
|
+
* deploy is out.
|
|
347
|
+
*
|
|
348
|
+
* A hotfix branch is needed until the release it cut is confirmed — a failed
|
|
349
|
+
* deploy cuts the next `-hotfix.N` from the same branch — so `/spec-complete`
|
|
350
|
+
* lands and keeps the worktree, and this is the later step that removes it.
|
|
351
|
+
*
|
|
352
|
+
* THREE GUARDS, every one a positive signal, and every failing one named:
|
|
353
|
+
* - a clean tree — anything uncommitted would go with the worktree;
|
|
354
|
+
* - a release tag at HEAD — the land actually released what is here;
|
|
355
|
+
* - the branch on the remote at HEAD — a copy exists after `branch -D`.
|
|
356
|
+
* A remote that cannot be asked is "cannot tell", which refuses: deleting
|
|
357
|
+
* the only copy of a deployed fix is not the safe way to be wrong.
|
|
358
|
+
*
|
|
359
|
+
* DELIBERATELY NO "MERGED INTO MAIN" GUARD: the version commit never reaches
|
|
360
|
+
* main, so the branch is never an ancestor of it and `-d` would always refuse.
|
|
361
|
+
* And DELIBERATELY NO `--force` — the guards are the point of the verb, and
|
|
362
|
+
* `spec-env down --force` already exists for anyone who means to override them.
|
|
363
|
+
*
|
|
364
|
+
* state: { dirty, tagsAtHead: string[], head, remoteHead: string|null|undefined,
|
|
365
|
+
* remote, mainRepoPath, hasSlot }
|
|
366
|
+
* remoteHead: the branch's sha on the remote; null = not there;
|
|
367
|
+
* undefined = the remote could not be asked.
|
|
368
|
+
*/
|
|
369
|
+
function planHotfixFinish(spec, state) {
|
|
370
|
+
const s = state || {}
|
|
371
|
+
const remote = s.remote || 'origin'
|
|
372
|
+
const reasons = []
|
|
373
|
+
if (s.dirty) reasons.push('the worktree has uncommitted changes')
|
|
374
|
+
const releaseTag = (s.tagsAtHead || []).find((t) => parseTag(t) !== null) || null
|
|
375
|
+
if (!releaseTag) {
|
|
376
|
+
reasons.push('no release tag at HEAD — run `spec-env hotfix land` first')
|
|
377
|
+
}
|
|
378
|
+
if (s.remoteHead === undefined) {
|
|
379
|
+
reasons.push(`cannot tell whether ${spec.branch} is on ${remote} — the remote did not answer`)
|
|
380
|
+
} else if (s.remoteHead === null) {
|
|
381
|
+
reasons.push(`${spec.branch} is not on ${remote} — push it (\`hotfix land --push\`)`)
|
|
382
|
+
} else if (s.head && s.remoteHead !== s.head) {
|
|
383
|
+
reasons.push(`${remote}/${spec.branch} is not at HEAD — push the branch first`)
|
|
384
|
+
}
|
|
385
|
+
// A Docker stack has volumes and a slot that only `down` knows how to free.
|
|
386
|
+
if (s.hasSlot) {
|
|
387
|
+
reasons.push(`${spec.folder} holds a Docker stack — tear it down with \`spec-env down\``)
|
|
388
|
+
}
|
|
389
|
+
if (reasons.length) return { blocked: true, reasons, releaseTag, argvs: [] }
|
|
390
|
+
const root = s.mainRepoPath
|
|
391
|
+
return {
|
|
392
|
+
blocked: false,
|
|
393
|
+
reasons: [],
|
|
394
|
+
releaseTag,
|
|
395
|
+
argvs: [
|
|
396
|
+
['-C', root, 'worktree', 'remove', spec.worktreePath],
|
|
397
|
+
// -D, not -d: the branch is never merged into main (see above), and its
|
|
398
|
+
// commits are on the remote and under the release tag.
|
|
399
|
+
['-C', root, 'branch', '-D', spec.branch],
|
|
400
|
+
],
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
module.exports = {
|
|
405
|
+
bumpPatch,
|
|
406
|
+
compareTags,
|
|
407
|
+
nextHotfixTag,
|
|
408
|
+
nextTag,
|
|
409
|
+
BUMPS,
|
|
410
|
+
tagSlug,
|
|
411
|
+
planHotfixLand,
|
|
412
|
+
planHotfixFinish,
|
|
413
|
+
}
|