@searls/turbocommit 0.14.1 → 0.15.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  # turbocommit
4
4
 
5
- turbocommit creates a git commit containing everything Claude Code or Codex
5
+ turbocommit creates a git commit containing the paths Claude Code or Codex
6
6
  changes on each turn. Think of it like save state in a game emulator: whenever
7
7
  you fall in a pit, you can safely rewind and try again.
8
8
 
@@ -18,19 +18,39 @@ sessions concurrently and detangle which agent committed what after the fact.
18
18
 
19
19
  turbocommit registers hooks with the harnesses you use:
20
20
 
21
- - Claude Code: **PreToolUse**, **SessionStart**, **SessionEnd**, and **Stop**.
22
- - Codex: **PreToolUse**, **SessionStart**, **PreCompact**, and
23
- **Stop**.
24
-
25
- - **PreToolUse** tracks which sessions actually modify files (Write, Edit,
26
- MultiEdit, NotebookEdit, MCP tools). Read-only sessions (Grep, Read,
27
- Bash-only) are never committed.
21
+ - Claude Code: **PreToolUse**, **PostToolUse**, **PostToolUseFailure**,
22
+ **SessionStart**, **SessionEnd**, and **Stop**.
23
+ - Codex: **PreToolUse**, **PostToolUse**, **SessionStart**, **SessionEnd**,
24
+ **PreCompact**, and **Stop**.
25
+
26
+ - **PreToolUse** tentatively claims the paths supplied to editing tools while
27
+ recovery finishes, promotes the claim only when the tool may proceed, and
28
+ snapshots the repository before shell commands.
29
+ - **PostToolUse** attributes paths that became dirty during a shell command.
30
+ Concurrent commands in one session share ownership. Commands from different
31
+ sessions retain hashed overlap evidence instead of claiming each other's
32
+ paths. Once every involved turn stops and no shell remains active in that
33
+ checkout, unchanged and otherwise-unclaimed paths are committed separately
34
+ as recovered shell changes. Evidence is scoped to its exact worktree and is
35
+ permanently discarded if file contents or executable modes change, or if an
36
+ unfinished shell snapshot expires.
37
+ - **PostToolUseFailure**, **Stop**, and **SessionEnd** finalize shell snapshots
38
+ whose normal post hook did not arrive. Claude normally finishes within its
39
+ SessionEnd hook. If its internal deadline expires, Turbocommit stores the
40
+ worktree in a hidden local rescue ref before starting a detached worker. The
41
+ worker can recreate a removed checkout at its recorded commit, but it leaves
42
+ the rescue intact if the original checkout identity or branch has changed.
43
+ Every completed deadline rescue remains reachable through a hidden local
44
+ safety ref, including detached-HEAD recovery. Codex preserves the transcript
45
+ and starts the normal commit and recovery path in a detached worker so it can
46
+ finish beyond Codex's three-second hook deadline. Read-only tracking is then
47
+ removed.
28
48
  - **SessionStart / SessionEnd** chain sessions across `/clear` boundaries
29
49
  so planning context survives into the eventual commit.
30
50
  - **PreCompact** on Codex buffers visible transcript context before compaction.
31
- - **Stop** fires after every turn. If the session modified files, it
32
- commits with `git add -A`. If not, it buffers the transcript for pickup
33
- by a later session that does commit.
51
+ - **Stop** fires after every turn. If the session modified files, it stages and
52
+ commits only the paths tracked for that session. If not, it buffers the
53
+ transcript for pickup by a later session that does commit.
34
54
 
35
55
  ### Changes across multiple repositories
36
56
 
@@ -43,7 +63,8 @@ additional repositories.
43
63
  Each touched checkout:
44
64
 
45
65
  - Reads its own merged global and project configuration.
46
- - Keeps the normal `git add -A` behavior.
66
+ - Stages only paths attributed to the current session, preserving unrelated
67
+ staged and unstaged work.
47
68
  - Gets an independent commit with the full turn transcript.
48
69
  - Gives the configured title agent repository-specific bounded diff context.
49
70
 
@@ -56,6 +77,29 @@ committed and pushed before enabled parent repositories. When one turn creates
56
77
  commits in multiple repositories, each commit includes a shared
57
78
  `Turbocommit-Session` trailer.
58
79
 
80
+ Relative tool paths are matched to the checkout where that path changed. If the
81
+ same relative path is dirty in multiple worktrees, Turbocommit skips the turn
82
+ with an `ambiguous-tracking` monitor reason instead of guessing which checkout
83
+ owns it, then clears that ambiguous tracking so later turns can proceed. Direct
84
+ editing tools such as `apply_patch` resolve relative paths from their recorded
85
+ working directory. Path-bearing MCP tools may report singular paths, path
86
+ arrays, or local file URIs. Pathless tool calls and directory-valued path fields
87
+ do not claim files or prevent later exact edits in the same session from
88
+ committing.
89
+
90
+ Before staging, Turbocommit resolves tracked paths to the exact changed files,
91
+ treats Git pathspec metacharacters literally, drops paths that are no longer
92
+ dirty, and excludes newly discovered embedded repositories. Existing tracked
93
+ submodule gitlinks remain eligible so child commits can update their parent.
94
+ During an active merge, cherry-pick, revert, or rebase, Git forbids partial
95
+ commits, so Turbocommit commits the operation's resolved index as a whole.
96
+ Recovered overlap evidence is deferred until the operation ends because its
97
+ generic commit must remain path-scoped.
98
+
99
+ Retry manifests created by older versions did not record path ownership. On
100
+ upgrade, Turbocommit discards those unsafe retry entries and records a
101
+ `legacy-manifest` monitor event rather than guessing which files they owned.
102
+
59
103
  The commit message headline is generated by a title agent (configurable).
60
104
  The body contains the full prompt/response transcript. When planning
61
105
  context was buffered from ancestor sessions, it appears under a
@@ -102,10 +146,11 @@ brew uninstall turbocommit
102
146
  ## Where turbocommit goes in your hook chain
103
147
 
104
148
  `turbocommit install` adds one hook group per event for each installed harness.
105
- The hooks are fire-and-forget: they always exit 0 and never block the agent.
149
+ The hooks ordinarily exit 0. A modifying PreToolUse hook denies the tool when
150
+ turbocommit cannot safely coordinate with an in-progress recovery, so the
151
+ agent can retry instead of making an unowned change.
106
152
 
107
153
  If you rearrange your hooks manually, the turbocommit hooks can go anywhere.
108
- Order doesn't matter since they never block.
109
154
 
110
155
  Codex users should restart Codex and run `/hooks` after install to review and
111
156
  trust the hook commands.
package/cli.js CHANGED
@@ -1,15 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { readStdin } = require('./lib/io')
3
+ const { spawn } = require('child_process')
4
+ const crypto = require('crypto')
5
+ const fs = require('fs')
6
+ const path = require('path')
7
+ const { readStdin, ensureDir } = require('./lib/io')
4
8
  const { install, uninstall } = require('./lib/install')
5
9
  const { init, deinit } = require('./lib/init')
6
- const { run, runPreCompact } = require('./lib/run')
7
- const { handleTrack } = require('./lib/track')
8
- const { handleSessionStart, handleSessionEnd } = require('./lib/session')
10
+ const { run, runPreCompact, recoverBashOverlaps } = require('./lib/run')
11
+ const { handleTrack, handlePostTrack, finalizeBashSnapshots, recordBashSessionStop, readTracking } = require('./lib/track')
12
+ const { handleSessionStart, handleSessionEnd, turbocommitDir } = require('./lib/session')
9
13
  const { doctor } = require('./lib/doctor')
10
14
  const { monitor } = require('./lib/monitor')
11
- const { gitRoot } = require('./lib/git')
15
+ const { gitRoot, pushClean } = require('./lib/git')
16
+ const { activeConfig } = require('./lib/config')
17
+ const { logEvent } = require('./lib/log')
12
18
  const { parseHarnessArg, normalizeHookInput } = require('./lib/harness')
19
+ const {
20
+ createSessionEndRescue,
21
+ restoreSessionEndRescue,
22
+ matchesSessionEndRescueRoot,
23
+ finalizeRestoredSessionEndRescue,
24
+ preserveSessionEndRecoveredCommit,
25
+ recordPreservedSessionEndRescue,
26
+ removeSessionEndRescueWorktree,
27
+ cleanupSessionEndRescue,
28
+ isClean
29
+ } = require('./lib/rescue')
13
30
 
14
31
  const VERSION = require('./package.json').version
15
32
 
@@ -59,6 +76,10 @@ function main (argv) {
59
76
  return cmdDeinit()
60
77
  case 'hook':
61
78
  return cmdHook(parsed.args.slice(1), parsed.harness)
79
+ case 'session-end-worker':
80
+ return cmdSessionEndWorker(parsed.args.slice(1), parsed.harness)
81
+ case 'session-end-rescue-worker':
82
+ return cmdSessionEndRescueWorker(parsed.args.slice(1))
62
83
  case 'run':
63
84
  return cmdRunDeprecated()
64
85
  case '--version':
@@ -165,6 +186,7 @@ function cmdMonitor (harness) {
165
186
  }
166
187
 
167
188
  function cmdHook (argv, harness) {
189
+ if (process.env.TURBOCOMMIT_DISABLED) return
168
190
  const event = argv[0]
169
191
  try {
170
192
  const input = readStdin()
@@ -173,13 +195,27 @@ function cmdHook (argv, harness) {
173
195
  const root = gitRoot(hookInput.cwd || process.cwd())
174
196
  switch (event) {
175
197
  case 'pre-tool-use':
176
- handleTrack(hookInput, root)
198
+ try {
199
+ if (handleTrack(hookInput, root) === false) denyPreToolUse()
200
+ } catch {
201
+ denyPreToolUse()
202
+ }
203
+ return
204
+ case 'post-tool-use':
205
+ handlePostTrack(hookInput, root)
177
206
  return
178
207
  case 'session-start':
179
208
  handleSessionStart(hookInput, root)
180
209
  return
181
210
  case 'session-end':
182
211
  handleSessionEnd(hookInput, root)
212
+ if (hookInput.harness === 'claude') {
213
+ let completed = false
214
+ try { completed = completeSessionEnd(hookInput, root, 45000) } catch {}
215
+ if (!completed) rescueClaudeSessionEnd(hookInput, root)
216
+ } else {
217
+ spawnSessionEndWorker(hookInput, root, harness)
218
+ }
183
219
  return
184
220
  case 'pre-compact':
185
221
  runPreCompact(hookInput)
@@ -196,6 +232,214 @@ function cmdHook (argv, harness) {
196
232
  }
197
233
  }
198
234
 
235
+ function spawnSessionEndWorker (hookInput, root, harness) {
236
+ if (process.env.TURBOCOMMIT_DISABLED || !root || !hookInput.sessionId) return
237
+ const transcript = copySessionEndTranscript(root, hookInput.transcriptPath)
238
+ const payload = Buffer.from(JSON.stringify({
239
+ sessionId: hookInput.sessionId,
240
+ event: 'SessionEnd',
241
+ cwd: root,
242
+ harness: hookInput.harness || harness,
243
+ transcriptPath: transcript.path,
244
+ temporaryTranscript: transcript.temporary,
245
+ model: hookInput.model
246
+ })).toString('base64url')
247
+ const args = [__filename, 'session-end-worker', payload]
248
+ if (harness) args.push('--harness', harness)
249
+ const child = spawn(process.execPath, args, {
250
+ cwd: stableWorkerCwd(root),
251
+ detached: true,
252
+ stdio: 'ignore'
253
+ })
254
+ child.on('error', () => {
255
+ if (transcript.temporary) cleanupSessionEndTranscript(root, transcript.path)
256
+ })
257
+ child.unref()
258
+ }
259
+
260
+ function cmdSessionEndWorker (argv, harness) {
261
+ if (process.env.TURBOCOMMIT_DISABLED) return
262
+ let hookInput
263
+ try {
264
+ hookInput = JSON.parse(Buffer.from(argv[0], 'base64url').toString('utf8'))
265
+ } catch {
266
+ return
267
+ }
268
+ hookInput.harness = hookInput.harness || harness
269
+ const root = gitRoot(hookInput.cwd || process.cwd())
270
+ try {
271
+ if (!root || !hookInput.sessionId) return
272
+ completeSessionEnd(hookInput, root, Infinity)
273
+ } finally {
274
+ if (hookInput.temporaryTranscript) cleanupTemporarySessionEndTranscript(root, hookInput.transcriptPath)
275
+ }
276
+ }
277
+
278
+ function completeSessionEnd (hookInput, root, waitMs, { deferPush = false } = {}) {
279
+ if (!root || !hookInput.sessionId) return false
280
+ const deadline = waitMs === Infinity ? Infinity : Date.now() + waitMs
281
+ const remainingWait = () => deadline === Infinity ? Infinity : Math.max(0, deadline - Date.now())
282
+ const finalized = finalizeBashSnapshots(root, hookInput.sessionId, Date.now(), remainingWait())
283
+ if (finalized == null) return false
284
+ if (readTracking(root, hookInput.sessionId).length > 0) {
285
+ run(hookInput, { recoveryDeadline: deadline, operationDeadline: deadline, deferPush })
286
+ return readTracking(root, hookInput.sessionId).length === 0
287
+ }
288
+ recordBashSessionStop(root, hookInput.sessionId)
289
+ const recoveryConfig = deferPush ? { ...activeConfig(root).config, push: false } : undefined
290
+ recoverBashOverlaps(root, hookInput, recoveryConfig, remainingWait())
291
+ return true
292
+ }
293
+
294
+ function rescueClaudeSessionEnd (hookInput, root) {
295
+ if (!root || !hookInput.sessionId) return
296
+ const transcript = copySessionEndTranscript(root, hookInput.transcriptPath)
297
+ const rescueInput = {
298
+ ...hookInput,
299
+ transcriptPath: transcript.path,
300
+ temporaryTranscript: transcript.temporary
301
+ }
302
+ const rescue = createSessionEndRescue(root, rescueInput)
303
+ if (!rescue) {
304
+ if (transcript.temporary) cleanupTemporarySessionEndTranscript(root, transcript.path)
305
+ return
306
+ }
307
+ spawnSessionEndRescueWorker(rescue)
308
+ }
309
+
310
+ function spawnSessionEndRescueWorker (rescue) {
311
+ const payload = Buffer.from(JSON.stringify(rescue)).toString('base64url')
312
+ const child = spawn(process.execPath, [__filename, 'session-end-rescue-worker', payload], {
313
+ cwd: stableWorkerCwd(rescue.root),
314
+ detached: true,
315
+ stdio: 'ignore'
316
+ })
317
+ child.on('error', () => {})
318
+ child.unref()
319
+ }
320
+
321
+ function cmdSessionEndRescueWorker (argv) {
322
+ let rescue
323
+ try {
324
+ rescue = JSON.parse(Buffer.from(argv[0], 'base64url').toString('utf8'))
325
+ } catch {
326
+ return
327
+ }
328
+ let recreated = false
329
+ let completed = false
330
+ try {
331
+ const existingRoot = gitRoot(rescue.root)
332
+ if (existingRoot) {
333
+ if (!matchesSessionEndRescueRoot(rescue, existingRoot)) return
334
+ try { completed = completeSessionEnd(rescue.hookInput, existingRoot, Infinity) } catch {}
335
+ }
336
+ if (!completed && !fs.existsSync(rescue.root)) {
337
+ recreated = restoreRescueWithRetry(rescue)
338
+ if (recreated) {
339
+ try { completed = completeSessionEnd(rescue.hookInput, rescue.root, Infinity, { deferPush: true }) } catch {}
340
+ }
341
+ }
342
+ if (!completed) return
343
+ const clean = isClean(rescue.root)
344
+ if (!recreated) {
345
+ const recovered = preserveSessionEndRecoveredCommit(rescue, rescue.root)
346
+ if (!recovered) return
347
+ if (clean) {
348
+ cleanupSessionEndRescue(rescue)
349
+ } else {
350
+ recordPreservedSessionEndRescue(rescue, recovered, 'dirty-existing-worktree')
351
+ }
352
+ } else {
353
+ const finalized = finalizeRestoredSessionEndRescue(rescue, { clean })
354
+ if (finalized.resolved) {
355
+ pushRestoredSessionEndRescue(rescue, finalized)
356
+ if (isClean(rescue.root)) {
357
+ cleanupSessionEndRescue(rescue, { removeWorktree: true })
358
+ } else {
359
+ recordPreservedSessionEndRescue(rescue, finalized, 'dirty-attached-worktree')
360
+ }
361
+ } else if (finalized.preserved && finalized.removeWorktree) {
362
+ removeSessionEndRescueWorktree(rescue)
363
+ }
364
+ }
365
+ if (rescue.hookInput?.temporaryTranscript) {
366
+ cleanupTemporarySessionEndTranscript(rescue.root, rescue.hookInput.transcriptPath)
367
+ }
368
+ } catch {
369
+ // The rescue ref and record remain available for a later retry.
370
+ }
371
+ }
372
+
373
+ function pushRestoredSessionEndRescue (rescue, finalized) {
374
+ const config = activeConfig(rescue.root).config
375
+ if (config.push !== true) return
376
+ const pushed = finalized.attached && pushClean(rescue.root)
377
+ logEvent(pushed ? 'push' : 'push-fail', {
378
+ harness: rescue.hookInput?.harness,
379
+ project: path.basename(rescue.root),
380
+ branch: rescue.branch
381
+ })
382
+ }
383
+
384
+ function restoreRescueWithRetry (rescue) {
385
+ const deadline = Date.now() + 30000
386
+ const sleeper = new Int32Array(new SharedArrayBuffer(4))
387
+ while (!fs.existsSync(rescue.root)) {
388
+ if (restoreSessionEndRescue(rescue)) return true
389
+ if (Date.now() >= deadline) return false
390
+ Atomics.wait(sleeper, 0, 0, 100)
391
+ }
392
+ return false
393
+ }
394
+
395
+ function stableWorkerCwd (root) {
396
+ const base = turbocommitDir(root)
397
+ return base ? path.dirname(base) : root
398
+ }
399
+
400
+ function copySessionEndTranscript (root, source) {
401
+ if (!source) return { path: source, temporary: false }
402
+ const base = turbocommitDir(root)
403
+ if (!base) return { path: source, temporary: false }
404
+ const dir = path.join(base, 'session-end-transcripts')
405
+ const destination = path.join(dir, `${process.pid}-${crypto.randomUUID()}.jsonl`)
406
+ try {
407
+ ensureDir(dir)
408
+ fs.copyFileSync(source, destination)
409
+ return { path: destination, temporary: true }
410
+ } catch {
411
+ return { path: source, temporary: false }
412
+ }
413
+ }
414
+
415
+ function cleanupSessionEndTranscript (root, transcriptPath) {
416
+ const base = turbocommitDir(root)
417
+ if (!base || !transcriptPath) return false
418
+ const dir = path.join(base, 'session-end-transcripts')
419
+ if (path.dirname(path.resolve(transcriptPath)) !== path.resolve(dir)) return false
420
+ try { fs.unlinkSync(transcriptPath) } catch {}
421
+ return !fs.existsSync(transcriptPath)
422
+ }
423
+
424
+ function cleanupTemporarySessionEndTranscript (root, transcriptPath) {
425
+ if (root && cleanupSessionEndTranscript(root, transcriptPath)) return
426
+ if (!transcriptPath) return
427
+ const resolved = path.resolve(transcriptPath)
428
+ if (path.basename(path.dirname(resolved)) !== 'session-end-transcripts') return
429
+ if (!/^\d+-[0-9a-f-]{36}\.jsonl$/i.test(path.basename(resolved))) return
430
+ try { fs.unlinkSync(resolved) } catch {}
431
+ }
432
+
433
+ function denyPreToolUse () {
434
+ process.stdout.write(JSON.stringify({
435
+ hookSpecificOutput: {
436
+ hookEventName: 'PreToolUse',
437
+ permissionDecision: 'deny',
438
+ permissionDecisionReason: 'turbocommit could not safely persist path ownership; retry the tool'
439
+ }
440
+ }) + '\n')
441
+ }
442
+
199
443
  function cmdRunDeprecated () {
200
444
  let hookInput
201
445
  try {
package/lib/agent.js CHANGED
@@ -32,14 +32,18 @@ function renderPrompt (template, transcript) {
32
32
  return template.replace(/\{\{transcript\}\}/g, () => transcript)
33
33
  }
34
34
 
35
- function runAgent (root, command, prompt) {
35
+ function runAgent (root, command, prompt, opts = {}) {
36
36
  const binary = command.split(/\s+/)[0]
37
37
  const whichResult = tryRun(`which ${binary}`, {})
38
38
  if (whichResult.code !== 0) return null
39
+ const timeout = Number.isFinite(opts.deadline)
40
+ ? Math.min(45000, Math.max(0, opts.deadline - Date.now()))
41
+ : 45000
42
+ if (timeout <= 0) return null
39
43
 
40
44
  const result = tryRun(command, {
41
45
  cwd: root,
42
- timeout: 45000,
46
+ timeout,
43
47
  input: prompt,
44
48
  env: { ...process.env, TURBOCOMMIT_DISABLED: '1', CLAUDECODE: '' }
45
49
  })
@@ -54,21 +58,21 @@ function defaultCommand (harness) {
54
58
  return harness === 'codex' ? DEFAULT_CODEX_COMMAND : DEFAULT_COMMAND
55
59
  }
56
60
 
57
- function runTitleAgent (root, titleCfg, transcript, harness) {
61
+ function runTitleAgent (root, titleCfg, transcript, harness, opts) {
58
62
  const command = titleCfg.command || defaultCommand(harness)
59
63
  const template = titleCfg.prompt || DEFAULT_TITLE_PROMPT
60
64
  const prompt = renderPrompt(template, transcript)
61
- const result = runAgent(root, command, prompt)
65
+ const result = runAgent(root, command, prompt, opts)
62
66
  if (!result) return null
63
67
  // Take only first line and enforce 72 char limit
64
68
  return result.split('\n')[0].slice(0, 72) || null
65
69
  }
66
70
 
67
- function runBodyAgent (root, bodyCfg, transcript, harness) {
71
+ function runBodyAgent (root, bodyCfg, transcript, harness, opts) {
68
72
  const command = bodyCfg.command || defaultCommand(harness)
69
73
  const template = bodyCfg.prompt || DEFAULT_BODY_PROMPT
70
74
  const prompt = renderPrompt(template, transcript)
71
- return runAgent(root, command, prompt)
75
+ return runAgent(root, command, prompt, opts)
72
76
  }
73
77
 
74
78
  module.exports = {
package/lib/git.js CHANGED
@@ -13,12 +13,32 @@ function git (args, opts = {}) {
13
13
 
14
14
  function gitRoot (cwd) {
15
15
  try {
16
- return git('rev-parse --show-toplevel', { cwd })
16
+ return canonicalRoot(git('rev-parse --show-toplevel', { cwd }))
17
17
  } catch {
18
18
  return null
19
19
  }
20
20
  }
21
21
 
22
+ function canonicalRoot (filePath) {
23
+ const target = path.resolve(filePath)
24
+ let probe = target
25
+ while (!fs.existsSync(probe)) {
26
+ const parent = path.dirname(probe)
27
+ if (parent === probe) return target
28
+ probe = parent
29
+ }
30
+ try {
31
+ return path.join(fs.realpathSync(probe), path.relative(probe, target))
32
+ } catch {
33
+ return target
34
+ }
35
+ }
36
+
37
+ function canonicalTrackedPath (filePath) {
38
+ const target = path.resolve(filePath)
39
+ return path.join(canonicalRoot(path.dirname(target)), path.basename(target))
40
+ }
41
+
22
42
  function gitRootForPath (filePath) {
23
43
  let probe = filePath
24
44
  try {
@@ -34,6 +54,25 @@ function gitRootForPath (filePath) {
34
54
  return gitRoot(probe)
35
55
  }
36
56
 
57
+ function gitWorktrees (cwd) {
58
+ try {
59
+ return git('worktree list --porcelain', { cwd })
60
+ .split('\n')
61
+ .filter(line => line.startsWith('worktree '))
62
+ .map(line => line.slice('worktree '.length))
63
+ .map(root => {
64
+ try {
65
+ return fs.realpathSync(root)
66
+ } catch {
67
+ return null
68
+ }
69
+ })
70
+ .filter(Boolean)
71
+ } catch {
72
+ return []
73
+ }
74
+ }
75
+
37
76
  function gitCommonDir (cwd) {
38
77
  try {
39
78
  const out = git('rev-parse --git-common-dir', { cwd })
@@ -60,6 +99,10 @@ function hasChanges (cwd) {
60
99
  return untracked.length > 0
61
100
  }
62
101
 
102
+ function hasPathChanges (cwd, filePath) {
103
+ return changedPaths(cwd, [filePath]).length > 0
104
+ }
105
+
63
106
  function addAndCommit (cwd, headline, body) {
64
107
  stageAll(cwd)
65
108
  commitStaged(cwd, headline, body)
@@ -70,15 +113,42 @@ function stageAll (cwd) {
70
113
  git('add -A', { cwd })
71
114
  }
72
115
 
116
+ function stagePaths (cwd, filePaths) {
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 })
121
+ return changed
122
+ }
123
+
124
+ function commitPaths (cwd, filePaths, headline, body, opts = {}) {
125
+ if (opts.pathScoped && hasRepositoryOperation(cwd)) return null
126
+ const changed = stagePaths(cwd, filePaths)
127
+ return commitStagedPaths(cwd, changed, headline, body, opts)
128
+ }
129
+
130
+ function commitStagedPaths (cwd, filePaths, headline, body, opts = {}) {
131
+ const paths = pathspecs(cwd, filePaths)
132
+ if (paths.length === 0) return null
133
+ if (hasRepositoryOperation(cwd)) {
134
+ return opts.pathScoped ? null : commitStaged(cwd, headline, body)
135
+ }
136
+ git(`commit --only -m "${esc(headline)}" -m "${esc(body)}" --no-verify -- ${paths.map(quote).join(' ')}`, { cwd })
137
+ return git('rev-parse HEAD', { cwd })
138
+ }
139
+
73
140
  function commitStaged (cwd, headline, body) {
74
141
  git(`commit -m "${esc(headline)}" -m "${esc(body)}" --no-verify`, { cwd })
75
142
  return git('rev-parse HEAD', { cwd })
76
143
  }
77
144
 
78
- function stagedChangeContext (cwd, budget = 20000) {
79
- const status = git('status --short', { cwd })
80
- const stat = git('diff --cached --stat', { cwd })
81
- const diff = git('diff --cached --no-ext-diff --unified=3', { cwd })
145
+ function stagedChangeContext (cwd, budget = 20000, filePaths) {
146
+ const selected = filePaths && filePaths.length > 0
147
+ ? ` -- ${pathspecs(cwd, filePaths).map(quote).join(' ')}`
148
+ : ''
149
+ const status = git(`status --short${selected}`, { cwd })
150
+ const stat = git(`diff --cached --stat${selected}`, { cwd })
151
+ const diff = git(`diff --cached --no-ext-diff --unified=3${selected}`, { cwd })
82
152
  const prefix = `Status:\n${status || '(clean)'}\n\nDiff stat:\n${stat || '(none)'}\n\nDiff:\n`
83
153
  const remaining = Math.max(0, budget - prefix.length)
84
154
  return prefix + (diff.length > remaining ? diff.slice(0, remaining) + '\n[... diff truncated ...]' : diff)
@@ -88,7 +158,7 @@ function isGitlink (parentRoot, childRoot) {
88
158
  const relative = path.relative(parentRoot, childRoot)
89
159
  if (!relative || relative.startsWith('..')) return false
90
160
  try {
91
- return git(`ls-files --stage -- "${esc(relative)}"`, { cwd: parentRoot })
161
+ return git(`ls-files --stage -- ${quote(literalPathspec(relative))}`, { cwd: parentRoot })
92
162
  .split('\n')
93
163
  .some(line => line.startsWith('160000 '))
94
164
  } catch {
@@ -126,18 +196,102 @@ function esc (s) {
126
196
  return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`')
127
197
  }
128
198
 
199
+ function quote (value) {
200
+ return `'${value.replace(/'/g, '\'"\'"\'')}'`
201
+ }
202
+
203
+ function pathspecs (cwd, filePaths) {
204
+ cwd = canonicalRoot(cwd)
205
+ return [...new Set(filePaths.map(filePath => path.relative(cwd, canonicalTrackedPath(filePath))))]
206
+ .filter(relative => relative && !relative.startsWith('..'))
207
+ .map(literalPathspec)
208
+ }
209
+
210
+ function literalPathspec (relative) {
211
+ return `:(literal)${relative}`
212
+ }
213
+
214
+ function changedPaths (cwd, filePaths) {
215
+ cwd = canonicalRoot(cwd)
216
+ const selected = pathspecs(cwd, filePaths)
217
+ if (selected.length === 0) return []
218
+ return collectChangedPaths(cwd, selected)
219
+ }
220
+
221
+ function changedPathsInRepository (cwd) {
222
+ cwd = canonicalRoot(cwd)
223
+ return collectChangedPaths(cwd, [])
224
+ }
225
+
226
+ function collectChangedPaths (cwd, selected) {
227
+ const args = selected.length > 0 ? ` -- ${selected.map(quote).join(' ')}` : ''
228
+ const tracked = hasCommits(cwd)
229
+ ? git(`diff --name-only --no-renames -z HEAD${args}`, { cwd })
230
+ : git(`ls-files --cached -z${args}`, { cwd })
231
+ const untracked = git(`ls-files --others --exclude-standard -z${args}`, { cwd })
232
+ const relativePaths = [...splitNull(tracked), ...splitNull(untracked)]
233
+ return [...new Set(relativePaths)]
234
+ .filter(relative => !isEmbeddedRepository(cwd, relative) || isHeadGitlink(cwd, relative))
235
+ .map(relative => path.join(cwd, relative))
236
+ }
237
+
238
+ function splitNull (value) {
239
+ return value ? value.split('\0').filter(Boolean) : []
240
+ }
241
+
242
+ function isEmbeddedRepository (cwd, relative) {
243
+ const candidate = path.join(cwd, relative)
244
+ try {
245
+ return fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, '.git'))
246
+ } catch {
247
+ return false
248
+ }
249
+ }
250
+
251
+ function isHeadGitlink (cwd, relative) {
252
+ try {
253
+ return git(`ls-tree HEAD -- ${quote(literalPathspec(relative))}`, { cwd })
254
+ .split('\n')
255
+ .some(line => line.startsWith('160000 '))
256
+ } catch {
257
+ return false
258
+ }
259
+ }
260
+
261
+ function hasRepositoryOperation (cwd) {
262
+ return ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply']
263
+ .some(name => {
264
+ try {
265
+ const gitPath = git(`rev-parse --git-path ${quote(name)}`, { cwd })
266
+ return fs.existsSync(path.isAbsolute(gitPath) ? gitPath : path.join(cwd, gitPath))
267
+ } catch {
268
+ return false
269
+ }
270
+ })
271
+ }
272
+
129
273
  module.exports = {
130
274
  git,
275
+ canonicalRoot,
276
+ canonicalTrackedPath,
131
277
  gitRoot,
132
278
  gitRootForPath,
279
+ gitWorktrees,
133
280
  gitCommonDir,
134
281
  hasChanges,
282
+ changedPaths,
283
+ changedPathsInRepository,
284
+ hasPathChanges,
135
285
  addAndCommit,
136
286
  stageAll,
287
+ stagePaths,
288
+ commitPaths,
289
+ commitStagedPaths,
137
290
  commitStaged,
138
291
  stagedChangeContext,
139
292
  hasCommits,
140
293
  currentBranch,
141
294
  pushClean,
142
- isGitlink
295
+ isGitlink,
296
+ hasRepositoryOperation
143
297
  }