@dsh-cc/cli 0.6.3 → 0.7.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.i18n.yaml +2 -2
- package/README.md +14 -0
- package/README.zh.md +12 -0
- package/bin/dsh-cc.js +164 -12
- package/bootstrap.mjs +390 -5
- package/package.json +3 -2
- package/worktree-lifecycle.mjs +379 -0
package/README.i18n.yaml
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
3
|
# after editing either side, bring the other along and re-record with:
|
|
4
4
|
# pnpm check:readme --write
|
|
5
|
-
README.md:
|
|
6
|
-
README.zh.md:
|
|
5
|
+
README.md: fb5d2db0aeb51b6089d8add10fe424b75c578f98
|
|
6
|
+
README.zh.md: 2373aae659fd013f355473e19c2bd3823d71b33f
|
package/README.md
CHANGED
|
@@ -20,6 +20,20 @@ auto-resume — the TUI resumes the project's last session. `--resume` /
|
|
|
20
20
|
`DSH_CC_WORKTREE`, and the TUI offers to keep or remove the worktree at
|
|
21
21
|
`/quit`. Requires a git repository with at least one commit.
|
|
22
22
|
|
|
23
|
+
Every launch also runs a boot-time sweep (never network I/O, 10s cap,
|
|
24
|
+
fail-silent): worktrees under `.claude/worktrees/` on `worktree-*` branches
|
|
25
|
+
older than `worktree.cleanupPeriodDays` (default 30, from the `worktree`
|
|
26
|
+
settings section read fail-open from user → project → local settings files)
|
|
27
|
+
are removed only when clean and nothing is unpushed; stale dsh-cc session
|
|
28
|
+
locks are never auto-released — they surface as advisory `git worktree
|
|
29
|
+
unlock <path>` lines. New worktrees branch from the base chosen by
|
|
30
|
+
`worktree.baseRef` (`fresh` = cached origin/HEAD with a 24h-stale refresh
|
|
31
|
+
fetch capped at 5s, `head` = literal HEAD); a reused named worktree is
|
|
32
|
+
hard-reset to that base when it is clean and all its own commits are
|
|
33
|
+
already merged into it. Created and reused worktrees are locked with
|
|
34
|
+
`git worktree lock --reason="dsh-cc session <slug>"` for the session's
|
|
35
|
+
duration.
|
|
36
|
+
|
|
23
37
|
## Resume environment contract
|
|
24
38
|
|
|
25
39
|
The launcher communicates the user's session intent to the TUI plugin (via
|
package/README.zh.md
CHANGED
|
@@ -18,6 +18,18 @@ worktree 共享它)划分作用域,会退回默认的 auto-resume——TUI
|
|
|
18
18
|
`DSH_CC_WORKTREE` 标记会话,TUI 会在 `/quit` 时询问保留还是删除该
|
|
19
19
|
worktree。要求 git 仓库至少有一个提交。
|
|
20
20
|
|
|
21
|
+
每次启动还会运行一次引导清扫(不做网络 I/O,10 秒上限,失败静默):
|
|
22
|
+
`.claude/worktrees/` 下位于 `worktree-*` 分支、超过
|
|
23
|
+
`worktree.cleanupPeriodDays`(默认 30,从 user → project → local 设置文件
|
|
24
|
+
中 fail-open 读取的 `worktree` 设置节取得)的 worktree,只有在干净且没有
|
|
25
|
+
未推送提交时才会被移除;过期的 dsh-cc 会话锁绝不自动释放——它们只以
|
|
26
|
+
`git worktree unlock <path>` 建议行出现。新 worktree 的创建基准由
|
|
27
|
+
`worktree.baseRef` 决定(`fresh` = 缓存的 origin/HEAD,reflog 超过 24 小时
|
|
28
|
+
时限 5 秒 fetch 刷新;`head` = 字面 HEAD);复用的命名 worktree 在干净且
|
|
29
|
+
自身提交全部已合入该基准时会被硬重置到该基准。创建与复用的 worktree 都会
|
|
30
|
+
以 `git worktree lock --reason="dsh-cc session <slug>"` 加锁,会话期间
|
|
31
|
+
保持锁定。
|
|
32
|
+
|
|
21
33
|
## Resume environment contract
|
|
22
34
|
|
|
23
35
|
启动器通过三个变量(经由 `cordis.patch.yml` 的 `!!js` 表达式)把用户的会话
|
package/bin/dsh-cc.js
CHANGED
|
@@ -9,18 +9,24 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import { dirname, join } from 'node:path'
|
|
11
11
|
import { fileURLToPath } from 'node:url'
|
|
12
|
-
import { bootstrapCommand, dshUnavailableMessage, existingWorktreeDecision, interceptResume, parseWorktreeFlag, planWorktree, PROFILE, sanitizeInheritedEnv, slugRetryDecision, spawnEnv, versionGate, worktreeAddArgv, worktreeEnv } from '../bootstrap.mjs'
|
|
12
|
+
import { bootstrapCommand, devStoreRestoreDecision, dshUnavailableMessage, existingWorktreeDecision, formatVersionLabel, interceptResume, parseLocalConfig, parseWorktreeFlag, parseWorktreeRef, planWorktree, planWorktreeRef, prFetchRefs, remoteHost, PROFILE, readBuildInfo, repoRootFromCommonDir, runStoreRestore, sanitizeInheritedEnv, slugRetryDecision, spawnEnv, symlinkedPath, versionGate, worktreeAddArgv, worktreeEnv, worktreeIdentityRefusal } from '../bootstrap.mjs'
|
|
13
|
+
import { readWorktreeSettings, resolveBaseRef, sweepWorktrees, SWEEP_CAP_MS, worktreeReuseReset, worktreeSettingsPaths } from '../worktree-lifecycle.mjs'
|
|
13
14
|
|
|
14
15
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
15
16
|
const ownVersion = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')).version
|
|
16
17
|
|
|
18
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
19
|
+
const profileDir = join(home, 'profiles', PROFILE)
|
|
20
|
+
// Dev-build stamp lives with the synced profile code (see
|
|
21
|
+
// scripts/stamp-build-info.mjs); read once per launch, sub-millisecond, no
|
|
22
|
+
// subprocess (W2 law). Missing/malformed -> null, fail-open to release.
|
|
23
|
+
const stampPath = join(profileDir, 'node_modules', '@dsh-cc', 'dsh-cc-build.json')
|
|
24
|
+
const buildInfo = readBuildInfo(stampPath)
|
|
25
|
+
|
|
17
26
|
if (process.argv.includes('--version') || process.argv.includes('-V')) {
|
|
18
|
-
console.log(ownVersion)
|
|
27
|
+
console.log(formatVersionLabel(ownVersion, buildInfo))
|
|
19
28
|
process.exit(0)
|
|
20
29
|
}
|
|
21
|
-
|
|
22
|
-
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
23
|
-
const profileDir = join(home, 'profiles', PROFILE)
|
|
24
30
|
const add = bootstrapCommand(existsSync(join(profileDir, 'package.json')), ownVersion)
|
|
25
31
|
if (add !== undefined) {
|
|
26
32
|
console.error(`dsh-cc: initializing profile "${PROFILE}"…`)
|
|
@@ -47,6 +53,12 @@ if (add !== undefined) {
|
|
|
47
53
|
}
|
|
48
54
|
}
|
|
49
55
|
|
|
56
|
+
// A dev-synced profile paired with a DIFFERENT launcher version converges
|
|
57
|
+
// back to store bundles before the session starts (plan 2026-09-13 §3.4).
|
|
58
|
+
// A fresh profile has no stamp, so this can never fire on the bootstrap path.
|
|
59
|
+
const restorePlan = devStoreRestoreDecision(buildInfo, ownVersion)
|
|
60
|
+
if (restorePlan !== null) runStoreRestore(profileDir, ownVersion)
|
|
61
|
+
|
|
50
62
|
// A parent dsh-cc TUI process leaks DSH_CC_RESUME_SESSION / DSH_CC_AUTO_RESUME
|
|
51
63
|
// / DSH_CC_CONTINUE into a child launcher's environment. Strip them up front —
|
|
52
64
|
// before any flag is parsed — so the three are re-derived only from THIS
|
|
@@ -54,6 +66,36 @@ if (add !== undefined) {
|
|
|
54
66
|
// DSH_CC_AUTO_RESUME=1 would otherwise defeat an explicit --new / --worktree.
|
|
55
67
|
const env0 = sanitizeInheritedEnv({ ...process.env })
|
|
56
68
|
|
|
69
|
+
// WS-4 boot-time sweep: remove long-orphaned dsh-cc worktrees and REPORT
|
|
70
|
+
// stale dsh-cc session locks (locks are never auto-released — no
|
|
71
|
+
// trustworthy cross-process liveness oracle). Runs on every launch whether
|
|
72
|
+
// or not this launch is a --worktree session, never does network I/O, is
|
|
73
|
+
// bounded by the 10s cap, and degrades to a silent no-op on any failure —
|
|
74
|
+
// launch must never block on the sweep.
|
|
75
|
+
try {
|
|
76
|
+
const sweepCommon = spawnSync('git', ['rev-parse', '--git-common-dir'], { encoding: 'utf8', timeout: 2000 })
|
|
77
|
+
const sweepRoot = sweepCommon.status === 0 ? repoRootFromCommonDir(process.cwd(), sweepCommon.stdout) : undefined
|
|
78
|
+
if (sweepRoot !== undefined && existsSync(sweepRoot)) {
|
|
79
|
+
const sweepSettings = readWorktreeSettings(worktreeSettingsPaths({ home, projectRoot: sweepRoot }))
|
|
80
|
+
const swept = sweepWorktrees({
|
|
81
|
+
repoRoot: sweepRoot,
|
|
82
|
+
cleanupPeriodDays: sweepSettings.cleanupPeriodDays,
|
|
83
|
+
git: (argv, opts) => spawnSync('git', argv, {
|
|
84
|
+
encoding: 'utf8',
|
|
85
|
+
...(opts?.timeoutMs ? { timeout: opts.timeoutMs } : {}),
|
|
86
|
+
...(opts?.cwd ? { cwd: opts.cwd } : {}),
|
|
87
|
+
}),
|
|
88
|
+
onAdvisory: line => console.error(line),
|
|
89
|
+
deadline: SWEEP_CAP_MS,
|
|
90
|
+
})
|
|
91
|
+
if (swept.removed.length > 0) {
|
|
92
|
+
console.error(`dsh-cc: swept ${swept.removed.length} stale worktree(s) older than ${sweepSettings.cleanupPeriodDays} days`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Sweep failures never block launch.
|
|
97
|
+
}
|
|
98
|
+
|
|
57
99
|
// `--worktree [name]` is intercepted here (never forwarded to dsh): the
|
|
58
100
|
// launcher creates `<repoRoot>/.claude/worktrees/<slug>` itself and starts
|
|
59
101
|
// the session inside it, marking it via DSH_CC_WORKTREE so the TUI offers
|
|
@@ -65,12 +107,31 @@ if (worktree.name !== undefined) {
|
|
|
65
107
|
// of this block. A REUSED worktree leaves DSH_CC_RESUME_SESSION undefined,
|
|
66
108
|
// so interceptResume sets DSH_CC_AUTO_RESUME=1 and the TUI resumes the
|
|
67
109
|
// project's last session.
|
|
68
|
-
|
|
69
|
-
|
|
110
|
+
// WS-1 root pinning: anchor at the git common dir so a session launched
|
|
111
|
+
// from inside a linked worktree still creates a SIBLING under the main
|
|
112
|
+
// root's .claude/worktrees/, never a nested tree.
|
|
113
|
+
const common = spawnSync('git', ['rev-parse', '--git-common-dir'], { encoding: 'utf8' })
|
|
114
|
+
if (common.error || common.status !== 0) {
|
|
70
115
|
console.error('dsh-cc: --worktree requires a git repository (run from inside a git working tree).')
|
|
71
116
|
process.exit(1)
|
|
72
117
|
}
|
|
73
|
-
const repoRoot =
|
|
118
|
+
const repoRoot = repoRootFromCommonDir(process.cwd(), common.stdout)
|
|
119
|
+
if (repoRoot === undefined || !existsSync(repoRoot)) {
|
|
120
|
+
console.error('dsh-cc: --worktree requires a git repository (could not locate the main checkout).')
|
|
121
|
+
process.exit(1)
|
|
122
|
+
}
|
|
123
|
+
// WS-1: neutralize repository-local filter drivers — read the local config
|
|
124
|
+
// up front and refuse on unreadable config or CC-parity refusal shapes.
|
|
125
|
+
const localConfig = spawnSync('git', ['-C', repoRoot, 'config', '--local', '--list', '-z'], { encoding: 'utf8' })
|
|
126
|
+
if (localConfig.error || localConfig.status !== 0) {
|
|
127
|
+
console.error('dsh-cc: refusing to create a worktree: repository local config is unreadable.')
|
|
128
|
+
process.exit(1)
|
|
129
|
+
}
|
|
130
|
+
const configScan = parseLocalConfig(localConfig.stdout)
|
|
131
|
+
if (configScan.refusals.length > 0) {
|
|
132
|
+
console.error(`dsh-cc: refusing to create a worktree: ${configScan.refusals.join('; ')}`)
|
|
133
|
+
process.exit(1)
|
|
134
|
+
}
|
|
74
135
|
// Drop stale registrations left by crashed sessions before planning paths.
|
|
75
136
|
spawnSync('git', ['-C', repoRoot, 'worktree', 'prune'], { encoding: 'utf8' })
|
|
76
137
|
const head = spawnSync('git', ['-C', repoRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' })
|
|
@@ -78,19 +139,109 @@ if (worktree.name !== undefined) {
|
|
|
78
139
|
console.error('dsh-cc: could not resolve HEAD; is this a repository without commits?')
|
|
79
140
|
process.exit(1)
|
|
80
141
|
}
|
|
81
|
-
|
|
142
|
+
// WS-4: read the `worktree` settings subset (user → project → local,
|
|
143
|
+
// fail-open) and resolve the creation base. The refresh fetch (when the
|
|
144
|
+
// cached origin/HEAD is older than 24h) is capped at 5s and happens on
|
|
145
|
+
// the creation path only.
|
|
146
|
+
const wtSettings = readWorktreeSettings(worktreeSettingsPaths({ home, projectRoot: repoRoot }))
|
|
147
|
+
const base = await resolveBaseRef(
|
|
148
|
+
(argv, opts) => spawnSync('git', argv, {
|
|
149
|
+
encoding: 'utf8',
|
|
150
|
+
cwd: repoRoot,
|
|
151
|
+
...(opts?.timeoutMs ? { timeout: opts.timeoutMs } : {}),
|
|
152
|
+
}),
|
|
153
|
+
wtSettings.baseRef,
|
|
154
|
+
)
|
|
155
|
+
const baseHead = base === 'HEAD'
|
|
156
|
+
? head.stdout.trim()
|
|
157
|
+
: (spawnSync('git', ['-C', repoRoot, 'rev-parse', base], { encoding: 'utf8' }).stdout.trim() || head.stdout.trim())
|
|
158
|
+
// WS-6 item 3: PR references (`#<n>`, GitHub PR / GitLab MR URLs) are
|
|
159
|
+
// parsed BEFORE any slug validation; the fetched head becomes the base.
|
|
160
|
+
// Pre-build limitation: no hooks run on this path (documented deviation).
|
|
161
|
+
const pr = parseWorktreeRef(worktree.name)
|
|
162
|
+
let prBase = null
|
|
163
|
+
if (pr !== undefined) {
|
|
164
|
+
let host = pr.host
|
|
165
|
+
if (host === undefined) {
|
|
166
|
+
const url = spawnSync('git', ['-C', repoRoot, 'remote', 'get-url', 'origin'], { encoding: 'utf8' })
|
|
167
|
+
host = url.status === 0 ? remoteHost(url.stdout) : undefined
|
|
168
|
+
}
|
|
169
|
+
const refs = prFetchRefs(host, pr.pr)
|
|
170
|
+
let fetched = null
|
|
171
|
+
for (const ref of refs) {
|
|
172
|
+
const f = spawnSync('git', ['-C', repoRoot, 'fetch', 'origin', ref], { encoding: 'utf8', timeout: 5000 })
|
|
173
|
+
if (!f.error && f.status === 0) { fetched = ref; break }
|
|
174
|
+
}
|
|
175
|
+
if (fetched === null) {
|
|
176
|
+
console.error(`dsh-cc: could not resolve PR ${pr.pr} from origin (tried ${refs.join(', ')}). Check the reference, the remote, and your network.`)
|
|
177
|
+
process.exit(1)
|
|
178
|
+
}
|
|
179
|
+
prBase = spawnSync('git', ['-C', repoRoot, 'rev-parse', 'FETCH_HEAD'], { encoding: 'utf8' }).stdout.trim() || null
|
|
180
|
+
if (prBase === null) {
|
|
181
|
+
console.error(`dsh-cc: fetched ${fetched} but could not resolve FETCH_HEAD.`)
|
|
182
|
+
process.exit(1)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const createBase = prBase ?? base
|
|
186
|
+
// WS-4: `git worktree lock --reason="dsh-cc session <slug>"` at managed
|
|
187
|
+
// session start (created OR reused). Pre-2.15 git without `worktree
|
|
188
|
+
// lock` is a tolerated no-op with a one-line warn.
|
|
189
|
+
const lockWorktreeSession = (slug, path) => {
|
|
190
|
+
const lock = spawnSync('git', ['-C', repoRoot, 'worktree', 'lock', `--reason=dsh-cc session ${slug}`, path], { encoding: 'utf8' })
|
|
191
|
+
if (lock.status !== 0) {
|
|
192
|
+
if (/unknown option|unknown switch/i.test(lock.stderr ?? '')) {
|
|
193
|
+
console.error(`dsh-cc: git worktree lock unsupported by this git version; ${path} left unlocked`)
|
|
194
|
+
} else {
|
|
195
|
+
console.error(`dsh-cc: could not lock worktree ${path}: ${(lock.stderr ?? '').trim()}`)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// A PR reference is user-pinned: the /quit overlay treats it as named.
|
|
200
|
+
const named = worktree.name !== null || pr !== undefined
|
|
82
201
|
let plan = null
|
|
83
202
|
let created = false
|
|
84
203
|
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
|
85
204
|
let candidate
|
|
86
205
|
try {
|
|
87
|
-
candidate = planWorktree(repoRoot, worktree.name)
|
|
206
|
+
candidate = pr !== undefined ? planWorktreeRef(repoRoot, pr.pr) : planWorktree(repoRoot, worktree.name)
|
|
88
207
|
} catch (error) {
|
|
89
208
|
console.error(`dsh-cc: ${error.message}`)
|
|
90
209
|
process.exit(1)
|
|
91
210
|
}
|
|
211
|
+
// WS-1 symlink refusal on the creation route (CC v2.1.212 parity).
|
|
212
|
+
const symlink = symlinkedPath([join(repoRoot, '.claude'), join(repoRoot, '.claude', 'worktrees'), candidate.worktreePath])
|
|
213
|
+
if (symlink !== null) {
|
|
214
|
+
console.error(`dsh-cc: refusing to create a worktree: a creation path is a symlink: ${symlink}`)
|
|
215
|
+
process.exit(1)
|
|
216
|
+
}
|
|
92
217
|
const pathExists = existsSync(candidate.worktreePath)
|
|
93
218
|
if (existingWorktreeDecision({ named, pathExists }) === 'reuse') {
|
|
219
|
+
// WS-1 adoption gate: verify the existing directory's git identity
|
|
220
|
+
// before handing it over (leave the directory in place on refusal).
|
|
221
|
+
const refusal = worktreeIdentityRefusal(candidate.worktreePath, repoRoot)
|
|
222
|
+
if (refusal !== null) {
|
|
223
|
+
console.error(`dsh-cc: ${refusal}`)
|
|
224
|
+
process.exit(1)
|
|
225
|
+
}
|
|
226
|
+
// WS-4 merged-reset rule: when the reused tree is clean, still on its
|
|
227
|
+
// worktree-* branch, and its own commits are all reachable from the
|
|
228
|
+
// resolved fresh base, hard-reset it to the base before handover;
|
|
229
|
+
// otherwise (or when any probe is unverifiable) continue at the old
|
|
230
|
+
// tip. `source: 'name'` keeps the WS-6 PR-reuse skip open.
|
|
231
|
+
const reset = worktreeReuseReset({
|
|
232
|
+
plan: candidate,
|
|
233
|
+
repoRoot,
|
|
234
|
+
freshBase: base,
|
|
235
|
+
source: pr !== undefined ? 'pr' : 'name',
|
|
236
|
+
git: (argv, opts) => spawnSync('git', argv, {
|
|
237
|
+
encoding: 'utf8',
|
|
238
|
+
...(opts?.timeoutMs ? { timeout: opts.timeoutMs } : {}),
|
|
239
|
+
...(opts?.cwd ? { cwd: opts.cwd } : {}),
|
|
240
|
+
}),
|
|
241
|
+
})
|
|
242
|
+
if (reset.action === 'reset') {
|
|
243
|
+
console.error(`dsh-cc: reset reused worktree "${candidate.slug}" to ${base}`)
|
|
244
|
+
}
|
|
94
245
|
plan = candidate
|
|
95
246
|
created = false
|
|
96
247
|
break
|
|
@@ -99,7 +250,7 @@ if (worktree.name !== undefined) {
|
|
|
99
250
|
? `path already exists: ${candidate.worktreePath}`
|
|
100
251
|
: null
|
|
101
252
|
if (failure === null) {
|
|
102
|
-
const add = spawnSync('git', ['-C', repoRoot, ...worktreeAddArgv(candidate)], { encoding: 'utf8' })
|
|
253
|
+
const add = spawnSync('git', ['-C', repoRoot, ...worktreeAddArgv(candidate, configScan.filters, createBase)], { encoding: 'utf8' })
|
|
103
254
|
if (add.error || add.status !== 0) {
|
|
104
255
|
failure = (add.stderr ?? (add.error ? String(add.error) : '')).trim() || 'git worktree add failed'
|
|
105
256
|
}
|
|
@@ -122,7 +273,8 @@ if (worktree.name !== undefined) {
|
|
|
122
273
|
console.error('dsh-cc: could not allocate a worktree name after several attempts; try --worktree <name>.')
|
|
123
274
|
process.exit(1)
|
|
124
275
|
}
|
|
125
|
-
|
|
276
|
+
lockWorktreeSession(plan.slug, plan.worktreePath)
|
|
277
|
+
Object.assign(env0, worktreeEnv(plan, repoRoot, prBase ?? baseHead, named))
|
|
126
278
|
spawnCwd = plan.worktreePath
|
|
127
279
|
const verb = created ? 'created' : 'reusing'
|
|
128
280
|
console.error(`dsh-cc: worktree "${plan.slug}" ${verb} at ${plan.worktreePath} (branch ${plan.branch})`)
|
package/bootstrap.mjs
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* decision table without spawning dsh.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import { closeSync, existsSync, lstatSync, openSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs'
|
|
8
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
7
9
|
|
|
8
10
|
export const PROFILE = 'tui'
|
|
9
11
|
export const BUNDLES = [
|
|
@@ -265,32 +267,229 @@ export function planWorktree(repoRoot, name, rand) {
|
|
|
265
267
|
return { slug, worktreePath: worktreePathFor(repoRoot, slug), branch: worktreeBranch(slug) }
|
|
266
268
|
}
|
|
267
269
|
|
|
270
|
+
/**
|
|
271
|
+
* WS-6 item 3: parse a PR reference `--worktree` value — `#<n>`, a GitHub PR
|
|
272
|
+
* URL, or a GitLab MR URL. Runs BEFORE any slug validation so `#12` never
|
|
273
|
+
* reaches validateWorktreeSlug. Anything else is not a PR reference.
|
|
274
|
+
* @param {string | null | undefined} value - the parsed flag value.
|
|
275
|
+
* @returns {{ pr: number, host?: string } | undefined}
|
|
276
|
+
* `host` is the URL's host when the value was a URL (it selects the fetch
|
|
277
|
+
* ref shape); undefined for a bare `#<n>`.
|
|
278
|
+
*/
|
|
279
|
+
export function parseWorktreeRef(value) {
|
|
280
|
+
if (value === null || value === undefined) return undefined
|
|
281
|
+
let m = /^#(\d+)$/.exec(value)
|
|
282
|
+
if (m !== null) return { pr: Number(m[1]) }
|
|
283
|
+
m = /^https:\/\/([^/]+)\/.+(?:\/pull|\/-\/merge_requests|\/merge_requests)\/(\d+)\/?$/.exec(value)
|
|
284
|
+
if (m !== null) return { pr: Number(m[2]), host: m[1] }
|
|
285
|
+
return undefined
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The origin fetch refs for a PR head, in try order (WS-6 item 3):
|
|
290
|
+
* `pull/<n>/head` on github.com, `merge-requests/<n>/head` on gitlab.com,
|
|
291
|
+
* first-then-second on any other (or unknown) host.
|
|
292
|
+
* @param {string | undefined} host
|
|
293
|
+
* @param {number} n
|
|
294
|
+
* @returns {string[]}
|
|
295
|
+
*/
|
|
296
|
+
export function prFetchRefs(host, n) {
|
|
297
|
+
if (host === 'github.com') return [`pull/${n}/head`]
|
|
298
|
+
if (host === 'gitlab.com') return [`merge-requests/${n}/head`]
|
|
299
|
+
return [`pull/${n}/head`, `merge-requests/${n}/head`]
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Host of a git remote URL (`https://…`, `git@host:…`, `ssh://…`), or
|
|
304
|
+
* undefined when it has none.
|
|
305
|
+
* @param {string} url
|
|
306
|
+
* @returns {string | undefined}
|
|
307
|
+
*/
|
|
308
|
+
export function remoteHost(url) {
|
|
309
|
+
const m = /^(?:(?:https?|ssh):\/\/)?(?:[^/@]+@)?([^/:]+)/.exec(url.trim())
|
|
310
|
+
return m !== null ? m[1] : undefined
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The fixed PR plan: slug `pr-<n>`, branch `worktree-pr-<n>`, path under the
|
|
315
|
+
* convention dir. Deliberately NOT routed through validateWorktreeSlug —
|
|
316
|
+
* the `pr-<n>` shape is generated here and always valid.
|
|
317
|
+
* @param {string} repoRoot
|
|
318
|
+
* @param {number} n
|
|
319
|
+
* @returns {{ slug: string, worktreePath: string, branch: string }}
|
|
320
|
+
*/
|
|
321
|
+
export function planWorktreeRef(repoRoot, n) {
|
|
322
|
+
const slug = `pr-${n}`
|
|
323
|
+
return { slug, worktreePath: join(repoRoot, '.claude', 'worktrees', slug), branch: `worktree-pr-${n}` }
|
|
324
|
+
}
|
|
325
|
+
|
|
268
326
|
/**
|
|
269
327
|
* argv for `git worktree add -B <branch> <path> HEAD` (execFile form — no
|
|
270
328
|
* shell, so no quoting concerns). `-B` resets a stale orphan branch left by
|
|
271
|
-
* a removed worktree.
|
|
329
|
+
* a removed worktree. When `filterNames` is nonempty, empty-string `-c`
|
|
330
|
+
* overrides neutralize every repository-local filter driver first
|
|
331
|
+
* (mechanism verified empirically — content-marker experiment, git 2.54:
|
|
332
|
+
* overriding all `filter.<name>.*` keys + `required=false` suppresses
|
|
333
|
+
* execution; `required=false` alone does NOT). Consequence (CC parity):
|
|
334
|
+
* LFS content arrives as pointer files; `git lfs pull` restores it.
|
|
272
335
|
* @param {{ worktreePath: string, branch: string }} plan
|
|
336
|
+
* @param {readonly string[]} [filterNames]
|
|
337
|
+
* @param {string} [base] - Resolved base ref/commit (WS-4 `worktree.baseRef`);
|
|
338
|
+
* defaults to the literal `HEAD`.
|
|
273
339
|
* @returns {string[]}
|
|
274
340
|
*/
|
|
275
|
-
export function worktreeAddArgv(plan) {
|
|
276
|
-
|
|
341
|
+
export function worktreeAddArgv(plan, filterNames = [], base = 'HEAD') {
|
|
342
|
+
const neutralize = []
|
|
343
|
+
for (const filter of filterNames) {
|
|
344
|
+
for (const key of ['command', 'smudge', 'clean', 'process']) {
|
|
345
|
+
neutralize.push('-c', `filter.${filter}.${key}=`)
|
|
346
|
+
}
|
|
347
|
+
neutralize.push('-c', `filter.${filter}.required=false`)
|
|
348
|
+
}
|
|
349
|
+
return [...neutralize, 'worktree', 'add', '-B', plan.branch, plan.worktreePath, base]
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* WS-1 root pinning: main repository root from `git rev-parse
|
|
354
|
+
* --git-common-dir` output run at `cwd`. Inside a linked worktree the
|
|
355
|
+
* common dir already points at the main checkout's `.git`, so creation
|
|
356
|
+
* always anchors at the main root (sibling worktrees, never nested).
|
|
357
|
+
* @param {string} cwd - Directory the git probe ran in.
|
|
358
|
+
* @param {string} output - Raw stdout of the probe.
|
|
359
|
+
* @returns {string | undefined}
|
|
360
|
+
*/
|
|
361
|
+
export function repoRootFromCommonDir(cwd, output) {
|
|
362
|
+
const raw = output.trim()
|
|
363
|
+
if (raw.length === 0) return undefined
|
|
364
|
+
return dirname(isAbsolute(raw) ? raw : resolve(cwd, raw))
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Parse `git config --local --list -z` output (NUL-separated `key\nvalue`
|
|
369
|
+
* entries — the NUL form keeps filter names containing `=` decidable).
|
|
370
|
+
* Refuses includeIf and filter names containing `=`; collects the rest.
|
|
371
|
+
* Keep in sync with tool-git-worktree/src/harden.ts.
|
|
372
|
+
* @param {string} text
|
|
373
|
+
* @returns {{ filters: string[], refusals: string[] }}
|
|
374
|
+
*/
|
|
375
|
+
export function parseLocalConfig(text) {
|
|
376
|
+
const filters = new Set()
|
|
377
|
+
const refusals = []
|
|
378
|
+
for (const entry of text.split('\0')) {
|
|
379
|
+
if (entry.length === 0) continue
|
|
380
|
+
const nl = entry.indexOf('\n')
|
|
381
|
+
const rawKey = nl < 0 ? entry : entry.slice(0, nl)
|
|
382
|
+
const key = rawKey.toLowerCase()
|
|
383
|
+
if (key.startsWith('includeif.')) {
|
|
384
|
+
refusals.push(`refusing local config with includeIf: ${rawKey}`)
|
|
385
|
+
continue
|
|
386
|
+
}
|
|
387
|
+
if (!key.startsWith('filter.')) continue
|
|
388
|
+
const name = key.slice('filter.'.length, key.lastIndexOf('.'))
|
|
389
|
+
if (name.length === 0) continue
|
|
390
|
+
if (name.includes('=') || name.includes('\n')) {
|
|
391
|
+
refusals.push(`refusing filter driver with ambiguous name: filter.${name}.*`)
|
|
392
|
+
continue
|
|
393
|
+
}
|
|
394
|
+
filters.add(name)
|
|
395
|
+
}
|
|
396
|
+
return { filters: [...filters], refusals }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* WS-1 symlink refusal: the first path in the list that is itself a symlink
|
|
401
|
+
* (CC v2.1.212 parity). Callers check `.claude`, `.claude/worktrees`, and
|
|
402
|
+
* the computed target path.
|
|
403
|
+
* @param {string[]} paths
|
|
404
|
+
* @returns {string | null} the offending path, or null when none is a symlink.
|
|
405
|
+
*/
|
|
406
|
+
export function symlinkedPath(paths) {
|
|
407
|
+
for (const path of paths) {
|
|
408
|
+
try {
|
|
409
|
+
if (lstatSync(path).isSymbolicLink()) return path
|
|
410
|
+
} catch {
|
|
411
|
+
// missing is fine
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return null
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* WS-1 adoption identity check for reusing an existing directory as a
|
|
419
|
+
* worktree: its `.git` entry must be a gitdir pointer file resolving into
|
|
420
|
+
* the main checkout's `.git/worktrees/` registration. Refuses plain clones,
|
|
421
|
+
* core.worktree redirects, unreadable entries, directories that contain the
|
|
422
|
+
* main checkout, and directories with no git metadata (may hold user work).
|
|
423
|
+
* Keep in sync with tool-git-worktree/src/harden.ts.
|
|
424
|
+
* @param {string} target - Absolute path of the existing directory.
|
|
425
|
+
* @param {string} mainRoot - Main checkout repository root.
|
|
426
|
+
* @returns {string | null} the refusal reason, or null when adoptable.
|
|
427
|
+
*/
|
|
428
|
+
export function worktreeIdentityRefusal(target, mainRoot) {
|
|
429
|
+
const rel = relative(target, mainRoot)
|
|
430
|
+
if (rel === '' || !rel.startsWith(`..${sep}`)) {
|
|
431
|
+
return `refusing to reuse ${target}: it contains the main checkout at ${mainRoot}. Remove or rename the directory (or pick another name) and retry.`
|
|
432
|
+
}
|
|
433
|
+
const gitEntry = join(target, '.git')
|
|
434
|
+
let info
|
|
435
|
+
try {
|
|
436
|
+
info = lstatSync(gitEntry)
|
|
437
|
+
} catch (error) {
|
|
438
|
+
if (/** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT') {
|
|
439
|
+
return `refusing to reuse ${target}: it holds no git worktree (no .git entry) and may contain user work. Remove or rename the directory (or pick another name) and retry.`
|
|
440
|
+
}
|
|
441
|
+
return `refusing to reuse ${target}: its .git entry is unreadable (${/** @type {NodeJS.ErrnoException} */ (error).message}). Inspect the directory manually; remove or rename it (or pick another name) and retry.`
|
|
442
|
+
}
|
|
443
|
+
if (!info.isFile()) {
|
|
444
|
+
let detail = 'it is a separate checkout (directory .git), not a linked worktree'
|
|
445
|
+
try {
|
|
446
|
+
const common = readFileSync(join(gitEntry, 'commondir'), 'utf8').trim()
|
|
447
|
+
const gitRoot = join(mainRoot, '.git')
|
|
448
|
+
const resolved = resolve(gitEntry, common)
|
|
449
|
+
if (resolved.startsWith(gitRoot + sep) || resolved === gitRoot) {
|
|
450
|
+
detail = 'its .git commondir resolves into the main checkout\'s own .git (plain-clone/core.worktree redirect shape)'
|
|
451
|
+
}
|
|
452
|
+
} catch {
|
|
453
|
+
// no commondir file — separate-checkout message stands
|
|
454
|
+
}
|
|
455
|
+
return `refusing to reuse ${target}: ${detail}. Remove or rename the directory (or pick another name) and retry.`
|
|
456
|
+
}
|
|
457
|
+
let gitdir
|
|
458
|
+
try {
|
|
459
|
+
gitdir = readFileSync(gitEntry, 'utf8').trim()
|
|
460
|
+
} catch {
|
|
461
|
+
return `refusing to reuse ${target}: its .git pointer file is unreadable. Inspect the directory manually; remove or rename it (or pick another name) and retry.`
|
|
462
|
+
}
|
|
463
|
+
if (!gitdir.startsWith('gitdir:')) {
|
|
464
|
+
return `refusing to reuse ${target}: its .git entry is a file but not a gitdir pointer. Remove or rename the directory (or pick another name) and retry.`
|
|
465
|
+
}
|
|
466
|
+
const registered = resolve(target, gitdir.slice('gitdir:'.length).trim())
|
|
467
|
+
const registrations = join(mainRoot, '.git', 'worktrees')
|
|
468
|
+
if (!registered.startsWith(registrations + sep) && registered !== registrations) {
|
|
469
|
+
return `refusing to reuse ${target}: its .git pointer resolves to ${registered}, which is not this repository's worktree registration (.git/worktrees/). It may be a clone, redirect, or foreign worktree. Remove or rename the directory (or pick another name) and retry.`
|
|
470
|
+
}
|
|
471
|
+
return null
|
|
277
472
|
}
|
|
278
473
|
|
|
279
474
|
/**
|
|
280
475
|
* Env fragment handed to the spawned dsh process so the TUI can recognize
|
|
281
476
|
* this session as launcher-managed worktree session at /quit time.
|
|
477
|
+
* `named` records whether the slug was user-chosen (WS-5's auto-remove
|
|
478
|
+
* predicate consumes it — keep in sync with the worktree-exit marker parse).
|
|
282
479
|
* @param {{ worktreePath: string, branch: string }} plan
|
|
283
480
|
* @param {string} repoRoot
|
|
284
481
|
* @param {string} baseHead - The commit the worktree was based on.
|
|
482
|
+
* @param {boolean} named - True when the user supplied the slug.
|
|
285
483
|
* @returns {Record<string, string>}
|
|
286
484
|
*/
|
|
287
|
-
export function worktreeEnv(plan, repoRoot, baseHead) {
|
|
485
|
+
export function worktreeEnv(plan, repoRoot, baseHead, named) {
|
|
288
486
|
return {
|
|
289
487
|
[WORKTREE_ENV]: JSON.stringify({
|
|
290
488
|
repoRoot,
|
|
291
489
|
worktreePath: plan.worktreePath,
|
|
292
490
|
branch: plan.branch,
|
|
293
491
|
baseHead,
|
|
492
|
+
named: Boolean(named),
|
|
294
493
|
}),
|
|
295
494
|
}
|
|
296
495
|
}
|
|
@@ -430,3 +629,189 @@ export function versionGate(runVersion) {
|
|
|
430
629
|
}
|
|
431
630
|
return { ok: true }
|
|
432
631
|
}
|
|
632
|
+
|
|
633
|
+
// --- dev-build version stamp + store restore ---------------------------------
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Read a dev-build stamp written by scripts/stamp-build-info.mjs into the
|
|
637
|
+
* profile's @dsh-cc scope. Any problem (missing file, EACCES, malformed
|
|
638
|
+
* JSON) fails open to null — the launcher must never brick on a stamp.
|
|
639
|
+
*
|
|
640
|
+
* @param {string} stampPath
|
|
641
|
+
* @returns {Record<string, unknown> | null}
|
|
642
|
+
*/
|
|
643
|
+
export function readBuildInfo(stampPath) {
|
|
644
|
+
try {
|
|
645
|
+
return JSON.parse(readFileSync(stampPath, 'utf8'))
|
|
646
|
+
} catch {
|
|
647
|
+
return null
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Version label for `dsh-cc --version`: bare semver for release installs,
|
|
653
|
+
* `<base>-dev+<commit>[.dirty]` for a dev-synced profile. The stamp's own
|
|
654
|
+
* version wins over the launcher's — the commit describes that tree.
|
|
655
|
+
*
|
|
656
|
+
* @param {string} version launcher's own package.json version
|
|
657
|
+
* @param {Record<string, unknown> | null} info parsed stamp or null
|
|
658
|
+
* @returns {string}
|
|
659
|
+
*/
|
|
660
|
+
export function formatVersionLabel(version, info) {
|
|
661
|
+
if (info?.channel !== 'dev') return version
|
|
662
|
+
const base = (typeof info.version === 'string' && info.version) || version
|
|
663
|
+
const commit = (typeof info.commit === 'string' && info.commit) || 'unknown'
|
|
664
|
+
return `${base}-dev+${commit}${info.dirty === true ? '.dirty' : ''}`
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Decide whether the profile's dev store copy must be restored to store
|
|
669
|
+
* bundles: only when the stamp pairs this profile with a DIFFERENT launcher
|
|
670
|
+
* version than the one now running. Unknown pairing (no launcherVersion)
|
|
671
|
+
* never destroys dev state.
|
|
672
|
+
*
|
|
673
|
+
* @param {Record<string, unknown> | null} info
|
|
674
|
+
* @param {string} ownVersion
|
|
675
|
+
* @returns {{ from: string, to: string } | null}
|
|
676
|
+
*/
|
|
677
|
+
export function devStoreRestoreDecision(info, ownVersion) {
|
|
678
|
+
if (info?.channel !== 'dev') return null
|
|
679
|
+
const seen = info.launcherVersion
|
|
680
|
+
if (typeof seen !== 'string' || seen.length === 0 || seen === ownVersion) return null
|
|
681
|
+
return { from: seen, to: ownVersion }
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Restore a dev-synced profile to store bundles after a launcher update
|
|
686
|
+
* (plan §3.4). Set-aside + rename dance is atomic per step; any unexpected
|
|
687
|
+
* throw is rolled back best-effort and reported, never escaping to the bin.
|
|
688
|
+
*
|
|
689
|
+
* @param {string} profileDir
|
|
690
|
+
* @param {string} ownVersion
|
|
691
|
+
* @param {{ spawnSyncImpl?: typeof import('node:child_process').spawnSync, log?: (...a: unknown[]) => void, now?: () => number }} [deps]
|
|
692
|
+
* @returns {{ restored: boolean, reason?: string, from?: string, to?: string }>}
|
|
693
|
+
*/
|
|
694
|
+
export function runStoreRestore(profileDir, ownVersion, deps = {}) {
|
|
695
|
+
const { spawnSyncImpl = spawnSync, log = console.error, now = Date.now } = deps
|
|
696
|
+
const scope = join(profileDir, 'node_modules', '@dsh-cc')
|
|
697
|
+
const backup = `${scope}.__dev-restore-backup`
|
|
698
|
+
const lock = join(profileDir, 'node_modules', '.dsh-cc-restore.lock')
|
|
699
|
+
const stamp = join(scope, 'dsh-cc-build.json')
|
|
700
|
+
const home = dirname(dirname(profileDir))
|
|
701
|
+
const presetDir = join(home, '.agent-presets', 'cc')
|
|
702
|
+
// The bin always hands us <home>/profiles/<PROFILE>; deriving the name keeps
|
|
703
|
+
// the function honest when tests pass tmp profile dirs.
|
|
704
|
+
const profileName = basename(profileDir)
|
|
705
|
+
let locked = false
|
|
706
|
+
// Rollback cleanup may only touch `scope` once the dev state is safely in
|
|
707
|
+
// `backup` — a throw BEFORE the set-aside must leave the profile untouched.
|
|
708
|
+
let setAside = false
|
|
709
|
+
|
|
710
|
+
const tryRename = (from, to) => {
|
|
711
|
+
try {
|
|
712
|
+
renameSync(from, to)
|
|
713
|
+
return true
|
|
714
|
+
} catch (error) {
|
|
715
|
+
log(`dsh-cc: dev-store restore: rename ${from} -> ${to} failed: ${/** @type {Error} */ (error).message}`)
|
|
716
|
+
return false
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
const tryRm = (target, recursive) => {
|
|
720
|
+
try {
|
|
721
|
+
rmSync(target, { recursive, force: true })
|
|
722
|
+
return true
|
|
723
|
+
} catch (error) {
|
|
724
|
+
log(`dsh-cc: dev-store restore: failed to remove ${target}: ${/** @type {Error} */ (error).message}`)
|
|
725
|
+
return false
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
try {
|
|
730
|
+
// 1. Lock FIRST: exclusive create; a lock younger than ten minutes means
|
|
731
|
+
// another launch is mid-restore — skip (launch as-is; transient until the
|
|
732
|
+
// holder finishes). Stale locks are taken over. Crash recovery MUST run
|
|
733
|
+
// only with the lock held: recovering backup->scope while a live holder
|
|
734
|
+
// re-materializes would let the holder delete the stamp out of the
|
|
735
|
+
// recovered dev scope and report success — dev code booting while
|
|
736
|
+
// --version claims a release, with no retry ever firing again.
|
|
737
|
+
let handle
|
|
738
|
+
try {
|
|
739
|
+
handle = openSync(lock, 'wx')
|
|
740
|
+
locked = true
|
|
741
|
+
} catch (error) {
|
|
742
|
+
if (/** @type {NodeJS.ErrnoException} */ (error).code !== 'EEXIST') throw error
|
|
743
|
+
let fresh = true
|
|
744
|
+
try {
|
|
745
|
+
fresh = now() - statSync(lock).mtimeMs < 10 * 60 * 1000
|
|
746
|
+
} catch { /* lock vanished between EEXIST and stat: proceed to retry */ }
|
|
747
|
+
if (fresh) {
|
|
748
|
+
log('dsh-cc: dev-store restore already in progress, skipping')
|
|
749
|
+
return { restored: false, reason: 'locked' }
|
|
750
|
+
}
|
|
751
|
+
tryRm(lock, false)
|
|
752
|
+
try {
|
|
753
|
+
handle = openSync(lock, 'wx')
|
|
754
|
+
locked = true
|
|
755
|
+
} catch {
|
|
756
|
+
log('dsh-cc: dev-store restore already in progress, skipping')
|
|
757
|
+
return { restored: false, reason: 'locked' }
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
closeSync(handle)
|
|
761
|
+
|
|
762
|
+
// 2. Crash recovery: a previous restore died between set-aside and commit.
|
|
763
|
+
if (existsSync(backup) && !existsSync(scope)) {
|
|
764
|
+
log('dsh-cc: dev-store restore: recovering interrupted restore (backup -> scope)')
|
|
765
|
+
if (!tryRename(backup, scope)) return { restored: false, reason: 'error' }
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Read the stamp BEFORE renaming the scope away, for the notice's `from`.
|
|
769
|
+
const info = readBuildInfo(stamp)
|
|
770
|
+
const from = (typeof info?.launcherVersion === 'string' && info.launcherVersion) || 'unknown'
|
|
771
|
+
|
|
772
|
+
// 3. Set aside the whole dev scope — presence is not content (pnpm trusts
|
|
773
|
+
// name+version), so it must never stay in place during re-materialize.
|
|
774
|
+
if (!tryRename(scope, backup)) return { restored: false, reason: 'error' }
|
|
775
|
+
setAside = true
|
|
776
|
+
|
|
777
|
+
// 4. Re-materialize from the registry, same command surface as first boot.
|
|
778
|
+
const result = spawnSyncImpl('dsh', ['plugin', '--profile', profileName, 'add', ...BUNDLES.map(n => `${n}@${ownVersion}`)], {
|
|
779
|
+
stdio: 'inherit',
|
|
780
|
+
env: spawnEnv(sanitizeInheritedEnv(process.env), home),
|
|
781
|
+
})
|
|
782
|
+
const success = !result.error && result.status === 0
|
|
783
|
+
|
|
784
|
+
if (success) {
|
|
785
|
+
// 5. Commit: drop backup + stamp, then preset cleanup.
|
|
786
|
+
tryRm(backup, true)
|
|
787
|
+
tryRm(stamp, false)
|
|
788
|
+
try {
|
|
789
|
+
if (existsSync(presetDir)) {
|
|
790
|
+
const marker = JSON.parse(readFileSync(join(presetDir, '.dsh-cc-managed.json'), 'utf8'))
|
|
791
|
+
if (marker?.owner !== '@dsh-cc/tui') throw new Error('unowned preset copy')
|
|
792
|
+
}
|
|
793
|
+
} catch {
|
|
794
|
+
tryRm(presetDir, true)
|
|
795
|
+
log('dsh-cc: removed dev preset copy at .agent-presets/cc (store boot will reinstall it)')
|
|
796
|
+
}
|
|
797
|
+
log(`dsh-cc: launcher updated ${from} → ${ownVersion}; restored store bundles in profile "${profileName}" (re-run scripts/sync-local-profile.sh to resume a dev build)`)
|
|
798
|
+
return { restored: true, from, to: ownVersion }
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// 6. Failure: drop pnpm's partials and put the dev state back.
|
|
802
|
+
tryRm(scope, true)
|
|
803
|
+
if (!tryRename(backup, scope)) return { restored: false, reason: 'plugin-add-failed' }
|
|
804
|
+
log(`dsh-cc: dev-store restore failed (will retry on next launch; if the new dsh-cc version was just published, npm/pnpm's minimum-release-age window may still be hiding it)`)
|
|
805
|
+
return { restored: false, reason: 'plugin-add-failed' }
|
|
806
|
+
} catch (error) {
|
|
807
|
+
log(`dsh-cc: dev-store restore failed unexpectedly: ${/** @type {Error} */ (error).message}`)
|
|
808
|
+
// Roll back ONLY if the set-aside happened: removing the scope before
|
|
809
|
+
// that point would destroy intact dev state over a transient error
|
|
810
|
+
// (e.g. EACCES creating the lock) and brick the profile.
|
|
811
|
+
if (setAside) tryRm(scope, true)
|
|
812
|
+
if (existsSync(backup) && !existsSync(scope)) tryRename(backup, scope)
|
|
813
|
+
return { restored: false, reason: 'error' }
|
|
814
|
+
} finally {
|
|
815
|
+
if (locked) tryRm(lock, false)
|
|
816
|
+
}
|
|
817
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-cc/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Optional dsh-cc shortcut: bootstraps the tui profile and runs dsh --profile tui",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
|
11
|
-
"bootstrap.mjs"
|
|
11
|
+
"bootstrap.mjs",
|
|
12
|
+
"worktree-lifecycle.mjs"
|
|
12
13
|
],
|
|
13
14
|
"repository": {
|
|
14
15
|
"type": "git",
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WS-4 lifecycle logic for the plain-JS launcher (docs/plans/
|
|
3
|
+
* 2026-09-14-cc-worktree-parity.md §6 WS-4): the fail-open `worktree`
|
|
4
|
+
* settings subset read, `worktree.baseRef` resolution, the boot-time
|
|
5
|
+
* worktree sweep, and the name-reuse merged-reset predicate. Pure over an
|
|
6
|
+
* injected `git` runner (spawnSync result shape) so tests drive every
|
|
7
|
+
* decision table without spawning git. Mirrors
|
|
8
|
+
* packages/workspace/tool-git-worktree/src/lifecycle.ts — keep in sync.
|
|
9
|
+
* @module @dsh-cc/cli/worktree-lifecycle
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
/** Consumption-time defaults for the `worktree` settings section. */
|
|
16
|
+
export const WORKTREE_DEFAULTS = { baseRef: 'fresh', cleanupPeriodDays: 30 }
|
|
17
|
+
|
|
18
|
+
/** How stale the cached origin/HEAD may be before a refresh fetch. */
|
|
19
|
+
export const FRESH_CACHE_WINDOW_MS = 24 * 60 * 60 * 1000
|
|
20
|
+
|
|
21
|
+
/** Overall sweep wall-clock cap (ms). */
|
|
22
|
+
export const SWEEP_CAP_MS = 10_000
|
|
23
|
+
|
|
24
|
+
/** Refresh-fetch cap (ms). */
|
|
25
|
+
export const FETCH_CAP_MS = 5_000
|
|
26
|
+
|
|
27
|
+
/** Lock-reason prefix marking dsh-cc-owned worktree locks (sweep key). */
|
|
28
|
+
export const DSH_CC_LOCK_PREFIX = 'dsh-cc '
|
|
29
|
+
|
|
30
|
+
/** Convention path segment under the main root. */
|
|
31
|
+
export const WORKTREES_DIR_SEGMENTS = ['.claude', 'worktrees']
|
|
32
|
+
|
|
33
|
+
/** Branch prefix marking dsh-cc-owned worktree branches. */
|
|
34
|
+
export const WORKTREE_BRANCH_PREFIX = 'worktree-'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The settings files the cascade reads for user → project → local, mirrored
|
|
38
|
+
* from packages/settings/settings-cascade/src/index.ts (userSettings =
|
|
39
|
+
* `<home>/settings.json`, projectSettings = `<project>/.claude/settings.json`,
|
|
40
|
+
* localSettings = `<project>/.claude/settings.local.json`). The launcher
|
|
41
|
+
* cannot load the cascade (pre-build, dependency-free) — this is the
|
|
42
|
+
* documented subset read; flag/policy layers are ignored.
|
|
43
|
+
* @param {{ home: string, projectRoot: string }} p
|
|
44
|
+
*/
|
|
45
|
+
export function worktreeSettingsPaths({ home, projectRoot }) {
|
|
46
|
+
return {
|
|
47
|
+
user: join(home, 'settings.json'),
|
|
48
|
+
project: join(projectRoot, '.claude', 'settings.json'),
|
|
49
|
+
local: join(projectRoot, '.claude', 'settings.local.json'),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Merge parsed settings documents (low → high precedence) into the
|
|
55
|
+
* `worktree` section. First defined key wins; unknown shapes ignored.
|
|
56
|
+
* @param {(Record<string, unknown> | undefined)[]} docs
|
|
57
|
+
* @returns {{ baseRef: 'fresh' | 'head', cleanupPeriodDays: number }}
|
|
58
|
+
*/
|
|
59
|
+
export function worktreeSettingsFromDocs(docs) {
|
|
60
|
+
const section = { ...WORKTREE_DEFAULTS }
|
|
61
|
+
for (const doc of docs) {
|
|
62
|
+
const worktree = doc && typeof doc === 'object' ? doc.worktree : undefined
|
|
63
|
+
if (!worktree || typeof worktree !== 'object') continue
|
|
64
|
+
if (worktree.baseRef === 'fresh' || worktree.baseRef === 'head') section.baseRef = worktree.baseRef
|
|
65
|
+
if (typeof worktree.cleanupPeriodDays === 'number' && worktree.cleanupPeriodDays >= 0) {
|
|
66
|
+
section.cleanupPeriodDays = worktree.cleanupPeriodDays
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return section
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fail-open file read of the three settings layers. Any unreadable or
|
|
74
|
+
* unparseable file is skipped; the defaults survive.
|
|
75
|
+
* @param {{ user: string, project: string, local: string }} paths
|
|
76
|
+
* @param {(path: string) => string} [read] - Injectable reader.
|
|
77
|
+
*/
|
|
78
|
+
export function readWorktreeSettings(paths, read = readFileSync) {
|
|
79
|
+
const docs = []
|
|
80
|
+
for (const path of [paths.user, paths.project, paths.local]) {
|
|
81
|
+
try {
|
|
82
|
+
docs.push(JSON.parse(read(path)))
|
|
83
|
+
} catch {
|
|
84
|
+
docs.push(undefined)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return worktreeSettingsFromDocs(docs)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the worktree base (WS-4 `worktree.baseRef`). `head` → literal
|
|
92
|
+
* HEAD; `fresh` → cached `origin/HEAD` symbolic ref, refreshed with one
|
|
93
|
+
* fetch when its reflog is older than 24h (or unreadable); fallback chain:
|
|
94
|
+
* cached ref → local HEAD. Any probe error degrades to the next fallback.
|
|
95
|
+
* @param {(argv: string[], opts?: { timeoutMs?: number, cwd?: string }) => { status: number | null, stdout: string } | undefined} git
|
|
96
|
+
* @param {'fresh' | 'head'} baseRef
|
|
97
|
+
* @param {{ now?: number, onFetch?: (branch: string) => void }} [opts]
|
|
98
|
+
* @returns {Promise<string>} the base to hand to `git worktree add`.
|
|
99
|
+
*/
|
|
100
|
+
export async function resolveBaseRef(git, baseRef, opts = {}) {
|
|
101
|
+
if (baseRef === 'head') return 'HEAD'
|
|
102
|
+
const now = opts.now ?? Date.now()
|
|
103
|
+
const symbolic = git(['symbolic-ref', 'refs/remotes/origin/HEAD'])
|
|
104
|
+
if (!symbolic || symbolic.status !== 0) return 'HEAD'
|
|
105
|
+
const ref = symbolic.stdout.trim()
|
|
106
|
+
if (ref.length === 0) return 'HEAD'
|
|
107
|
+
const defaultBranch = ref.replace(/^refs\/remotes\/[^/]+\//, '')
|
|
108
|
+
const reflog = git(['reflog', 'show', ref, '--format=%ct', '-n', '1'])
|
|
109
|
+
const ageMs = reflog && reflog.status === 0 ? now - Number.parseInt(reflog.stdout.trim(), 10) * 1000 : Number.NaN
|
|
110
|
+
if (Number.isNaN(ageMs) || ageMs > FRESH_CACHE_WINDOW_MS) {
|
|
111
|
+
opts.onFetch?.(defaultBranch)
|
|
112
|
+
git(['fetch', 'origin', defaultBranch], { timeoutMs: FETCH_CAP_MS })
|
|
113
|
+
}
|
|
114
|
+
return ref
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- sweep -------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* One parsed `git worktree list --porcelain` entry.
|
|
121
|
+
* @typedef {{ path: string, branch: string, locked: boolean, lockReason: string }} WorktreeEntry
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parse `git worktree list --porcelain` output. Locked entries carry the
|
|
126
|
+
* reason text (empty when `lock` has none).
|
|
127
|
+
* @param {string} text
|
|
128
|
+
* @returns {WorktreeEntry[]}
|
|
129
|
+
*/
|
|
130
|
+
export function parseWorktreeListPorcelain(text) {
|
|
131
|
+
const entries = []
|
|
132
|
+
let current = null
|
|
133
|
+
for (const line of text.split('\n')) {
|
|
134
|
+
if (line.startsWith('worktree ')) {
|
|
135
|
+
if (current) entries.push(current)
|
|
136
|
+
current = { path: line.slice('worktree '.length), branch: '', locked: false, lockReason: '' }
|
|
137
|
+
} else if (current && line.startsWith('branch ')) {
|
|
138
|
+
current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
|
|
139
|
+
} else if (current && line === 'locked') {
|
|
140
|
+
current.locked = true
|
|
141
|
+
} else if (current && line.startsWith('locked ')) {
|
|
142
|
+
current.locked = true
|
|
143
|
+
current.lockReason = line.slice('locked '.length)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (current) entries.push(current)
|
|
147
|
+
return entries
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Sweep decision for one candidate (all probes pre-run by the caller — this
|
|
152
|
+
* is the pure decision table). Removal requires ALL of: path under the
|
|
153
|
+
* convention dir, `worktree-` branch prefix, not locked, age strictly
|
|
154
|
+
* greater than the window, clean status, nothing unpushed. Locked dsh-cc
|
|
155
|
+
* worktrees older than the window become advisories (dsh-cc NEVER
|
|
156
|
+
* auto-releases locks — no trustworthy cross-process liveness oracle).
|
|
157
|
+
* @param {{
|
|
158
|
+
* underConventionDir: boolean
|
|
159
|
+
* ownedBranch: boolean
|
|
160
|
+
* locked: boolean
|
|
161
|
+
* lockReason: string
|
|
162
|
+
* ageSeconds: number | undefined
|
|
163
|
+
* dirty: boolean | undefined
|
|
164
|
+
* unpushed: boolean | undefined
|
|
165
|
+
* cleanupPeriodDays: number
|
|
166
|
+
* nowSeconds: number
|
|
167
|
+
* }} p
|
|
168
|
+
* @returns {'keep' | 'remove' | 'advisory'}
|
|
169
|
+
*/
|
|
170
|
+
export function sweepDecision(p) {
|
|
171
|
+
const stale = p.ageSeconds !== undefined
|
|
172
|
+
&& p.ageSeconds > p.cleanupPeriodDays * 24 * 60 * 60
|
|
173
|
+
if (p.locked) {
|
|
174
|
+
return stale && p.lockReason.startsWith(DSH_CC_LOCK_PREFIX) ? 'advisory' : 'keep'
|
|
175
|
+
}
|
|
176
|
+
if (!stale) return 'keep'
|
|
177
|
+
if (!p.underConventionDir || !p.ownedBranch) return 'keep'
|
|
178
|
+
if (p.dirty === undefined || p.dirty || p.unpushed === undefined || p.unpushed) return 'keep'
|
|
179
|
+
return 'remove'
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Run the boot-time sweep for a repository. Degrades to a silent no-op on
|
|
184
|
+
* any probe failure (like the pre-existing `git worktree prune`); never
|
|
185
|
+
* touches the network; bounded by `deadline` (default {@link SWEEP_CAP_MS}).
|
|
186
|
+
* @param {{
|
|
187
|
+
* repoRoot: string
|
|
188
|
+
* cleanupPeriodDays: number
|
|
189
|
+
* nowSeconds?: number
|
|
190
|
+
* git: (argv: string[], opts?: { timeoutMs?: number, cwd?: string }) => { status: number | null, stdout: string, stderr?: string } | undefined
|
|
191
|
+
* deadline?: number
|
|
192
|
+
* onAdvisory?: (line: string) => void
|
|
193
|
+
* }} p
|
|
194
|
+
* @returns {{ removed: string[], advisories: string[] }}
|
|
195
|
+
*/
|
|
196
|
+
export function sweepWorktrees(p) {
|
|
197
|
+
const nowSeconds = p.nowSeconds ?? Math.floor(Date.now() / 1000)
|
|
198
|
+
const deadline = p.deadline ?? SWEEP_CAP_MS
|
|
199
|
+
const start = Date.now()
|
|
200
|
+
const expired = () => Date.now() - start >= deadline
|
|
201
|
+
const git = p.git
|
|
202
|
+
const noTime = () => expired()
|
|
203
|
+
const removed = []
|
|
204
|
+
const advisories = []
|
|
205
|
+
let list
|
|
206
|
+
try {
|
|
207
|
+
list = git(['worktree', 'list', '--porcelain'], { cwd: p.repoRoot })
|
|
208
|
+
} catch {
|
|
209
|
+
list = undefined
|
|
210
|
+
}
|
|
211
|
+
if (!list || list.status !== 0) return { removed, advisories }
|
|
212
|
+
if (noTime()) return { removed, advisories }
|
|
213
|
+
for (const entry of parseWorktreeListPorcelain(list.stdout)) {
|
|
214
|
+
if (expired()) break
|
|
215
|
+
const underConventionDir = entry.path.includes(WORKTREES_DIR_SEGMENTS.join('/'))
|
|
216
|
+
const ownedBranch = entry.branch.startsWith(WORKTREE_BRANCH_PREFIX)
|
|
217
|
+
let ageSeconds
|
|
218
|
+
try {
|
|
219
|
+
// PRIMARY age: last commit inside the worktree.
|
|
220
|
+
const lastCommit = git(['-C', entry.path, 'log', '-1', '--format=%ct'], { timeoutMs: 2000, cwd: p.repoRoot })
|
|
221
|
+
const parsed = lastCommit && lastCommit.status === 0
|
|
222
|
+
? Number.parseInt(lastCommit.stdout.trim(), 10)
|
|
223
|
+
: Number.NaN
|
|
224
|
+
if (Number.isNaN(parsed)) throw new Error('unreadable')
|
|
225
|
+
ageSeconds = nowSeconds - parsed
|
|
226
|
+
} catch {
|
|
227
|
+
// FALLBACK age: directory mtime (only when the commit probe is unreadable).
|
|
228
|
+
try {
|
|
229
|
+
ageSeconds = nowSeconds - Math.floor(statSync(entry.path).mtimeMs / 1000)
|
|
230
|
+
} catch {
|
|
231
|
+
ageSeconds = undefined
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// No age at all (both probes failed) → fail closed, skip the entry.
|
|
235
|
+
if (ageSeconds === undefined) continue
|
|
236
|
+
let dirty
|
|
237
|
+
let unpushed
|
|
238
|
+
if (!entry.locked) {
|
|
239
|
+
try {
|
|
240
|
+
const status = git(['-C', entry.path, 'status', '--porcelain'], { timeoutMs: 2000, cwd: p.repoRoot })
|
|
241
|
+
if (!status || status.status !== 0) {
|
|
242
|
+
dirty = undefined
|
|
243
|
+
} else {
|
|
244
|
+
dirty = status.stdout.split('\n').some(line => line.trim().length > 0)
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
dirty = undefined
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const unpushedProbe = git(['-C', entry.path, 'rev-list', '@{u}..HEAD'], { timeoutMs: 2000, cwd: p.repoRoot })
|
|
251
|
+
if (!unpushedProbe || unpushedProbe.status !== 0) {
|
|
252
|
+
unpushed = undefined
|
|
253
|
+
} else {
|
|
254
|
+
unpushed = unpushedProbe.stdout.trim().length > 0
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
unpushed = undefined
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const decision = sweepDecision({
|
|
261
|
+
underConventionDir,
|
|
262
|
+
ownedBranch,
|
|
263
|
+
locked: entry.locked,
|
|
264
|
+
lockReason: entry.lockReason,
|
|
265
|
+
ageSeconds,
|
|
266
|
+
dirty,
|
|
267
|
+
unpushed,
|
|
268
|
+
cleanupPeriodDays: p.cleanupPeriodDays,
|
|
269
|
+
nowSeconds,
|
|
270
|
+
})
|
|
271
|
+
if (decision === 'advisory') {
|
|
272
|
+
const line = `dsh-cc: stale session lock (age > ${p.cleanupPeriodDays}d) on ${entry.path} — run: git worktree unlock ${entry.path}`
|
|
273
|
+
advisories.push(line)
|
|
274
|
+
p.onAdvisory?.(line)
|
|
275
|
+
} else if (decision === 'remove') {
|
|
276
|
+
const remove = git(['worktree', 'remove', '--force', entry.path], { timeoutMs: 4000, cwd: p.repoRoot })
|
|
277
|
+
if (!remove || remove.status !== 0) continue
|
|
278
|
+
git(['branch', '-D', entry.branch], { timeoutMs: 2000, cwd: p.repoRoot })
|
|
279
|
+
removed.push(entry.path)
|
|
280
|
+
p.onAdvisory?.(`dsh-cc: swept stale worktree ${entry.path} (branch ${entry.branch})`)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { removed, advisories }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// --- name-reuse merged reset ---------------------------------------------------
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Reuse-reset predicate for the launcher's named-reuse path (WS-4, CC
|
|
290
|
+
* merged-reset rule). Pure over pre-run probes. Reset requires ALL of:
|
|
291
|
+
* clean target, still on its `worktree-` branch, and either no own commits
|
|
292
|
+
* beyond the base, OR the upstream is gone AND every own commit is
|
|
293
|
+
* reachable from the resolved fresh base. ANY unverifiable probe keeps the
|
|
294
|
+
* old tip. A `source` other than 'name' (WS-6 PR reuse) never resets.
|
|
295
|
+
* @param {{
|
|
296
|
+
* source?: 'name' | string
|
|
297
|
+
* clean: boolean | undefined
|
|
298
|
+
* ownedBranch: boolean
|
|
299
|
+
* ownCommits: number | undefined
|
|
300
|
+
* upstreamGone: boolean | undefined
|
|
301
|
+
* mergedIntoFreshBase: boolean | undefined
|
|
302
|
+
* }} p
|
|
303
|
+
* @returns {'reset' | 'keep-tip'}
|
|
304
|
+
*/
|
|
305
|
+
export function reuseResetDecision(p) {
|
|
306
|
+
if (p.source !== 'name') return 'keep-tip'
|
|
307
|
+
if (p.clean === undefined || !p.clean) return 'keep-tip'
|
|
308
|
+
if (!p.ownedBranch) return 'keep-tip'
|
|
309
|
+
if (p.ownCommits === undefined) return 'keep-tip'
|
|
310
|
+
if (p.ownCommits === 0) return 'reset'
|
|
311
|
+
if (p.upstreamGone === true && p.mergedIntoFreshBase === true) return 'reset'
|
|
312
|
+
return 'keep-tip'
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Probe a reused worktree and (when the predicate holds) hard-reset it to
|
|
317
|
+
* the resolved fresh base before handover. Every probe failure degrades to
|
|
318
|
+
* keep-tip; the reset itself failing also degrades (worktree is still
|
|
319
|
+
* handed over as-is).
|
|
320
|
+
* @param {{
|
|
321
|
+
* plan: { worktreePath: string, branch: string }
|
|
322
|
+
* repoRoot: string
|
|
323
|
+
* freshBase: string
|
|
324
|
+
* source?: string
|
|
325
|
+
* git: (argv: string[], opts?: { timeoutMs?: number, cwd?: string }) => { status: number | null, stdout: string } | undefined
|
|
326
|
+
* onReset?: (freshBase: string) => void
|
|
327
|
+
* }} p
|
|
328
|
+
* @returns {{ action: 'reset' | 'keep-tip' }}
|
|
329
|
+
*/
|
|
330
|
+
export function worktreeReuseReset(p) {
|
|
331
|
+
const git = p.git
|
|
332
|
+
const probe = (argv) => {
|
|
333
|
+
try {
|
|
334
|
+
const r = git(argv, { timeoutMs: 2000, cwd: p.repoRoot })
|
|
335
|
+
return r && r.status === 0 ? r : undefined
|
|
336
|
+
} catch {
|
|
337
|
+
return undefined
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const status = probe(['-C', p.plan.worktreePath, 'status', '--porcelain'])
|
|
341
|
+
const clean = status !== undefined ? status.stdout.trim().length === 0 : undefined
|
|
342
|
+
const headBranch = probe(['-C', p.plan.worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD'])
|
|
343
|
+
const ownedBranch = headBranch !== undefined && headBranch.stdout.trim() === p.plan.branch
|
|
344
|
+
&& p.plan.branch.startsWith(WORKTREE_BRANCH_PREFIX)
|
|
345
|
+
// Own commits: everything on the branch not reachable from the worktree's
|
|
346
|
+
// recorded base. The branch was created with -B from a base commit; treat
|
|
347
|
+
// the fresh base as the reference point.
|
|
348
|
+
let ownCommits
|
|
349
|
+
if (ownedBranch) {
|
|
350
|
+
const count = probe(['-C', p.plan.worktreePath, 'rev-list', '--count', `${p.freshBase}..HEAD`])
|
|
351
|
+
ownCommits = count !== undefined ? Number.parseInt(count.stdout.trim(), 10) : undefined
|
|
352
|
+
if (Number.isNaN(ownCommits)) ownCommits = undefined
|
|
353
|
+
}
|
|
354
|
+
const upstream = probe(['-C', p.plan.worktreePath, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])
|
|
355
|
+
const upstreamGone = upstream === undefined
|
|
356
|
+
? undefined
|
|
357
|
+
: !probe(['-C', p.plan.worktreePath, 'rev-parse', '--verify', '--quiet', `${upstream.stdout.trim()}^{}`])
|
|
358
|
+
let mergedIntoFreshBase
|
|
359
|
+
if (ownCommits !== undefined && ownCommits > 0 && upstreamGone === true) {
|
|
360
|
+
const merged = probe(['-C', p.plan.worktreePath, 'merge-base', '--is-ancestor', 'HEAD', p.freshBase])
|
|
361
|
+
mergedIntoFreshBase = merged !== undefined
|
|
362
|
+
}
|
|
363
|
+
const action = reuseResetDecision({
|
|
364
|
+
source: p.source,
|
|
365
|
+
clean,
|
|
366
|
+
ownedBranch,
|
|
367
|
+
ownCommits,
|
|
368
|
+
upstreamGone,
|
|
369
|
+
mergedIntoFreshBase,
|
|
370
|
+
})
|
|
371
|
+
if (action === 'reset') {
|
|
372
|
+
const reset = probe(['-C', p.plan.worktreePath, 'reset', '--hard', p.freshBase])
|
|
373
|
+
if (reset !== undefined) {
|
|
374
|
+
p.onReset?.(p.freshBase)
|
|
375
|
+
return { action: 'reset' }
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return { action: 'keep-tip' }
|
|
379
|
+
}
|