kimaki 0.4.42 → 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 +236 -30
- 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 +63 -27
- package/dist/system-message.js +44 -5
- package/dist/voice-handler.js +1 -0
- package/dist/worktree-utils.js +50 -0
- package/package.json +2 -2
- package/src/cli.ts +287 -35
- 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 +71 -31
- package/src/system-message.ts +56 -4
- package/src/voice-handler.ts +1 -0
- package/src/worktree-utils.ts +78 -0
package/src/cli.ts
CHANGED
|
@@ -174,6 +174,7 @@ type CliOptions = {
|
|
|
174
174
|
restart?: boolean
|
|
175
175
|
addChannels?: boolean
|
|
176
176
|
dataDir?: string
|
|
177
|
+
useWorktrees?: boolean
|
|
177
178
|
}
|
|
178
179
|
|
|
179
180
|
// Commands to skip when registering user commands (reserved names)
|
|
@@ -249,6 +250,18 @@ async function registerCommands({
|
|
|
249
250
|
return option
|
|
250
251
|
})
|
|
251
252
|
.toJSON(),
|
|
253
|
+
new SlashCommandBuilder()
|
|
254
|
+
.setName('merge-worktree')
|
|
255
|
+
.setDescription('Merge the worktree branch into the default branch')
|
|
256
|
+
.toJSON(),
|
|
257
|
+
new SlashCommandBuilder()
|
|
258
|
+
.setName('enable-worktrees')
|
|
259
|
+
.setDescription('Enable automatic git worktree creation for new sessions in this channel')
|
|
260
|
+
.toJSON(),
|
|
261
|
+
new SlashCommandBuilder()
|
|
262
|
+
.setName('disable-worktrees')
|
|
263
|
+
.setDescription('Disable automatic git worktree creation for new sessions in this channel')
|
|
264
|
+
.toJSON(),
|
|
252
265
|
new SlashCommandBuilder()
|
|
253
266
|
.setName('add-project')
|
|
254
267
|
.setDescription('Create Discord channels for a new OpenCode project')
|
|
@@ -516,7 +529,7 @@ async function backgroundInit({
|
|
|
516
529
|
}
|
|
517
530
|
}
|
|
518
531
|
|
|
519
|
-
async function run({ restart, addChannels }: CliOptions) {
|
|
532
|
+
async function run({ restart, addChannels, useWorktrees }: CliOptions) {
|
|
520
533
|
const forceSetup = Boolean(restart)
|
|
521
534
|
|
|
522
535
|
intro('🤖 Discord Bot Setup')
|
|
@@ -827,7 +840,7 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
827
840
|
const isQuickStart = existingBot && !forceSetup && !addChannels
|
|
828
841
|
if (isQuickStart) {
|
|
829
842
|
s.start('Starting Discord bot...')
|
|
830
|
-
await startDiscordBot({ token, appId, discordClient })
|
|
843
|
+
await startDiscordBot({ token, appId, discordClient, useWorktrees })
|
|
831
844
|
s.stop('Discord bot is running!')
|
|
832
845
|
|
|
833
846
|
// Background: OpenCode init + slash command registration (non-blocking)
|
|
@@ -998,7 +1011,7 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
998
1011
|
})
|
|
999
1012
|
|
|
1000
1013
|
s.start('Starting Discord bot...')
|
|
1001
|
-
await startDiscordBot({ token, appId, discordClient })
|
|
1014
|
+
await startDiscordBot({ token, appId, discordClient, useWorktrees })
|
|
1002
1015
|
s.stop('Discord bot is running!')
|
|
1003
1016
|
|
|
1004
1017
|
showReadyMessage({ kimakiChannels, createdChannels, appId })
|
|
@@ -1011,12 +1024,14 @@ cli
|
|
|
1011
1024
|
.option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
|
|
1012
1025
|
.option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
|
|
1013
1026
|
.option('--install-url', 'Print the bot install URL and exit')
|
|
1027
|
+
.option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
|
|
1014
1028
|
.action(
|
|
1015
1029
|
async (options: {
|
|
1016
1030
|
restart?: boolean
|
|
1017
1031
|
addChannels?: boolean
|
|
1018
1032
|
dataDir?: string
|
|
1019
1033
|
installUrl?: boolean
|
|
1034
|
+
useWorktrees?: boolean
|
|
1020
1035
|
}) => {
|
|
1021
1036
|
try {
|
|
1022
1037
|
// Set data directory early, before any database access
|
|
@@ -1046,6 +1061,7 @@ cli
|
|
|
1046
1061
|
restart: options.restart,
|
|
1047
1062
|
addChannels: options.addChannels,
|
|
1048
1063
|
dataDir: options.dataDir,
|
|
1064
|
+
useWorktrees: options.useWorktrees,
|
|
1049
1065
|
})
|
|
1050
1066
|
} catch (error) {
|
|
1051
1067
|
cliLogger.error('Unhandled error:', error instanceof Error ? error.message : String(error))
|
|
@@ -1381,28 +1397,92 @@ cli
|
|
|
1381
1397
|
|
|
1382
1398
|
s.message('Creating starter message...')
|
|
1383
1399
|
|
|
1384
|
-
//
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1400
|
+
// Discord has a 2000 character limit for messages.
|
|
1401
|
+
// If prompt exceeds this, send it as a file attachment instead.
|
|
1402
|
+
const DISCORD_MAX_LENGTH = 2000
|
|
1403
|
+
let starterMessage: { id: string }
|
|
1404
|
+
|
|
1405
|
+
// Embed marker for auto-start sessions (unless --notify-only)
|
|
1406
|
+
// Bot checks for this embed footer to know it should start a session
|
|
1407
|
+
const AUTO_START_MARKER = 'kimaki:start'
|
|
1408
|
+
const autoStartEmbed = notifyOnly
|
|
1409
|
+
? undefined
|
|
1410
|
+
: [{ color: 0x2b2d31, footer: { text: AUTO_START_MARKER } }]
|
|
1411
|
+
|
|
1412
|
+
if (prompt.length > DISCORD_MAX_LENGTH) {
|
|
1413
|
+
// Send as file attachment with a short summary
|
|
1414
|
+
const preview = prompt.slice(0, 100).replace(/\n/g, ' ')
|
|
1415
|
+
const summaryContent = `📄 **Prompt attached as file** (${prompt.length} chars)\n\n> ${preview}...`
|
|
1416
|
+
|
|
1417
|
+
// Write prompt to a temp file
|
|
1418
|
+
const tmpDir = path.join(process.cwd(), 'tmp')
|
|
1419
|
+
if (!fs.existsSync(tmpDir)) {
|
|
1420
|
+
fs.mkdirSync(tmpDir, { recursive: true })
|
|
1421
|
+
}
|
|
1422
|
+
const tmpFile = path.join(tmpDir, `prompt-${Date.now()}.md`)
|
|
1423
|
+
fs.writeFileSync(tmpFile, prompt)
|
|
1424
|
+
|
|
1425
|
+
try {
|
|
1426
|
+
// Create message with file attachment
|
|
1427
|
+
const formData = new FormData()
|
|
1428
|
+
formData.append(
|
|
1429
|
+
'payload_json',
|
|
1430
|
+
JSON.stringify({
|
|
1431
|
+
content: summaryContent,
|
|
1432
|
+
attachments: [{ id: 0, filename: 'prompt.md' }],
|
|
1433
|
+
embeds: autoStartEmbed,
|
|
1434
|
+
}),
|
|
1435
|
+
)
|
|
1436
|
+
const buffer = fs.readFileSync(tmpFile)
|
|
1437
|
+
formData.append('files[0]', new Blob([buffer], { type: 'text/markdown' }), 'prompt.md')
|
|
1438
|
+
|
|
1439
|
+
const starterMessageResponse = await fetch(
|
|
1440
|
+
`https://discord.com/api/v10/channels/${channelId}/messages`,
|
|
1441
|
+
{
|
|
1442
|
+
method: 'POST',
|
|
1443
|
+
headers: {
|
|
1444
|
+
Authorization: `Bot ${botToken}`,
|
|
1445
|
+
},
|
|
1446
|
+
body: formData,
|
|
1447
|
+
},
|
|
1448
|
+
)
|
|
1449
|
+
|
|
1450
|
+
if (!starterMessageResponse.ok) {
|
|
1451
|
+
const error = await starterMessageResponse.text()
|
|
1452
|
+
s.stop('Failed to create message')
|
|
1453
|
+
throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`)
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
starterMessage = (await starterMessageResponse.json()) as { id: string }
|
|
1457
|
+
} finally {
|
|
1458
|
+
// Clean up temp file
|
|
1459
|
+
fs.unlinkSync(tmpFile)
|
|
1460
|
+
}
|
|
1461
|
+
} else {
|
|
1462
|
+
// Normal case: send prompt inline
|
|
1463
|
+
const starterMessageResponse = await fetch(
|
|
1464
|
+
`https://discord.com/api/v10/channels/${channelId}/messages`,
|
|
1465
|
+
{
|
|
1466
|
+
method: 'POST',
|
|
1467
|
+
headers: {
|
|
1468
|
+
Authorization: `Bot ${botToken}`,
|
|
1469
|
+
'Content-Type': 'application/json',
|
|
1470
|
+
},
|
|
1471
|
+
body: JSON.stringify({
|
|
1472
|
+
content: prompt,
|
|
1473
|
+
embeds: autoStartEmbed,
|
|
1474
|
+
}),
|
|
1392
1475
|
},
|
|
1393
|
-
|
|
1394
|
-
content: prompt,
|
|
1395
|
-
}),
|
|
1396
|
-
},
|
|
1397
|
-
)
|
|
1476
|
+
)
|
|
1398
1477
|
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1478
|
+
if (!starterMessageResponse.ok) {
|
|
1479
|
+
const error = await starterMessageResponse.text()
|
|
1480
|
+
s.stop('Failed to create message')
|
|
1481
|
+
throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`)
|
|
1482
|
+
}
|
|
1404
1483
|
|
|
1405
|
-
|
|
1484
|
+
starterMessage = (await starterMessageResponse.json()) as { id: string }
|
|
1485
|
+
}
|
|
1406
1486
|
|
|
1407
1487
|
s.message('Creating thread...')
|
|
1408
1488
|
|
|
@@ -1431,19 +1511,6 @@ cli
|
|
|
1431
1511
|
|
|
1432
1512
|
const threadData = (await threadResponse.json()) as { id: string; name: string }
|
|
1433
1513
|
|
|
1434
|
-
// Mark thread for auto-start if not notify-only
|
|
1435
|
-
// This is optional - only works if local database exists (for local bot auto-start)
|
|
1436
|
-
if (!notifyOnly) {
|
|
1437
|
-
try {
|
|
1438
|
-
const db = getDatabase()
|
|
1439
|
-
db.prepare('INSERT OR REPLACE INTO pending_auto_start (thread_id) VALUES (?)').run(
|
|
1440
|
-
threadData.id,
|
|
1441
|
-
)
|
|
1442
|
-
} catch {
|
|
1443
|
-
// Database not available (e.g., CI environment) - skip auto-start marking
|
|
1444
|
-
}
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
1514
|
s.stop('Thread created!')
|
|
1448
1515
|
|
|
1449
1516
|
const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`
|
|
@@ -1463,5 +1530,190 @@ cli
|
|
|
1463
1530
|
}
|
|
1464
1531
|
})
|
|
1465
1532
|
|
|
1533
|
+
cli
|
|
1534
|
+
.command('add-project [directory]', 'Create Discord channels for a project directory')
|
|
1535
|
+
.option('-g, --guild <guildId>', 'Discord guild/server ID (auto-detects if bot is in only one server)')
|
|
1536
|
+
.option('-a, --app-id <appId>', 'Bot application ID (reads from database if available)')
|
|
1537
|
+
.action(
|
|
1538
|
+
async (
|
|
1539
|
+
directory: string | undefined,
|
|
1540
|
+
options: {
|
|
1541
|
+
guild?: string
|
|
1542
|
+
appId?: string
|
|
1543
|
+
},
|
|
1544
|
+
) => {
|
|
1545
|
+
try {
|
|
1546
|
+
const absolutePath = path.resolve(directory || '.')
|
|
1547
|
+
|
|
1548
|
+
if (!fs.existsSync(absolutePath)) {
|
|
1549
|
+
cliLogger.error(`Directory does not exist: ${absolutePath}`)
|
|
1550
|
+
process.exit(EXIT_NO_RESTART)
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// Get bot token from env var or database
|
|
1554
|
+
const envToken = process.env.KIMAKI_BOT_TOKEN
|
|
1555
|
+
let botToken: string | undefined
|
|
1556
|
+
let appId: string | undefined = options.appId
|
|
1557
|
+
|
|
1558
|
+
if (envToken) {
|
|
1559
|
+
botToken = envToken
|
|
1560
|
+
if (!appId) {
|
|
1561
|
+
try {
|
|
1562
|
+
const db = getDatabase()
|
|
1563
|
+
const botRow = db
|
|
1564
|
+
.prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
1565
|
+
.get() as { app_id: string } | undefined
|
|
1566
|
+
appId = botRow?.app_id
|
|
1567
|
+
} catch {
|
|
1568
|
+
// Database might not exist in CI
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
} else {
|
|
1572
|
+
try {
|
|
1573
|
+
const db = getDatabase()
|
|
1574
|
+
const botRow = db
|
|
1575
|
+
.prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
1576
|
+
.get() as { app_id: string; token: string } | undefined
|
|
1577
|
+
|
|
1578
|
+
if (botRow) {
|
|
1579
|
+
botToken = botRow.token
|
|
1580
|
+
appId = appId || botRow.app_id
|
|
1581
|
+
}
|
|
1582
|
+
} catch (e) {
|
|
1583
|
+
cliLogger.error('Database error:', e instanceof Error ? e.message : String(e))
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
if (!botToken) {
|
|
1588
|
+
cliLogger.error(
|
|
1589
|
+
'No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.',
|
|
1590
|
+
)
|
|
1591
|
+
process.exit(EXIT_NO_RESTART)
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
if (!appId) {
|
|
1595
|
+
cliLogger.error(
|
|
1596
|
+
'App ID is required to create channels. Use --app-id or run `kimaki` first.',
|
|
1597
|
+
)
|
|
1598
|
+
process.exit(EXIT_NO_RESTART)
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
const s = spinner()
|
|
1602
|
+
s.start('Checking for existing channel...')
|
|
1603
|
+
|
|
1604
|
+
// Check if channel already exists
|
|
1605
|
+
try {
|
|
1606
|
+
const db = getDatabase()
|
|
1607
|
+
const existingChannel = db
|
|
1608
|
+
.prepare(
|
|
1609
|
+
'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?',
|
|
1610
|
+
)
|
|
1611
|
+
.get(absolutePath, 'text', appId) as { channel_id: string } | undefined
|
|
1612
|
+
|
|
1613
|
+
if (existingChannel) {
|
|
1614
|
+
s.stop('Channel already exists')
|
|
1615
|
+
note(
|
|
1616
|
+
`Channel already exists for this directory.\n\nChannel: <#${existingChannel.channel_id}>\nDirectory: ${absolutePath}`,
|
|
1617
|
+
'⚠️ Already Exists',
|
|
1618
|
+
)
|
|
1619
|
+
process.exit(0)
|
|
1620
|
+
}
|
|
1621
|
+
} catch {
|
|
1622
|
+
// Database might not exist, continue to create
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
s.message('Connecting to Discord...')
|
|
1626
|
+
const client = await createDiscordClient()
|
|
1627
|
+
|
|
1628
|
+
await new Promise<void>((resolve, reject) => {
|
|
1629
|
+
client.once(Events.ClientReady, () => {
|
|
1630
|
+
resolve()
|
|
1631
|
+
})
|
|
1632
|
+
client.once(Events.Error, reject)
|
|
1633
|
+
client.login(botToken)
|
|
1634
|
+
})
|
|
1635
|
+
|
|
1636
|
+
s.message('Finding guild...')
|
|
1637
|
+
|
|
1638
|
+
// Find guild
|
|
1639
|
+
let guild: Guild
|
|
1640
|
+
if (options.guild) {
|
|
1641
|
+
const foundGuild = client.guilds.cache.get(options.guild)
|
|
1642
|
+
if (!foundGuild) {
|
|
1643
|
+
s.stop('Guild not found')
|
|
1644
|
+
cliLogger.error(`Guild not found: ${options.guild}`)
|
|
1645
|
+
client.destroy()
|
|
1646
|
+
process.exit(EXIT_NO_RESTART)
|
|
1647
|
+
}
|
|
1648
|
+
guild = foundGuild
|
|
1649
|
+
} else {
|
|
1650
|
+
// Auto-detect: prefer guild with existing channels for this bot, else first guild
|
|
1651
|
+
const db = getDatabase()
|
|
1652
|
+
const existingChannelRow = db
|
|
1653
|
+
.prepare(
|
|
1654
|
+
'SELECT channel_id FROM channel_directories WHERE app_id = ? ORDER BY created_at DESC LIMIT 1',
|
|
1655
|
+
)
|
|
1656
|
+
.get(appId) as { channel_id: string } | undefined
|
|
1657
|
+
|
|
1658
|
+
if (existingChannelRow) {
|
|
1659
|
+
try {
|
|
1660
|
+
const ch = await client.channels.fetch(existingChannelRow.channel_id)
|
|
1661
|
+
if (ch && 'guild' in ch && ch.guild) {
|
|
1662
|
+
guild = ch.guild
|
|
1663
|
+
} else {
|
|
1664
|
+
throw new Error('Channel has no guild')
|
|
1665
|
+
}
|
|
1666
|
+
} catch {
|
|
1667
|
+
// Channel might be deleted, fall back to first guild
|
|
1668
|
+
const firstGuild = client.guilds.cache.first()
|
|
1669
|
+
if (!firstGuild) {
|
|
1670
|
+
s.stop('No guild found')
|
|
1671
|
+
cliLogger.error('No guild found. Add the bot to a server first.')
|
|
1672
|
+
client.destroy()
|
|
1673
|
+
process.exit(EXIT_NO_RESTART)
|
|
1674
|
+
}
|
|
1675
|
+
guild = firstGuild
|
|
1676
|
+
}
|
|
1677
|
+
} else {
|
|
1678
|
+
const firstGuild = client.guilds.cache.first()
|
|
1679
|
+
if (!firstGuild) {
|
|
1680
|
+
s.stop('No guild found')
|
|
1681
|
+
cliLogger.error('No guild found. Add the bot to a server first.')
|
|
1682
|
+
client.destroy()
|
|
1683
|
+
process.exit(EXIT_NO_RESTART)
|
|
1684
|
+
}
|
|
1685
|
+
guild = firstGuild
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
s.message(`Creating channels in ${guild.name}...`)
|
|
1690
|
+
|
|
1691
|
+
const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
|
|
1692
|
+
guild,
|
|
1693
|
+
projectDirectory: absolutePath,
|
|
1694
|
+
appId,
|
|
1695
|
+
botName: client.user?.username,
|
|
1696
|
+
})
|
|
1697
|
+
|
|
1698
|
+
client.destroy()
|
|
1699
|
+
|
|
1700
|
+
s.stop('Channels created!')
|
|
1701
|
+
|
|
1702
|
+
const channelUrl = `https://discord.com/channels/${guild.id}/${textChannelId}`
|
|
1703
|
+
|
|
1704
|
+
note(
|
|
1705
|
+
`Created channels for project:\n\n📝 Text: #${channelName}\n🔊 Voice: #${channelName}\n📁 Directory: ${absolutePath}\n\nURL: ${channelUrl}`,
|
|
1706
|
+
'✅ Success',
|
|
1707
|
+
)
|
|
1708
|
+
|
|
1709
|
+
console.log(channelUrl)
|
|
1710
|
+
process.exit(0)
|
|
1711
|
+
} catch (error) {
|
|
1712
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error))
|
|
1713
|
+
process.exit(EXIT_NO_RESTART)
|
|
1714
|
+
}
|
|
1715
|
+
},
|
|
1716
|
+
)
|
|
1717
|
+
|
|
1466
1718
|
cli.help()
|
|
1467
1719
|
cli.parse()
|
|
@@ -0,0 +1,186 @@
|
|
|
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
|
+
|
|
5
|
+
import { type ThreadChannel } from 'discord.js'
|
|
6
|
+
import type { CommandContext } from './types.js'
|
|
7
|
+
import { getThreadWorktree } from '../database.js'
|
|
8
|
+
import { createLogger } from '../logger.js'
|
|
9
|
+
import { execAsync } from '../worktree-utils.js'
|
|
10
|
+
|
|
11
|
+
const logger = createLogger('MERGE-WORKTREE')
|
|
12
|
+
|
|
13
|
+
/** Worktree thread title prefix - indicates unmerged worktree */
|
|
14
|
+
export const WORKTREE_PREFIX = '⬦ '
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Remove the worktree prefix from a thread title.
|
|
18
|
+
* Uses Promise.race with timeout since Discord thread title updates can hang.
|
|
19
|
+
*/
|
|
20
|
+
async function removeWorktreePrefixFromTitle(thread: ThreadChannel): Promise<void> {
|
|
21
|
+
if (!thread.name.startsWith(WORKTREE_PREFIX)) {
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const newName = thread.name.slice(WORKTREE_PREFIX.length)
|
|
26
|
+
|
|
27
|
+
// Race between the edit and a timeout - thread title updates are heavily rate-limited
|
|
28
|
+
const timeoutMs = 5000
|
|
29
|
+
const editPromise = thread.setName(newName).catch((e) => {
|
|
30
|
+
logger.warn(`Failed to update thread title: ${e instanceof Error ? e.message : String(e)}`)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const timeoutPromise = new Promise<void>((resolve) => {
|
|
34
|
+
setTimeout(() => {
|
|
35
|
+
logger.warn(`Thread title update timed out after ${timeoutMs}ms`)
|
|
36
|
+
resolve()
|
|
37
|
+
}, timeoutMs)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
await Promise.race([editPromise, timeoutPromise])
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if worktree is in detached HEAD state.
|
|
45
|
+
*/
|
|
46
|
+
async function isDetachedHead(worktreeDir: string): Promise<boolean> {
|
|
47
|
+
try {
|
|
48
|
+
await execAsync(`git -C "${worktreeDir}" symbolic-ref HEAD`)
|
|
49
|
+
return false
|
|
50
|
+
} catch {
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get current branch name (returns null if detached).
|
|
57
|
+
*/
|
|
58
|
+
async function getCurrentBranch(worktreeDir: string): Promise<string | null> {
|
|
59
|
+
try {
|
|
60
|
+
const { stdout } = await execAsync(`git -C "${worktreeDir}" symbolic-ref --short HEAD`)
|
|
61
|
+
return stdout.trim() || null
|
|
62
|
+
} catch {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function handleMergeWorktreeCommand({ command, appId }: CommandContext): Promise<void> {
|
|
68
|
+
await command.deferReply({ ephemeral: false })
|
|
69
|
+
|
|
70
|
+
const channel = command.channel
|
|
71
|
+
|
|
72
|
+
// Must be in a thread
|
|
73
|
+
if (!channel || !channel.isThread()) {
|
|
74
|
+
await command.editReply('This command can only be used in a thread')
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const thread = channel as ThreadChannel
|
|
79
|
+
|
|
80
|
+
// Get worktree info from database
|
|
81
|
+
const worktreeInfo = getThreadWorktree(thread.id)
|
|
82
|
+
if (!worktreeInfo) {
|
|
83
|
+
await command.editReply('This thread is not associated with a worktree')
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (worktreeInfo.status !== 'ready' || !worktreeInfo.worktree_directory) {
|
|
88
|
+
await command.editReply(
|
|
89
|
+
`Worktree is not ready (status: ${worktreeInfo.status})${worktreeInfo.error_message ? `: ${worktreeInfo.error_message}` : ''}`,
|
|
90
|
+
)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const mainRepoDir = worktreeInfo.project_directory
|
|
95
|
+
const worktreeDir = worktreeInfo.worktree_directory
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
// 1. Check for uncommitted changes
|
|
99
|
+
const { stdout: status } = await execAsync(`git -C "${worktreeDir}" status --porcelain`)
|
|
100
|
+
if (status.trim()) {
|
|
101
|
+
await command.editReply(
|
|
102
|
+
`❌ Uncommitted changes detected in worktree.\n\nPlease commit your changes first, then retry \`/merge-worktree\`.`,
|
|
103
|
+
)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 2. Get the default branch name
|
|
108
|
+
logger.log(`Getting default branch for ${mainRepoDir}`)
|
|
109
|
+
let defaultBranch: string
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const { stdout } = await execAsync(
|
|
113
|
+
`git -C "${mainRepoDir}" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@'`,
|
|
114
|
+
)
|
|
115
|
+
defaultBranch = stdout.trim() || 'main'
|
|
116
|
+
} catch {
|
|
117
|
+
defaultBranch = 'main'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 3. Determine if we're on a branch or detached HEAD
|
|
121
|
+
const isDetached = await isDetachedHead(worktreeDir)
|
|
122
|
+
const currentBranch = await getCurrentBranch(worktreeDir)
|
|
123
|
+
let branchToMerge: string
|
|
124
|
+
let tempBranch: string | null = null
|
|
125
|
+
|
|
126
|
+
if (isDetached) {
|
|
127
|
+
// Create a temporary branch from detached HEAD
|
|
128
|
+
tempBranch = `temp-merge-${Date.now()}`
|
|
129
|
+
logger.log(`Detached HEAD detected, creating temp branch: ${tempBranch}`)
|
|
130
|
+
await execAsync(`git -C "${worktreeDir}" checkout -b ${tempBranch}`)
|
|
131
|
+
branchToMerge = tempBranch
|
|
132
|
+
} else {
|
|
133
|
+
branchToMerge = currentBranch || worktreeInfo.worktree_name
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
logger.log(`Default branch: ${defaultBranch}, branch to merge: ${branchToMerge}`)
|
|
137
|
+
|
|
138
|
+
// 4. Merge default branch INTO worktree (handles diverged branches)
|
|
139
|
+
logger.log(`Merging ${defaultBranch} into worktree at ${worktreeDir}`)
|
|
140
|
+
try {
|
|
141
|
+
await execAsync(`git -C "${worktreeDir}" merge ${defaultBranch} --no-edit`)
|
|
142
|
+
} catch (e) {
|
|
143
|
+
// If merge fails (conflicts), abort and report
|
|
144
|
+
await execAsync(`git -C "${worktreeDir}" merge --abort`).catch(() => {})
|
|
145
|
+
// Clean up temp branch if we created one
|
|
146
|
+
if (tempBranch) {
|
|
147
|
+
await execAsync(`git -C "${worktreeDir}" checkout --detach`).catch(() => {})
|
|
148
|
+
await execAsync(`git -C "${worktreeDir}" branch -D ${tempBranch}`).catch(() => {})
|
|
149
|
+
}
|
|
150
|
+
throw new Error(`Merge conflict - resolve manually in worktree then retry`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 5. Update default branch ref to point to current HEAD
|
|
154
|
+
// Use update-ref instead of fetch because fetch refuses if branch is checked out
|
|
155
|
+
logger.log(`Updating ${defaultBranch} to point to current HEAD`)
|
|
156
|
+
const { stdout: commitHash } = await execAsync(`git -C "${worktreeDir}" rev-parse HEAD`)
|
|
157
|
+
await execAsync(`git -C "${mainRepoDir}" update-ref refs/heads/${defaultBranch} ${commitHash.trim()}`)
|
|
158
|
+
|
|
159
|
+
// 6. Switch to detached HEAD at default branch (allows main to be checked out elsewhere)
|
|
160
|
+
logger.log(`Switching to detached HEAD at ${defaultBranch}`)
|
|
161
|
+
await execAsync(`git -C "${worktreeDir}" checkout --detach ${defaultBranch}`)
|
|
162
|
+
|
|
163
|
+
// 7. Delete the merged branch (temp or original)
|
|
164
|
+
logger.log(`Deleting merged branch ${branchToMerge}`)
|
|
165
|
+
await execAsync(`git -C "${worktreeDir}" branch -D ${branchToMerge}`).catch(() => {})
|
|
166
|
+
|
|
167
|
+
// Also delete the original worktree branch if different from what we merged
|
|
168
|
+
if (!isDetached && branchToMerge !== worktreeInfo.worktree_name) {
|
|
169
|
+
await execAsync(`git -C "${worktreeDir}" branch -D ${worktreeInfo.worktree_name}`).catch(() => {})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 8. Remove worktree prefix from thread title (fire and forget with timeout)
|
|
173
|
+
void removeWorktreePrefixFromTitle(thread)
|
|
174
|
+
|
|
175
|
+
const sourceDesc = isDetached ? 'detached commits' : `\`${branchToMerge}\``
|
|
176
|
+
await command.editReply(
|
|
177
|
+
`✅ Merged ${sourceDesc} into \`${defaultBranch}\`\n\nWorktree now at detached HEAD - you can keep working here.`,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
logger.log(`Successfully merged ${branchToMerge} into ${defaultBranch}`)
|
|
181
|
+
} catch (e) {
|
|
182
|
+
const errorMsg = e instanceof Error ? e.message : String(e)
|
|
183
|
+
logger.error(`Merge failed: ${errorMsg}`)
|
|
184
|
+
await command.editReply(`❌ Merge failed:\n\`\`\`\n${errorMsg}\n\`\`\``)
|
|
185
|
+
}
|
|
186
|
+
}
|