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