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.
- package/dist/agent-model.e2e.test.js +250 -164
- package/dist/anthropic-account-identity.js +9 -1
- package/dist/anthropic-auth-plugin.js +10 -13
- package/dist/anthropic-auth-plugin.test.js +17 -0
- package/dist/anthropic-auth-state.js +1 -0
- package/dist/bash-tool-schema-plugin.js +32 -0
- package/dist/bash-tool-schema-plugin.test.js +49 -0
- package/dist/bin.js +73 -19
- package/dist/cli-commands/maintenance.js +46 -7
- package/dist/cli-commands/project.js +11 -27
- package/dist/cli-commands/send.js +30 -1
- package/dist/cli-commands/session.js +322 -90
- package/dist/cli-commands/task.js +34 -15
- package/dist/cli-parsing.test.js +83 -4
- package/dist/cli-runner.js +44 -8
- package/dist/cli-runner.test.js +27 -0
- package/dist/cli-send-thread.e2e.test.js +9 -9
- package/dist/cli.js +32 -0
- package/dist/commands/abort.js +5 -1
- package/dist/commands/action-buttons.js +2 -1
- package/dist/commands/add-project.js +4 -7
- package/dist/commands/agent.js +164 -32
- package/dist/commands/ask-question.js +23 -33
- package/dist/commands/ask-question.test.js +43 -1
- package/dist/commands/btw.js +28 -6
- package/dist/commands/file-upload.js +2 -1
- package/dist/commands/fork-subagent.js +2 -4
- package/dist/commands/fork.js +4 -4
- package/dist/commands/last-sessions.js +17 -10
- package/dist/commands/login.js +6 -0
- package/dist/commands/merge-worktree.js +47 -14
- package/dist/commands/model-variant.js +8 -1
- package/dist/commands/model.js +16 -9
- package/dist/commands/multioauth.js +1 -0
- package/dist/commands/new-worktree.js +170 -102
- package/dist/commands/queue.js +3 -2
- package/dist/commands/resume.js +4 -4
- package/dist/commands/session.js +78 -63
- package/dist/commands/user-command.js +6 -8
- package/dist/commands/worktrees.js +5 -13
- package/dist/config.js +16 -1
- package/dist/cpu-profiler.js +211 -0
- package/dist/cpu-profiler.test.js +160 -0
- package/dist/database.js +152 -3
- package/dist/db.js +33 -0
- package/dist/db.test.js +267 -1
- package/dist/default-channel-provisioning.test.js +148 -0
- package/dist/discord-bot.js +517 -488
- package/dist/discord-command-registration.js +25 -10
- package/dist/discord-command-registration.test.js +17 -0
- package/dist/discord-utils.js +97 -5
- package/dist/discord-utils.test.js +151 -1
- package/dist/errors.js +10 -0
- package/dist/exec-async.js +17 -5
- package/dist/external-directory-permissions.e2e.test.js +11 -7
- package/dist/external-directory-project-deny.e2e.test.js +5 -3
- package/dist/external-opencode-sync.js +94 -17
- package/dist/external-opencode-sync.test.js +206 -0
- package/dist/extract-sdk-error-message.test.js +13 -0
- package/dist/file-edit-log.js +197 -0
- package/dist/file-edit-log.test.js +258 -0
- package/dist/format-tables.js +52 -0
- package/dist/format-tables.test.js +93 -0
- package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
- package/dist/gateway-proxy.e2e.test.js +18 -18
- package/dist/git-worktree-core.js +182 -17
- package/dist/interaction-handler.js +416 -398
- package/dist/ipc-tools-plugin.js +68 -3
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
- package/dist/kimaki-opencode-plugin.js +9 -3
- package/dist/kimaki-workspace-adaptor.js +33 -10
- package/dist/logger.js +11 -1
- package/dist/markdown.js +3 -1
- package/dist/markdown.test.js +11 -3
- package/dist/message-formatting.js +181 -51
- package/dist/message-formatting.test.js +529 -22
- package/dist/message-preprocessing.js +105 -1
- package/dist/oauth-rotation-shared.js +10 -0
- package/dist/openai-auth-plugin.js +1 -0
- package/dist/openai-auth-state.js +1 -0
- package/dist/opencode-command.js +28 -0
- package/dist/opencode-command.test.js +109 -1
- package/dist/opencode-interrupt-plugin.js +4 -0
- package/dist/opencode-interrupt-plugin.test.js +24 -0
- package/dist/opencode.js +82 -15
- package/dist/project-add.test.js +62 -0
- package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
- package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
- package/dist/question-text-before-dropdown.e2e.test.js +52 -0
- package/dist/queue-advanced-abort.e2e.test.js +8 -10
- package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
- package/dist/queue-advanced-e2e-setup.js +78 -5
- package/dist/queue-advanced-footer.e2e.test.js +60 -269
- package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
- package/dist/queue-advanced-permissions-typing.e2e.test.js +15 -15
- package/dist/queue-advanced-question.e2e.test.js +32 -27
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
- package/dist/queue-advanced-typing.e2e.test.js +7 -9
- package/dist/queue-delete-message.e2e.test.js +3 -3
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
- package/dist/queue-interrupt-drain.e2e.test.js +122 -12
- package/dist/queue-question-select-drain.e2e.test.js +34 -132
- package/dist/runtime-lifecycle.e2e.test.js +21 -51
- package/dist/schema.js +35 -1
- package/dist/session-handler/event-stream-state.js +391 -50
- package/dist/session-handler/event-stream-state.test.js +736 -8
- package/dist/session-handler/model-utils.js +172 -5
- package/dist/session-handler/model-utils.test.js +445 -0
- package/dist/session-handler/opencode-session-event-log.js +30 -1
- package/dist/session-handler/thread-ingress.test.js +41 -0
- package/dist/session-handler/thread-runtime-state.js +2 -0
- package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
- package/dist/session-handler/thread-session-runtime.js +574 -299
- package/dist/session-search.js +123 -4
- package/dist/session-search.test.js +248 -1
- package/dist/session-sleep.test.js +224 -0
- package/dist/session-title-rename.test.js +37 -39
- package/dist/sleep.e2e.test.js +494 -0
- package/dist/startup-time.e2e.test.js +2 -1
- package/dist/store.js +3 -0
- package/dist/system-message.js +102 -16
- package/dist/system-message.test.js +192 -15
- package/dist/task-runner.js +96 -5
- package/dist/task-schedule.js +87 -1
- package/dist/task-schedule.test.js +149 -1
- package/dist/test-utils.js +17 -9
- package/dist/thinking-utils.js +10 -0
- package/dist/thinking-utils.test.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +65 -213
- package/dist/undo-redo.e2e.test.js +17 -9
- package/dist/voice-attachment.js +23 -1
- package/dist/voice-handler.js +21 -4
- package/dist/voice-message.e2e.test.js +144 -30
- package/dist/voice-routing-input.e2e.test.js +195 -0
- package/dist/voice-routing-question.e2e.test.js +125 -0
- package/dist/voice.js +28 -8
- package/dist/voice.test.js +96 -30
- package/dist/worktree-clone-isolation.e2e.test.js +188 -0
- package/dist/worktree-lifecycle.e2e.test.js +55 -36
- package/dist/worktrees.js +226 -443
- package/dist/worktrees.test.js +435 -58
- package/dist/xai-auth-plugin.js +1 -0
- package/dist/xai-auth-state.js +1 -0
- package/package.json +8 -6
- package/skills/bump-holocron-consumers/SKILL.md +137 -0
- package/skills/critique/SKILL.md +4 -4
- package/skills/errore/SKILL.md +60 -218
- package/skills/goke/SKILL.md +150 -10
- package/skills/holocron/SKILL.md +154 -17
- package/skills/npm-package/SKILL.md +55 -35
- package/skills/profano/SKILL.md +17 -2
- package/skills/spiceflow/SKILL.md +39 -3
- package/skills/strada/SKILL.md +39 -0
- package/skills/zele/SKILL.md +19 -0
- package/src/agent-model.e2e.test.ts +287 -186
- package/src/anthropic-account-identity.ts +20 -1
- package/src/anthropic-auth-plugin.test.ts +19 -0
- package/src/anthropic-auth-plugin.ts +12 -16
- package/src/anthropic-auth-state.ts +1 -0
- package/src/bash-tool-schema-plugin.test.ts +55 -0
- package/src/bash-tool-schema-plugin.ts +46 -0
- package/src/bin.ts +78 -26
- package/src/cli-commands/maintenance.ts +59 -13
- package/src/cli-commands/project.ts +14 -35
- package/src/cli-commands/send.ts +31 -1
- package/src/cli-commands/session.ts +374 -114
- package/src/cli-commands/task.ts +34 -15
- package/src/cli-parsing.test.ts +103 -4
- package/src/cli-runner.test.ts +49 -0
- package/src/cli-runner.ts +44 -7
- package/src/cli-send-thread.e2e.test.ts +9 -11
- package/src/cli.ts +53 -0
- package/src/commands/abort.ts +6 -1
- package/src/commands/action-buttons.ts +2 -1
- package/src/commands/add-project.ts +4 -8
- package/src/commands/agent.ts +205 -29
- package/src/commands/ask-question.test.ts +48 -1
- package/src/commands/ask-question.ts +23 -33
- package/src/commands/btw.ts +40 -5
- package/src/commands/file-upload.ts +2 -1
- package/src/commands/fork-subagent.ts +2 -3
- package/src/commands/fork.ts +4 -3
- package/src/commands/last-sessions.ts +10 -4
- package/src/commands/login.ts +6 -0
- package/src/commands/merge-worktree.ts +54 -16
- package/src/commands/model-variant.ts +9 -1
- package/src/commands/model.ts +20 -9
- package/src/commands/multioauth.ts +1 -0
- package/src/commands/new-worktree.ts +138 -55
- package/src/commands/queue.ts +3 -2
- package/src/commands/resume.ts +4 -3
- package/src/commands/session.ts +94 -70
- package/src/commands/user-command.ts +6 -6
- package/src/commands/worktrees.ts +5 -12
- package/src/config.ts +18 -1
- package/src/cpu-profiler.test.ts +183 -0
- package/src/cpu-profiler.ts +293 -0
- package/src/database.ts +235 -3
- package/src/db.test.ts +293 -0
- package/src/db.ts +38 -0
- package/src/default-channel-provisioning.test.ts +155 -0
- package/src/discord-bot.ts +78 -36
- package/src/discord-command-registration.test.ts +20 -0
- package/src/discord-command-registration.ts +42 -12
- package/src/discord-utils.test.ts +176 -0
- package/src/discord-utils.ts +152 -7
- package/src/errors.ts +12 -0
- package/src/exec-async.ts +20 -11
- package/src/external-directory-permissions.e2e.test.ts +11 -7
- package/src/external-directory-project-deny.e2e.test.ts +5 -3
- package/src/external-opencode-sync.test.ts +248 -0
- package/src/external-opencode-sync.ts +122 -19
- package/src/extract-sdk-error-message.test.ts +18 -0
- package/src/file-edit-log.test.ts +287 -0
- package/src/file-edit-log.ts +283 -0
- package/src/format-tables.test.ts +101 -0
- package/src/format-tables.ts +75 -0
- package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
- package/src/gateway-proxy.e2e.test.ts +18 -17
- package/src/git-worktree-core.ts +247 -20
- package/src/interaction-handler.ts +36 -13
- package/src/ipc-tools-plugin.ts +82 -5
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
- package/src/kimaki-opencode-plugin.ts +9 -3
- package/src/kimaki-workspace-adaptor.ts +40 -11
- package/src/logger.ts +14 -4
- package/src/markdown.test.ts +13 -3
- package/src/markdown.ts +4 -1
- package/src/message-formatting.test.ts +672 -25
- package/src/message-formatting.ts +242 -53
- package/src/message-preprocessing.ts +116 -1
- package/src/oauth-rotation-shared.ts +10 -0
- package/src/openai-auth-plugin.ts +1 -0
- package/src/openai-auth-state.ts +1 -0
- package/src/opencode-command.test.ts +140 -0
- package/src/opencode-command.ts +38 -0
- package/src/opencode-interrupt-plugin.test.ts +32 -0
- package/src/opencode-interrupt-plugin.ts +3 -0
- package/src/opencode.ts +115 -15
- package/src/project-add.test.ts +80 -0
- package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
- package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
- package/src/question-text-before-dropdown.e2e.test.ts +67 -0
- package/src/queue-advanced-abort.e2e.test.ts +8 -9
- package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
- package/src/queue-advanced-e2e-setup.ts +83 -4
- package/src/queue-advanced-footer.e2e.test.ts +67 -315
- package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
- package/src/queue-advanced-permissions-typing.e2e.test.ts +15 -15
- package/src/queue-advanced-question.e2e.test.ts +36 -30
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
- package/src/queue-advanced-typing.e2e.test.ts +8 -8
- package/src/queue-delete-message.e2e.test.ts +3 -3
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
- package/src/queue-interrupt-drain.e2e.test.ts +133 -12
- package/src/queue-question-select-drain.e2e.test.ts +36 -161
- package/src/runtime-lifecycle.e2e.test.ts +21 -54
- package/src/schema.sql +15 -2
- package/src/schema.ts +37 -1
- package/src/session-handler/event-stream-state.test.ts +790 -7
- package/src/session-handler/event-stream-state.ts +568 -53
- package/src/session-handler/model-utils.test.ts +537 -0
- package/src/session-handler/model-utils.ts +281 -5
- package/src/session-handler/opencode-session-event-log.ts +31 -1
- package/src/session-handler/thread-ingress.test.ts +54 -0
- package/src/session-handler/thread-runtime-state.ts +3 -0
- package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
- package/src/session-handler/thread-session-runtime.ts +712 -358
- package/src/session-search.test.ts +281 -0
- package/src/session-search.ts +241 -7
- package/src/session-sleep.test.ts +294 -0
- package/src/session-title-rename.test.ts +54 -48
- package/src/sleep.e2e.test.ts +565 -0
- package/src/startup-time.e2e.test.ts +5 -1
- package/src/store.ts +22 -0
- package/src/system-message.test.ts +226 -15
- package/src/system-message.ts +130 -16
- package/src/task-runner.ts +138 -3
- package/src/task-schedule.test.ts +170 -0
- package/src/task-schedule.ts +141 -1
- package/src/test-utils.ts +21 -10
- package/src/thinking-utils.test.ts +46 -0
- package/src/thinking-utils.ts +21 -0
- package/src/thread-message-queue.e2e.test.ts +65 -245
- package/src/undo-redo.e2e.test.ts +17 -9
- package/src/voice-attachment.ts +29 -1
- package/src/voice-handler.ts +23 -4
- package/src/voice-message.e2e.test.ts +149 -29
- package/src/voice-routing-input.e2e.test.ts +203 -0
- package/src/voice-routing-question.e2e.test.ts +131 -0
- package/src/voice.test.ts +107 -29
- package/src/voice.ts +34 -6
- package/src/worktree-clone-isolation.e2e.test.ts +213 -0
- package/src/worktree-lifecycle.e2e.test.ts +55 -35
- package/src/worktrees.test.ts +505 -64
- package/src/worktrees.ts +257 -603
- package/src/xai-auth-plugin.ts +1 -0
- package/src/xai-auth-state.ts +1 -0
- package/dist/message-finish-field.e2e.test.js +0 -169
- package/dist/subagent-rate-limit-plugin.js +0 -184
- package/src/message-finish-field.e2e.test.ts +0 -196
- 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
|
|
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
|
|
51
|
+
// Merge pipeline:
|
|
582
52
|
// 1. Reject if uncommitted changes exist
|
|
583
53
|
// 2. Rebase worktree commits onto target (default branch)
|
|
584
|
-
// 3.
|
|
585
|
-
// 4.
|
|
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>
|
|
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
|
-
|
|
105
|
+
command,
|
|
627
106
|
opts ? { timeout: opts.timeout } : undefined,
|
|
628
|
-
).catch((e) => new GitCommandError({ command:
|
|
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
|
|
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,
|
|
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,
|
|
342
|
+
const mergeBase = await git(dir, ['merge-base', 'HEAD', target])
|
|
741
343
|
if (mergeBase instanceof Error) return false
|
|
742
|
-
const targetSha = await git(dir,
|
|
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,
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
|
|
846
|
-
|
|
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:
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
|
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
|
-
|
|
880
|
-
|
|
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
|
-
|
|
887
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
}
|