@brianli/kimaki 0.4.72-brianli.1
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/bin.js +2 -0
- package/dist/ai-tool-to-genai.js +233 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/ai-tool.js +6 -0
- package/dist/bin.js +87 -0
- package/dist/bot-token.js +121 -0
- package/dist/bot-token.test.js +134 -0
- package/dist/channel-management.js +101 -0
- package/dist/cli-parsing.test.js +89 -0
- package/dist/cli.js +2529 -0
- package/dist/commands/abort.js +82 -0
- package/dist/commands/action-buttons.js +257 -0
- package/dist/commands/add-project.js +114 -0
- package/dist/commands/agent.js +291 -0
- package/dist/commands/ask-question.js +223 -0
- package/dist/commands/compact.js +120 -0
- package/dist/commands/context-usage.js +140 -0
- package/dist/commands/create-new-project.js +118 -0
- package/dist/commands/diff.js +128 -0
- package/dist/commands/file-upload.js +275 -0
- package/dist/commands/fork.js +217 -0
- package/dist/commands/gemini-apikey.js +70 -0
- package/dist/commands/login.js +490 -0
- package/dist/commands/mention-mode.js +51 -0
- package/dist/commands/merge-worktree.js +124 -0
- package/dist/commands/model.js +694 -0
- package/dist/commands/permissions.js +163 -0
- package/dist/commands/queue.js +217 -0
- package/dist/commands/remove-project.js +115 -0
- package/dist/commands/restart-opencode-server.js +116 -0
- package/dist/commands/resume.js +159 -0
- package/dist/commands/run-command.js +79 -0
- package/dist/commands/session-id.js +78 -0
- package/dist/commands/session.js +192 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +159 -0
- package/dist/commands/unset-model.js +152 -0
- package/dist/commands/upgrade.js +42 -0
- package/dist/commands/user-command.js +148 -0
- package/dist/commands/verbosity.js +60 -0
- package/dist/commands/worktree-settings.js +50 -0
- package/dist/commands/worktree.js +299 -0
- package/dist/condense-memory.js +33 -0
- package/dist/config.js +110 -0
- package/dist/database.js +1050 -0
- package/dist/db.js +159 -0
- package/dist/db.test.js +49 -0
- package/dist/discord-api.js +28 -0
- package/dist/discord-auth.js +231 -0
- package/dist/discord-auth.test.js +80 -0
- package/dist/discord-bot.js +997 -0
- package/dist/discord-utils.js +560 -0
- package/dist/discord-utils.test.js +115 -0
- package/dist/errors.js +167 -0
- package/dist/escape-backticks.test.js +429 -0
- package/dist/format-tables.js +122 -0
- package/dist/format-tables.test.js +199 -0
- package/dist/forum-sync/config.js +79 -0
- package/dist/forum-sync/discord-operations.js +154 -0
- package/dist/forum-sync/index.js +5 -0
- package/dist/forum-sync/markdown.js +117 -0
- package/dist/forum-sync/sync-to-discord.js +417 -0
- package/dist/forum-sync/sync-to-files.js +190 -0
- package/dist/forum-sync/types.js +53 -0
- package/dist/forum-sync/watchers.js +307 -0
- package/dist/gateway-consumer.js +232 -0
- package/dist/gateway-consumer.test.js +18 -0
- package/dist/genai-worker-wrapper.js +111 -0
- package/dist/genai-worker.js +311 -0
- package/dist/genai.js +232 -0
- package/dist/generated/browser.js +17 -0
- package/dist/generated/client.js +35 -0
- package/dist/generated/commonInputTypes.js +10 -0
- package/dist/generated/enums.js +30 -0
- package/dist/generated/internal/class.js +41 -0
- package/dist/generated/internal/prismaNamespace.js +239 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
- package/dist/generated/models/bot_api_keys.js +1 -0
- package/dist/generated/models/bot_tokens.js +1 -0
- package/dist/generated/models/channel_agents.js +1 -0
- package/dist/generated/models/channel_directories.js +1 -0
- package/dist/generated/models/channel_mention_mode.js +1 -0
- package/dist/generated/models/channel_models.js +1 -0
- package/dist/generated/models/channel_verbosity.js +1 -0
- package/dist/generated/models/channel_worktrees.js +1 -0
- package/dist/generated/models/forum_sync_configs.js +1 -0
- package/dist/generated/models/global_models.js +1 -0
- package/dist/generated/models/ipc_requests.js +1 -0
- package/dist/generated/models/part_messages.js +1 -0
- package/dist/generated/models/scheduled_tasks.js +1 -0
- package/dist/generated/models/session_agents.js +1 -0
- package/dist/generated/models/session_models.js +1 -0
- package/dist/generated/models/session_start_sources.js +1 -0
- package/dist/generated/models/thread_sessions.js +1 -0
- package/dist/generated/models/thread_worktrees.js +1 -0
- package/dist/generated/models.js +1 -0
- package/dist/heap-monitor.js +95 -0
- package/dist/hrana-server.js +416 -0
- package/dist/hrana-server.test.js +368 -0
- package/dist/image-utils.js +112 -0
- package/dist/interaction-handler.js +327 -0
- package/dist/ipc-polling.js +251 -0
- package/dist/kimaki-digital-twin.e2e.test.js +165 -0
- package/dist/limit-heading-depth.js +25 -0
- package/dist/limit-heading-depth.test.js +105 -0
- package/dist/logger.js +160 -0
- package/dist/markdown.js +342 -0
- package/dist/markdown.test.js +253 -0
- package/dist/message-formatting.js +433 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/openai-realtime.js +228 -0
- package/dist/opencode-plugin-loading.e2e.test.js +91 -0
- package/dist/opencode-plugin.js +536 -0
- package/dist/opencode-plugin.test.js +98 -0
- package/dist/opencode.js +409 -0
- package/dist/privacy-sanitizer.js +105 -0
- package/dist/runtime-mode.js +51 -0
- package/dist/runtime-mode.test.js +115 -0
- package/dist/sentry.js +127 -0
- package/dist/session-handler/state.js +151 -0
- package/dist/session-handler.js +1874 -0
- package/dist/session-search.js +100 -0
- package/dist/session-search.test.js +40 -0
- package/dist/startup-service.js +153 -0
- package/dist/system-message.js +499 -0
- package/dist/task-runner.js +282 -0
- package/dist/task-schedule.js +191 -0
- package/dist/task-schedule.test.js +71 -0
- package/dist/thinking-utils.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +781 -0
- package/dist/tools.js +359 -0
- package/dist/unnest-code-blocks.js +136 -0
- package/dist/unnest-code-blocks.test.js +641 -0
- package/dist/upgrade.js +114 -0
- package/dist/utils.js +109 -0
- package/dist/voice-handler.js +606 -0
- package/dist/voice.js +304 -0
- package/dist/voice.test.js +187 -0
- package/dist/wait-session.js +94 -0
- package/dist/worker-types.js +4 -0
- package/dist/worktree-utils.js +727 -0
- package/dist/xml.js +92 -0
- package/dist/xml.test.js +32 -0
- package/package.json +82 -0
- package/schema.prisma +246 -0
- package/skills/batch/SKILL.md +87 -0
- package/skills/critique/SKILL.md +129 -0
- package/skills/errore/SKILL.md +589 -0
- package/skills/goke/.prettierrc +5 -0
- package/skills/goke/CHANGELOG.md +40 -0
- package/skills/goke/LICENSE +21 -0
- package/skills/goke/README.md +666 -0
- package/skills/goke/SKILL.md +458 -0
- package/skills/goke/package.json +43 -0
- package/skills/goke/src/__test__/coerce.test.ts +411 -0
- package/skills/goke/src/__test__/index.test.ts +1798 -0
- package/skills/goke/src/__test__/types.test-d.ts +111 -0
- package/skills/goke/src/coerce.ts +547 -0
- package/skills/goke/src/goke.ts +1362 -0
- package/skills/goke/src/index.ts +16 -0
- package/skills/goke/src/mri.ts +164 -0
- package/skills/goke/tsconfig.json +15 -0
- package/skills/jitter/EDITOR.md +219 -0
- package/skills/jitter/EXPORT-INTERNALS.md +309 -0
- package/skills/jitter/SKILL.md +158 -0
- package/skills/jitter/jitter-clipboard.json +1042 -0
- package/skills/jitter/package.json +14 -0
- package/skills/jitter/tsconfig.json +15 -0
- package/skills/jitter/utils/actions.ts +212 -0
- package/skills/jitter/utils/export.ts +114 -0
- package/skills/jitter/utils/index.ts +141 -0
- package/skills/jitter/utils/snapshot.ts +154 -0
- package/skills/jitter/utils/traverse.ts +246 -0
- package/skills/jitter/utils/types.ts +279 -0
- package/skills/jitter/utils/wait.ts +133 -0
- package/skills/playwriter/SKILL.md +31 -0
- package/skills/security-review/SKILL.md +208 -0
- package/skills/simplify/SKILL.md +58 -0
- package/skills/termcast/SKILL.md +945 -0
- package/skills/tuistory/SKILL.md +250 -0
- package/skills/zustand-centralized-state/SKILL.md +582 -0
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +41 -0
- package/src/__snapshots__/first-session-no-info.md +17 -0
- package/src/__snapshots__/first-session-with-info.md +23 -0
- package/src/__snapshots__/session-1.md +17 -0
- package/src/__snapshots__/session-2.md +5871 -0
- package/src/__snapshots__/session-3.md +17 -0
- package/src/__snapshots__/session-with-tools.md +5871 -0
- package/src/ai-tool-to-genai.test.ts +296 -0
- package/src/ai-tool-to-genai.ts +282 -0
- package/src/ai-tool.ts +39 -0
- package/src/bin.ts +108 -0
- package/src/bot-token.test.ts +171 -0
- package/src/bot-token.ts +159 -0
- package/src/channel-management.ts +172 -0
- package/src/cli-parsing.test.ts +132 -0
- package/src/cli.ts +3605 -0
- package/src/commands/abort.ts +112 -0
- package/src/commands/action-buttons.ts +376 -0
- package/src/commands/add-project.ts +152 -0
- package/src/commands/agent.ts +404 -0
- package/src/commands/ask-question.ts +330 -0
- package/src/commands/compact.ts +157 -0
- package/src/commands/context-usage.ts +199 -0
- package/src/commands/create-new-project.ts +179 -0
- package/src/commands/diff.ts +165 -0
- package/src/commands/file-upload.ts +389 -0
- package/src/commands/fork.ts +320 -0
- package/src/commands/gemini-apikey.ts +104 -0
- package/src/commands/login.ts +634 -0
- package/src/commands/mention-mode.ts +77 -0
- package/src/commands/merge-worktree.ts +177 -0
- package/src/commands/model.ts +961 -0
- package/src/commands/permissions.ts +261 -0
- package/src/commands/queue.ts +296 -0
- package/src/commands/remove-project.ts +155 -0
- package/src/commands/restart-opencode-server.ts +162 -0
- package/src/commands/resume.ts +242 -0
- package/src/commands/run-command.ts +123 -0
- package/src/commands/session-id.ts +109 -0
- package/src/commands/session.ts +250 -0
- package/src/commands/share.ts +106 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +221 -0
- package/src/commands/unset-model.ts +189 -0
- package/src/commands/upgrade.ts +52 -0
- package/src/commands/user-command.ts +193 -0
- package/src/commands/verbosity.ts +88 -0
- package/src/commands/worktree-settings.ts +79 -0
- package/src/commands/worktree.ts +431 -0
- package/src/condense-memory.ts +36 -0
- package/src/config.ts +148 -0
- package/src/database.ts +1530 -0
- package/src/db.test.ts +60 -0
- package/src/db.ts +190 -0
- package/src/discord-api.ts +35 -0
- package/src/discord-bot.ts +1316 -0
- package/src/discord-utils.test.ts +132 -0
- package/src/discord-utils.ts +767 -0
- package/src/errors.ts +213 -0
- package/src/escape-backticks.test.ts +469 -0
- package/src/format-tables.test.ts +223 -0
- package/src/format-tables.ts +145 -0
- package/src/forum-sync/config.ts +92 -0
- package/src/forum-sync/discord-operations.ts +241 -0
- package/src/forum-sync/index.ts +9 -0
- package/src/forum-sync/markdown.ts +176 -0
- package/src/forum-sync/sync-to-discord.ts +595 -0
- package/src/forum-sync/sync-to-files.ts +294 -0
- package/src/forum-sync/types.ts +175 -0
- package/src/forum-sync/watchers.ts +454 -0
- package/src/genai-worker-wrapper.ts +164 -0
- package/src/genai-worker.ts +386 -0
- package/src/genai.ts +321 -0
- package/src/generated/browser.ts +109 -0
- package/src/generated/client.ts +131 -0
- package/src/generated/commonInputTypes.ts +512 -0
- package/src/generated/enums.ts +46 -0
- package/src/generated/internal/class.ts +362 -0
- package/src/generated/internal/prismaNamespace.ts +2251 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
- package/src/generated/models/bot_api_keys.ts +1288 -0
- package/src/generated/models/bot_tokens.ts +1577 -0
- package/src/generated/models/channel_agents.ts +1256 -0
- package/src/generated/models/channel_directories.ts +2104 -0
- package/src/generated/models/channel_mention_mode.ts +1300 -0
- package/src/generated/models/channel_models.ts +1288 -0
- package/src/generated/models/channel_verbosity.ts +1224 -0
- package/src/generated/models/channel_worktrees.ts +1308 -0
- package/src/generated/models/forum_sync_configs.ts +1452 -0
- package/src/generated/models/global_models.ts +1288 -0
- package/src/generated/models/ipc_requests.ts +1485 -0
- package/src/generated/models/part_messages.ts +1302 -0
- package/src/generated/models/scheduled_tasks.ts +2320 -0
- package/src/generated/models/session_agents.ts +1086 -0
- package/src/generated/models/session_models.ts +1114 -0
- package/src/generated/models/session_start_sources.ts +1408 -0
- package/src/generated/models/thread_sessions.ts +1599 -0
- package/src/generated/models/thread_worktrees.ts +1352 -0
- package/src/generated/models.ts +29 -0
- package/src/heap-monitor.ts +121 -0
- package/src/hrana-server.test.ts +428 -0
- package/src/hrana-server.ts +547 -0
- package/src/image-utils.ts +149 -0
- package/src/interaction-handler.ts +461 -0
- package/src/ipc-polling.ts +325 -0
- package/src/kimaki-digital-twin.e2e.test.ts +201 -0
- package/src/limit-heading-depth.test.ts +116 -0
- package/src/limit-heading-depth.ts +26 -0
- package/src/logger.ts +203 -0
- package/src/markdown.test.ts +360 -0
- package/src/markdown.ts +410 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +549 -0
- package/src/openai-realtime.ts +362 -0
- package/src/opencode-plugin-loading.e2e.test.ts +112 -0
- package/src/opencode-plugin.test.ts +108 -0
- package/src/opencode-plugin.ts +652 -0
- package/src/opencode.ts +554 -0
- package/src/privacy-sanitizer.ts +142 -0
- package/src/schema.sql +158 -0
- package/src/sentry.ts +137 -0
- package/src/session-handler/state.ts +232 -0
- package/src/session-handler.ts +2668 -0
- package/src/session-search.test.ts +50 -0
- package/src/session-search.ts +148 -0
- package/src/startup-service.ts +200 -0
- package/src/system-message.ts +568 -0
- package/src/task-runner.ts +425 -0
- package/src/task-schedule.test.ts +84 -0
- package/src/task-schedule.ts +287 -0
- package/src/thinking-utils.ts +61 -0
- package/src/thread-message-queue.e2e.test.ts +997 -0
- package/src/tools.ts +432 -0
- package/src/unnest-code-blocks.test.ts +679 -0
- package/src/unnest-code-blocks.ts +168 -0
- package/src/upgrade.ts +127 -0
- package/src/utils.ts +145 -0
- package/src/voice-handler.ts +852 -0
- package/src/voice.test.ts +219 -0
- package/src/voice.ts +444 -0
- package/src/wait-session.ts +147 -0
- package/src/worker-types.ts +64 -0
- package/src/worktree-utils.ts +988 -0
- package/src/xml.test.ts +38 -0
- package/src/xml.ts +121 -0
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
// Worktree utility functions.
|
|
2
|
+
// Wrapper for git worktree creation that initializes and validates submodules.
|
|
3
|
+
// Also handles capturing and applying git diffs when creating worktrees from threads.
|
|
4
|
+
|
|
5
|
+
import crypto from 'node:crypto'
|
|
6
|
+
import { exec, spawn } from 'node:child_process'
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import os from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { promisify } from 'node:util'
|
|
11
|
+
import { createLogger, LogPrefix } from './logger.js'
|
|
12
|
+
|
|
13
|
+
const DEFAULT_EXEC_TIMEOUT_MS = 10_000
|
|
14
|
+
const SUBMODULE_INIT_TIMEOUT_MS = 20 * 60_000
|
|
15
|
+
|
|
16
|
+
const _execAsync = promisify(exec)
|
|
17
|
+
|
|
18
|
+
// Wraps child_process.exec with a default 10s timeout via Promise.race.
|
|
19
|
+
// Callers can override with a longer timeout in the options.
|
|
20
|
+
export function execAsync(
|
|
21
|
+
command: string,
|
|
22
|
+
options?: Parameters<typeof _execAsync>[1],
|
|
23
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
24
|
+
const timeoutMs =
|
|
25
|
+
(options as { timeout?: number })?.timeout || DEFAULT_EXEC_TIMEOUT_MS
|
|
26
|
+
const execPromise = _execAsync(command, options) as Promise<{
|
|
27
|
+
stdout: string
|
|
28
|
+
stderr: string
|
|
29
|
+
}> & { child?: import('node:child_process').ChildProcess }
|
|
30
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
31
|
+
setTimeout(() => {
|
|
32
|
+
execPromise.child?.kill()
|
|
33
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms: ${command}`))
|
|
34
|
+
}, timeoutMs)
|
|
35
|
+
})
|
|
36
|
+
return Promise.race([execPromise, timeoutPromise])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const logger = createLogger(LogPrefix.WORKTREE)
|
|
40
|
+
|
|
41
|
+
const LOCKFILE_TO_INSTALL_COMMAND: Array<[string, string]> = [
|
|
42
|
+
['pnpm-lock.yaml', 'pnpm install'],
|
|
43
|
+
['bun.lock', 'bun install'],
|
|
44
|
+
['bun.lockb', 'bun install'],
|
|
45
|
+
['yarn.lock', 'yarn install'],
|
|
46
|
+
['package-lock.json', 'npm install'],
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
function detectInstallCommand(directory: string): string | null {
|
|
50
|
+
for (const [lockfile, command] of LOCKFILE_TO_INSTALL_COMMAND) {
|
|
51
|
+
if (fs.existsSync(path.join(directory, lockfile))) {
|
|
52
|
+
return command
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type CommandError = Error & {
|
|
59
|
+
cmd?: string
|
|
60
|
+
stderr?: string
|
|
61
|
+
stdout?: string
|
|
62
|
+
signal?: NodeJS.Signals
|
|
63
|
+
killed?: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatCommandError(error: unknown): string {
|
|
67
|
+
if (!(error instanceof Error)) {
|
|
68
|
+
return String(error)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const commandError = error as CommandError
|
|
72
|
+
const details: string[] = [commandError.message]
|
|
73
|
+
|
|
74
|
+
if (commandError.cmd) {
|
|
75
|
+
details.push(`cmd=${commandError.cmd}`)
|
|
76
|
+
}
|
|
77
|
+
if (commandError.signal) {
|
|
78
|
+
details.push(`signal=${commandError.signal}`)
|
|
79
|
+
}
|
|
80
|
+
if (commandError.killed) {
|
|
81
|
+
details.push('process=killed')
|
|
82
|
+
}
|
|
83
|
+
if (commandError.stderr?.trim()) {
|
|
84
|
+
details.push(`stderr=${commandError.stderr.trim()}`)
|
|
85
|
+
}
|
|
86
|
+
if (commandError.stdout?.trim()) {
|
|
87
|
+
details.push(`stdout=${commandError.stdout.trim()}`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return details.join(' | ')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get submodule paths from .gitmodules file.
|
|
95
|
+
* Returns empty array if no submodules or on error.
|
|
96
|
+
*/
|
|
97
|
+
async function getSubmodulePaths(directory: string): Promise<string[]> {
|
|
98
|
+
try {
|
|
99
|
+
const result = await execAsync(
|
|
100
|
+
'git config --file .gitmodules --get-regexp path',
|
|
101
|
+
{
|
|
102
|
+
cwd: directory,
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
// Output format: "submodule.name.path value"
|
|
106
|
+
return result.stdout
|
|
107
|
+
.trim()
|
|
108
|
+
.split('\n')
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
.map((line) => {
|
|
111
|
+
return line.split(' ')[1]
|
|
112
|
+
})
|
|
113
|
+
.filter((p): p is string => {
|
|
114
|
+
return Boolean(p)
|
|
115
|
+
})
|
|
116
|
+
} catch {
|
|
117
|
+
return [] // No .gitmodules or no submodules
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Remove broken submodule stubs created by git worktree.
|
|
123
|
+
* When git worktree add runs on a repo with submodules, it creates submodule
|
|
124
|
+
* directories with .git files pointing to ../.git/worktrees/<name>/modules/<submodule>
|
|
125
|
+
* but that path only has a config file, missing HEAD/objects/refs.
|
|
126
|
+
* This causes git commands to fail with "fatal: not a git repository".
|
|
127
|
+
*/
|
|
128
|
+
async function removeBrokenSubmoduleStubs(directory: string): Promise<void> {
|
|
129
|
+
const submodulePaths = await getSubmodulePaths(directory)
|
|
130
|
+
|
|
131
|
+
for (const subPath of submodulePaths) {
|
|
132
|
+
const fullPath = path.join(directory, subPath)
|
|
133
|
+
const gitFile = path.join(fullPath, '.git')
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const stat = await fs.promises.stat(gitFile)
|
|
137
|
+
if (!stat.isFile()) {
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Read .git file to get gitdir path
|
|
142
|
+
const content = await fs.promises.readFile(gitFile, 'utf-8')
|
|
143
|
+
const match = content.match(/^gitdir:\s*(.+)$/m)
|
|
144
|
+
if (!match || !match[1]) {
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const gitdir = path.resolve(fullPath, match[1].trim())
|
|
149
|
+
const headFile = path.join(gitdir, 'HEAD')
|
|
150
|
+
|
|
151
|
+
// If HEAD doesn't exist, this is a broken stub
|
|
152
|
+
const headExists = await fs.promises
|
|
153
|
+
.access(headFile)
|
|
154
|
+
.then(() => {
|
|
155
|
+
return true
|
|
156
|
+
})
|
|
157
|
+
.catch(() => {
|
|
158
|
+
return false
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
if (!headExists) {
|
|
162
|
+
logger.log(`Removing broken submodule stub: ${subPath}`)
|
|
163
|
+
await fs.promises.rm(fullPath, { recursive: true, force: true })
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
// Directory doesn't exist or other error, skip
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseSubmoduleGitdir(gitFileContent: string): string | Error {
|
|
172
|
+
const match = gitFileContent.match(/^gitdir:\s*(.+)$/m)
|
|
173
|
+
const gitdir = match?.[1]?.trim()
|
|
174
|
+
if (!gitdir) {
|
|
175
|
+
return new Error('Missing gitdir pointer')
|
|
176
|
+
}
|
|
177
|
+
return gitdir
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function validateSubmodulePointers(
|
|
181
|
+
directory: string,
|
|
182
|
+
): Promise<void | Error> {
|
|
183
|
+
const submodulePaths = await getSubmodulePaths(directory)
|
|
184
|
+
if (submodulePaths.length === 0) {
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const validationIssues: string[] = []
|
|
189
|
+
|
|
190
|
+
await Promise.all(
|
|
191
|
+
submodulePaths.map(async (submodulePath) => {
|
|
192
|
+
const submoduleDir = path.join(directory, submodulePath)
|
|
193
|
+
const submoduleGitFile = path.join(submoduleDir, '.git')
|
|
194
|
+
|
|
195
|
+
const gitFileExists = await fs.promises
|
|
196
|
+
.access(submoduleGitFile)
|
|
197
|
+
.then(() => {
|
|
198
|
+
return true
|
|
199
|
+
})
|
|
200
|
+
.catch(() => {
|
|
201
|
+
return false
|
|
202
|
+
})
|
|
203
|
+
if (!gitFileExists) {
|
|
204
|
+
validationIssues.push(`${submodulePath}: missing .git file`)
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const gitFileContentResult = await errore.tryAsync({
|
|
209
|
+
try: () => fs.promises.readFile(submoduleGitFile, 'utf-8'),
|
|
210
|
+
catch: (e) =>
|
|
211
|
+
new Error(`Failed to read .git for ${submodulePath}`, { cause: e }),
|
|
212
|
+
})
|
|
213
|
+
if (gitFileContentResult instanceof Error) {
|
|
214
|
+
validationIssues.push(
|
|
215
|
+
`${submodulePath}: ${gitFileContentResult.message}`,
|
|
216
|
+
)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const parsedGitdir = parseSubmoduleGitdir(gitFileContentResult)
|
|
221
|
+
if (parsedGitdir instanceof Error) {
|
|
222
|
+
validationIssues.push(`${submodulePath}: ${parsedGitdir.message}`)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const resolvedGitdir = path.resolve(submoduleDir, parsedGitdir)
|
|
227
|
+
const headPath = path.join(resolvedGitdir, 'HEAD')
|
|
228
|
+
const headExists = await fs.promises
|
|
229
|
+
.access(headPath)
|
|
230
|
+
.then(() => {
|
|
231
|
+
return true
|
|
232
|
+
})
|
|
233
|
+
.catch(() => {
|
|
234
|
+
return false
|
|
235
|
+
})
|
|
236
|
+
if (!headExists) {
|
|
237
|
+
validationIssues.push(
|
|
238
|
+
`${submodulePath}: gitdir missing HEAD (${resolvedGitdir})`,
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
}),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
const submoduleStatusResult = await errore.tryAsync({
|
|
245
|
+
try: () =>
|
|
246
|
+
execAsync('git submodule status --recursive', {
|
|
247
|
+
cwd: directory,
|
|
248
|
+
timeout: SUBMODULE_INIT_TIMEOUT_MS,
|
|
249
|
+
}),
|
|
250
|
+
catch: (e) =>
|
|
251
|
+
new Error('git submodule status --recursive failed', { cause: e }),
|
|
252
|
+
})
|
|
253
|
+
if (submoduleStatusResult instanceof Error) {
|
|
254
|
+
validationIssues.push(submoduleStatusResult.message)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (validationIssues.length === 0) {
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return new Error(
|
|
262
|
+
`Submodule validation failed: ${validationIssues.join('; ')}`,
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
type WorktreeResult = {
|
|
267
|
+
directory: string
|
|
268
|
+
branch: string
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function resolveDefaultWorktreeTarget(
|
|
272
|
+
directory: string,
|
|
273
|
+
): Promise<string> {
|
|
274
|
+
const remoteHead = await execAsync(
|
|
275
|
+
'git symbolic-ref refs/remotes/origin/HEAD',
|
|
276
|
+
{
|
|
277
|
+
cwd: directory,
|
|
278
|
+
},
|
|
279
|
+
).catch(() => {
|
|
280
|
+
return null
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
const remoteRef = remoteHead?.stdout.trim()
|
|
284
|
+
if (remoteRef?.startsWith('refs/remotes/')) {
|
|
285
|
+
return remoteRef.replace('refs/remotes/', '')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const hasMain = await execAsync(
|
|
289
|
+
'git show-ref --verify --quiet refs/heads/main',
|
|
290
|
+
{
|
|
291
|
+
cwd: directory,
|
|
292
|
+
},
|
|
293
|
+
)
|
|
294
|
+
.then(() => {
|
|
295
|
+
return true
|
|
296
|
+
})
|
|
297
|
+
.catch(() => {
|
|
298
|
+
return false
|
|
299
|
+
})
|
|
300
|
+
if (hasMain) {
|
|
301
|
+
return 'main'
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const hasMaster = await execAsync(
|
|
305
|
+
'git show-ref --verify --quiet refs/heads/master',
|
|
306
|
+
{
|
|
307
|
+
cwd: directory,
|
|
308
|
+
},
|
|
309
|
+
)
|
|
310
|
+
.then(() => {
|
|
311
|
+
return true
|
|
312
|
+
})
|
|
313
|
+
.catch(() => {
|
|
314
|
+
return false
|
|
315
|
+
})
|
|
316
|
+
if (hasMaster) {
|
|
317
|
+
return 'master'
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return 'HEAD'
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function getManagedWorktreeDirectory({
|
|
324
|
+
directory,
|
|
325
|
+
name,
|
|
326
|
+
}: {
|
|
327
|
+
directory: string
|
|
328
|
+
name: string
|
|
329
|
+
}): string {
|
|
330
|
+
const projectHash = crypto.createHash('sha1').update(directory).digest('hex')
|
|
331
|
+
const safeName = name.replaceAll('/', '-')
|
|
332
|
+
return path.join(
|
|
333
|
+
os.homedir(),
|
|
334
|
+
'.local',
|
|
335
|
+
'share',
|
|
336
|
+
'opencode',
|
|
337
|
+
'worktree',
|
|
338
|
+
projectHash,
|
|
339
|
+
safeName,
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Create a worktree using git and initialize git submodules.
|
|
345
|
+
* This wrapper ensures submodules are properly set up in new worktrees.
|
|
346
|
+
*
|
|
347
|
+
* If diff is provided, it's applied BEFORE submodule update to ensure
|
|
348
|
+
* any submodule pointer changes in the diff are respected.
|
|
349
|
+
*/
|
|
350
|
+
export async function createWorktreeWithSubmodules({
|
|
351
|
+
directory,
|
|
352
|
+
name,
|
|
353
|
+
diff,
|
|
354
|
+
}: {
|
|
355
|
+
directory: string
|
|
356
|
+
name: string
|
|
357
|
+
diff?: CapturedDiff | null
|
|
358
|
+
}): Promise<(WorktreeResult & { diffApplied: boolean }) | Error> {
|
|
359
|
+
// 1. Create worktree via git (checked out immediately).
|
|
360
|
+
const worktreeDir = getManagedWorktreeDirectory({ directory, name })
|
|
361
|
+
const targetRef = await resolveDefaultWorktreeTarget(directory)
|
|
362
|
+
|
|
363
|
+
if (fs.existsSync(worktreeDir)) {
|
|
364
|
+
return new Error(`Worktree directory already exists: ${worktreeDir}`)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
await fs.promises.mkdir(path.dirname(worktreeDir), { recursive: true })
|
|
368
|
+
|
|
369
|
+
const createCommand = `git worktree add ${JSON.stringify(worktreeDir)} -B ${JSON.stringify(name)} ${JSON.stringify(targetRef)}`
|
|
370
|
+
const createResult = await errore.tryAsync({
|
|
371
|
+
try: () =>
|
|
372
|
+
execAsync(createCommand, {
|
|
373
|
+
cwd: directory,
|
|
374
|
+
timeout: SUBMODULE_INIT_TIMEOUT_MS,
|
|
375
|
+
}),
|
|
376
|
+
catch: (e) =>
|
|
377
|
+
new Error(`git worktree add failed: ${formatCommandError(e)}`, {
|
|
378
|
+
cause: e,
|
|
379
|
+
}),
|
|
380
|
+
})
|
|
381
|
+
if (createResult instanceof Error) {
|
|
382
|
+
return createResult
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
let diffApplied = false
|
|
386
|
+
|
|
387
|
+
// 2. Apply diff BEFORE submodule update (if provided)
|
|
388
|
+
// This ensures any submodule pointer changes in the diff are applied first,
|
|
389
|
+
// so submodule update checks out the correct commits.
|
|
390
|
+
if (diff) {
|
|
391
|
+
logger.log(`Applying diff to ${worktreeDir} before submodule init`)
|
|
392
|
+
diffApplied = await applyGitDiff(worktreeDir, diff)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// 3. Remove broken submodule stubs before init
|
|
396
|
+
// git worktree creates stub directories with .git files pointing to incomplete gitdirs
|
|
397
|
+
await removeBrokenSubmoduleStubs(worktreeDir)
|
|
398
|
+
|
|
399
|
+
// 4. Init submodules in new worktree
|
|
400
|
+
// Uses --init to initialize, --recursive for nested submodules.
|
|
401
|
+
// Submodules will be checked out at the commit specified by the (possibly updated) index.
|
|
402
|
+
try {
|
|
403
|
+
logger.log(
|
|
404
|
+
`Initializing submodules in ${worktreeDir} (timeout=${SUBMODULE_INIT_TIMEOUT_MS}ms)`,
|
|
405
|
+
)
|
|
406
|
+
await execAsync('git submodule update --init --recursive', {
|
|
407
|
+
cwd: worktreeDir,
|
|
408
|
+
timeout: SUBMODULE_INIT_TIMEOUT_MS,
|
|
409
|
+
})
|
|
410
|
+
logger.log(`Submodules initialized in ${worktreeDir}`)
|
|
411
|
+
} catch (e) {
|
|
412
|
+
const errorMessage = formatCommandError(e)
|
|
413
|
+
logger.error('Submodule initialization failed', {
|
|
414
|
+
worktreeDir,
|
|
415
|
+
timeoutMs: SUBMODULE_INIT_TIMEOUT_MS,
|
|
416
|
+
command: 'git submodule update --init --recursive',
|
|
417
|
+
error: errorMessage,
|
|
418
|
+
})
|
|
419
|
+
return new Error(`Submodule initialization failed: ${errorMessage}`)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// 4.5 Validate submodule pointers and git metadata before marking ready.
|
|
423
|
+
const submoduleValidationError = await validateSubmodulePointers(worktreeDir)
|
|
424
|
+
if (submoduleValidationError instanceof Error) {
|
|
425
|
+
logger.error('Submodule validation failed after init', {
|
|
426
|
+
worktreeDir,
|
|
427
|
+
error: submoduleValidationError.message,
|
|
428
|
+
})
|
|
429
|
+
return submoduleValidationError
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// 5. Dependency install disabled.
|
|
433
|
+
// `npx -y ni` resolved to the wrong npm package `ni` (browser-launcher), not `@antfu/ni`.
|
|
434
|
+
// detectInstallCommand() was built as a replacement but install is skipped for now.
|
|
435
|
+
// Opencode sessions can run install themselves if needed.
|
|
436
|
+
|
|
437
|
+
return { directory: worktreeDir, branch: name, diffApplied }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Captured git diff (both staged and unstaged changes).
|
|
442
|
+
*/
|
|
443
|
+
export type CapturedDiff = {
|
|
444
|
+
unstaged: string
|
|
445
|
+
staged: string
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Capture git diff from a directory (both staged and unstaged changes).
|
|
450
|
+
* Returns null if no changes or on error.
|
|
451
|
+
*/
|
|
452
|
+
export async function captureGitDiff(
|
|
453
|
+
directory: string,
|
|
454
|
+
): Promise<CapturedDiff | null> {
|
|
455
|
+
try {
|
|
456
|
+
// Capture unstaged changes
|
|
457
|
+
const unstagedResult = await execAsync('git diff', { cwd: directory })
|
|
458
|
+
const unstaged = unstagedResult.stdout.trim()
|
|
459
|
+
|
|
460
|
+
// Capture staged changes
|
|
461
|
+
const stagedResult = await execAsync('git diff --staged', {
|
|
462
|
+
cwd: directory,
|
|
463
|
+
})
|
|
464
|
+
const staged = stagedResult.stdout.trim()
|
|
465
|
+
|
|
466
|
+
if (!unstaged && !staged) {
|
|
467
|
+
return null
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return { unstaged, staged }
|
|
471
|
+
} catch (e) {
|
|
472
|
+
logger.warn(
|
|
473
|
+
`Failed to capture git diff from ${directory}: ${e instanceof Error ? e.message : String(e)}`,
|
|
474
|
+
)
|
|
475
|
+
return null
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Run a git command with stdin input.
|
|
481
|
+
* Uses spawn to pipe the diff content to git apply.
|
|
482
|
+
*/
|
|
483
|
+
function runGitWithStdin(
|
|
484
|
+
args: string[],
|
|
485
|
+
cwd: string,
|
|
486
|
+
input: string,
|
|
487
|
+
): Promise<void> {
|
|
488
|
+
return new Promise((resolve, reject) => {
|
|
489
|
+
const child = spawn('git', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
490
|
+
|
|
491
|
+
let stderr = ''
|
|
492
|
+
child.stderr?.on('data', (data) => {
|
|
493
|
+
stderr += data.toString()
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
child.on('close', (code) => {
|
|
497
|
+
if (code === 0) {
|
|
498
|
+
resolve()
|
|
499
|
+
} else {
|
|
500
|
+
reject(
|
|
501
|
+
new Error(stderr || `git ${args.join(' ')} failed with code ${code}`),
|
|
502
|
+
)
|
|
503
|
+
}
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
child.on('error', reject)
|
|
507
|
+
|
|
508
|
+
child.stdin?.write(input)
|
|
509
|
+
child.stdin?.end()
|
|
510
|
+
})
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Apply a captured git diff to a directory.
|
|
515
|
+
* Applies staged changes first, then unstaged.
|
|
516
|
+
*/
|
|
517
|
+
export async function applyGitDiff(
|
|
518
|
+
directory: string,
|
|
519
|
+
diff: CapturedDiff,
|
|
520
|
+
): Promise<boolean> {
|
|
521
|
+
try {
|
|
522
|
+
// Apply staged changes first (and stage them)
|
|
523
|
+
if (diff.staged) {
|
|
524
|
+
logger.log(`Applying staged diff to ${directory}`)
|
|
525
|
+
await runGitWithStdin(['apply', '--index'], directory, diff.staged)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Apply unstaged changes (don't stage them)
|
|
529
|
+
if (diff.unstaged) {
|
|
530
|
+
logger.log(`Applying unstaged diff to ${directory}`)
|
|
531
|
+
await runGitWithStdin(['apply'], directory, diff.unstaged)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
logger.log(`Successfully applied diff to ${directory}`)
|
|
535
|
+
return true
|
|
536
|
+
} catch (e) {
|
|
537
|
+
logger.warn(
|
|
538
|
+
`Failed to apply git diff to ${directory}: ${e instanceof Error ? e.message : String(e)}`,
|
|
539
|
+
)
|
|
540
|
+
return false
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ─── Worktree merge ──────────────────────────────────────────────────────────
|
|
545
|
+
// Implements a worktrunk-style merge pipeline:
|
|
546
|
+
// 1. Reject if uncommitted changes exist
|
|
547
|
+
// 2. Squash all commits since merge-base into one
|
|
548
|
+
// 3. Rebase onto target (default branch)
|
|
549
|
+
// 4. Fast-forward push to target via local git push
|
|
550
|
+
// 5. Switch to detached HEAD, delete branch
|
|
551
|
+
//
|
|
552
|
+
// Uses `git push <git-common-dir> HEAD:<target>` with
|
|
553
|
+
// `receive.denyCurrentBranch=updateInstead` to fast-forward the target
|
|
554
|
+
// WITHOUT checking it out in the main repo.
|
|
555
|
+
//
|
|
556
|
+
// Returns MergeWorktreeErrors | MergeSuccess. All errors are tagged via errore.
|
|
557
|
+
// - DirtyWorktreeError → git untouched
|
|
558
|
+
// - NothingToMergeError → git untouched
|
|
559
|
+
// - SquashError → HEAD may be at merge-base with staged changes
|
|
560
|
+
// - RebaseConflictError → git left mid-rebase for AI/user resolution
|
|
561
|
+
// - RebaseError → rebase not in progress; temp branch cleaned
|
|
562
|
+
// - NotFastForwardError → source intact; no push
|
|
563
|
+
// - ConflictingFilesError → no push; lists overlapping files
|
|
564
|
+
// - PushError → source rebased but target unchanged
|
|
565
|
+
// - GitCommandError → catch-all for unexpected git failures
|
|
566
|
+
|
|
567
|
+
import * as errore from 'errore'
|
|
568
|
+
import {
|
|
569
|
+
DirtyWorktreeError,
|
|
570
|
+
NothingToMergeError,
|
|
571
|
+
SquashError,
|
|
572
|
+
RebaseConflictError,
|
|
573
|
+
RebaseError,
|
|
574
|
+
NotFastForwardError,
|
|
575
|
+
ConflictingFilesError,
|
|
576
|
+
PushError,
|
|
577
|
+
GitCommandError,
|
|
578
|
+
type MergeWorktreeErrors,
|
|
579
|
+
} from './errors.js'
|
|
580
|
+
|
|
581
|
+
export type MergeSuccess = {
|
|
582
|
+
defaultBranch: string
|
|
583
|
+
branchName: string
|
|
584
|
+
commitCount: number
|
|
585
|
+
shortSha: string
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function git(
|
|
589
|
+
dir: string,
|
|
590
|
+
args: string,
|
|
591
|
+
opts?: { timeout?: number },
|
|
592
|
+
): Promise<GitCommandError | string> {
|
|
593
|
+
const result = await errore.tryAsync({
|
|
594
|
+
try: () =>
|
|
595
|
+
execAsync(
|
|
596
|
+
`git -C "${dir}" ${args}`,
|
|
597
|
+
opts ? { timeout: opts.timeout } : undefined,
|
|
598
|
+
),
|
|
599
|
+
catch: (e) => new GitCommandError({ command: args, cause: e }),
|
|
600
|
+
})
|
|
601
|
+
if (result instanceof Error) {
|
|
602
|
+
return result
|
|
603
|
+
}
|
|
604
|
+
return result.stdout.trim()
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function getDefaultBranch(repoDir: string): Promise<string> {
|
|
608
|
+
const ref = await git(repoDir, 'symbolic-ref refs/remotes/origin/HEAD')
|
|
609
|
+
if (ref instanceof Error) {
|
|
610
|
+
return 'main'
|
|
611
|
+
}
|
|
612
|
+
return ref.replace(/^refs\/remotes\/origin\//, '') || 'main'
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async function isDirty(dir: string): Promise<boolean> {
|
|
616
|
+
const status = await git(dir, 'status --porcelain')
|
|
617
|
+
if (status instanceof Error) {
|
|
618
|
+
return false
|
|
619
|
+
}
|
|
620
|
+
return status.length > 0
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function getGitCommonDir(dir: string): Promise<GitCommandError | string> {
|
|
624
|
+
const commonDir = await git(dir, 'rev-parse --git-common-dir')
|
|
625
|
+
if (commonDir instanceof Error) {
|
|
626
|
+
return commonDir
|
|
627
|
+
}
|
|
628
|
+
if (path.isAbsolute(commonDir)) {
|
|
629
|
+
return commonDir
|
|
630
|
+
}
|
|
631
|
+
return path.resolve(dir, commonDir)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function isAncestor(
|
|
635
|
+
dir: string,
|
|
636
|
+
ref1: string,
|
|
637
|
+
ref2: string,
|
|
638
|
+
): Promise<boolean> {
|
|
639
|
+
const result = await git(dir, `merge-base --is-ancestor "${ref1}" "${ref2}"`)
|
|
640
|
+
return !(result instanceof Error)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async function isRebasedOnto(dir: string, target: string): Promise<boolean> {
|
|
644
|
+
const mergeBase = await git(dir, `merge-base HEAD "${target}"`)
|
|
645
|
+
if (mergeBase instanceof Error) {
|
|
646
|
+
return false
|
|
647
|
+
}
|
|
648
|
+
const targetSha = await git(dir, `rev-parse "${target}"`)
|
|
649
|
+
if (targetSha instanceof Error) {
|
|
650
|
+
return false
|
|
651
|
+
}
|
|
652
|
+
return mergeBase === targetSha
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function getChangedFiles(
|
|
656
|
+
dir: string,
|
|
657
|
+
ref1: string,
|
|
658
|
+
ref2: string,
|
|
659
|
+
): Promise<string[]> {
|
|
660
|
+
const result = await git(dir, `diff --name-only "${ref1}" "${ref2}"`)
|
|
661
|
+
if (result instanceof Error) {
|
|
662
|
+
return []
|
|
663
|
+
}
|
|
664
|
+
return result.split('\n').filter(Boolean)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Get dirty files using porcelain -z format.
|
|
669
|
+
* Handles rename/copy entries which emit two NUL-separated paths.
|
|
670
|
+
*/
|
|
671
|
+
async function getDirtyFiles(dir: string): Promise<string[]> {
|
|
672
|
+
const result = await git(dir, 'status --porcelain -z')
|
|
673
|
+
if (result instanceof Error) {
|
|
674
|
+
return []
|
|
675
|
+
}
|
|
676
|
+
const files: string[] = []
|
|
677
|
+
const parts = result.split('\0')
|
|
678
|
+
let i = 0
|
|
679
|
+
while (i < parts.length) {
|
|
680
|
+
const entry = parts[i]
|
|
681
|
+
if (!entry || entry.length < 3) {
|
|
682
|
+
i++
|
|
683
|
+
continue
|
|
684
|
+
}
|
|
685
|
+
const status = entry.slice(0, 2)
|
|
686
|
+
const filePath = entry.slice(3)
|
|
687
|
+
if (filePath) {
|
|
688
|
+
files.push(filePath)
|
|
689
|
+
}
|
|
690
|
+
if (
|
|
691
|
+
status[0] === 'R' ||
|
|
692
|
+
status[0] === 'C' ||
|
|
693
|
+
status[1] === 'R' ||
|
|
694
|
+
status[1] === 'C'
|
|
695
|
+
) {
|
|
696
|
+
i++
|
|
697
|
+
const oldPath = parts[i]
|
|
698
|
+
if (oldPath) {
|
|
699
|
+
files.push(oldPath)
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
i++
|
|
703
|
+
}
|
|
704
|
+
return files
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Check if target worktree has dirty files overlapping with the push range.
|
|
709
|
+
* updateInstead only rejects overlapping files; non-overlapping dirty files
|
|
710
|
+
* are left untouched.
|
|
711
|
+
*/
|
|
712
|
+
async function checkTargetWorktreeConflicts({
|
|
713
|
+
targetDir,
|
|
714
|
+
sourceDir,
|
|
715
|
+
targetBranch,
|
|
716
|
+
}: {
|
|
717
|
+
targetDir: string
|
|
718
|
+
sourceDir: string
|
|
719
|
+
targetBranch: string
|
|
720
|
+
}): Promise<string[] | null> {
|
|
721
|
+
if (!(await isDirty(targetDir))) {
|
|
722
|
+
return null
|
|
723
|
+
}
|
|
724
|
+
const pushFiles = await getChangedFiles(sourceDir, targetBranch, 'HEAD')
|
|
725
|
+
const dirtyFiles = await getDirtyFiles(targetDir)
|
|
726
|
+
const overlapping = pushFiles.filter((f) => {
|
|
727
|
+
return dirtyFiles.includes(f)
|
|
728
|
+
})
|
|
729
|
+
return overlapping.length > 0 ? overlapping : null
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Check if git is mid-rebase by looking for rebase-merge or rebase-apply dirs.
|
|
734
|
+
*/
|
|
735
|
+
async function isRebaseInProgress(dir: string): Promise<boolean> {
|
|
736
|
+
for (const rebaseDir of ['rebase-merge', 'rebase-apply']) {
|
|
737
|
+
const gitPath = await git(dir, `rev-parse --git-path ${rebaseDir}`)
|
|
738
|
+
if (gitPath instanceof Error) {
|
|
739
|
+
continue
|
|
740
|
+
}
|
|
741
|
+
const resolvedPath = path.isAbsolute(gitPath)
|
|
742
|
+
? gitPath
|
|
743
|
+
: path.resolve(dir, gitPath)
|
|
744
|
+
const exists = await fs.promises
|
|
745
|
+
.access(resolvedPath)
|
|
746
|
+
.then(() => {
|
|
747
|
+
return true
|
|
748
|
+
})
|
|
749
|
+
.catch(() => {
|
|
750
|
+
return false
|
|
751
|
+
})
|
|
752
|
+
if (exists) {
|
|
753
|
+
return true
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return false
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
export function buildSquashMessage({
|
|
760
|
+
branchName,
|
|
761
|
+
commitMessages,
|
|
762
|
+
}: {
|
|
763
|
+
branchName: string
|
|
764
|
+
commitMessages: string[]
|
|
765
|
+
}): string {
|
|
766
|
+
const lines: string[] = [`worktree merge: ${branchName}`]
|
|
767
|
+
if (commitMessages.length > 0) {
|
|
768
|
+
lines.push('')
|
|
769
|
+
for (const message of commitMessages) {
|
|
770
|
+
const msgLines = message.split('\n')
|
|
771
|
+
lines.push(`- ${msgLines[0]}`)
|
|
772
|
+
for (const extra of msgLines.slice(1)) {
|
|
773
|
+
lines.push(` ${extra}`)
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return lines.join('\n')
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Merge a worktree branch into the default branch using worktrunk-style pipeline.
|
|
782
|
+
* Returns MergeWorktreeErrors | MergeSuccess.
|
|
783
|
+
*/
|
|
784
|
+
export async function mergeWorktree({
|
|
785
|
+
worktreeDir,
|
|
786
|
+
mainRepoDir,
|
|
787
|
+
worktreeName,
|
|
788
|
+
onProgress,
|
|
789
|
+
}: {
|
|
790
|
+
worktreeDir: string
|
|
791
|
+
mainRepoDir: string
|
|
792
|
+
worktreeName: string
|
|
793
|
+
onProgress?: (message: string) => void
|
|
794
|
+
}): Promise<MergeWorktreeErrors | MergeSuccess> {
|
|
795
|
+
const log = (msg: string) => {
|
|
796
|
+
logger.log(msg)
|
|
797
|
+
onProgress?.(msg)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Resolve current branch. If detached, create a temp branch.
|
|
801
|
+
let branchName: string
|
|
802
|
+
let tempBranch: string | null = null
|
|
803
|
+
const branchResult = await git(worktreeDir, 'symbolic-ref --short HEAD')
|
|
804
|
+
if (branchResult instanceof Error) {
|
|
805
|
+
tempBranch = `kimaki-merge-${Date.now()}`
|
|
806
|
+
const createResult = await git(worktreeDir, `checkout -b "${tempBranch}"`)
|
|
807
|
+
if (createResult instanceof Error) {
|
|
808
|
+
return createResult
|
|
809
|
+
}
|
|
810
|
+
branchName = tempBranch
|
|
811
|
+
} else {
|
|
812
|
+
branchName = branchResult || worktreeName
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const defaultBranch = await getDefaultBranch(mainRepoDir)
|
|
816
|
+
log(`Merging ${branchName} into ${defaultBranch}`)
|
|
817
|
+
|
|
818
|
+
// Best-effort cleanup of temp branch on error paths
|
|
819
|
+
const cleanupTempBranch = async () => {
|
|
820
|
+
if (!tempBranch) {
|
|
821
|
+
return
|
|
822
|
+
}
|
|
823
|
+
await git(worktreeDir, 'checkout --detach')
|
|
824
|
+
await git(worktreeDir, `branch -D "${tempBranch}"`)
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ── Step 1: Reject uncommitted changes ──
|
|
828
|
+
if (await isDirty(worktreeDir)) {
|
|
829
|
+
await cleanupTempBranch()
|
|
830
|
+
return new DirtyWorktreeError()
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// ── Step 2: Squash + Step 3: Rebase ──
|
|
834
|
+
// If already rebased onto target, skip squash+rebase entirely.
|
|
835
|
+
// This happens on retry after the model resolved a rebase conflict --
|
|
836
|
+
// the previous run already squashed, and the model completed the rebase.
|
|
837
|
+
const alreadyRebased = await isRebasedOnto(worktreeDir, defaultBranch)
|
|
838
|
+
|
|
839
|
+
const mergeBaseResult = await git(
|
|
840
|
+
worktreeDir,
|
|
841
|
+
`merge-base HEAD "${defaultBranch}"`,
|
|
842
|
+
)
|
|
843
|
+
const mergeBase =
|
|
844
|
+
mergeBaseResult instanceof Error ? defaultBranch : mergeBaseResult
|
|
845
|
+
|
|
846
|
+
const commitCountResult = await git(
|
|
847
|
+
worktreeDir,
|
|
848
|
+
`rev-list --count "${mergeBase}..HEAD"`,
|
|
849
|
+
)
|
|
850
|
+
if (commitCountResult instanceof Error) {
|
|
851
|
+
await cleanupTempBranch()
|
|
852
|
+
return commitCountResult
|
|
853
|
+
}
|
|
854
|
+
const commitCount = parseInt(commitCountResult, 10)
|
|
855
|
+
|
|
856
|
+
if (commitCount === 0) {
|
|
857
|
+
await cleanupTempBranch()
|
|
858
|
+
return new NothingToMergeError({ target: defaultBranch })
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (!alreadyRebased) {
|
|
862
|
+
// Squash into single commit with full commit messages
|
|
863
|
+
log(
|
|
864
|
+
commitCount > 1
|
|
865
|
+
? `Squashing ${commitCount} commits...`
|
|
866
|
+
: 'Preparing merge commit...',
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
const SEP = '---KIMAKI-COMMIT-SEP---'
|
|
870
|
+
const logRange = `${mergeBase}..HEAD`
|
|
871
|
+
const messagesResult = await git(
|
|
872
|
+
worktreeDir,
|
|
873
|
+
`log --format="%B${SEP}" --reverse "${logRange}"`,
|
|
874
|
+
)
|
|
875
|
+
if (messagesResult instanceof Error) {
|
|
876
|
+
await cleanupTempBranch()
|
|
877
|
+
return new SquashError({
|
|
878
|
+
reason: 'Failed to read commit messages',
|
|
879
|
+
cause: messagesResult,
|
|
880
|
+
})
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const commitMessages = messagesResult
|
|
884
|
+
.split(SEP)
|
|
885
|
+
.map((m) => {
|
|
886
|
+
return m.trim()
|
|
887
|
+
})
|
|
888
|
+
.filter(Boolean)
|
|
889
|
+
|
|
890
|
+
const squashMessage = buildSquashMessage({
|
|
891
|
+
branchName: worktreeName || branchName,
|
|
892
|
+
commitMessages,
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
const resetResult = await git(worktreeDir, `reset --soft "${mergeBase}"`)
|
|
896
|
+
if (resetResult instanceof Error) {
|
|
897
|
+
await cleanupTempBranch()
|
|
898
|
+
return new SquashError({
|
|
899
|
+
reason: 'git reset --soft failed',
|
|
900
|
+
cause: resetResult,
|
|
901
|
+
})
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const commitResult = await errore.tryAsync({
|
|
905
|
+
try: () =>
|
|
906
|
+
runGitWithStdin(['commit', '-m', squashMessage, '--'], worktreeDir, ''),
|
|
907
|
+
catch: (e) =>
|
|
908
|
+
new SquashError({ reason: 'git commit failed after reset', cause: e }),
|
|
909
|
+
})
|
|
910
|
+
if (commitResult instanceof Error) {
|
|
911
|
+
await cleanupTempBranch()
|
|
912
|
+
return commitResult
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Rebase onto target
|
|
916
|
+
log(`Rebasing onto ${defaultBranch}...`)
|
|
917
|
+
const rebaseResult = await git(worktreeDir, `rebase "${defaultBranch}"`, {
|
|
918
|
+
timeout: 60_000,
|
|
919
|
+
})
|
|
920
|
+
if (rebaseResult instanceof Error) {
|
|
921
|
+
if (await isRebaseInProgress(worktreeDir)) {
|
|
922
|
+
return new RebaseConflictError({
|
|
923
|
+
target: defaultBranch,
|
|
924
|
+
cause: rebaseResult,
|
|
925
|
+
})
|
|
926
|
+
}
|
|
927
|
+
await cleanupTempBranch()
|
|
928
|
+
return new RebaseError({ target: defaultBranch, cause: rebaseResult })
|
|
929
|
+
}
|
|
930
|
+
} else {
|
|
931
|
+
log('Already rebased onto target')
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// ── Step 4: Fast-forward push via local git push ──
|
|
935
|
+
if (!(await isAncestor(worktreeDir, defaultBranch, 'HEAD'))) {
|
|
936
|
+
await cleanupTempBranch()
|
|
937
|
+
return new NotFastForwardError({ target: defaultBranch })
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const overlappingFiles = await checkTargetWorktreeConflicts({
|
|
941
|
+
targetDir: mainRepoDir,
|
|
942
|
+
sourceDir: worktreeDir,
|
|
943
|
+
targetBranch: defaultBranch,
|
|
944
|
+
})
|
|
945
|
+
if (overlappingFiles) {
|
|
946
|
+
await cleanupTempBranch()
|
|
947
|
+
return new ConflictingFilesError({ target: defaultBranch })
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const gitCommonDir = await getGitCommonDir(worktreeDir)
|
|
951
|
+
if (gitCommonDir instanceof Error) {
|
|
952
|
+
await cleanupTempBranch()
|
|
953
|
+
return gitCommonDir
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
log(`Pushing to ${defaultBranch}...`)
|
|
957
|
+
const pushResult = await git(
|
|
958
|
+
worktreeDir,
|
|
959
|
+
`push --receive-pack="git -c receive.denyCurrentBranch=updateInstead receive-pack" "${gitCommonDir}" "HEAD:${defaultBranch}"`,
|
|
960
|
+
{ timeout: 30_000 },
|
|
961
|
+
)
|
|
962
|
+
if (pushResult instanceof Error) {
|
|
963
|
+
await cleanupTempBranch()
|
|
964
|
+
return new PushError({ target: defaultBranch, cause: pushResult })
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Get short SHA for display
|
|
968
|
+
const shortSha = await git(worktreeDir, 'rev-parse --short HEAD')
|
|
969
|
+
if (shortSha instanceof Error) {
|
|
970
|
+
// Push succeeded but can't get SHA -- non-fatal, use placeholder
|
|
971
|
+
logger.warn('Failed to get short SHA after push')
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// ── Step 5: Clean up -- detach HEAD and delete branch ──
|
|
975
|
+
log('Cleaning up worktree...')
|
|
976
|
+
await git(worktreeDir, `checkout --detach "${defaultBranch}"`)
|
|
977
|
+
await git(worktreeDir, `branch -D "${branchName}"`)
|
|
978
|
+
if (branchName !== worktreeName && worktreeName) {
|
|
979
|
+
await git(worktreeDir, `branch -D "${worktreeName}"`)
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
return {
|
|
983
|
+
defaultBranch,
|
|
984
|
+
branchName: worktreeName || branchName,
|
|
985
|
+
commitCount,
|
|
986
|
+
shortSha: shortSha instanceof Error ? 'unknown' : shortSha,
|
|
987
|
+
}
|
|
988
|
+
}
|