@searls/turbocommit 0.14.1 → 0.15.1

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/lib/harness.js CHANGED
@@ -75,6 +75,7 @@ function normalizeHookInput (input, event, forcedHarness) {
75
75
  transcriptPath: hookInput.transcript_path,
76
76
  toolName: hookInput.tool_name,
77
77
  toolInput: hookInput.tool_input || {},
78
+ toolUseId: hookInput.tool_use_id,
78
79
  cwd: hookInput.cwd,
79
80
  model: hookInput.model,
80
81
  source: hookInput.source,
@@ -85,6 +86,7 @@ function normalizeHookInput (input, event, forcedHarness) {
85
86
  function eventName (event) {
86
87
  const names = {
87
88
  'pre-tool-use': 'PreToolUse',
89
+ 'post-tool-use': 'PostToolUse',
88
90
  'session-start': 'SessionStart',
89
91
  'session-end': 'SessionEnd',
90
92
  'pre-compact': 'PreCompact',
package/lib/install.js CHANGED
@@ -10,11 +10,19 @@ const HOOK_DEFS = {
10
10
  matcher: 'Write|Edit|MultiEdit|NotebookEdit|Bash|mcp__.*',
11
11
  hooks: [{ type: 'command', command: 'turbocommit hook pre-tool-use --harness claude' }]
12
12
  },
13
+ PostToolUse: {
14
+ matcher: 'Bash',
15
+ hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness claude' }]
16
+ },
17
+ PostToolUseFailure: {
18
+ matcher: 'Bash',
19
+ hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness claude' }]
20
+ },
13
21
  SessionStart: {
14
22
  hooks: [{ type: 'command', command: 'turbocommit hook session-start --harness claude' }]
15
23
  },
16
24
  SessionEnd: {
17
- hooks: [{ type: 'command', command: 'turbocommit hook session-end --harness claude' }]
25
+ hooks: [{ type: 'command', command: 'turbocommit hook session-end --harness claude', timeout: 60 }]
18
26
  },
19
27
  Stop: {
20
28
  hooks: [{ type: 'command', command: 'turbocommit hook stop --harness claude' }]
@@ -26,10 +34,17 @@ const CODEX_HOOK_DEFS = {
26
34
  matcher: 'apply_patch|Write|Edit|MultiEdit|NotebookEdit|Bash|mcp__.*',
27
35
  hooks: [{ type: 'command', command: 'turbocommit hook pre-tool-use --harness codex' }]
28
36
  },
37
+ PostToolUse: {
38
+ matcher: 'Bash',
39
+ hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness codex' }]
40
+ },
29
41
  SessionStart: {
30
42
  matcher: 'resume|clear',
31
43
  hooks: [{ type: 'command', command: 'turbocommit hook session-start --harness codex' }]
32
44
  },
45
+ SessionEnd: {
46
+ hooks: [{ type: 'command', command: 'turbocommit hook session-end --harness codex', timeout: 3 }]
47
+ },
33
48
  PreCompact: {
34
49
  matcher: 'manual|auto',
35
50
  hooks: [{ type: 'command', command: 'turbocommit hook pre-compact --harness codex' }]
@@ -63,11 +78,12 @@ function hasTurbocommit (groups) {
63
78
  */
64
79
  function hasExactHooks (groups, def) {
65
80
  if (!Array.isArray(groups)) return false
66
- const expected = def.hooks.map(h => h.command)
67
- return expected.every(cmd =>
81
+ return def.hooks.every(expected =>
68
82
  groups.some(g => {
69
83
  const hooks = g && g.hooks ? g.hooks : []
70
- return hooks.some(h => h.command === cmd)
84
+ const matcherMatches = def.matcher === undefined || g.matcher === def.matcher
85
+ return matcherMatches && hooks.some(actual =>
86
+ Object.entries(expected).every(([key, value]) => actual[key] === value))
71
87
  })
72
88
  )
73
89
  }
@@ -99,8 +115,7 @@ function removeTurbocommitHooks (groups) {
99
115
  }
100
116
 
101
117
  /**
102
- * Install turbocommit hooks for all 4 events (PreToolUse, SessionStart,
103
- * SessionEnd, Stop). Each event gets its own group at the end.
118
+ * Install every turbocommit hook event. Each event gets its own group at the end.
104
119
  * Cleans up stale entries (including old `turbocommit run`) on install.
105
120
  */
106
121
  function installClaude (settingsPath) {
package/lib/multi.js CHANGED
@@ -3,7 +3,7 @@ const fs = require('fs')
3
3
  const path = require('path')
4
4
  const { ensureDir, loadJson } = require('./io')
5
5
  const { turbocommitDir } = require('./session')
6
- const { gitRoot, gitRootForPath, isGitlink } = require('./git')
6
+ const { canonicalRoot, canonicalTrackedPath, gitRoot, gitRootForPath, gitWorktrees, hasPathChanges, isGitlink } = require('./git')
7
7
  const { activeConfig } = require('./config')
8
8
 
9
9
  function checkoutKey (root) {
@@ -15,13 +15,16 @@ function manifestPath (anchor) {
15
15
  }
16
16
 
17
17
  function loadManifest (anchor) {
18
- return loadJson(manifestPath(anchor)) || { nextOrder: 0, repos: [] }
18
+ return normalizeManifest(loadJson(manifestPath(anchor)))
19
19
  }
20
20
 
21
21
  function saveManifest (anchor, manifest) {
22
22
  const file = manifestPath(anchor)
23
23
  ensureDir(path.dirname(file))
24
- fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + '\n')
24
+ fs.writeFileSync(file, JSON.stringify({
25
+ nextOrder: manifest.nextOrder,
26
+ repos: manifest.repos
27
+ }, null, 2) + '\n')
25
28
  }
26
29
 
27
30
  function deleteManifestIfEmpty (anchor, manifest) {
@@ -34,41 +37,91 @@ function deleteManifestIfEmpty (anchor, manifest) {
34
37
  } catch {}
35
38
  }
36
39
 
37
- function rootsFromEntries (anchor, entries) {
38
- const roots = []
39
- const seen = new Set()
40
- const add = root => {
41
- if (!root) return
42
- let real
43
- try {
44
- real = fs.realpathSync(root)
45
- } catch {
46
- return
47
- }
48
- if (seen.has(real)) return
49
- if (activeConfig(real).config.enabled !== true) return
50
- seen.add(real)
51
- roots.push(real)
40
+ function trackedChangesFromEntries (anchor, entries) {
41
+ const pathsByRoot = new Map()
42
+ const ambiguous = []
43
+ const add = (root, file) => {
44
+ root = canonicalRoot(root)
45
+ file = canonicalTrackedPath(file)
46
+ if (!root || activeConfig(root).config.enabled !== true) return
47
+ const relative = path.relative(root, file)
48
+ if (!relative || relative.startsWith('..')) return
49
+ if (!pathsByRoot.has(root)) pathsByRoot.set(root, new Set())
50
+ pathsByRoot.get(root).add(path.join(root, relative))
52
51
  }
53
52
 
54
53
  for (const entry of entries) {
55
- if (entry.tool === 'Bash') continue
56
- if (Array.isArray(entry.files) && entry.files.length > 0) {
57
- for (const file of entry.files) add(gitRootForPath(file) || anchor)
58
- } else {
59
- add(anchor)
54
+ const rawFiles = Array.isArray(entry.rawFiles) && entry.rawFiles.length > 0
55
+ ? entry.rawFiles
56
+ : Array.isArray(entry.files) ? entry.files : []
57
+ if (entry.tool === 'Bash' && rawFiles.length === 0) continue
58
+ if (rawFiles.length === 0) continue
59
+
60
+ for (const rawFile of rawFiles) {
61
+ const candidates = changedCandidates(anchor, entry.cwd || anchor, rawFile, entry.tool)
62
+ if (candidates.length > 1) {
63
+ ambiguous.push({ tool: entry.tool, path: rawFile, reason: 'multiple-worktrees' })
64
+ } else if (candidates.length === 1) {
65
+ add(candidates[0].root, candidates[0].file)
66
+ }
60
67
  }
61
68
  }
62
- return addEnabledSuperprojects(roots, add)
69
+
70
+ addEnabledSuperprojects(pathsByRoot, add)
71
+ const repos = [...pathsByRoot].map(([root, paths]) => ({ root, paths: [...paths] }))
72
+ return { repos, ambiguous }
73
+ }
74
+
75
+ function changedCandidates (anchor, cwd, rawFile, tool) {
76
+ if (path.isAbsolute(rawFile)) return changedCandidate(rawFile)
77
+
78
+ anchor = canonicalRoot(anchor)
79
+ const direct = canonicalTrackedPath(path.resolve(cwd, rawFile))
80
+ const directRoot = gitRootForPath(direct)
81
+ if (!isOpaquePathTool(tool) || (directRoot && directRoot !== anchor)) {
82
+ return changedCandidate(direct)
83
+ }
84
+
85
+ const relative = path.relative(anchor, direct)
86
+ if (!relative || relative.startsWith('..')) return changedCandidate(direct)
87
+ const candidates = []
88
+ const seen = new Set()
89
+ for (const worktree of gitWorktrees(anchor)) {
90
+ const file = canonicalTrackedPath(path.join(worktree, relative))
91
+ if (isDirectoryPath(file)) continue
92
+ const root = gitRootForPath(file)
93
+ if (!root || seen.has(root) || activeConfig(root).config.enabled !== true) continue
94
+ seen.add(root)
95
+ if (hasPathChanges(root, file)) candidates.push({ root, file })
96
+ }
97
+ return candidates
98
+ }
99
+
100
+ function isOpaquePathTool (tool) {
101
+ return typeof tool === 'string' && tool.startsWith('mcp__')
63
102
  }
64
103
 
65
- function addEnabledSuperprojects (roots, add) {
66
- for (let i = 0; i < roots.length; i++) {
67
- const child = roots[i]
104
+ function changedCandidate (file) {
105
+ if (isDirectoryPath(file)) return []
106
+ file = canonicalTrackedPath(file)
107
+ const root = gitRootForPath(file)
108
+ if (!root || activeConfig(root).config.enabled !== true) return []
109
+ return hasPathChanges(root, file) ? [{ root, file }] : []
110
+ }
111
+
112
+ function isDirectoryPath (file) {
113
+ try {
114
+ return fs.statSync(file).isDirectory()
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
120
+ function addEnabledSuperprojects (pathsByRoot, add) {
121
+ for (const child of [...pathsByRoot.keys()]) {
68
122
  const parent = gitRoot(path.dirname(child))
69
- if (parent && parent !== child && isGitlink(parent, child)) add(parent)
123
+ if (parent && parent !== child && isGitlink(parent, child)) add(parent, child)
70
124
  }
71
- return roots
72
125
  }
73
126
 
74
127
  function dependencyOrder (repos) {
@@ -89,6 +142,29 @@ function appendWork (manifest, root, work) {
89
142
  return repo
90
143
  }
91
144
 
145
+ function normalizeManifest (stored) {
146
+ const manifest = stored && typeof stored === 'object' ? stored : {}
147
+ const repos = Array.isArray(manifest.repos) ? manifest.repos : []
148
+ let discardedLegacyWork = 0
149
+ const normalizedRepos = []
150
+
151
+ for (const repo of repos) {
152
+ if (!repo || typeof repo.root !== 'string' || !Array.isArray(repo.work)) continue
153
+ const work = repo.work.filter(item => {
154
+ const safe = item && Array.isArray(item.paths) && item.paths.length > 0
155
+ if (!safe) discardedLegacyWork++
156
+ return safe
157
+ })
158
+ if (work.length > 0) normalizedRepos.push({ ...repo, work })
159
+ }
160
+
161
+ return {
162
+ nextOrder: Number.isInteger(manifest.nextOrder) ? manifest.nextOrder : 0,
163
+ repos: normalizedRepos,
164
+ discardedLegacyWork
165
+ }
166
+ }
167
+
92
168
  function multiWatermarkPath (root, sessionId) {
93
169
  return path.join(turbocommitDir(root), 'multi-watermarks', checkoutKey(root), sessionId + '.json')
94
170
  }
@@ -115,7 +191,7 @@ module.exports = {
115
191
  loadManifest,
116
192
  manifestPath,
117
193
  readMultiWatermark,
118
- rootsFromEntries,
194
+ trackedChangesFromEntries,
119
195
  saveManifest,
120
196
  saveMultiWatermark
121
197
  }
package/lib/rescue.js ADDED
@@ -0,0 +1,308 @@
1
+ const crypto = require('crypto')
2
+ const { execFileSync } = require('child_process')
3
+ const fs = require('fs')
4
+ const os = require('os')
5
+ const path = require('path')
6
+ const { gitCommonDir } = require('./git')
7
+ const { ensureDir } = require('./io')
8
+
9
+ function createSessionEndRescue (root, hookInput) {
10
+ const gitDir = gitCommonDir(root)
11
+ if (!gitDir || !hookInput?.sessionId) return null
12
+ const id = crypto.randomUUID()
13
+ const ref = `refs/turbocommit/session-end-rescues/${id}`
14
+ const stateDir = path.join(gitDir, 'turbocommit', 'session-end-rescues')
15
+ const index = path.join(stateDir, `${id}.index`)
16
+ const recordPath = path.join(stateDir, `${id}.json`)
17
+ const env = { ...process.env, GIT_INDEX_FILE: index }
18
+ let checkoutIdentity
19
+ try {
20
+ ensureDir(stateDir)
21
+ const rawIndex = gitOutput(root, ['rev-parse', '--git-path', 'index'])
22
+ const realIndex = path.isAbsolute(rawIndex) ? rawIndex : path.resolve(root, rawIndex)
23
+ if (realIndex && fs.existsSync(realIndex)) {
24
+ fs.copyFileSync(realIndex, index)
25
+ } else {
26
+ gitOutput(root, ['read-tree', 'HEAD'], { env })
27
+ }
28
+ gitOutput(root, ['add', '-A'], { env })
29
+ const tree = gitOutput(root, ['write-tree'], { env })
30
+ const head = gitOutput(root, ['rev-parse', 'HEAD'])
31
+ const branch = gitOutput(root, ['branch', '--show-current'])
32
+ const commit = gitOutput(root, ['commit-tree', tree, '-p', head, '-m', 'turbocommit SessionEnd rescue'])
33
+ gitOutput(root, ['update-ref', ref, commit, '0'.repeat(commit.length)])
34
+ checkoutIdentity = createCheckoutIdentity(root, id)
35
+ if (!checkoutIdentity) throw new Error('Could not identify rescued checkout')
36
+ const rescue = {
37
+ id,
38
+ ref,
39
+ commit,
40
+ head,
41
+ branch: branch || null,
42
+ root,
43
+ gitDir,
44
+ checkoutIdentity,
45
+ recordPath,
46
+ hookInput
47
+ }
48
+ writeRecord(recordPath, rescue)
49
+ return rescue
50
+ } catch {
51
+ try { gitOutput(root, ['update-ref', '-d', ref]) } catch {}
52
+ cleanupCheckoutIdentity(checkoutIdentity)
53
+ try { fs.unlinkSync(recordPath) } catch {}
54
+ return null
55
+ } finally {
56
+ try { fs.unlinkSync(index) } catch {}
57
+ }
58
+ }
59
+
60
+ function restoreSessionEndRescue (rescue) {
61
+ if (!rescue?.gitDir || !rescue.root || !rescue.commit) return false
62
+ if (fs.existsSync(rescue.root)) return false
63
+ let created = false
64
+ try {
65
+ ensureDir(path.dirname(rescue.root))
66
+ const args = ['--git-dir', rescue.gitDir, 'worktree', 'add', '--force', '--detach', rescue.root, rescue.head]
67
+ execFileSync('git', args, { cwd: stableGitCwd(rescue.gitDir), stdio: 'ignore' })
68
+ created = true
69
+ gitOutput(rescue.root, ['restore', '--source', rescue.commit, '--worktree', '--', '.'])
70
+ return true
71
+ } catch {
72
+ if (created) removeSessionEndRescueWorktree(rescue)
73
+ return false
74
+ }
75
+ }
76
+
77
+ function matchesSessionEndRescueRoot (rescue, root) {
78
+ if (!rescue?.checkoutIdentity || !root) return false
79
+ try {
80
+ if (canonicalExistingPath(gitCommonDir(root)) !== canonicalExistingPath(rescue.gitDir)) return false
81
+ if (gitOutput(root, ['rev-parse', 'HEAD']) !== rescue.head) return false
82
+ if ((gitOutput(root, ['branch', '--show-current']) || null) !== rescue.branch) return false
83
+ return sameCheckoutIdentity(readCheckoutIdentity(root), rescue.checkoutIdentity) &&
84
+ hasCheckoutIdentityMarker(rescue.checkoutIdentity)
85
+ } catch {
86
+ return false
87
+ }
88
+ }
89
+
90
+ function finalizeRestoredSessionEndRescue (rescue, { clean = false } = {}) {
91
+ const recovered = preserveSessionEndRecoveredCommit(rescue, rescue.root)
92
+ if (!recovered) return { resolved: false, preserved: false }
93
+ const { recoveredCommit, recoveredRef } = recovered
94
+
95
+ if (!clean) {
96
+ recordPreservedSessionEndRescue(rescue, recovered, 'dirty-recovery-worktree')
97
+ return { resolved: false, preserved: true, recoveredCommit, recoveredRef, removeWorktree: false }
98
+ }
99
+
100
+ if (!rescue.branch) {
101
+ recordPreservedSessionEndRescue(rescue, recovered, 'detached-recovered')
102
+ return { resolved: false, preserved: true, recoveredCommit, recoveredRef, removeWorktree: true }
103
+ }
104
+
105
+ if (!removeSessionEndRescueWorktree(rescue)) {
106
+ recordPreservedSessionEndRescue(rescue, recovered, 'restore-worktree-removal-failed')
107
+ return { resolved: false, preserved: true, recoveredCommit, recoveredRef, removeWorktree: false }
108
+ }
109
+
110
+ let attached = false
111
+ try {
112
+ execFileSync('git', ['--git-dir', rescue.gitDir, 'worktree', 'add', rescue.root, rescue.branch], {
113
+ cwd: stableGitCwd(rescue.gitDir),
114
+ stdio: 'ignore'
115
+ })
116
+ attached = true
117
+ if (gitOutput(rescue.root, ['rev-parse', 'HEAD']) !== rescue.head) throw new Error('Branch advanced')
118
+ if (gitOutput(rescue.root, ['branch', '--show-current']) !== rescue.branch) throw new Error('Wrong branch')
119
+ if (!isClean(rescue.root)) throw new Error('Attached worktree is dirty')
120
+ gitOutput(rescue.root, ['merge', '--ff-only', recoveredRef])
121
+ if (gitOutput(rescue.root, ['rev-parse', 'HEAD']) !== recoveredCommit) throw new Error('Branch did not advance')
122
+ return { resolved: true, preserved: true, recoveredCommit, recoveredRef, attached: true }
123
+ } catch {
124
+ if (attached) removeSessionEndRescueWorktree(rescue)
125
+ const status = branchAtCapturedHead(rescue) ? 'branch-unavailable' : 'branch-advanced'
126
+ recordPreservedSessionEndRescue(rescue, recovered, status)
127
+ return { resolved: false, preserved: true, recoveredCommit, recoveredRef, removeWorktree: false }
128
+ }
129
+ }
130
+
131
+ function recordPreservedSessionEndRescue (rescue, recovered, status) {
132
+ try {
133
+ writeRecord(rescue.recordPath, {
134
+ ...rescue,
135
+ ...recovered,
136
+ status
137
+ })
138
+ } catch {}
139
+ }
140
+
141
+ function branchAtCapturedHead (rescue) {
142
+ if (!rescue?.branch || !rescue.gitDir || !rescue.head) return false
143
+ try {
144
+ return gitOutput(stableGitCwd(rescue.gitDir), [
145
+ '--git-dir', rescue.gitDir, 'rev-parse', '--verify', `refs/heads/${rescue.branch}`
146
+ ]) === rescue.head
147
+ } catch {
148
+ return false
149
+ }
150
+ }
151
+
152
+ function preserveSessionEndRecoveredCommit (rescue, root) {
153
+ if (!rescue?.gitDir || !rescue.id || !root) return null
154
+ try {
155
+ const recoveredCommit = gitOutput(root, ['rev-parse', 'HEAD'])
156
+ const recoveredRef = `refs/turbocommit/session-end-recovered/${rescue.id}`
157
+ let current = null
158
+ try {
159
+ current = gitOutput(stableGitCwd(rescue.gitDir), [
160
+ '--git-dir', rescue.gitDir, 'rev-parse', '--verify', recoveredRef
161
+ ])
162
+ } catch {}
163
+ if (current && current !== recoveredCommit) return null
164
+ if (!current) {
165
+ execFileSync('git', [
166
+ '--git-dir', rescue.gitDir,
167
+ 'update-ref', recoveredRef, recoveredCommit, '0'.repeat(recoveredCommit.length)
168
+ ], {
169
+ cwd: stableGitCwd(rescue.gitDir),
170
+ stdio: 'ignore'
171
+ })
172
+ }
173
+ return { recoveredCommit, recoveredRef }
174
+ } catch {
175
+ return null
176
+ }
177
+ }
178
+
179
+ function removeSessionEndRescueWorktree (rescue, { force = false } = {}) {
180
+ if (!rescue?.gitDir || !rescue.root) return false
181
+ try {
182
+ const args = ['--git-dir', rescue.gitDir, 'worktree', 'remove']
183
+ if (force) args.push('--force')
184
+ args.push(rescue.root)
185
+ execFileSync('git', args, {
186
+ cwd: stableGitCwd(rescue.gitDir),
187
+ stdio: 'ignore'
188
+ })
189
+ } catch {}
190
+ return !fs.existsSync(rescue.root)
191
+ }
192
+
193
+ function cleanupSessionEndRescue (rescue, { removeWorktree = false } = {}) {
194
+ if (!rescue) return false
195
+ if (removeWorktree && !removeSessionEndRescueWorktree(rescue)) return false
196
+ if (rescue.gitDir && rescue.ref && rescue.commit) {
197
+ try {
198
+ execFileSync('git', ['--git-dir', rescue.gitDir, 'update-ref', '-d', rescue.ref, rescue.commit], {
199
+ cwd: stableGitCwd(rescue.gitDir),
200
+ stdio: 'ignore'
201
+ })
202
+ } catch {}
203
+ }
204
+ cleanupCheckoutIdentity(rescue.checkoutIdentity)
205
+ try { fs.unlinkSync(rescue.recordPath) } catch {}
206
+ return true
207
+ }
208
+
209
+ function isClean (root) {
210
+ try {
211
+ return gitOutput(root, ['status', '--porcelain']) === ''
212
+ } catch {
213
+ return false
214
+ }
215
+ }
216
+
217
+ function writeRecord (file, rescue) {
218
+ const temporary = file + `.${process.pid}.${crypto.randomUUID()}.tmp`
219
+ try {
220
+ fs.writeFileSync(temporary, JSON.stringify(rescue) + '\n')
221
+ fs.renameSync(temporary, file)
222
+ } finally {
223
+ try { fs.unlinkSync(temporary) } catch {}
224
+ }
225
+ }
226
+
227
+ function readCheckoutIdentity (root) {
228
+ try {
229
+ const rootStat = fs.statSync(root, { bigint: true })
230
+ const rawGitDir = gitOutput(root, ['rev-parse', '--git-dir'])
231
+ const worktreeGitDir = canonicalExistingPath(path.isAbsolute(rawGitDir) ? rawGitDir : path.resolve(root, rawGitDir))
232
+ const gitDirStat = fs.statSync(worktreeGitDir, { bigint: true })
233
+ return {
234
+ rootDevice: rootStat.dev.toString(),
235
+ rootInode: rootStat.ino.toString(),
236
+ worktreeGitDir,
237
+ gitDirDevice: gitDirStat.dev.toString(),
238
+ gitDirInode: gitDirStat.ino.toString()
239
+ }
240
+ } catch {
241
+ return null
242
+ }
243
+ }
244
+
245
+ function createCheckoutIdentity (root, id) {
246
+ const identity = readCheckoutIdentity(root)
247
+ if (!identity) return null
248
+ const markerDir = path.join(identity.worktreeGitDir, 'turbocommit-rescue-identities')
249
+ const markerPath = path.join(markerDir, `${id}.marker`)
250
+ const markerToken = crypto.randomUUID()
251
+ ensureDir(markerDir)
252
+ fs.writeFileSync(markerPath, markerToken + '\n', { flag: 'wx' })
253
+ return { ...identity, markerPath, markerToken }
254
+ }
255
+
256
+ function hasCheckoutIdentityMarker (identity) {
257
+ if (!identity?.markerPath || !identity.markerToken) return false
258
+ try {
259
+ return fs.readFileSync(identity.markerPath, 'utf8').trim() === identity.markerToken
260
+ } catch {
261
+ return false
262
+ }
263
+ }
264
+
265
+ function cleanupCheckoutIdentity (identity) {
266
+ if (!identity?.markerPath) return
267
+ try { fs.unlinkSync(identity.markerPath) } catch {}
268
+ try { fs.rmdirSync(path.dirname(identity.markerPath)) } catch {}
269
+ }
270
+
271
+ function sameCheckoutIdentity (actual, expected) {
272
+ if (!actual || !expected) return false
273
+ return actual.rootDevice === expected.rootDevice &&
274
+ actual.rootInode === expected.rootInode &&
275
+ actual.worktreeGitDir === expected.worktreeGitDir &&
276
+ actual.gitDirDevice === expected.gitDirDevice &&
277
+ actual.gitDirInode === expected.gitDirInode
278
+ }
279
+
280
+ function canonicalExistingPath (filePath) {
281
+ return fs.realpathSync(path.resolve(filePath))
282
+ }
283
+
284
+ function gitOutput (cwd, args, opts = {}) {
285
+ return execFileSync('git', args, {
286
+ cwd,
287
+ env: opts.env || process.env,
288
+ encoding: 'utf8',
289
+ stdio: ['ignore', 'pipe', 'ignore']
290
+ }).trim()
291
+ }
292
+
293
+ function stableGitCwd (gitDir) {
294
+ const parent = path.dirname(gitDir)
295
+ return fs.existsSync(parent) ? parent : os.tmpdir()
296
+ }
297
+
298
+ module.exports = {
299
+ createSessionEndRescue,
300
+ restoreSessionEndRescue,
301
+ matchesSessionEndRescueRoot,
302
+ finalizeRestoredSessionEndRescue,
303
+ preserveSessionEndRecoveredCommit,
304
+ recordPreservedSessionEndRescue,
305
+ removeSessionEndRescueWorktree,
306
+ cleanupSessionEndRescue,
307
+ isClean
308
+ }