kimaki 0.4.43 ā 0.4.44
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/cli.js +175 -14
- package/dist/commands/merge-worktree.js +152 -0
- package/dist/commands/worktree-settings.js +88 -0
- package/dist/commands/worktree.js +14 -25
- package/dist/database.js +36 -0
- package/dist/discord-bot.js +74 -18
- package/dist/interaction-handler.js +11 -0
- package/dist/session-handler.js +11 -2
- package/dist/system-message.js +25 -1
- package/dist/worktree-utils.js +50 -0
- package/package.json +1 -1
- package/src/cli.ts +213 -16
- package/src/commands/merge-worktree.ts +186 -0
- package/src/commands/worktree-settings.ts +122 -0
- package/src/commands/worktree.ts +14 -28
- package/src/database.ts +43 -0
- package/src/discord-bot.ts +93 -21
- package/src/interaction-handler.ts +17 -0
- package/src/session-handler.ts +14 -2
- package/src/system-message.ts +37 -0
- package/src/worktree-utils.ts +78 -0
package/dist/cli.js
CHANGED
|
@@ -170,6 +170,18 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
|
|
|
170
170
|
return option;
|
|
171
171
|
})
|
|
172
172
|
.toJSON(),
|
|
173
|
+
new SlashCommandBuilder()
|
|
174
|
+
.setName('merge-worktree')
|
|
175
|
+
.setDescription('Merge the worktree branch into the default branch')
|
|
176
|
+
.toJSON(),
|
|
177
|
+
new SlashCommandBuilder()
|
|
178
|
+
.setName('enable-worktrees')
|
|
179
|
+
.setDescription('Enable automatic git worktree creation for new sessions in this channel')
|
|
180
|
+
.toJSON(),
|
|
181
|
+
new SlashCommandBuilder()
|
|
182
|
+
.setName('disable-worktrees')
|
|
183
|
+
.setDescription('Disable automatic git worktree creation for new sessions in this channel')
|
|
184
|
+
.toJSON(),
|
|
173
185
|
new SlashCommandBuilder()
|
|
174
186
|
.setName('add-project')
|
|
175
187
|
.setDescription('Create Discord channels for a new OpenCode project')
|
|
@@ -365,7 +377,7 @@ async function backgroundInit({ currentDir, token, appId, }) {
|
|
|
365
377
|
cliLogger.error('Background init failed:', error instanceof Error ? error.message : String(error));
|
|
366
378
|
}
|
|
367
379
|
}
|
|
368
|
-
async function run({ restart, addChannels }) {
|
|
380
|
+
async function run({ restart, addChannels, useWorktrees }) {
|
|
369
381
|
const forceSetup = Boolean(restart);
|
|
370
382
|
intro('š¤ Discord Bot Setup');
|
|
371
383
|
// Step 0: Check if OpenCode CLI is available
|
|
@@ -600,7 +612,7 @@ async function run({ restart, addChannels }) {
|
|
|
600
612
|
const isQuickStart = existingBot && !forceSetup && !addChannels;
|
|
601
613
|
if (isQuickStart) {
|
|
602
614
|
s.start('Starting Discord bot...');
|
|
603
|
-
await startDiscordBot({ token, appId, discordClient });
|
|
615
|
+
await startDiscordBot({ token, appId, discordClient, useWorktrees });
|
|
604
616
|
s.stop('Discord bot is running!');
|
|
605
617
|
// Background: OpenCode init + slash command registration (non-blocking)
|
|
606
618
|
void backgroundInit({ currentDir, token, appId });
|
|
@@ -731,7 +743,7 @@ async function run({ restart, addChannels }) {
|
|
|
731
743
|
cliLogger.error('Failed to register slash commands:', error instanceof Error ? error.message : String(error));
|
|
732
744
|
});
|
|
733
745
|
s.start('Starting Discord bot...');
|
|
734
|
-
await startDiscordBot({ token, appId, discordClient });
|
|
746
|
+
await startDiscordBot({ token, appId, discordClient, useWorktrees });
|
|
735
747
|
s.stop('Discord bot is running!');
|
|
736
748
|
showReadyMessage({ kimakiChannels, createdChannels, appId });
|
|
737
749
|
outro('⨠Setup complete! Listening for new messages... do not close this process.');
|
|
@@ -742,6 +754,7 @@ cli
|
|
|
742
754
|
.option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
|
|
743
755
|
.option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
|
|
744
756
|
.option('--install-url', 'Print the bot install URL and exit')
|
|
757
|
+
.option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
|
|
745
758
|
.action(async (options) => {
|
|
746
759
|
try {
|
|
747
760
|
// Set data directory early, before any database access
|
|
@@ -767,6 +780,7 @@ cli
|
|
|
767
780
|
restart: options.restart,
|
|
768
781
|
addChannels: options.addChannels,
|
|
769
782
|
dataDir: options.dataDir,
|
|
783
|
+
useWorktrees: options.useWorktrees,
|
|
770
784
|
});
|
|
771
785
|
}
|
|
772
786
|
catch (error) {
|
|
@@ -1031,6 +1045,12 @@ cli
|
|
|
1031
1045
|
// If prompt exceeds this, send it as a file attachment instead.
|
|
1032
1046
|
const DISCORD_MAX_LENGTH = 2000;
|
|
1033
1047
|
let starterMessage;
|
|
1048
|
+
// Embed marker for auto-start sessions (unless --notify-only)
|
|
1049
|
+
// Bot checks for this embed footer to know it should start a session
|
|
1050
|
+
const AUTO_START_MARKER = 'kimaki:start';
|
|
1051
|
+
const autoStartEmbed = notifyOnly
|
|
1052
|
+
? undefined
|
|
1053
|
+
: [{ color: 0x2b2d31, footer: { text: AUTO_START_MARKER } }];
|
|
1034
1054
|
if (prompt.length > DISCORD_MAX_LENGTH) {
|
|
1035
1055
|
// Send as file attachment with a short summary
|
|
1036
1056
|
const preview = prompt.slice(0, 100).replace(/\n/g, ' ');
|
|
@@ -1048,6 +1068,7 @@ cli
|
|
|
1048
1068
|
formData.append('payload_json', JSON.stringify({
|
|
1049
1069
|
content: summaryContent,
|
|
1050
1070
|
attachments: [{ id: 0, filename: 'prompt.md' }],
|
|
1071
|
+
embeds: autoStartEmbed,
|
|
1051
1072
|
}));
|
|
1052
1073
|
const buffer = fs.readFileSync(tmpFile);
|
|
1053
1074
|
formData.append('files[0]', new Blob([buffer], { type: 'text/markdown' }), 'prompt.md');
|
|
@@ -1080,6 +1101,7 @@ cli
|
|
|
1080
1101
|
},
|
|
1081
1102
|
body: JSON.stringify({
|
|
1082
1103
|
content: prompt,
|
|
1104
|
+
embeds: autoStartEmbed,
|
|
1083
1105
|
}),
|
|
1084
1106
|
});
|
|
1085
1107
|
if (!starterMessageResponse.ok) {
|
|
@@ -1109,17 +1131,6 @@ cli
|
|
|
1109
1131
|
throw new Error(`Discord API error: ${threadResponse.status} - ${error}`);
|
|
1110
1132
|
}
|
|
1111
1133
|
const threadData = (await threadResponse.json());
|
|
1112
|
-
// Mark thread for auto-start if not notify-only
|
|
1113
|
-
// This is optional - only works if local database exists (for local bot auto-start)
|
|
1114
|
-
if (!notifyOnly) {
|
|
1115
|
-
try {
|
|
1116
|
-
const db = getDatabase();
|
|
1117
|
-
db.prepare('INSERT OR REPLACE INTO pending_auto_start (thread_id) VALUES (?)').run(threadData.id);
|
|
1118
|
-
}
|
|
1119
|
-
catch {
|
|
1120
|
-
// Database not available (e.g., CI environment) - skip auto-start marking
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
1134
|
s.stop('Thread created!');
|
|
1124
1135
|
const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
|
|
1125
1136
|
const successMessage = notifyOnly
|
|
@@ -1134,5 +1145,155 @@ cli
|
|
|
1134
1145
|
process.exit(EXIT_NO_RESTART);
|
|
1135
1146
|
}
|
|
1136
1147
|
});
|
|
1148
|
+
cli
|
|
1149
|
+
.command('add-project [directory]', 'Create Discord channels for a project directory')
|
|
1150
|
+
.option('-g, --guild <guildId>', 'Discord guild/server ID (auto-detects if bot is in only one server)')
|
|
1151
|
+
.option('-a, --app-id <appId>', 'Bot application ID (reads from database if available)')
|
|
1152
|
+
.action(async (directory, options) => {
|
|
1153
|
+
try {
|
|
1154
|
+
const absolutePath = path.resolve(directory || '.');
|
|
1155
|
+
if (!fs.existsSync(absolutePath)) {
|
|
1156
|
+
cliLogger.error(`Directory does not exist: ${absolutePath}`);
|
|
1157
|
+
process.exit(EXIT_NO_RESTART);
|
|
1158
|
+
}
|
|
1159
|
+
// Get bot token from env var or database
|
|
1160
|
+
const envToken = process.env.KIMAKI_BOT_TOKEN;
|
|
1161
|
+
let botToken;
|
|
1162
|
+
let appId = options.appId;
|
|
1163
|
+
if (envToken) {
|
|
1164
|
+
botToken = envToken;
|
|
1165
|
+
if (!appId) {
|
|
1166
|
+
try {
|
|
1167
|
+
const db = getDatabase();
|
|
1168
|
+
const botRow = db
|
|
1169
|
+
.prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
1170
|
+
.get();
|
|
1171
|
+
appId = botRow?.app_id;
|
|
1172
|
+
}
|
|
1173
|
+
catch {
|
|
1174
|
+
// Database might not exist in CI
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
else {
|
|
1179
|
+
try {
|
|
1180
|
+
const db = getDatabase();
|
|
1181
|
+
const botRow = db
|
|
1182
|
+
.prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
1183
|
+
.get();
|
|
1184
|
+
if (botRow) {
|
|
1185
|
+
botToken = botRow.token;
|
|
1186
|
+
appId = appId || botRow.app_id;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
catch (e) {
|
|
1190
|
+
cliLogger.error('Database error:', e instanceof Error ? e.message : String(e));
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
if (!botToken) {
|
|
1194
|
+
cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.');
|
|
1195
|
+
process.exit(EXIT_NO_RESTART);
|
|
1196
|
+
}
|
|
1197
|
+
if (!appId) {
|
|
1198
|
+
cliLogger.error('App ID is required to create channels. Use --app-id or run `kimaki` first.');
|
|
1199
|
+
process.exit(EXIT_NO_RESTART);
|
|
1200
|
+
}
|
|
1201
|
+
const s = spinner();
|
|
1202
|
+
s.start('Checking for existing channel...');
|
|
1203
|
+
// Check if channel already exists
|
|
1204
|
+
try {
|
|
1205
|
+
const db = getDatabase();
|
|
1206
|
+
const existingChannel = db
|
|
1207
|
+
.prepare('SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?')
|
|
1208
|
+
.get(absolutePath, 'text', appId);
|
|
1209
|
+
if (existingChannel) {
|
|
1210
|
+
s.stop('Channel already exists');
|
|
1211
|
+
note(`Channel already exists for this directory.\n\nChannel: <#${existingChannel.channel_id}>\nDirectory: ${absolutePath}`, 'ā ļø Already Exists');
|
|
1212
|
+
process.exit(0);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
catch {
|
|
1216
|
+
// Database might not exist, continue to create
|
|
1217
|
+
}
|
|
1218
|
+
s.message('Connecting to Discord...');
|
|
1219
|
+
const client = await createDiscordClient();
|
|
1220
|
+
await new Promise((resolve, reject) => {
|
|
1221
|
+
client.once(Events.ClientReady, () => {
|
|
1222
|
+
resolve();
|
|
1223
|
+
});
|
|
1224
|
+
client.once(Events.Error, reject);
|
|
1225
|
+
client.login(botToken);
|
|
1226
|
+
});
|
|
1227
|
+
s.message('Finding guild...');
|
|
1228
|
+
// Find guild
|
|
1229
|
+
let guild;
|
|
1230
|
+
if (options.guild) {
|
|
1231
|
+
const foundGuild = client.guilds.cache.get(options.guild);
|
|
1232
|
+
if (!foundGuild) {
|
|
1233
|
+
s.stop('Guild not found');
|
|
1234
|
+
cliLogger.error(`Guild not found: ${options.guild}`);
|
|
1235
|
+
client.destroy();
|
|
1236
|
+
process.exit(EXIT_NO_RESTART);
|
|
1237
|
+
}
|
|
1238
|
+
guild = foundGuild;
|
|
1239
|
+
}
|
|
1240
|
+
else {
|
|
1241
|
+
// Auto-detect: prefer guild with existing channels for this bot, else first guild
|
|
1242
|
+
const db = getDatabase();
|
|
1243
|
+
const existingChannelRow = db
|
|
1244
|
+
.prepare('SELECT channel_id FROM channel_directories WHERE app_id = ? ORDER BY created_at DESC LIMIT 1')
|
|
1245
|
+
.get(appId);
|
|
1246
|
+
if (existingChannelRow) {
|
|
1247
|
+
try {
|
|
1248
|
+
const ch = await client.channels.fetch(existingChannelRow.channel_id);
|
|
1249
|
+
if (ch && 'guild' in ch && ch.guild) {
|
|
1250
|
+
guild = ch.guild;
|
|
1251
|
+
}
|
|
1252
|
+
else {
|
|
1253
|
+
throw new Error('Channel has no guild');
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
catch {
|
|
1257
|
+
// Channel might be deleted, fall back to first guild
|
|
1258
|
+
const firstGuild = client.guilds.cache.first();
|
|
1259
|
+
if (!firstGuild) {
|
|
1260
|
+
s.stop('No guild found');
|
|
1261
|
+
cliLogger.error('No guild found. Add the bot to a server first.');
|
|
1262
|
+
client.destroy();
|
|
1263
|
+
process.exit(EXIT_NO_RESTART);
|
|
1264
|
+
}
|
|
1265
|
+
guild = firstGuild;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
else {
|
|
1269
|
+
const firstGuild = client.guilds.cache.first();
|
|
1270
|
+
if (!firstGuild) {
|
|
1271
|
+
s.stop('No guild found');
|
|
1272
|
+
cliLogger.error('No guild found. Add the bot to a server first.');
|
|
1273
|
+
client.destroy();
|
|
1274
|
+
process.exit(EXIT_NO_RESTART);
|
|
1275
|
+
}
|
|
1276
|
+
guild = firstGuild;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
s.message(`Creating channels in ${guild.name}...`);
|
|
1280
|
+
const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
|
|
1281
|
+
guild,
|
|
1282
|
+
projectDirectory: absolutePath,
|
|
1283
|
+
appId,
|
|
1284
|
+
botName: client.user?.username,
|
|
1285
|
+
});
|
|
1286
|
+
client.destroy();
|
|
1287
|
+
s.stop('Channels created!');
|
|
1288
|
+
const channelUrl = `https://discord.com/channels/${guild.id}/${textChannelId}`;
|
|
1289
|
+
note(`Created channels for project:\n\nš Text: #${channelName}\nš Voice: #${channelName}\nš Directory: ${absolutePath}\n\nURL: ${channelUrl}`, 'ā
Success');
|
|
1290
|
+
console.log(channelUrl);
|
|
1291
|
+
process.exit(0);
|
|
1292
|
+
}
|
|
1293
|
+
catch (error) {
|
|
1294
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
1295
|
+
process.exit(EXIT_NO_RESTART);
|
|
1296
|
+
}
|
|
1297
|
+
});
|
|
1137
1298
|
cli.help();
|
|
1138
1299
|
cli.parse();
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// /merge-worktree command - Merge worktree commits into main/default branch.
|
|
2
|
+
// Handles both branch-based worktrees and detached HEAD state.
|
|
3
|
+
// After merge, switches to detached HEAD at main so user can keep working.
|
|
4
|
+
import {} from 'discord.js';
|
|
5
|
+
import { getThreadWorktree } from '../database.js';
|
|
6
|
+
import { createLogger } from '../logger.js';
|
|
7
|
+
import { execAsync } from '../worktree-utils.js';
|
|
8
|
+
const logger = createLogger('MERGE-WORKTREE');
|
|
9
|
+
/** Worktree thread title prefix - indicates unmerged worktree */
|
|
10
|
+
export const WORKTREE_PREFIX = '⬦ ';
|
|
11
|
+
/**
|
|
12
|
+
* Remove the worktree prefix from a thread title.
|
|
13
|
+
* Uses Promise.race with timeout since Discord thread title updates can hang.
|
|
14
|
+
*/
|
|
15
|
+
async function removeWorktreePrefixFromTitle(thread) {
|
|
16
|
+
if (!thread.name.startsWith(WORKTREE_PREFIX)) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const newName = thread.name.slice(WORKTREE_PREFIX.length);
|
|
20
|
+
// Race between the edit and a timeout - thread title updates are heavily rate-limited
|
|
21
|
+
const timeoutMs = 5000;
|
|
22
|
+
const editPromise = thread.setName(newName).catch((e) => {
|
|
23
|
+
logger.warn(`Failed to update thread title: ${e instanceof Error ? e.message : String(e)}`);
|
|
24
|
+
});
|
|
25
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
26
|
+
setTimeout(() => {
|
|
27
|
+
logger.warn(`Thread title update timed out after ${timeoutMs}ms`);
|
|
28
|
+
resolve();
|
|
29
|
+
}, timeoutMs);
|
|
30
|
+
});
|
|
31
|
+
await Promise.race([editPromise, timeoutPromise]);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Check if worktree is in detached HEAD state.
|
|
35
|
+
*/
|
|
36
|
+
async function isDetachedHead(worktreeDir) {
|
|
37
|
+
try {
|
|
38
|
+
await execAsync(`git -C "${worktreeDir}" symbolic-ref HEAD`);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Get current branch name (returns null if detached).
|
|
47
|
+
*/
|
|
48
|
+
async function getCurrentBranch(worktreeDir) {
|
|
49
|
+
try {
|
|
50
|
+
const { stdout } = await execAsync(`git -C "${worktreeDir}" symbolic-ref --short HEAD`);
|
|
51
|
+
return stdout.trim() || null;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function handleMergeWorktreeCommand({ command, appId }) {
|
|
58
|
+
await command.deferReply({ ephemeral: false });
|
|
59
|
+
const channel = command.channel;
|
|
60
|
+
// Must be in a thread
|
|
61
|
+
if (!channel || !channel.isThread()) {
|
|
62
|
+
await command.editReply('This command can only be used in a thread');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const thread = channel;
|
|
66
|
+
// Get worktree info from database
|
|
67
|
+
const worktreeInfo = getThreadWorktree(thread.id);
|
|
68
|
+
if (!worktreeInfo) {
|
|
69
|
+
await command.editReply('This thread is not associated with a worktree');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (worktreeInfo.status !== 'ready' || !worktreeInfo.worktree_directory) {
|
|
73
|
+
await command.editReply(`Worktree is not ready (status: ${worktreeInfo.status})${worktreeInfo.error_message ? `: ${worktreeInfo.error_message}` : ''}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const mainRepoDir = worktreeInfo.project_directory;
|
|
77
|
+
const worktreeDir = worktreeInfo.worktree_directory;
|
|
78
|
+
try {
|
|
79
|
+
// 1. Check for uncommitted changes
|
|
80
|
+
const { stdout: status } = await execAsync(`git -C "${worktreeDir}" status --porcelain`);
|
|
81
|
+
if (status.trim()) {
|
|
82
|
+
await command.editReply(`ā Uncommitted changes detected in worktree.\n\nPlease commit your changes first, then retry \`/merge-worktree\`.`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// 2. Get the default branch name
|
|
86
|
+
logger.log(`Getting default branch for ${mainRepoDir}`);
|
|
87
|
+
let defaultBranch;
|
|
88
|
+
try {
|
|
89
|
+
const { stdout } = await execAsync(`git -C "${mainRepoDir}" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@'`);
|
|
90
|
+
defaultBranch = stdout.trim() || 'main';
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
defaultBranch = 'main';
|
|
94
|
+
}
|
|
95
|
+
// 3. Determine if we're on a branch or detached HEAD
|
|
96
|
+
const isDetached = await isDetachedHead(worktreeDir);
|
|
97
|
+
const currentBranch = await getCurrentBranch(worktreeDir);
|
|
98
|
+
let branchToMerge;
|
|
99
|
+
let tempBranch = null;
|
|
100
|
+
if (isDetached) {
|
|
101
|
+
// Create a temporary branch from detached HEAD
|
|
102
|
+
tempBranch = `temp-merge-${Date.now()}`;
|
|
103
|
+
logger.log(`Detached HEAD detected, creating temp branch: ${tempBranch}`);
|
|
104
|
+
await execAsync(`git -C "${worktreeDir}" checkout -b ${tempBranch}`);
|
|
105
|
+
branchToMerge = tempBranch;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
branchToMerge = currentBranch || worktreeInfo.worktree_name;
|
|
109
|
+
}
|
|
110
|
+
logger.log(`Default branch: ${defaultBranch}, branch to merge: ${branchToMerge}`);
|
|
111
|
+
// 4. Merge default branch INTO worktree (handles diverged branches)
|
|
112
|
+
logger.log(`Merging ${defaultBranch} into worktree at ${worktreeDir}`);
|
|
113
|
+
try {
|
|
114
|
+
await execAsync(`git -C "${worktreeDir}" merge ${defaultBranch} --no-edit`);
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
// If merge fails (conflicts), abort and report
|
|
118
|
+
await execAsync(`git -C "${worktreeDir}" merge --abort`).catch(() => { });
|
|
119
|
+
// Clean up temp branch if we created one
|
|
120
|
+
if (tempBranch) {
|
|
121
|
+
await execAsync(`git -C "${worktreeDir}" checkout --detach`).catch(() => { });
|
|
122
|
+
await execAsync(`git -C "${worktreeDir}" branch -D ${tempBranch}`).catch(() => { });
|
|
123
|
+
}
|
|
124
|
+
throw new Error(`Merge conflict - resolve manually in worktree then retry`);
|
|
125
|
+
}
|
|
126
|
+
// 5. Update default branch ref to point to current HEAD
|
|
127
|
+
// Use update-ref instead of fetch because fetch refuses if branch is checked out
|
|
128
|
+
logger.log(`Updating ${defaultBranch} to point to current HEAD`);
|
|
129
|
+
const { stdout: commitHash } = await execAsync(`git -C "${worktreeDir}" rev-parse HEAD`);
|
|
130
|
+
await execAsync(`git -C "${mainRepoDir}" update-ref refs/heads/${defaultBranch} ${commitHash.trim()}`);
|
|
131
|
+
// 6. Switch to detached HEAD at default branch (allows main to be checked out elsewhere)
|
|
132
|
+
logger.log(`Switching to detached HEAD at ${defaultBranch}`);
|
|
133
|
+
await execAsync(`git -C "${worktreeDir}" checkout --detach ${defaultBranch}`);
|
|
134
|
+
// 7. Delete the merged branch (temp or original)
|
|
135
|
+
logger.log(`Deleting merged branch ${branchToMerge}`);
|
|
136
|
+
await execAsync(`git -C "${worktreeDir}" branch -D ${branchToMerge}`).catch(() => { });
|
|
137
|
+
// Also delete the original worktree branch if different from what we merged
|
|
138
|
+
if (!isDetached && branchToMerge !== worktreeInfo.worktree_name) {
|
|
139
|
+
await execAsync(`git -C "${worktreeDir}" branch -D ${worktreeInfo.worktree_name}`).catch(() => { });
|
|
140
|
+
}
|
|
141
|
+
// 8. Remove worktree prefix from thread title (fire and forget with timeout)
|
|
142
|
+
void removeWorktreePrefixFromTitle(thread);
|
|
143
|
+
const sourceDesc = isDetached ? 'detached commits' : `\`${branchToMerge}\``;
|
|
144
|
+
await command.editReply(`ā
Merged ${sourceDesc} into \`${defaultBranch}\`\n\nWorktree now at detached HEAD - you can keep working here.`);
|
|
145
|
+
logger.log(`Successfully merged ${branchToMerge} into ${defaultBranch}`);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
149
|
+
logger.error(`Merge failed: ${errorMsg}`);
|
|
150
|
+
await command.editReply(`ā Merge failed:\n\`\`\`\n${errorMsg}\n\`\`\``);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// /enable-worktrees and /disable-worktrees commands.
|
|
2
|
+
// Allows per-channel opt-in for automatic worktree creation,
|
|
3
|
+
// as an alternative to the global --use-worktrees CLI flag.
|
|
4
|
+
import { ChatInputCommandInteraction, ChannelType } from 'discord.js';
|
|
5
|
+
import { getChannelWorktreesEnabled, setChannelWorktreesEnabled } from '../database.js';
|
|
6
|
+
import { getKimakiMetadata } from '../discord-utils.js';
|
|
7
|
+
import { createLogger } from '../logger.js';
|
|
8
|
+
const worktreeSettingsLogger = createLogger('WORKTREE_SETTINGS');
|
|
9
|
+
/**
|
|
10
|
+
* Handle the /enable-worktrees slash command.
|
|
11
|
+
* Enables automatic worktree creation for new sessions in this channel.
|
|
12
|
+
*/
|
|
13
|
+
export async function handleEnableWorktreesCommand({ command, appId, }) {
|
|
14
|
+
worktreeSettingsLogger.log('[ENABLE_WORKTREES] Command called');
|
|
15
|
+
const channel = command.channel;
|
|
16
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
17
|
+
await command.reply({
|
|
18
|
+
content: 'This command can only be used in text channels (not threads).',
|
|
19
|
+
ephemeral: true,
|
|
20
|
+
});
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const textChannel = channel;
|
|
24
|
+
const metadata = getKimakiMetadata(textChannel);
|
|
25
|
+
if (metadata.channelAppId && metadata.channelAppId !== appId) {
|
|
26
|
+
await command.reply({
|
|
27
|
+
content: 'This channel is configured for a different bot.',
|
|
28
|
+
ephemeral: true,
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!metadata.projectDirectory) {
|
|
33
|
+
await command.reply({
|
|
34
|
+
content: 'This channel is not configured with a project directory.\nAdd a `<kimaki.directory>/path/to/project</kimaki.directory>` tag to the channel description.',
|
|
35
|
+
ephemeral: true,
|
|
36
|
+
});
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const wasEnabled = getChannelWorktreesEnabled(textChannel.id);
|
|
40
|
+
setChannelWorktreesEnabled(textChannel.id, true);
|
|
41
|
+
worktreeSettingsLogger.log(`[ENABLE_WORKTREES] Enabled for channel ${textChannel.id}`);
|
|
42
|
+
await command.reply({
|
|
43
|
+
content: wasEnabled
|
|
44
|
+
? `Worktrees are already enabled for this channel.\n\nNew sessions started from messages in **#${textChannel.name}** will automatically create git worktrees.`
|
|
45
|
+
: `Worktrees **enabled** for this channel.\n\nNew sessions started from messages in **#${textChannel.name}** will now automatically create git worktrees.\n\nUse \`/disable-worktrees\` to turn this off.`,
|
|
46
|
+
ephemeral: true,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Handle the /disable-worktrees slash command.
|
|
51
|
+
* Disables automatic worktree creation for new sessions in this channel.
|
|
52
|
+
*/
|
|
53
|
+
export async function handleDisableWorktreesCommand({ command, appId, }) {
|
|
54
|
+
worktreeSettingsLogger.log('[DISABLE_WORKTREES] Command called');
|
|
55
|
+
const channel = command.channel;
|
|
56
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
57
|
+
await command.reply({
|
|
58
|
+
content: 'This command can only be used in text channels (not threads).',
|
|
59
|
+
ephemeral: true,
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const textChannel = channel;
|
|
64
|
+
const metadata = getKimakiMetadata(textChannel);
|
|
65
|
+
if (metadata.channelAppId && metadata.channelAppId !== appId) {
|
|
66
|
+
await command.reply({
|
|
67
|
+
content: 'This channel is configured for a different bot.',
|
|
68
|
+
ephemeral: true,
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!metadata.projectDirectory) {
|
|
73
|
+
await command.reply({
|
|
74
|
+
content: 'This channel is not configured with a project directory.\nAdd a `<kimaki.directory>/path/to/project</kimaki.directory>` tag to the channel description.',
|
|
75
|
+
ephemeral: true,
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const wasEnabled = getChannelWorktreesEnabled(textChannel.id);
|
|
80
|
+
setChannelWorktreesEnabled(textChannel.id, false);
|
|
81
|
+
worktreeSettingsLogger.log(`[DISABLE_WORKTREES] Disabled for channel ${textChannel.id}`);
|
|
82
|
+
await command.reply({
|
|
83
|
+
content: wasEnabled
|
|
84
|
+
? `Worktrees **disabled** for this channel.\n\nNew sessions started from messages in **#${textChannel.name}** will use the main project directory.\n\nUse \`/enable-worktrees\` to turn this back on.`
|
|
85
|
+
: `Worktrees are already disabled for this channel.\n\nNew sessions will use the main project directory.`,
|
|
86
|
+
ephemeral: true,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -8,6 +8,8 @@ import { initializeOpencodeForDirectory, getOpencodeClientV2 } from '../opencode
|
|
|
8
8
|
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
9
9
|
import { extractTagsArrays } from '../xml.js';
|
|
10
10
|
import { createLogger } from '../logger.js';
|
|
11
|
+
import { createWorktreeWithSubmodules } from '../worktree-utils.js';
|
|
12
|
+
import { WORKTREE_PREFIX } from './merge-worktree.js';
|
|
11
13
|
import * as errore from 'errore';
|
|
12
14
|
const logger = createLogger('WORKTREE');
|
|
13
15
|
class WorktreeError extends Error {
|
|
@@ -17,16 +19,16 @@ class WorktreeError extends Error {
|
|
|
17
19
|
}
|
|
18
20
|
}
|
|
19
21
|
/**
|
|
20
|
-
* Format worktree name: lowercase, spaces to dashes, remove special chars, add kimaki- prefix.
|
|
21
|
-
* "My Feature" ā "kimaki-my-feature"
|
|
22
|
+
* Format worktree name: lowercase, spaces to dashes, remove special chars, add opencode/kimaki- prefix.
|
|
23
|
+
* "My Feature" ā "opencode/kimaki-my-feature"
|
|
22
24
|
*/
|
|
23
|
-
function formatWorktreeName(name) {
|
|
25
|
+
export function formatWorktreeName(name) {
|
|
24
26
|
const formatted = name
|
|
25
27
|
.toLowerCase()
|
|
26
28
|
.trim()
|
|
27
29
|
.replace(/\s+/g, '-')
|
|
28
30
|
.replace(/[^a-z0-9-]/g, '');
|
|
29
|
-
return `kimaki-${formatted}`;
|
|
31
|
+
return `opencode/kimaki-${formatted}`;
|
|
30
32
|
}
|
|
31
33
|
/**
|
|
32
34
|
* Get project directory from channel topic.
|
|
@@ -56,29 +58,16 @@ function getProjectDirectoryFromChannel(channel, appId) {
|
|
|
56
58
|
* Create worktree in background and update starter message when done.
|
|
57
59
|
*/
|
|
58
60
|
async function createWorktreeInBackground({ thread, starterMessage, worktreeName, projectDirectory, clientV2, }) {
|
|
59
|
-
// Create worktree using SDK v2
|
|
61
|
+
// Create worktree using SDK v2 and init submodules
|
|
60
62
|
logger.log(`Creating worktree "${worktreeName}" for project ${projectDirectory}`);
|
|
61
|
-
const worktreeResult = await
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
worktreeCreateInput: {
|
|
66
|
-
name: worktreeName,
|
|
67
|
-
},
|
|
68
|
-
});
|
|
69
|
-
if (response.error) {
|
|
70
|
-
throw new Error(`SDK error: ${JSON.stringify(response.error)}`);
|
|
71
|
-
}
|
|
72
|
-
if (!response.data) {
|
|
73
|
-
throw new Error('No worktree data returned from SDK');
|
|
74
|
-
}
|
|
75
|
-
return response.data;
|
|
76
|
-
},
|
|
77
|
-
catch: (e) => new WorktreeError('Failed to create worktree', { cause: e }),
|
|
63
|
+
const worktreeResult = await createWorktreeWithSubmodules({
|
|
64
|
+
clientV2,
|
|
65
|
+
directory: projectDirectory,
|
|
66
|
+
name: worktreeName,
|
|
78
67
|
});
|
|
79
|
-
if (
|
|
68
|
+
if (worktreeResult instanceof Error) {
|
|
80
69
|
const errorMsg = worktreeResult.message;
|
|
81
|
-
logger.error('[NEW-WORKTREE] Error:', worktreeResult
|
|
70
|
+
logger.error('[NEW-WORKTREE] Error:', worktreeResult);
|
|
82
71
|
setWorktreeError({ threadId: thread.id, errorMessage: errorMsg });
|
|
83
72
|
await starterMessage.edit(`š³ **Worktree: ${worktreeName}**\nā ${errorMsg}`);
|
|
84
73
|
return;
|
|
@@ -146,7 +135,7 @@ export async function handleNewWorktreeCommand({ command, appId, }) {
|
|
|
146
135
|
flags: SILENT_MESSAGE_FLAGS,
|
|
147
136
|
});
|
|
148
137
|
const thread = await starterMessage.startThread({
|
|
149
|
-
name:
|
|
138
|
+
name: `${WORKTREE_PREFIX}worktree: ${worktreeName}`,
|
|
150
139
|
autoArchiveDuration: 1440,
|
|
151
140
|
reason: 'Worktree session',
|
|
152
141
|
});
|
package/dist/database.js
CHANGED
|
@@ -90,6 +90,7 @@ export function getDatabase() {
|
|
|
90
90
|
)
|
|
91
91
|
`);
|
|
92
92
|
runModelMigrations(db);
|
|
93
|
+
runWorktreeSettingsMigrations(db);
|
|
93
94
|
}
|
|
94
95
|
return db;
|
|
95
96
|
}
|
|
@@ -250,6 +251,41 @@ export function deleteThreadWorktree(threadId) {
|
|
|
250
251
|
const db = getDatabase();
|
|
251
252
|
db.prepare('DELETE FROM thread_worktrees WHERE thread_id = ?').run(threadId);
|
|
252
253
|
}
|
|
254
|
+
/**
|
|
255
|
+
* Run migrations for channel worktree settings table.
|
|
256
|
+
* Called on startup. Allows per-channel opt-in for automatic worktree creation.
|
|
257
|
+
*/
|
|
258
|
+
export function runWorktreeSettingsMigrations(database) {
|
|
259
|
+
const targetDb = database || getDatabase();
|
|
260
|
+
targetDb.exec(`
|
|
261
|
+
CREATE TABLE IF NOT EXISTS channel_worktrees (
|
|
262
|
+
channel_id TEXT PRIMARY KEY,
|
|
263
|
+
enabled INTEGER NOT NULL DEFAULT 0,
|
|
264
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
265
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
266
|
+
)
|
|
267
|
+
`);
|
|
268
|
+
dbLogger.log('Channel worktree settings migrations complete');
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Check if automatic worktree creation is enabled for a channel.
|
|
272
|
+
*/
|
|
273
|
+
export function getChannelWorktreesEnabled(channelId) {
|
|
274
|
+
const db = getDatabase();
|
|
275
|
+
const row = db
|
|
276
|
+
.prepare('SELECT enabled FROM channel_worktrees WHERE channel_id = ?')
|
|
277
|
+
.get(channelId);
|
|
278
|
+
return row?.enabled === 1;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Enable or disable automatic worktree creation for a channel.
|
|
282
|
+
*/
|
|
283
|
+
export function setChannelWorktreesEnabled(channelId, enabled) {
|
|
284
|
+
const db = getDatabase();
|
|
285
|
+
db.prepare(`INSERT INTO channel_worktrees (channel_id, enabled, updated_at)
|
|
286
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
287
|
+
ON CONFLICT(channel_id) DO UPDATE SET enabled = ?, updated_at = CURRENT_TIMESTAMP`).run(channelId, enabled ? 1 : 0, enabled ? 1 : 0);
|
|
288
|
+
}
|
|
253
289
|
export function closeDatabase() {
|
|
254
290
|
if (db) {
|
|
255
291
|
db.close();
|