kimaki 0.26.0 → 0.28.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.
Files changed (302) hide show
  1. package/dist/agent-model.e2e.test.js +250 -164
  2. package/dist/anthropic-account-identity.js +9 -1
  3. package/dist/anthropic-auth-plugin.js +10 -13
  4. package/dist/anthropic-auth-plugin.test.js +17 -0
  5. package/dist/anthropic-auth-state.js +1 -0
  6. package/dist/bash-tool-schema-plugin.js +32 -0
  7. package/dist/bash-tool-schema-plugin.test.js +49 -0
  8. package/dist/bin.js +73 -19
  9. package/dist/cli-commands/maintenance.js +46 -7
  10. package/dist/cli-commands/project.js +11 -27
  11. package/dist/cli-commands/send.js +30 -1
  12. package/dist/cli-commands/session.js +322 -90
  13. package/dist/cli-commands/task.js +34 -15
  14. package/dist/cli-parsing.test.js +83 -4
  15. package/dist/cli-runner.js +44 -8
  16. package/dist/cli-runner.test.js +27 -0
  17. package/dist/cli-send-thread.e2e.test.js +9 -9
  18. package/dist/cli.js +32 -0
  19. package/dist/commands/abort.js +5 -1
  20. package/dist/commands/action-buttons.js +2 -1
  21. package/dist/commands/add-project.js +4 -7
  22. package/dist/commands/agent.js +164 -32
  23. package/dist/commands/ask-question.js +23 -33
  24. package/dist/commands/ask-question.test.js +43 -1
  25. package/dist/commands/btw.js +28 -6
  26. package/dist/commands/file-upload.js +2 -1
  27. package/dist/commands/fork-subagent.js +2 -4
  28. package/dist/commands/fork.js +4 -4
  29. package/dist/commands/last-sessions.js +17 -10
  30. package/dist/commands/login.js +6 -0
  31. package/dist/commands/merge-worktree.js +47 -14
  32. package/dist/commands/model-variant.js +8 -1
  33. package/dist/commands/model.js +16 -9
  34. package/dist/commands/multioauth.js +1 -0
  35. package/dist/commands/new-worktree.js +170 -102
  36. package/dist/commands/queue.js +3 -2
  37. package/dist/commands/resume.js +4 -4
  38. package/dist/commands/session.js +78 -63
  39. package/dist/commands/user-command.js +6 -8
  40. package/dist/commands/worktrees.js +5 -13
  41. package/dist/config.js +16 -1
  42. package/dist/cpu-profiler.js +211 -0
  43. package/dist/cpu-profiler.test.js +160 -0
  44. package/dist/database.js +152 -3
  45. package/dist/db.js +33 -0
  46. package/dist/db.test.js +267 -1
  47. package/dist/default-channel-provisioning.test.js +148 -0
  48. package/dist/discord-bot.js +517 -488
  49. package/dist/discord-command-registration.js +25 -10
  50. package/dist/discord-command-registration.test.js +17 -0
  51. package/dist/discord-utils.js +97 -5
  52. package/dist/discord-utils.test.js +151 -1
  53. package/dist/errors.js +10 -0
  54. package/dist/exec-async.js +17 -5
  55. package/dist/external-directory-permissions.e2e.test.js +11 -7
  56. package/dist/external-directory-project-deny.e2e.test.js +5 -3
  57. package/dist/external-opencode-sync.js +94 -17
  58. package/dist/external-opencode-sync.test.js +206 -0
  59. package/dist/extract-sdk-error-message.test.js +13 -0
  60. package/dist/file-edit-log.js +197 -0
  61. package/dist/file-edit-log.test.js +258 -0
  62. package/dist/format-tables.js +52 -0
  63. package/dist/format-tables.test.js +93 -0
  64. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  65. package/dist/gateway-proxy.e2e.test.js +18 -18
  66. package/dist/git-worktree-core.js +182 -17
  67. package/dist/interaction-handler.js +416 -398
  68. package/dist/ipc-tools-plugin.js +68 -3
  69. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  70. package/dist/kimaki-opencode-plugin.js +9 -3
  71. package/dist/kimaki-workspace-adaptor.js +33 -10
  72. package/dist/logger.js +11 -1
  73. package/dist/markdown.js +3 -1
  74. package/dist/markdown.test.js +11 -3
  75. package/dist/message-formatting.js +181 -51
  76. package/dist/message-formatting.test.js +529 -22
  77. package/dist/message-preprocessing.js +105 -1
  78. package/dist/oauth-rotation-shared.js +10 -0
  79. package/dist/openai-auth-plugin.js +1 -0
  80. package/dist/openai-auth-state.js +1 -0
  81. package/dist/opencode-command.js +28 -0
  82. package/dist/opencode-command.test.js +109 -1
  83. package/dist/opencode-interrupt-plugin.js +4 -0
  84. package/dist/opencode-interrupt-plugin.test.js +24 -0
  85. package/dist/opencode.js +82 -15
  86. package/dist/project-add.test.js +62 -0
  87. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  88. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
  89. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  90. package/dist/queue-advanced-abort.e2e.test.js +8 -10
  91. package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
  92. package/dist/queue-advanced-e2e-setup.js +78 -5
  93. package/dist/queue-advanced-footer.e2e.test.js +60 -269
  94. package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
  95. package/dist/queue-advanced-permissions-typing.e2e.test.js +15 -15
  96. package/dist/queue-advanced-question.e2e.test.js +32 -27
  97. package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
  98. package/dist/queue-advanced-typing.e2e.test.js +7 -9
  99. package/dist/queue-delete-message.e2e.test.js +3 -3
  100. package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
  101. package/dist/queue-interrupt-drain.e2e.test.js +122 -12
  102. package/dist/queue-question-select-drain.e2e.test.js +34 -132
  103. package/dist/runtime-lifecycle.e2e.test.js +21 -51
  104. package/dist/schema.js +35 -1
  105. package/dist/session-handler/event-stream-state.js +391 -50
  106. package/dist/session-handler/event-stream-state.test.js +736 -8
  107. package/dist/session-handler/model-utils.js +172 -5
  108. package/dist/session-handler/model-utils.test.js +445 -0
  109. package/dist/session-handler/opencode-session-event-log.js +30 -1
  110. package/dist/session-handler/thread-ingress.test.js +41 -0
  111. package/dist/session-handler/thread-runtime-state.js +2 -0
  112. package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
  113. package/dist/session-handler/thread-session-runtime.js +574 -299
  114. package/dist/session-search.js +123 -4
  115. package/dist/session-search.test.js +248 -1
  116. package/dist/session-sleep.test.js +224 -0
  117. package/dist/session-title-rename.test.js +37 -39
  118. package/dist/sleep.e2e.test.js +494 -0
  119. package/dist/startup-time.e2e.test.js +2 -1
  120. package/dist/store.js +3 -0
  121. package/dist/system-message.js +102 -16
  122. package/dist/system-message.test.js +192 -15
  123. package/dist/task-runner.js +96 -5
  124. package/dist/task-schedule.js +87 -1
  125. package/dist/task-schedule.test.js +149 -1
  126. package/dist/test-utils.js +17 -9
  127. package/dist/thinking-utils.js +10 -0
  128. package/dist/thinking-utils.test.js +35 -0
  129. package/dist/thread-message-queue.e2e.test.js +65 -213
  130. package/dist/undo-redo.e2e.test.js +17 -9
  131. package/dist/voice-attachment.js +23 -1
  132. package/dist/voice-handler.js +21 -4
  133. package/dist/voice-message.e2e.test.js +144 -30
  134. package/dist/voice-routing-input.e2e.test.js +195 -0
  135. package/dist/voice-routing-question.e2e.test.js +125 -0
  136. package/dist/voice.js +28 -8
  137. package/dist/voice.test.js +96 -30
  138. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  139. package/dist/worktree-lifecycle.e2e.test.js +55 -36
  140. package/dist/worktrees.js +226 -443
  141. package/dist/worktrees.test.js +435 -58
  142. package/dist/xai-auth-plugin.js +1 -0
  143. package/dist/xai-auth-state.js +1 -0
  144. package/package.json +8 -6
  145. package/skills/bump-holocron-consumers/SKILL.md +137 -0
  146. package/skills/critique/SKILL.md +4 -4
  147. package/skills/errore/SKILL.md +60 -218
  148. package/skills/goke/SKILL.md +150 -10
  149. package/skills/holocron/SKILL.md +154 -17
  150. package/skills/npm-package/SKILL.md +55 -35
  151. package/skills/profano/SKILL.md +17 -2
  152. package/skills/spiceflow/SKILL.md +39 -3
  153. package/skills/strada/SKILL.md +39 -0
  154. package/skills/zele/SKILL.md +19 -0
  155. package/src/agent-model.e2e.test.ts +287 -186
  156. package/src/anthropic-account-identity.ts +20 -1
  157. package/src/anthropic-auth-plugin.test.ts +19 -0
  158. package/src/anthropic-auth-plugin.ts +12 -16
  159. package/src/anthropic-auth-state.ts +1 -0
  160. package/src/bash-tool-schema-plugin.test.ts +55 -0
  161. package/src/bash-tool-schema-plugin.ts +46 -0
  162. package/src/bin.ts +78 -26
  163. package/src/cli-commands/maintenance.ts +59 -13
  164. package/src/cli-commands/project.ts +14 -35
  165. package/src/cli-commands/send.ts +31 -1
  166. package/src/cli-commands/session.ts +374 -114
  167. package/src/cli-commands/task.ts +34 -15
  168. package/src/cli-parsing.test.ts +103 -4
  169. package/src/cli-runner.test.ts +49 -0
  170. package/src/cli-runner.ts +44 -7
  171. package/src/cli-send-thread.e2e.test.ts +9 -11
  172. package/src/cli.ts +53 -0
  173. package/src/commands/abort.ts +6 -1
  174. package/src/commands/action-buttons.ts +2 -1
  175. package/src/commands/add-project.ts +4 -8
  176. package/src/commands/agent.ts +205 -29
  177. package/src/commands/ask-question.test.ts +48 -1
  178. package/src/commands/ask-question.ts +23 -33
  179. package/src/commands/btw.ts +40 -5
  180. package/src/commands/file-upload.ts +2 -1
  181. package/src/commands/fork-subagent.ts +2 -3
  182. package/src/commands/fork.ts +4 -3
  183. package/src/commands/last-sessions.ts +10 -4
  184. package/src/commands/login.ts +6 -0
  185. package/src/commands/merge-worktree.ts +54 -16
  186. package/src/commands/model-variant.ts +9 -1
  187. package/src/commands/model.ts +20 -9
  188. package/src/commands/multioauth.ts +1 -0
  189. package/src/commands/new-worktree.ts +138 -55
  190. package/src/commands/queue.ts +3 -2
  191. package/src/commands/resume.ts +4 -3
  192. package/src/commands/session.ts +94 -70
  193. package/src/commands/user-command.ts +6 -6
  194. package/src/commands/worktrees.ts +5 -12
  195. package/src/config.ts +18 -1
  196. package/src/cpu-profiler.test.ts +183 -0
  197. package/src/cpu-profiler.ts +293 -0
  198. package/src/database.ts +235 -3
  199. package/src/db.test.ts +293 -0
  200. package/src/db.ts +38 -0
  201. package/src/default-channel-provisioning.test.ts +155 -0
  202. package/src/discord-bot.ts +78 -36
  203. package/src/discord-command-registration.test.ts +20 -0
  204. package/src/discord-command-registration.ts +42 -12
  205. package/src/discord-utils.test.ts +176 -0
  206. package/src/discord-utils.ts +152 -7
  207. package/src/errors.ts +12 -0
  208. package/src/exec-async.ts +20 -11
  209. package/src/external-directory-permissions.e2e.test.ts +11 -7
  210. package/src/external-directory-project-deny.e2e.test.ts +5 -3
  211. package/src/external-opencode-sync.test.ts +248 -0
  212. package/src/external-opencode-sync.ts +122 -19
  213. package/src/extract-sdk-error-message.test.ts +18 -0
  214. package/src/file-edit-log.test.ts +287 -0
  215. package/src/file-edit-log.ts +283 -0
  216. package/src/format-tables.test.ts +101 -0
  217. package/src/format-tables.ts +75 -0
  218. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  219. package/src/gateway-proxy.e2e.test.ts +18 -17
  220. package/src/git-worktree-core.ts +247 -20
  221. package/src/interaction-handler.ts +36 -13
  222. package/src/ipc-tools-plugin.ts +82 -5
  223. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  224. package/src/kimaki-opencode-plugin.ts +9 -3
  225. package/src/kimaki-workspace-adaptor.ts +40 -11
  226. package/src/logger.ts +14 -4
  227. package/src/markdown.test.ts +13 -3
  228. package/src/markdown.ts +4 -1
  229. package/src/message-formatting.test.ts +672 -25
  230. package/src/message-formatting.ts +242 -53
  231. package/src/message-preprocessing.ts +116 -1
  232. package/src/oauth-rotation-shared.ts +10 -0
  233. package/src/openai-auth-plugin.ts +1 -0
  234. package/src/openai-auth-state.ts +1 -0
  235. package/src/opencode-command.test.ts +140 -0
  236. package/src/opencode-command.ts +38 -0
  237. package/src/opencode-interrupt-plugin.test.ts +32 -0
  238. package/src/opencode-interrupt-plugin.ts +3 -0
  239. package/src/opencode.ts +115 -15
  240. package/src/project-add.test.ts +80 -0
  241. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  242. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
  243. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  244. package/src/queue-advanced-abort.e2e.test.ts +8 -9
  245. package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
  246. package/src/queue-advanced-e2e-setup.ts +83 -4
  247. package/src/queue-advanced-footer.e2e.test.ts +67 -315
  248. package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
  249. package/src/queue-advanced-permissions-typing.e2e.test.ts +15 -15
  250. package/src/queue-advanced-question.e2e.test.ts +36 -30
  251. package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
  252. package/src/queue-advanced-typing.e2e.test.ts +8 -8
  253. package/src/queue-delete-message.e2e.test.ts +3 -3
  254. package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
  255. package/src/queue-interrupt-drain.e2e.test.ts +133 -12
  256. package/src/queue-question-select-drain.e2e.test.ts +36 -161
  257. package/src/runtime-lifecycle.e2e.test.ts +21 -54
  258. package/src/schema.sql +15 -2
  259. package/src/schema.ts +37 -1
  260. package/src/session-handler/event-stream-state.test.ts +790 -7
  261. package/src/session-handler/event-stream-state.ts +568 -53
  262. package/src/session-handler/model-utils.test.ts +537 -0
  263. package/src/session-handler/model-utils.ts +281 -5
  264. package/src/session-handler/opencode-session-event-log.ts +31 -1
  265. package/src/session-handler/thread-ingress.test.ts +54 -0
  266. package/src/session-handler/thread-runtime-state.ts +3 -0
  267. package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
  268. package/src/session-handler/thread-session-runtime.ts +712 -358
  269. package/src/session-search.test.ts +281 -0
  270. package/src/session-search.ts +241 -7
  271. package/src/session-sleep.test.ts +294 -0
  272. package/src/session-title-rename.test.ts +54 -48
  273. package/src/sleep.e2e.test.ts +565 -0
  274. package/src/startup-time.e2e.test.ts +5 -1
  275. package/src/store.ts +22 -0
  276. package/src/system-message.test.ts +226 -15
  277. package/src/system-message.ts +130 -16
  278. package/src/task-runner.ts +138 -3
  279. package/src/task-schedule.test.ts +170 -0
  280. package/src/task-schedule.ts +141 -1
  281. package/src/test-utils.ts +21 -10
  282. package/src/thinking-utils.test.ts +46 -0
  283. package/src/thinking-utils.ts +21 -0
  284. package/src/thread-message-queue.e2e.test.ts +65 -245
  285. package/src/undo-redo.e2e.test.ts +17 -9
  286. package/src/voice-attachment.ts +29 -1
  287. package/src/voice-handler.ts +23 -4
  288. package/src/voice-message.e2e.test.ts +149 -29
  289. package/src/voice-routing-input.e2e.test.ts +203 -0
  290. package/src/voice-routing-question.e2e.test.ts +131 -0
  291. package/src/voice.test.ts +107 -29
  292. package/src/voice.ts +34 -6
  293. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  294. package/src/worktree-lifecycle.e2e.test.ts +55 -35
  295. package/src/worktrees.test.ts +505 -64
  296. package/src/worktrees.ts +257 -603
  297. package/src/xai-auth-plugin.ts +1 -0
  298. package/src/xai-auth-state.ts +1 -0
  299. package/dist/message-finish-field.e2e.test.js +0 -169
  300. package/dist/subagent-rate-limit-plugin.js +0 -184
  301. package/src/message-finish-field.e2e.test.ts +0 -196
  302. package/src/subagent-rate-limit-plugin.ts +0 -228
package/src/worktrees.ts CHANGED
@@ -1,514 +1,18 @@
1
1
  // Worktree service and git helpers.
2
- // Provides reusable, Discord-agnostic worktree creation/merge logic,
3
- // submodule initialization, and git diff transfer utilities.
2
+ // Provides managed paths, merge logic, and git diff transfer utilities.
4
3
 
5
4
  import crypto from 'node:crypto'
6
5
  import fs from 'node:fs'
7
6
  import path from 'node:path'
8
7
  import { getDataDir } from './config.js'
9
8
  import { execAsync } from './exec-async.js'
10
- import { createWorktreeCore, type WorktreeResult } from './git-worktree-core.js'
11
9
  import { createLogger, LogPrefix } from './logger.js'
12
10
 
13
11
  export { execAsync } from './exec-async.js'
14
12
 
15
13
  const SUBMODULE_INIT_TIMEOUT_MS = 20 * 60_000
16
- const INSTALL_TIMEOUT_MS = 60_000
17
-
18
14
  const logger = createLogger(LogPrefix.WORKTREE)
19
15
 
20
- const LOCKFILE_TO_INSTALL_COMMAND: Array<[string, string]> = [
21
- ['pnpm-lock.yaml', 'pnpm install'],
22
- ['bun.lock', 'bun install'],
23
- ['bun.lockb', 'bun install'],
24
- ['yarn.lock', 'yarn install'],
25
- ['package-lock.json', 'npm install'],
26
- ]
27
-
28
- function detectInstallCommand(directory: string): string | null {
29
- for (const [lockfile, command] of LOCKFILE_TO_INSTALL_COMMAND) {
30
- if (fs.existsSync(path.join(directory, lockfile))) {
31
- return command
32
- }
33
- }
34
- return null
35
- }
36
-
37
- /**
38
- * Run the detected package manager install in a worktree directory.
39
- * Non-fatal: returns Error on failure/timeout so callers can log and continue.
40
- * The 60s timeout kills the process if install hangs.
41
- */
42
- export async function runDependencyInstall({
43
- directory,
44
- }: {
45
- directory: string
46
- }): Promise<void | Error> {
47
- const installCommand = detectInstallCommand(directory)
48
- if (!installCommand) {
49
- return
50
- }
51
- logger.log(`Running "${installCommand}" in ${directory} (timeout=${INSTALL_TIMEOUT_MS}ms)`)
52
- try {
53
- await execAsync(installCommand, {
54
- cwd: directory,
55
- timeout: INSTALL_TIMEOUT_MS,
56
- })
57
- logger.log(`Dependencies installed in ${directory}`)
58
- } catch (e) {
59
- return new Error(`Install failed: ${formatCommandError(e)}`, { cause: e })
60
- }
61
- }
62
-
63
- type CommandError = Error & {
64
- cmd?: string
65
- stderr?: string
66
- stdout?: string
67
- signal?: NodeJS.Signals
68
- killed?: boolean
69
- }
70
-
71
- function formatCommandError(error: unknown): string {
72
- if (!(error instanceof Error)) {
73
- return String(error)
74
- }
75
-
76
- const commandError = error as CommandError
77
- const details: string[] = [commandError.message]
78
-
79
- if (commandError.cmd) {
80
- details.push(`cmd=${commandError.cmd}`)
81
- }
82
- if (commandError.signal) {
83
- details.push(`signal=${commandError.signal}`)
84
- }
85
- if (commandError.killed) {
86
- details.push('process=killed')
87
- }
88
- if (commandError.stderr?.trim()) {
89
- details.push(`stderr=${commandError.stderr.trim()}`)
90
- }
91
- if (commandError.stdout?.trim()) {
92
- details.push(`stdout=${commandError.stdout.trim()}`)
93
- }
94
-
95
- return details.join(' | ')
96
- }
97
-
98
- type GitSubmoduleConfig = {
99
- name: string
100
- path: string
101
- url: string | null
102
- }
103
-
104
- export type SubmoduleReferencePlan = {
105
- path: string
106
- referenceDirectory: string | null
107
- }
108
-
109
- export function parseGitmodulesFileContent(
110
- gitmodulesContent: string,
111
- ): GitSubmoduleConfig[] | Error {
112
- const lines = gitmodulesContent.split('\n')
113
- const configs: GitSubmoduleConfig[] = []
114
- let currentName: string | null = null
115
- let currentPath: string | null = null
116
- let currentUrl: string | null = null
117
-
118
- const flushCurrent = (): void | Error => {
119
- if (!currentName) {
120
- return
121
- }
122
- if (!currentPath) {
123
- return new Error(`Submodule ${currentName} is missing path in .gitmodules`)
124
- }
125
- configs.push({
126
- name: currentName,
127
- path: currentPath,
128
- url: currentUrl,
129
- })
130
- }
131
-
132
- for (const rawLine of lines) {
133
- const line = rawLine.trim()
134
- if (!line || line.startsWith('#') || line.startsWith(';')) {
135
- continue
136
- }
137
-
138
- const sectionMatch = line.match(/^\[submodule\s+"([^"]+)"\]$/)
139
- if (sectionMatch?.[1]) {
140
- const flushError = flushCurrent()
141
- if (flushError instanceof Error) return flushError
142
-
143
- currentName = sectionMatch[1]
144
- currentPath = null
145
- currentUrl = null
146
- continue
147
- }
148
-
149
- if (!currentName) {
150
- continue
151
- }
152
-
153
- const keyValueMatch = line.match(/^([^=\s]+)\s*=\s*(.*)$/)
154
- const key = keyValueMatch?.[1]
155
- const value = keyValueMatch?.[2]
156
- if (!key || value === undefined) {
157
- continue
158
- }
159
-
160
- if (key === 'path') {
161
- currentPath = value
162
- continue
163
- }
164
-
165
- if (key === 'url') {
166
- currentUrl = value
167
- }
168
- }
169
-
170
- const flushError = flushCurrent()
171
- if (flushError instanceof Error) return flushError
172
-
173
- return configs
174
- }
175
-
176
- async function readSubmoduleConfigs(
177
- directory: string,
178
- ): Promise<GitSubmoduleConfig[] | Error> {
179
- const gitmodulesPath = path.join(directory, '.gitmodules')
180
- const gitmodulesExists = await fs.promises
181
- .access(gitmodulesPath)
182
- .then(() => {
183
- return true
184
- })
185
- .catch(() => {
186
- return false
187
- })
188
- if (!gitmodulesExists) {
189
- return []
190
- }
191
-
192
- const gitmodulesContent = await fs.promises.readFile(gitmodulesPath, 'utf-8')
193
- .catch((e) =>
194
- new Error(`Failed to read ${gitmodulesPath}`, {
195
- cause: e,
196
- }),
197
- )
198
- if (gitmodulesContent instanceof Error) return gitmodulesContent
199
-
200
- const parsed = parseGitmodulesFileContent(gitmodulesContent)
201
- if (parsed instanceof Error) {
202
- return new Error(`Failed to parse ${gitmodulesPath}: ${parsed.message}`, {
203
- cause: parsed,
204
- })
205
- }
206
-
207
- return parsed
208
- }
209
-
210
- export function buildSubmoduleReferencePlan({
211
- sourceDirectory,
212
- submodulePaths,
213
- existingSourceSubmoduleDirectories,
214
- }: {
215
- sourceDirectory: string
216
- submodulePaths: string[]
217
- existingSourceSubmoduleDirectories: Set<string>
218
- }): SubmoduleReferencePlan[] {
219
- return submodulePaths.map((submodulePath) => {
220
- const sourceSubmoduleDirectory = path.resolve(sourceDirectory, submodulePath)
221
- if (existingSourceSubmoduleDirectories.has(sourceSubmoduleDirectory)) {
222
- return {
223
- path: submodulePath,
224
- referenceDirectory: sourceSubmoduleDirectory,
225
- }
226
- }
227
-
228
- return {
229
- path: submodulePath,
230
- referenceDirectory: null,
231
- }
232
- })
233
- }
234
-
235
- function buildGitCommand(args: string[]): string {
236
- const quotedArgs = args.map((arg) => {
237
- return JSON.stringify(arg)
238
- })
239
- return `git ${quotedArgs.join(' ')}`
240
- }
241
-
242
- export function buildSubmoduleUpdateCommandArgs({
243
- path: submodulePath,
244
- referenceDirectory,
245
- }: SubmoduleReferencePlan): string[] {
246
- if (referenceDirectory) {
247
- return [
248
- '-c',
249
- 'protocol.file.allow=always',
250
- 'submodule',
251
- 'update',
252
- '--init',
253
- '--recursive',
254
- '--reference',
255
- referenceDirectory,
256
- '--',
257
- submodulePath,
258
- ]
259
- }
260
-
261
- return [
262
- '-c',
263
- 'protocol.file.allow=always',
264
- 'submodule',
265
- 'update',
266
- '--init',
267
- '--recursive',
268
- '--',
269
- submodulePath,
270
- ]
271
- }
272
-
273
- async function hasSubmoduleGitMetadata(directory: string): Promise<boolean> {
274
- const gitPath = path.join(directory, '.git')
275
- return fs.promises
276
- .access(gitPath)
277
- .then(() => {
278
- return true
279
- })
280
- .catch(() => {
281
- return false
282
- })
283
- }
284
-
285
- async function initializeSubmodulesWithLocalReferences({
286
- sourceDirectory,
287
- worktreeDirectory,
288
- }: {
289
- sourceDirectory: string
290
- worktreeDirectory: string
291
- }): Promise<void | Error> {
292
- const submoduleConfigs = await readSubmoduleConfigs(worktreeDirectory)
293
- if (submoduleConfigs instanceof Error) return submoduleConfigs
294
- if (submoduleConfigs.length === 0) {
295
- return
296
- }
297
-
298
- const sourceDirectories = submoduleConfigs.map(({ path: submodulePath }) => {
299
- return path.resolve(sourceDirectory, submodulePath)
300
- })
301
-
302
- const sourceDirectoryChecks = await Promise.all(
303
- sourceDirectories.map(async (sourceSubmoduleDirectory) => {
304
- const exists = await hasSubmoduleGitMetadata(sourceSubmoduleDirectory)
305
- return { sourceSubmoduleDirectory, exists }
306
- }),
307
- )
308
-
309
- const existingSourceSubmoduleDirectories = new Set(
310
- sourceDirectoryChecks
311
- .filter(({ exists }) => {
312
- return exists
313
- })
314
- .map(({ sourceSubmoduleDirectory }) => {
315
- return sourceSubmoduleDirectory
316
- }),
317
- )
318
-
319
- const submodulePlan = buildSubmoduleReferencePlan({
320
- sourceDirectory,
321
- submodulePaths: submoduleConfigs.map(({ path: submodulePath }) => {
322
- return submodulePath
323
- }),
324
- existingSourceSubmoduleDirectories,
325
- })
326
-
327
- for (const planItem of submodulePlan) {
328
- const commandArgs = buildSubmoduleUpdateCommandArgs(planItem)
329
- const command = buildGitCommand(commandArgs)
330
- const result = await execAsync(command, {
331
- cwd: worktreeDirectory,
332
- timeout: SUBMODULE_INIT_TIMEOUT_MS,
333
- }).catch((e) =>
334
- new Error(
335
- `git ${commandArgs.join(' ')} failed for ${planItem.path}: ${formatCommandError(e)}`,
336
- { cause: e },
337
- ),
338
- )
339
- if (result instanceof Error) {
340
- // Non-fatal: broken .gitmodules entries (e.g. path listed but not in tree)
341
- // should not block worktree creation. Log and continue with remaining submodules.
342
- logger.warn(
343
- `Skipping submodule ${planItem.path}: ${result.message}`,
344
- )
345
- }
346
- }
347
- }
348
-
349
- /**
350
- * Get submodule paths from .gitmodules file.
351
- * Returns empty array if no submodules or on error.
352
- */
353
- async function getSubmodulePaths(directory: string): Promise<string[]> {
354
- const submoduleConfigs = await readSubmoduleConfigs(directory)
355
- if (submoduleConfigs instanceof Error) {
356
- logger.warn(`Failed reading submodules from ${directory}: ${submoduleConfigs.message}`)
357
- return []
358
- }
359
-
360
- return submoduleConfigs.map(({ path: submodulePath }) => {
361
- return submodulePath
362
- })
363
- }
364
-
365
- /**
366
- * Remove broken submodule stubs created by git worktree.
367
- * When git worktree add runs on a repo with submodules, it creates submodule
368
- * directories with .git files pointing to ../.git/worktrees/<name>/modules/<submodule>
369
- * but that path only has a config file, missing HEAD/objects/refs.
370
- * This causes git commands to fail with "fatal: not a git repository".
371
- */
372
- async function removeBrokenSubmoduleStubs(directory: string): Promise<void> {
373
- const submodulePaths = await getSubmodulePaths(directory)
374
-
375
- for (const subPath of submodulePaths) {
376
- const fullPath = path.join(directory, subPath)
377
- const gitFile = path.join(fullPath, '.git')
378
-
379
- try {
380
- const stat = await fs.promises.stat(gitFile)
381
- if (!stat.isFile()) {
382
- continue
383
- }
384
-
385
- // Read .git file to get gitdir path
386
- const content = await fs.promises.readFile(gitFile, 'utf-8')
387
- const match = content.match(/^gitdir:\s*(.+)$/m)
388
- if (!match || !match[1]) {
389
- continue
390
- }
391
-
392
- const gitdir = path.resolve(fullPath, match[1].trim())
393
- const headFile = path.join(gitdir, 'HEAD')
394
-
395
- // If HEAD doesn't exist, this is a broken stub
396
- const headExists = await fs.promises
397
- .access(headFile)
398
- .then(() => {
399
- return true
400
- })
401
- .catch(() => {
402
- return false
403
- })
404
-
405
- if (!headExists) {
406
- logger.log(`Removing broken submodule stub: ${subPath}`)
407
- await fs.promises.rm(fullPath, { recursive: true, force: true })
408
- }
409
- } catch {
410
- // Directory doesn't exist or other error, skip
411
- }
412
- }
413
- }
414
-
415
- function parseSubmoduleGitdir(gitFileContent: string): string | Error {
416
- const match = gitFileContent.match(/^gitdir:\s*(.+)$/m)
417
- const gitdir = match?.[1]?.trim()
418
- if (!gitdir) {
419
- return new Error('Missing gitdir pointer')
420
- }
421
- return gitdir
422
- }
423
-
424
- async function validateSubmodulePointers(
425
- directory: string,
426
- ): Promise<void | Error> {
427
- const submodulePaths = await getSubmodulePaths(directory)
428
- if (submodulePaths.length === 0) {
429
- return
430
- }
431
-
432
- const validationIssues: string[] = []
433
-
434
- await Promise.all(
435
- submodulePaths.map(async (submodulePath) => {
436
- const submoduleDir = path.join(directory, submodulePath)
437
- const submoduleGitFile = path.join(submoduleDir, '.git')
438
-
439
- const gitFileExists = await fs.promises
440
- .access(submoduleGitFile)
441
- .then(() => {
442
- return true
443
- })
444
- .catch(() => {
445
- return false
446
- })
447
- if (!gitFileExists) {
448
- validationIssues.push(`${submodulePath}: missing .git file`)
449
- return
450
- }
451
-
452
- const gitFileContentResult = await fs.promises.readFile(submoduleGitFile, 'utf-8')
453
- .catch((e) =>
454
- new Error(`Failed to read .git for ${submodulePath}`, { cause: e }),
455
- )
456
- if (gitFileContentResult instanceof Error) {
457
- validationIssues.push(
458
- `${submodulePath}: ${gitFileContentResult.message}`,
459
- )
460
- return
461
- }
462
-
463
- const parsedGitdir = parseSubmoduleGitdir(gitFileContentResult)
464
- if (parsedGitdir instanceof Error) {
465
- validationIssues.push(`${submodulePath}: ${parsedGitdir.message}`)
466
- return
467
- }
468
-
469
- const resolvedGitdir = path.resolve(submoduleDir, parsedGitdir)
470
- const headPath = path.join(resolvedGitdir, 'HEAD')
471
- const headExists = await fs.promises
472
- .access(headPath)
473
- .then(() => {
474
- return true
475
- })
476
- .catch(() => {
477
- return false
478
- })
479
- if (!headExists) {
480
- validationIssues.push(
481
- `${submodulePath}: gitdir missing HEAD (${resolvedGitdir})`,
482
- )
483
- }
484
- }),
485
- )
486
-
487
- const submoduleStatusResult = await execAsync('git submodule status --recursive', {
488
- cwd: directory,
489
- timeout: SUBMODULE_INIT_TIMEOUT_MS,
490
- }).catch((e) =>
491
- new Error('git submodule status --recursive failed', { cause: e }),
492
- )
493
- if (submoduleStatusResult instanceof Error) {
494
- validationIssues.push(submoduleStatusResult.message)
495
- }
496
-
497
- if (validationIssues.length === 0) {
498
- return
499
- }
500
-
501
- return new Error(
502
- `Submodule validation failed: ${validationIssues.join('; ')}`,
503
- )
504
- }
505
-
506
- async function resolveDefaultWorktreeTarget(
507
- directory: string,
508
- ): Promise<string> {
509
- return 'HEAD'
510
- }
511
-
512
16
  /**
513
17
  * Build the on-disk directory for a managed worktree.
514
18
  *
@@ -543,48 +47,15 @@ export function getManagedWorktreeDirectory({
543
47
  return path.join(getDataDir(), 'worktrees', projectHash, withoutPrefix)
544
48
  }
545
49
 
546
- /**
547
- * Create a worktree using git and initialize git submodules.
548
- * This wrapper ensures submodules are properly set up in new worktrees.
549
- */
550
- export async function createWorktreeWithSubmodules({
551
- directory,
552
- name,
553
- baseBranch,
554
- onProgress,
555
- }: {
556
- directory: string
557
- name: string
558
- /** Override the base branch to create the worktree from. Defaults to HEAD. */
559
- baseBranch?: string
560
- /** Called with a short phase label so callers can update UI (e.g. Discord status message). */
561
- onProgress?: (phase: string) => void
562
- }): Promise<WorktreeResult | Error> {
563
- const worktreeDir = getManagedWorktreeDirectory({ directory, name })
564
-
565
- // Delegate to the plugin-safe core module, passing our logger as callbacks.
566
- return createWorktreeCore({
567
- projectDirectory: directory,
568
- targetDirectory: worktreeDir,
569
- branchName: name,
570
- baseBranch,
571
- onProgress,
572
- log: {
573
- info: (msg) => logger.log(msg),
574
- warn: (msg) => logger.warn(msg),
575
- error: (msg) => logger.error(msg),
576
- },
577
- })
578
- }
579
-
580
50
  // ─── Worktree merge ──────────────────────────────────────────────────────────
581
- // Merge pipeline (preserves all worktree commits, no squash):
51
+ // Merge pipeline:
582
52
  // 1. Reject if uncommitted changes exist
583
53
  // 2. Rebase worktree commits onto target (default branch)
584
- // 3. Fast-forward push to target via local git push
585
- // 4. Switch to detached HEAD, delete branch
54
+ // 3. Optionally collapse the rebased tree into one commit
55
+ // 4. Fast-forward push to target via local git push
56
+ // 5. Switch to detached HEAD, delete branch
586
57
  //
587
- // Uses `git push <git-common-dir> HEAD:<target>` with
58
+ // Uses `git push <git-common-dir> <merge-ref>:<target>` with
588
59
  // `receive.denyCurrentBranch=updateInstead` to fast-forward the target
589
60
  // WITHOUT checking it out in the main repo.
590
61
  //
@@ -617,24 +88,155 @@ export type MergeSuccess = {
617
88
  shortSha: string
618
89
  }
619
90
 
91
+ export type MergeStrategy = 'rebase' | 'squash'
92
+
620
93
  export async function git(
621
94
  dir: string,
622
- args: string,
95
+ args: string | string[],
623
96
  opts?: { timeout?: number },
624
97
  ): Promise<GitCommandError | string> {
98
+ const command = Array.isArray(args)
99
+ ? { command: 'git', args: ['-C', dir, ...args] }
100
+ : `git -C "${dir}" ${args}`
101
+ const commandLabel = Array.isArray(args)
102
+ ? ['git', '-C', dir, ...args].join(' ')
103
+ : `git -C "${dir}" ${args}`
625
104
  const result = await execAsync(
626
- `git -C "${dir}" ${args}`,
105
+ command,
627
106
  opts ? { timeout: opts.timeout } : undefined,
628
- ).catch((e) => new GitCommandError({ command: args, cause: e }))
107
+ ).catch((e) => new GitCommandError({ command: commandLabel, cause: e }))
629
108
  if (result instanceof Error) return result
630
109
  return result.stdout.trim()
631
110
  }
632
111
 
112
+ function walkErrorCauseChain(error: Error): Error[] {
113
+ const seen = new Set<Error>()
114
+ const chain: Error[] = []
115
+ let current: unknown = error
116
+ while (current instanceof Error && !seen.has(current)) {
117
+ seen.add(current)
118
+ chain.push(current)
119
+ current = current.cause
120
+ }
121
+ return chain
122
+ }
123
+
124
+ function execTextField(error: Error, field: 'stderr' | 'stdout'): string {
125
+ const value = Reflect.get(error, field)
126
+ if (typeof value === 'string') return value.trim()
127
+ if (value instanceof Uint8Array) return new TextDecoder().decode(value).trim()
128
+ return ''
129
+ }
130
+
131
+ function execExitCode(error: Error): string | undefined {
132
+ const code = Reflect.get(error, 'code')
133
+ if (typeof code === 'string' || typeof code === 'number') return String(code)
134
+ return undefined
135
+ }
136
+
137
+ export type GitExecOutput = {
138
+ command: string | undefined
139
+ stderr: string
140
+ stdout: string
141
+ code: string | undefined
142
+ }
143
+
144
+ export function extractGitExecOutput(error: Error): GitExecOutput {
145
+ const chain = walkErrorCauseChain(error)
146
+ const gitErr = chain.find((item) => item instanceof GitCommandError)
147
+ let stderr = ''
148
+ let stdout = ''
149
+ let code: string | undefined
150
+ for (const item of chain) {
151
+ if (!stderr) stderr = execTextField(item, 'stderr')
152
+ if (!stdout) stdout = execTextField(item, 'stdout')
153
+ if (code === undefined) code = execExitCode(item)
154
+ }
155
+ const command = gitErr instanceof GitCommandError ? gitErr.command : undefined
156
+ return {
157
+ command: typeof command === 'string' ? command : undefined,
158
+ stderr,
159
+ stdout,
160
+ code,
161
+ }
162
+ }
163
+
164
+ function isRedundantCauseMessage({
165
+ message,
166
+ headline,
167
+ command,
168
+ output,
169
+ }: {
170
+ message: string
171
+ headline: string
172
+ command: string | undefined
173
+ output: string
174
+ }): boolean {
175
+ if (!message) return true
176
+ if (message === headline) return true
177
+ if (command && message === `Git command failed: ${command}`) return true
178
+ if (message.startsWith('Command failed:')) return true
179
+ if (output && message.includes(output)) return true
180
+ return false
181
+ }
182
+
183
+ function truncateFormattedError(text: string, maxLength: number): string {
184
+ if (text.length <= maxLength) return text
185
+ const ellipsis = '\n… [truncated]'
186
+ return text.slice(0, Math.max(0, maxLength - ellipsis.length)) + ellipsis
187
+ }
188
+
189
+ export function formatMergeWorktreeError(
190
+ error: Error,
191
+ opts?: { maxLength?: number },
192
+ ): string {
193
+ const maxLength = opts?.maxLength ?? 1900
194
+ const output = extractGitExecOutput(error)
195
+ const lines: string[] = [`Merge failed: ${error.message}`]
196
+ if (error instanceof PushError) {
197
+ lines.push(
198
+ 'Worktree rebase succeeded. Local branch was not updated. This is not a push to origin.',
199
+ )
200
+ }
201
+
202
+ const extraCauses = walkErrorCauseChain(error)
203
+ .slice(1)
204
+ .map((item) => item.message)
205
+ .filter((message) => {
206
+ return !isRedundantCauseMessage({
207
+ message,
208
+ headline: error.message,
209
+ command: output.command,
210
+ output: [output.stderr, output.stdout].filter(Boolean).join('\n'),
211
+ })
212
+ })
213
+
214
+ const blockLines: string[] = []
215
+ if (output.command) blockLines.push(output.command)
216
+ if (output.code) blockLines.push(`exit ${output.code}`)
217
+ if (output.stderr) {
218
+ if (blockLines.length) blockLines.push('')
219
+ blockLines.push(output.stderr)
220
+ }
221
+ if (output.stdout && output.stdout !== output.stderr) {
222
+ if (blockLines.length) blockLines.push('')
223
+ blockLines.push(output.stdout)
224
+ }
225
+ for (const message of extraCauses) {
226
+ if (blockLines.length) blockLines.push('')
227
+ blockLines.push(message)
228
+ }
229
+ if (blockLines.length > 0) {
230
+ lines.push('', '```', ...blockLines, '```')
231
+ }
232
+ return truncateFormattedError(lines.join('\n'), maxLength)
233
+ }
234
+
633
235
  export async function getDefaultBranch(
634
236
  repoDir: string,
635
237
  opts?: { timeout?: number },
636
238
  ): Promise<string> {
637
- const ref = await git(repoDir, 'symbolic-ref refs/remotes/origin/HEAD', opts)
239
+ const ref = await git(repoDir, ['symbolic-ref', 'refs/remotes/origin/HEAD'], opts)
638
240
  if (ref instanceof Error) return 'main'
639
241
  return ref.replace(/^refs\/remotes\/origin\//, '') || 'main'
640
242
  }
@@ -700,9 +302,9 @@ export async function deleteWorktree({
700
302
  export async function isDirty(
701
303
  dir: string,
702
304
  opts?: { timeout?: number },
703
- ): Promise<boolean> {
704
- const status = await git(dir, 'status --porcelain', opts)
705
- if (status instanceof Error) return false
305
+ ): Promise<GitCommandError | boolean> {
306
+ const status = await git(dir, ['status', '--porcelain'], opts)
307
+ if (status instanceof Error) return status
706
308
  return status.length > 0
707
309
  }
708
310
 
@@ -713,7 +315,7 @@ export async function isGitRepositoryRoot(directory: string): Promise<boolean> {
713
315
  }
714
316
 
715
317
  async function getGitCommonDir(dir: string): Promise<GitCommandError | string> {
716
- const commonDir = await git(dir, 'rev-parse --git-common-dir')
318
+ const commonDir = await git(dir, ['rev-parse', '--git-common-dir'])
717
319
  if (commonDir instanceof Error) return commonDir
718
320
  if (path.isAbsolute(commonDir)) {
719
321
  return commonDir
@@ -732,14 +334,14 @@ async function isAncestor(
732
334
  ref2: string
733
335
  },
734
336
  ): Promise<boolean> {
735
- const result = await git(dir, `merge-base --is-ancestor "${ref1}" "${ref2}"`)
337
+ const result = await git(dir, ['merge-base', '--is-ancestor', ref1, ref2])
736
338
  return !(result instanceof Error)
737
339
  }
738
340
 
739
341
  async function isRebasedOnto(dir: string, target: string): Promise<boolean> {
740
- const mergeBase = await git(dir, `merge-base HEAD "${target}"`)
342
+ const mergeBase = await git(dir, ['merge-base', 'HEAD', target])
741
343
  if (mergeBase instanceof Error) return false
742
- const targetSha = await git(dir, `rev-parse "${target}"`)
344
+ const targetSha = await git(dir, ['rev-parse', target])
743
345
  if (targetSha instanceof Error) return false
744
346
  return mergeBase === targetSha
745
347
  }
@@ -755,8 +357,8 @@ async function isCheckedOutTargetDirty({
755
357
  }: {
756
358
  targetDir: string
757
359
  targetBranch: string
758
- }): Promise<boolean> {
759
- const currentBranch = await git(targetDir, 'symbolic-ref --short HEAD')
360
+ }): Promise<GitCommandError | boolean> {
361
+ const currentBranch = await git(targetDir, ['symbolic-ref', '--short', 'HEAD'])
760
362
  if (currentBranch instanceof Error || currentBranch !== targetBranch) {
761
363
  return false
762
364
  }
@@ -768,7 +370,7 @@ async function isCheckedOutTargetDirty({
768
370
  */
769
371
  async function isRebaseInProgress(dir: string): Promise<boolean> {
770
372
  for (const rebaseDir of ['rebase-merge', 'rebase-apply']) {
771
- const gitPath = await git(dir, `rev-parse --git-path ${rebaseDir}`)
373
+ const gitPath = await git(dir, ['rev-parse', '--git-path', rebaseDir])
772
374
  if (gitPath instanceof Error) continue
773
375
  const resolvedPath = path.isAbsolute(gitPath)
774
376
  ? gitPath
@@ -788,9 +390,32 @@ async function isRebaseInProgress(dir: string): Promise<boolean> {
788
390
  return false
789
391
  }
790
392
 
393
+ async function createSquashCommit({
394
+ worktreeDir,
395
+ target,
396
+ branchName,
397
+ }: {
398
+ worktreeDir: string
399
+ target: string
400
+ branchName: string
401
+ }) {
402
+ const tree = await git(worktreeDir, ['rev-parse', 'HEAD^{tree}'])
403
+ if (tree instanceof Error) return tree
404
+ const parent = await git(worktreeDir, ['rev-parse', `${target}^{commit}`])
405
+ if (parent instanceof Error) return parent
406
+ return git(worktreeDir, [
407
+ 'commit-tree',
408
+ tree,
409
+ '-p',
410
+ parent,
411
+ '-m',
412
+ `Merge worktree ${branchName}`,
413
+ ])
414
+ }
415
+
791
416
  /**
792
- * Merge a worktree branch into the default branch by rebasing all commits
793
- * onto target, then fast-forward pushing. Preserves every worktree commit.
417
+ * Merge a worktree branch into the default branch by rebasing all commits,
418
+ * optionally squashing them, then fast-forward pushing.
794
419
  * Returns MergeWorktreeErrors | MergeSuccess.
795
420
  */
796
421
  export async function mergeWorktree({
@@ -798,6 +423,7 @@ export async function mergeWorktree({
798
423
  mainRepoDir,
799
424
  worktreeName,
800
425
  targetBranch,
426
+ strategy = 'rebase',
801
427
  onProgress,
802
428
  }: {
803
429
  worktreeDir: string
@@ -805,6 +431,7 @@ export async function mergeWorktree({
805
431
  worktreeName: string
806
432
  /** Override the branch to merge into. Defaults to origin/HEAD (or main). */
807
433
  targetBranch?: string
434
+ strategy?: MergeStrategy
808
435
  onProgress?: (message: string) => void
809
436
  }): Promise<MergeWorktreeErrors | MergeSuccess> {
810
437
  const log = (msg: string) => {
@@ -812,20 +439,37 @@ export async function mergeWorktree({
812
439
  onProgress?.(msg)
813
440
  }
814
441
 
442
+ const requestedTarget = targetBranch || (await getDefaultBranch(mainRepoDir))
443
+ const validatedTarget = await validateBranchRef({
444
+ directory: mainRepoDir,
445
+ ref: requestedTarget,
446
+ })
447
+ if (validatedTarget instanceof Error) {
448
+ return new GitCommandError({
449
+ command: `validate merge target ${requestedTarget}`,
450
+ cause: validatedTarget,
451
+ })
452
+ }
453
+ const defaultBranch = validatedTarget
454
+
455
+ // A paused rebase has a detached HEAD, so detect it before creating a temp branch.
456
+ if (await isRebaseInProgress(worktreeDir)) {
457
+ return new RebaseConflictError({ target: defaultBranch })
458
+ }
459
+
815
460
  // Resolve current branch. If detached, create a temp branch.
816
461
  let branchName: string
817
462
  let tempBranch: string | null = null
818
- const branchResult = await git(worktreeDir, 'symbolic-ref --short HEAD')
463
+ const branchResult = await git(worktreeDir, ['symbolic-ref', '--short', 'HEAD'])
819
464
  if (branchResult instanceof Error) {
820
465
  tempBranch = `kimaki-merge-${Date.now()}`
821
- const createResult = await git(worktreeDir, `checkout -b "${tempBranch}"`)
466
+ const createResult = await git(worktreeDir, ['checkout', '-b', tempBranch])
822
467
  if (createResult instanceof Error) return createResult
823
468
  branchName = tempBranch
824
469
  } else {
825
470
  branchName = branchResult || worktreeName
826
471
  }
827
472
 
828
- const defaultBranch = targetBranch || (await getDefaultBranch(mainRepoDir))
829
473
  log(`Merging ${branchName} into ${defaultBranch}`)
830
474
 
831
475
  // Best-effort cleanup of temp branch on error paths
@@ -834,17 +478,18 @@ export async function mergeWorktree({
834
478
  return
835
479
  }
836
480
 
837
- const detachResult = await git(worktreeDir, 'checkout --detach')
481
+ const detachResult = await git(worktreeDir, ['checkout', '--detach'])
838
482
  if (detachResult instanceof Error) {
839
483
  logger.warn(
840
484
  `[MERGE CLEANUP] Failed to detach HEAD before deleting temp branch: ${detachResult.message}`,
841
485
  )
842
486
  }
843
487
 
844
- const deleteTempBranchResult = await git(
845
- worktreeDir,
846
- `branch -D "${tempBranch}"`,
847
- )
488
+ const deleteTempBranchResult = await git(worktreeDir, [
489
+ 'branch',
490
+ '-D',
491
+ tempBranch,
492
+ ])
848
493
  if (deleteTempBranchResult instanceof Error) {
849
494
  logger.warn(
850
495
  `[MERGE CLEANUP] Failed to delete temp branch ${tempBranch}: ${deleteTempBranchResult.message}`,
@@ -852,40 +497,36 @@ export async function mergeWorktree({
852
497
  }
853
498
  }
854
499
 
855
- // ── Step 1: If a rebase is already paused mid-flight, surface it ──
856
- // This happens when the user reruns /merge-worktree while the model is
857
- // still resolving conflicts. With multi-commit rebases, each conflict
858
- // leaves staged conflict markers (isDirty would say yes) AND merge-base
859
- // may already equal target (isRebasedOnto would say yes), so neither
860
- // of those checks is safe to run first. We must detect the in-progress
861
- // rebase explicitly and route back to the AI-resolve flow.
862
- if (await isRebaseInProgress(worktreeDir)) {
863
- return new RebaseConflictError({ target: defaultBranch })
500
+ // ── Step 1: Reject uncommitted changes ──
501
+ const worktreeDirty = await isDirty(worktreeDir)
502
+ if (worktreeDirty instanceof Error) {
503
+ await cleanupTempBranch()
504
+ return worktreeDirty
864
505
  }
865
-
866
- // ── Step 2: Reject uncommitted changes ──
867
- if (await isDirty(worktreeDir)) {
506
+ if (worktreeDirty) {
868
507
  await cleanupTempBranch()
869
508
  return new DirtyWorktreeError()
870
509
  }
871
510
 
872
- // ── Step 3: Rebase worktree commits onto target ──
511
+ // ── Step 2: Rebase worktree commits onto target ──
873
512
  // If already rebased onto target AND no rebase is in progress, skip
874
513
  // rebase entirely. The in-progress check above guarantees the second
875
514
  // half; we keep it implicit here.
876
515
  const alreadyRebased = await isRebasedOnto(worktreeDir, defaultBranch)
877
516
 
878
- const mergeBaseResult = await git(
879
- worktreeDir,
880
- `merge-base HEAD "${defaultBranch}"`,
881
- )
517
+ const mergeBaseResult = await git(worktreeDir, [
518
+ 'merge-base',
519
+ 'HEAD',
520
+ defaultBranch,
521
+ ])
882
522
  const mergeBase =
883
523
  mergeBaseResult instanceof Error ? defaultBranch : mergeBaseResult
884
524
 
885
- const commitCountResult = await git(
886
- worktreeDir,
887
- `rev-list --count "${mergeBase}..HEAD"`,
888
- )
525
+ const commitCountResult = await git(worktreeDir, [
526
+ 'rev-list',
527
+ '--count',
528
+ `${mergeBase}..HEAD`,
529
+ ])
889
530
  if (commitCountResult instanceof Error) {
890
531
  await cleanupTempBranch()
891
532
  return commitCountResult
@@ -904,7 +545,7 @@ export async function mergeWorktree({
904
545
  ? `Rebasing ${commitCount} commits onto ${defaultBranch}...`
905
546
  : `Rebasing onto ${defaultBranch}...`,
906
547
  )
907
- const rebaseResult = await git(worktreeDir, `rebase "${defaultBranch}"`, {
548
+ const rebaseResult = await git(worktreeDir, ['rebase', defaultBranch], {
908
549
  timeout: 60_000,
909
550
  })
910
551
  if (rebaseResult instanceof Error) {
@@ -921,8 +562,24 @@ export async function mergeWorktree({
921
562
  log('Already rebased onto target')
922
563
  }
923
564
 
565
+ // ── Step 3: Optionally create one commit for the complete rebased tree ──
566
+ const mergeRef = strategy === 'squash'
567
+ ? await createSquashCommit({
568
+ worktreeDir,
569
+ target: defaultBranch,
570
+ branchName,
571
+ })
572
+ : 'HEAD'
573
+ if (mergeRef instanceof Error) {
574
+ await cleanupTempBranch()
575
+ return mergeRef
576
+ }
577
+ if (strategy === 'squash') {
578
+ log(`Squashing ${commitCount} commit${commitCount === 1 ? '' : 's'}...`)
579
+ }
580
+
924
581
  // ── Step 4: Fast-forward push via local git push ──
925
- if (!(await isAncestor({ dir: worktreeDir, ref1: defaultBranch, ref2: 'HEAD' }))) {
582
+ if (!(await isAncestor({ dir: worktreeDir, ref1: defaultBranch, ref2: mergeRef }))) {
926
583
  await cleanupTempBranch()
927
584
  return new NotFastForwardError({ target: defaultBranch })
928
585
  }
@@ -931,6 +588,10 @@ export async function mergeWorktree({
931
588
  targetDir: mainRepoDir,
932
589
  targetBranch: defaultBranch,
933
590
  })
591
+ if (targetIsDirty instanceof Error) {
592
+ await cleanupTempBranch()
593
+ return targetIsDirty
594
+ }
934
595
  if (targetIsDirty) {
935
596
  await cleanupTempBranch()
936
597
  return new TargetDirtyWorktreeError({ target: defaultBranch })
@@ -943,18 +604,19 @@ export async function mergeWorktree({
943
604
  }
944
605
 
945
606
  log(`Pushing to ${defaultBranch}...`)
946
- const pushResult = await git(
947
- worktreeDir,
948
- `push --receive-pack="git -c receive.denyCurrentBranch=updateInstead receive-pack" "${gitCommonDir}" "HEAD:${defaultBranch}"`,
949
- { timeout: 30_000 },
950
- )
607
+ const pushResult = await git(worktreeDir, [
608
+ 'push',
609
+ '--receive-pack=git -c receive.denyCurrentBranch=updateInstead receive-pack',
610
+ gitCommonDir,
611
+ `${mergeRef}:${defaultBranch}`,
612
+ ], { timeout: 30_000 })
951
613
  if (pushResult instanceof Error) {
952
614
  await cleanupTempBranch()
953
615
  return new PushError({ target: defaultBranch, cause: pushResult })
954
616
  }
955
617
 
956
618
  // Get short SHA for display
957
- const shortSha = await git(worktreeDir, 'rev-parse --short HEAD')
619
+ const shortSha = await git(worktreeDir, ['rev-parse', '--short', mergeRef])
958
620
  if (shortSha instanceof Error) {
959
621
  // Push succeeded but can't get SHA -- non-fatal, use placeholder
960
622
  logger.warn('Failed to get short SHA after push')
@@ -962,32 +624,24 @@ export async function mergeWorktree({
962
624
 
963
625
  // ── Step 5: Clean up -- detach HEAD and delete branch ──
964
626
  log('Cleaning up worktree...')
965
- const detachResult = await git(worktreeDir, `checkout --detach "${defaultBranch}"`)
627
+ const detachResult = await git(worktreeDir, [
628
+ 'checkout',
629
+ '--detach',
630
+ defaultBranch,
631
+ ])
966
632
  if (detachResult instanceof Error) {
967
633
  logger.warn(
968
634
  `[MERGE CLEANUP] Failed to detach worktree HEAD after push: ${detachResult.message}`,
969
635
  )
970
636
  }
971
637
 
972
- const deleteBranchResult = await git(worktreeDir, `branch -D "${branchName}"`)
638
+ const deleteBranchResult = await git(worktreeDir, ['branch', '-D', branchName])
973
639
  if (deleteBranchResult instanceof Error) {
974
640
  logger.warn(
975
641
  `[MERGE CLEANUP] Failed to delete branch ${branchName}: ${deleteBranchResult.message}`,
976
642
  )
977
643
  }
978
644
 
979
- if (branchName !== worktreeName && worktreeName) {
980
- const deleteWorktreeBranchResult = await git(
981
- worktreeDir,
982
- `branch -D "${worktreeName}"`,
983
- )
984
- if (deleteWorktreeBranchResult instanceof Error) {
985
- logger.warn(
986
- `[MERGE CLEANUP] Failed to delete worktree branch ${worktreeName}: ${deleteWorktreeBranchResult.message}`,
987
- )
988
- }
989
- }
990
-
991
645
  return {
992
646
  defaultBranch,
993
647
  branchName: worktreeName || branchName,
@@ -1129,7 +783,7 @@ export async function validateBranchRef({
1129
783
  directory: string
1130
784
  ref: string
1131
785
  }): Promise<string | Error> {
1132
- const result = await git(directory, `check-ref-format --branch ${JSON.stringify(ref)}`)
786
+ const result = await git(directory, ['check-ref-format', '--branch', ref])
1133
787
  if (result instanceof Error) return new Error(`Invalid branch name: ${ref}`)
1134
788
  return result
1135
789
  }