@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 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
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.1",
4
4
  "description": "Auto-commit after every AI coding agent turn",
5
5
  "bin": {
6
6
  "turbocommit": "./cli.js"