@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 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: b3521cecfa66e117b0665e12d454b7ad5982000f
6
- README.zh.md: 3d47cfad50f2974b166ac85880eec3d7b1ee8039
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
- const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' })
69
- if (top.error || top.status !== 0) {
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 = top.stdout.trim()
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
- const named = worktree.name !== null
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
- Object.assign(env0, worktreeEnv(plan, repoRoot, head.stdout.trim()))
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 { join } from 'node:path'
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
- return ['worktree', 'add', '-B', plan.branch, plan.worktreePath, 'HEAD']
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.6.3",
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
+ }