@skitterbyte/skitterspec 1.0.1 → 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/README.md +27 -244
- package/assets/claude-md-section.md +0 -6
- package/assets/core/env.config.json.example +5 -1
- package/assets/core/env.config.md +21 -5
- package/assets/rules/spec-planning.md +14 -10
- package/assets/skills/spec/SKILL.md +11 -38
- package/assets/skills/spec-complete/SKILL.md +31 -4
- package/assets/skills/spec-env/SKILL.md +6 -0
- package/assets/skills/spec-env-down/SKILL.md +16 -8
- package/assets/skills/spec-go/SKILL.md +15 -17
- package/package.json +6 -11
- package/src/cli.js +174 -318
- package/src/deprecate.js +138 -0
- package/src/env/config.js +17 -4
- package/src/env/integrate.js +46 -0
- package/src/env/resolve.js +54 -45
- package/src/env/teardown.js +19 -4
- package/src/env/trust.js +87 -0
- package/src/init.js +78 -170
- package/src/prompts.js +26 -63
- package/LICENSE +0 -21
- package/assets/core/linear.config.json.example +0 -39
- package/assets/core/linear.config.md +0 -121
- package/assets/rules/commit-messages.md +0 -85
- package/assets/scripts/generate-changelog.js +0 -274
- package/assets/scripts/generate-releases.js +0 -360
- package/assets/scripts/lib/config.js +0 -127
- package/assets/scripts/lib/git-commits.js +0 -265
- package/assets/skills/commit/SKILL.md +0 -28
- package/assets/skills/spec-pull/SKILL.md +0 -46
- package/assets/skills/spec-push/SKILL.md +0 -53
- package/assets/skills/spec-status/SKILL.md +0 -46
- package/src/config.js +0 -13
- package/src/sync/apply.js +0 -66
- package/src/sync/base.js +0 -83
- package/src/sync/compare.js +0 -99
- package/src/sync/config.js +0 -198
- package/src/sync/mcp.js +0 -112
- package/src/sync/normalize.js +0 -249
- package/src/sync/pull.js +0 -84
- package/src/sync/push.js +0 -106
- package/src/sync/write.js +0 -86
package/src/deprecate.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Cleanup path for projects that installed release tooling from an older
|
|
4
|
+
// skitterspec (before it moved to @skitterbyte/skittership). `skitterspec update`
|
|
5
|
+
// detects the leftover files and — only on an explicit interactive "yes" or the
|
|
6
|
+
// --remove-release-tooling flag — removes exactly what skitterspec used to
|
|
7
|
+
// install. It never touches the user's generated CHANGELOG.md / RELEASES.md, nor
|
|
8
|
+
// any script it didn't add.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs')
|
|
11
|
+
const path = require('path')
|
|
12
|
+
|
|
13
|
+
const SKITTERSHIP = '@skitterbyte/skittership'
|
|
14
|
+
|
|
15
|
+
// Files/dirs skitterspec used to install for release tooling (repo-relative).
|
|
16
|
+
const RELEASE_PATHS = [
|
|
17
|
+
'skitterspec.config.json',
|
|
18
|
+
path.join('scripts', 'generate-changelog.js'),
|
|
19
|
+
path.join('scripts', 'generate-releases.js'),
|
|
20
|
+
path.join('scripts', 'lib', 'git-commits.js'),
|
|
21
|
+
path.join('scripts', 'lib', 'config.js'),
|
|
22
|
+
path.join('.claude', 'skills', 'commit'),
|
|
23
|
+
path.join('.claude', 'rules', 'commit-messages.md'),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
// npm scripts skitterspec used to wire; only removed when their value still
|
|
27
|
+
// matches the generator command (so a user's custom override is preserved).
|
|
28
|
+
const HELPER_SCRIPTS = {
|
|
29
|
+
changelog: 'node scripts/generate-changelog.js',
|
|
30
|
+
'changelog:retro': 'node scripts/generate-changelog.js --retro',
|
|
31
|
+
releases: 'node scripts/generate-releases.js',
|
|
32
|
+
'releases:retro': 'node scripts/generate-releases.js --retro',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readPkg(dir) {
|
|
36
|
+
const pkgPath = path.join(dir, 'package.json')
|
|
37
|
+
if (!fs.existsSync(pkgPath)) return null
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Does package.json have a `version` script that runs the release generators?
|
|
46
|
+
function versionHookReferencesGenerators(pkg) {
|
|
47
|
+
const v = pkg && pkg.scripts && pkg.scripts.version
|
|
48
|
+
return typeof v === 'string' && /generate-(changelog|releases)\.js/.test(v)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Is skittership the source of the release tooling here (rather than a leftover
|
|
52
|
+
// legacy skitterspec install)? True when the project has adopted skittership —
|
|
53
|
+
// its config file is present, or it's a declared dependency. In that case the
|
|
54
|
+
// release files are skittership's current install and must NOT be offered for
|
|
55
|
+
// removal (they'd just come back on the next `skittership init`).
|
|
56
|
+
function skittershipAdopted(dir) {
|
|
57
|
+
if (fs.existsSync(path.join(dir, 'skittership.config.json'))) return true
|
|
58
|
+
const pkg = readPkg(dir)
|
|
59
|
+
const deps = Object.assign({}, pkg && pkg.dependencies, pkg && pkg.devDependencies)
|
|
60
|
+
return Boolean(deps['@skitterbyte/skittership'])
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Report which release-tooling artifacts are present. `present` is true only when
|
|
64
|
+
// there are legacy artifacts to clean up (a file/dir or a generator-driven version
|
|
65
|
+
// hook) AND skittership hasn't been adopted — otherwise the files belong to a
|
|
66
|
+
// live skittership install, not an old bundled-skitterspec one.
|
|
67
|
+
function detectReleaseTooling(dir) {
|
|
68
|
+
const files = RELEASE_PATHS.filter((rel) => fs.existsSync(path.join(dir, rel)))
|
|
69
|
+
const pkg = readPkg(dir)
|
|
70
|
+
const versionHook = versionHookReferencesGenerators(pkg)
|
|
71
|
+
const adopted = skittershipAdopted(dir)
|
|
72
|
+
return { present: (files.length > 0 || versionHook) && !adopted, files, versionHook, adopted }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Remove an emptied directory, walking up while parents are left empty. Never
|
|
76
|
+
// climbs out of `dir`.
|
|
77
|
+
function pruneEmptyDirs(dir, startAbs) {
|
|
78
|
+
let cur = startAbs
|
|
79
|
+
while (cur.startsWith(dir) && cur !== dir && fs.existsSync(cur)) {
|
|
80
|
+
if (fs.readdirSync(cur).length > 0) break
|
|
81
|
+
fs.rmdirSync(cur)
|
|
82
|
+
cur = path.dirname(cur)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Remove exactly the detected artifacts + unwire the version hook. Returns a
|
|
87
|
+
// report of what was removed. Scoped and non-destructive: only skitterspec's own
|
|
88
|
+
// files, and only npm scripts whose value still matches the generator command.
|
|
89
|
+
function removeReleaseTooling(dir, detection = detectReleaseTooling(dir)) {
|
|
90
|
+
const removed = []
|
|
91
|
+
|
|
92
|
+
for (const rel of detection.files) {
|
|
93
|
+
const abs = path.join(dir, rel)
|
|
94
|
+
if (!fs.existsSync(abs)) continue
|
|
95
|
+
fs.rmSync(abs, { recursive: true, force: true })
|
|
96
|
+
removed.push(rel)
|
|
97
|
+
pruneEmptyDirs(dir, path.dirname(abs))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const pkg = readPkg(dir)
|
|
101
|
+
if (pkg && pkg.scripts) {
|
|
102
|
+
let changed = false
|
|
103
|
+
if (versionHookReferencesGenerators(pkg)) {
|
|
104
|
+
delete pkg.scripts.version
|
|
105
|
+
removed.push('package.json (version hook)')
|
|
106
|
+
changed = true
|
|
107
|
+
}
|
|
108
|
+
for (const [name, cmd] of Object.entries(HELPER_SCRIPTS)) {
|
|
109
|
+
if (pkg.scripts[name] === cmd) {
|
|
110
|
+
delete pkg.scripts[name]
|
|
111
|
+
changed = true
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (changed) {
|
|
115
|
+
if (Object.keys(pkg.scripts).length === 0) delete pkg.scripts
|
|
116
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n')
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { removed }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// One-line pointer shown when we detect release tooling but don't remove it
|
|
124
|
+
// (declined, or a non-interactive run).
|
|
125
|
+
function releaseToolingNotice() {
|
|
126
|
+
return (
|
|
127
|
+
`Release tooling has moved to ${SKITTERSHIP} — run ` +
|
|
128
|
+
`npx ${SKITTERSHIP} init to keep it (your config is migrated automatically).`
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
detectReleaseTooling,
|
|
134
|
+
removeReleaseTooling,
|
|
135
|
+
releaseToolingNotice,
|
|
136
|
+
RELEASE_PATHS,
|
|
137
|
+
SKITTERSHIP,
|
|
138
|
+
}
|
package/src/env/config.js
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* portsPerSpec, envFile, backupCommand },
|
|
19
19
|
* open: { command }, // optional, editor/terminal-agnostic opener
|
|
20
20
|
* registry: ".spec-env/registry.json",
|
|
21
|
-
*
|
|
21
|
+
* branch: { pattern, identifierField }, // git branch naming (provider-neutral)
|
|
22
|
+
* baseBranch: "", // "" = auto-detect (origin/HEAD → main → master)
|
|
22
23
|
* guards: { refuseTeardownIfDirty, refuseTeardownIfUnpushed }
|
|
23
24
|
* }
|
|
24
25
|
*/
|
|
@@ -41,7 +42,13 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
41
42
|
}),
|
|
42
43
|
open: Object.freeze({ command: '' }),
|
|
43
44
|
registry: '.spec-env/registry.json',
|
|
44
|
-
|
|
45
|
+
// Git branch naming, provider-neutral. `pattern` expands {type}/{slug} and,
|
|
46
|
+
// when a tracker provider is linked, {identifier}; `identifierField` names the
|
|
47
|
+
// 00-overview.md frontmatter field a provider writes the ticket id into (empty
|
|
48
|
+
// = no identifier, so patterns referencing {identifier} fall back to type/slug).
|
|
49
|
+
branch: Object.freeze({ pattern: '{type}/{slug}', identifierField: '' }),
|
|
50
|
+
// Integration base branch. Empty = auto-detect (origin/HEAD → main → master).
|
|
51
|
+
baseBranch: '',
|
|
45
52
|
guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
|
|
46
53
|
})
|
|
47
54
|
|
|
@@ -56,7 +63,8 @@ function defaults() {
|
|
|
56
63
|
docker: { ...DEFAULT_CONFIG.docker },
|
|
57
64
|
open: { ...DEFAULT_CONFIG.open },
|
|
58
65
|
registry: DEFAULT_CONFIG.registry,
|
|
59
|
-
|
|
66
|
+
branch: { ...DEFAULT_CONFIG.branch },
|
|
67
|
+
baseBranch: DEFAULT_CONFIG.baseBranch,
|
|
60
68
|
guards: { ...DEFAULT_CONFIG.guards },
|
|
61
69
|
}
|
|
62
70
|
}
|
|
@@ -104,8 +112,13 @@ function mergeConfig(base, parsed) {
|
|
|
104
112
|
assign(base.open, parsed.open, 'command', 'string?')
|
|
105
113
|
}
|
|
106
114
|
|
|
115
|
+
if (isObject(parsed.branch)) {
|
|
116
|
+
assign(base.branch, parsed.branch, 'pattern', 'string')
|
|
117
|
+
assign(base.branch, parsed.branch, 'identifierField', 'string')
|
|
118
|
+
}
|
|
119
|
+
|
|
107
120
|
assign(base, parsed, 'registry', 'string')
|
|
108
|
-
assign(base, parsed, '
|
|
121
|
+
assign(base, parsed, 'baseBranch', 'string')
|
|
109
122
|
|
|
110
123
|
if (isObject(parsed.guards)) {
|
|
111
124
|
assign(base.guards, parsed.guards, 'refuseTeardownIfDirty', 'boolean')
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure integrate planner for `spec-env integrate`.
|
|
5
|
+
*
|
|
6
|
+
* `planIntegrate` decides whether a spec's worktree branch can be landed onto the
|
|
7
|
+
* base branch and, if so, emits the exact commands the `/spec-complete` skill runs
|
|
8
|
+
* (rebase the branch onto base in the worktree, then fast-forward base to it in the
|
|
9
|
+
* primary checkout). It performs no side effects: the caller (the CLI) queries git
|
|
10
|
+
* for `dirty`/`aheadOfBase`/`mainRepoPath` and supplies them, keeping this
|
|
11
|
+
* deterministic and unit-testable with no live git.
|
|
12
|
+
*
|
|
13
|
+
* Strategy is rebase + fast-forward (linear history). Conflict handling lives in
|
|
14
|
+
* the skill: it runs the rebase and, on a non-zero exit, `git rebase --abort` and
|
|
15
|
+
* hands back — so the planner never needs to reason about conflicts.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} spec resolved spec: { branch, worktreePath, folder, ... }
|
|
18
|
+
* @param {object} config normalised env config (unused today; kept for symmetry).
|
|
19
|
+
* @param {object} ctx { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath }
|
|
20
|
+
* @returns {object} { blocked, noop, reason, commands, base, branch }
|
|
21
|
+
*/
|
|
22
|
+
function planIntegrate(spec, config, ctx) {
|
|
23
|
+
const { worktreeState = {}, base, aheadOfBase, mainRepoPath } = ctx || {}
|
|
24
|
+
const branch = spec.branch
|
|
25
|
+
const result = { blocked: false, noop: false, reason: null, commands: [], base, branch }
|
|
26
|
+
|
|
27
|
+
// The completion edits must be committed first — never rebase a dirty tree.
|
|
28
|
+
if (worktreeState.dirty) {
|
|
29
|
+
return { ...result, blocked: true, reason: 'worktree has uncommitted changes — commit the completion first' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Nothing on the branch that isn't already on base → already landed.
|
|
33
|
+
if (!aheadOfBase) {
|
|
34
|
+
return { ...result, noop: true }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
...result,
|
|
39
|
+
commands: [
|
|
40
|
+
`git -C ${spec.worktreePath} rebase ${base}`,
|
|
41
|
+
`git -C ${mainRepoPath} merge --ff-only ${branch}`,
|
|
42
|
+
],
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { planIntegrate }
|
package/src/env/resolve.js
CHANGED
|
@@ -5,21 +5,18 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Given a spec argument (a folder name or path) it locates the spec folder under
|
|
7
7
|
* `specs/**`, splits the `feat-`/`bug-` prefix into `{ type, slug }`, derives the
|
|
8
|
-
* git branch
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* git branch from the config's `branch.pattern` (provider-neutral; `{identifier}`
|
|
9
|
+
* is filled from a tracker id when one is configured, else it falls back to
|
|
10
|
+
* `{type}/{slug}`), and expands the config's path/name tokens (`{repo}`,
|
|
11
|
+
* `{repoSlug}`, `{slug}`). Reads files to locate the spec and read frontmatter,
|
|
12
|
+
* but makes no git/docker side effects — deterministic and safe to unit-test with
|
|
13
|
+
* fixtures.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
const fs = require('node:fs')
|
|
15
17
|
const path = require('node:path')
|
|
16
18
|
|
|
17
19
|
const BUCKETS = ['backlog', 'in-progress', 'complete', 'cancelled']
|
|
18
|
-
const LINEAR_CONFIG = path.join('specs', '.core', 'linear.config.json')
|
|
19
|
-
|
|
20
|
-
function isObject(value) {
|
|
21
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
22
|
-
}
|
|
23
20
|
|
|
24
21
|
// Find the spec folder under specs/<bucket>/<name>. `specArg` may be a bare
|
|
25
22
|
// folder name or a path — only its basename is matched against the buckets.
|
|
@@ -59,9 +56,12 @@ function expandTokens(str, tokens) {
|
|
|
59
56
|
)
|
|
60
57
|
}
|
|
61
58
|
|
|
62
|
-
// Read
|
|
63
|
-
//
|
|
64
|
-
|
|
59
|
+
// Read a named field from a spec's 00-overview.md YAML frontmatter, if present.
|
|
60
|
+
// `field` is provider-neutral (e.g. a tracker's ticket-id field, configured via
|
|
61
|
+
// `branch.identifierField`). Returns null when there's no frontmatter / field /
|
|
62
|
+
// file, or no field name was given.
|
|
63
|
+
function readFrontmatterField(specPath, field) {
|
|
64
|
+
if (!field) return null
|
|
65
65
|
const overview = path.join(specPath, '00-overview.md')
|
|
66
66
|
let raw
|
|
67
67
|
try {
|
|
@@ -71,7 +71,7 @@ function readLinearIdentifier(specPath) {
|
|
|
71
71
|
}
|
|
72
72
|
const fm = /^---\n([\s\S]*?)\n---/.exec(raw)
|
|
73
73
|
if (!fm) return null
|
|
74
|
-
const m =
|
|
74
|
+
const m = new RegExp(`^${field}:\\s*(.+)$`, 'm').exec(fm[1])
|
|
75
75
|
if (!m) return null
|
|
76
76
|
return m[1].trim().replace(/^["']|["']$/g, '') || null
|
|
77
77
|
}
|
|
@@ -99,43 +99,51 @@ function readStackField(specPath, config) {
|
|
|
99
99
|
return config.docker && config.docker.enabled ? 'docker' : 'worktree'
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Derive the git branch for a spec from the provider-neutral `branch.pattern`
|
|
104
|
+
* (`{type}`, `{slug}`, and optionally `{identifier}`). When the pattern uses
|
|
105
|
+
* `{identifier}`, the id is read from the frontmatter field named by
|
|
106
|
+
* `branch.identifierField` (a tracker provider writes it); if that field is unset
|
|
107
|
+
* or absent on the spec, the branch falls back to `{type}/{slug}` so we never
|
|
108
|
+
* emit a half-expanded name. No knowledge of any specific tracker lives here.
|
|
109
|
+
*/
|
|
110
|
+
function branchFor(spec, config) {
|
|
111
|
+
const branch = (config.branch && config.branch.pattern) || '{type}/{slug}'
|
|
112
|
+
const tokens = { type: spec.type, slug: spec.slug }
|
|
113
|
+
if (/\{identifier\}/.test(branch)) {
|
|
114
|
+
const field = config.branch && config.branch.identifierField
|
|
115
|
+
const identifier = readFrontmatterField(spec.path, field)
|
|
116
|
+
if (!identifier) return `${spec.type}/${spec.slug}`
|
|
117
|
+
tokens.identifier = identifier
|
|
116
118
|
}
|
|
119
|
+
return expandTokens(branch, tokens)
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
123
|
+
* Resolve the integration base branch (the branch specs fork from and land back
|
|
124
|
+
* onto). Precedence:
|
|
125
|
+
* 1. `config.baseBranch` — explicit override
|
|
126
|
+
* 2. `origin/HEAD` — the remote's default branch
|
|
127
|
+
* 3. `main` if it exists locally
|
|
128
|
+
* 4. `master` if it exists locally
|
|
129
|
+
* 5. `main` — last-resort default
|
|
130
|
+
*
|
|
131
|
+
* `git(args)` runs a read-only git command and returns trimmed stdout, or `null`
|
|
132
|
+
* on a non-zero exit / failure. It's injected so this stays pure and unit-testable
|
|
133
|
+
* with no live git; the CLI supplies a real reader. (Note: `show-ref --quiet`
|
|
134
|
+
* emits no stdout on success, so a non-null `''` still means "exists".)
|
|
124
135
|
*/
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
type: spec.type,
|
|
135
|
-
})
|
|
136
|
-
}
|
|
136
|
+
function resolveBaseBranch(config, git) {
|
|
137
|
+
const explicit = config && typeof config.baseBranch === 'string' && config.baseBranch.trim()
|
|
138
|
+
if (explicit) return explicit
|
|
139
|
+
|
|
140
|
+
const originHead = git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
|
|
141
|
+
if (originHead) return originHead.replace(/^origin\//, '')
|
|
142
|
+
|
|
143
|
+
for (const name of ['main', 'master']) {
|
|
144
|
+
if (git(['show-ref', '--verify', '--quiet', `refs/heads/${name}`]) !== null) return name
|
|
137
145
|
}
|
|
138
|
-
return
|
|
146
|
+
return 'main'
|
|
139
147
|
}
|
|
140
148
|
|
|
141
149
|
/**
|
|
@@ -154,7 +162,7 @@ function resolveSpec(specArg, dir, config) {
|
|
|
154
162
|
|
|
155
163
|
const stack = readStackField(found.path, config)
|
|
156
164
|
const spec = { folder: found.folder, bucket: found.bucket, path: found.path, type, slug, stack }
|
|
157
|
-
const branch = branchFor(spec,
|
|
165
|
+
const branch = branchFor(spec, config)
|
|
158
166
|
|
|
159
167
|
const worktreeRoot = expandTokens(config.worktree.root, tokens)
|
|
160
168
|
const worktreeFolder = expandTokens(config.worktree.folderPattern, tokens)
|
|
@@ -175,6 +183,7 @@ function resolveSpec(specArg, dir, config) {
|
|
|
175
183
|
|
|
176
184
|
module.exports = {
|
|
177
185
|
resolveSpec,
|
|
186
|
+
resolveBaseBranch,
|
|
178
187
|
branchFor,
|
|
179
188
|
splitPrefix,
|
|
180
189
|
repoInfo,
|
package/src/env/teardown.js
CHANGED
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
const { expandTokens } = require('./resolve.js')
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* @param {object} spec resolved spec: { slug, worktreePath, projectName, ... }
|
|
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 }, timestamp }
|
|
24
|
+
* @param {object} ctx { worktreeState: { dirty, unpushed, merged }, timestamp }
|
|
25
25
|
* @returns {object} { blocked, reason, commands, backupCommand, backupPath,
|
|
26
26
|
* volumesDropped }
|
|
27
27
|
*/
|
|
@@ -35,8 +35,16 @@ function planDown(spec, config, flags, ctx) {
|
|
|
35
35
|
if (config.guards.refuseTeardownIfDirty && worktreeState.dirty) {
|
|
36
36
|
return blocked('worktree has uncommitted changes')
|
|
37
37
|
}
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
// Unpushed commits are only unsafe when they aren't already integrated into
|
|
39
|
+
// the base branch. A branch merged into base carries nothing to lose even
|
|
40
|
+
// with no remote — so /spec-complete's local land-then-teardown needs no
|
|
41
|
+
// --force. Block only when the commits are both unpushed AND unmerged.
|
|
42
|
+
if (
|
|
43
|
+
config.guards.refuseTeardownIfUnpushed &&
|
|
44
|
+
worktreeState.unpushed &&
|
|
45
|
+
!worktreeState.merged
|
|
46
|
+
) {
|
|
47
|
+
return blocked('worktree has unpushed commits not yet merged into the base branch')
|
|
40
48
|
}
|
|
41
49
|
}
|
|
42
50
|
|
|
@@ -77,6 +85,13 @@ function planDown(spec, config, flags, ctx) {
|
|
|
77
85
|
: `git worktree remove ${spec.worktreePath}`,
|
|
78
86
|
)
|
|
79
87
|
|
|
88
|
+
// --- delete the branch (safe: -d refuses an unmerged branch, never -D) ---
|
|
89
|
+
// Runs after the worktree remove frees the branch. On a forced teardown of an
|
|
90
|
+
// unmerged branch this fails loudly; the skill relays it rather than -D-ing.
|
|
91
|
+
if (spec.branch) {
|
|
92
|
+
commands.push(`git branch -d ${spec.branch}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
80
95
|
return { blocked: false, reason: null, commands, backupCommand, backupPath, volumesDropped }
|
|
81
96
|
}
|
|
82
97
|
|
package/src/env/trust.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Trust a per-spec worktree directory with Claude Code.
|
|
5
|
+
*
|
|
6
|
+
* Worktrees live outside the primary checkout (`../{repo}-wt/{slug}`), so Claude
|
|
7
|
+
* Code treats them as untrusted and prompts on every edit until the operator
|
|
8
|
+
* grants access. Registering the shared worktree root in
|
|
9
|
+
* `permissions.additionalDirectories` lifts those prompts for every spec at once.
|
|
10
|
+
*
|
|
11
|
+
* The root is an **absolute** path (relative entries aren't reliable in
|
|
12
|
+
* `additionalDirectories`) and therefore machine-specific, so it belongs in the
|
|
13
|
+
* gitignored `.claude/settings.local.json` — never committed config. This merge
|
|
14
|
+
* is deliberately conservative: it preserves every existing key (notably
|
|
15
|
+
* `permissions.allow`), dedups by exact path, and refuses to clobber a file it
|
|
16
|
+
* can't parse. `fs` is the only side effect; callers own the reporting.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs')
|
|
20
|
+
const path = require('node:path')
|
|
21
|
+
|
|
22
|
+
function isObject(value) {
|
|
23
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Absolute path to the machine-local Claude Code settings for `dir`.
|
|
27
|
+
function settingsPath(dir) {
|
|
28
|
+
return path.join(dir, '.claude', 'settings.local.json')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeSettings(file, settings) {
|
|
32
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
33
|
+
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Ensure `rootAbs` is listed in `permissions.additionalDirectories` of `dir`'s
|
|
38
|
+
* `.claude/settings.local.json`. Idempotent and non-destructive. Returns
|
|
39
|
+
* `{ changed, reason }` where reason is one of:
|
|
40
|
+
* - `created` — the settings file was absent and was created
|
|
41
|
+
* - `added` — the root was merged into an existing file
|
|
42
|
+
* - `present` — the root was already listed (no write)
|
|
43
|
+
* - `malformed` — the file exists but isn't parseable JSON (left untouched)
|
|
44
|
+
*/
|
|
45
|
+
function ensureWorktreeDirTrusted(dir, rootAbs) {
|
|
46
|
+
const file = settingsPath(dir)
|
|
47
|
+
|
|
48
|
+
let raw
|
|
49
|
+
try {
|
|
50
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error.code === 'ENOENT') {
|
|
53
|
+
writeSettings(file, { permissions: { additionalDirectories: [rootAbs] } })
|
|
54
|
+
return { changed: true, reason: 'created' }
|
|
55
|
+
}
|
|
56
|
+
throw error
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let parsed
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(raw)
|
|
62
|
+
} catch {
|
|
63
|
+
return { changed: false, reason: 'malformed' }
|
|
64
|
+
}
|
|
65
|
+
if (!isObject(parsed)) return { changed: false, reason: 'malformed' }
|
|
66
|
+
|
|
67
|
+
const permissions = isObject(parsed.permissions) ? parsed.permissions : {}
|
|
68
|
+
const dirs = Array.isArray(permissions.additionalDirectories)
|
|
69
|
+
? permissions.additionalDirectories
|
|
70
|
+
: []
|
|
71
|
+
|
|
72
|
+
if (dirs.includes(rootAbs)) return { changed: false, reason: 'present' }
|
|
73
|
+
|
|
74
|
+
writeSettings(file, {
|
|
75
|
+
...parsed,
|
|
76
|
+
permissions: {
|
|
77
|
+
...permissions,
|
|
78
|
+
additionalDirectories: [...dirs, rootAbs],
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
return { changed: true, reason: 'added' }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
ensureWorktreeDirTrusted,
|
|
86
|
+
settingsPath,
|
|
87
|
+
}
|