@searls/turbocommit 0.15.3 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/lib/run.js +6 -1
- package/lib/shell.js +70 -0
- package/lib/track.js +99 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,7 +28,11 @@ turbocommit registers hooks with the harnesses you use:
|
|
|
28
28
|
snapshots the repository before shell commands and MCP tool calls.
|
|
29
29
|
- **PostToolUse** attributes paths that became dirty during a shell command or
|
|
30
30
|
MCP tool call, so files a tool rewrites without naming them (an Xcode project
|
|
31
|
-
file, for example) belong to the session that changed them.
|
|
31
|
+
file, for example) belong to the session that changed them. A shell command
|
|
32
|
+
that names a path inside another enabled checkout (absolute, `~`-relative, or
|
|
33
|
+
relative to a directory the command mentions) is snapshotted in that checkout
|
|
34
|
+
too, so a pin bump made with `sed` in a sibling repository is attributed and
|
|
35
|
+
committed there.
|
|
32
36
|
Concurrent commands in one session share ownership. Commands from different
|
|
33
37
|
sessions retain hashed overlap evidence instead of claiming each other's
|
|
34
38
|
paths. Once every involved turn stops and no shell remains active in that
|
|
@@ -61,8 +65,8 @@ turbocommit registers hooks with the harnesses you use:
|
|
|
61
65
|
A session that starts in an enabled repository can commit changes in other
|
|
62
66
|
enabled local checkouts during the same turn. Turbocommit discovers each
|
|
63
67
|
checkout from explicit file paths supplied to Claude Code tools, Codex
|
|
64
|
-
`apply_patch`, and path-bearing MCP tools
|
|
65
|
-
|
|
68
|
+
`apply_patch`, and path-bearing MCP tools, and from paths named in shell
|
|
69
|
+
commands that resolve inside another enabled checkout.
|
|
66
70
|
|
|
67
71
|
Each touched checkout:
|
|
68
72
|
|
package/lib/run.js
CHANGED
|
@@ -21,7 +21,8 @@ const {
|
|
|
21
21
|
hasActiveBashSnapshot,
|
|
22
22
|
acquireBashOverlapRecoveryLock,
|
|
23
23
|
fingerprintTrackedPath,
|
|
24
|
-
claimedPathsBySessions
|
|
24
|
+
claimedPathsBySessions,
|
|
25
|
+
trackedShellCheckouts
|
|
25
26
|
} = require('./track')
|
|
26
27
|
const { redact, buildRedactions } = require('./redact')
|
|
27
28
|
const { handleSessionEnd, getAncestors, savePending, collectPending, cleanupConsumed, cleanupStale, readWatermark, saveWatermark, resolveParentCommit } = require('./session')
|
|
@@ -128,6 +129,7 @@ function run (input, opts = {}) {
|
|
|
128
129
|
if (config.enabled !== true) return
|
|
129
130
|
const recoveryDeadline = opts.recoveryDeadline ?? recoveryDeadlineFromWait(opts.recoveryWaitMs ?? 5000)
|
|
130
131
|
|
|
132
|
+
const checkouts = hookInput.sessionId ? trackedShellCheckouts(root, hookInput.sessionId) : []
|
|
131
133
|
if (hookInput.sessionId) {
|
|
132
134
|
const finalized = finalizeBashSnapshots(root, hookInput.sessionId, Date.now(), remainingRecoveryWait(recoveryDeadline))
|
|
133
135
|
if (finalized == null) return
|
|
@@ -139,6 +141,9 @@ function run (input, opts = {}) {
|
|
|
139
141
|
} finally {
|
|
140
142
|
const recoveryConfig = opts.deferPush ? { ...config, push: false } : config
|
|
141
143
|
recoverBashOverlaps(root, hookInput, recoveryConfig, remainingRecoveryWait(recoveryDeadline))
|
|
144
|
+
for (const checkout of checkouts) {
|
|
145
|
+
recoverBashOverlaps(checkout, hookInput, undefined, remainingRecoveryWait(recoveryDeadline))
|
|
146
|
+
}
|
|
142
147
|
}
|
|
143
148
|
}
|
|
144
149
|
|
package/lib/shell.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const os = require('os')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const { canonicalRoot, gitRootForPath } = require('./git')
|
|
5
|
+
const { activeConfig } = require('./config')
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Enabled checkouts other than the anchor that a shell command names.
|
|
9
|
+
*
|
|
10
|
+
* A command can `cd` anywhere before it edits, so every word that resolves to
|
|
11
|
+
* an existing path is probed for the repository containing it. Relative words
|
|
12
|
+
* are resolved against the command's working directory and against every
|
|
13
|
+
* absolute directory the command mentions, which is how a loop such as
|
|
14
|
+
* `cd ~/code && for r in app/Core lib/Core; do ...` reaches each checkout.
|
|
15
|
+
*/
|
|
16
|
+
function shellCheckouts (command, cwd, anchor) {
|
|
17
|
+
if (typeof command !== 'string' || !command) return []
|
|
18
|
+
const anchorRoot = canonicalRoot(anchor)
|
|
19
|
+
const words = shellWords(command)
|
|
20
|
+
const bases = [cwd, ...words.filter(word => path.isAbsolute(word) && isDirectory(word))]
|
|
21
|
+
|
|
22
|
+
const candidates = new Set()
|
|
23
|
+
for (const word of words) {
|
|
24
|
+
const resolved = path.isAbsolute(word)
|
|
25
|
+
? [word]
|
|
26
|
+
: bases.map(base => path.resolve(base, word))
|
|
27
|
+
for (const candidate of resolved) {
|
|
28
|
+
if (!fs.existsSync(candidate)) continue
|
|
29
|
+
const canonical = canonicalRoot(candidate)
|
|
30
|
+
if (isInside(canonical, anchorRoot) || canonical.startsWith('/dev/')) continue
|
|
31
|
+
candidates.add(isDirectory(canonical) ? canonical : path.dirname(canonical))
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rootsByDir = new Map()
|
|
36
|
+
const checkouts = []
|
|
37
|
+
for (const dir of candidates) {
|
|
38
|
+
if (!rootsByDir.has(dir)) rootsByDir.set(dir, gitRootForPath(dir))
|
|
39
|
+
const root = rootsByDir.get(dir)
|
|
40
|
+
if (!root || root === anchorRoot || checkouts.includes(root)) continue
|
|
41
|
+
if (activeConfig(root).config.enabled !== true) continue
|
|
42
|
+
checkouts.push(root)
|
|
43
|
+
}
|
|
44
|
+
return checkouts
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function shellWords (command) {
|
|
48
|
+
const home = os.homedir()
|
|
49
|
+
return command.split(/[\s;|&()<>`"'=]+/).flatMap(word => {
|
|
50
|
+
word = word.replace(/[,:.]+$/, '')
|
|
51
|
+
if (word === '~' || word.startsWith('~/')) word = home + word.slice(1)
|
|
52
|
+
if (!word || word.startsWith('-') || word.length > 1024) return []
|
|
53
|
+
if (/[$*?{}[\]\\]/.test(word)) return []
|
|
54
|
+
return [word]
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isDirectory (file) {
|
|
59
|
+
try {
|
|
60
|
+
return fs.statSync(file).isDirectory()
|
|
61
|
+
} catch {
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isInside (file, root) {
|
|
67
|
+
return file === root || file.startsWith(root + path.sep)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { shellCheckouts }
|
package/lib/track.js
CHANGED
|
@@ -6,6 +6,7 @@ const { fileURLToPath } = require('url')
|
|
|
6
6
|
const { ensureDir } = require('./io')
|
|
7
7
|
const { turbocommitDir } = require('./session')
|
|
8
8
|
const { canonicalRoot, canonicalTrackedPath, changedPathsInRepository, gitCommonDir } = require('./git')
|
|
9
|
+
const { shellCheckouts } = require('./shell')
|
|
9
10
|
|
|
10
11
|
const BASH_SNAPSHOT_TTL_MS = 60 * 60 * 1000
|
|
11
12
|
// A session's tracking file is rewritten on every tool call and deleted when
|
|
@@ -166,38 +167,45 @@ function handleTrack (input, root, opts = {}) {
|
|
|
166
167
|
if (files.length > 0) entry.files = files
|
|
167
168
|
|
|
168
169
|
const toolUseId = hookInput.toolUseId || hookInput.tool_use_id
|
|
170
|
+
// A shell command can edit any checkout it names, so each of those is
|
|
171
|
+
// snapshotted alongside the anchor and attributed back to this session.
|
|
172
|
+
const checkouts = toolName === 'Bash' ? shellCheckouts(toolInput.command, cwd, root) : []
|
|
173
|
+
const snapshotRoots = [root, ...checkouts]
|
|
174
|
+
const removeSnapshots = () => {
|
|
175
|
+
for (const checkout of snapshotRoots) removeBashSnapshot(checkout, sessionId, toolUseId)
|
|
176
|
+
}
|
|
169
177
|
let pendingSnapshot = false
|
|
170
178
|
let preclaim = null
|
|
171
179
|
try {
|
|
172
180
|
if (snapshotsRepository(toolName)) {
|
|
173
181
|
if (toolName === 'Bash') entry.command = toolInput.command
|
|
174
|
-
|
|
182
|
+
if (checkouts.length > 0) entry.checkouts = checkouts
|
|
175
183
|
pendingSnapshot = true
|
|
184
|
+
for (const checkout of snapshotRoots) {
|
|
185
|
+
savePendingBashSnapshot(checkout, sessionId, toolUseId, cwd, toolName, checkout === root ? checkouts : [])
|
|
186
|
+
}
|
|
176
187
|
appendTracking(root, sessionId, entry)
|
|
177
188
|
} else {
|
|
178
189
|
preclaim = savePreclaim(root, sessionId, entry)
|
|
179
190
|
}
|
|
180
191
|
} catch (error) {
|
|
181
|
-
if (pendingSnapshot)
|
|
192
|
+
if (pendingSnapshot) removeSnapshots()
|
|
182
193
|
if (preclaim) removePreclaim(preclaim)
|
|
183
194
|
throw error
|
|
184
195
|
}
|
|
185
196
|
|
|
186
197
|
const recoveryWaitMs = opts.recoveryWaitMs ?? 5000
|
|
187
198
|
if (pendingSnapshot) {
|
|
188
|
-
const release = acquireBashOverlapRecoveryLock(root, recoveryWaitMs)
|
|
189
|
-
if (!release) {
|
|
190
|
-
removeBashSnapshot(root, sessionId, toolUseId)
|
|
191
|
-
return false
|
|
192
|
-
}
|
|
193
199
|
try {
|
|
194
|
-
|
|
195
|
-
|
|
200
|
+
for (const checkout of snapshotRoots) {
|
|
201
|
+
if (!startBashSnapshot(checkout, sessionId, toolUseId, cwd, recoveryWaitMs)) {
|
|
202
|
+
removeSnapshots()
|
|
203
|
+
return false
|
|
204
|
+
}
|
|
205
|
+
}
|
|
196
206
|
} catch (error) {
|
|
197
|
-
|
|
207
|
+
removeSnapshots()
|
|
198
208
|
throw error
|
|
199
|
-
} finally {
|
|
200
|
-
release()
|
|
201
209
|
}
|
|
202
210
|
return true
|
|
203
211
|
}
|
|
@@ -224,21 +232,49 @@ function handlePostTrack (input, root) {
|
|
|
224
232
|
if (!sessionId || !snapshotsRepository(toolName)) return
|
|
225
233
|
|
|
226
234
|
const toolUseId = hookInput.toolUseId || hookInput.tool_use_id
|
|
227
|
-
const
|
|
228
|
-
|
|
235
|
+
const cwd = hookInput.cwd || hookInput.raw?.cwd
|
|
236
|
+
const snapshot = completeBashSnapshot(root, root, sessionId, toolUseId, cwd)
|
|
237
|
+
if (!snapshot) return
|
|
238
|
+
for (const checkout of Array.isArray(snapshot.checkouts) ? snapshot.checkouts : []) {
|
|
239
|
+
completeBashSnapshot(checkout, root, sessionId, toolUseId, cwd)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function startBashSnapshot (root, sessionId, toolUseId, cwd, waitMs) {
|
|
244
|
+
const release = acquireBashOverlapRecoveryLock(root, waitMs)
|
|
245
|
+
if (!release) return false
|
|
246
|
+
try {
|
|
247
|
+
pruneBashSnapshots(root)
|
|
248
|
+
initializeBashSnapshot(root, sessionId, toolUseId, cwd)
|
|
249
|
+
return true
|
|
250
|
+
} finally {
|
|
251
|
+
release()
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function completeBashSnapshot (root, trackingRoot, sessionId, toolUseId, cwd, waitMs = 5000) {
|
|
256
|
+
const release = acquireBashOverlapRecoveryLock(root, waitMs)
|
|
257
|
+
if (!release) return null
|
|
229
258
|
try {
|
|
230
259
|
const snapshot = loadBashSnapshot(root, sessionId, toolUseId)
|
|
231
|
-
if (!snapshot || Number.isFinite(snapshot.endedAt)) return
|
|
260
|
+
if (!snapshot || Number.isFinite(snapshot.endedAt)) return null
|
|
232
261
|
finishBashSnapshot(root, snapshot, {
|
|
233
|
-
cwd:
|
|
234
|
-
endedAt: Date.now()
|
|
262
|
+
cwd: cwd || snapshot.cwd || root,
|
|
263
|
+
endedAt: Date.now(),
|
|
264
|
+
trackingRoot
|
|
235
265
|
})
|
|
266
|
+
return snapshot
|
|
236
267
|
} finally {
|
|
237
268
|
release()
|
|
238
269
|
}
|
|
239
270
|
}
|
|
240
271
|
|
|
241
|
-
|
|
272
|
+
/**
|
|
273
|
+
* Attributes the paths a finished shell snapshot made dirty in `root` to the
|
|
274
|
+
* session's tracking file in `trackingRoot`, which is the session's anchor
|
|
275
|
+
* checkout when the command reached into another repository.
|
|
276
|
+
*/
|
|
277
|
+
function finishBashSnapshot (root, snapshot, { cwd, endedAt, trackingRoot = root }) {
|
|
242
278
|
if (snapshot.pending === true || !Array.isArray(snapshot.before) || !Number.isFinite(snapshot.startedAt)) return false
|
|
243
279
|
const overlaps = overlappingBashSnapshots(root, snapshot, endedAt)
|
|
244
280
|
const before = new Set(snapshot.before.map(canonicalTrackedPath))
|
|
@@ -259,7 +295,7 @@ function finishBashSnapshot (root, snapshot, { cwd, endedAt }) {
|
|
|
259
295
|
entry.rawFiles = files
|
|
260
296
|
entry.files = files
|
|
261
297
|
}
|
|
262
|
-
appendTracking(
|
|
298
|
+
appendTracking(trackingRoot, snapshot.sessionId, entry)
|
|
263
299
|
snapshot.endedAt = endedAt
|
|
264
300
|
writeBashSnapshot(root, snapshot.sessionId, snapshot.toolUseId, snapshot)
|
|
265
301
|
return true
|
|
@@ -279,7 +315,7 @@ function bashSnapshotPath (root, sessionId, toolUseId) {
|
|
|
279
315
|
return path.join(dir, key + '.json')
|
|
280
316
|
}
|
|
281
317
|
|
|
282
|
-
function savePendingBashSnapshot (root, sessionId, toolUseId, cwd, toolName = 'Bash') {
|
|
318
|
+
function savePendingBashSnapshot (root, sessionId, toolUseId, cwd, toolName = 'Bash', checkouts = []) {
|
|
283
319
|
const checkout = canonicalRoot(root)
|
|
284
320
|
writeBashSnapshot(root, sessionId, toolUseId, {
|
|
285
321
|
root: checkout,
|
|
@@ -288,7 +324,8 @@ function savePendingBashSnapshot (root, sessionId, toolUseId, cwd, toolName = 'B
|
|
|
288
324
|
toolName,
|
|
289
325
|
toolUseId: toolUseId || null,
|
|
290
326
|
createdAt: Date.now(),
|
|
291
|
-
pending: true
|
|
327
|
+
pending: true,
|
|
328
|
+
...(checkouts.length > 0 ? { checkouts } : {})
|
|
292
329
|
})
|
|
293
330
|
}
|
|
294
331
|
|
|
@@ -355,7 +392,22 @@ function overlappingBashSnapshots (root, current, now) {
|
|
|
355
392
|
})
|
|
356
393
|
}
|
|
357
394
|
|
|
395
|
+
/**
|
|
396
|
+
* Finishes every shell snapshot the session left unfinished in its anchor and
|
|
397
|
+
* in each checkout its shell commands named. Returns null when a recovery lock
|
|
398
|
+
* could not be acquired in time.
|
|
399
|
+
*/
|
|
358
400
|
function finalizeBashSnapshots (root, sessionId, endedAt = Date.now(), waitMs = 5000) {
|
|
401
|
+
let finalized = 0
|
|
402
|
+
for (const checkout of [root, ...trackedShellCheckouts(root, sessionId)]) {
|
|
403
|
+
const count = finalizeBashSnapshotsIn(checkout, root, sessionId, endedAt, waitMs)
|
|
404
|
+
if (count == null) return null
|
|
405
|
+
finalized += count
|
|
406
|
+
}
|
|
407
|
+
return finalized
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function finalizeBashSnapshotsIn (root, trackingRoot, sessionId, endedAt, waitMs) {
|
|
359
411
|
const files = unfinishedBashSnapshotFiles(root, sessionId)
|
|
360
412
|
if (files.length === 0) return 0
|
|
361
413
|
const release = acquireBashOverlapRecoveryLock(root, waitMs)
|
|
@@ -369,7 +421,8 @@ function finalizeBashSnapshots (root, sessionId, endedAt = Date.now(), waitMs =
|
|
|
369
421
|
if (canonicalRoot(snapshot.root) !== canonicalRoot(root)) continue
|
|
370
422
|
if (finishBashSnapshot(root, snapshot, {
|
|
371
423
|
cwd: snapshot.cwd || snapshot.root || root,
|
|
372
|
-
endedAt
|
|
424
|
+
endedAt,
|
|
425
|
+
trackingRoot
|
|
373
426
|
})) finalized++
|
|
374
427
|
} catch {}
|
|
375
428
|
}
|
|
@@ -379,6 +432,22 @@ function finalizeBashSnapshots (root, sessionId, endedAt = Date.now(), waitMs =
|
|
|
379
432
|
}
|
|
380
433
|
}
|
|
381
434
|
|
|
435
|
+
/**
|
|
436
|
+
* Other checkouts the session's shell commands named, read from its tracking
|
|
437
|
+
* file in the anchor checkout.
|
|
438
|
+
*/
|
|
439
|
+
function trackedShellCheckouts (root, sessionId) {
|
|
440
|
+
const anchor = canonicalRoot(root)
|
|
441
|
+
const checkouts = []
|
|
442
|
+
for (const entry of readTracking(root, sessionId)) {
|
|
443
|
+
for (const checkout of Array.isArray(entry.checkouts) ? entry.checkouts : []) {
|
|
444
|
+
const canonical = canonicalRoot(checkout)
|
|
445
|
+
if (canonical !== anchor && !checkouts.includes(canonical)) checkouts.push(canonical)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return checkouts
|
|
449
|
+
}
|
|
450
|
+
|
|
382
451
|
function unfinishedBashSnapshotFiles (root, sessionId) {
|
|
383
452
|
const dir = bashSnapshotDir(root)
|
|
384
453
|
let files
|
|
@@ -546,6 +615,13 @@ function fingerprintTrackedPath (file) {
|
|
|
546
615
|
}
|
|
547
616
|
|
|
548
617
|
function recordBashSessionStop (root, sessionId, at = Date.now()) {
|
|
618
|
+
recordBashSessionStopIn(root, sessionId, at)
|
|
619
|
+
for (const checkout of trackedShellCheckouts(root, sessionId)) {
|
|
620
|
+
recordBashSessionStopIn(checkout, sessionId, at)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function recordBashSessionStopIn (root, sessionId, at) {
|
|
549
625
|
const overlapFile = bashOverlapPath(root)
|
|
550
626
|
if (!overlapFile || !fs.existsSync(overlapFile)) return
|
|
551
627
|
const file = sessionId && bashSessionStopPath(root, sessionId)
|
|
@@ -1007,6 +1083,7 @@ module.exports = {
|
|
|
1007
1083
|
extractFilePaths,
|
|
1008
1084
|
extractRawFilePaths,
|
|
1009
1085
|
readTracking,
|
|
1086
|
+
trackedShellCheckouts,
|
|
1010
1087
|
trackingDir,
|
|
1011
1088
|
trackingPath
|
|
1012
1089
|
}
|