@searls/turbocommit 0.15.2 → 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 CHANGED
@@ -25,8 +25,14 @@ turbocommit registers hooks with the harnesses you use:
25
25
 
26
26
  - **PreToolUse** tentatively claims the paths supplied to editing tools while
27
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.
28
+ snapshots the repository before shell commands and MCP tool calls.
29
+ - **PostToolUse** attributes paths that became dirty during a shell command or
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. 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.
30
36
  Concurrent commands in one session share ownership. Commands from different
31
37
  sessions retain hashed overlap evidence instead of claiming each other's
32
38
  paths. Once every involved turn stops and no shell remains active in that
@@ -59,8 +65,8 @@ turbocommit registers hooks with the harnesses you use:
59
65
  A session that starts in an enabled repository can commit changes in other
60
66
  enabled local checkouts during the same turn. Turbocommit discovers each
61
67
  checkout from explicit file paths supplied to Claude Code tools, Codex
62
- `apply_patch`, and path-bearing MCP tools. Shell commands alone do not discover
63
- additional repositories.
68
+ `apply_patch`, and path-bearing MCP tools, and from paths named in shell
69
+ commands that resolve inside another enabled checkout.
64
70
 
65
71
  Each touched checkout:
66
72
 
package/lib/install.js CHANGED
@@ -11,11 +11,11 @@ const HOOK_DEFS = {
11
11
  hooks: [{ type: 'command', command: 'turbocommit hook pre-tool-use --harness claude' }]
12
12
  },
13
13
  PostToolUse: {
14
- matcher: 'Bash',
14
+ matcher: 'Bash|mcp__.*',
15
15
  hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness claude' }]
16
16
  },
17
17
  PostToolUseFailure: {
18
- matcher: 'Bash',
18
+ matcher: 'Bash|mcp__.*',
19
19
  hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness claude' }]
20
20
  },
21
21
  SessionStart: {
@@ -35,7 +35,7 @@ const CODEX_HOOK_DEFS = {
35
35
  hooks: [{ type: 'command', command: 'turbocommit hook pre-tool-use --harness codex' }]
36
36
  },
37
37
  PostToolUse: {
38
- matcher: 'Bash',
38
+ matcher: 'Bash|mcp__.*',
39
39
  hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness codex' }]
40
40
  },
41
41
  SessionStart: {
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/session.js CHANGED
@@ -328,11 +328,10 @@ function trackingMayDescribeModifications (file) {
328
328
  } catch {
329
329
  return true
330
330
  }
331
+ const { describesModification } = require('./track')
331
332
  return lines.some(line => {
332
333
  try {
333
- const entry = JSON.parse(line)
334
- return entry.tool !== 'Bash' ||
335
- (entry.phase === 'post' && Array.isArray(entry.files) && entry.files.length > 0)
334
+ return describesModification(JSON.parse(line))
336
335
  } catch {
337
336
  return true
338
337
  }
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
@@ -45,6 +46,8 @@ const FILE_PATH_KEYS = [
45
46
  'notebook_path',
46
47
  'relative_path',
47
48
  'relativePath',
49
+ 'sourcePath',
50
+ 'destinationPath',
48
51
  'uri'
49
52
  ]
50
53
  const FILE_PATH_ARRAY_KEYS = ['file_paths', 'filePaths', 'paths', 'files']
@@ -124,6 +127,15 @@ function extractFilePaths (toolName, toolInput, cwd) {
124
127
  })
125
128
  }
126
129
 
130
+ /**
131
+ * Bash and MCP tools change paths they never name (a shell script, an Xcode
132
+ * project file rewritten as a side effect), so both are attributed by
133
+ * snapshotting the checkout before and after the call.
134
+ */
135
+ function snapshotsRepository (toolName) {
136
+ return toolName === 'Bash' || (typeof toolName === 'string' && toolName.startsWith('mcp__'))
137
+ }
138
+
127
139
  /**
128
140
  * PreToolUse handler. Persists ownership before waiting for overlap recovery.
129
141
  * Returns false when the caller must deny the tool rather than let it run
@@ -155,38 +167,45 @@ function handleTrack (input, root, opts = {}) {
155
167
  if (files.length > 0) entry.files = files
156
168
 
157
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
+ }
158
177
  let pendingSnapshot = false
159
178
  let preclaim = null
160
179
  try {
161
- if (toolName === 'Bash') {
162
- entry.command = toolInput.command
163
- savePendingBashSnapshot(root, sessionId, toolUseId, cwd)
180
+ if (snapshotsRepository(toolName)) {
181
+ if (toolName === 'Bash') entry.command = toolInput.command
182
+ if (checkouts.length > 0) entry.checkouts = checkouts
164
183
  pendingSnapshot = true
184
+ for (const checkout of snapshotRoots) {
185
+ savePendingBashSnapshot(checkout, sessionId, toolUseId, cwd, toolName, checkout === root ? checkouts : [])
186
+ }
165
187
  appendTracking(root, sessionId, entry)
166
188
  } else {
167
189
  preclaim = savePreclaim(root, sessionId, entry)
168
190
  }
169
191
  } catch (error) {
170
- if (pendingSnapshot) removeBashSnapshot(root, sessionId, toolUseId)
192
+ if (pendingSnapshot) removeSnapshots()
171
193
  if (preclaim) removePreclaim(preclaim)
172
194
  throw error
173
195
  }
174
196
 
175
197
  const recoveryWaitMs = opts.recoveryWaitMs ?? 5000
176
198
  if (pendingSnapshot) {
177
- const release = acquireBashOverlapRecoveryLock(root, recoveryWaitMs)
178
- if (!release) {
179
- removeBashSnapshot(root, sessionId, toolUseId)
180
- return false
181
- }
182
199
  try {
183
- pruneBashSnapshots(root)
184
- initializeBashSnapshot(root, sessionId, toolUseId, cwd)
200
+ for (const checkout of snapshotRoots) {
201
+ if (!startBashSnapshot(checkout, sessionId, toolUseId, cwd, recoveryWaitMs)) {
202
+ removeSnapshots()
203
+ return false
204
+ }
205
+ }
185
206
  } catch (error) {
186
- removeBashSnapshot(root, sessionId, toolUseId)
207
+ removeSnapshots()
187
208
  throw error
188
- } finally {
189
- release()
190
209
  }
191
210
  return true
192
211
  }
@@ -210,24 +229,52 @@ function handlePostTrack (input, root) {
210
229
 
211
230
  const sessionId = hookInput.sessionId || hookInput.session_id
212
231
  const toolName = hookInput.toolName || hookInput.tool_name
213
- if (!sessionId || toolName !== 'Bash') return
232
+ if (!sessionId || !snapshotsRepository(toolName)) return
214
233
 
215
234
  const toolUseId = hookInput.toolUseId || hookInput.tool_use_id
216
- const release = acquireBashOverlapRecoveryLock(root, 5000)
217
- if (!release) return
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
218
258
  try {
219
259
  const snapshot = loadBashSnapshot(root, sessionId, toolUseId)
220
- if (!snapshot || Number.isFinite(snapshot.endedAt)) return
260
+ if (!snapshot || Number.isFinite(snapshot.endedAt)) return null
221
261
  finishBashSnapshot(root, snapshot, {
222
- cwd: hookInput.cwd || hookInput.raw?.cwd || snapshot.cwd || root,
223
- endedAt: Date.now()
262
+ cwd: cwd || snapshot.cwd || root,
263
+ endedAt: Date.now(),
264
+ trackingRoot
224
265
  })
266
+ return snapshot
225
267
  } finally {
226
268
  release()
227
269
  }
228
270
  }
229
271
 
230
- function finishBashSnapshot (root, snapshot, { cwd, endedAt }) {
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 }) {
231
278
  if (snapshot.pending === true || !Array.isArray(snapshot.before) || !Number.isFinite(snapshot.startedAt)) return false
232
279
  const overlaps = overlappingBashSnapshots(root, snapshot, endedAt)
233
280
  const before = new Set(snapshot.before.map(canonicalTrackedPath))
@@ -239,7 +286,7 @@ function finishBashSnapshot (root, snapshot, { cwd, endedAt }) {
239
286
  const candidates = changed.filter(file => !before.has(file) && !claimed.has(file))
240
287
  const files = overlapping ? [] : candidates
241
288
 
242
- const entry = { tool: 'Bash', phase: 'post', t: endedAt, cwd }
289
+ const entry = { tool: snapshot.toolName || 'Bash', phase: 'post', t: endedAt, cwd }
243
290
  if (overlapping) {
244
291
  entry.overlapping = true
245
292
  entry.overlapEventId = recordBashOverlap(root, snapshot, overlaps, candidates, endedAt)
@@ -248,7 +295,7 @@ function finishBashSnapshot (root, snapshot, { cwd, endedAt }) {
248
295
  entry.rawFiles = files
249
296
  entry.files = files
250
297
  }
251
- appendTracking(root, snapshot.sessionId, entry)
298
+ appendTracking(trackingRoot, snapshot.sessionId, entry)
252
299
  snapshot.endedAt = endedAt
253
300
  writeBashSnapshot(root, snapshot.sessionId, snapshot.toolUseId, snapshot)
254
301
  return true
@@ -268,15 +315,17 @@ function bashSnapshotPath (root, sessionId, toolUseId) {
268
315
  return path.join(dir, key + '.json')
269
316
  }
270
317
 
271
- function savePendingBashSnapshot (root, sessionId, toolUseId, cwd) {
318
+ function savePendingBashSnapshot (root, sessionId, toolUseId, cwd, toolName = 'Bash', checkouts = []) {
272
319
  const checkout = canonicalRoot(root)
273
320
  writeBashSnapshot(root, sessionId, toolUseId, {
274
321
  root: checkout,
275
322
  cwd: cwd || checkout,
276
323
  sessionId,
324
+ toolName,
277
325
  toolUseId: toolUseId || null,
278
326
  createdAt: Date.now(),
279
- pending: true
327
+ pending: true,
328
+ ...(checkouts.length > 0 ? { checkouts } : {})
280
329
  })
281
330
  }
282
331
 
@@ -343,7 +392,22 @@ function overlappingBashSnapshots (root, current, now) {
343
392
  })
344
393
  }
345
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
+ */
346
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) {
347
411
  const files = unfinishedBashSnapshotFiles(root, sessionId)
348
412
  if (files.length === 0) return 0
349
413
  const release = acquireBashOverlapRecoveryLock(root, waitMs)
@@ -357,7 +421,8 @@ function finalizeBashSnapshots (root, sessionId, endedAt = Date.now(), waitMs =
357
421
  if (canonicalRoot(snapshot.root) !== canonicalRoot(root)) continue
358
422
  if (finishBashSnapshot(root, snapshot, {
359
423
  cwd: snapshot.cwd || snapshot.root || root,
360
- endedAt
424
+ endedAt,
425
+ trackingRoot
361
426
  })) finalized++
362
427
  } catch {}
363
428
  }
@@ -367,6 +432,22 @@ function finalizeBashSnapshots (root, sessionId, endedAt = Date.now(), waitMs =
367
432
  }
368
433
  }
369
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
+
370
451
  function unfinishedBashSnapshotFiles (root, sessionId) {
371
452
  const dir = bashSnapshotDir(root)
372
453
  let files
@@ -534,6 +615,13 @@ function fingerprintTrackedPath (file) {
534
615
  }
535
616
 
536
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) {
537
625
  const overlapFile = bashOverlapPath(root)
538
626
  if (!overlapFile || !fs.existsSync(overlapFile)) return
539
627
  const file = sessionId && bashSessionStopPath(root, sessionId)
@@ -918,12 +1006,15 @@ function parseInput (input) {
918
1006
  /**
919
1007
  * Check whether a session has tracked any file-modifying tool calls.
920
1008
  * A Bash pre-hook alone doesn't count because shell commands may be read-only.
921
- * A Bash post-hook counts only when its snapshot found newly dirty paths.
1009
+ * A snapshot post-hook counts only when it found newly dirty paths.
922
1010
  */
923
1011
  function hasTrackedModifications (root, sessionId) {
924
- return readTracking(root, sessionId).some(entry =>
925
- entry.tool !== 'Bash' || (entry.phase === 'post' && Array.isArray(entry.files) && entry.files.length > 0)
926
- )
1012
+ return readTracking(root, sessionId).some(describesModification)
1013
+ }
1014
+
1015
+ function describesModification (entry) {
1016
+ if (entry.phase === 'post') return Array.isArray(entry.files) && entry.files.length > 0
1017
+ return entry.tool !== 'Bash'
927
1018
  }
928
1019
 
929
1020
  function readTracking (root, sessionId) {
@@ -986,11 +1077,13 @@ module.exports = {
986
1077
  fingerprintTrackedPath,
987
1078
  claimedPathsBySessions,
988
1079
  hasTrackedModifications,
1080
+ describesModification,
989
1081
  cleanupTracking,
990
1082
  extractFilePath,
991
1083
  extractFilePaths,
992
1084
  extractRawFilePaths,
993
1085
  readTracking,
1086
+ trackedShellCheckouts,
994
1087
  trackingDir,
995
1088
  trackingPath
996
1089
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@searls/turbocommit",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
4
  "description": "Auto-commit after every AI coding agent turn",
5
5
  "bin": {
6
6
  "turbocommit": "./cli.js"