@searls/turbocommit 0.16.0 → 0.16.2

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
@@ -29,10 +29,13 @@ turbocommit registers hooks with the harnesses you use:
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
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
+ 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.
36
39
  Concurrent commands in one session share ownership. Commands from different
37
40
  sessions retain hashed overlap evidence instead of claiming each other's
38
41
  paths. Once every involved turn stops and no shell remains active in that
@@ -77,8 +80,14 @@ Each touched checkout:
77
80
  - Gives the configured title agent repository-specific bounded diff context.
78
81
 
79
82
  Turbocommit attempts every commit before pushing every successful commit whose
80
- repository has `push: true`. Failures are retained and retried on a later Stop,
81
- along with every transcript accumulated before the commit succeeds.
83
+ repository has `push: true`. Staging and committing are retried a few times
84
+ when Git is momentarily unavailable, for example while Xcode holds the index
85
+ lock. Failures are retained and retried on a later Stop in that checkout by
86
+ any session, along with every transcript accumulated before the commit
87
+ succeeds, and the failed session's claims are released so they do not block
88
+ other sessions meanwhile. Each `fail` monitor event records Git's error. A
89
+ deletion the session already staged with `git rm` is committed with the rest
90
+ of its paths.
82
91
 
83
92
  Separate worktrees are committed independently. Enabled submodules are
84
93
  committed and pushed before enabled parent repositories. When one turn creates
package/lib/git.js CHANGED
@@ -115,12 +115,46 @@ function stageAll (cwd) {
115
115
 
116
116
  function stagePaths (cwd, filePaths) {
117
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 })
118
+ if (changed.length === 0) return []
119
+ // A deletion the session already staged (git rm) matches nothing on disk or
120
+ // in the index, and `git add` aborts on such a pathspec. It is already
121
+ // staged, so only the remaining paths are added.
122
+ const paths = pathspecs(cwd, changed.filter(file => existsOnDisk(file) || isIndexed(cwd, file)))
123
+ if (paths.length > 0) git(`add -A -- ${paths.map(quote).join(' ')}`, { cwd })
121
124
  return changed
122
125
  }
123
126
 
127
+ function existsOnDisk (file) {
128
+ try {
129
+ fs.lstatSync(file)
130
+ return true
131
+ } catch {
132
+ return false
133
+ }
134
+ }
135
+
136
+ function isIndexed (cwd, file) {
137
+ const spec = pathspecs(cwd, [file])
138
+ if (spec.length === 0) return false
139
+ return git(`ls-files --cached -z -- ${quote(spec[0])}`, { cwd }) !== ''
140
+ }
141
+
142
+ /**
143
+ * Git refuses to work while another process holds the index (Xcode and other
144
+ * tools take `.git/index.lock` briefly), so a staging or commit step is tried
145
+ * again before its failure is allowed to strand a turn's work.
146
+ */
147
+ function withGitRetry (operation, attempts = 3, delayMs = 250) {
148
+ for (let attempt = 1; ; attempt++) {
149
+ try {
150
+ return operation()
151
+ } catch (err) {
152
+ if (attempt >= attempts) throw err
153
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs)
154
+ }
155
+ }
156
+ }
157
+
124
158
  function commitPaths (cwd, filePaths, headline, body, opts = {}) {
125
159
  if (opts.pathScoped && hasRepositoryOperation(cwd)) return null
126
160
  const changed = stagePaths(cwd, filePaths)
@@ -271,6 +305,7 @@ function hasRepositoryOperation (cwd) {
271
305
  }
272
306
 
273
307
  module.exports = {
308
+ withGitRetry,
274
309
  git,
275
310
  canonicalRoot,
276
311
  canonicalTrackedPath,
package/lib/run.js CHANGED
@@ -4,7 +4,7 @@ const path = require('path')
4
4
  const { loadJson } = require('./io')
5
5
  const { parseTranscript, parseCodexTranscript, formatBody, formatTitleTranscript, extractHeadline, extractModel, extractCodexModel } = require('./transcript')
6
6
  const { runTitleAgent, runBodyAgent } = require('./agent')
7
- const { gitRoot, hasChanges, changedPaths, addAndCommit, stagePaths, commitPaths, commitStagedPaths, stagedChangeContext, currentBranch, pushClean, hasRepositoryOperation } = require('./git')
7
+ const { withGitRetry, gitRoot, hasChanges, changedPaths, addAndCommit, stagePaths, commitPaths, commitStagedPaths, stagedChangeContext, currentBranch, pushClean, hasRepositoryOperation } = require('./git')
8
8
  const { logEvent } = require('./log')
9
9
  const { wrapText } = require('./wrap')
10
10
  const {
@@ -281,6 +281,8 @@ function runSingle (input, opts = {}) {
281
281
  const project = path.basename(root)
282
282
  const branch = currentBranch(root)
283
283
  let context = 0
284
+ // Work a failed commit could not land is retained for a later Stop.
285
+ let retained = null
284
286
  try { context = fs.statSync(transcriptPath).size } catch {}
285
287
 
286
288
  const sessionId = hookInput.sessionId
@@ -344,6 +346,7 @@ function runSingle (input, opts = {}) {
344
346
  logEvent('start', { harness: hookInput.harness, project, branch, context })
345
347
 
346
348
  const formattedTranscript = formatBody(effectivePairs)
349
+ if (sessionId) retained = { body: formattedTranscript, paths: ownedPaths }
347
350
 
348
351
  // Title: agent by default, transcript if opted out
349
352
  let headline
@@ -397,9 +400,9 @@ function runSingle (input, opts = {}) {
397
400
  const redactions = buildRedactions()
398
401
  const safeHeadline = redact(headline, redactions)
399
402
  const safeBody = redact(wrappedBody + tag, redactions)
400
- const sha = sessionId
403
+ const sha = withGitRetry(() => sessionId
401
404
  ? commitPaths(root, ownedPaths, safeHeadline, safeBody)
402
- : addAndCommit(root, safeHeadline, safeBody)
405
+ : addAndCommit(root, safeHeadline, safeBody))
403
406
  if (!sha) {
404
407
  if (sessionId) cleanupTracking(root, sessionId)
405
408
  logEvent('skip', {
@@ -432,7 +435,8 @@ function runSingle (input, opts = {}) {
432
435
  }
433
436
  cleanupStale(root)
434
437
  } catch (err) {
435
- logEvent('fail', { harness: hookInput.harness, project, branch, context })
438
+ logEvent('fail', { harness: hookInput.harness, project, branch, context, error: failureMessage(err) })
439
+ if (retained) retainFailedWork(root, sessionId, retained, pairs.length)
436
440
  throw err
437
441
  }
438
442
  }
@@ -534,7 +538,7 @@ function runMulti (hookInput, anchor, currentRepos, manifest, opts = {}) {
534
538
 
535
539
  try {
536
540
  logEvent('start', { harness: hookInput.harness, project, branch, context })
537
- const stagedPaths = stagePaths(root, currentPaths)
541
+ const stagedPaths = withGitRetry(() => stagePaths(root, currentPaths))
538
542
  if (stagedPaths.length === 0) {
539
543
  manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
540
544
  logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-path-changes' })
@@ -582,7 +586,7 @@ function runMulti (hookInput, anchor, currentRepos, manifest, opts = {}) {
582
586
 
583
587
  const safeHeadline = redact(headline, redactions)
584
588
  const safeBody = redact(body, redactions)
585
- const sha = commitStagedPaths(root, stagedPaths, safeHeadline, safeBody)
589
+ const sha = withGitRetry(() => commitStagedPaths(root, stagedPaths, safeHeadline, safeBody))
586
590
  if (!sha) throw new Error('Tracked paths changed before commit')
587
591
  if (sessionId) saveMultiWatermark(root, sessionId, sha)
588
592
  if (root === anchor && sessionId) {
@@ -597,8 +601,8 @@ function runMulti (hookInput, anchor, currentRepos, manifest, opts = {}) {
597
601
  context,
598
602
  title: safeHeadline
599
603
  })
600
- } catch {
601
- logEvent('fail', { harness: hookInput.harness, project, branch, context })
604
+ } catch (err) {
605
+ logEvent('fail', { harness: hookInput.harness, project, branch, context, error: failureMessage(err) })
602
606
  }
603
607
  }
604
608
 
@@ -631,6 +635,30 @@ function runMulti (hookInput, anchor, currentRepos, manifest, opts = {}) {
631
635
  cleanupStale(anchor)
632
636
  }
633
637
 
638
+ /**
639
+ * A single-repository commit that failed joins the retry manifest, which any
640
+ * later Stop in this checkout replays. The session's tracking file is
641
+ * released so its claims stop blocking other sessions in the meantime.
642
+ */
643
+ function retainFailedWork (root, sessionId, retained, pairCount) {
644
+ try {
645
+ const manifest = loadManifest(root)
646
+ appendWork(manifest, root, {
647
+ key: cryptoKey(`${sessionId}\n${pairCount}\n${retained.body}`),
648
+ body: retained.body,
649
+ sessionIds: [],
650
+ paths: retained.paths
651
+ })
652
+ saveManifest(root, manifest)
653
+ cleanupTracking(root, sessionId)
654
+ } catch {}
655
+ }
656
+
657
+ function failureMessage (err) {
658
+ const text = err?.stderr?.toString?.().trim() || err?.message || String(err)
659
+ return text.split('\n')[0].slice(0, 200)
660
+ }
661
+
634
662
  function transcriptSize (transcriptPath) {
635
663
  try {
636
664
  return fs.statSync(transcriptPath).size
package/lib/shell.js CHANGED
@@ -1,17 +1,23 @@
1
1
  const fs = require('fs')
2
2
  const os = require('os')
3
3
  const path = require('path')
4
- const { canonicalRoot, gitRootForPath } = require('./git')
4
+ const { canonicalRoot, gitRootForPath, hasRepositoryOperation } = require('./git')
5
5
  const { activeConfig } = require('./config')
6
6
 
7
7
  /**
8
- * Enabled checkouts other than the anchor that a shell command names.
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.
9
12
  *
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.
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.
15
21
  */
16
22
  function shellCheckouts (command, cwd, anchor) {
17
23
  if (typeof command !== 'string' || !command) return []
@@ -19,7 +25,7 @@ function shellCheckouts (command, cwd, anchor) {
19
25
  const words = shellWords(command)
20
26
  const bases = [cwd, ...words.filter(word => path.isAbsolute(word) && isDirectory(word))]
21
27
 
22
- const candidates = new Set()
28
+ const named = new Set()
23
29
  for (const word of words) {
24
30
  const resolved = path.isAbsolute(word)
25
31
  ? [word]
@@ -28,18 +34,24 @@ function shellCheckouts (command, cwd, anchor) {
28
34
  if (!fs.existsSync(candidate)) continue
29
35
  const canonical = canonicalRoot(candidate)
30
36
  if (isInside(canonical, anchorRoot) || canonical.startsWith('/dev/')) continue
31
- candidates.add(isDirectory(canonical) ? canonical : path.dirname(canonical))
37
+ named.add(canonical)
32
38
  }
33
39
  }
34
40
 
35
41
  const rootsByDir = new Map()
36
42
  const checkouts = []
37
- for (const dir of candidates) {
43
+ for (const file of named) {
44
+ const dir = isDirectory(file) ? file : path.dirname(file)
38
45
  if (!rootsByDir.has(dir)) rootsByDir.set(dir, gitRootForPath(dir))
39
46
  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)
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)
43
55
  }
44
56
  return checkouts
45
57
  }
@@ -51,6 +63,9 @@ function shellWords (command) {
51
63
  if (word === '~' || word.startsWith('~/')) word = home + word.slice(1)
52
64
  if (!word || word.startsWith('-') || word.length > 1024) return []
53
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 []
54
69
  return [word]
55
70
  })
56
71
  }
package/lib/track.js CHANGED
@@ -170,7 +170,7 @@ function handleTrack (input, root, opts = {}) {
170
170
  // A shell command can edit any checkout it names, so each of those is
171
171
  // snapshotted alongside the anchor and attributed back to this session.
172
172
  const checkouts = toolName === 'Bash' ? shellCheckouts(toolInput.command, cwd, root) : []
173
- const snapshotRoots = [root, ...checkouts]
173
+ const snapshotRoots = [root, ...checkouts.map(checkout => checkout.root)]
174
174
  const removeSnapshots = () => {
175
175
  for (const checkout of snapshotRoots) removeBashSnapshot(checkout, sessionId, toolUseId)
176
176
  }
@@ -179,12 +179,12 @@ function handleTrack (input, root, opts = {}) {
179
179
  try {
180
180
  if (snapshotsRepository(toolName)) {
181
181
  if (toolName === 'Bash') entry.command = toolInput.command
182
- if (checkouts.length > 0) entry.checkouts = checkouts
182
+ if (checkouts.length > 0) entry.checkouts = snapshotRoots.slice(1)
183
183
  pendingSnapshot = true
184
- for (const checkout of snapshotRoots) {
185
- savePendingBashSnapshot(checkout, sessionId, toolUseId, cwd, toolName, checkout === root ? checkouts : [])
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 })
186
187
  }
187
- appendTracking(root, sessionId, entry)
188
188
  } else {
189
189
  preclaim = savePreclaim(root, sessionId, entry)
190
190
  }
@@ -196,6 +196,9 @@ function handleTrack (input, root, opts = {}) {
196
196
 
197
197
  const recoveryWaitMs = opts.recoveryWaitMs ?? 5000
198
198
  if (pendingSnapshot) {
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.
199
202
  try {
200
203
  for (const checkout of snapshotRoots) {
201
204
  if (!startBashSnapshot(checkout, sessionId, toolUseId, cwd, recoveryWaitMs)) {
@@ -203,6 +206,7 @@ function handleTrack (input, root, opts = {}) {
203
206
  return false
204
207
  }
205
208
  }
209
+ appendTracking(root, sessionId, entry)
206
210
  } catch (error) {
207
211
  removeSnapshots()
208
212
  throw error
@@ -283,7 +287,7 @@ function finishBashSnapshot (root, snapshot, { cwd, endedAt, trackingRoot = root
283
287
  const claimed = overlapping
284
288
  ? claimedPathsBySessions(root)
285
289
  : claimedPathsBySessions(root, snapshot.sessionId)
286
- 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))
287
291
  const files = overlapping ? [] : candidates
288
292
 
289
293
  const entry = { tool: snapshot.toolName || 'Bash', phase: 'post', t: endedAt, cwd }
@@ -301,6 +305,15 @@ function finishBashSnapshot (root, snapshot, { cwd, endedAt, trackingRoot = root
301
305
  return true
302
306
  }
303
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
+
304
317
  function bashSnapshotDir (root) {
305
318
  const base = turbocommitDir(root)
306
319
  return base && path.join(base, 'bash-snapshots')
@@ -315,7 +328,7 @@ function bashSnapshotPath (root, sessionId, toolUseId) {
315
328
  return path.join(dir, key + '.json')
316
329
  }
317
330
 
318
- function savePendingBashSnapshot (root, sessionId, toolUseId, cwd, toolName = 'Bash', checkouts = []) {
331
+ function savePendingBashSnapshot (root, sessionId, toolUseId, cwd, toolName = 'Bash', { checkouts = [], scope } = {}) {
319
332
  const checkout = canonicalRoot(root)
320
333
  writeBashSnapshot(root, sessionId, toolUseId, {
321
334
  root: checkout,
@@ -325,7 +338,8 @@ function savePendingBashSnapshot (root, sessionId, toolUseId, cwd, toolName = 'B
325
338
  toolUseId: toolUseId || null,
326
339
  createdAt: Date.now(),
327
340
  pending: true,
328
- ...(checkouts.length > 0 ? { checkouts } : {})
341
+ ...(checkouts.length > 0 ? { checkouts } : {}),
342
+ ...(Array.isArray(scope) ? { scope } : {})
329
343
  })
330
344
  }
331
345
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@searls/turbocommit",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
4
4
  "description": "Auto-commit after every AI coding agent turn",
5
5
  "bin": {
6
6
  "turbocommit": "./cli.js"