@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 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. Shell commands alone do not discover
65
- additional repositories.
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
- savePendingBashSnapshot(root, sessionId, toolUseId, cwd, toolName)
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) 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
- }
193
199
  try {
194
- pruneBashSnapshots(root)
195
- 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
+ }
196
206
  } catch (error) {
197
- removeBashSnapshot(root, sessionId, toolUseId)
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 release = acquireBashOverlapRecoveryLock(root, 5000)
228
- 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
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: hookInput.cwd || hookInput.raw?.cwd || snapshot.cwd || root,
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
- 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 }) {
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(root, snapshot.sessionId, entry)
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@searls/turbocommit",
3
- "version": "0.15.3",
3
+ "version": "0.16.0",
4
4
  "description": "Auto-commit after every AI coding agent turn",
5
5
  "bin": {
6
6
  "turbocommit": "./cli.js"