@searls/turbocommit 0.10.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/LICENSE +21 -0
- package/README.md +179 -0
- package/cli.js +193 -0
- package/lib/agent.js +77 -0
- package/lib/doctor.js +101 -0
- package/lib/git.js +70 -0
- package/lib/init.js +41 -0
- package/lib/install.js +115 -0
- package/lib/io.js +58 -0
- package/lib/log.js +18 -0
- package/lib/monitor.js +92 -0
- package/lib/run.js +208 -0
- package/lib/session.js +298 -0
- package/lib/track.js +120 -0
- package/lib/transcript.js +133 -0
- package/lib/wrap.js +129 -0
- package/package.json +33 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Searls LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
[](https://justin.searls.co/shovelware/)
|
|
2
|
+
|
|
3
|
+
# turbocommit
|
|
4
|
+
|
|
5
|
+
turbocommit creates a git commit containing everything Claude Code changes
|
|
6
|
+
on each turn. Think of it like save state in a game emulator: whenever you
|
|
7
|
+
fall in a pit, you can safely rewind and try again.
|
|
8
|
+
|
|
9
|
+
Captures every prompt/response transcript for the associated changes so you
|
|
10
|
+
and your agent never lose the thread on what you were doing after the fact,
|
|
11
|
+
even if you didn't leave behind any comments or docs.
|
|
12
|
+
|
|
13
|
+
Each commit also [links back to the previous commit from the same Claude Code
|
|
14
|
+
session](#continuity-across-workstreams), so you can run multiple agent
|
|
15
|
+
sessions concurrently and detangle which agent committed what after the fact.
|
|
16
|
+
|
|
17
|
+
## How it works
|
|
18
|
+
|
|
19
|
+
turbocommit registers four hooks with Claude Code: **PreToolUse**,
|
|
20
|
+
**SessionStart**, **SessionEnd**, and **Stop**.
|
|
21
|
+
|
|
22
|
+
- **PreToolUse** tracks which sessions actually modify files (Write, Edit,
|
|
23
|
+
MultiEdit, NotebookEdit, MCP tools). Read-only sessions (Grep, Read,
|
|
24
|
+
Bash-only) are never committed.
|
|
25
|
+
- **SessionStart / SessionEnd** chain sessions across `/clear` boundaries
|
|
26
|
+
so planning context survives into the eventual commit.
|
|
27
|
+
- **Stop** fires after every turn. If the session modified files, it
|
|
28
|
+
commits with `git add -A`. If not, it buffers the transcript for pickup
|
|
29
|
+
by a later session that does commit.
|
|
30
|
+
|
|
31
|
+
The commit message headline is generated by a title agent (configurable).
|
|
32
|
+
The body contains the full prompt/response transcript. When planning
|
|
33
|
+
context was buffered from ancestor sessions, it appears under a
|
|
34
|
+
`## Planning` section before the `## Implementation` section.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
brew install searlsco/tap/turbocommit
|
|
40
|
+
turbocommit install
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Then enable it per-project:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
cd your-repo
|
|
47
|
+
turbocommit init
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Uninstall
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
turbocommit deinit # disable in a project
|
|
54
|
+
turbocommit uninstall # remove the global hook
|
|
55
|
+
brew uninstall turbocommit
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Where turbocommit goes in your hook chain
|
|
59
|
+
|
|
60
|
+
`turbocommit install` adds one hook group per event (PreToolUse,
|
|
61
|
+
SessionStart, SessionEnd, Stop). The hooks are fire-and-forget: they
|
|
62
|
+
always exit 0 and never block Claude.
|
|
63
|
+
|
|
64
|
+
If you rearrange your hooks manually, the turbocommit hooks can go
|
|
65
|
+
anywhere — order doesn't matter since they never block.
|
|
66
|
+
|
|
67
|
+
## Commands
|
|
68
|
+
|
|
69
|
+
| Command | Description |
|
|
70
|
+
|---------|-------------|
|
|
71
|
+
| `turbocommit install` | Add hooks to `~/.claude/settings.json` |
|
|
72
|
+
| `turbocommit uninstall` | Remove them |
|
|
73
|
+
| `turbocommit init` | Create `.claude/turbocommit.json` in current repo |
|
|
74
|
+
| `turbocommit deinit` | Remove it |
|
|
75
|
+
| `turbocommit doctor` | Check hook and config health |
|
|
76
|
+
| `turbocommit monitor` | Tail the event log (start/success/skip/fail) |
|
|
77
|
+
| `turbocommit help` | Show usage |
|
|
78
|
+
| `turbocommit --version` | Show version |
|
|
79
|
+
|
|
80
|
+
## Continuity across workstreams
|
|
81
|
+
|
|
82
|
+
Every turbocommit adds a `Continuation of <SHA>` reference linking back to
|
|
83
|
+
the previous commit from the same Claude session. This means you can run
|
|
84
|
+
multiple sessions in parallel — different tabs, different tasks — all
|
|
85
|
+
committing to a single branch, and still trace each logical workstream
|
|
86
|
+
by following the SHA chain.
|
|
87
|
+
|
|
88
|
+
Here's a real stretch of [`prove_it`](https://github.com/searlsco/prove_it)'s
|
|
89
|
+
`main` branch. Two sessions and a couple manual commits, all interleaved:
|
|
90
|
+
|
|
91
|
+
1. [`cb20f72`](https://github.com/searlsco/prove_it/commit/cb20f72) 🔵 Fix PROVE_IT_DISABLED leak in integration tests
|
|
92
|
+
2. [`cff5a2e`](https://github.com/searlsco/prove_it/commit/cff5a2e) 🔵 Fix env variable leaks in integration test harness
|
|
93
|
+
3. [`5c3ca43`](https://github.com/searlsco/prove_it/commit/5c3ca43) 🟠 Fix reviewer rationale parsing for multi-line verdicts
|
|
94
|
+
4. [`6f22cee`](https://github.com/searlsco/prove_it/commit/6f22cee) 🟠 Remove dead cached output filter and JSDoc
|
|
95
|
+
5. [`efca526`](https://github.com/searlsco/prove_it/commit/efca526) v0.44.0
|
|
96
|
+
6. [`1561c46`](https://github.com/searlsco/prove_it/commit/1561c46) update conifg
|
|
97
|
+
7. [`de771db`](https://github.com/searlsco/prove_it/commit/de771db) 🟠 Fix init to preserve custom sources during config upgrade
|
|
98
|
+
8. [`538fc72`](https://github.com/searlsco/prove_it/commit/538fc72) 🟠 Fix signal command to fail honestly outside hook context
|
|
99
|
+
|
|
100
|
+
Each linked commit's description starts with the reference back:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
Fix signal command to fail honestly outside hook context
|
|
104
|
+
|
|
105
|
+
Continuation of de771db
|
|
106
|
+
|
|
107
|
+
Prompt:
|
|
108
|
+
...
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
So after the fact, you can reconstruct each session's lineage:
|
|
112
|
+
|
|
113
|
+
**🟠 Session A:** [`5c3ca43`](https://github.com/searlsco/prove_it/commit/5c3ca43) → [`6f22cee`](https://github.com/searlsco/prove_it/commit/6f22cee) → [`de771db`](https://github.com/searlsco/prove_it/commit/de771db) → [`538fc72`](https://github.com/searlsco/prove_it/commit/538fc72)
|
|
114
|
+
|
|
115
|
+
**🔵 Session B:** [`cb20f72`](https://github.com/searlsco/prove_it/commit/cb20f72) → [`cff5a2e`](https://github.com/searlsco/prove_it/commit/cff5a2e)
|
|
116
|
+
|
|
117
|
+
No feature branches. No rebasing. Just a flat history where every thread
|
|
118
|
+
is traceable.
|
|
119
|
+
|
|
120
|
+
## Configuration
|
|
121
|
+
|
|
122
|
+
`turbocommit init` creates `.claude/turbocommit.json` with `{"enabled": true}`.
|
|
123
|
+
You can also place a global config at `~/.claude/turbocommit.json` — project
|
|
124
|
+
config is deep-merged on top.
|
|
125
|
+
|
|
126
|
+
Here's every property with its default:
|
|
127
|
+
|
|
128
|
+
```jsonc
|
|
129
|
+
{
|
|
130
|
+
// Required. turbocommit is inert unless this is true.
|
|
131
|
+
"enabled": true,
|
|
132
|
+
|
|
133
|
+
// Co-Authored-By trailer.
|
|
134
|
+
// true → auto-detect model from transcript (default)
|
|
135
|
+
// false → no trailer
|
|
136
|
+
// "Name <email>" → custom trailer value
|
|
137
|
+
"coauthor": true,
|
|
138
|
+
|
|
139
|
+
"title": {
|
|
140
|
+
// "agent" (default) → run a title agent to generate the headline
|
|
141
|
+
// "transcript" → extract the first prompt as the headline
|
|
142
|
+
"type": "agent",
|
|
143
|
+
|
|
144
|
+
// Command to run for title generation (only when type is "agent").
|
|
145
|
+
"command": "claude -p --model haiku",
|
|
146
|
+
|
|
147
|
+
// Prompt template sent to the title agent. Use {{transcript}} as the
|
|
148
|
+
// placeholder for the session transcript.
|
|
149
|
+
"prompt": "You have 10 seconds. Write a single-line git commit headline (max 72 chars) from this coding session transcript. Speed over perfection — a rough title beats no title.\n\nRules:\n- Imperative mood (\"Add\", \"Fix\", \"Update\")\n- Specific about what changed\n- No trailing period\n- No conventional commit prefixes unless clearly a fix/feat\n\nTranscript:\n{{transcript}}\n\nRespond with ONLY the headline, nothing else. Do not deliberate."
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
"body": {
|
|
153
|
+
// "transcript" (default) → use the formatted prompt/response transcript
|
|
154
|
+
// "agent" → run a body agent to summarize
|
|
155
|
+
"type": "transcript",
|
|
156
|
+
|
|
157
|
+
// Command to run for body generation (only when type is "agent").
|
|
158
|
+
"command": "claude -p --model haiku",
|
|
159
|
+
|
|
160
|
+
// Prompt template sent to the body agent. Use {{transcript}} as the
|
|
161
|
+
// placeholder for the session transcript.
|
|
162
|
+
"prompt": "Given this transcript of a coding session, write a concise git commit body.\n\nRules:\n- Summarize what was done and why\n- Be concise — a few sentences or bullet points\n- Focus on the \"why\" more than the \"what\"\n\nTranscript:\n{{transcript}}\n\nRespond with ONLY the commit body, nothing else.",
|
|
163
|
+
|
|
164
|
+
// Wrap prose lines at this width. Code blocks, tables, headers, and
|
|
165
|
+
// other structured content are preserved verbatim.
|
|
166
|
+
// false/absent → no wrapping (default).
|
|
167
|
+
"maxLineLength": false
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Set `TURBOCOMMIT_DISABLED` to any non-empty value to skip turbocommit at
|
|
173
|
+
runtime without changing config.
|
|
174
|
+
|
|
175
|
+
## Requirements
|
|
176
|
+
|
|
177
|
+
- Node.js >= 18
|
|
178
|
+
- Git
|
|
179
|
+
- Claude Code
|
package/cli.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { readStdin } = require('./lib/io')
|
|
4
|
+
const { install, uninstall } = require('./lib/install')
|
|
5
|
+
const { init, deinit } = require('./lib/init')
|
|
6
|
+
const { run } = require('./lib/run')
|
|
7
|
+
const { handleTrack } = require('./lib/track')
|
|
8
|
+
const { handleSessionStart, handleSessionEnd } = require('./lib/session')
|
|
9
|
+
const { doctor } = require('./lib/doctor')
|
|
10
|
+
const { monitor } = require('./lib/monitor')
|
|
11
|
+
const { gitRoot } = require('./lib/git')
|
|
12
|
+
|
|
13
|
+
const VERSION = require('./package.json').version
|
|
14
|
+
|
|
15
|
+
const USAGE = `turbocommit v${VERSION}
|
|
16
|
+
Auto-commit after every Claude Code turn.
|
|
17
|
+
|
|
18
|
+
Commands:
|
|
19
|
+
install Add turbocommit hooks to ~/.claude/settings.json
|
|
20
|
+
uninstall Remove turbocommit from settings
|
|
21
|
+
init Create .claude/turbocommit.json in current git repo
|
|
22
|
+
deinit Remove .claude/turbocommit.json
|
|
23
|
+
doctor Check hook and config health
|
|
24
|
+
monitor Tail the event log (start/success/fail)
|
|
25
|
+
hook Hook entry points (called by Claude Code, not manually)
|
|
26
|
+
help Show this help text
|
|
27
|
+
--version, -v Show version
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
turbocommit install # set up the global hooks
|
|
31
|
+
turbocommit init # enable in a project
|
|
32
|
+
turbocommit doctor # verify everything is wired correctly
|
|
33
|
+
turbocommit monitor # watch commits in real-time
|
|
34
|
+
`
|
|
35
|
+
|
|
36
|
+
function main (argv) {
|
|
37
|
+
const cmd = argv[0]
|
|
38
|
+
|
|
39
|
+
switch (cmd) {
|
|
40
|
+
case 'install':
|
|
41
|
+
return cmdInstall()
|
|
42
|
+
case 'uninstall':
|
|
43
|
+
return cmdUninstall()
|
|
44
|
+
case 'doctor':
|
|
45
|
+
return cmdDoctor()
|
|
46
|
+
case 'monitor':
|
|
47
|
+
return cmdMonitor()
|
|
48
|
+
case 'init':
|
|
49
|
+
return cmdInit()
|
|
50
|
+
case 'deinit':
|
|
51
|
+
return cmdDeinit()
|
|
52
|
+
case 'hook':
|
|
53
|
+
return cmdHook(argv.slice(1))
|
|
54
|
+
case 'run':
|
|
55
|
+
return cmdRunDeprecated()
|
|
56
|
+
case '--version':
|
|
57
|
+
case '-v':
|
|
58
|
+
case 'version':
|
|
59
|
+
console.log(VERSION)
|
|
60
|
+
return
|
|
61
|
+
case 'help':
|
|
62
|
+
case '--help':
|
|
63
|
+
case '-h':
|
|
64
|
+
case undefined:
|
|
65
|
+
console.log(USAGE)
|
|
66
|
+
return
|
|
67
|
+
default:
|
|
68
|
+
console.error(`Unknown command: ${cmd}`)
|
|
69
|
+
console.error('Run "turbocommit help" for usage.')
|
|
70
|
+
process.exitCode = 1
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function cmdInstall () {
|
|
75
|
+
const result = install()
|
|
76
|
+
if (result.alreadyInstalled) {
|
|
77
|
+
console.log('turbocommit already installed.')
|
|
78
|
+
console.log(` Settings: ${result.settingsPath}`)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
console.log('turbocommit installed.')
|
|
82
|
+
console.log(` Settings: ${result.settingsPath}`)
|
|
83
|
+
console.log('')
|
|
84
|
+
console.log('════════════════════════════════════════════════════════════════════')
|
|
85
|
+
console.log('IMPORTANT: Restart Claude Code for the hooks to take effect.')
|
|
86
|
+
console.log('════════════════════════════════════════════════════════════════════')
|
|
87
|
+
console.log('')
|
|
88
|
+
console.log('Next steps:')
|
|
89
|
+
console.log(' 1. Restart Claude Code (required)')
|
|
90
|
+
console.log(' 2. Run: turbocommit init in a repo to enable auto-commits')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cmdUninstall () {
|
|
94
|
+
const result = uninstall()
|
|
95
|
+
if (!result.wasInstalled) {
|
|
96
|
+
console.log('turbocommit was not installed.')
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
console.log('turbocommit uninstalled.')
|
|
100
|
+
console.log(` Settings: ${result.settingsPath}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function cmdInit () {
|
|
104
|
+
const result = init()
|
|
105
|
+
if (!result.ok) {
|
|
106
|
+
console.error(`Error: ${result.error}`)
|
|
107
|
+
process.exitCode = 1
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (result.alreadyExists) {
|
|
111
|
+
console.log('turbocommit already enabled in this repo.')
|
|
112
|
+
console.log(` Config: ${result.path}`)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
console.log('turbocommit enabled.')
|
|
116
|
+
console.log(` Config: ${result.path}`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function cmdDeinit () {
|
|
120
|
+
const result = deinit()
|
|
121
|
+
if (!result.ok) {
|
|
122
|
+
console.error(`Error: ${result.error}`)
|
|
123
|
+
process.exitCode = 1
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (!result.existed) {
|
|
127
|
+
console.log('turbocommit was not enabled in this repo.')
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
console.log('turbocommit disabled.')
|
|
131
|
+
console.log(` Removed: ${result.path}`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function cmdDoctor () {
|
|
135
|
+
const STATUS = { ok: ' ok', warn: 'warn', error: ' err', info: 'info' }
|
|
136
|
+
const result = doctor()
|
|
137
|
+
for (const check of result.checks) {
|
|
138
|
+
console.log(`[${STATUS[check.status] || check.status}] ${check.name}: ${check.message}`)
|
|
139
|
+
}
|
|
140
|
+
if (!result.ok) {
|
|
141
|
+
process.exitCode = 1
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function cmdMonitor () {
|
|
146
|
+
monitor()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function cmdHook (argv) {
|
|
150
|
+
const event = argv[0]
|
|
151
|
+
try {
|
|
152
|
+
const input = readStdin()
|
|
153
|
+
const root = gitRoot()
|
|
154
|
+
switch (event) {
|
|
155
|
+
case 'pre-tool-use':
|
|
156
|
+
handleTrack(input, root)
|
|
157
|
+
return
|
|
158
|
+
case 'session-start':
|
|
159
|
+
handleSessionStart(input, root)
|
|
160
|
+
return
|
|
161
|
+
case 'session-end':
|
|
162
|
+
handleSessionEnd(input, root)
|
|
163
|
+
return
|
|
164
|
+
case 'stop':
|
|
165
|
+
run(input)
|
|
166
|
+
break
|
|
167
|
+
default:
|
|
168
|
+
// Unknown hook event — ignore silently (never fail)
|
|
169
|
+
break
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
// Never fail — fire and forget
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function cmdRunDeprecated () {
|
|
177
|
+
let hookInput
|
|
178
|
+
try {
|
|
179
|
+
hookInput = JSON.parse(readStdin())
|
|
180
|
+
} catch {
|
|
181
|
+
hookInput = {}
|
|
182
|
+
}
|
|
183
|
+
// Prevent infinite loop: if we already blocked once, let Claude stop
|
|
184
|
+
if (hookInput.stop_hook_active) return
|
|
185
|
+
const msg = 'turbocommit hooks are outdated (v0.6). ' +
|
|
186
|
+
'Auto-commits are paused until you upgrade. ' +
|
|
187
|
+
'Run: turbocommit install'
|
|
188
|
+
// Block the stop so the agent sees the reason and can relay it to the user
|
|
189
|
+
const output = JSON.stringify({ decision: 'block', reason: msg })
|
|
190
|
+
process.stdout.write(output + '\n')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
main(process.argv.slice(2))
|
package/lib/agent.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const { tryRun } = require('./io')
|
|
2
|
+
|
|
3
|
+
const DEFAULT_COMMAND = 'claude -p --model haiku'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TITLE_PROMPT = `You have 10 seconds. Write a single-line git commit headline (max 72 chars) from this coding session transcript. Speed over perfection — a rough title beats no title.
|
|
6
|
+
|
|
7
|
+
Rules:
|
|
8
|
+
- Imperative mood ("Add", "Fix", "Update")
|
|
9
|
+
- Specific about what changed
|
|
10
|
+
- No trailing period
|
|
11
|
+
- No conventional commit prefixes unless clearly a fix/feat
|
|
12
|
+
|
|
13
|
+
Transcript:
|
|
14
|
+
{{transcript}}
|
|
15
|
+
|
|
16
|
+
Respond with ONLY the headline, nothing else. Do not deliberate.`
|
|
17
|
+
|
|
18
|
+
const DEFAULT_BODY_PROMPT = `Given this transcript of a coding session, write a concise git commit body.
|
|
19
|
+
|
|
20
|
+
Rules:
|
|
21
|
+
- Summarize what was done and why
|
|
22
|
+
- Be concise — a few sentences or bullet points
|
|
23
|
+
- Focus on the "why" more than the "what"
|
|
24
|
+
|
|
25
|
+
Transcript:
|
|
26
|
+
{{transcript}}
|
|
27
|
+
|
|
28
|
+
Respond with ONLY the commit body, nothing else.`
|
|
29
|
+
|
|
30
|
+
function renderPrompt (template, transcript) {
|
|
31
|
+
return template.replace(/\{\{transcript\}\}/g, () => transcript)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function runAgent (root, command, prompt) {
|
|
35
|
+
const binary = command.split(/\s+/)[0]
|
|
36
|
+
const whichResult = tryRun(`which ${binary}`, {})
|
|
37
|
+
if (whichResult.code !== 0) return null
|
|
38
|
+
|
|
39
|
+
const result = tryRun(command, {
|
|
40
|
+
cwd: root,
|
|
41
|
+
timeout: 45000,
|
|
42
|
+
input: prompt,
|
|
43
|
+
env: { ...process.env, TURBOCOMMIT_DISABLED: '1', CLAUDECODE: '' }
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
if (result.code !== 0) return null
|
|
47
|
+
|
|
48
|
+
const output = (result.stdout.trim() || result.stderr.trim())
|
|
49
|
+
return output || null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function runTitleAgent (root, titleCfg, transcript) {
|
|
53
|
+
const command = titleCfg.command || DEFAULT_COMMAND
|
|
54
|
+
const template = titleCfg.prompt || DEFAULT_TITLE_PROMPT
|
|
55
|
+
const prompt = renderPrompt(template, transcript)
|
|
56
|
+
const result = runAgent(root, command, prompt)
|
|
57
|
+
if (!result) return null
|
|
58
|
+
// Take only first line and enforce 72 char limit
|
|
59
|
+
return result.split('\n')[0].slice(0, 72) || null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function runBodyAgent (root, bodyCfg, transcript) {
|
|
63
|
+
const command = bodyCfg.command || DEFAULT_COMMAND
|
|
64
|
+
const template = bodyCfg.prompt || DEFAULT_BODY_PROMPT
|
|
65
|
+
const prompt = renderPrompt(template, transcript)
|
|
66
|
+
return runAgent(root, command, prompt)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = {
|
|
70
|
+
DEFAULT_COMMAND,
|
|
71
|
+
DEFAULT_TITLE_PROMPT,
|
|
72
|
+
DEFAULT_BODY_PROMPT,
|
|
73
|
+
renderPrompt,
|
|
74
|
+
runAgent,
|
|
75
|
+
runTitleAgent,
|
|
76
|
+
runBodyAgent
|
|
77
|
+
}
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const os = require('os')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { loadJson, mergeConfig } = require('./io')
|
|
4
|
+
const { hasTurbocommit, isFullyInstalled, getSettingsPath, HOOK_DEFS } = require('./install')
|
|
5
|
+
const { gitRoot } = require('./git')
|
|
6
|
+
const { configPath } = require('./init')
|
|
7
|
+
|
|
8
|
+
function globalConfigPath () {
|
|
9
|
+
return path.join(os.homedir(), '.claude', 'turbocommit.json')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function doctor (settingsPath, cwd) {
|
|
13
|
+
settingsPath = settingsPath || getSettingsPath()
|
|
14
|
+
cwd = cwd || process.cwd()
|
|
15
|
+
const checks = []
|
|
16
|
+
|
|
17
|
+
// 1. Global settings exist
|
|
18
|
+
const settings = loadJson(settingsPath)
|
|
19
|
+
if (!settings) {
|
|
20
|
+
checks.push({ name: 'Global settings', status: 'error', message: `Not found: ${settingsPath}` })
|
|
21
|
+
return { ok: false, checks }
|
|
22
|
+
}
|
|
23
|
+
checks.push({ name: 'Global settings', status: 'ok', message: settingsPath })
|
|
24
|
+
|
|
25
|
+
// 2. All hooks installed
|
|
26
|
+
if (!isFullyInstalled(settings)) {
|
|
27
|
+
const missing = Object.keys(HOOK_DEFS).filter(event =>
|
|
28
|
+
!hasTurbocommit((settings.hooks && settings.hooks[event]) || [])
|
|
29
|
+
)
|
|
30
|
+
checks.push({ name: 'Hooks installed', status: 'error', message: `Missing hooks: ${missing.join(', ')}. Run: turbocommit uninstall && turbocommit install` })
|
|
31
|
+
return { ok: false, checks }
|
|
32
|
+
}
|
|
33
|
+
checks.push({ name: 'Hooks installed', status: 'ok', message: `All ${Object.keys(HOOK_DEFS).length} hooks installed` })
|
|
34
|
+
|
|
35
|
+
// 3. Stop group isolation — turbocommit must be the sole hook in the last group
|
|
36
|
+
const stopGroups = (settings.hooks && settings.hooks.Stop) || []
|
|
37
|
+
const tcGroupIndex = findTurbocommitGroup(stopGroups)
|
|
38
|
+
if (tcGroupIndex >= 0) {
|
|
39
|
+
const tcGroup = stopGroups[tcGroupIndex]
|
|
40
|
+
const isLastGroup = tcGroupIndex === stopGroups.length - 1
|
|
41
|
+
const isSoleHook = tcGroup.hooks.length === 1
|
|
42
|
+
|
|
43
|
+
if (!isSoleHook) {
|
|
44
|
+
checks.push({ name: 'Group isolation', status: 'warn', message: 'turbocommit shares a Stop group with other hooks — will commit even if another hook blocks' })
|
|
45
|
+
} else if (!isLastGroup) {
|
|
46
|
+
checks.push({ name: 'Group isolation', status: 'warn', message: 'Another group runs after turbocommit in Stop' })
|
|
47
|
+
} else {
|
|
48
|
+
checks.push({ name: 'Group isolation', status: 'ok', message: 'Sole hook in last Stop group' })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 4. Global turbocommit config
|
|
53
|
+
const globalPath = globalConfigPath()
|
|
54
|
+
const globalCfg = loadJson(globalPath)
|
|
55
|
+
if (globalCfg) {
|
|
56
|
+
checks.push({ name: 'Global config', status: 'ok', message: globalPath })
|
|
57
|
+
} else {
|
|
58
|
+
checks.push({ name: 'Global config', status: 'info', message: 'Not found (optional)' })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 5. Local config exists
|
|
62
|
+
const root = gitRoot(cwd)
|
|
63
|
+
if (!root) {
|
|
64
|
+
checks.push({ name: 'Local config', status: 'info', message: 'Not in a git repo — skipping local checks' })
|
|
65
|
+
} else {
|
|
66
|
+
const localPath = configPath(root)
|
|
67
|
+
const localConfig = loadJson(localPath)
|
|
68
|
+
if (!localConfig) {
|
|
69
|
+
if (globalCfg) {
|
|
70
|
+
checks.push({ name: 'Local config', status: 'info', message: 'Not found (using global config)' })
|
|
71
|
+
} else {
|
|
72
|
+
checks.push({ name: 'Local config', status: 'warn', message: `Not found: ${localPath}. Run: turbocommit init` })
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
checks.push({ name: 'Local config', status: 'ok', message: localPath })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 6. Enabled — evaluate merged config
|
|
79
|
+
const merged = mergeConfig(globalCfg || {}, localConfig || {})
|
|
80
|
+
if (merged.enabled !== true) {
|
|
81
|
+
checks.push({ name: 'Enabled', status: 'warn', message: 'enabled is not true in merged config' })
|
|
82
|
+
} else {
|
|
83
|
+
checks.push({ name: 'Enabled', status: 'ok', message: 'Enabled' })
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ok = checks.every(c => c.status !== 'error')
|
|
88
|
+
return { ok, checks }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function findTurbocommitGroup (groups) {
|
|
92
|
+
for (let i = 0; i < groups.length; i++) {
|
|
93
|
+
const hooks = (groups[i] && groups[i].hooks) || []
|
|
94
|
+
if (hooks.some(h => h.command && h.command.includes('turbocommit'))) {
|
|
95
|
+
return i
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return -1
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { doctor }
|
package/lib/git.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const { execSync } = require('child_process')
|
|
2
|
+
|
|
3
|
+
function git (args, opts = {}) {
|
|
4
|
+
const cwd = opts.cwd || process.cwd()
|
|
5
|
+
return execSync(`git ${args}`, {
|
|
6
|
+
cwd,
|
|
7
|
+
encoding: 'utf8',
|
|
8
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
9
|
+
}).trimEnd()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function gitRoot (cwd) {
|
|
13
|
+
try {
|
|
14
|
+
return git('rev-parse --show-toplevel', { cwd })
|
|
15
|
+
} catch {
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function hasChanges (cwd) {
|
|
21
|
+
try {
|
|
22
|
+
git('diff --quiet HEAD', { cwd })
|
|
23
|
+
} catch {
|
|
24
|
+
return true
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
git('diff --cached --quiet', { cwd })
|
|
28
|
+
} catch {
|
|
29
|
+
return true
|
|
30
|
+
}
|
|
31
|
+
// Also check for untracked files
|
|
32
|
+
const untracked = git('ls-files --others --exclude-standard', { cwd })
|
|
33
|
+
return untracked.length > 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function addAndCommit (cwd, headline, body) {
|
|
37
|
+
git('add -A', { cwd })
|
|
38
|
+
git(`commit -m "${esc(headline)}" -m "${esc(body)}" --no-verify`, { cwd })
|
|
39
|
+
return git('rev-parse HEAD', { cwd })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hasCommits (cwd) {
|
|
43
|
+
try {
|
|
44
|
+
git('rev-parse HEAD', { cwd })
|
|
45
|
+
return true
|
|
46
|
+
} catch {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function currentBranch (cwd) {
|
|
52
|
+
try {
|
|
53
|
+
return git('branch --show-current', { cwd }) || 'HEAD'
|
|
54
|
+
} catch {
|
|
55
|
+
return 'HEAD'
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function esc (s) {
|
|
60
|
+
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = {
|
|
64
|
+
git,
|
|
65
|
+
gitRoot,
|
|
66
|
+
hasChanges,
|
|
67
|
+
addAndCommit,
|
|
68
|
+
hasCommits,
|
|
69
|
+
currentBranch
|
|
70
|
+
}
|
package/lib/init.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { gitRoot } = require('./git')
|
|
4
|
+
const { writeJson, ensureDir } = require('./io')
|
|
5
|
+
|
|
6
|
+
function configPath (root) {
|
|
7
|
+
return path.join(root, '.claude', 'turbocommit.json')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function init (cwd) {
|
|
11
|
+
const root = gitRoot(cwd)
|
|
12
|
+
if (!root) {
|
|
13
|
+
return { ok: false, error: 'Not a git repository' }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const p = configPath(root)
|
|
17
|
+
if (fs.existsSync(p)) {
|
|
18
|
+
return { ok: true, alreadyExists: true, path: p }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
ensureDir(path.dirname(p))
|
|
22
|
+
writeJson(p, { enabled: true })
|
|
23
|
+
return { ok: true, alreadyExists: false, path: p }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function deinit (cwd) {
|
|
27
|
+
const root = gitRoot(cwd)
|
|
28
|
+
if (!root) {
|
|
29
|
+
return { ok: false, error: 'Not a git repository' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const p = configPath(root)
|
|
33
|
+
if (!fs.existsSync(p)) {
|
|
34
|
+
return { ok: true, existed: false, path: p }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fs.unlinkSync(p)
|
|
38
|
+
return { ok: true, existed: true, path: p }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { init, deinit, configPath }
|