@searls/turbocommit 0.15.3 → 0.16.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 CHANGED
@@ -28,7 +28,14 @@ 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,
33
+ or relative to the working directory or to a directory the command mentions)
34
+ is snapshotted in that checkout too, so a pin bump made with `sed` in a
35
+ sibling repository is attributed and committed there. Only changes under the
36
+ named paths are attributed, bare words never count as paths, and a checkout
37
+ with a merge, rebase, or similar operation in progress is left untouched. A
38
+ tool the pre hook denies leaves no claim behind.
32
39
  Concurrent commands in one session share ownership. Commands from different
33
40
  sessions retain hashed overlap evidence instead of claiming each other's
34
41
  paths. Once every involved turn stops and no shell remains active in that
@@ -61,8 +68,8 @@ turbocommit registers hooks with the harnesses you use:
61
68
  A session that starts in an enabled repository can commit changes in other
62
69
  enabled local checkouts during the same turn. Turbocommit discovers each
63
70
  checkout from explicit file paths supplied to Claude Code tools, Codex
64
- `apply_patch`, and path-bearing MCP tools. Shell commands alone do not discover
65
- additional repositories.
71
+ `apply_patch`, and path-bearing MCP tools, and from paths named in shell
72
+ commands that resolve inside another enabled checkout.
66
73
 
67
74
  Each touched checkout:
68
75
 
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,85 @@
1
+ const fs = require('fs')
2
+ const os = require('os')
3
+ const path = require('path')
4
+ const { canonicalRoot, gitRootForPath, hasRepositoryOperation } = require('./git')
5
+ const { activeConfig } = require('./config')
6
+
7
+ /**
8
+ * Enabled checkouts other than the anchor that a shell command names, each
9
+ * with the paths inside it the command named. Only changes under those paths
10
+ * are attributed, since a command that merely mentions a repository is not
11
+ * evidence that it wrote anywhere else in it.
12
+ *
13
+ * A command can `cd` anywhere before it edits, so every path-shaped word that
14
+ * resolves to an existing path is probed for the repository containing it.
15
+ * Relative words are resolved against the command's working directory and
16
+ * against every absolute directory the command mentions, which is how a loop
17
+ * such as `cd ~/code && for r in app/Core lib/Core; do ...` reaches each
18
+ * checkout. A checkout with a merge, rebase, or similar operation in progress
19
+ * is left alone: its commits cannot be path-scoped, so nothing there may be
20
+ * attributed on the strength of a mention.
21
+ */
22
+ function shellCheckouts (command, cwd, anchor) {
23
+ if (typeof command !== 'string' || !command) return []
24
+ const anchorRoot = canonicalRoot(anchor)
25
+ const words = shellWords(command)
26
+ const bases = [cwd, ...words.filter(word => path.isAbsolute(word) && isDirectory(word))]
27
+
28
+ const named = new Set()
29
+ for (const word of words) {
30
+ const resolved = path.isAbsolute(word)
31
+ ? [word]
32
+ : bases.map(base => path.resolve(base, word))
33
+ for (const candidate of resolved) {
34
+ if (!fs.existsSync(candidate)) continue
35
+ const canonical = canonicalRoot(candidate)
36
+ if (isInside(canonical, anchorRoot) || canonical.startsWith('/dev/')) continue
37
+ named.add(canonical)
38
+ }
39
+ }
40
+
41
+ const rootsByDir = new Map()
42
+ const checkouts = []
43
+ for (const file of named) {
44
+ const dir = isDirectory(file) ? file : path.dirname(file)
45
+ if (!rootsByDir.has(dir)) rootsByDir.set(dir, gitRootForPath(dir))
46
+ const root = rootsByDir.get(dir)
47
+ if (!root || root === anchorRoot) continue
48
+ let checkout = checkouts.find(candidate => candidate.root === root)
49
+ if (!checkout) {
50
+ if (activeConfig(root).config.enabled !== true || hasRepositoryOperation(root)) continue
51
+ checkout = { root, paths: [] }
52
+ checkouts.push(checkout)
53
+ }
54
+ if (!checkout.paths.includes(file)) checkout.paths.push(file)
55
+ }
56
+ return checkouts
57
+ }
58
+
59
+ function shellWords (command) {
60
+ const home = os.homedir()
61
+ return command.split(/[\s;|&()<>`"'=]+/).flatMap(word => {
62
+ word = word.replace(/[,:.]+$/, '')
63
+ if (word === '~' || word.startsWith('~/')) word = home + word.slice(1)
64
+ if (!word || word.startsWith('-') || word.length > 1024) return []
65
+ if (/[$*?{}[\]\\]/.test(word)) return []
66
+ // A bare word is an argument or prose, not a path, unless it exists
67
+ // relative to the working directory itself.
68
+ if (!path.isAbsolute(word) && !word.includes('/')) return []
69
+ return [word]
70
+ })
71
+ }
72
+
73
+ function isDirectory (file) {
74
+ try {
75
+ return fs.statSync(file).isDirectory()
76
+ } catch {
77
+ return false
78
+ }
79
+ }
80
+
81
+ function isInside (file, root) {
82
+ return file === root || file.startsWith(root + path.sep)
83
+ }
84
+
85
+ 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,49 @@ 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.map(checkout => checkout.root)]
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
- savePendingBashSnapshot(root, sessionId, toolUseId, cwd, toolName)
182
+ if (checkouts.length > 0) entry.checkouts = snapshotRoots.slice(1)
175
183
  pendingSnapshot = true
176
- appendTracking(root, sessionId, entry)
184
+ savePendingBashSnapshot(root, sessionId, toolUseId, cwd, toolName, { checkouts: snapshotRoots.slice(1) })
185
+ for (const checkout of checkouts) {
186
+ savePendingBashSnapshot(checkout.root, sessionId, toolUseId, cwd, toolName, { scope: checkout.paths })
187
+ }
177
188
  } else {
178
189
  preclaim = savePreclaim(root, sessionId, entry)
179
190
  }
180
191
  } catch (error) {
181
- if (pendingSnapshot) removeBashSnapshot(root, sessionId, toolUseId)
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
- }
199
+ // The pending snapshots already keep recovery out of every checkout, so
200
+ // the claim is recorded only once the tool is certain to run. A denied
201
+ // tool must leave nothing behind for another session to honor.
193
202
  try {
194
- pruneBashSnapshots(root)
195
- initializeBashSnapshot(root, sessionId, toolUseId, cwd)
203
+ for (const checkout of snapshotRoots) {
204
+ if (!startBashSnapshot(checkout, sessionId, toolUseId, cwd, recoveryWaitMs)) {
205
+ removeSnapshots()
206
+ return false
207
+ }
208
+ }
209
+ appendTracking(root, sessionId, entry)
196
210
  } catch (error) {
197
- removeBashSnapshot(root, sessionId, toolUseId)
211
+ removeSnapshots()
198
212
  throw error
199
- } finally {
200
- release()
201
213
  }
202
214
  return true
203
215
  }
@@ -224,21 +236,49 @@ function handlePostTrack (input, root) {
224
236
  if (!sessionId || !snapshotsRepository(toolName)) return
225
237
 
226
238
  const toolUseId = hookInput.toolUseId || hookInput.tool_use_id
227
- const release = acquireBashOverlapRecoveryLock(root, 5000)
228
- if (!release) return
239
+ const cwd = hookInput.cwd || hookInput.raw?.cwd
240
+ const snapshot = completeBashSnapshot(root, root, sessionId, toolUseId, cwd)
241
+ if (!snapshot) return
242
+ for (const checkout of Array.isArray(snapshot.checkouts) ? snapshot.checkouts : []) {
243
+ completeBashSnapshot(checkout, root, sessionId, toolUseId, cwd)
244
+ }
245
+ }
246
+
247
+ function startBashSnapshot (root, sessionId, toolUseId, cwd, waitMs) {
248
+ const release = acquireBashOverlapRecoveryLock(root, waitMs)
249
+ if (!release) return false
250
+ try {
251
+ pruneBashSnapshots(root)
252
+ initializeBashSnapshot(root, sessionId, toolUseId, cwd)
253
+ return true
254
+ } finally {
255
+ release()
256
+ }
257
+ }
258
+
259
+ function completeBashSnapshot (root, trackingRoot, sessionId, toolUseId, cwd, waitMs = 5000) {
260
+ const release = acquireBashOverlapRecoveryLock(root, waitMs)
261
+ if (!release) return null
229
262
  try {
230
263
  const snapshot = loadBashSnapshot(root, sessionId, toolUseId)
231
- if (!snapshot || Number.isFinite(snapshot.endedAt)) return
264
+ if (!snapshot || Number.isFinite(snapshot.endedAt)) return null
232
265
  finishBashSnapshot(root, snapshot, {
233
- cwd: hookInput.cwd || hookInput.raw?.cwd || snapshot.cwd || root,
234
- endedAt: Date.now()
266
+ cwd: cwd || snapshot.cwd || root,
267
+ endedAt: Date.now(),
268
+ trackingRoot
235
269
  })
270
+ return snapshot
236
271
  } finally {
237
272
  release()
238
273
  }
239
274
  }
240
275
 
241
- function finishBashSnapshot (root, snapshot, { cwd, endedAt }) {
276
+ /**
277
+ * Attributes the paths a finished shell snapshot made dirty in `root` to the
278
+ * session's tracking file in `trackingRoot`, which is the session's anchor
279
+ * checkout when the command reached into another repository.
280
+ */
281
+ function finishBashSnapshot (root, snapshot, { cwd, endedAt, trackingRoot = root }) {
242
282
  if (snapshot.pending === true || !Array.isArray(snapshot.before) || !Number.isFinite(snapshot.startedAt)) return false
243
283
  const overlaps = overlappingBashSnapshots(root, snapshot, endedAt)
244
284
  const before = new Set(snapshot.before.map(canonicalTrackedPath))
@@ -247,7 +287,7 @@ function finishBashSnapshot (root, snapshot, { cwd, endedAt }) {
247
287
  const claimed = overlapping
248
288
  ? claimedPathsBySessions(root)
249
289
  : claimedPathsBySessions(root, snapshot.sessionId)
250
- const candidates = changed.filter(file => !before.has(file) && !claimed.has(file))
290
+ const candidates = changed.filter(file => !before.has(file) && !claimed.has(file) && withinScope(snapshot, file))
251
291
  const files = overlapping ? [] : candidates
252
292
 
253
293
  const entry = { tool: snapshot.toolName || 'Bash', phase: 'post', t: endedAt, cwd }
@@ -259,12 +299,21 @@ function finishBashSnapshot (root, snapshot, { cwd, endedAt }) {
259
299
  entry.rawFiles = files
260
300
  entry.files = files
261
301
  }
262
- appendTracking(root, snapshot.sessionId, entry)
302
+ appendTracking(trackingRoot, snapshot.sessionId, entry)
263
303
  snapshot.endedAt = endedAt
264
304
  writeBashSnapshot(root, snapshot.sessionId, snapshot.toolUseId, snapshot)
265
305
  return true
266
306
  }
267
307
 
308
+ /**
309
+ * A snapshot taken in a checkout the command merely named only attributes
310
+ * paths under what it named; the anchor snapshot has no scope.
311
+ */
312
+ function withinScope (snapshot, file) {
313
+ if (!Array.isArray(snapshot.scope)) return true
314
+ return snapshot.scope.some(named => file === named || file.startsWith(named + path.sep))
315
+ }
316
+
268
317
  function bashSnapshotDir (root) {
269
318
  const base = turbocommitDir(root)
270
319
  return base && path.join(base, 'bash-snapshots')
@@ -279,7 +328,7 @@ function bashSnapshotPath (root, sessionId, toolUseId) {
279
328
  return path.join(dir, key + '.json')
280
329
  }
281
330
 
282
- function savePendingBashSnapshot (root, sessionId, toolUseId, cwd, toolName = 'Bash') {
331
+ function savePendingBashSnapshot (root, sessionId, toolUseId, cwd, toolName = 'Bash', { checkouts = [], scope } = {}) {
283
332
  const checkout = canonicalRoot(root)
284
333
  writeBashSnapshot(root, sessionId, toolUseId, {
285
334
  root: checkout,
@@ -288,7 +337,9 @@ function savePendingBashSnapshot (root, sessionId, toolUseId, cwd, toolName = 'B
288
337
  toolName,
289
338
  toolUseId: toolUseId || null,
290
339
  createdAt: Date.now(),
291
- pending: true
340
+ pending: true,
341
+ ...(checkouts.length > 0 ? { checkouts } : {}),
342
+ ...(Array.isArray(scope) ? { scope } : {})
292
343
  })
293
344
  }
294
345
 
@@ -355,7 +406,22 @@ function overlappingBashSnapshots (root, current, now) {
355
406
  })
356
407
  }
357
408
 
409
+ /**
410
+ * Finishes every shell snapshot the session left unfinished in its anchor and
411
+ * in each checkout its shell commands named. Returns null when a recovery lock
412
+ * could not be acquired in time.
413
+ */
358
414
  function finalizeBashSnapshots (root, sessionId, endedAt = Date.now(), waitMs = 5000) {
415
+ let finalized = 0
416
+ for (const checkout of [root, ...trackedShellCheckouts(root, sessionId)]) {
417
+ const count = finalizeBashSnapshotsIn(checkout, root, sessionId, endedAt, waitMs)
418
+ if (count == null) return null
419
+ finalized += count
420
+ }
421
+ return finalized
422
+ }
423
+
424
+ function finalizeBashSnapshotsIn (root, trackingRoot, sessionId, endedAt, waitMs) {
359
425
  const files = unfinishedBashSnapshotFiles(root, sessionId)
360
426
  if (files.length === 0) return 0
361
427
  const release = acquireBashOverlapRecoveryLock(root, waitMs)
@@ -369,7 +435,8 @@ function finalizeBashSnapshots (root, sessionId, endedAt = Date.now(), waitMs =
369
435
  if (canonicalRoot(snapshot.root) !== canonicalRoot(root)) continue
370
436
  if (finishBashSnapshot(root, snapshot, {
371
437
  cwd: snapshot.cwd || snapshot.root || root,
372
- endedAt
438
+ endedAt,
439
+ trackingRoot
373
440
  })) finalized++
374
441
  } catch {}
375
442
  }
@@ -379,6 +446,22 @@ function finalizeBashSnapshots (root, sessionId, endedAt = Date.now(), waitMs =
379
446
  }
380
447
  }
381
448
 
449
+ /**
450
+ * Other checkouts the session's shell commands named, read from its tracking
451
+ * file in the anchor checkout.
452
+ */
453
+ function trackedShellCheckouts (root, sessionId) {
454
+ const anchor = canonicalRoot(root)
455
+ const checkouts = []
456
+ for (const entry of readTracking(root, sessionId)) {
457
+ for (const checkout of Array.isArray(entry.checkouts) ? entry.checkouts : []) {
458
+ const canonical = canonicalRoot(checkout)
459
+ if (canonical !== anchor && !checkouts.includes(canonical)) checkouts.push(canonical)
460
+ }
461
+ }
462
+ return checkouts
463
+ }
464
+
382
465
  function unfinishedBashSnapshotFiles (root, sessionId) {
383
466
  const dir = bashSnapshotDir(root)
384
467
  let files
@@ -546,6 +629,13 @@ function fingerprintTrackedPath (file) {
546
629
  }
547
630
 
548
631
  function recordBashSessionStop (root, sessionId, at = Date.now()) {
632
+ recordBashSessionStopIn(root, sessionId, at)
633
+ for (const checkout of trackedShellCheckouts(root, sessionId)) {
634
+ recordBashSessionStopIn(checkout, sessionId, at)
635
+ }
636
+ }
637
+
638
+ function recordBashSessionStopIn (root, sessionId, at) {
549
639
  const overlapFile = bashOverlapPath(root)
550
640
  if (!overlapFile || !fs.existsSync(overlapFile)) return
551
641
  const file = sessionId && bashSessionStopPath(root, sessionId)
@@ -1007,6 +1097,7 @@ module.exports = {
1007
1097
  extractFilePaths,
1008
1098
  extractRawFilePaths,
1009
1099
  readTracking,
1100
+ trackedShellCheckouts,
1010
1101
  trackingDir,
1011
1102
  trackingPath
1012
1103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@searls/turbocommit",
3
- "version": "0.15.3",
3
+ "version": "0.16.1",
4
4
  "description": "Auto-commit after every AI coding agent turn",
5
5
  "bin": {
6
6
  "turbocommit": "./cli.js"