@skitterbyte/skitterspec 22.4.0 → 23.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 +42 -0
- package/README.md +3 -2
- package/assets/commands/spec-remote-review.md +24 -9
- package/assets/core/env.config.md +37 -15
- package/assets/hooks/main-guard.cjs +20 -3
- package/assets/rules/spec-planning.md +11 -4
- package/assets/skills/no-spec/SKILL.md +28 -4
- package/assets/skills/spec/SKILL.md +12 -4
- package/assets/skills/spec-bug/SKILL.md +12 -3
- package/assets/skills/spec-complete/SKILL.md +29 -7
- package/assets/skills/spec-diff/SKILL.md +14 -10
- package/assets/skills/spec-hotfix/SKILL.md +12 -2
- package/assets/skills/spec-start/SKILL.md +19 -8
- package/assets/skills/spec-to-main/SKILL.md +17 -2
- package/package.json +1 -1
- package/src/cli.js +360 -108
- package/src/env/bootstrap.js +188 -0
- package/src/env/config.js +15 -0
- package/src/env/hotfix.js +8 -2
- package/src/env/integrate.js +28 -8
- package/src/env/mainguard.js +64 -5
- package/src/env/provision.js +86 -168
- package/src/env/review.js +51 -1
- package/src/env/serve.js +4 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run a worktree's bootstrap steps — the engine half of `spec-env bootstrap`.
|
|
5
|
+
*
|
|
6
|
+
* `planUp` decides WHAT a fresh worktree needs (`worktreeSteps`); this module
|
|
7
|
+
* does it. Seeds and the env file are plain filesystem work, done here with
|
|
8
|
+
* `fs` and no shell at all. `setup` commands and the stack are spawned with the
|
|
9
|
+
* worktree as their cwd.
|
|
10
|
+
*
|
|
11
|
+
* WHY THE ENGINE RUNS THEM. These steps used to be printed as shell for the
|
|
12
|
+
* agent to run, and an agent does not copy shell faithfully. A consumer's
|
|
13
|
+
* secrets hook refuses any Bash call naming a `.env` file, with one exception
|
|
14
|
+
* for the exact printed shape — so every rewrite was refused, and the retry was
|
|
15
|
+
* refused again by auto mode as a workaround. The call that replaces them,
|
|
16
|
+
* `skitterspec spec-env bootstrap <name>`, names no file at all.
|
|
17
|
+
*
|
|
18
|
+
* Behaviour is the printed commands' behaviour, kept exactly: idempotent, one
|
|
19
|
+
* `seeded …` / `… — skipped` line per seed, a seed missing from the primary
|
|
20
|
+
* checkout is skipped rather than failed, a symlinked env file is refused before
|
|
21
|
+
* anything is written through it, and the run stops at the first failing step.
|
|
22
|
+
*
|
|
23
|
+
* Every side effect goes through `io`, so the tests drive it with a real temp
|
|
24
|
+
* directory and a fake spawn — no docker, no package manager.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const realFs = require('node:fs')
|
|
28
|
+
const path = require('node:path')
|
|
29
|
+
const { spawnSync } = require('node:child_process')
|
|
30
|
+
|
|
31
|
+
/** One line describing a step, for `--plan` and for the failure report. */
|
|
32
|
+
function describeStep(step) {
|
|
33
|
+
switch (step.kind) {
|
|
34
|
+
case 'seed':
|
|
35
|
+
return `seed ${step.file} (${step.mode} from the primary checkout)`
|
|
36
|
+
case 'envFile':
|
|
37
|
+
return `env file ${step.file} (COMPOSE_PROJECT_NAME, PORT_OFFSET)`
|
|
38
|
+
case 'setup':
|
|
39
|
+
return `setup: ${step.command}`
|
|
40
|
+
case 'docker':
|
|
41
|
+
return `docker: ${step.argv.join(' ')}`
|
|
42
|
+
default:
|
|
43
|
+
return `unknown step ${step.kind}`
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// `lstat` that answers null for a missing path rather than throwing.
|
|
48
|
+
function lstatOrNull(fs, p) {
|
|
49
|
+
try {
|
|
50
|
+
return fs.lstatSync(p)
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Seed one gitignored file from the primary checkout.
|
|
58
|
+
*
|
|
59
|
+
* The source is tested with `existsSync`, which follows links — a dangling
|
|
60
|
+
* link in the primary checkout is "not in main", exactly as `[ -e ]` read it.
|
|
61
|
+
* The target is tested with `lstat`, which does not: an existing link, even a
|
|
62
|
+
* dangling one, is somebody's deliberate state and is left alone.
|
|
63
|
+
*/
|
|
64
|
+
function runSeed(step, { cwd, mainRoot, fs }) {
|
|
65
|
+
const src = path.join(mainRoot, step.file)
|
|
66
|
+
const dst = path.join(cwd, step.file)
|
|
67
|
+
if (!fs.existsSync(src)) {
|
|
68
|
+
return { outcome: 'skipped', line: `seed ${step.file}: not in main — skipped` }
|
|
69
|
+
}
|
|
70
|
+
if (lstatOrNull(fs, dst)) {
|
|
71
|
+
return { outcome: 'skipped', line: `seed ${step.file}: exists — skipped` }
|
|
72
|
+
}
|
|
73
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true })
|
|
74
|
+
if (step.mode === 'copy') fs.copyFileSync(src, dst)
|
|
75
|
+
else fs.symlinkSync(src, dst)
|
|
76
|
+
return { outcome: 'seeded', line: `seeded ${step.file} → ${src}` }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Make sure the spec's env file sets its project name and port block.
|
|
81
|
+
*
|
|
82
|
+
* Additive: a key is appended only when no line already sets it, so a re-run
|
|
83
|
+
* writes nothing and a file `setup` already wrote to keeps every value it holds
|
|
84
|
+
* — a deliberate `PORT_OFFSET` of its own included.
|
|
85
|
+
*
|
|
86
|
+
* A SYMLINK IS REFUSED, before anything is written. Seeding links gitignored
|
|
87
|
+
* files from the primary checkout, so a seeded env file IS the primary
|
|
88
|
+
* checkout's own, and appending through it would write this spec's port block
|
|
89
|
+
* into the main stack's configuration. The fix is a `docker.envFile` naming a
|
|
90
|
+
* file of its own.
|
|
91
|
+
*/
|
|
92
|
+
function runEnvFile(step, { cwd, fs }) {
|
|
93
|
+
const p = path.join(cwd, step.file)
|
|
94
|
+
const st = lstatOrNull(fs, p)
|
|
95
|
+
if (st && st.isSymbolicLink()) {
|
|
96
|
+
return {
|
|
97
|
+
outcome: 'refused',
|
|
98
|
+
line:
|
|
99
|
+
`env file ${step.file} is a symlink — docker.envFile must name a file of its own, ` +
|
|
100
|
+
"not a seeded one; refusing to write this spec's port block through it",
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const before = st ? fs.readFileSync(p, 'utf8') : ''
|
|
104
|
+
let add = ''
|
|
105
|
+
// A file without a trailing newline would glue the first appended key onto
|
|
106
|
+
// its last line.
|
|
107
|
+
const sep = before.length && !before.endsWith('\n') ? '\n' : ''
|
|
108
|
+
for (const [key, value] of [
|
|
109
|
+
['COMPOSE_PROJECT_NAME', step.projectName],
|
|
110
|
+
['PORT_OFFSET', step.offset],
|
|
111
|
+
]) {
|
|
112
|
+
if (!new RegExp(`^${key}=`, 'm').test(before)) add += `${key}=${value}\n`
|
|
113
|
+
}
|
|
114
|
+
if (!st || add) {
|
|
115
|
+
fs.mkdirSync(path.dirname(p), { recursive: true })
|
|
116
|
+
fs.writeFileSync(p, before + (add ? sep + add : ''))
|
|
117
|
+
}
|
|
118
|
+
return { outcome: add ? 'written' : 'skipped', line: `env file ${step.file}: ready` }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Spawn a setup command (through the shell — it is the project's own command
|
|
123
|
+
* line) or the stack (an argv, no shell).
|
|
124
|
+
*
|
|
125
|
+
* `stdout` is where the child's own output goes: inherited normally, so an
|
|
126
|
+
* install streams as it runs, and moved to stderr under `--json` so the payload
|
|
127
|
+
* on stdout stays parseable.
|
|
128
|
+
*/
|
|
129
|
+
function runSpawned(step, { cwd, spawn, childStdout }) {
|
|
130
|
+
const stdio = ['ignore', childStdout, 'inherit']
|
|
131
|
+
const res =
|
|
132
|
+
step.kind === 'setup'
|
|
133
|
+
? spawn(step.command, { cwd, shell: true, stdio })
|
|
134
|
+
: spawn(step.argv[0], step.argv.slice(1), { cwd, stdio })
|
|
135
|
+
if (res.error) {
|
|
136
|
+
return { outcome: 'failed', line: `${describeStep(step)} — ${res.error.message}` }
|
|
137
|
+
}
|
|
138
|
+
if (res.status !== 0) {
|
|
139
|
+
const why = res.status === null ? `killed by ${res.signal || 'a signal'}` : `exit ${res.status}`
|
|
140
|
+
return { outcome: 'failed', line: `${describeStep(step)} — ${why}` }
|
|
141
|
+
}
|
|
142
|
+
return { outcome: 'ran', line: null }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Run `steps` in order in `cwd`, stopping at the first that fails.
|
|
147
|
+
*
|
|
148
|
+
* io: { cwd, mainRoot, fs?, spawn?, write?, childStdout? }
|
|
149
|
+
* - `write(line)` receives every report line as it happens
|
|
150
|
+
* - `childStdout` is the stdio slot for spawned output ('inherit' default)
|
|
151
|
+
*
|
|
152
|
+
* Returns { ok, results: [{ step, outcome, line }], failedAt } — `failedAt` is
|
|
153
|
+
* the 1-based index of the step that stopped the run, or null.
|
|
154
|
+
*/
|
|
155
|
+
function runBootstrap(steps, io) {
|
|
156
|
+
const ctx = {
|
|
157
|
+
cwd: io.cwd,
|
|
158
|
+
mainRoot: io.mainRoot,
|
|
159
|
+
fs: io.fs || realFs,
|
|
160
|
+
spawn: io.spawn || spawnSync,
|
|
161
|
+
childStdout: io.childStdout || 'inherit',
|
|
162
|
+
}
|
|
163
|
+
const write = io.write || (() => {})
|
|
164
|
+
const results = []
|
|
165
|
+
for (let i = 0; i < steps.length; i++) {
|
|
166
|
+
const step = steps[i]
|
|
167
|
+
// A spawned step announces itself BEFORE it runs, so the streamed output
|
|
168
|
+
// that follows has a heading.
|
|
169
|
+
if (step.kind === 'setup' || step.kind === 'docker') write(describeStep(step))
|
|
170
|
+
let r
|
|
171
|
+
try {
|
|
172
|
+
if (step.kind === 'seed') r = runSeed(step, ctx)
|
|
173
|
+
else if (step.kind === 'envFile') r = runEnvFile(step, ctx)
|
|
174
|
+
else if (step.kind === 'setup' || step.kind === 'docker') r = runSpawned(step, ctx)
|
|
175
|
+
else r = { outcome: 'failed', line: describeStep(step) }
|
|
176
|
+
} catch (err) {
|
|
177
|
+
r = { outcome: 'failed', line: `${describeStep(step)} — ${err.message}` }
|
|
178
|
+
}
|
|
179
|
+
results.push({ step, ...r })
|
|
180
|
+
if (r.line && r.outcome !== 'failed' && r.outcome !== 'refused') write(r.line)
|
|
181
|
+
if (r.outcome === 'failed' || r.outcome === 'refused') {
|
|
182
|
+
return { ok: false, results, failedAt: i + 1 }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { ok: true, results, failedAt: null }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { runBootstrap, describeStep }
|
package/src/env/config.js
CHANGED
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
* // setup runs (mode: "symlink" default | "copy"); empty = none
|
|
25
25
|
* setup: [ "cmd", ... ], // bootstrap commands run in the worktree right
|
|
26
26
|
* // after `git worktree add` (e.g. install deps); empty = none
|
|
27
|
+
* afterLand:[ "cmd", ... ], // commands run in the PRIMARY checkout after a
|
|
28
|
+
* // land (integrate's ff, hotfix land's main cherry-pick), e.g. a
|
|
29
|
+
* // data migration; literal, no token expansion; empty = none
|
|
27
30
|
* dev: [ { name, command, portVar, health?, frontPort? } ], // host dev
|
|
28
31
|
* // servers started on the spec's port block (empty = none)
|
|
29
32
|
* proxy: { enabled, host }, // bundled front-door proxy (spec-env connect)
|
|
@@ -166,6 +169,12 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
166
169
|
// `git worktree add` (before Docker/dev), on every provision. Array of shell
|
|
167
170
|
// strings (e.g. "pnpm install"); {slug}/{branch}/… expand. Default: none.
|
|
168
171
|
setup: Object.freeze([]),
|
|
172
|
+
// Commands run in the primary checkout after a land, printed by `spec-env
|
|
173
|
+
// integrate` (including its already-landed no-op) and by `hotfix land` when
|
|
174
|
+
// it cherry-picks onto the base branch. The skill runs them; the engine only
|
|
175
|
+
// plans. Literal strings — no {slug}/{port} tokens, since they run on the base
|
|
176
|
+
// branch where a spec's tokens mean nothing. Default: none.
|
|
177
|
+
afterLand: Object.freeze([]),
|
|
169
178
|
// Host dev servers started on the spec's port block by `spec-env dev up`.
|
|
170
179
|
// Each: { name, command, portVar, health?, frontPort? }. Default: none.
|
|
171
180
|
dev: Object.freeze([]),
|
|
@@ -332,6 +341,7 @@ function defaults() {
|
|
|
332
341
|
docker: { ...DEFAULT_CONFIG.docker },
|
|
333
342
|
seedFiles: { mode: DEFAULT_CONFIG.seedFiles.mode, files: [] },
|
|
334
343
|
setup: [],
|
|
344
|
+
afterLand: [],
|
|
335
345
|
dev: [],
|
|
336
346
|
proxy: { ...DEFAULT_CONFIG.proxy },
|
|
337
347
|
registry: DEFAULT_CONFIG.registry,
|
|
@@ -542,6 +552,11 @@ function mergeConfig(base, parsed) {
|
|
|
542
552
|
base.setup = normalizeSetup(parsed.setup)
|
|
543
553
|
}
|
|
544
554
|
|
|
555
|
+
// Same rule as `setup`: a stray entry is dropped, never thrown on.
|
|
556
|
+
if (Array.isArray(parsed.afterLand)) {
|
|
557
|
+
base.afterLand = normalizeSetup(parsed.afterLand)
|
|
558
|
+
}
|
|
559
|
+
|
|
545
560
|
if (Array.isArray(parsed.dev)) {
|
|
546
561
|
base.dev = normalizeDev(parsed.dev)
|
|
547
562
|
}
|
package/src/env/hotfix.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
const path = require('node:path')
|
|
24
|
+
const { afterLandFor } = require('./integrate.js')
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Parse a semver-ish tag and return it with its PATCH bumped by one. Preserves any
|
|
@@ -50,7 +51,7 @@ function tagSlug(tag) {
|
|
|
50
51
|
* @param {object} config normalised env config (reads `config.hotfix`).
|
|
51
52
|
* @param {object} ctx { worktreeState:{dirty}, aheadOfBase, fixRange, mainRepoPath,
|
|
52
53
|
* base, extraTargets: string[], existingTags: string[] }
|
|
53
|
-
* @returns {object} { blocked, noop, reason, commands, prodTag, targets, branch, base }
|
|
54
|
+
* @returns {object} { blocked, noop, reason, commands, afterLand, prodTag, targets, branch, base }
|
|
54
55
|
*/
|
|
55
56
|
function planHotfixLand(spec, config, ctx) {
|
|
56
57
|
const c = ctx || {}
|
|
@@ -62,6 +63,7 @@ function planHotfixLand(spec, config, ctx) {
|
|
|
62
63
|
noop: false,
|
|
63
64
|
reason: null,
|
|
64
65
|
commands: [],
|
|
66
|
+
afterLand: afterLandFor(null),
|
|
65
67
|
prodTag: null,
|
|
66
68
|
targets: [],
|
|
67
69
|
branch,
|
|
@@ -122,12 +124,16 @@ function planHotfixLand(spec, config, ctx) {
|
|
|
122
124
|
}
|
|
123
125
|
|
|
124
126
|
// 3. Cherry-pick the fix onto the base branch (main) for the next release.
|
|
127
|
+
// Only then does the primary checkout gain anything, so only then do the
|
|
128
|
+
// after-land commands apply — a prod-only land leaves main untouched.
|
|
129
|
+
let afterLand = result.afterLand
|
|
125
130
|
if (hotfixCfg.cherryPickMain !== false) {
|
|
126
131
|
commands.push(`git -C ${c.mainRepoPath} cherry-pick ${fixRange}`)
|
|
127
132
|
targets.push({ kind: 'main', base: c.base })
|
|
133
|
+
afterLand = afterLandFor(config, c.mainRepoPath)
|
|
128
134
|
}
|
|
129
135
|
|
|
130
|
-
return { ...result, commands, prodTag, targets, fixRange }
|
|
136
|
+
return { ...result, commands, afterLand, prodTag, targets, fixRange }
|
|
131
137
|
}
|
|
132
138
|
|
|
133
139
|
module.exports = { bumpPatch, tagSlug, planHotfixLand }
|
package/src/env/integrate.js
CHANGED
|
@@ -15,28 +15,32 @@
|
|
|
15
15
|
* hands back — so the planner never needs to reason about conflicts.
|
|
16
16
|
*
|
|
17
17
|
* @param {object} spec resolved spec: { branch, worktreePath, folder, ... }
|
|
18
|
-
* @param {object} config normalised env config (
|
|
18
|
+
* @param {object} config normalised env config (reads `afterLand`).
|
|
19
19
|
* In CHECKOUT mode there is no worktree: the branch is already checked out in the
|
|
20
20
|
* primary checkout, so landing is a rebase in place, a switch to base, and the
|
|
21
21
|
* fast-forward. `planIntegrateCheckout` below covers that; the shape of what it
|
|
22
22
|
* returns is identical so callers need no second code path.
|
|
23
23
|
*
|
|
24
24
|
* @param {object} ctx { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath }
|
|
25
|
-
* @returns {object} { blocked, noop, reason, commands, base, branch }
|
|
25
|
+
* @returns {object} { blocked, noop, reason, commands, afterLand, base, branch }
|
|
26
26
|
*/
|
|
27
27
|
function planIntegrate(spec, config, ctx) {
|
|
28
28
|
const { worktreeState = {}, base, aheadOfBase, mainRepoPath } = ctx || {}
|
|
29
29
|
const branch = spec.branch
|
|
30
|
-
const result = {
|
|
30
|
+
const result = {
|
|
31
|
+
blocked: false, noop: false, reason: null, commands: [], afterLand: noAfterLand(), base, branch,
|
|
32
|
+
}
|
|
31
33
|
|
|
32
34
|
// The completion edits must be committed first — never rebase a dirty tree.
|
|
33
35
|
if (worktreeState.dirty) {
|
|
34
36
|
return { ...result, blocked: true, reason: 'worktree has uncommitted changes — commit the completion first' }
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
// Nothing on the branch that isn't already on base → already landed.
|
|
39
|
+
// Nothing on the branch that isn't already on base → already landed. The
|
|
40
|
+
// after-land commands still apply: the land may have happened on another
|
|
41
|
+
// machine or in an earlier session, leaving them pending in THIS checkout.
|
|
38
42
|
if (!aheadOfBase) {
|
|
39
|
-
return { ...result, noop: true }
|
|
43
|
+
return { ...result, noop: true, afterLand: afterLandFor(config, mainRepoPath) }
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
return {
|
|
@@ -45,6 +49,7 @@ function planIntegrate(spec, config, ctx) {
|
|
|
45
49
|
`git -C ${spec.worktreePath} rebase ${base}`,
|
|
46
50
|
`git -C ${mainRepoPath} merge --ff-only ${branch}`,
|
|
47
51
|
],
|
|
52
|
+
afterLand: afterLandFor(config, mainRepoPath),
|
|
48
53
|
}
|
|
49
54
|
}
|
|
50
55
|
|
|
@@ -65,7 +70,9 @@ function planIntegrate(spec, config, ctx) {
|
|
|
65
70
|
function planIntegrateCheckout(spec, config, ctx) {
|
|
66
71
|
const { dirty, base, aheadOfBase, checkoutPath, onBranch } = ctx || {}
|
|
67
72
|
const branch = spec.branch
|
|
68
|
-
const result = {
|
|
73
|
+
const result = {
|
|
74
|
+
blocked: false, noop: false, reason: null, commands: [], afterLand: noAfterLand(), base, branch,
|
|
75
|
+
}
|
|
69
76
|
|
|
70
77
|
// "Already landed" is answered FIRST, before any refusal. A landed spec needs
|
|
71
78
|
// no action wherever the checkout happens to be standing — and after a
|
|
@@ -73,7 +80,7 @@ function planIntegrateCheckout(spec, config, ctx) {
|
|
|
73
80
|
// would refuse the very spec this just finished landing. /spec-complete calls
|
|
74
81
|
// integrate again on exactly that state.
|
|
75
82
|
if (!aheadOfBase) {
|
|
76
|
-
return { ...result, noop: true }
|
|
83
|
+
return { ...result, noop: true, afterLand: afterLandFor(config, checkoutPath) }
|
|
77
84
|
}
|
|
78
85
|
if (dirty) {
|
|
79
86
|
return {
|
|
@@ -100,7 +107,20 @@ function planIntegrateCheckout(spec, config, ctx) {
|
|
|
100
107
|
`git -C ${checkoutPath} switch ${base}`,
|
|
101
108
|
`git -C ${checkoutPath} merge --ff-only ${branch}`,
|
|
102
109
|
],
|
|
110
|
+
afterLand: afterLandFor(config, checkoutPath),
|
|
103
111
|
}
|
|
104
112
|
}
|
|
105
113
|
|
|
106
|
-
|
|
114
|
+
// The after-land half of a plan: `config.afterLand`, to run in `cwd` (the
|
|
115
|
+
// primary checkout, standing on base) once the land commands have succeeded.
|
|
116
|
+
// A blocked plan carries the empty form — nothing landed, so nothing follows.
|
|
117
|
+
function afterLandFor(config, cwd) {
|
|
118
|
+
const commands = (config && Array.isArray(config.afterLand)) ? [...config.afterLand] : []
|
|
119
|
+
return { cwd: commands.length ? cwd : null, commands }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function noAfterLand() {
|
|
123
|
+
return { cwd: null, commands: [] }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { planIntegrate, planIntegrateCheckout, afterLandFor }
|
package/src/env/mainguard.js
CHANGED
|
@@ -139,12 +139,61 @@ function judgeMainWrite(ctx) {
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The checkout a path lives in: the `--show-toplevel` of its nearest existing
|
|
144
|
+
* ancestor, or null when that is in no git repo at all.
|
|
145
|
+
*
|
|
146
|
+
* NEAREST EXISTING ANCESTOR, because a `Write` usually creates its file and
|
|
147
|
+
* often its folder too — `git -C` on a directory that does not exist yet fails,
|
|
148
|
+
* and reading that failure as "not in a repo" would wave through exactly the
|
|
149
|
+
* new file the guard exists for.
|
|
150
|
+
*/
|
|
151
|
+
function checkoutOf(target, gitAt) {
|
|
152
|
+
let at = path.resolve(target)
|
|
153
|
+
while (!fs.existsSync(at)) {
|
|
154
|
+
const up = path.dirname(at)
|
|
155
|
+
if (up === at) return null
|
|
156
|
+
at = up
|
|
157
|
+
}
|
|
158
|
+
if (!fs.statSync(at).isDirectory()) at = path.dirname(at)
|
|
159
|
+
return gitAt(at)(['rev-parse', '--show-toplevel'])
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Two spellings of one directory — `/tmp` and `/private/tmp` on macOS — are
|
|
163
|
+
// the same directory. Cannot-tell is "not the same", the branch that allows.
|
|
164
|
+
function sameDir(a, b) {
|
|
165
|
+
try {
|
|
166
|
+
return fs.realpathSync(a) === fs.realpathSync(b)
|
|
167
|
+
} catch {
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const ALLOWS = { refuse: false, reason: null, branch: null, baseBranch: null }
|
|
173
|
+
|
|
142
174
|
/**
|
|
143
175
|
* Gather the facts and judge. The only IO in the module's hot path.
|
|
144
176
|
*
|
|
145
177
|
* `git` is an injected reader, as everywhere in this package: the caller owns
|
|
146
178
|
* the child-process boundary, which is what keeps `judgeMainWrite` testable
|
|
147
179
|
* without one.
|
|
180
|
+
*
|
|
181
|
+
* `opts.target` is the path being written, and when it is given IT decides
|
|
182
|
+
* where the write lands — not `dir`, which is only where the session stands.
|
|
183
|
+
* Judging the session's directory refused every write a session on the base
|
|
184
|
+
* branch made anywhere, its own scratchpad under /tmp included, while a session
|
|
185
|
+
* standing in a worktree could write into the primary checkout unseen
|
|
186
|
+
* (bug-main-guard-judges-cwd). `opts.gitAt(dir)` makes a reader for another
|
|
187
|
+
* directory; it is required alongside `target`.
|
|
188
|
+
*
|
|
189
|
+
* This reads the write's LOCATION, never its name, so it is not the exception
|
|
190
|
+
* list `judgeMainWrite` refuses to grow: a path inside the primary checkout is
|
|
191
|
+
* judged exactly as before, whatever it is called.
|
|
192
|
+
*
|
|
193
|
+
* WHAT WOULD FOOL THIS: a target in ANOTHER configured repo is judged against
|
|
194
|
+
* this session's repo, so it is allowed — that repo's guard is its own sessions'
|
|
195
|
+
* business. And without `target` (an older hook, a tool with no path) the
|
|
196
|
+
* session's directory is judged as it always was.
|
|
148
197
|
*/
|
|
149
198
|
function checkMainWrite(dir, config, git, opts = {}) {
|
|
150
199
|
// `present` from `loadEnvConfig` — the caller has it and this module must not
|
|
@@ -159,27 +208,37 @@ function checkMainWrite(dir, config, git, opts = {}) {
|
|
|
159
208
|
// looking for the absence of a worktree marker.
|
|
160
209
|
let isPrimary = false
|
|
161
210
|
let primary = null
|
|
211
|
+
// Which checkout's HEAD decides "on the base branch". With a target that is
|
|
212
|
+
// the primary checkout's own, wherever the session is standing.
|
|
213
|
+
let branchGit = git
|
|
162
214
|
try {
|
|
163
215
|
primary = resolvePrimaryCheckout(dir, git)
|
|
164
|
-
|
|
216
|
+
if (opts.target && typeof opts.gitAt === 'function') {
|
|
217
|
+
const top = checkoutOf(opts.target, opts.gitAt)
|
|
218
|
+
isPrimary = Boolean(top) && Boolean(primary) && sameDir(top, primary)
|
|
219
|
+
if (!isPrimary) return ALLOWS
|
|
220
|
+
branchGit = opts.gitAt(primary)
|
|
221
|
+
} else {
|
|
222
|
+
isPrimary = Boolean(primary) && path.resolve(primary) === path.resolve(dir)
|
|
223
|
+
}
|
|
165
224
|
} catch {
|
|
166
225
|
// Not a git repo, or git unreadable. Cannot tell → allow.
|
|
167
|
-
return
|
|
226
|
+
return ALLOWS
|
|
168
227
|
}
|
|
169
228
|
|
|
170
229
|
let onBase = false
|
|
171
230
|
let branch = null
|
|
172
231
|
let baseBranch = null
|
|
173
232
|
try {
|
|
174
|
-
const state = assertPrimaryOnMain(config,
|
|
233
|
+
const state = assertPrimaryOnMain(config, branchGit)
|
|
175
234
|
onBase = state.onBase
|
|
176
235
|
branch = state.branch
|
|
177
236
|
baseBranch = state.baseBranch
|
|
178
237
|
} catch {
|
|
179
|
-
return
|
|
238
|
+
return ALLOWS
|
|
180
239
|
}
|
|
181
240
|
|
|
182
|
-
const root =
|
|
241
|
+
const root = primary || dir
|
|
183
242
|
const verdict = judgeMainWrite({
|
|
184
243
|
configured,
|
|
185
244
|
enabled,
|