@searls/turbocommit 0.16.0 → 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 +7 -4
- 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
|
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
|
|