@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/README.md +59 -14
- package/cli.js +250 -6
- package/lib/agent.js +10 -6
- package/lib/git.js +161 -7
- package/lib/harness.js +2 -0
- package/lib/install.js +21 -6
- package/lib/multi.js +106 -30
- package/lib/rescue.js +308 -0
- package/lib/run.js +215 -35
- package/lib/session.js +40 -1
- package/lib/track.js +849 -27
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
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))
|
|
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(
|
|
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
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
const add = root => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
+
}
|