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/dist/worktrees.js CHANGED
@@ -1,387 +1,14 @@
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
  import crypto from 'node:crypto';
5
4
  import fs from 'node:fs';
6
5
  import path from 'node:path';
7
6
  import { getDataDir } from './config.js';
8
7
  import { execAsync } from './exec-async.js';
9
- import { createWorktreeCore } from './git-worktree-core.js';
10
8
  import { createLogger, LogPrefix } from './logger.js';
11
9
  export { execAsync } from './exec-async.js';
12
10
  const SUBMODULE_INIT_TIMEOUT_MS = 20 * 60_000;
13
- const INSTALL_TIMEOUT_MS = 60_000;
14
11
  const logger = createLogger(LogPrefix.WORKTREE);
15
- const LOCKFILE_TO_INSTALL_COMMAND = [
16
- ['pnpm-lock.yaml', 'pnpm install'],
17
- ['bun.lock', 'bun install'],
18
- ['bun.lockb', 'bun install'],
19
- ['yarn.lock', 'yarn install'],
20
- ['package-lock.json', 'npm install'],
21
- ];
22
- function detectInstallCommand(directory) {
23
- for (const [lockfile, command] of LOCKFILE_TO_INSTALL_COMMAND) {
24
- if (fs.existsSync(path.join(directory, lockfile))) {
25
- return command;
26
- }
27
- }
28
- return null;
29
- }
30
- /**
31
- * Run the detected package manager install in a worktree directory.
32
- * Non-fatal: returns Error on failure/timeout so callers can log and continue.
33
- * The 60s timeout kills the process if install hangs.
34
- */
35
- export async function runDependencyInstall({ directory, }) {
36
- const installCommand = detectInstallCommand(directory);
37
- if (!installCommand) {
38
- return;
39
- }
40
- logger.log(`Running "${installCommand}" in ${directory} (timeout=${INSTALL_TIMEOUT_MS}ms)`);
41
- try {
42
- await execAsync(installCommand, {
43
- cwd: directory,
44
- timeout: INSTALL_TIMEOUT_MS,
45
- });
46
- logger.log(`Dependencies installed in ${directory}`);
47
- }
48
- catch (e) {
49
- return new Error(`Install failed: ${formatCommandError(e)}`, { cause: e });
50
- }
51
- }
52
- function formatCommandError(error) {
53
- if (!(error instanceof Error)) {
54
- return String(error);
55
- }
56
- const commandError = error;
57
- const details = [commandError.message];
58
- if (commandError.cmd) {
59
- details.push(`cmd=${commandError.cmd}`);
60
- }
61
- if (commandError.signal) {
62
- details.push(`signal=${commandError.signal}`);
63
- }
64
- if (commandError.killed) {
65
- details.push('process=killed');
66
- }
67
- if (commandError.stderr?.trim()) {
68
- details.push(`stderr=${commandError.stderr.trim()}`);
69
- }
70
- if (commandError.stdout?.trim()) {
71
- details.push(`stdout=${commandError.stdout.trim()}`);
72
- }
73
- return details.join(' | ');
74
- }
75
- export function parseGitmodulesFileContent(gitmodulesContent) {
76
- const lines = gitmodulesContent.split('\n');
77
- const configs = [];
78
- let currentName = null;
79
- let currentPath = null;
80
- let currentUrl = null;
81
- const flushCurrent = () => {
82
- if (!currentName) {
83
- return;
84
- }
85
- if (!currentPath) {
86
- return new Error(`Submodule ${currentName} is missing path in .gitmodules`);
87
- }
88
- configs.push({
89
- name: currentName,
90
- path: currentPath,
91
- url: currentUrl,
92
- });
93
- };
94
- for (const rawLine of lines) {
95
- const line = rawLine.trim();
96
- if (!line || line.startsWith('#') || line.startsWith(';')) {
97
- continue;
98
- }
99
- const sectionMatch = line.match(/^\[submodule\s+"([^"]+)"\]$/);
100
- if (sectionMatch?.[1]) {
101
- const flushError = flushCurrent();
102
- if (flushError instanceof Error)
103
- return flushError;
104
- currentName = sectionMatch[1];
105
- currentPath = null;
106
- currentUrl = null;
107
- continue;
108
- }
109
- if (!currentName) {
110
- continue;
111
- }
112
- const keyValueMatch = line.match(/^([^=\s]+)\s*=\s*(.*)$/);
113
- const key = keyValueMatch?.[1];
114
- const value = keyValueMatch?.[2];
115
- if (!key || value === undefined) {
116
- continue;
117
- }
118
- if (key === 'path') {
119
- currentPath = value;
120
- continue;
121
- }
122
- if (key === 'url') {
123
- currentUrl = value;
124
- }
125
- }
126
- const flushError = flushCurrent();
127
- if (flushError instanceof Error)
128
- return flushError;
129
- return configs;
130
- }
131
- async function readSubmoduleConfigs(directory) {
132
- const gitmodulesPath = path.join(directory, '.gitmodules');
133
- const gitmodulesExists = await fs.promises
134
- .access(gitmodulesPath)
135
- .then(() => {
136
- return true;
137
- })
138
- .catch(() => {
139
- return false;
140
- });
141
- if (!gitmodulesExists) {
142
- return [];
143
- }
144
- const gitmodulesContent = await fs.promises.readFile(gitmodulesPath, 'utf-8')
145
- .catch((e) => new Error(`Failed to read ${gitmodulesPath}`, {
146
- cause: e,
147
- }));
148
- if (gitmodulesContent instanceof Error)
149
- return gitmodulesContent;
150
- const parsed = parseGitmodulesFileContent(gitmodulesContent);
151
- if (parsed instanceof Error) {
152
- return new Error(`Failed to parse ${gitmodulesPath}: ${parsed.message}`, {
153
- cause: parsed,
154
- });
155
- }
156
- return parsed;
157
- }
158
- export function buildSubmoduleReferencePlan({ sourceDirectory, submodulePaths, existingSourceSubmoduleDirectories, }) {
159
- return submodulePaths.map((submodulePath) => {
160
- const sourceSubmoduleDirectory = path.resolve(sourceDirectory, submodulePath);
161
- if (existingSourceSubmoduleDirectories.has(sourceSubmoduleDirectory)) {
162
- return {
163
- path: submodulePath,
164
- referenceDirectory: sourceSubmoduleDirectory,
165
- };
166
- }
167
- return {
168
- path: submodulePath,
169
- referenceDirectory: null,
170
- };
171
- });
172
- }
173
- function buildGitCommand(args) {
174
- const quotedArgs = args.map((arg) => {
175
- return JSON.stringify(arg);
176
- });
177
- return `git ${quotedArgs.join(' ')}`;
178
- }
179
- export function buildSubmoduleUpdateCommandArgs({ path: submodulePath, referenceDirectory, }) {
180
- if (referenceDirectory) {
181
- return [
182
- '-c',
183
- 'protocol.file.allow=always',
184
- 'submodule',
185
- 'update',
186
- '--init',
187
- '--recursive',
188
- '--reference',
189
- referenceDirectory,
190
- '--',
191
- submodulePath,
192
- ];
193
- }
194
- return [
195
- '-c',
196
- 'protocol.file.allow=always',
197
- 'submodule',
198
- 'update',
199
- '--init',
200
- '--recursive',
201
- '--',
202
- submodulePath,
203
- ];
204
- }
205
- async function hasSubmoduleGitMetadata(directory) {
206
- const gitPath = path.join(directory, '.git');
207
- return fs.promises
208
- .access(gitPath)
209
- .then(() => {
210
- return true;
211
- })
212
- .catch(() => {
213
- return false;
214
- });
215
- }
216
- async function initializeSubmodulesWithLocalReferences({ sourceDirectory, worktreeDirectory, }) {
217
- const submoduleConfigs = await readSubmoduleConfigs(worktreeDirectory);
218
- if (submoduleConfigs instanceof Error)
219
- return submoduleConfigs;
220
- if (submoduleConfigs.length === 0) {
221
- return;
222
- }
223
- const sourceDirectories = submoduleConfigs.map(({ path: submodulePath }) => {
224
- return path.resolve(sourceDirectory, submodulePath);
225
- });
226
- const sourceDirectoryChecks = await Promise.all(sourceDirectories.map(async (sourceSubmoduleDirectory) => {
227
- const exists = await hasSubmoduleGitMetadata(sourceSubmoduleDirectory);
228
- return { sourceSubmoduleDirectory, exists };
229
- }));
230
- const existingSourceSubmoduleDirectories = new Set(sourceDirectoryChecks
231
- .filter(({ exists }) => {
232
- return exists;
233
- })
234
- .map(({ sourceSubmoduleDirectory }) => {
235
- return sourceSubmoduleDirectory;
236
- }));
237
- const submodulePlan = buildSubmoduleReferencePlan({
238
- sourceDirectory,
239
- submodulePaths: submoduleConfigs.map(({ path: submodulePath }) => {
240
- return submodulePath;
241
- }),
242
- existingSourceSubmoduleDirectories,
243
- });
244
- for (const planItem of submodulePlan) {
245
- const commandArgs = buildSubmoduleUpdateCommandArgs(planItem);
246
- const command = buildGitCommand(commandArgs);
247
- const result = await execAsync(command, {
248
- cwd: worktreeDirectory,
249
- timeout: SUBMODULE_INIT_TIMEOUT_MS,
250
- }).catch((e) => new Error(`git ${commandArgs.join(' ')} failed for ${planItem.path}: ${formatCommandError(e)}`, { cause: e }));
251
- if (result instanceof Error) {
252
- // Non-fatal: broken .gitmodules entries (e.g. path listed but not in tree)
253
- // should not block worktree creation. Log and continue with remaining submodules.
254
- logger.warn(`Skipping submodule ${planItem.path}: ${result.message}`);
255
- }
256
- }
257
- }
258
- /**
259
- * Get submodule paths from .gitmodules file.
260
- * Returns empty array if no submodules or on error.
261
- */
262
- async function getSubmodulePaths(directory) {
263
- const submoduleConfigs = await readSubmoduleConfigs(directory);
264
- if (submoduleConfigs instanceof Error) {
265
- logger.warn(`Failed reading submodules from ${directory}: ${submoduleConfigs.message}`);
266
- return [];
267
- }
268
- return submoduleConfigs.map(({ path: submodulePath }) => {
269
- return submodulePath;
270
- });
271
- }
272
- /**
273
- * Remove broken submodule stubs created by git worktree.
274
- * When git worktree add runs on a repo with submodules, it creates submodule
275
- * directories with .git files pointing to ../.git/worktrees/<name>/modules/<submodule>
276
- * but that path only has a config file, missing HEAD/objects/refs.
277
- * This causes git commands to fail with "fatal: not a git repository".
278
- */
279
- async function removeBrokenSubmoduleStubs(directory) {
280
- const submodulePaths = await getSubmodulePaths(directory);
281
- for (const subPath of submodulePaths) {
282
- const fullPath = path.join(directory, subPath);
283
- const gitFile = path.join(fullPath, '.git');
284
- try {
285
- const stat = await fs.promises.stat(gitFile);
286
- if (!stat.isFile()) {
287
- continue;
288
- }
289
- // Read .git file to get gitdir path
290
- const content = await fs.promises.readFile(gitFile, 'utf-8');
291
- const match = content.match(/^gitdir:\s*(.+)$/m);
292
- if (!match || !match[1]) {
293
- continue;
294
- }
295
- const gitdir = path.resolve(fullPath, match[1].trim());
296
- const headFile = path.join(gitdir, 'HEAD');
297
- // If HEAD doesn't exist, this is a broken stub
298
- const headExists = await fs.promises
299
- .access(headFile)
300
- .then(() => {
301
- return true;
302
- })
303
- .catch(() => {
304
- return false;
305
- });
306
- if (!headExists) {
307
- logger.log(`Removing broken submodule stub: ${subPath}`);
308
- await fs.promises.rm(fullPath, { recursive: true, force: true });
309
- }
310
- }
311
- catch {
312
- // Directory doesn't exist or other error, skip
313
- }
314
- }
315
- }
316
- function parseSubmoduleGitdir(gitFileContent) {
317
- const match = gitFileContent.match(/^gitdir:\s*(.+)$/m);
318
- const gitdir = match?.[1]?.trim();
319
- if (!gitdir) {
320
- return new Error('Missing gitdir pointer');
321
- }
322
- return gitdir;
323
- }
324
- async function validateSubmodulePointers(directory) {
325
- const submodulePaths = await getSubmodulePaths(directory);
326
- if (submodulePaths.length === 0) {
327
- return;
328
- }
329
- const validationIssues = [];
330
- await Promise.all(submodulePaths.map(async (submodulePath) => {
331
- const submoduleDir = path.join(directory, submodulePath);
332
- const submoduleGitFile = path.join(submoduleDir, '.git');
333
- const gitFileExists = await fs.promises
334
- .access(submoduleGitFile)
335
- .then(() => {
336
- return true;
337
- })
338
- .catch(() => {
339
- return false;
340
- });
341
- if (!gitFileExists) {
342
- validationIssues.push(`${submodulePath}: missing .git file`);
343
- return;
344
- }
345
- const gitFileContentResult = await fs.promises.readFile(submoduleGitFile, 'utf-8')
346
- .catch((e) => new Error(`Failed to read .git for ${submodulePath}`, { cause: e }));
347
- if (gitFileContentResult instanceof Error) {
348
- validationIssues.push(`${submodulePath}: ${gitFileContentResult.message}`);
349
- return;
350
- }
351
- const parsedGitdir = parseSubmoduleGitdir(gitFileContentResult);
352
- if (parsedGitdir instanceof Error) {
353
- validationIssues.push(`${submodulePath}: ${parsedGitdir.message}`);
354
- return;
355
- }
356
- const resolvedGitdir = path.resolve(submoduleDir, parsedGitdir);
357
- const headPath = path.join(resolvedGitdir, 'HEAD');
358
- const headExists = await fs.promises
359
- .access(headPath)
360
- .then(() => {
361
- return true;
362
- })
363
- .catch(() => {
364
- return false;
365
- });
366
- if (!headExists) {
367
- validationIssues.push(`${submodulePath}: gitdir missing HEAD (${resolvedGitdir})`);
368
- }
369
- }));
370
- const submoduleStatusResult = await execAsync('git submodule status --recursive', {
371
- cwd: directory,
372
- timeout: SUBMODULE_INIT_TIMEOUT_MS,
373
- }).catch((e) => new Error('git submodule status --recursive failed', { cause: e }));
374
- if (submoduleStatusResult instanceof Error) {
375
- validationIssues.push(submoduleStatusResult.message);
376
- }
377
- if (validationIssues.length === 0) {
378
- return;
379
- }
380
- return new Error(`Submodule validation failed: ${validationIssues.join('; ')}`);
381
- }
382
- async function resolveDefaultWorktreeTarget(directory) {
383
- return 'HEAD';
384
- }
385
12
  /**
386
13
  * Build the on-disk directory for a managed worktree.
387
14
  *
@@ -409,34 +36,15 @@ export function getManagedWorktreeDirectory({ directory, name, }) {
409
36
  .replaceAll('/', '-');
410
37
  return path.join(getDataDir(), 'worktrees', projectHash, withoutPrefix);
411
38
  }
412
- /**
413
- * Create a worktree using git and initialize git submodules.
414
- * This wrapper ensures submodules are properly set up in new worktrees.
415
- */
416
- export async function createWorktreeWithSubmodules({ directory, name, baseBranch, onProgress, }) {
417
- const worktreeDir = getManagedWorktreeDirectory({ directory, name });
418
- // Delegate to the plugin-safe core module, passing our logger as callbacks.
419
- return createWorktreeCore({
420
- projectDirectory: directory,
421
- targetDirectory: worktreeDir,
422
- branchName: name,
423
- baseBranch,
424
- onProgress,
425
- log: {
426
- info: (msg) => logger.log(msg),
427
- warn: (msg) => logger.warn(msg),
428
- error: (msg) => logger.error(msg),
429
- },
430
- });
431
- }
432
39
  // ─── Worktree merge ──────────────────────────────────────────────────────────
433
- // Merge pipeline (preserves all worktree commits, no squash):
40
+ // Merge pipeline:
434
41
  // 1. Reject if uncommitted changes exist
435
42
  // 2. Rebase worktree commits onto target (default branch)
436
- // 3. Fast-forward push to target via local git push
437
- // 4. Switch to detached HEAD, delete branch
43
+ // 3. Optionally collapse the rebased tree into one commit
44
+ // 4. Fast-forward push to target via local git push
45
+ // 5. Switch to detached HEAD, delete branch
438
46
  //
439
- // Uses `git push <git-common-dir> HEAD:<target>` with
47
+ // Uses `git push <git-common-dir> <merge-ref>:<target>` with
440
48
  // `receive.denyCurrentBranch=updateInstead` to fast-forward the target
441
49
  // WITHOUT checking it out in the main repo.
442
50
  //
@@ -451,13 +59,128 @@ export async function createWorktreeWithSubmodules({ directory, name, baseBranch
451
59
  // - GitCommandError → catch-all for unexpected git failures
452
60
  import { DirtyWorktreeError, NothingToMergeError, RebaseConflictError, RebaseError, NotFastForwardError, TargetDirtyWorktreeError, PushError, GitCommandError, } from './errors.js';
453
61
  export async function git(dir, args, opts) {
454
- const result = await execAsync(`git -C "${dir}" ${args}`, opts ? { timeout: opts.timeout } : undefined).catch((e) => new GitCommandError({ command: args, cause: e }));
62
+ const command = Array.isArray(args)
63
+ ? { command: 'git', args: ['-C', dir, ...args] }
64
+ : `git -C "${dir}" ${args}`;
65
+ const commandLabel = Array.isArray(args)
66
+ ? ['git', '-C', dir, ...args].join(' ')
67
+ : `git -C "${dir}" ${args}`;
68
+ const result = await execAsync(command, opts ? { timeout: opts.timeout } : undefined).catch((e) => new GitCommandError({ command: commandLabel, cause: e }));
455
69
  if (result instanceof Error)
456
70
  return result;
457
71
  return result.stdout.trim();
458
72
  }
73
+ function walkErrorCauseChain(error) {
74
+ const seen = new Set();
75
+ const chain = [];
76
+ let current = error;
77
+ while (current instanceof Error && !seen.has(current)) {
78
+ seen.add(current);
79
+ chain.push(current);
80
+ current = current.cause;
81
+ }
82
+ return chain;
83
+ }
84
+ function execTextField(error, field) {
85
+ const value = Reflect.get(error, field);
86
+ if (typeof value === 'string')
87
+ return value.trim();
88
+ if (value instanceof Uint8Array)
89
+ return new TextDecoder().decode(value).trim();
90
+ return '';
91
+ }
92
+ function execExitCode(error) {
93
+ const code = Reflect.get(error, 'code');
94
+ if (typeof code === 'string' || typeof code === 'number')
95
+ return String(code);
96
+ return undefined;
97
+ }
98
+ export function extractGitExecOutput(error) {
99
+ const chain = walkErrorCauseChain(error);
100
+ const gitErr = chain.find((item) => item instanceof GitCommandError);
101
+ let stderr = '';
102
+ let stdout = '';
103
+ let code;
104
+ for (const item of chain) {
105
+ if (!stderr)
106
+ stderr = execTextField(item, 'stderr');
107
+ if (!stdout)
108
+ stdout = execTextField(item, 'stdout');
109
+ if (code === undefined)
110
+ code = execExitCode(item);
111
+ }
112
+ const command = gitErr instanceof GitCommandError ? gitErr.command : undefined;
113
+ return {
114
+ command: typeof command === 'string' ? command : undefined,
115
+ stderr,
116
+ stdout,
117
+ code,
118
+ };
119
+ }
120
+ function isRedundantCauseMessage({ message, headline, command, output, }) {
121
+ if (!message)
122
+ return true;
123
+ if (message === headline)
124
+ return true;
125
+ if (command && message === `Git command failed: ${command}`)
126
+ return true;
127
+ if (message.startsWith('Command failed:'))
128
+ return true;
129
+ if (output && message.includes(output))
130
+ return true;
131
+ return false;
132
+ }
133
+ function truncateFormattedError(text, maxLength) {
134
+ if (text.length <= maxLength)
135
+ return text;
136
+ const ellipsis = '\n… [truncated]';
137
+ return text.slice(0, Math.max(0, maxLength - ellipsis.length)) + ellipsis;
138
+ }
139
+ export function formatMergeWorktreeError(error, opts) {
140
+ const maxLength = opts?.maxLength ?? 1900;
141
+ const output = extractGitExecOutput(error);
142
+ const lines = [`Merge failed: ${error.message}`];
143
+ if (error instanceof PushError) {
144
+ lines.push('Worktree rebase succeeded. Local branch was not updated. This is not a push to origin.');
145
+ }
146
+ const extraCauses = walkErrorCauseChain(error)
147
+ .slice(1)
148
+ .map((item) => item.message)
149
+ .filter((message) => {
150
+ return !isRedundantCauseMessage({
151
+ message,
152
+ headline: error.message,
153
+ command: output.command,
154
+ output: [output.stderr, output.stdout].filter(Boolean).join('\n'),
155
+ });
156
+ });
157
+ const blockLines = [];
158
+ if (output.command)
159
+ blockLines.push(output.command);
160
+ if (output.code)
161
+ blockLines.push(`exit ${output.code}`);
162
+ if (output.stderr) {
163
+ if (blockLines.length)
164
+ blockLines.push('');
165
+ blockLines.push(output.stderr);
166
+ }
167
+ if (output.stdout && output.stdout !== output.stderr) {
168
+ if (blockLines.length)
169
+ blockLines.push('');
170
+ blockLines.push(output.stdout);
171
+ }
172
+ for (const message of extraCauses) {
173
+ if (blockLines.length)
174
+ blockLines.push('');
175
+ blockLines.push(message);
176
+ }
177
+ if (blockLines.length > 0) {
178
+ lines.push('', '```', ...blockLines, '```');
179
+ }
180
+ return truncateFormattedError(lines.join('\n'), maxLength);
181
+ }
459
182
  export async function getDefaultBranch(repoDir, opts) {
460
- const ref = await git(repoDir, 'symbolic-ref refs/remotes/origin/HEAD', opts);
183
+ const ref = await git(repoDir, ['symbolic-ref', 'refs/remotes/origin/HEAD'], opts);
461
184
  if (ref instanceof Error)
462
185
  return 'main';
463
186
  return ref.replace(/^refs\/remotes\/origin\//, '') || 'main';
@@ -496,9 +219,9 @@ export async function deleteWorktree({ projectDirectory, worktreeDirectory, work
496
219
  }
497
220
  }
498
221
  export async function isDirty(dir, opts) {
499
- const status = await git(dir, 'status --porcelain', opts);
222
+ const status = await git(dir, ['status', '--porcelain'], opts);
500
223
  if (status instanceof Error)
501
- return false;
224
+ return status;
502
225
  return status.length > 0;
503
226
  }
504
227
  export async function isGitRepositoryRoot(directory) {
@@ -508,7 +231,7 @@ export async function isGitRepositoryRoot(directory) {
508
231
  return path.resolve(topLevel) === path.resolve(directory);
509
232
  }
510
233
  async function getGitCommonDir(dir) {
511
- const commonDir = await git(dir, 'rev-parse --git-common-dir');
234
+ const commonDir = await git(dir, ['rev-parse', '--git-common-dir']);
512
235
  if (commonDir instanceof Error)
513
236
  return commonDir;
514
237
  if (path.isAbsolute(commonDir)) {
@@ -517,14 +240,14 @@ async function getGitCommonDir(dir) {
517
240
  return path.resolve(dir, commonDir);
518
241
  }
519
242
  async function isAncestor({ dir, ref1, ref2, }) {
520
- const result = await git(dir, `merge-base --is-ancestor "${ref1}" "${ref2}"`);
243
+ const result = await git(dir, ['merge-base', '--is-ancestor', ref1, ref2]);
521
244
  return !(result instanceof Error);
522
245
  }
523
246
  async function isRebasedOnto(dir, target) {
524
- const mergeBase = await git(dir, `merge-base HEAD "${target}"`);
247
+ const mergeBase = await git(dir, ['merge-base', 'HEAD', target]);
525
248
  if (mergeBase instanceof Error)
526
249
  return false;
527
- const targetSha = await git(dir, `rev-parse "${target}"`);
250
+ const targetSha = await git(dir, ['rev-parse', target]);
528
251
  if (targetSha instanceof Error)
529
252
  return false;
530
253
  return mergeBase === targetSha;
@@ -535,7 +258,7 @@ async function isRebasedOnto(dir, target) {
535
258
  * even if the dirty files do not overlap with the incoming commits.
536
259
  */
537
260
  async function isCheckedOutTargetDirty({ targetDir, targetBranch, }) {
538
- const currentBranch = await git(targetDir, 'symbolic-ref --short HEAD');
261
+ const currentBranch = await git(targetDir, ['symbolic-ref', '--short', 'HEAD']);
539
262
  if (currentBranch instanceof Error || currentBranch !== targetBranch) {
540
263
  return false;
541
264
  }
@@ -546,7 +269,7 @@ async function isCheckedOutTargetDirty({ targetDir, targetBranch, }) {
546
269
  */
547
270
  async function isRebaseInProgress(dir) {
548
271
  for (const rebaseDir of ['rebase-merge', 'rebase-apply']) {
549
- const gitPath = await git(dir, `rev-parse --git-path ${rebaseDir}`);
272
+ const gitPath = await git(dir, ['rev-parse', '--git-path', rebaseDir]);
550
273
  if (gitPath instanceof Error)
551
274
  continue;
552
275
  const resolvedPath = path.isAbsolute(gitPath)
@@ -566,23 +289,55 @@ async function isRebaseInProgress(dir) {
566
289
  }
567
290
  return false;
568
291
  }
292
+ async function createSquashCommit({ worktreeDir, target, branchName, }) {
293
+ const tree = await git(worktreeDir, ['rev-parse', 'HEAD^{tree}']);
294
+ if (tree instanceof Error)
295
+ return tree;
296
+ const parent = await git(worktreeDir, ['rev-parse', `${target}^{commit}`]);
297
+ if (parent instanceof Error)
298
+ return parent;
299
+ return git(worktreeDir, [
300
+ 'commit-tree',
301
+ tree,
302
+ '-p',
303
+ parent,
304
+ '-m',
305
+ `Merge worktree ${branchName}`,
306
+ ]);
307
+ }
569
308
  /**
570
- * Merge a worktree branch into the default branch by rebasing all commits
571
- * onto target, then fast-forward pushing. Preserves every worktree commit.
309
+ * Merge a worktree branch into the default branch by rebasing all commits,
310
+ * optionally squashing them, then fast-forward pushing.
572
311
  * Returns MergeWorktreeErrors | MergeSuccess.
573
312
  */
574
- export async function mergeWorktree({ worktreeDir, mainRepoDir, worktreeName, targetBranch, onProgress, }) {
313
+ export async function mergeWorktree({ worktreeDir, mainRepoDir, worktreeName, targetBranch, strategy = 'rebase', onProgress, }) {
575
314
  const log = (msg) => {
576
315
  logger.log(msg);
577
316
  onProgress?.(msg);
578
317
  };
318
+ const requestedTarget = targetBranch || (await getDefaultBranch(mainRepoDir));
319
+ const validatedTarget = await validateBranchRef({
320
+ directory: mainRepoDir,
321
+ ref: requestedTarget,
322
+ });
323
+ if (validatedTarget instanceof Error) {
324
+ return new GitCommandError({
325
+ command: `validate merge target ${requestedTarget}`,
326
+ cause: validatedTarget,
327
+ });
328
+ }
329
+ const defaultBranch = validatedTarget;
330
+ // A paused rebase has a detached HEAD, so detect it before creating a temp branch.
331
+ if (await isRebaseInProgress(worktreeDir)) {
332
+ return new RebaseConflictError({ target: defaultBranch });
333
+ }
579
334
  // Resolve current branch. If detached, create a temp branch.
580
335
  let branchName;
581
336
  let tempBranch = null;
582
- const branchResult = await git(worktreeDir, 'symbolic-ref --short HEAD');
337
+ const branchResult = await git(worktreeDir, ['symbolic-ref', '--short', 'HEAD']);
583
338
  if (branchResult instanceof Error) {
584
339
  tempBranch = `kimaki-merge-${Date.now()}`;
585
- const createResult = await git(worktreeDir, `checkout -b "${tempBranch}"`);
340
+ const createResult = await git(worktreeDir, ['checkout', '-b', tempBranch]);
586
341
  if (createResult instanceof Error)
587
342
  return createResult;
588
343
  branchName = tempBranch;
@@ -590,45 +345,51 @@ export async function mergeWorktree({ worktreeDir, mainRepoDir, worktreeName, ta
590
345
  else {
591
346
  branchName = branchResult || worktreeName;
592
347
  }
593
- const defaultBranch = targetBranch || (await getDefaultBranch(mainRepoDir));
594
348
  log(`Merging ${branchName} into ${defaultBranch}`);
595
349
  // Best-effort cleanup of temp branch on error paths
596
350
  const cleanupTempBranch = async () => {
597
351
  if (!tempBranch) {
598
352
  return;
599
353
  }
600
- const detachResult = await git(worktreeDir, 'checkout --detach');
354
+ const detachResult = await git(worktreeDir, ['checkout', '--detach']);
601
355
  if (detachResult instanceof Error) {
602
356
  logger.warn(`[MERGE CLEANUP] Failed to detach HEAD before deleting temp branch: ${detachResult.message}`);
603
357
  }
604
- const deleteTempBranchResult = await git(worktreeDir, `branch -D "${tempBranch}"`);
358
+ const deleteTempBranchResult = await git(worktreeDir, [
359
+ 'branch',
360
+ '-D',
361
+ tempBranch,
362
+ ]);
605
363
  if (deleteTempBranchResult instanceof Error) {
606
364
  logger.warn(`[MERGE CLEANUP] Failed to delete temp branch ${tempBranch}: ${deleteTempBranchResult.message}`);
607
365
  }
608
366
  };
609
- // ── Step 1: If a rebase is already paused mid-flight, surface it ──
610
- // This happens when the user reruns /merge-worktree while the model is
611
- // still resolving conflicts. With multi-commit rebases, each conflict
612
- // leaves staged conflict markers (isDirty would say yes) AND merge-base
613
- // may already equal target (isRebasedOnto would say yes), so neither
614
- // of those checks is safe to run first. We must detect the in-progress
615
- // rebase explicitly and route back to the AI-resolve flow.
616
- if (await isRebaseInProgress(worktreeDir)) {
617
- return new RebaseConflictError({ target: defaultBranch });
367
+ // ── Step 1: Reject uncommitted changes ──
368
+ const worktreeDirty = await isDirty(worktreeDir);
369
+ if (worktreeDirty instanceof Error) {
370
+ await cleanupTempBranch();
371
+ return worktreeDirty;
618
372
  }
619
- // ── Step 2: Reject uncommitted changes ──
620
- if (await isDirty(worktreeDir)) {
373
+ if (worktreeDirty) {
621
374
  await cleanupTempBranch();
622
375
  return new DirtyWorktreeError();
623
376
  }
624
- // ── Step 3: Rebase worktree commits onto target ──
377
+ // ── Step 2: Rebase worktree commits onto target ──
625
378
  // If already rebased onto target AND no rebase is in progress, skip
626
379
  // rebase entirely. The in-progress check above guarantees the second
627
380
  // half; we keep it implicit here.
628
381
  const alreadyRebased = await isRebasedOnto(worktreeDir, defaultBranch);
629
- const mergeBaseResult = await git(worktreeDir, `merge-base HEAD "${defaultBranch}"`);
382
+ const mergeBaseResult = await git(worktreeDir, [
383
+ 'merge-base',
384
+ 'HEAD',
385
+ defaultBranch,
386
+ ]);
630
387
  const mergeBase = mergeBaseResult instanceof Error ? defaultBranch : mergeBaseResult;
631
- const commitCountResult = await git(worktreeDir, `rev-list --count "${mergeBase}..HEAD"`);
388
+ const commitCountResult = await git(worktreeDir, [
389
+ 'rev-list',
390
+ '--count',
391
+ `${mergeBase}..HEAD`,
392
+ ]);
632
393
  if (commitCountResult instanceof Error) {
633
394
  await cleanupTempBranch();
634
395
  return commitCountResult;
@@ -643,7 +404,7 @@ export async function mergeWorktree({ worktreeDir, mainRepoDir, worktreeName, ta
643
404
  log(commitCount > 1
644
405
  ? `Rebasing ${commitCount} commits onto ${defaultBranch}...`
645
406
  : `Rebasing onto ${defaultBranch}...`);
646
- const rebaseResult = await git(worktreeDir, `rebase "${defaultBranch}"`, {
407
+ const rebaseResult = await git(worktreeDir, ['rebase', defaultBranch], {
647
408
  timeout: 60_000,
648
409
  });
649
410
  if (rebaseResult instanceof Error) {
@@ -660,8 +421,23 @@ export async function mergeWorktree({ worktreeDir, mainRepoDir, worktreeName, ta
660
421
  else {
661
422
  log('Already rebased onto target');
662
423
  }
424
+ // ── Step 3: Optionally create one commit for the complete rebased tree ──
425
+ const mergeRef = strategy === 'squash'
426
+ ? await createSquashCommit({
427
+ worktreeDir,
428
+ target: defaultBranch,
429
+ branchName,
430
+ })
431
+ : 'HEAD';
432
+ if (mergeRef instanceof Error) {
433
+ await cleanupTempBranch();
434
+ return mergeRef;
435
+ }
436
+ if (strategy === 'squash') {
437
+ log(`Squashing ${commitCount} commit${commitCount === 1 ? '' : 's'}...`);
438
+ }
663
439
  // ── Step 4: Fast-forward push via local git push ──
664
- if (!(await isAncestor({ dir: worktreeDir, ref1: defaultBranch, ref2: 'HEAD' }))) {
440
+ if (!(await isAncestor({ dir: worktreeDir, ref1: defaultBranch, ref2: mergeRef }))) {
665
441
  await cleanupTempBranch();
666
442
  return new NotFastForwardError({ target: defaultBranch });
667
443
  }
@@ -669,6 +445,10 @@ export async function mergeWorktree({ worktreeDir, mainRepoDir, worktreeName, ta
669
445
  targetDir: mainRepoDir,
670
446
  targetBranch: defaultBranch,
671
447
  });
448
+ if (targetIsDirty instanceof Error) {
449
+ await cleanupTempBranch();
450
+ return targetIsDirty;
451
+ }
672
452
  if (targetIsDirty) {
673
453
  await cleanupTempBranch();
674
454
  return new TargetDirtyWorktreeError({ target: defaultBranch });
@@ -679,33 +459,36 @@ export async function mergeWorktree({ worktreeDir, mainRepoDir, worktreeName, ta
679
459
  return gitCommonDir;
680
460
  }
681
461
  log(`Pushing to ${defaultBranch}...`);
682
- const pushResult = await git(worktreeDir, `push --receive-pack="git -c receive.denyCurrentBranch=updateInstead receive-pack" "${gitCommonDir}" "HEAD:${defaultBranch}"`, { timeout: 30_000 });
462
+ const pushResult = await git(worktreeDir, [
463
+ 'push',
464
+ '--receive-pack=git -c receive.denyCurrentBranch=updateInstead receive-pack',
465
+ gitCommonDir,
466
+ `${mergeRef}:${defaultBranch}`,
467
+ ], { timeout: 30_000 });
683
468
  if (pushResult instanceof Error) {
684
469
  await cleanupTempBranch();
685
470
  return new PushError({ target: defaultBranch, cause: pushResult });
686
471
  }
687
472
  // Get short SHA for display
688
- const shortSha = await git(worktreeDir, 'rev-parse --short HEAD');
473
+ const shortSha = await git(worktreeDir, ['rev-parse', '--short', mergeRef]);
689
474
  if (shortSha instanceof Error) {
690
475
  // Push succeeded but can't get SHA -- non-fatal, use placeholder
691
476
  logger.warn('Failed to get short SHA after push');
692
477
  }
693
478
  // ── Step 5: Clean up -- detach HEAD and delete branch ──
694
479
  log('Cleaning up worktree...');
695
- const detachResult = await git(worktreeDir, `checkout --detach "${defaultBranch}"`);
480
+ const detachResult = await git(worktreeDir, [
481
+ 'checkout',
482
+ '--detach',
483
+ defaultBranch,
484
+ ]);
696
485
  if (detachResult instanceof Error) {
697
486
  logger.warn(`[MERGE CLEANUP] Failed to detach worktree HEAD after push: ${detachResult.message}`);
698
487
  }
699
- const deleteBranchResult = await git(worktreeDir, `branch -D "${branchName}"`);
488
+ const deleteBranchResult = await git(worktreeDir, ['branch', '-D', branchName]);
700
489
  if (deleteBranchResult instanceof Error) {
701
490
  logger.warn(`[MERGE CLEANUP] Failed to delete branch ${branchName}: ${deleteBranchResult.message}`);
702
491
  }
703
- if (branchName !== worktreeName && worktreeName) {
704
- const deleteWorktreeBranchResult = await git(worktreeDir, `branch -D "${worktreeName}"`);
705
- if (deleteWorktreeBranchResult instanceof Error) {
706
- logger.warn(`[MERGE CLEANUP] Failed to delete worktree branch ${worktreeName}: ${deleteWorktreeBranchResult.message}`);
707
- }
708
- }
709
492
  return {
710
493
  defaultBranch,
711
494
  branchName: worktreeName || branchName,
@@ -817,7 +600,7 @@ export async function listBranchesByLastCommit({ directory, query, includeRemote
817
600
  * double dots, trailing dots/locks, etc. Returns the normalized name or an Error.
818
601
  */
819
602
  export async function validateBranchRef({ directory, ref, }) {
820
- const result = await git(directory, `check-ref-format --branch ${JSON.stringify(ref)}`);
603
+ const result = await git(directory, ['check-ref-format', '--branch', ref]);
821
604
  if (result instanceof Error)
822
605
  return new Error(`Invalid branch name: ${ref}`);
823
606
  return result;