@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 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
+ [![Certified Shovelware](https://justin.searls.co/img/shovelware.svg)](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 }