@searls/turbocommit 0.13.2 → 0.14.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 +24 -0
- package/lib/git.js +52 -1
- package/lib/install.js +1 -1
- package/lib/multi.js +121 -0
- package/lib/run.js +220 -2
- package/lib/track.js +50 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,6 +32,30 @@ turbocommit registers hooks with the harnesses you use:
|
|
|
32
32
|
commits with `git add -A`. If not, it buffers the transcript for pickup
|
|
33
33
|
by a later session that does commit.
|
|
34
34
|
|
|
35
|
+
### Changes across multiple repositories
|
|
36
|
+
|
|
37
|
+
A session that starts in an enabled repository can commit changes in other
|
|
38
|
+
enabled local checkouts during the same turn. Turbocommit discovers each
|
|
39
|
+
checkout from explicit file paths supplied to Claude Code tools, Codex
|
|
40
|
+
`apply_patch`, and path-bearing MCP tools. Shell commands alone do not discover
|
|
41
|
+
additional repositories.
|
|
42
|
+
|
|
43
|
+
Each touched checkout:
|
|
44
|
+
|
|
45
|
+
- Reads its own merged global and project configuration.
|
|
46
|
+
- Keeps the normal `git add -A` behavior.
|
|
47
|
+
- Gets an independent commit with the full turn transcript.
|
|
48
|
+
- Gives the configured title agent repository-specific bounded diff context.
|
|
49
|
+
|
|
50
|
+
Turbocommit attempts every commit before pushing every successful commit whose
|
|
51
|
+
repository has `push: true`. Failures are retained and retried on a later Stop,
|
|
52
|
+
along with every transcript accumulated before the commit succeeds.
|
|
53
|
+
|
|
54
|
+
Separate worktrees are committed independently. Enabled submodules are
|
|
55
|
+
committed and pushed before enabled parent repositories. When one turn creates
|
|
56
|
+
commits in multiple repositories, each commit includes a shared
|
|
57
|
+
`Turbocommit-Session` trailer.
|
|
58
|
+
|
|
35
59
|
The commit message headline is generated by a title agent (configurable).
|
|
36
60
|
The body contains the full prompt/response transcript. When planning
|
|
37
61
|
context was buffered from ancestor sessions, it appears under a
|
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 = {}) {
|
|
@@ -18,6 +19,21 @@ function gitRoot (cwd) {
|
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
function gitRootForPath (filePath) {
|
|
23
|
+
let probe = filePath
|
|
24
|
+
try {
|
|
25
|
+
if (!fs.statSync(probe).isDirectory()) probe = path.dirname(probe)
|
|
26
|
+
} catch {
|
|
27
|
+
probe = path.dirname(probe)
|
|
28
|
+
}
|
|
29
|
+
while (probe && !fs.existsSync(probe)) {
|
|
30
|
+
const parent = path.dirname(probe)
|
|
31
|
+
if (parent === probe) return null
|
|
32
|
+
probe = parent
|
|
33
|
+
}
|
|
34
|
+
return gitRoot(probe)
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
function gitCommonDir (cwd) {
|
|
22
38
|
try {
|
|
23
39
|
const out = git('rev-parse --git-common-dir', { cwd })
|
|
@@ -45,11 +61,41 @@ function hasChanges (cwd) {
|
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
function addAndCommit (cwd, headline, body) {
|
|
64
|
+
stageAll(cwd)
|
|
65
|
+
commitStaged(cwd, headline, body)
|
|
66
|
+
return git('rev-parse HEAD', { cwd })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stageAll (cwd) {
|
|
48
70
|
git('add -A', { cwd })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function commitStaged (cwd, headline, body) {
|
|
49
74
|
git(`commit -m "${esc(headline)}" -m "${esc(body)}" --no-verify`, { cwd })
|
|
50
75
|
return git('rev-parse HEAD', { cwd })
|
|
51
76
|
}
|
|
52
77
|
|
|
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 })
|
|
82
|
+
const prefix = `Status:\n${status || '(clean)'}\n\nDiff stat:\n${stat || '(none)'}\n\nDiff:\n`
|
|
83
|
+
const remaining = Math.max(0, budget - prefix.length)
|
|
84
|
+
return prefix + (diff.length > remaining ? diff.slice(0, remaining) + '\n[... diff truncated ...]' : diff)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isGitlink (parentRoot, childRoot) {
|
|
88
|
+
const relative = path.relative(parentRoot, childRoot)
|
|
89
|
+
if (!relative || relative.startsWith('..')) return false
|
|
90
|
+
try {
|
|
91
|
+
return git(`ls-files --stage -- "${esc(relative)}"`, { cwd: parentRoot })
|
|
92
|
+
.split('\n')
|
|
93
|
+
.some(line => line.startsWith('160000 '))
|
|
94
|
+
} catch {
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
53
99
|
function hasCommits (cwd) {
|
|
54
100
|
try {
|
|
55
101
|
git('rev-parse HEAD', { cwd })
|
|
@@ -83,10 +129,15 @@ function esc (s) {
|
|
|
83
129
|
module.exports = {
|
|
84
130
|
git,
|
|
85
131
|
gitRoot,
|
|
132
|
+
gitRootForPath,
|
|
86
133
|
gitCommonDir,
|
|
87
134
|
hasChanges,
|
|
88
135
|
addAndCommit,
|
|
136
|
+
stageAll,
|
|
137
|
+
commitStaged,
|
|
138
|
+
stagedChangeContext,
|
|
89
139
|
hasCommits,
|
|
90
140
|
currentBranch,
|
|
91
|
-
pushClean
|
|
141
|
+
pushClean,
|
|
142
|
+
isGitlink
|
|
92
143
|
}
|
package/lib/install.js
CHANGED
|
@@ -23,7 +23,7 @@ const HOOK_DEFS = {
|
|
|
23
23
|
|
|
24
24
|
const CODEX_HOOK_DEFS = {
|
|
25
25
|
PreToolUse: {
|
|
26
|
-
matcher: 'Write|Edit|MultiEdit|NotebookEdit|Bash|mcp__.*',
|
|
26
|
+
matcher: 'apply_patch|Write|Edit|MultiEdit|NotebookEdit|Bash|mcp__.*',
|
|
27
27
|
hooks: [{ type: 'command', command: 'turbocommit hook pre-tool-use --harness codex' }]
|
|
28
28
|
},
|
|
29
29
|
SessionStart: {
|
package/lib/multi.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
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 { gitRoot, gitRootForPath, 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 loadJson(manifestPath(anchor)) || { nextOrder: 0, repos: [] }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function saveManifest (anchor, manifest) {
|
|
22
|
+
const file = manifestPath(anchor)
|
|
23
|
+
ensureDir(path.dirname(file))
|
|
24
|
+
fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + '\n')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function deleteManifestIfEmpty (anchor, manifest) {
|
|
28
|
+
if (manifest.repos.length > 0) {
|
|
29
|
+
saveManifest(anchor, manifest)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
fs.unlinkSync(manifestPath(anchor))
|
|
34
|
+
} catch {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rootsFromEntries (anchor, entries) {
|
|
38
|
+
const roots = []
|
|
39
|
+
const seen = new Set()
|
|
40
|
+
const add = root => {
|
|
41
|
+
if (!root) return
|
|
42
|
+
let real
|
|
43
|
+
try {
|
|
44
|
+
real = fs.realpathSync(root)
|
|
45
|
+
} catch {
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (seen.has(real)) return
|
|
49
|
+
if (activeConfig(real).config.enabled !== true) return
|
|
50
|
+
seen.add(real)
|
|
51
|
+
roots.push(real)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
if (entry.tool === 'Bash') continue
|
|
56
|
+
if (Array.isArray(entry.files) && entry.files.length > 0) {
|
|
57
|
+
for (const file of entry.files) add(gitRootForPath(file) || anchor)
|
|
58
|
+
} else {
|
|
59
|
+
add(anchor)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return addEnabledSuperprojects(roots, add)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function addEnabledSuperprojects (roots, add) {
|
|
66
|
+
for (let i = 0; i < roots.length; i++) {
|
|
67
|
+
const child = roots[i]
|
|
68
|
+
const parent = gitRoot(path.dirname(child))
|
|
69
|
+
if (parent && parent !== child && isGitlink(parent, child)) add(parent)
|
|
70
|
+
}
|
|
71
|
+
return roots
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function dependencyOrder (repos) {
|
|
75
|
+
return [...repos].sort((a, b) => {
|
|
76
|
+
if (isGitlink(a.root, b.root)) return 1
|
|
77
|
+
if (isGitlink(b.root, a.root)) return -1
|
|
78
|
+
return a.order - b.order
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function appendWork (manifest, root, work) {
|
|
83
|
+
let repo = manifest.repos.find(candidate => candidate.root === root)
|
|
84
|
+
if (!repo) {
|
|
85
|
+
repo = { root, order: manifest.nextOrder++, work: [] }
|
|
86
|
+
manifest.repos.push(repo)
|
|
87
|
+
}
|
|
88
|
+
if (!repo.work.some(item => item.key === work.key)) repo.work.push(work)
|
|
89
|
+
return repo
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function multiWatermarkPath (root, sessionId) {
|
|
93
|
+
return path.join(turbocommitDir(root), 'multi-watermarks', checkoutKey(root), sessionId + '.json')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readMultiWatermark (root, sessionIds) {
|
|
97
|
+
for (const sessionId of sessionIds) {
|
|
98
|
+
const value = loadJson(multiWatermarkPath(root, sessionId))
|
|
99
|
+
if (value?.commit) return value
|
|
100
|
+
}
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function saveMultiWatermark (root, sessionId, commit) {
|
|
105
|
+
const file = multiWatermarkPath(root, sessionId)
|
|
106
|
+
ensureDir(path.dirname(file))
|
|
107
|
+
fs.writeFileSync(file, JSON.stringify({ commit }) + '\n')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = {
|
|
111
|
+
appendWork,
|
|
112
|
+
checkoutKey,
|
|
113
|
+
deleteManifestIfEmpty,
|
|
114
|
+
dependencyOrder,
|
|
115
|
+
loadManifest,
|
|
116
|
+
manifestPath,
|
|
117
|
+
readMultiWatermark,
|
|
118
|
+
rootsFromEntries,
|
|
119
|
+
saveManifest,
|
|
120
|
+
saveMultiWatermark
|
|
121
|
+
}
|
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, hasCommits, currentBranch, pushClean } = require('./git')
|
|
7
|
+
const { gitRoot, hasChanges, addAndCommit, stageAll, commitStaged, stagedChangeContext, hasCommits, 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
|
+
rootsFromEntries,
|
|
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".
|
|
@@ -92,6 +102,29 @@ function resolveCoauthor (config, transcriptPath, root, opts = {}) {
|
|
|
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 roots = rootsFromEntries(root, entries)
|
|
115
|
+
const manifest = loadManifest(root)
|
|
116
|
+
const hasCrossRootPath = entries.some(entry =>
|
|
117
|
+
Array.isArray(entry.files) && entry.files.length > 0
|
|
118
|
+
) && (roots.length !== 1 || roots[0] !== root)
|
|
119
|
+
|
|
120
|
+
if (manifest.repos.length > 0 || roots.length > 1 || hasCrossRootPath) {
|
|
121
|
+
return runMulti(hookInput, root, roots, manifest)
|
|
122
|
+
}
|
|
123
|
+
return runSingle(hookInput)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function runSingle (input, opts = {}) {
|
|
127
|
+
if (process.env.TURBOCOMMIT_DISABLED) return
|
|
95
128
|
|
|
96
129
|
const hookInput = typeof input === 'string'
|
|
97
130
|
? normalizeHookInput(input, 'stop', opts.harness)
|
|
@@ -240,6 +273,191 @@ function run (input, opts = {}) {
|
|
|
240
273
|
}
|
|
241
274
|
}
|
|
242
275
|
|
|
276
|
+
function runMulti (hookInput, anchor, currentRoots, manifest) {
|
|
277
|
+
recordCodexStop(hookInput, anchor)
|
|
278
|
+
|
|
279
|
+
const transcriptPath = resolveTranscriptPath(hookInput)
|
|
280
|
+
const pairs = hookInput.harness === 'codex'
|
|
281
|
+
? parseCodexTranscript(transcriptPath)
|
|
282
|
+
: parseTranscript(transcriptPath)
|
|
283
|
+
const sessionId = hookInput.sessionId
|
|
284
|
+
const watermark = sessionId ? readWatermark(anchor, sessionId) : null
|
|
285
|
+
const watermarkPairCount = watermark && Number.isInteger(watermark.pairs) ? watermark.pairs : 0
|
|
286
|
+
const newPairs = watermark ? pairs.slice(watermarkPairCount) : pairs
|
|
287
|
+
const precompactWatermark = watermark && watermark.source === 'precompact'
|
|
288
|
+
const selfPrecompactPending = sessionId
|
|
289
|
+
? collectPending(anchor, [sessionId], { source: 'precompact' })
|
|
290
|
+
: []
|
|
291
|
+
let effectivePairs = newPairs.length > 0 ? newPairs : pairs
|
|
292
|
+
if (precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0) {
|
|
293
|
+
const basePairs = Number.isInteger(watermark.basePairs) ? watermark.basePairs : 0
|
|
294
|
+
const bufferedPairs = pairs.slice(basePairs, watermarkPairCount)
|
|
295
|
+
effectivePairs = bufferedPairs.length > 0 ? bufferedPairs : pairs
|
|
296
|
+
}
|
|
297
|
+
const formattedTranscript = formatBody(effectivePairs)
|
|
298
|
+
const ancestors = sessionId ? getAncestors(anchor, sessionId) : []
|
|
299
|
+
const pending = sessionId ? collectPending(anchor, [...ancestors].reverse()) : []
|
|
300
|
+
if (precompactWatermark && newPairs.length > 0 && selfPrecompactPending.length > 0) {
|
|
301
|
+
pending.push(...selfPrecompactPending)
|
|
302
|
+
}
|
|
303
|
+
const turnBody = pending.length > 0
|
|
304
|
+
? '## Planning\n\n' + pending.join('\n\n---\n\n') +
|
|
305
|
+
'\n\n## Implementation\n\n' + formattedTranscript
|
|
306
|
+
: formattedTranscript
|
|
307
|
+
|
|
308
|
+
const dirtyRoots = currentRoots.filter(root => !hasCommits(root) || hasChanges(root))
|
|
309
|
+
const isMultiTurn = currentRoots.length > 1
|
|
310
|
+
const turnKey = hookInput.raw?.turn_id ||
|
|
311
|
+
cryptoKey(`${sessionId || 'no-session'}\n${pairs.length}\n${turnBody}`)
|
|
312
|
+
|
|
313
|
+
if (dirtyRoots.length === 0 && manifest.repos.length === 0) {
|
|
314
|
+
if (sessionId) {
|
|
315
|
+
savePending(anchor, sessionId, formattedTranscript)
|
|
316
|
+
cleanupTracking(anchor, sessionId)
|
|
317
|
+
}
|
|
318
|
+
logEvent('skip', {
|
|
319
|
+
harness: hookInput.harness,
|
|
320
|
+
project: path.basename(anchor),
|
|
321
|
+
branch: currentBranch(anchor),
|
|
322
|
+
context: transcriptSize(transcriptPath)
|
|
323
|
+
})
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
for (const root of currentRoots) {
|
|
328
|
+
appendWork(manifest, root, {
|
|
329
|
+
key: turnKey,
|
|
330
|
+
body: turnBody,
|
|
331
|
+
sessionIds: isMultiTurn && sessionId ? [sessionId] : []
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
saveManifest(anchor, manifest)
|
|
336
|
+
const successes = []
|
|
337
|
+
const ordered = dependencyOrder(manifest.repos)
|
|
338
|
+
|
|
339
|
+
for (const repo of ordered) {
|
|
340
|
+
const root = repo.root
|
|
341
|
+
const { config } = activeConfig(root)
|
|
342
|
+
const project = path.basename(root)
|
|
343
|
+
const branch = currentBranch(root)
|
|
344
|
+
const context = transcriptSize(transcriptPath)
|
|
345
|
+
|
|
346
|
+
if (config.enabled !== true) {
|
|
347
|
+
manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
|
|
348
|
+
logEvent('skip', { harness: hookInput.harness, project, branch, context })
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
if (hasCommits(root) && !hasChanges(root)) {
|
|
352
|
+
manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
|
|
353
|
+
logEvent('skip', { harness: hookInput.harness, project, branch, context })
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
logEvent('start', { harness: hookInput.harness, project, branch, context })
|
|
359
|
+
stageAll(root)
|
|
360
|
+
|
|
361
|
+
const rawBody = repo.work.map(item => item.body).join('\n\n---\n\n')
|
|
362
|
+
const redactions = buildRedactions()
|
|
363
|
+
const changeContext = redact(stagedChangeContext(root, 10000), redactions)
|
|
364
|
+
const titleInput = `${changeContext}\n\nTranscript:\n${rawBody}`.slice(0, 20000)
|
|
365
|
+
|
|
366
|
+
let headline
|
|
367
|
+
if (config.title?.type !== 'transcript') {
|
|
368
|
+
headline = runTitleAgent(root, config.title || {}, titleInput, hookInput.harness)
|
|
369
|
+
}
|
|
370
|
+
headline = headline || extractHeadline(effectivePairs)
|
|
371
|
+
|
|
372
|
+
let body
|
|
373
|
+
if (config.body?.type === 'agent') {
|
|
374
|
+
body = runBodyAgent(root, config.body, rawBody, hookInput.harness)
|
|
375
|
+
}
|
|
376
|
+
body = body || rawBody
|
|
377
|
+
|
|
378
|
+
const parent = sessionId
|
|
379
|
+
? readMultiWatermark(root, [sessionId, ...ancestors])
|
|
380
|
+
: null
|
|
381
|
+
if (parent?.commit) body = `Continuation of ${parent.commit.slice(0, 7)}\n\n${body}`
|
|
382
|
+
body = wrapText(body, config.body?.maxLineLength)
|
|
383
|
+
|
|
384
|
+
const trailers = []
|
|
385
|
+
for (const work of repo.work) {
|
|
386
|
+
for (const id of work.sessionIds || []) {
|
|
387
|
+
if (!trailers.includes(id)) trailers.push(id)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (trailers.length > 0) {
|
|
391
|
+
body += '\n\n' + trailers.map(id => `Turbocommit-Session: ${id}`).join('\n')
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const coauthor = resolveCoauthor(config, transcriptPath, root, {
|
|
395
|
+
harness: hookInput.harness,
|
|
396
|
+
model: hookInput.model
|
|
397
|
+
})
|
|
398
|
+
if (coauthor) body += '\n\n' + coauthor
|
|
399
|
+
|
|
400
|
+
const safeHeadline = redact(headline, redactions)
|
|
401
|
+
const safeBody = redact(body, redactions)
|
|
402
|
+
const sha = commitStaged(root, safeHeadline, safeBody)
|
|
403
|
+
if (sessionId) saveMultiWatermark(root, sessionId, sha)
|
|
404
|
+
if (root === anchor && sessionId) {
|
|
405
|
+
saveWatermark(anchor, sessionId, pairs.length, sha, { source: 'commit' })
|
|
406
|
+
}
|
|
407
|
+
manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
|
|
408
|
+
successes.push({ root, config, project, branch })
|
|
409
|
+
logEvent('success', {
|
|
410
|
+
harness: hookInput.harness,
|
|
411
|
+
project,
|
|
412
|
+
branch,
|
|
413
|
+
context,
|
|
414
|
+
title: safeHeadline
|
|
415
|
+
})
|
|
416
|
+
} catch {
|
|
417
|
+
logEvent('fail', { harness: hookInput.harness, project, branch, context })
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
for (const success of successes) {
|
|
422
|
+
if (success.config.push !== true) continue
|
|
423
|
+
if (pushClean(success.root)) {
|
|
424
|
+
logEvent('push', {
|
|
425
|
+
harness: hookInput.harness,
|
|
426
|
+
project: success.project,
|
|
427
|
+
branch: success.branch
|
|
428
|
+
})
|
|
429
|
+
} else {
|
|
430
|
+
logEvent('push-fail', {
|
|
431
|
+
harness: hookInput.harness,
|
|
432
|
+
project: success.project,
|
|
433
|
+
branch: success.branch
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (sessionId) {
|
|
439
|
+
if (!readWatermark(anchor, sessionId)) {
|
|
440
|
+
saveWatermark(anchor, sessionId, pairs.length, undefined, { source: 'multi' })
|
|
441
|
+
}
|
|
442
|
+
cleanupConsumed(anchor, [...ancestors, sessionId])
|
|
443
|
+
cleanupTracking(anchor, sessionId)
|
|
444
|
+
}
|
|
445
|
+
deleteManifestIfEmpty(anchor, manifest)
|
|
446
|
+
cleanupStale(anchor)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function transcriptSize (transcriptPath) {
|
|
450
|
+
try {
|
|
451
|
+
return fs.statSync(transcriptPath).size
|
|
452
|
+
} catch {
|
|
453
|
+
return 0
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function cryptoKey (value) {
|
|
458
|
+
return require('crypto').createHash('sha256').update(value).digest('hex').slice(0, 20)
|
|
459
|
+
}
|
|
460
|
+
|
|
243
461
|
function runPreCompact (input, opts = {}) {
|
|
244
462
|
if (process.env.TURBOCOMMIT_DISABLED) return
|
|
245
463
|
const hookInput = typeof input === 'string'
|
package/lib/track.js
CHANGED
|
@@ -36,6 +36,41 @@ function extractFilePath (toolInput) {
|
|
|
36
36
|
return null
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
function extractFilePaths (toolName, toolInput, cwd) {
|
|
40
|
+
const paths = []
|
|
41
|
+
const seen = new Set()
|
|
42
|
+
const add = value => {
|
|
43
|
+
if (typeof value !== 'string' || value.length === 0) return
|
|
44
|
+
const resolved = path.isAbsolute(value) ? value : path.resolve(cwd || process.cwd(), value)
|
|
45
|
+
if (seen.has(resolved)) return
|
|
46
|
+
seen.add(resolved)
|
|
47
|
+
paths.push(resolved)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (toolName === 'apply_patch' && typeof toolInput?.command === 'string') {
|
|
51
|
+
for (const line of toolInput.command.split('\n')) {
|
|
52
|
+
const match = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/) ||
|
|
53
|
+
line.match(/^\*\*\* Move to: (.+)$/)
|
|
54
|
+
if (match) add(match[1])
|
|
55
|
+
}
|
|
56
|
+
return paths
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const visit = value => {
|
|
60
|
+
if (!value || typeof value !== 'object') return
|
|
61
|
+
if (Array.isArray(value)) {
|
|
62
|
+
for (const item of value) visit(item)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
66
|
+
if (FILE_PATH_KEYS.includes(key)) add(nested)
|
|
67
|
+
if (nested && typeof nested === 'object') visit(nested)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
visit(toolInput)
|
|
71
|
+
return paths
|
|
72
|
+
}
|
|
73
|
+
|
|
39
74
|
/**
|
|
40
75
|
* PreToolUse handler. Appends a tracking entry for potentially-modifying tools.
|
|
41
76
|
* Always exits 0 (never blocks tool execution).
|
|
@@ -55,11 +90,9 @@ function handleTrack (input, root) {
|
|
|
55
90
|
|
|
56
91
|
const entry = { tool: toolName, t: Date.now() }
|
|
57
92
|
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
if (
|
|
61
|
-
entry.file = filePath
|
|
62
|
-
}
|
|
93
|
+
const cwd = hookInput.cwd || hookInput.raw?.cwd || root
|
|
94
|
+
const files = extractFilePaths(toolName, toolInput, cwd)
|
|
95
|
+
if (files.length > 0) entry.files = files
|
|
63
96
|
|
|
64
97
|
// For Bash, record the command
|
|
65
98
|
if (toolName === 'Bash' && typeof toolInput.command === 'string') {
|
|
@@ -92,21 +125,23 @@ function parseInput (input) {
|
|
|
92
125
|
* The definitive signal comes from Write/Edit/NotebookEdit/MCP tools.
|
|
93
126
|
*/
|
|
94
127
|
function hasTrackedModifications (root, sessionId) {
|
|
128
|
+
return readTracking(root, sessionId).some(entry => entry.tool !== 'Bash')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readTracking (root, sessionId) {
|
|
95
132
|
const file = trackingPath(root, sessionId)
|
|
96
133
|
try {
|
|
97
134
|
const data = fs.readFileSync(file, 'utf8')
|
|
98
|
-
if (!data) return
|
|
99
|
-
|
|
100
|
-
return lines.some(line => {
|
|
135
|
+
if (!data) return []
|
|
136
|
+
return data.trim().split('\n').map(line => {
|
|
101
137
|
try {
|
|
102
|
-
|
|
103
|
-
return entry.tool !== 'Bash'
|
|
138
|
+
return JSON.parse(line)
|
|
104
139
|
} catch {
|
|
105
|
-
return
|
|
140
|
+
return null
|
|
106
141
|
}
|
|
107
|
-
})
|
|
142
|
+
}).filter(Boolean)
|
|
108
143
|
} catch {
|
|
109
|
-
return
|
|
144
|
+
return []
|
|
110
145
|
}
|
|
111
146
|
}
|
|
112
147
|
|
|
@@ -124,6 +159,8 @@ module.exports = {
|
|
|
124
159
|
hasTrackedModifications,
|
|
125
160
|
cleanupTracking,
|
|
126
161
|
extractFilePath,
|
|
162
|
+
extractFilePaths,
|
|
163
|
+
readTracking,
|
|
127
164
|
trackingDir,
|
|
128
165
|
trackingPath
|
|
129
166
|
}
|