@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 +15 -6
- package/lib/git.js +38 -3
- package/lib/run.js +36 -8
- package/lib/shell.js +28 -13
- package/lib/track.js +22 -8
- package/package.json +1 -1
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,
|
|
33
|
-
relative to a directory the command mentions)
|
|
34
|
-
too, so a pin bump made with `sed` in a
|
|
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`.
|
|
81
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
git
|
|
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
|
|
11
|
-
* an existing path is probed for the repository containing it.
|
|
12
|
-
* are resolved against the command's working directory and
|
|
13
|
-
* absolute directory the command mentions, which is how a loop
|
|
14
|
-
* `cd ~/code && for r in app/Core lib/Core; do ...` reaches each
|
|
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
|
|
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
|
-
|
|
37
|
+
named.add(canonical)
|
|
32
38
|
}
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
const rootsByDir = new Map()
|
|
36
42
|
const checkouts = []
|
|
37
|
-
for (const
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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 =
|
|
182
|
+
if (checkouts.length > 0) entry.checkouts = snapshotRoots.slice(1)
|
|
183
183
|
pendingSnapshot = true
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|