@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/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# turbocommit
|
|
4
4
|
|
|
5
|
-
turbocommit creates a git commit containing
|
|
5
|
+
turbocommit creates a git commit containing the paths Claude Code or Codex
|
|
6
6
|
changes on each turn. Think of it like save state in a game emulator: whenever
|
|
7
7
|
you fall in a pit, you can safely rewind and try again.
|
|
8
8
|
|
|
@@ -18,19 +18,39 @@ sessions concurrently and detangle which agent committed what after the fact.
|
|
|
18
18
|
|
|
19
19
|
turbocommit registers hooks with the harnesses you use:
|
|
20
20
|
|
|
21
|
-
- Claude Code: **PreToolUse**, **
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
- Claude Code: **PreToolUse**, **PostToolUse**, **PostToolUseFailure**,
|
|
22
|
+
**SessionStart**, **SessionEnd**, and **Stop**.
|
|
23
|
+
- Codex: **PreToolUse**, **PostToolUse**, **SessionStart**, **SessionEnd**,
|
|
24
|
+
**PreCompact**, and **Stop**.
|
|
25
|
+
|
|
26
|
+
- **PreToolUse** tentatively claims the paths supplied to editing tools while
|
|
27
|
+
recovery finishes, promotes the claim only when the tool may proceed, and
|
|
28
|
+
snapshots the repository before shell commands.
|
|
29
|
+
- **PostToolUse** attributes paths that became dirty during a shell command.
|
|
30
|
+
Concurrent commands in one session share ownership. Commands from different
|
|
31
|
+
sessions retain hashed overlap evidence instead of claiming each other's
|
|
32
|
+
paths. Once every involved turn stops and no shell remains active in that
|
|
33
|
+
checkout, unchanged and otherwise-unclaimed paths are committed separately
|
|
34
|
+
as recovered shell changes. Evidence is scoped to its exact worktree and is
|
|
35
|
+
permanently discarded if file contents or executable modes change, or if an
|
|
36
|
+
unfinished shell snapshot expires.
|
|
37
|
+
- **PostToolUseFailure**, **Stop**, and **SessionEnd** finalize shell snapshots
|
|
38
|
+
whose normal post hook did not arrive. Claude normally finishes within its
|
|
39
|
+
SessionEnd hook. If its internal deadline expires, Turbocommit stores the
|
|
40
|
+
worktree in a hidden local rescue ref before starting a detached worker. The
|
|
41
|
+
worker can recreate a removed checkout at its recorded commit, but it leaves
|
|
42
|
+
the rescue intact if the original checkout identity or branch has changed.
|
|
43
|
+
Every completed deadline rescue remains reachable through a hidden local
|
|
44
|
+
safety ref, including detached-HEAD recovery. Codex preserves the transcript
|
|
45
|
+
and starts the normal commit and recovery path in a detached worker so it can
|
|
46
|
+
finish beyond Codex's three-second hook deadline. Read-only tracking is then
|
|
47
|
+
removed.
|
|
28
48
|
- **SessionStart / SessionEnd** chain sessions across `/clear` boundaries
|
|
29
49
|
so planning context survives into the eventual commit.
|
|
30
50
|
- **PreCompact** on Codex buffers visible transcript context before compaction.
|
|
31
|
-
- **Stop** fires after every turn. If the session modified files, it
|
|
32
|
-
commits
|
|
33
|
-
by a later session that does commit.
|
|
51
|
+
- **Stop** fires after every turn. If the session modified files, it stages and
|
|
52
|
+
commits only the paths tracked for that session. If not, it buffers the
|
|
53
|
+
transcript for pickup by a later session that does commit.
|
|
34
54
|
|
|
35
55
|
### Changes across multiple repositories
|
|
36
56
|
|
|
@@ -43,7 +63,8 @@ additional repositories.
|
|
|
43
63
|
Each touched checkout:
|
|
44
64
|
|
|
45
65
|
- Reads its own merged global and project configuration.
|
|
46
|
-
-
|
|
66
|
+
- Stages only paths attributed to the current session, preserving unrelated
|
|
67
|
+
staged and unstaged work.
|
|
47
68
|
- Gets an independent commit with the full turn transcript.
|
|
48
69
|
- Gives the configured title agent repository-specific bounded diff context.
|
|
49
70
|
|
|
@@ -56,6 +77,29 @@ committed and pushed before enabled parent repositories. When one turn creates
|
|
|
56
77
|
commits in multiple repositories, each commit includes a shared
|
|
57
78
|
`Turbocommit-Session` trailer.
|
|
58
79
|
|
|
80
|
+
Relative tool paths are matched to the checkout where that path changed. If the
|
|
81
|
+
same relative path is dirty in multiple worktrees, Turbocommit skips the turn
|
|
82
|
+
with an `ambiguous-tracking` monitor reason instead of guessing which checkout
|
|
83
|
+
owns it, then clears that ambiguous tracking so later turns can proceed. Direct
|
|
84
|
+
editing tools such as `apply_patch` resolve relative paths from their recorded
|
|
85
|
+
working directory. Path-bearing MCP tools may report singular paths, path
|
|
86
|
+
arrays, or local file URIs. Pathless tool calls and directory-valued path fields
|
|
87
|
+
do not claim files or prevent later exact edits in the same session from
|
|
88
|
+
committing.
|
|
89
|
+
|
|
90
|
+
Before staging, Turbocommit resolves tracked paths to the exact changed files,
|
|
91
|
+
treats Git pathspec metacharacters literally, drops paths that are no longer
|
|
92
|
+
dirty, and excludes newly discovered embedded repositories. Existing tracked
|
|
93
|
+
submodule gitlinks remain eligible so child commits can update their parent.
|
|
94
|
+
During an active merge, cherry-pick, revert, or rebase, Git forbids partial
|
|
95
|
+
commits, so Turbocommit commits the operation's resolved index as a whole.
|
|
96
|
+
Recovered overlap evidence is deferred until the operation ends because its
|
|
97
|
+
generic commit must remain path-scoped.
|
|
98
|
+
|
|
99
|
+
Retry manifests created by older versions did not record path ownership. On
|
|
100
|
+
upgrade, Turbocommit discards those unsafe retry entries and records a
|
|
101
|
+
`legacy-manifest` monitor event rather than guessing which files they owned.
|
|
102
|
+
|
|
59
103
|
The commit message headline is generated by a title agent (configurable).
|
|
60
104
|
The body contains the full prompt/response transcript. When planning
|
|
61
105
|
context was buffered from ancestor sessions, it appears under a
|
|
@@ -102,10 +146,11 @@ brew uninstall turbocommit
|
|
|
102
146
|
## Where turbocommit goes in your hook chain
|
|
103
147
|
|
|
104
148
|
`turbocommit install` adds one hook group per event for each installed harness.
|
|
105
|
-
The hooks
|
|
149
|
+
The hooks ordinarily exit 0. A modifying PreToolUse hook denies the tool when
|
|
150
|
+
turbocommit cannot safely coordinate with an in-progress recovery, so the
|
|
151
|
+
agent can retry instead of making an unowned change.
|
|
106
152
|
|
|
107
153
|
If you rearrange your hooks manually, the turbocommit hooks can go anywhere.
|
|
108
|
-
Order doesn't matter since they never block.
|
|
109
154
|
|
|
110
155
|
Codex users should restart Codex and run `/hooks` after install to review and
|
|
111
156
|
trust the hook commands.
|
package/cli.js
CHANGED
|
@@ -1,15 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const {
|
|
3
|
+
const { spawn } = require('child_process')
|
|
4
|
+
const crypto = require('crypto')
|
|
5
|
+
const fs = require('fs')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
const { readStdin, ensureDir } = require('./lib/io')
|
|
4
8
|
const { install, uninstall } = require('./lib/install')
|
|
5
9
|
const { init, deinit } = require('./lib/init')
|
|
6
|
-
const { run, runPreCompact } = require('./lib/run')
|
|
7
|
-
const { handleTrack } = require('./lib/track')
|
|
8
|
-
const { handleSessionStart, handleSessionEnd } = require('./lib/session')
|
|
10
|
+
const { run, runPreCompact, recoverBashOverlaps } = require('./lib/run')
|
|
11
|
+
const { handleTrack, handlePostTrack, finalizeBashSnapshots, recordBashSessionStop, readTracking } = require('./lib/track')
|
|
12
|
+
const { handleSessionStart, handleSessionEnd, turbocommitDir } = require('./lib/session')
|
|
9
13
|
const { doctor } = require('./lib/doctor')
|
|
10
14
|
const { monitor } = require('./lib/monitor')
|
|
11
|
-
const { gitRoot } = require('./lib/git')
|
|
15
|
+
const { gitRoot, pushClean } = require('./lib/git')
|
|
16
|
+
const { activeConfig } = require('./lib/config')
|
|
17
|
+
const { logEvent } = require('./lib/log')
|
|
12
18
|
const { parseHarnessArg, normalizeHookInput } = require('./lib/harness')
|
|
19
|
+
const {
|
|
20
|
+
createSessionEndRescue,
|
|
21
|
+
restoreSessionEndRescue,
|
|
22
|
+
matchesSessionEndRescueRoot,
|
|
23
|
+
finalizeRestoredSessionEndRescue,
|
|
24
|
+
preserveSessionEndRecoveredCommit,
|
|
25
|
+
recordPreservedSessionEndRescue,
|
|
26
|
+
removeSessionEndRescueWorktree,
|
|
27
|
+
cleanupSessionEndRescue,
|
|
28
|
+
isClean
|
|
29
|
+
} = require('./lib/rescue')
|
|
13
30
|
|
|
14
31
|
const VERSION = require('./package.json').version
|
|
15
32
|
|
|
@@ -59,6 +76,10 @@ function main (argv) {
|
|
|
59
76
|
return cmdDeinit()
|
|
60
77
|
case 'hook':
|
|
61
78
|
return cmdHook(parsed.args.slice(1), parsed.harness)
|
|
79
|
+
case 'session-end-worker':
|
|
80
|
+
return cmdSessionEndWorker(parsed.args.slice(1), parsed.harness)
|
|
81
|
+
case 'session-end-rescue-worker':
|
|
82
|
+
return cmdSessionEndRescueWorker(parsed.args.slice(1))
|
|
62
83
|
case 'run':
|
|
63
84
|
return cmdRunDeprecated()
|
|
64
85
|
case '--version':
|
|
@@ -165,6 +186,7 @@ function cmdMonitor (harness) {
|
|
|
165
186
|
}
|
|
166
187
|
|
|
167
188
|
function cmdHook (argv, harness) {
|
|
189
|
+
if (process.env.TURBOCOMMIT_DISABLED) return
|
|
168
190
|
const event = argv[0]
|
|
169
191
|
try {
|
|
170
192
|
const input = readStdin()
|
|
@@ -173,13 +195,27 @@ function cmdHook (argv, harness) {
|
|
|
173
195
|
const root = gitRoot(hookInput.cwd || process.cwd())
|
|
174
196
|
switch (event) {
|
|
175
197
|
case 'pre-tool-use':
|
|
176
|
-
|
|
198
|
+
try {
|
|
199
|
+
if (handleTrack(hookInput, root) === false) denyPreToolUse()
|
|
200
|
+
} catch {
|
|
201
|
+
denyPreToolUse()
|
|
202
|
+
}
|
|
203
|
+
return
|
|
204
|
+
case 'post-tool-use':
|
|
205
|
+
handlePostTrack(hookInput, root)
|
|
177
206
|
return
|
|
178
207
|
case 'session-start':
|
|
179
208
|
handleSessionStart(hookInput, root)
|
|
180
209
|
return
|
|
181
210
|
case 'session-end':
|
|
182
211
|
handleSessionEnd(hookInput, root)
|
|
212
|
+
if (hookInput.harness === 'claude') {
|
|
213
|
+
let completed = false
|
|
214
|
+
try { completed = completeSessionEnd(hookInput, root, 45000) } catch {}
|
|
215
|
+
if (!completed) rescueClaudeSessionEnd(hookInput, root)
|
|
216
|
+
} else {
|
|
217
|
+
spawnSessionEndWorker(hookInput, root, harness)
|
|
218
|
+
}
|
|
183
219
|
return
|
|
184
220
|
case 'pre-compact':
|
|
185
221
|
runPreCompact(hookInput)
|
|
@@ -196,6 +232,214 @@ function cmdHook (argv, harness) {
|
|
|
196
232
|
}
|
|
197
233
|
}
|
|
198
234
|
|
|
235
|
+
function spawnSessionEndWorker (hookInput, root, harness) {
|
|
236
|
+
if (process.env.TURBOCOMMIT_DISABLED || !root || !hookInput.sessionId) return
|
|
237
|
+
const transcript = copySessionEndTranscript(root, hookInput.transcriptPath)
|
|
238
|
+
const payload = Buffer.from(JSON.stringify({
|
|
239
|
+
sessionId: hookInput.sessionId,
|
|
240
|
+
event: 'SessionEnd',
|
|
241
|
+
cwd: root,
|
|
242
|
+
harness: hookInput.harness || harness,
|
|
243
|
+
transcriptPath: transcript.path,
|
|
244
|
+
temporaryTranscript: transcript.temporary,
|
|
245
|
+
model: hookInput.model
|
|
246
|
+
})).toString('base64url')
|
|
247
|
+
const args = [__filename, 'session-end-worker', payload]
|
|
248
|
+
if (harness) args.push('--harness', harness)
|
|
249
|
+
const child = spawn(process.execPath, args, {
|
|
250
|
+
cwd: stableWorkerCwd(root),
|
|
251
|
+
detached: true,
|
|
252
|
+
stdio: 'ignore'
|
|
253
|
+
})
|
|
254
|
+
child.on('error', () => {
|
|
255
|
+
if (transcript.temporary) cleanupSessionEndTranscript(root, transcript.path)
|
|
256
|
+
})
|
|
257
|
+
child.unref()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function cmdSessionEndWorker (argv, harness) {
|
|
261
|
+
if (process.env.TURBOCOMMIT_DISABLED) return
|
|
262
|
+
let hookInput
|
|
263
|
+
try {
|
|
264
|
+
hookInput = JSON.parse(Buffer.from(argv[0], 'base64url').toString('utf8'))
|
|
265
|
+
} catch {
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
hookInput.harness = hookInput.harness || harness
|
|
269
|
+
const root = gitRoot(hookInput.cwd || process.cwd())
|
|
270
|
+
try {
|
|
271
|
+
if (!root || !hookInput.sessionId) return
|
|
272
|
+
completeSessionEnd(hookInput, root, Infinity)
|
|
273
|
+
} finally {
|
|
274
|
+
if (hookInput.temporaryTranscript) cleanupTemporarySessionEndTranscript(root, hookInput.transcriptPath)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function completeSessionEnd (hookInput, root, waitMs, { deferPush = false } = {}) {
|
|
279
|
+
if (!root || !hookInput.sessionId) return false
|
|
280
|
+
const deadline = waitMs === Infinity ? Infinity : Date.now() + waitMs
|
|
281
|
+
const remainingWait = () => deadline === Infinity ? Infinity : Math.max(0, deadline - Date.now())
|
|
282
|
+
const finalized = finalizeBashSnapshots(root, hookInput.sessionId, Date.now(), remainingWait())
|
|
283
|
+
if (finalized == null) return false
|
|
284
|
+
if (readTracking(root, hookInput.sessionId).length > 0) {
|
|
285
|
+
run(hookInput, { recoveryDeadline: deadline, operationDeadline: deadline, deferPush })
|
|
286
|
+
return readTracking(root, hookInput.sessionId).length === 0
|
|
287
|
+
}
|
|
288
|
+
recordBashSessionStop(root, hookInput.sessionId)
|
|
289
|
+
const recoveryConfig = deferPush ? { ...activeConfig(root).config, push: false } : undefined
|
|
290
|
+
recoverBashOverlaps(root, hookInput, recoveryConfig, remainingWait())
|
|
291
|
+
return true
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function rescueClaudeSessionEnd (hookInput, root) {
|
|
295
|
+
if (!root || !hookInput.sessionId) return
|
|
296
|
+
const transcript = copySessionEndTranscript(root, hookInput.transcriptPath)
|
|
297
|
+
const rescueInput = {
|
|
298
|
+
...hookInput,
|
|
299
|
+
transcriptPath: transcript.path,
|
|
300
|
+
temporaryTranscript: transcript.temporary
|
|
301
|
+
}
|
|
302
|
+
const rescue = createSessionEndRescue(root, rescueInput)
|
|
303
|
+
if (!rescue) {
|
|
304
|
+
if (transcript.temporary) cleanupTemporarySessionEndTranscript(root, transcript.path)
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
spawnSessionEndRescueWorker(rescue)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function spawnSessionEndRescueWorker (rescue) {
|
|
311
|
+
const payload = Buffer.from(JSON.stringify(rescue)).toString('base64url')
|
|
312
|
+
const child = spawn(process.execPath, [__filename, 'session-end-rescue-worker', payload], {
|
|
313
|
+
cwd: stableWorkerCwd(rescue.root),
|
|
314
|
+
detached: true,
|
|
315
|
+
stdio: 'ignore'
|
|
316
|
+
})
|
|
317
|
+
child.on('error', () => {})
|
|
318
|
+
child.unref()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function cmdSessionEndRescueWorker (argv) {
|
|
322
|
+
let rescue
|
|
323
|
+
try {
|
|
324
|
+
rescue = JSON.parse(Buffer.from(argv[0], 'base64url').toString('utf8'))
|
|
325
|
+
} catch {
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
let recreated = false
|
|
329
|
+
let completed = false
|
|
330
|
+
try {
|
|
331
|
+
const existingRoot = gitRoot(rescue.root)
|
|
332
|
+
if (existingRoot) {
|
|
333
|
+
if (!matchesSessionEndRescueRoot(rescue, existingRoot)) return
|
|
334
|
+
try { completed = completeSessionEnd(rescue.hookInput, existingRoot, Infinity) } catch {}
|
|
335
|
+
}
|
|
336
|
+
if (!completed && !fs.existsSync(rescue.root)) {
|
|
337
|
+
recreated = restoreRescueWithRetry(rescue)
|
|
338
|
+
if (recreated) {
|
|
339
|
+
try { completed = completeSessionEnd(rescue.hookInput, rescue.root, Infinity, { deferPush: true }) } catch {}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (!completed) return
|
|
343
|
+
const clean = isClean(rescue.root)
|
|
344
|
+
if (!recreated) {
|
|
345
|
+
const recovered = preserveSessionEndRecoveredCommit(rescue, rescue.root)
|
|
346
|
+
if (!recovered) return
|
|
347
|
+
if (clean) {
|
|
348
|
+
cleanupSessionEndRescue(rescue)
|
|
349
|
+
} else {
|
|
350
|
+
recordPreservedSessionEndRescue(rescue, recovered, 'dirty-existing-worktree')
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
const finalized = finalizeRestoredSessionEndRescue(rescue, { clean })
|
|
354
|
+
if (finalized.resolved) {
|
|
355
|
+
pushRestoredSessionEndRescue(rescue, finalized)
|
|
356
|
+
if (isClean(rescue.root)) {
|
|
357
|
+
cleanupSessionEndRescue(rescue, { removeWorktree: true })
|
|
358
|
+
} else {
|
|
359
|
+
recordPreservedSessionEndRescue(rescue, finalized, 'dirty-attached-worktree')
|
|
360
|
+
}
|
|
361
|
+
} else if (finalized.preserved && finalized.removeWorktree) {
|
|
362
|
+
removeSessionEndRescueWorktree(rescue)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (rescue.hookInput?.temporaryTranscript) {
|
|
366
|
+
cleanupTemporarySessionEndTranscript(rescue.root, rescue.hookInput.transcriptPath)
|
|
367
|
+
}
|
|
368
|
+
} catch {
|
|
369
|
+
// The rescue ref and record remain available for a later retry.
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function pushRestoredSessionEndRescue (rescue, finalized) {
|
|
374
|
+
const config = activeConfig(rescue.root).config
|
|
375
|
+
if (config.push !== true) return
|
|
376
|
+
const pushed = finalized.attached && pushClean(rescue.root)
|
|
377
|
+
logEvent(pushed ? 'push' : 'push-fail', {
|
|
378
|
+
harness: rescue.hookInput?.harness,
|
|
379
|
+
project: path.basename(rescue.root),
|
|
380
|
+
branch: rescue.branch
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function restoreRescueWithRetry (rescue) {
|
|
385
|
+
const deadline = Date.now() + 30000
|
|
386
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4))
|
|
387
|
+
while (!fs.existsSync(rescue.root)) {
|
|
388
|
+
if (restoreSessionEndRescue(rescue)) return true
|
|
389
|
+
if (Date.now() >= deadline) return false
|
|
390
|
+
Atomics.wait(sleeper, 0, 0, 100)
|
|
391
|
+
}
|
|
392
|
+
return false
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function stableWorkerCwd (root) {
|
|
396
|
+
const base = turbocommitDir(root)
|
|
397
|
+
return base ? path.dirname(base) : root
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function copySessionEndTranscript (root, source) {
|
|
401
|
+
if (!source) return { path: source, temporary: false }
|
|
402
|
+
const base = turbocommitDir(root)
|
|
403
|
+
if (!base) return { path: source, temporary: false }
|
|
404
|
+
const dir = path.join(base, 'session-end-transcripts')
|
|
405
|
+
const destination = path.join(dir, `${process.pid}-${crypto.randomUUID()}.jsonl`)
|
|
406
|
+
try {
|
|
407
|
+
ensureDir(dir)
|
|
408
|
+
fs.copyFileSync(source, destination)
|
|
409
|
+
return { path: destination, temporary: true }
|
|
410
|
+
} catch {
|
|
411
|
+
return { path: source, temporary: false }
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function cleanupSessionEndTranscript (root, transcriptPath) {
|
|
416
|
+
const base = turbocommitDir(root)
|
|
417
|
+
if (!base || !transcriptPath) return false
|
|
418
|
+
const dir = path.join(base, 'session-end-transcripts')
|
|
419
|
+
if (path.dirname(path.resolve(transcriptPath)) !== path.resolve(dir)) return false
|
|
420
|
+
try { fs.unlinkSync(transcriptPath) } catch {}
|
|
421
|
+
return !fs.existsSync(transcriptPath)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function cleanupTemporarySessionEndTranscript (root, transcriptPath) {
|
|
425
|
+
if (root && cleanupSessionEndTranscript(root, transcriptPath)) return
|
|
426
|
+
if (!transcriptPath) return
|
|
427
|
+
const resolved = path.resolve(transcriptPath)
|
|
428
|
+
if (path.basename(path.dirname(resolved)) !== 'session-end-transcripts') return
|
|
429
|
+
if (!/^\d+-[0-9a-f-]{36}\.jsonl$/i.test(path.basename(resolved))) return
|
|
430
|
+
try { fs.unlinkSync(resolved) } catch {}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function denyPreToolUse () {
|
|
434
|
+
process.stdout.write(JSON.stringify({
|
|
435
|
+
hookSpecificOutput: {
|
|
436
|
+
hookEventName: 'PreToolUse',
|
|
437
|
+
permissionDecision: 'deny',
|
|
438
|
+
permissionDecisionReason: 'turbocommit could not safely persist path ownership; retry the tool'
|
|
439
|
+
}
|
|
440
|
+
}) + '\n')
|
|
441
|
+
}
|
|
442
|
+
|
|
199
443
|
function cmdRunDeprecated () {
|
|
200
444
|
let hookInput
|
|
201
445
|
try {
|
package/lib/agent.js
CHANGED
|
@@ -32,14 +32,18 @@ function renderPrompt (template, transcript) {
|
|
|
32
32
|
return template.replace(/\{\{transcript\}\}/g, () => transcript)
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function runAgent (root, command, prompt) {
|
|
35
|
+
function runAgent (root, command, prompt, opts = {}) {
|
|
36
36
|
const binary = command.split(/\s+/)[0]
|
|
37
37
|
const whichResult = tryRun(`which ${binary}`, {})
|
|
38
38
|
if (whichResult.code !== 0) return null
|
|
39
|
+
const timeout = Number.isFinite(opts.deadline)
|
|
40
|
+
? Math.min(45000, Math.max(0, opts.deadline - Date.now()))
|
|
41
|
+
: 45000
|
|
42
|
+
if (timeout <= 0) return null
|
|
39
43
|
|
|
40
44
|
const result = tryRun(command, {
|
|
41
45
|
cwd: root,
|
|
42
|
-
timeout
|
|
46
|
+
timeout,
|
|
43
47
|
input: prompt,
|
|
44
48
|
env: { ...process.env, TURBOCOMMIT_DISABLED: '1', CLAUDECODE: '' }
|
|
45
49
|
})
|
|
@@ -54,21 +58,21 @@ function defaultCommand (harness) {
|
|
|
54
58
|
return harness === 'codex' ? DEFAULT_CODEX_COMMAND : DEFAULT_COMMAND
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
function runTitleAgent (root, titleCfg, transcript, harness) {
|
|
61
|
+
function runTitleAgent (root, titleCfg, transcript, harness, opts) {
|
|
58
62
|
const command = titleCfg.command || defaultCommand(harness)
|
|
59
63
|
const template = titleCfg.prompt || DEFAULT_TITLE_PROMPT
|
|
60
64
|
const prompt = renderPrompt(template, transcript)
|
|
61
|
-
const result = runAgent(root, command, prompt)
|
|
65
|
+
const result = runAgent(root, command, prompt, opts)
|
|
62
66
|
if (!result) return null
|
|
63
67
|
// Take only first line and enforce 72 char limit
|
|
64
68
|
return result.split('\n')[0].slice(0, 72) || null
|
|
65
69
|
}
|
|
66
70
|
|
|
67
|
-
function runBodyAgent (root, bodyCfg, transcript, harness) {
|
|
71
|
+
function runBodyAgent (root, bodyCfg, transcript, harness, opts) {
|
|
68
72
|
const command = bodyCfg.command || defaultCommand(harness)
|
|
69
73
|
const template = bodyCfg.prompt || DEFAULT_BODY_PROMPT
|
|
70
74
|
const prompt = renderPrompt(template, transcript)
|
|
71
|
-
return runAgent(root, command, prompt)
|
|
75
|
+
return runAgent(root, command, prompt, opts)
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
module.exports = {
|
package/lib/git.js
CHANGED
|
@@ -13,12 +13,32 @@ function git (args, opts = {}) {
|
|
|
13
13
|
|
|
14
14
|
function gitRoot (cwd) {
|
|
15
15
|
try {
|
|
16
|
-
return git('rev-parse --show-toplevel', { cwd })
|
|
16
|
+
return canonicalRoot(git('rev-parse --show-toplevel', { cwd }))
|
|
17
17
|
} catch {
|
|
18
18
|
return null
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function canonicalRoot (filePath) {
|
|
23
|
+
const target = path.resolve(filePath)
|
|
24
|
+
let probe = target
|
|
25
|
+
while (!fs.existsSync(probe)) {
|
|
26
|
+
const parent = path.dirname(probe)
|
|
27
|
+
if (parent === probe) return target
|
|
28
|
+
probe = parent
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return path.join(fs.realpathSync(probe), path.relative(probe, target))
|
|
32
|
+
} catch {
|
|
33
|
+
return target
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function canonicalTrackedPath (filePath) {
|
|
38
|
+
const target = path.resolve(filePath)
|
|
39
|
+
return path.join(canonicalRoot(path.dirname(target)), path.basename(target))
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
function gitRootForPath (filePath) {
|
|
23
43
|
let probe = filePath
|
|
24
44
|
try {
|
|
@@ -34,6 +54,25 @@ function gitRootForPath (filePath) {
|
|
|
34
54
|
return gitRoot(probe)
|
|
35
55
|
}
|
|
36
56
|
|
|
57
|
+
function gitWorktrees (cwd) {
|
|
58
|
+
try {
|
|
59
|
+
return git('worktree list --porcelain', { cwd })
|
|
60
|
+
.split('\n')
|
|
61
|
+
.filter(line => line.startsWith('worktree '))
|
|
62
|
+
.map(line => line.slice('worktree '.length))
|
|
63
|
+
.map(root => {
|
|
64
|
+
try {
|
|
65
|
+
return fs.realpathSync(root)
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
} catch {
|
|
72
|
+
return []
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
37
76
|
function gitCommonDir (cwd) {
|
|
38
77
|
try {
|
|
39
78
|
const out = git('rev-parse --git-common-dir', { cwd })
|
|
@@ -60,6 +99,10 @@ function hasChanges (cwd) {
|
|
|
60
99
|
return untracked.length > 0
|
|
61
100
|
}
|
|
62
101
|
|
|
102
|
+
function hasPathChanges (cwd, filePath) {
|
|
103
|
+
return changedPaths(cwd, [filePath]).length > 0
|
|
104
|
+
}
|
|
105
|
+
|
|
63
106
|
function addAndCommit (cwd, headline, body) {
|
|
64
107
|
stageAll(cwd)
|
|
65
108
|
commitStaged(cwd, headline, body)
|
|
@@ -70,15 +113,42 @@ function stageAll (cwd) {
|
|
|
70
113
|
git('add -A', { cwd })
|
|
71
114
|
}
|
|
72
115
|
|
|
116
|
+
function stagePaths (cwd, filePaths) {
|
|
117
|
+
const changed = changedPaths(cwd, filePaths)
|
|
118
|
+
const paths = pathspecs(cwd, changed)
|
|
119
|
+
if (paths.length === 0) return []
|
|
120
|
+
git(`add -A -- ${paths.map(quote).join(' ')}`, { cwd })
|
|
121
|
+
return changed
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function commitPaths (cwd, filePaths, headline, body, opts = {}) {
|
|
125
|
+
if (opts.pathScoped && hasRepositoryOperation(cwd)) return null
|
|
126
|
+
const changed = stagePaths(cwd, filePaths)
|
|
127
|
+
return commitStagedPaths(cwd, changed, headline, body, opts)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function commitStagedPaths (cwd, filePaths, headline, body, opts = {}) {
|
|
131
|
+
const paths = pathspecs(cwd, filePaths)
|
|
132
|
+
if (paths.length === 0) return null
|
|
133
|
+
if (hasRepositoryOperation(cwd)) {
|
|
134
|
+
return opts.pathScoped ? null : commitStaged(cwd, headline, body)
|
|
135
|
+
}
|
|
136
|
+
git(`commit --only -m "${esc(headline)}" -m "${esc(body)}" --no-verify -- ${paths.map(quote).join(' ')}`, { cwd })
|
|
137
|
+
return git('rev-parse HEAD', { cwd })
|
|
138
|
+
}
|
|
139
|
+
|
|
73
140
|
function commitStaged (cwd, headline, body) {
|
|
74
141
|
git(`commit -m "${esc(headline)}" -m "${esc(body)}" --no-verify`, { cwd })
|
|
75
142
|
return git('rev-parse HEAD', { cwd })
|
|
76
143
|
}
|
|
77
144
|
|
|
78
|
-
function stagedChangeContext (cwd, budget = 20000) {
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
145
|
+
function stagedChangeContext (cwd, budget = 20000, filePaths) {
|
|
146
|
+
const selected = filePaths && filePaths.length > 0
|
|
147
|
+
? ` -- ${pathspecs(cwd, filePaths).map(quote).join(' ')}`
|
|
148
|
+
: ''
|
|
149
|
+
const status = git(`status --short${selected}`, { cwd })
|
|
150
|
+
const stat = git(`diff --cached --stat${selected}`, { cwd })
|
|
151
|
+
const diff = git(`diff --cached --no-ext-diff --unified=3${selected}`, { cwd })
|
|
82
152
|
const prefix = `Status:\n${status || '(clean)'}\n\nDiff stat:\n${stat || '(none)'}\n\nDiff:\n`
|
|
83
153
|
const remaining = Math.max(0, budget - prefix.length)
|
|
84
154
|
return prefix + (diff.length > remaining ? diff.slice(0, remaining) + '\n[... diff truncated ...]' : diff)
|
|
@@ -88,7 +158,7 @@ function isGitlink (parentRoot, childRoot) {
|
|
|
88
158
|
const relative = path.relative(parentRoot, childRoot)
|
|
89
159
|
if (!relative || relative.startsWith('..')) return false
|
|
90
160
|
try {
|
|
91
|
-
return git(`ls-files --stage --
|
|
161
|
+
return git(`ls-files --stage -- ${quote(literalPathspec(relative))}`, { cwd: parentRoot })
|
|
92
162
|
.split('\n')
|
|
93
163
|
.some(line => line.startsWith('160000 '))
|
|
94
164
|
} catch {
|
|
@@ -126,18 +196,102 @@ function esc (s) {
|
|
|
126
196
|
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`')
|
|
127
197
|
}
|
|
128
198
|
|
|
199
|
+
function quote (value) {
|
|
200
|
+
return `'${value.replace(/'/g, '\'"\'"\'')}'`
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function pathspecs (cwd, filePaths) {
|
|
204
|
+
cwd = canonicalRoot(cwd)
|
|
205
|
+
return [...new Set(filePaths.map(filePath => path.relative(cwd, canonicalTrackedPath(filePath))))]
|
|
206
|
+
.filter(relative => relative && !relative.startsWith('..'))
|
|
207
|
+
.map(literalPathspec)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function literalPathspec (relative) {
|
|
211
|
+
return `:(literal)${relative}`
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function changedPaths (cwd, filePaths) {
|
|
215
|
+
cwd = canonicalRoot(cwd)
|
|
216
|
+
const selected = pathspecs(cwd, filePaths)
|
|
217
|
+
if (selected.length === 0) return []
|
|
218
|
+
return collectChangedPaths(cwd, selected)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function changedPathsInRepository (cwd) {
|
|
222
|
+
cwd = canonicalRoot(cwd)
|
|
223
|
+
return collectChangedPaths(cwd, [])
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function collectChangedPaths (cwd, selected) {
|
|
227
|
+
const args = selected.length > 0 ? ` -- ${selected.map(quote).join(' ')}` : ''
|
|
228
|
+
const tracked = hasCommits(cwd)
|
|
229
|
+
? git(`diff --name-only --no-renames -z HEAD${args}`, { cwd })
|
|
230
|
+
: git(`ls-files --cached -z${args}`, { cwd })
|
|
231
|
+
const untracked = git(`ls-files --others --exclude-standard -z${args}`, { cwd })
|
|
232
|
+
const relativePaths = [...splitNull(tracked), ...splitNull(untracked)]
|
|
233
|
+
return [...new Set(relativePaths)]
|
|
234
|
+
.filter(relative => !isEmbeddedRepository(cwd, relative) || isHeadGitlink(cwd, relative))
|
|
235
|
+
.map(relative => path.join(cwd, relative))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function splitNull (value) {
|
|
239
|
+
return value ? value.split('\0').filter(Boolean) : []
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function isEmbeddedRepository (cwd, relative) {
|
|
243
|
+
const candidate = path.join(cwd, relative)
|
|
244
|
+
try {
|
|
245
|
+
return fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, '.git'))
|
|
246
|
+
} catch {
|
|
247
|
+
return false
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isHeadGitlink (cwd, relative) {
|
|
252
|
+
try {
|
|
253
|
+
return git(`ls-tree HEAD -- ${quote(literalPathspec(relative))}`, { cwd })
|
|
254
|
+
.split('\n')
|
|
255
|
+
.some(line => line.startsWith('160000 '))
|
|
256
|
+
} catch {
|
|
257
|
+
return false
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function hasRepositoryOperation (cwd) {
|
|
262
|
+
return ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply']
|
|
263
|
+
.some(name => {
|
|
264
|
+
try {
|
|
265
|
+
const gitPath = git(`rev-parse --git-path ${quote(name)}`, { cwd })
|
|
266
|
+
return fs.existsSync(path.isAbsolute(gitPath) ? gitPath : path.join(cwd, gitPath))
|
|
267
|
+
} catch {
|
|
268
|
+
return false
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
129
273
|
module.exports = {
|
|
130
274
|
git,
|
|
275
|
+
canonicalRoot,
|
|
276
|
+
canonicalTrackedPath,
|
|
131
277
|
gitRoot,
|
|
132
278
|
gitRootForPath,
|
|
279
|
+
gitWorktrees,
|
|
133
280
|
gitCommonDir,
|
|
134
281
|
hasChanges,
|
|
282
|
+
changedPaths,
|
|
283
|
+
changedPathsInRepository,
|
|
284
|
+
hasPathChanges,
|
|
135
285
|
addAndCommit,
|
|
136
286
|
stageAll,
|
|
287
|
+
stagePaths,
|
|
288
|
+
commitPaths,
|
|
289
|
+
commitStagedPaths,
|
|
137
290
|
commitStaged,
|
|
138
291
|
stagedChangeContext,
|
|
139
292
|
hasCommits,
|
|
140
293
|
currentBranch,
|
|
141
294
|
pushClean,
|
|
142
|
-
isGitlink
|
|
295
|
+
isGitlink,
|
|
296
|
+
hasRepositoryOperation
|
|
143
297
|
}
|