@searls/turbocommit 0.13.2 → 0.15.0
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 +59 -10
- package/cli.js +4 -1
- package/lib/git.js +203 -2
- package/lib/harness.js +2 -0
- package/lib/install.js +12 -4
- package/lib/multi.js +197 -0
- package/lib/run.js +290 -7
- package/lib/track.js +258 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# turbocommit
|
|
4
4
|
|
|
5
|
-
turbocommit creates a git commit containing
|
|
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,68 @@ 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**, **
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
- Claude Code: **PreToolUse**, **PostToolUse**, **SessionStart**,
|
|
22
|
+
**SessionEnd**, and **Stop**.
|
|
23
|
+
- Codex: **PreToolUse**, **PostToolUse**, **SessionStart**, **PreCompact**,
|
|
24
|
+
and **Stop**.
|
|
24
25
|
|
|
25
|
-
- **PreToolUse** tracks
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
- **PreToolUse** tracks the paths supplied to editing tools and snapshots the
|
|
27
|
+
repository before shell commands.
|
|
28
|
+
- **PostToolUse** attributes paths that became dirty during a shell command.
|
|
29
|
+
Commands that overlap another shell command fail closed and claim nothing.
|
|
30
|
+
Read-only commands remain uncommitted.
|
|
28
31
|
- **SessionStart / SessionEnd** chain sessions across `/clear` boundaries
|
|
29
32
|
so planning context survives into the eventual commit.
|
|
30
33
|
- **PreCompact** on Codex buffers visible transcript context before compaction.
|
|
31
|
-
- **Stop** fires after every turn. If the session modified files, it
|
|
32
|
-
commits
|
|
33
|
-
by a later session that does commit.
|
|
34
|
+
- **Stop** fires after every turn. If the session modified files, it stages and
|
|
35
|
+
commits only the paths tracked for that session. If not, it buffers the
|
|
36
|
+
transcript for pickup by a later session that does commit.
|
|
37
|
+
|
|
38
|
+
### Changes across multiple repositories
|
|
39
|
+
|
|
40
|
+
A session that starts in an enabled repository can commit changes in other
|
|
41
|
+
enabled local checkouts during the same turn. Turbocommit discovers each
|
|
42
|
+
checkout from explicit file paths supplied to Claude Code tools, Codex
|
|
43
|
+
`apply_patch`, and path-bearing MCP tools. Shell commands alone do not discover
|
|
44
|
+
additional repositories.
|
|
45
|
+
|
|
46
|
+
Each touched checkout:
|
|
47
|
+
|
|
48
|
+
- Reads its own merged global and project configuration.
|
|
49
|
+
- Stages only paths attributed to the current session, preserving unrelated
|
|
50
|
+
staged and unstaged work.
|
|
51
|
+
- Gets an independent commit with the full turn transcript.
|
|
52
|
+
- Gives the configured title agent repository-specific bounded diff context.
|
|
53
|
+
|
|
54
|
+
Turbocommit attempts every commit before pushing every successful commit whose
|
|
55
|
+
repository has `push: true`. Failures are retained and retried on a later Stop,
|
|
56
|
+
along with every transcript accumulated before the commit succeeds.
|
|
57
|
+
|
|
58
|
+
Separate worktrees are committed independently. Enabled submodules are
|
|
59
|
+
committed and pushed before enabled parent repositories. When one turn creates
|
|
60
|
+
commits in multiple repositories, each commit includes a shared
|
|
61
|
+
`Turbocommit-Session` trailer.
|
|
62
|
+
|
|
63
|
+
Relative tool paths are matched to the checkout where that path changed. If the
|
|
64
|
+
same relative path is dirty in multiple worktrees, Turbocommit skips the turn
|
|
65
|
+
with an `ambiguous-tracking` monitor reason instead of guessing which checkout
|
|
66
|
+
owns it, then clears that ambiguous tracking so later turns can proceed. Direct
|
|
67
|
+
editing tools such as `apply_patch` resolve relative paths from their recorded
|
|
68
|
+
working directory. Path-bearing MCP tools may report singular paths, path
|
|
69
|
+
arrays, or local file URIs. Pathless tool calls and directory-valued path fields
|
|
70
|
+
do not claim files or prevent later exact edits in the same session from
|
|
71
|
+
committing.
|
|
72
|
+
|
|
73
|
+
Before staging, Turbocommit resolves tracked paths to the exact changed files,
|
|
74
|
+
treats Git pathspec metacharacters literally, drops paths that are no longer
|
|
75
|
+
dirty, and excludes newly discovered embedded repositories. Existing tracked
|
|
76
|
+
submodule gitlinks remain eligible so child commits can update their parent.
|
|
77
|
+
During an active merge, cherry-pick, revert, or rebase, Git forbids partial
|
|
78
|
+
commits, so Turbocommit commits the operation's resolved index as a whole.
|
|
79
|
+
|
|
80
|
+
Retry manifests created by older versions did not record path ownership. On
|
|
81
|
+
upgrade, Turbocommit discards those unsafe retry entries and records a
|
|
82
|
+
`legacy-manifest` monitor event rather than guessing which files they owned.
|
|
34
83
|
|
|
35
84
|
The commit message headline is generated by a title agent (configurable).
|
|
36
85
|
The body contains the full prompt/response transcript. When planning
|
package/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ const { readStdin } = require('./lib/io')
|
|
|
4
4
|
const { install, uninstall } = require('./lib/install')
|
|
5
5
|
const { init, deinit } = require('./lib/init')
|
|
6
6
|
const { run, runPreCompact } = require('./lib/run')
|
|
7
|
-
const { handleTrack } = require('./lib/track')
|
|
7
|
+
const { handleTrack, handlePostTrack } = require('./lib/track')
|
|
8
8
|
const { handleSessionStart, handleSessionEnd } = require('./lib/session')
|
|
9
9
|
const { doctor } = require('./lib/doctor')
|
|
10
10
|
const { monitor } = require('./lib/monitor')
|
|
@@ -175,6 +175,9 @@ function cmdHook (argv, harness) {
|
|
|
175
175
|
case 'pre-tool-use':
|
|
176
176
|
handleTrack(hookInput, root)
|
|
177
177
|
return
|
|
178
|
+
case 'post-tool-use':
|
|
179
|
+
handlePostTrack(hookInput, root)
|
|
180
|
+
return
|
|
178
181
|
case 'session-start':
|
|
179
182
|
handleSessionStart(hookInput, root)
|
|
180
183
|
return
|
package/lib/git.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { execSync } = require('child_process')
|
|
2
|
+
const fs = require('fs')
|
|
2
3
|
const path = require('path')
|
|
3
4
|
|
|
4
5
|
function git (args, opts = {}) {
|
|
@@ -12,12 +13,66 @@ function git (args, opts = {}) {
|
|
|
12
13
|
|
|
13
14
|
function gitRoot (cwd) {
|
|
14
15
|
try {
|
|
15
|
-
return git('rev-parse --show-toplevel', { cwd })
|
|
16
|
+
return canonicalRoot(git('rev-parse --show-toplevel', { cwd }))
|
|
16
17
|
} catch {
|
|
17
18
|
return null
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
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
|
+
|
|
42
|
+
function gitRootForPath (filePath) {
|
|
43
|
+
let probe = filePath
|
|
44
|
+
try {
|
|
45
|
+
if (!fs.statSync(probe).isDirectory()) probe = path.dirname(probe)
|
|
46
|
+
} catch {
|
|
47
|
+
probe = path.dirname(probe)
|
|
48
|
+
}
|
|
49
|
+
while (probe && !fs.existsSync(probe)) {
|
|
50
|
+
const parent = path.dirname(probe)
|
|
51
|
+
if (parent === probe) return null
|
|
52
|
+
probe = parent
|
|
53
|
+
}
|
|
54
|
+
return gitRoot(probe)
|
|
55
|
+
}
|
|
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
|
+
|
|
21
76
|
function gitCommonDir (cwd) {
|
|
22
77
|
try {
|
|
23
78
|
const out = git('rev-parse --git-common-dir', { cwd })
|
|
@@ -44,12 +99,70 @@ function hasChanges (cwd) {
|
|
|
44
99
|
return untracked.length > 0
|
|
45
100
|
}
|
|
46
101
|
|
|
102
|
+
function hasPathChanges (cwd, filePath) {
|
|
103
|
+
return changedPaths(cwd, [filePath]).length > 0
|
|
104
|
+
}
|
|
105
|
+
|
|
47
106
|
function addAndCommit (cwd, headline, body) {
|
|
107
|
+
stageAll(cwd)
|
|
108
|
+
commitStaged(cwd, headline, body)
|
|
109
|
+
return git('rev-parse HEAD', { cwd })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function stageAll (cwd) {
|
|
48
113
|
git('add -A', { cwd })
|
|
114
|
+
}
|
|
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) {
|
|
125
|
+
const changed = stagePaths(cwd, filePaths)
|
|
126
|
+
return commitStagedPaths(cwd, changed, headline, body)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function commitStagedPaths (cwd, filePaths, headline, body) {
|
|
130
|
+
const paths = pathspecs(cwd, filePaths)
|
|
131
|
+
if (paths.length === 0) return null
|
|
132
|
+
if (hasRepositoryOperation(cwd)) return commitStaged(cwd, headline, body)
|
|
133
|
+
git(`commit --only -m "${esc(headline)}" -m "${esc(body)}" --no-verify -- ${paths.map(quote).join(' ')}`, { cwd })
|
|
134
|
+
return git('rev-parse HEAD', { cwd })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function commitStaged (cwd, headline, body) {
|
|
49
138
|
git(`commit -m "${esc(headline)}" -m "${esc(body)}" --no-verify`, { cwd })
|
|
50
139
|
return git('rev-parse HEAD', { cwd })
|
|
51
140
|
}
|
|
52
141
|
|
|
142
|
+
function stagedChangeContext (cwd, budget = 20000, filePaths) {
|
|
143
|
+
const selected = filePaths && filePaths.length > 0
|
|
144
|
+
? ` -- ${pathspecs(cwd, filePaths).map(quote).join(' ')}`
|
|
145
|
+
: ''
|
|
146
|
+
const status = git(`status --short${selected}`, { cwd })
|
|
147
|
+
const stat = git(`diff --cached --stat${selected}`, { cwd })
|
|
148
|
+
const diff = git(`diff --cached --no-ext-diff --unified=3${selected}`, { cwd })
|
|
149
|
+
const prefix = `Status:\n${status || '(clean)'}\n\nDiff stat:\n${stat || '(none)'}\n\nDiff:\n`
|
|
150
|
+
const remaining = Math.max(0, budget - prefix.length)
|
|
151
|
+
return prefix + (diff.length > remaining ? diff.slice(0, remaining) + '\n[... diff truncated ...]' : diff)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isGitlink (parentRoot, childRoot) {
|
|
155
|
+
const relative = path.relative(parentRoot, childRoot)
|
|
156
|
+
if (!relative || relative.startsWith('..')) return false
|
|
157
|
+
try {
|
|
158
|
+
return git(`ls-files --stage -- ${quote(literalPathspec(relative))}`, { cwd: parentRoot })
|
|
159
|
+
.split('\n')
|
|
160
|
+
.some(line => line.startsWith('160000 '))
|
|
161
|
+
} catch {
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
53
166
|
function hasCommits (cwd) {
|
|
54
167
|
try {
|
|
55
168
|
git('rev-parse HEAD', { cwd })
|
|
@@ -80,13 +193,101 @@ function esc (s) {
|
|
|
80
193
|
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`')
|
|
81
194
|
}
|
|
82
195
|
|
|
196
|
+
function quote (value) {
|
|
197
|
+
return `'${value.replace(/'/g, '\'"\'"\'')}'`
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function pathspecs (cwd, filePaths) {
|
|
201
|
+
cwd = canonicalRoot(cwd)
|
|
202
|
+
return [...new Set(filePaths.map(filePath => path.relative(cwd, canonicalTrackedPath(filePath))))]
|
|
203
|
+
.filter(relative => relative && !relative.startsWith('..'))
|
|
204
|
+
.map(literalPathspec)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function literalPathspec (relative) {
|
|
208
|
+
return `:(literal)${relative}`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function changedPaths (cwd, filePaths) {
|
|
212
|
+
cwd = canonicalRoot(cwd)
|
|
213
|
+
const selected = pathspecs(cwd, filePaths)
|
|
214
|
+
if (selected.length === 0) return []
|
|
215
|
+
return collectChangedPaths(cwd, selected)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function changedPathsInRepository (cwd) {
|
|
219
|
+
cwd = canonicalRoot(cwd)
|
|
220
|
+
return collectChangedPaths(cwd, [])
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function collectChangedPaths (cwd, selected) {
|
|
224
|
+
const args = selected.length > 0 ? ` -- ${selected.map(quote).join(' ')}` : ''
|
|
225
|
+
const tracked = hasCommits(cwd)
|
|
226
|
+
? git(`diff --name-only --no-renames -z HEAD${args}`, { cwd })
|
|
227
|
+
: git(`ls-files --cached -z${args}`, { cwd })
|
|
228
|
+
const untracked = git(`ls-files --others --exclude-standard -z${args}`, { cwd })
|
|
229
|
+
const relativePaths = [...splitNull(tracked), ...splitNull(untracked)]
|
|
230
|
+
return [...new Set(relativePaths)]
|
|
231
|
+
.filter(relative => !isEmbeddedRepository(cwd, relative) || isHeadGitlink(cwd, relative))
|
|
232
|
+
.map(relative => path.join(cwd, relative))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function splitNull (value) {
|
|
236
|
+
return value ? value.split('\0').filter(Boolean) : []
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isEmbeddedRepository (cwd, relative) {
|
|
240
|
+
const candidate = path.join(cwd, relative)
|
|
241
|
+
try {
|
|
242
|
+
return fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, '.git'))
|
|
243
|
+
} catch {
|
|
244
|
+
return false
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function isHeadGitlink (cwd, relative) {
|
|
249
|
+
try {
|
|
250
|
+
return git(`ls-tree HEAD -- ${quote(literalPathspec(relative))}`, { cwd })
|
|
251
|
+
.split('\n')
|
|
252
|
+
.some(line => line.startsWith('160000 '))
|
|
253
|
+
} catch {
|
|
254
|
+
return false
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function hasRepositoryOperation (cwd) {
|
|
259
|
+
return ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply']
|
|
260
|
+
.some(name => {
|
|
261
|
+
try {
|
|
262
|
+
const gitPath = git(`rev-parse --git-path ${quote(name)}`, { cwd })
|
|
263
|
+
return fs.existsSync(path.isAbsolute(gitPath) ? gitPath : path.join(cwd, gitPath))
|
|
264
|
+
} catch {
|
|
265
|
+
return false
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
83
270
|
module.exports = {
|
|
84
271
|
git,
|
|
272
|
+
canonicalRoot,
|
|
273
|
+
canonicalTrackedPath,
|
|
85
274
|
gitRoot,
|
|
275
|
+
gitRootForPath,
|
|
276
|
+
gitWorktrees,
|
|
86
277
|
gitCommonDir,
|
|
87
278
|
hasChanges,
|
|
279
|
+
changedPaths,
|
|
280
|
+
changedPathsInRepository,
|
|
281
|
+
hasPathChanges,
|
|
88
282
|
addAndCommit,
|
|
283
|
+
stageAll,
|
|
284
|
+
stagePaths,
|
|
285
|
+
commitPaths,
|
|
286
|
+
commitStagedPaths,
|
|
287
|
+
commitStaged,
|
|
288
|
+
stagedChangeContext,
|
|
89
289
|
hasCommits,
|
|
90
290
|
currentBranch,
|
|
91
|
-
pushClean
|
|
291
|
+
pushClean,
|
|
292
|
+
isGitlink
|
|
92
293
|
}
|
package/lib/harness.js
CHANGED
|
@@ -75,6 +75,7 @@ function normalizeHookInput (input, event, forcedHarness) {
|
|
|
75
75
|
transcriptPath: hookInput.transcript_path,
|
|
76
76
|
toolName: hookInput.tool_name,
|
|
77
77
|
toolInput: hookInput.tool_input || {},
|
|
78
|
+
toolUseId: hookInput.tool_use_id,
|
|
78
79
|
cwd: hookInput.cwd,
|
|
79
80
|
model: hookInput.model,
|
|
80
81
|
source: hookInput.source,
|
|
@@ -85,6 +86,7 @@ function normalizeHookInput (input, event, forcedHarness) {
|
|
|
85
86
|
function eventName (event) {
|
|
86
87
|
const names = {
|
|
87
88
|
'pre-tool-use': 'PreToolUse',
|
|
89
|
+
'post-tool-use': 'PostToolUse',
|
|
88
90
|
'session-start': 'SessionStart',
|
|
89
91
|
'session-end': 'SessionEnd',
|
|
90
92
|
'pre-compact': 'PreCompact',
|
package/lib/install.js
CHANGED
|
@@ -10,6 +10,10 @@ const HOOK_DEFS = {
|
|
|
10
10
|
matcher: 'Write|Edit|MultiEdit|NotebookEdit|Bash|mcp__.*',
|
|
11
11
|
hooks: [{ type: 'command', command: 'turbocommit hook pre-tool-use --harness claude' }]
|
|
12
12
|
},
|
|
13
|
+
PostToolUse: {
|
|
14
|
+
matcher: 'Bash',
|
|
15
|
+
hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness claude' }]
|
|
16
|
+
},
|
|
13
17
|
SessionStart: {
|
|
14
18
|
hooks: [{ type: 'command', command: 'turbocommit hook session-start --harness claude' }]
|
|
15
19
|
},
|
|
@@ -23,9 +27,13 @@ const HOOK_DEFS = {
|
|
|
23
27
|
|
|
24
28
|
const CODEX_HOOK_DEFS = {
|
|
25
29
|
PreToolUse: {
|
|
26
|
-
matcher: 'Write|Edit|MultiEdit|NotebookEdit|Bash|mcp__.*',
|
|
30
|
+
matcher: 'apply_patch|Write|Edit|MultiEdit|NotebookEdit|Bash|mcp__.*',
|
|
27
31
|
hooks: [{ type: 'command', command: 'turbocommit hook pre-tool-use --harness codex' }]
|
|
28
32
|
},
|
|
33
|
+
PostToolUse: {
|
|
34
|
+
matcher: 'Bash',
|
|
35
|
+
hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness codex' }]
|
|
36
|
+
},
|
|
29
37
|
SessionStart: {
|
|
30
38
|
matcher: 'resume|clear',
|
|
31
39
|
hooks: [{ type: 'command', command: 'turbocommit hook session-start --harness codex' }]
|
|
@@ -67,7 +75,8 @@ function hasExactHooks (groups, def) {
|
|
|
67
75
|
return expected.every(cmd =>
|
|
68
76
|
groups.some(g => {
|
|
69
77
|
const hooks = g && g.hooks ? g.hooks : []
|
|
70
|
-
|
|
78
|
+
const matcherMatches = def.matcher === undefined || g.matcher === def.matcher
|
|
79
|
+
return matcherMatches && hooks.some(h => h.command === cmd)
|
|
71
80
|
})
|
|
72
81
|
)
|
|
73
82
|
}
|
|
@@ -99,8 +108,7 @@ function removeTurbocommitHooks (groups) {
|
|
|
99
108
|
}
|
|
100
109
|
|
|
101
110
|
/**
|
|
102
|
-
* Install turbocommit
|
|
103
|
-
* SessionEnd, Stop). Each event gets its own group at the end.
|
|
111
|
+
* Install every turbocommit hook event. Each event gets its own group at the end.
|
|
104
112
|
* Cleans up stale entries (including old `turbocommit run`) on install.
|
|
105
113
|
*/
|
|
106
114
|
function installClaude (settingsPath) {
|
package/lib/multi.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
const crypto = require('crypto')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const { ensureDir, loadJson } = require('./io')
|
|
5
|
+
const { turbocommitDir } = require('./session')
|
|
6
|
+
const { canonicalRoot, canonicalTrackedPath, gitRoot, gitRootForPath, gitWorktrees, hasPathChanges, isGitlink } = require('./git')
|
|
7
|
+
const { activeConfig } = require('./config')
|
|
8
|
+
|
|
9
|
+
function checkoutKey (root) {
|
|
10
|
+
return crypto.createHash('sha256').update(fs.realpathSync(root)).digest('hex').slice(0, 16)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function manifestPath (anchor) {
|
|
14
|
+
return path.join(turbocommitDir(anchor), 'multi', checkoutKey(anchor), 'manifest.json')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function loadManifest (anchor) {
|
|
18
|
+
return normalizeManifest(loadJson(manifestPath(anchor)))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function saveManifest (anchor, manifest) {
|
|
22
|
+
const file = manifestPath(anchor)
|
|
23
|
+
ensureDir(path.dirname(file))
|
|
24
|
+
fs.writeFileSync(file, JSON.stringify({
|
|
25
|
+
nextOrder: manifest.nextOrder,
|
|
26
|
+
repos: manifest.repos
|
|
27
|
+
}, null, 2) + '\n')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function deleteManifestIfEmpty (anchor, manifest) {
|
|
31
|
+
if (manifest.repos.length > 0) {
|
|
32
|
+
saveManifest(anchor, manifest)
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
fs.unlinkSync(manifestPath(anchor))
|
|
37
|
+
} catch {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function trackedChangesFromEntries (anchor, entries) {
|
|
41
|
+
const pathsByRoot = new Map()
|
|
42
|
+
const ambiguous = []
|
|
43
|
+
const add = (root, file) => {
|
|
44
|
+
root = canonicalRoot(root)
|
|
45
|
+
file = canonicalTrackedPath(file)
|
|
46
|
+
if (!root || activeConfig(root).config.enabled !== true) return
|
|
47
|
+
const relative = path.relative(root, file)
|
|
48
|
+
if (!relative || relative.startsWith('..')) return
|
|
49
|
+
if (!pathsByRoot.has(root)) pathsByRoot.set(root, new Set())
|
|
50
|
+
pathsByRoot.get(root).add(path.join(root, relative))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
const rawFiles = Array.isArray(entry.rawFiles) && entry.rawFiles.length > 0
|
|
55
|
+
? entry.rawFiles
|
|
56
|
+
: Array.isArray(entry.files) ? entry.files : []
|
|
57
|
+
if (entry.tool === 'Bash' && rawFiles.length === 0) continue
|
|
58
|
+
if (rawFiles.length === 0) continue
|
|
59
|
+
|
|
60
|
+
for (const rawFile of rawFiles) {
|
|
61
|
+
const candidates = changedCandidates(anchor, entry.cwd || anchor, rawFile, entry.tool)
|
|
62
|
+
if (candidates.length > 1) {
|
|
63
|
+
ambiguous.push({ tool: entry.tool, path: rawFile, reason: 'multiple-worktrees' })
|
|
64
|
+
} else if (candidates.length === 1) {
|
|
65
|
+
add(candidates[0].root, candidates[0].file)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
addEnabledSuperprojects(pathsByRoot, add)
|
|
71
|
+
const repos = [...pathsByRoot].map(([root, paths]) => ({ root, paths: [...paths] }))
|
|
72
|
+
return { repos, ambiguous }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function changedCandidates (anchor, cwd, rawFile, tool) {
|
|
76
|
+
if (path.isAbsolute(rawFile)) return changedCandidate(rawFile)
|
|
77
|
+
|
|
78
|
+
anchor = canonicalRoot(anchor)
|
|
79
|
+
const direct = canonicalTrackedPath(path.resolve(cwd, rawFile))
|
|
80
|
+
const directRoot = gitRootForPath(direct)
|
|
81
|
+
if (!isOpaquePathTool(tool) || (directRoot && directRoot !== anchor)) {
|
|
82
|
+
return changedCandidate(direct)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const relative = path.relative(anchor, direct)
|
|
86
|
+
if (!relative || relative.startsWith('..')) return changedCandidate(direct)
|
|
87
|
+
const candidates = []
|
|
88
|
+
const seen = new Set()
|
|
89
|
+
for (const worktree of gitWorktrees(anchor)) {
|
|
90
|
+
const file = canonicalTrackedPath(path.join(worktree, relative))
|
|
91
|
+
if (isDirectoryPath(file)) continue
|
|
92
|
+
const root = gitRootForPath(file)
|
|
93
|
+
if (!root || seen.has(root) || activeConfig(root).config.enabled !== true) continue
|
|
94
|
+
seen.add(root)
|
|
95
|
+
if (hasPathChanges(root, file)) candidates.push({ root, file })
|
|
96
|
+
}
|
|
97
|
+
return candidates
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isOpaquePathTool (tool) {
|
|
101
|
+
return typeof tool === 'string' && tool.startsWith('mcp__')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function changedCandidate (file) {
|
|
105
|
+
if (isDirectoryPath(file)) return []
|
|
106
|
+
file = canonicalTrackedPath(file)
|
|
107
|
+
const root = gitRootForPath(file)
|
|
108
|
+
if (!root || activeConfig(root).config.enabled !== true) return []
|
|
109
|
+
return hasPathChanges(root, file) ? [{ root, file }] : []
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isDirectoryPath (file) {
|
|
113
|
+
try {
|
|
114
|
+
return fs.statSync(file).isDirectory()
|
|
115
|
+
} catch {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function addEnabledSuperprojects (pathsByRoot, add) {
|
|
121
|
+
for (const child of [...pathsByRoot.keys()]) {
|
|
122
|
+
const parent = gitRoot(path.dirname(child))
|
|
123
|
+
if (parent && parent !== child && isGitlink(parent, child)) add(parent, child)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function dependencyOrder (repos) {
|
|
128
|
+
return [...repos].sort((a, b) => {
|
|
129
|
+
if (isGitlink(a.root, b.root)) return 1
|
|
130
|
+
if (isGitlink(b.root, a.root)) return -1
|
|
131
|
+
return a.order - b.order
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function appendWork (manifest, root, work) {
|
|
136
|
+
let repo = manifest.repos.find(candidate => candidate.root === root)
|
|
137
|
+
if (!repo) {
|
|
138
|
+
repo = { root, order: manifest.nextOrder++, work: [] }
|
|
139
|
+
manifest.repos.push(repo)
|
|
140
|
+
}
|
|
141
|
+
if (!repo.work.some(item => item.key === work.key)) repo.work.push(work)
|
|
142
|
+
return repo
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function normalizeManifest (stored) {
|
|
146
|
+
const manifest = stored && typeof stored === 'object' ? stored : {}
|
|
147
|
+
const repos = Array.isArray(manifest.repos) ? manifest.repos : []
|
|
148
|
+
let discardedLegacyWork = 0
|
|
149
|
+
const normalizedRepos = []
|
|
150
|
+
|
|
151
|
+
for (const repo of repos) {
|
|
152
|
+
if (!repo || typeof repo.root !== 'string' || !Array.isArray(repo.work)) continue
|
|
153
|
+
const work = repo.work.filter(item => {
|
|
154
|
+
const safe = item && Array.isArray(item.paths) && item.paths.length > 0
|
|
155
|
+
if (!safe) discardedLegacyWork++
|
|
156
|
+
return safe
|
|
157
|
+
})
|
|
158
|
+
if (work.length > 0) normalizedRepos.push({ ...repo, work })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
nextOrder: Number.isInteger(manifest.nextOrder) ? manifest.nextOrder : 0,
|
|
163
|
+
repos: normalizedRepos,
|
|
164
|
+
discardedLegacyWork
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function multiWatermarkPath (root, sessionId) {
|
|
169
|
+
return path.join(turbocommitDir(root), 'multi-watermarks', checkoutKey(root), sessionId + '.json')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function readMultiWatermark (root, sessionIds) {
|
|
173
|
+
for (const sessionId of sessionIds) {
|
|
174
|
+
const value = loadJson(multiWatermarkPath(root, sessionId))
|
|
175
|
+
if (value?.commit) return value
|
|
176
|
+
}
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function saveMultiWatermark (root, sessionId, commit) {
|
|
181
|
+
const file = multiWatermarkPath(root, sessionId)
|
|
182
|
+
ensureDir(path.dirname(file))
|
|
183
|
+
fs.writeFileSync(file, JSON.stringify({ commit }) + '\n')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
appendWork,
|
|
188
|
+
checkoutKey,
|
|
189
|
+
deleteManifestIfEmpty,
|
|
190
|
+
dependencyOrder,
|
|
191
|
+
loadManifest,
|
|
192
|
+
manifestPath,
|
|
193
|
+
readMultiWatermark,
|
|
194
|
+
trackedChangesFromEntries,
|
|
195
|
+
saveManifest,
|
|
196
|
+
saveMultiWatermark
|
|
197
|
+
}
|
package/lib/run.js
CHANGED
|
@@ -4,15 +4,25 @@ 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, addAndCommit,
|
|
7
|
+
const { gitRoot, hasChanges, changedPaths, addAndCommit, stagePaths, commitPaths, commitStagedPaths, stagedChangeContext, currentBranch, pushClean } = require('./git')
|
|
8
8
|
const { logEvent } = require('./log')
|
|
9
9
|
const { wrapText } = require('./wrap')
|
|
10
|
-
const { hasTrackedModifications, cleanupTracking } = require('./track')
|
|
10
|
+
const { hasTrackedModifications, cleanupTracking, readTracking } = require('./track')
|
|
11
11
|
const { redact, buildRedactions } = require('./redact')
|
|
12
12
|
const { handleSessionEnd, getAncestors, savePending, collectPending, cleanupConsumed, cleanupStale, readWatermark, saveWatermark, resolveParentCommit } = require('./session')
|
|
13
13
|
const { activeConfig } = require('./config')
|
|
14
14
|
const { normalizeHookInput } = require('./harness')
|
|
15
15
|
const { resolveCodexTranscriptPath } = require('./codex')
|
|
16
|
+
const {
|
|
17
|
+
appendWork,
|
|
18
|
+
deleteManifestIfEmpty,
|
|
19
|
+
dependencyOrder,
|
|
20
|
+
loadManifest,
|
|
21
|
+
readMultiWatermark,
|
|
22
|
+
trackedChangesFromEntries,
|
|
23
|
+
saveManifest,
|
|
24
|
+
saveMultiWatermark
|
|
25
|
+
} = require('./multi')
|
|
16
26
|
|
|
17
27
|
/**
|
|
18
28
|
* Map a model ID like "claude-opus-4-6" to a friendly name like "Claude Opus 4.6".
|
|
@@ -87,11 +97,47 @@ function resolveCoauthor (config, transcriptPath, root, opts = {}) {
|
|
|
87
97
|
* Always exits 0 — never blocks Claude, never outputs to stdout.
|
|
88
98
|
*
|
|
89
99
|
* Skip/commit decision is based on PreToolUse tracking:
|
|
90
|
-
* - If tracking file
|
|
100
|
+
* - If tracking file contains changed paths → commit only those paths
|
|
91
101
|
* - If tracking file missing/empty → skip, buffer transcript for later pickup
|
|
92
102
|
*/
|
|
93
103
|
function run (input, opts = {}) {
|
|
94
104
|
if (process.env.TURBOCOMMIT_DISABLED) return
|
|
105
|
+
const hookInput = typeof input === 'string'
|
|
106
|
+
? normalizeHookInput(input, 'stop', opts.harness)
|
|
107
|
+
: input
|
|
108
|
+
if (!hookInput) return
|
|
109
|
+
|
|
110
|
+
const root = gitRoot(hookInput.cwd || process.cwd())
|
|
111
|
+
if (!root || activeConfig(root).config.enabled !== true) return
|
|
112
|
+
|
|
113
|
+
const entries = hookInput.sessionId ? readTracking(root, hookInput.sessionId) : []
|
|
114
|
+
const tracked = trackedChangesFromEntries(root, entries)
|
|
115
|
+
const manifest = loadManifest(root)
|
|
116
|
+
if (manifest.discardedLegacyWork > 0) {
|
|
117
|
+
logEvent('skip', {
|
|
118
|
+
harness: hookInput.harness,
|
|
119
|
+
project: path.basename(root),
|
|
120
|
+
branch: currentBranch(root),
|
|
121
|
+
reason: 'legacy-manifest',
|
|
122
|
+
discarded: manifest.discardedLegacyWork
|
|
123
|
+
})
|
|
124
|
+
manifest.discardedLegacyWork = 0
|
|
125
|
+
deleteManifestIfEmpty(root, manifest)
|
|
126
|
+
}
|
|
127
|
+
const hasCrossRootPath = tracked.repos.length > 0 &&
|
|
128
|
+
(tracked.repos.length !== 1 || tracked.repos[0].root !== root)
|
|
129
|
+
|
|
130
|
+
if (tracked.ambiguous.length > 0) {
|
|
131
|
+
return runSingle(hookInput, { paths: [], trackingReason: 'ambiguous-tracking' })
|
|
132
|
+
}
|
|
133
|
+
if (manifest.repos.length > 0 || tracked.repos.length > 1 || hasCrossRootPath) {
|
|
134
|
+
return runMulti(hookInput, root, tracked.repos, manifest)
|
|
135
|
+
}
|
|
136
|
+
return runSingle(hookInput, { paths: tracked.repos[0]?.paths || [] })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function runSingle (input, opts = {}) {
|
|
140
|
+
if (process.env.TURBOCOMMIT_DISABLED) return
|
|
95
141
|
|
|
96
142
|
const hookInput = typeof input === 'string'
|
|
97
143
|
? normalizeHookInput(input, 'stop', opts.harness)
|
|
@@ -120,6 +166,7 @@ function run (input, opts = {}) {
|
|
|
120
166
|
try { context = fs.statSync(transcriptPath).size } catch {}
|
|
121
167
|
|
|
122
168
|
const sessionId = hookInput.sessionId
|
|
169
|
+
const ownedPaths = opts.paths || []
|
|
123
170
|
|
|
124
171
|
// Watermark slicing: only include new pairs since last commit in this session
|
|
125
172
|
const watermark = sessionId ? readWatermark(root, sessionId) : null
|
|
@@ -139,21 +186,38 @@ function run (input, opts = {}) {
|
|
|
139
186
|
if (!(precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0)) {
|
|
140
187
|
savePending(root, sessionId, formatBody(effectivePairs))
|
|
141
188
|
}
|
|
142
|
-
logEvent('skip', { harness: hookInput.harness, project, branch, context })
|
|
189
|
+
logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-tracking' })
|
|
190
|
+
cleanupStale(root)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (sessionId && (opts.trackingReason || ownedPaths.length === 0)) {
|
|
195
|
+
if (!(precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0)) {
|
|
196
|
+
savePending(root, sessionId, formatBody(effectivePairs))
|
|
197
|
+
}
|
|
198
|
+
cleanupTracking(root, sessionId)
|
|
199
|
+
logEvent('skip', {
|
|
200
|
+
harness: hookInput.harness,
|
|
201
|
+
project,
|
|
202
|
+
branch,
|
|
203
|
+
context,
|
|
204
|
+
reason: opts.trackingReason || 'no-path-changes'
|
|
205
|
+
})
|
|
143
206
|
cleanupStale(root)
|
|
144
207
|
return
|
|
145
208
|
}
|
|
146
209
|
|
|
147
210
|
try {
|
|
148
211
|
// Early exit: tracking fired but all changes were reverted
|
|
149
|
-
|
|
212
|
+
const hasOwnedChanges = sessionId ? changedPaths(root, ownedPaths).length > 0 : hasChanges(root)
|
|
213
|
+
if (!hasOwnedChanges) {
|
|
150
214
|
if (sessionId) {
|
|
151
215
|
if (!(precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0)) {
|
|
152
216
|
savePending(root, sessionId, formatBody(effectivePairs))
|
|
153
217
|
}
|
|
154
218
|
cleanupTracking(root, sessionId)
|
|
155
219
|
}
|
|
156
|
-
logEvent('skip', { harness: hookInput.harness, project, branch, context })
|
|
220
|
+
logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-path-changes' })
|
|
157
221
|
cleanupStale(root)
|
|
158
222
|
return
|
|
159
223
|
}
|
|
@@ -214,7 +278,21 @@ function run (input, opts = {}) {
|
|
|
214
278
|
const redactions = buildRedactions()
|
|
215
279
|
const safeHeadline = redact(headline, redactions)
|
|
216
280
|
const safeBody = redact(wrappedBody + tag, redactions)
|
|
217
|
-
const sha =
|
|
281
|
+
const sha = sessionId
|
|
282
|
+
? commitPaths(root, ownedPaths, safeHeadline, safeBody)
|
|
283
|
+
: addAndCommit(root, safeHeadline, safeBody)
|
|
284
|
+
if (!sha) {
|
|
285
|
+
if (sessionId) cleanupTracking(root, sessionId)
|
|
286
|
+
logEvent('skip', {
|
|
287
|
+
harness: hookInput.harness,
|
|
288
|
+
project,
|
|
289
|
+
branch,
|
|
290
|
+
context,
|
|
291
|
+
reason: 'no-path-changes'
|
|
292
|
+
})
|
|
293
|
+
cleanupStale(root)
|
|
294
|
+
return
|
|
295
|
+
}
|
|
218
296
|
|
|
219
297
|
logEvent('success', { harness: hookInput.harness, project, branch, context, title: safeHeadline })
|
|
220
298
|
|
|
@@ -240,6 +318,211 @@ function run (input, opts = {}) {
|
|
|
240
318
|
}
|
|
241
319
|
}
|
|
242
320
|
|
|
321
|
+
function runMulti (hookInput, anchor, currentRepos, manifest) {
|
|
322
|
+
recordCodexStop(hookInput, anchor)
|
|
323
|
+
|
|
324
|
+
const transcriptPath = resolveTranscriptPath(hookInput)
|
|
325
|
+
const pairs = hookInput.harness === 'codex'
|
|
326
|
+
? parseCodexTranscript(transcriptPath)
|
|
327
|
+
: parseTranscript(transcriptPath)
|
|
328
|
+
const sessionId = hookInput.sessionId
|
|
329
|
+
const watermark = sessionId ? readWatermark(anchor, sessionId) : null
|
|
330
|
+
const watermarkPairCount = watermark && Number.isInteger(watermark.pairs) ? watermark.pairs : 0
|
|
331
|
+
const newPairs = watermark ? pairs.slice(watermarkPairCount) : pairs
|
|
332
|
+
const precompactWatermark = watermark && watermark.source === 'precompact'
|
|
333
|
+
const selfPrecompactPending = sessionId
|
|
334
|
+
? collectPending(anchor, [sessionId], { source: 'precompact' })
|
|
335
|
+
: []
|
|
336
|
+
let effectivePairs = newPairs.length > 0 ? newPairs : pairs
|
|
337
|
+
if (precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0) {
|
|
338
|
+
const basePairs = Number.isInteger(watermark.basePairs) ? watermark.basePairs : 0
|
|
339
|
+
const bufferedPairs = pairs.slice(basePairs, watermarkPairCount)
|
|
340
|
+
effectivePairs = bufferedPairs.length > 0 ? bufferedPairs : pairs
|
|
341
|
+
}
|
|
342
|
+
const formattedTranscript = formatBody(effectivePairs)
|
|
343
|
+
const ancestors = sessionId ? getAncestors(anchor, sessionId) : []
|
|
344
|
+
const pending = sessionId ? collectPending(anchor, [...ancestors].reverse()) : []
|
|
345
|
+
if (precompactWatermark && newPairs.length > 0 && selfPrecompactPending.length > 0) {
|
|
346
|
+
pending.push(...selfPrecompactPending)
|
|
347
|
+
}
|
|
348
|
+
const turnBody = pending.length > 0
|
|
349
|
+
? '## Planning\n\n' + pending.join('\n\n---\n\n') +
|
|
350
|
+
'\n\n## Implementation\n\n' + formattedTranscript
|
|
351
|
+
: formattedTranscript
|
|
352
|
+
|
|
353
|
+
const dirtyRepos = currentRepos.filter(repo => changedPaths(repo.root, repo.paths).length > 0)
|
|
354
|
+
const isMultiTurn = currentRepos.length > 1
|
|
355
|
+
const turnKey = hookInput.raw?.turn_id ||
|
|
356
|
+
cryptoKey(`${sessionId || 'no-session'}\n${pairs.length}\n${turnBody}`)
|
|
357
|
+
|
|
358
|
+
if (dirtyRepos.length === 0 && manifest.repos.length === 0) {
|
|
359
|
+
if (sessionId) {
|
|
360
|
+
savePending(anchor, sessionId, formattedTranscript)
|
|
361
|
+
cleanupTracking(anchor, sessionId)
|
|
362
|
+
}
|
|
363
|
+
logEvent('skip', {
|
|
364
|
+
harness: hookInput.harness,
|
|
365
|
+
project: path.basename(anchor),
|
|
366
|
+
branch: currentBranch(anchor),
|
|
367
|
+
context: transcriptSize(transcriptPath),
|
|
368
|
+
reason: 'no-path-changes'
|
|
369
|
+
})
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
for (const repo of currentRepos) {
|
|
374
|
+
appendWork(manifest, repo.root, {
|
|
375
|
+
key: turnKey,
|
|
376
|
+
body: turnBody,
|
|
377
|
+
sessionIds: isMultiTurn && sessionId ? [sessionId] : [],
|
|
378
|
+
paths: repo.paths
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
saveManifest(anchor, manifest)
|
|
383
|
+
const successes = []
|
|
384
|
+
const ordered = dependencyOrder(manifest.repos)
|
|
385
|
+
|
|
386
|
+
for (const repo of ordered) {
|
|
387
|
+
const root = repo.root
|
|
388
|
+
const { config } = activeConfig(root)
|
|
389
|
+
const project = path.basename(root)
|
|
390
|
+
const branch = currentBranch(root)
|
|
391
|
+
const context = transcriptSize(transcriptPath)
|
|
392
|
+
const ownedPaths = [...new Set(repo.work.flatMap(item => item.paths || []))]
|
|
393
|
+
|
|
394
|
+
if (config.enabled !== true) {
|
|
395
|
+
manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
|
|
396
|
+
logEvent('skip', { harness: hookInput.harness, project, branch, context })
|
|
397
|
+
continue
|
|
398
|
+
}
|
|
399
|
+
if (ownedPaths.length === 0) {
|
|
400
|
+
logEvent('skip', {
|
|
401
|
+
harness: hookInput.harness,
|
|
402
|
+
project,
|
|
403
|
+
branch,
|
|
404
|
+
context,
|
|
405
|
+
reason: 'missing-manifest-paths'
|
|
406
|
+
})
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
const currentPaths = changedPaths(root, ownedPaths)
|
|
410
|
+
if (currentPaths.length === 0) {
|
|
411
|
+
manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
|
|
412
|
+
logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-path-changes' })
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
logEvent('start', { harness: hookInput.harness, project, branch, context })
|
|
418
|
+
const stagedPaths = stagePaths(root, currentPaths)
|
|
419
|
+
if (stagedPaths.length === 0) {
|
|
420
|
+
manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
|
|
421
|
+
logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-path-changes' })
|
|
422
|
+
continue
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const rawBody = repo.work.map(item => item.body).join('\n\n---\n\n')
|
|
426
|
+
const redactions = buildRedactions()
|
|
427
|
+
const changeContext = redact(stagedChangeContext(root, 10000, stagedPaths), redactions)
|
|
428
|
+
const titleInput = `${changeContext}\n\nTranscript:\n${rawBody}`.slice(0, 20000)
|
|
429
|
+
|
|
430
|
+
let headline
|
|
431
|
+
if (config.title?.type !== 'transcript') {
|
|
432
|
+
headline = runTitleAgent(root, config.title || {}, titleInput, hookInput.harness)
|
|
433
|
+
}
|
|
434
|
+
headline = headline || extractHeadline(effectivePairs)
|
|
435
|
+
|
|
436
|
+
let body
|
|
437
|
+
if (config.body?.type === 'agent') {
|
|
438
|
+
body = runBodyAgent(root, config.body, rawBody, hookInput.harness)
|
|
439
|
+
}
|
|
440
|
+
body = body || rawBody
|
|
441
|
+
|
|
442
|
+
const parent = sessionId
|
|
443
|
+
? readMultiWatermark(root, [sessionId, ...ancestors])
|
|
444
|
+
: null
|
|
445
|
+
if (parent?.commit) body = `Continuation of ${parent.commit.slice(0, 7)}\n\n${body}`
|
|
446
|
+
body = wrapText(body, config.body?.maxLineLength)
|
|
447
|
+
|
|
448
|
+
const trailers = []
|
|
449
|
+
for (const work of repo.work) {
|
|
450
|
+
for (const id of work.sessionIds || []) {
|
|
451
|
+
if (!trailers.includes(id)) trailers.push(id)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (trailers.length > 0) {
|
|
455
|
+
body += '\n\n' + trailers.map(id => `Turbocommit-Session: ${id}`).join('\n')
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const coauthor = resolveCoauthor(config, transcriptPath, root, {
|
|
459
|
+
harness: hookInput.harness,
|
|
460
|
+
model: hookInput.model
|
|
461
|
+
})
|
|
462
|
+
if (coauthor) body += '\n\n' + coauthor
|
|
463
|
+
|
|
464
|
+
const safeHeadline = redact(headline, redactions)
|
|
465
|
+
const safeBody = redact(body, redactions)
|
|
466
|
+
const sha = commitStagedPaths(root, stagedPaths, safeHeadline, safeBody)
|
|
467
|
+
if (!sha) throw new Error('Tracked paths changed before commit')
|
|
468
|
+
if (sessionId) saveMultiWatermark(root, sessionId, sha)
|
|
469
|
+
if (root === anchor && sessionId) {
|
|
470
|
+
saveWatermark(anchor, sessionId, pairs.length, sha, { source: 'commit' })
|
|
471
|
+
}
|
|
472
|
+
manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
|
|
473
|
+
successes.push({ root, config, project, branch })
|
|
474
|
+
logEvent('success', {
|
|
475
|
+
harness: hookInput.harness,
|
|
476
|
+
project,
|
|
477
|
+
branch,
|
|
478
|
+
context,
|
|
479
|
+
title: safeHeadline
|
|
480
|
+
})
|
|
481
|
+
} catch {
|
|
482
|
+
logEvent('fail', { harness: hookInput.harness, project, branch, context })
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
for (const success of successes) {
|
|
487
|
+
if (success.config.push !== true) continue
|
|
488
|
+
if (pushClean(success.root)) {
|
|
489
|
+
logEvent('push', {
|
|
490
|
+
harness: hookInput.harness,
|
|
491
|
+
project: success.project,
|
|
492
|
+
branch: success.branch
|
|
493
|
+
})
|
|
494
|
+
} else {
|
|
495
|
+
logEvent('push-fail', {
|
|
496
|
+
harness: hookInput.harness,
|
|
497
|
+
project: success.project,
|
|
498
|
+
branch: success.branch
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (sessionId) {
|
|
504
|
+
if (!readWatermark(anchor, sessionId)) {
|
|
505
|
+
saveWatermark(anchor, sessionId, pairs.length, undefined, { source: 'multi' })
|
|
506
|
+
}
|
|
507
|
+
cleanupConsumed(anchor, [...ancestors, sessionId])
|
|
508
|
+
cleanupTracking(anchor, sessionId)
|
|
509
|
+
}
|
|
510
|
+
deleteManifestIfEmpty(anchor, manifest)
|
|
511
|
+
cleanupStale(anchor)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function transcriptSize (transcriptPath) {
|
|
515
|
+
try {
|
|
516
|
+
return fs.statSync(transcriptPath).size
|
|
517
|
+
} catch {
|
|
518
|
+
return 0
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function cryptoKey (value) {
|
|
523
|
+
return require('crypto').createHash('sha256').update(value).digest('hex').slice(0, 20)
|
|
524
|
+
}
|
|
525
|
+
|
|
243
526
|
function runPreCompact (input, opts = {}) {
|
|
244
527
|
if (process.env.TURBOCOMMIT_DISABLED) return
|
|
245
528
|
const hookInput = typeof input === 'string'
|
package/lib/track.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
const crypto = require('crypto')
|
|
1
2
|
const fs = require('fs')
|
|
2
3
|
const path = require('path')
|
|
4
|
+
const { fileURLToPath } = require('url')
|
|
3
5
|
const { ensureDir } = require('./io')
|
|
4
6
|
const { turbocommitDir } = require('./session')
|
|
7
|
+
const { canonicalRoot, canonicalTrackedPath, changedPathsInRepository } = require('./git')
|
|
8
|
+
|
|
9
|
+
const BASH_SNAPSHOT_TTL_MS = 60 * 60 * 1000
|
|
5
10
|
|
|
6
11
|
/**
|
|
7
12
|
* Directory under the git common dir where turbocommit stores tracking state.
|
|
@@ -21,7 +26,17 @@ function trackingPath (root, sessionId) {
|
|
|
21
26
|
/**
|
|
22
27
|
* Keys to probe in tool_input for a file path (MCP tools, Write, Edit, etc.)
|
|
23
28
|
*/
|
|
24
|
-
const FILE_PATH_KEYS = [
|
|
29
|
+
const FILE_PATH_KEYS = [
|
|
30
|
+
'file_path',
|
|
31
|
+
'filePath',
|
|
32
|
+
'path',
|
|
33
|
+
'file',
|
|
34
|
+
'notebook_path',
|
|
35
|
+
'relative_path',
|
|
36
|
+
'relativePath',
|
|
37
|
+
'uri'
|
|
38
|
+
]
|
|
39
|
+
const FILE_PATH_ARRAY_KEYS = ['file_paths', 'filePaths', 'paths', 'files']
|
|
25
40
|
|
|
26
41
|
/**
|
|
27
42
|
* Extract a file path from tool_input, heuristically checking known keys.
|
|
@@ -29,13 +44,75 @@ const FILE_PATH_KEYS = ['file_path', 'filePath', 'path', 'file', 'notebook_path'
|
|
|
29
44
|
function extractFilePath (toolInput) {
|
|
30
45
|
if (!toolInput || typeof toolInput !== 'object') return null
|
|
31
46
|
for (const key of FILE_PATH_KEYS) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
47
|
+
const value = normalizeRawPath(toolInput[key], key)
|
|
48
|
+
if (value) return value
|
|
35
49
|
}
|
|
36
50
|
return null
|
|
37
51
|
}
|
|
38
52
|
|
|
53
|
+
function extractRawFilePaths (toolName, toolInput) {
|
|
54
|
+
const paths = []
|
|
55
|
+
const seen = new Set()
|
|
56
|
+
const add = (value, key) => {
|
|
57
|
+
value = normalizeRawPath(value, key)
|
|
58
|
+
if (!value) return
|
|
59
|
+
if (seen.has(value)) return
|
|
60
|
+
seen.add(value)
|
|
61
|
+
paths.push(value)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (toolName === 'apply_patch' && typeof toolInput?.command === 'string') {
|
|
65
|
+
for (const line of toolInput.command.split('\n')) {
|
|
66
|
+
const match = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/) ||
|
|
67
|
+
line.match(/^\*\*\* Move to: (.+)$/)
|
|
68
|
+
if (match) add(match[1])
|
|
69
|
+
}
|
|
70
|
+
return paths
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const visit = value => {
|
|
74
|
+
if (!value || typeof value !== 'object') return
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
for (const item of value) visit(item)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
80
|
+
if (FILE_PATH_KEYS.includes(key)) add(nested, key)
|
|
81
|
+
if (FILE_PATH_ARRAY_KEYS.includes(key) && Array.isArray(nested)) {
|
|
82
|
+
for (const item of nested) add(item, key)
|
|
83
|
+
}
|
|
84
|
+
if (nested && typeof nested === 'object') visit(nested)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
visit(toolInput)
|
|
88
|
+
return paths
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeRawPath (value, key) {
|
|
92
|
+
if (typeof value !== 'string' || value.length === 0) return null
|
|
93
|
+
if (key !== 'uri') return value
|
|
94
|
+
if (value.startsWith('file://')) {
|
|
95
|
+
try {
|
|
96
|
+
return fileURLToPath(value)
|
|
97
|
+
} catch {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return null
|
|
102
|
+
return value
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function extractFilePaths (toolName, toolInput, cwd) {
|
|
106
|
+
const seen = new Set()
|
|
107
|
+
return extractRawFilePaths(toolName, toolInput).map(value =>
|
|
108
|
+
path.isAbsolute(value) ? value : path.resolve(cwd || process.cwd(), value)
|
|
109
|
+
).filter(value => {
|
|
110
|
+
if (seen.has(value)) return false
|
|
111
|
+
seen.add(value)
|
|
112
|
+
return true
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
39
116
|
/**
|
|
40
117
|
* PreToolUse handler. Appends a tracking entry for potentially-modifying tools.
|
|
41
118
|
* Always exits 0 (never blocks tool execution).
|
|
@@ -53,17 +130,17 @@ function handleTrack (input, root) {
|
|
|
53
130
|
|
|
54
131
|
const toolInput = hookInput.toolInput || hookInput.tool_input || {}
|
|
55
132
|
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
}
|
|
133
|
+
const cwd = hookInput.cwd || hookInput.raw?.cwd || root
|
|
134
|
+
const entry = { tool: toolName, t: Date.now(), cwd }
|
|
135
|
+
const rawFiles = extractRawFilePaths(toolName, toolInput)
|
|
136
|
+
const files = extractFilePaths(toolName, toolInput, cwd)
|
|
137
|
+
if (rawFiles.length > 0) entry.rawFiles = rawFiles
|
|
138
|
+
if (files.length > 0) entry.files = files
|
|
63
139
|
|
|
64
140
|
// For Bash, record the command
|
|
65
141
|
if (toolName === 'Bash' && typeof toolInput.command === 'string') {
|
|
66
142
|
entry.command = toolInput.command
|
|
143
|
+
saveBashSnapshot(root, sessionId, hookInput.toolUseId || hookInput.tool_use_id)
|
|
67
144
|
}
|
|
68
145
|
|
|
69
146
|
// Skip Bash with no command (malformed input). All other non-Bash tools
|
|
@@ -77,6 +154,158 @@ function handleTrack (input, root) {
|
|
|
77
154
|
fs.appendFileSync(file, JSON.stringify(entry) + '\n')
|
|
78
155
|
}
|
|
79
156
|
|
|
157
|
+
function handlePostTrack (input, root) {
|
|
158
|
+
const hookInput = typeof input === 'string' ? parseInput(input) : input
|
|
159
|
+
root = root || hookInput?.root
|
|
160
|
+
if (!root || !hookInput) return
|
|
161
|
+
|
|
162
|
+
const sessionId = hookInput.sessionId || hookInput.session_id
|
|
163
|
+
const toolName = hookInput.toolName || hookInput.tool_name
|
|
164
|
+
if (!sessionId || toolName !== 'Bash') return
|
|
165
|
+
|
|
166
|
+
const cwd = hookInput.cwd || hookInput.raw?.cwd || root
|
|
167
|
+
const toolUseId = hookInput.toolUseId || hookInput.tool_use_id
|
|
168
|
+
const snapshot = loadBashSnapshot(root, sessionId, toolUseId)
|
|
169
|
+
if (!snapshot) return
|
|
170
|
+
|
|
171
|
+
const now = Date.now()
|
|
172
|
+
const overlapping = hasOverlappingBashSnapshot(root, snapshot, now)
|
|
173
|
+
snapshot.endedAt = now
|
|
174
|
+
writeBashSnapshot(root, sessionId, toolUseId, snapshot)
|
|
175
|
+
|
|
176
|
+
const before = new Set(snapshot.before.map(canonicalTrackedPath))
|
|
177
|
+
const claimed = claimedPathsByOtherSessions(root, sessionId)
|
|
178
|
+
const files = overlapping
|
|
179
|
+
? []
|
|
180
|
+
: changedPathsInRepository(snapshot.root)
|
|
181
|
+
.map(canonicalTrackedPath)
|
|
182
|
+
.filter(file => !before.has(file) && !claimed.has(file))
|
|
183
|
+
|
|
184
|
+
const entry = { tool: 'Bash', phase: 'post', t: now, cwd }
|
|
185
|
+
if (overlapping) entry.overlapping = true
|
|
186
|
+
if (files.length > 0) {
|
|
187
|
+
entry.rawFiles = files
|
|
188
|
+
entry.files = files
|
|
189
|
+
}
|
|
190
|
+
appendTracking(root, sessionId, entry)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function bashSnapshotDir (root) {
|
|
194
|
+
const base = turbocommitDir(root)
|
|
195
|
+
return base && path.join(base, 'bash-snapshots')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function bashSnapshotPath (root, sessionId, toolUseId) {
|
|
199
|
+
const dir = bashSnapshotDir(root)
|
|
200
|
+
if (!dir) return null
|
|
201
|
+
const key = crypto.createHash('sha256')
|
|
202
|
+
.update(`${sessionId}\0${toolUseId || 'current'}`)
|
|
203
|
+
.digest('hex')
|
|
204
|
+
return path.join(dir, key + '.json')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function saveBashSnapshot (root, sessionId, toolUseId) {
|
|
208
|
+
const checkout = canonicalRoot(root)
|
|
209
|
+
pruneBashSnapshots(root)
|
|
210
|
+
writeBashSnapshot(root, sessionId, toolUseId, {
|
|
211
|
+
root: checkout,
|
|
212
|
+
sessionId,
|
|
213
|
+
toolUseId: toolUseId || null,
|
|
214
|
+
startedAt: Date.now(),
|
|
215
|
+
before: changedPathsInRepository(checkout)
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function writeBashSnapshot (root, sessionId, toolUseId, snapshot) {
|
|
220
|
+
const file = bashSnapshotPath(root, sessionId, toolUseId)
|
|
221
|
+
if (!file) return
|
|
222
|
+
ensureDir(path.dirname(file))
|
|
223
|
+
const temporary = file + `.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
224
|
+
try {
|
|
225
|
+
fs.writeFileSync(temporary, JSON.stringify(snapshot) + '\n')
|
|
226
|
+
fs.renameSync(temporary, file)
|
|
227
|
+
} finally {
|
|
228
|
+
try { fs.unlinkSync(temporary) } catch {}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function loadBashSnapshot (root, sessionId, toolUseId) {
|
|
233
|
+
try {
|
|
234
|
+
return JSON.parse(fs.readFileSync(bashSnapshotPath(root, sessionId, toolUseId), 'utf8'))
|
|
235
|
+
} catch {
|
|
236
|
+
return null
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function hasOverlappingBashSnapshot (root, current, now) {
|
|
241
|
+
const dir = bashSnapshotDir(root)
|
|
242
|
+
let files
|
|
243
|
+
try {
|
|
244
|
+
files = fs.readdirSync(dir)
|
|
245
|
+
} catch {
|
|
246
|
+
return false
|
|
247
|
+
}
|
|
248
|
+
return files.some(file => {
|
|
249
|
+
try {
|
|
250
|
+
const other = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
|
|
251
|
+
if (other.sessionId === current.sessionId && other.toolUseId === current.toolUseId) return false
|
|
252
|
+
if (canonicalRoot(other.root) !== canonicalRoot(current.root)) return false
|
|
253
|
+
const endedAt = Number.isFinite(other.endedAt) ? other.endedAt : now
|
|
254
|
+
return other.startedAt <= now && endedAt >= current.startedAt
|
|
255
|
+
} catch {
|
|
256
|
+
return false
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function pruneBashSnapshots (root) {
|
|
262
|
+
const dir = bashSnapshotDir(root)
|
|
263
|
+
let files
|
|
264
|
+
try {
|
|
265
|
+
files = fs.readdirSync(dir)
|
|
266
|
+
} catch {
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
const cutoff = Date.now() - BASH_SNAPSHOT_TTL_MS
|
|
270
|
+
for (const file of files) {
|
|
271
|
+
try {
|
|
272
|
+
const fullPath = path.join(dir, file)
|
|
273
|
+
const snapshot = JSON.parse(fs.readFileSync(fullPath, 'utf8'))
|
|
274
|
+
if ((snapshot.endedAt || snapshot.startedAt || 0) < cutoff) fs.unlinkSync(fullPath)
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function claimedPathsByOtherSessions (root, sessionId) {
|
|
280
|
+
const result = new Set()
|
|
281
|
+
const dir = trackingDir(root)
|
|
282
|
+
let files
|
|
283
|
+
try {
|
|
284
|
+
files = fs.readdirSync(dir)
|
|
285
|
+
} catch {
|
|
286
|
+
return result
|
|
287
|
+
}
|
|
288
|
+
for (const file of files) {
|
|
289
|
+
if (file === sessionId + '.jsonl' || !file.endsWith('.jsonl')) continue
|
|
290
|
+
try {
|
|
291
|
+
const entries = fs.readFileSync(path.join(dir, file), 'utf8').trim().split('\n')
|
|
292
|
+
for (const line of entries) {
|
|
293
|
+
const entry = JSON.parse(line)
|
|
294
|
+
if (!Array.isArray(entry.files)) continue
|
|
295
|
+
for (const claimed of entry.files) result.add(canonicalTrackedPath(claimed))
|
|
296
|
+
}
|
|
297
|
+
} catch {}
|
|
298
|
+
}
|
|
299
|
+
return result
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function appendTracking (root, sessionId, entry) {
|
|
303
|
+
const file = trackingPath(root, sessionId)
|
|
304
|
+
if (!file) return
|
|
305
|
+
ensureDir(path.dirname(file))
|
|
306
|
+
fs.appendFileSync(file, JSON.stringify(entry) + '\n')
|
|
307
|
+
}
|
|
308
|
+
|
|
80
309
|
function parseInput (input) {
|
|
81
310
|
try {
|
|
82
311
|
return JSON.parse(input)
|
|
@@ -87,26 +316,29 @@ function parseInput (input) {
|
|
|
87
316
|
|
|
88
317
|
/**
|
|
89
318
|
* Check whether a session has tracked any file-modifying tool calls.
|
|
90
|
-
* Bash
|
|
91
|
-
*
|
|
92
|
-
* The definitive signal comes from Write/Edit/NotebookEdit/MCP tools.
|
|
319
|
+
* A Bash pre-hook alone doesn't count because shell commands may be read-only.
|
|
320
|
+
* A Bash post-hook counts only when its snapshot found newly dirty paths.
|
|
93
321
|
*/
|
|
94
322
|
function hasTrackedModifications (root, sessionId) {
|
|
323
|
+
return readTracking(root, sessionId).some(entry =>
|
|
324
|
+
entry.tool !== 'Bash' || (entry.phase === 'post' && Array.isArray(entry.files) && entry.files.length > 0)
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function readTracking (root, sessionId) {
|
|
95
329
|
const file = trackingPath(root, sessionId)
|
|
96
330
|
try {
|
|
97
331
|
const data = fs.readFileSync(file, 'utf8')
|
|
98
|
-
if (!data) return
|
|
99
|
-
|
|
100
|
-
return lines.some(line => {
|
|
332
|
+
if (!data) return []
|
|
333
|
+
return data.trim().split('\n').map(line => {
|
|
101
334
|
try {
|
|
102
|
-
|
|
103
|
-
return entry.tool !== 'Bash'
|
|
335
|
+
return JSON.parse(line)
|
|
104
336
|
} catch {
|
|
105
|
-
return
|
|
337
|
+
return null
|
|
106
338
|
}
|
|
107
|
-
})
|
|
339
|
+
}).filter(Boolean)
|
|
108
340
|
} catch {
|
|
109
|
-
return
|
|
341
|
+
return []
|
|
110
342
|
}
|
|
111
343
|
}
|
|
112
344
|
|
|
@@ -121,9 +353,13 @@ function cleanupTracking (root, sessionId) {
|
|
|
121
353
|
|
|
122
354
|
module.exports = {
|
|
123
355
|
handleTrack,
|
|
356
|
+
handlePostTrack,
|
|
124
357
|
hasTrackedModifications,
|
|
125
358
|
cleanupTracking,
|
|
126
359
|
extractFilePath,
|
|
360
|
+
extractFilePaths,
|
|
361
|
+
extractRawFilePaths,
|
|
362
|
+
readTracking,
|
|
127
363
|
trackingDir,
|
|
128
364
|
trackingPath
|
|
129
365
|
}
|