@searls/turbocommit 0.15.0 → 0.15.2
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 +33 -11
- package/cli.js +247 -6
- package/lib/agent.js +10 -6
- package/lib/git.js +9 -5
- package/lib/install.js +11 -4
- package/lib/rescue.js +308 -0
- package/lib/run.js +128 -13
- package/lib/session.js +40 -1
- package/lib/track.js +679 -48
- package/package.json +1 -1
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
|
+
}
|
package/lib/run.js
CHANGED
|
@@ -4,10 +4,25 @@ const path = require('path')
|
|
|
4
4
|
const { loadJson } = require('./io')
|
|
5
5
|
const { parseTranscript, parseCodexTranscript, formatBody, formatTitleTranscript, extractHeadline, extractModel, extractCodexModel } = require('./transcript')
|
|
6
6
|
const { runTitleAgent, runBodyAgent } = require('./agent')
|
|
7
|
-
const { gitRoot, hasChanges, changedPaths, addAndCommit, stagePaths, commitPaths, commitStagedPaths, stagedChangeContext, currentBranch, pushClean } = require('./git')
|
|
7
|
+
const { gitRoot, hasChanges, changedPaths, addAndCommit, stagePaths, commitPaths, commitStagedPaths, stagedChangeContext, currentBranch, pushClean, hasRepositoryOperation } = require('./git')
|
|
8
8
|
const { logEvent } = require('./log')
|
|
9
9
|
const { wrapText } = require('./wrap')
|
|
10
|
-
const {
|
|
10
|
+
const {
|
|
11
|
+
hasTrackedModifications,
|
|
12
|
+
cleanupTracking,
|
|
13
|
+
readTracking,
|
|
14
|
+
finalizeBashSnapshots,
|
|
15
|
+
recordBashSessionStop,
|
|
16
|
+
readReadyBashOverlaps,
|
|
17
|
+
pruneBashSnapshots,
|
|
18
|
+
compactBashOverlapEvents,
|
|
19
|
+
resolveBashOverlap,
|
|
20
|
+
invalidateBashOverlap,
|
|
21
|
+
hasActiveBashSnapshot,
|
|
22
|
+
acquireBashOverlapRecoveryLock,
|
|
23
|
+
fingerprintTrackedPath,
|
|
24
|
+
claimedPathsBySessions
|
|
25
|
+
} = require('./track')
|
|
11
26
|
const { redact, buildRedactions } = require('./redact')
|
|
12
27
|
const { handleSessionEnd, getAncestors, savePending, collectPending, cleanupConsumed, cleanupStale, readWatermark, saveWatermark, resolveParentCommit } = require('./session')
|
|
13
28
|
const { activeConfig } = require('./config')
|
|
@@ -108,8 +123,34 @@ function run (input, opts = {}) {
|
|
|
108
123
|
if (!hookInput) return
|
|
109
124
|
|
|
110
125
|
const root = gitRoot(hookInput.cwd || process.cwd())
|
|
111
|
-
if (!root
|
|
126
|
+
if (!root) return
|
|
127
|
+
const { config } = activeConfig(root)
|
|
128
|
+
if (config.enabled !== true) return
|
|
129
|
+
const recoveryDeadline = opts.recoveryDeadline ?? recoveryDeadlineFromWait(opts.recoveryWaitMs ?? 5000)
|
|
130
|
+
|
|
131
|
+
if (hookInput.sessionId) {
|
|
132
|
+
const finalized = finalizeBashSnapshots(root, hookInput.sessionId, Date.now(), remainingRecoveryWait(recoveryDeadline))
|
|
133
|
+
if (finalized == null) return
|
|
134
|
+
recordBashSessionStop(root, hookInput.sessionId)
|
|
135
|
+
}
|
|
112
136
|
|
|
137
|
+
try {
|
|
138
|
+
return runTrackedStop(hookInput, root, opts)
|
|
139
|
+
} finally {
|
|
140
|
+
const recoveryConfig = opts.deferPush ? { ...config, push: false } : config
|
|
141
|
+
recoverBashOverlaps(root, hookInput, recoveryConfig, remainingRecoveryWait(recoveryDeadline))
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function recoveryDeadlineFromWait (waitMs) {
|
|
146
|
+
return waitMs === Infinity ? Infinity : Date.now() + waitMs
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function remainingRecoveryWait (deadline) {
|
|
150
|
+
return deadline === Infinity ? Infinity : Math.max(0, deadline - Date.now())
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function runTrackedStop (hookInput, root, opts) {
|
|
113
154
|
const entries = hookInput.sessionId ? readTracking(root, hookInput.sessionId) : []
|
|
114
155
|
const tracked = trackedChangesFromEntries(root, entries)
|
|
115
156
|
const manifest = loadManifest(root)
|
|
@@ -128,12 +169,84 @@ function run (input, opts = {}) {
|
|
|
128
169
|
(tracked.repos.length !== 1 || tracked.repos[0].root !== root)
|
|
129
170
|
|
|
130
171
|
if (tracked.ambiguous.length > 0) {
|
|
131
|
-
return runSingle(hookInput, { paths: [], trackingReason: 'ambiguous-tracking' })
|
|
172
|
+
return runSingle(hookInput, { paths: [], trackingReason: 'ambiguous-tracking', operationDeadline: opts.operationDeadline })
|
|
132
173
|
}
|
|
133
174
|
if (manifest.repos.length > 0 || tracked.repos.length > 1 || hasCrossRootPath) {
|
|
134
|
-
return runMulti(hookInput, root, tracked.repos, manifest)
|
|
175
|
+
return runMulti(hookInput, root, tracked.repos, manifest, opts)
|
|
176
|
+
}
|
|
177
|
+
return runSingle(hookInput, { paths: tracked.repos[0]?.paths || [], operationDeadline: opts.operationDeadline })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function recoverBashOverlaps (root, hookInput, config, waitMs = 5000) {
|
|
181
|
+
if (process.env.TURBOCOMMIT_DISABLED || !root) return
|
|
182
|
+
hookInput = hookInput || {}
|
|
183
|
+
config = config || activeConfig(root).config
|
|
184
|
+
if (config.enabled !== true) return
|
|
185
|
+
const release = acquireBashOverlapRecoveryLock(root, waitMs)
|
|
186
|
+
if (!release) return
|
|
187
|
+
let shouldPush = false
|
|
188
|
+
try {
|
|
189
|
+
pruneBashSnapshots(root)
|
|
190
|
+
if (hasActiveBashSnapshot(root) || hasRepositoryOperation(root)) return
|
|
191
|
+
for (const overlap of readReadyBashOverlaps(root)) {
|
|
192
|
+
const claimed = claimedPathsBySessions(root)
|
|
193
|
+
const changedFingerprint = overlap.paths.some(item =>
|
|
194
|
+
item.fingerprint == null || fingerprintTrackedPath(item.path) !== item.fingerprint)
|
|
195
|
+
const claimedPath = overlap.paths.some(item => claimed.has(item.path))
|
|
196
|
+
if (changedFingerprint || claimedPath) {
|
|
197
|
+
logEvent('skip', {
|
|
198
|
+
harness: hookInput.harness,
|
|
199
|
+
project: path.basename(root),
|
|
200
|
+
branch: currentBranch(root),
|
|
201
|
+
reason: changedFingerprint ? 'bash-overlap-changed' : 'bash-overlap-claimed',
|
|
202
|
+
paths: overlap.paths.length
|
|
203
|
+
})
|
|
204
|
+
if (changedFingerprint) {
|
|
205
|
+
invalidateBashOverlap(root, overlap.eventIds, 'changed-fingerprint')
|
|
206
|
+
}
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const paths = changedPaths(root, overlap.paths.map(item => item.path))
|
|
211
|
+
if (paths.length === 0) {
|
|
212
|
+
resolveBashOverlap(root, overlap.eventIds)
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const title = 'Recover overlapping shell changes'
|
|
217
|
+
const body = 'Recovered unchanged paths observed during overlapping shell commands.\n\n' +
|
|
218
|
+
overlap.sessionIds.map(sessionId => `Session: ${sessionId}`).join('\n')
|
|
219
|
+
const commit = commitPaths(root, paths, title, body, { pathScoped: true })
|
|
220
|
+
if (!commit) continue
|
|
221
|
+
|
|
222
|
+
logEvent('success', {
|
|
223
|
+
harness: hookInput.harness,
|
|
224
|
+
project: path.basename(root),
|
|
225
|
+
branch: currentBranch(root),
|
|
226
|
+
title,
|
|
227
|
+
recovered: paths.length
|
|
228
|
+
})
|
|
229
|
+
if (config.push === true) shouldPush = true
|
|
230
|
+
resolveBashOverlap(root, overlap.eventIds)
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
logEvent('fail', {
|
|
234
|
+
harness: hookInput.harness,
|
|
235
|
+
project: path.basename(root),
|
|
236
|
+
branch: currentBranch(root),
|
|
237
|
+
reason: 'bash-overlap-recovery'
|
|
238
|
+
})
|
|
239
|
+
} finally {
|
|
240
|
+
try { compactBashOverlapEvents(root) } catch {}
|
|
241
|
+
release()
|
|
242
|
+
}
|
|
243
|
+
if (shouldPush) {
|
|
244
|
+
logEvent(pushClean(root) ? 'push' : 'push-fail', {
|
|
245
|
+
harness: hookInput.harness,
|
|
246
|
+
project: path.basename(root),
|
|
247
|
+
branch: currentBranch(root)
|
|
248
|
+
})
|
|
135
249
|
}
|
|
136
|
-
return runSingle(hookInput, { paths: tracked.repos[0]?.paths || [] })
|
|
137
250
|
}
|
|
138
251
|
|
|
139
252
|
function runSingle (input, opts = {}) {
|
|
@@ -186,6 +299,7 @@ function runSingle (input, opts = {}) {
|
|
|
186
299
|
if (!(precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0)) {
|
|
187
300
|
savePending(root, sessionId, formatBody(effectivePairs))
|
|
188
301
|
}
|
|
302
|
+
cleanupTracking(root, sessionId)
|
|
189
303
|
logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-tracking' })
|
|
190
304
|
cleanupStale(root)
|
|
191
305
|
return
|
|
@@ -230,14 +344,14 @@ function runSingle (input, opts = {}) {
|
|
|
230
344
|
let headline
|
|
231
345
|
if (config.title?.type !== 'transcript') {
|
|
232
346
|
const titleTranscript = formatTitleTranscript(effectivePairs)
|
|
233
|
-
headline = runTitleAgent(root, config.title || {}, titleTranscript, hookInput.harness)
|
|
347
|
+
headline = runTitleAgent(root, config.title || {}, titleTranscript, hookInput.harness, { deadline: opts.operationDeadline })
|
|
234
348
|
}
|
|
235
349
|
headline = headline || extractHeadline(effectivePairs)
|
|
236
350
|
|
|
237
351
|
// Body: transcript by default, agent if opted in
|
|
238
352
|
let body
|
|
239
353
|
if (config.body?.type === 'agent') {
|
|
240
|
-
body = runBodyAgent(root, config.body, formattedTranscript, hookInput.harness)
|
|
354
|
+
body = runBodyAgent(root, config.body, formattedTranscript, hookInput.harness, { deadline: opts.operationDeadline })
|
|
241
355
|
}
|
|
242
356
|
body = body || formattedTranscript
|
|
243
357
|
|
|
@@ -296,7 +410,7 @@ function runSingle (input, opts = {}) {
|
|
|
296
410
|
|
|
297
411
|
logEvent('success', { harness: hookInput.harness, project, branch, context, title: safeHeadline })
|
|
298
412
|
|
|
299
|
-
if (config.push === true) {
|
|
413
|
+
if (config.push === true && !opts.deferPush) {
|
|
300
414
|
if (pushClean(root)) {
|
|
301
415
|
logEvent('push', { harness: hookInput.harness, project, branch })
|
|
302
416
|
} else {
|
|
@@ -318,7 +432,7 @@ function runSingle (input, opts = {}) {
|
|
|
318
432
|
}
|
|
319
433
|
}
|
|
320
434
|
|
|
321
|
-
function runMulti (hookInput, anchor, currentRepos, manifest) {
|
|
435
|
+
function runMulti (hookInput, anchor, currentRepos, manifest, opts = {}) {
|
|
322
436
|
recordCodexStop(hookInput, anchor)
|
|
323
437
|
|
|
324
438
|
const transcriptPath = resolveTranscriptPath(hookInput)
|
|
@@ -429,13 +543,13 @@ function runMulti (hookInput, anchor, currentRepos, manifest) {
|
|
|
429
543
|
|
|
430
544
|
let headline
|
|
431
545
|
if (config.title?.type !== 'transcript') {
|
|
432
|
-
headline = runTitleAgent(root, config.title || {}, titleInput, hookInput.harness)
|
|
546
|
+
headline = runTitleAgent(root, config.title || {}, titleInput, hookInput.harness, { deadline: opts.operationDeadline })
|
|
433
547
|
}
|
|
434
548
|
headline = headline || extractHeadline(effectivePairs)
|
|
435
549
|
|
|
436
550
|
let body
|
|
437
551
|
if (config.body?.type === 'agent') {
|
|
438
|
-
body = runBodyAgent(root, config.body, rawBody, hookInput.harness)
|
|
552
|
+
body = runBodyAgent(root, config.body, rawBody, hookInput.harness, { deadline: opts.operationDeadline })
|
|
439
553
|
}
|
|
440
554
|
body = body || rawBody
|
|
441
555
|
|
|
@@ -485,6 +599,7 @@ function runMulti (hookInput, anchor, currentRepos, manifest) {
|
|
|
485
599
|
|
|
486
600
|
for (const success of successes) {
|
|
487
601
|
if (success.config.push !== true) continue
|
|
602
|
+
if (opts.deferPush && success.root === anchor) continue
|
|
488
603
|
if (pushClean(success.root)) {
|
|
489
604
|
logEvent('push', {
|
|
490
605
|
harness: hookInput.harness,
|
|
@@ -565,4 +680,4 @@ function recordCodexStop (hookInput, root) {
|
|
|
565
680
|
if (hookInput.harness === 'codex' && hookInput.event === 'Stop') handleSessionEnd(hookInput, root)
|
|
566
681
|
}
|
|
567
682
|
|
|
568
|
-
module.exports = { run, runPreCompact, formatModelName, resolveCoauthor, readClaudeAttribution, resolveTranscriptPath, recordCodexStop }
|
|
683
|
+
module.exports = { run, runPreCompact, recoverBashOverlaps, formatModelName, resolveCoauthor, readClaudeAttribution, resolveTranscriptPath, recordCodexStop }
|
package/lib/session.js
CHANGED
|
@@ -257,7 +257,7 @@ function cleanupStale (root, maxAgeMs) {
|
|
|
257
257
|
const base = turbocommitDir(root)
|
|
258
258
|
if (!base) return
|
|
259
259
|
|
|
260
|
-
for (const sub of ['breadcrumbs', 'chains', '
|
|
260
|
+
for (const sub of ['breadcrumbs', 'chains', 'watermarks']) {
|
|
261
261
|
const dir = path.join(base, sub)
|
|
262
262
|
let files
|
|
263
263
|
try {
|
|
@@ -276,6 +276,8 @@ function cleanupStale (root, maxAgeMs) {
|
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
cleanupStaleTracking(root, now, ttl)
|
|
280
|
+
|
|
279
281
|
// Clean stale pending directories
|
|
280
282
|
const pDir = path.join(base, 'pending')
|
|
281
283
|
let pdirs
|
|
@@ -300,6 +302,43 @@ function cleanupStale (root, maxAgeMs) {
|
|
|
300
302
|
}
|
|
301
303
|
}
|
|
302
304
|
|
|
305
|
+
function cleanupStaleTracking (root, now, ttl) {
|
|
306
|
+
const dir = path.join(turbocommitDir(root), 'tracking')
|
|
307
|
+
let files
|
|
308
|
+
try {
|
|
309
|
+
files = fs.readdirSync(dir)
|
|
310
|
+
} catch {
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
for (const file of files) {
|
|
314
|
+
const fullPath = path.join(dir, file)
|
|
315
|
+
try {
|
|
316
|
+
const stat = fs.statSync(fullPath)
|
|
317
|
+
if (now - stat.mtimeMs <= ttl || trackingMayDescribeModifications(fullPath)) continue
|
|
318
|
+
fs.unlinkSync(fullPath)
|
|
319
|
+
} catch {}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function trackingMayDescribeModifications (file) {
|
|
324
|
+
let lines
|
|
325
|
+
try {
|
|
326
|
+
const data = fs.readFileSync(file, 'utf8').trim()
|
|
327
|
+
lines = data ? data.split('\n') : []
|
|
328
|
+
} catch {
|
|
329
|
+
return true
|
|
330
|
+
}
|
|
331
|
+
return lines.some(line => {
|
|
332
|
+
try {
|
|
333
|
+
const entry = JSON.parse(line)
|
|
334
|
+
return entry.tool !== 'Bash' ||
|
|
335
|
+
(entry.phase === 'post' && Array.isArray(entry.files) && entry.files.length > 0)
|
|
336
|
+
} catch {
|
|
337
|
+
return true
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
|
|
303
342
|
module.exports = {
|
|
304
343
|
handleSessionEnd,
|
|
305
344
|
handleSessionStart,
|