@otto-assistant/bridge 0.4.101 → 0.4.103
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 +1 -0
- package/dist/anthropic-auth-plugin.js +22 -1
- package/dist/anthropic-auth-state.js +31 -0
- package/dist/btw-prefix-detection.js +17 -0
- package/dist/btw-prefix-detection.test.js +63 -0
- package/dist/cli.js +101 -15
- package/dist/commands/agent.js +21 -2
- package/dist/commands/ask-question.js +50 -4
- package/dist/commands/ask-question.test.js +92 -0
- package/dist/commands/btw.js +71 -66
- package/dist/commands/new-worktree.js +92 -35
- package/dist/commands/queue.js +17 -0
- package/dist/commands/worktrees.js +196 -139
- package/dist/context-awareness-plugin.js +16 -8
- package/dist/context-awareness-plugin.test.js +4 -2
- package/dist/discord-bot.js +35 -2
- package/dist/discord-command-registration.js +9 -2
- package/dist/memory-overview-plugin.js +3 -1
- package/dist/opencode.js +24 -1
- package/dist/queue-question-select-drain.e2e.test.js +135 -10
- package/dist/session-handler/thread-runtime-state.js +27 -0
- package/dist/session-handler/thread-session-runtime.js +58 -28
- package/dist/session-title-rename.test.js +12 -0
- package/dist/skill-filter.js +31 -0
- package/dist/skill-filter.test.js +65 -0
- package/dist/store.js +2 -0
- package/dist/system-message.js +12 -3
- package/dist/system-message.test.js +10 -6
- package/dist/thread-message-queue.e2e.test.js +109 -0
- package/dist/worktree-lifecycle.e2e.test.js +4 -1
- package/dist/worktrees.js +106 -12
- package/dist/worktrees.test.js +232 -6
- package/package.json +2 -2
- package/skills/goke/SKILL.md +13 -619
- package/skills/new-skill/SKILL.md +34 -10
- package/skills/npm-package/SKILL.md +336 -2
- package/skills/profano/SKILL.md +24 -0
- package/skills/zele/SKILL.md +50 -21
- package/src/agent-model.e2e.test.ts +1 -0
- package/src/anthropic-auth-plugin.ts +24 -4
- package/src/anthropic-auth-state.ts +45 -0
- package/src/btw-prefix-detection.test.ts +73 -0
- package/src/btw-prefix-detection.ts +23 -0
- package/src/cli.ts +138 -46
- package/src/commands/agent.ts +24 -2
- package/src/commands/ask-question.test.ts +111 -0
- package/src/commands/ask-question.ts +69 -4
- package/src/commands/btw.ts +105 -85
- package/src/commands/new-worktree.ts +107 -40
- package/src/commands/queue.ts +22 -0
- package/src/commands/worktrees.ts +246 -154
- package/src/context-awareness-plugin.test.ts +4 -2
- package/src/context-awareness-plugin.ts +16 -8
- package/src/discord-bot.ts +40 -2
- package/src/discord-command-registration.ts +12 -2
- package/src/memory-overview-plugin.ts +3 -1
- package/src/opencode.ts +31 -1
- package/src/queue-question-select-drain.e2e.test.ts +174 -10
- package/src/session-handler/thread-runtime-state.ts +36 -1
- package/src/session-handler/thread-session-runtime.ts +72 -32
- package/src/session-title-rename.test.ts +18 -0
- package/src/skill-filter.test.ts +83 -0
- package/src/skill-filter.ts +42 -0
- package/src/store.ts +17 -0
- package/src/system-message.test.ts +10 -6
- package/src/system-message.ts +12 -3
- package/src/thread-message-queue.e2e.test.ts +126 -0
- package/src/worktree-lifecycle.e2e.test.ts +6 -1
- package/src/worktrees.test.ts +274 -9
- package/src/worktrees.ts +144 -23
package/src/worktrees.test.ts
CHANGED
|
@@ -8,19 +8,19 @@ import {
|
|
|
8
8
|
buildSubmoduleReferencePlan,
|
|
9
9
|
createWorktreeWithSubmodules,
|
|
10
10
|
execAsync,
|
|
11
|
+
getManagedWorktreeDirectory,
|
|
11
12
|
parseGitmodulesFileContent,
|
|
13
|
+
parseGitWorktreeListPorcelain,
|
|
12
14
|
} from './worktrees.js'
|
|
15
|
+
import {
|
|
16
|
+
formatAutoWorktreeName,
|
|
17
|
+
formatWorktreeName,
|
|
18
|
+
shortenWorktreeSlug,
|
|
19
|
+
} from './commands/new-worktree.js'
|
|
20
|
+
import { setDataDir } from './config.js'
|
|
13
21
|
|
|
14
22
|
const GIT_TIMEOUT_MS = 60_000
|
|
15
23
|
|
|
16
|
-
function gitCommand(args: string[]): string {
|
|
17
|
-
return `git ${args
|
|
18
|
-
.map((arg) => {
|
|
19
|
-
return JSON.stringify(arg)
|
|
20
|
-
})
|
|
21
|
-
.join(' ')}`
|
|
22
|
-
}
|
|
23
|
-
|
|
24
24
|
async function git({
|
|
25
25
|
cwd,
|
|
26
26
|
args,
|
|
@@ -28,7 +28,13 @@ async function git({
|
|
|
28
28
|
cwd: string
|
|
29
29
|
args: string[]
|
|
30
30
|
}): Promise<string> {
|
|
31
|
-
const
|
|
31
|
+
const command = `git ${args
|
|
32
|
+
.map((arg) => {
|
|
33
|
+
return JSON.stringify(arg)
|
|
34
|
+
})
|
|
35
|
+
.join(' ')}`
|
|
36
|
+
|
|
37
|
+
const result = await execAsync(command, {
|
|
32
38
|
cwd,
|
|
33
39
|
timeout: GIT_TIMEOUT_MS,
|
|
34
40
|
})
|
|
@@ -221,4 +227,263 @@ describe('worktrees', () => {
|
|
|
221
227
|
}
|
|
222
228
|
})
|
|
223
229
|
|
|
230
|
+
test('createWorktreeWithSubmodules uses current HEAD even when origin does not have the commit', async () => {
|
|
231
|
+
const sandbox = createTestRoot()
|
|
232
|
+
const parentRemote = path.join(sandbox, 'parent-remote.git')
|
|
233
|
+
const parentLocal = path.join(sandbox, 'parent-local')
|
|
234
|
+
const worktreeName = `opencode/kimaki-local-head-${Date.now()}`
|
|
235
|
+
|
|
236
|
+
let createdWorktreeDirectory = ''
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
await git({ cwd: sandbox, args: ['init', '--bare', '-b', 'main', parentRemote] })
|
|
240
|
+
await git({ cwd: sandbox, args: ['clone', parentRemote, parentLocal] })
|
|
241
|
+
|
|
242
|
+
await git({
|
|
243
|
+
cwd: parentLocal,
|
|
244
|
+
args: ['config', 'user.email', 'kimaki-tests@example.com'],
|
|
245
|
+
})
|
|
246
|
+
await git({
|
|
247
|
+
cwd: parentLocal,
|
|
248
|
+
args: ['config', 'user.name', 'Kimaki Tests'],
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
fs.writeFileSync(path.join(parentLocal, 'README.md'), 'v1\n', 'utf-8')
|
|
252
|
+
await git({ cwd: parentLocal, args: ['add', 'README.md'] })
|
|
253
|
+
await git({ cwd: parentLocal, args: ['commit', '-m', 'v1'] })
|
|
254
|
+
await git({ cwd: parentLocal, args: ['push', 'origin', 'HEAD:main'] })
|
|
255
|
+
|
|
256
|
+
fs.writeFileSync(path.join(parentLocal, 'README.md'), 'v2-local-only\n', 'utf-8')
|
|
257
|
+
await git({ cwd: parentLocal, args: ['commit', '-am', 'v2 local only'] })
|
|
258
|
+
|
|
259
|
+
const localHeadSha = await git({
|
|
260
|
+
cwd: parentLocal,
|
|
261
|
+
args: ['rev-parse', 'HEAD'],
|
|
262
|
+
})
|
|
263
|
+
const originHeadSha = await git({
|
|
264
|
+
cwd: parentLocal,
|
|
265
|
+
args: ['rev-parse', 'origin/main'],
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const worktreeResult = await createWorktreeWithSubmodules({
|
|
269
|
+
directory: parentLocal,
|
|
270
|
+
name: worktreeName,
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
if (worktreeResult instanceof Error) {
|
|
274
|
+
throw worktreeResult
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
createdWorktreeDirectory = worktreeResult.directory
|
|
278
|
+
const worktreeHeadSha = await git({
|
|
279
|
+
cwd: createdWorktreeDirectory,
|
|
280
|
+
args: ['rev-parse', 'HEAD'],
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
expect({
|
|
284
|
+
localHeadShaLength: localHeadSha.length,
|
|
285
|
+
originHeadShaLength: originHeadSha.length,
|
|
286
|
+
worktreeHeadShaLength: worktreeHeadSha.length,
|
|
287
|
+
usesLocalOnlyHead: localHeadSha === worktreeHeadSha,
|
|
288
|
+
differsFromOrigin: localHeadSha !== originHeadSha,
|
|
289
|
+
}).toMatchInlineSnapshot(`
|
|
290
|
+
{
|
|
291
|
+
"differsFromOrigin": true,
|
|
292
|
+
"localHeadShaLength": 40,
|
|
293
|
+
"originHeadShaLength": 40,
|
|
294
|
+
"usesLocalOnlyHead": true,
|
|
295
|
+
"worktreeHeadShaLength": 40,
|
|
296
|
+
}
|
|
297
|
+
`)
|
|
298
|
+
} finally {
|
|
299
|
+
if (createdWorktreeDirectory) {
|
|
300
|
+
await git({
|
|
301
|
+
cwd: parentLocal,
|
|
302
|
+
args: ['worktree', 'remove', '--force', createdWorktreeDirectory],
|
|
303
|
+
}).catch(() => {
|
|
304
|
+
return ''
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
fs.rmSync(sandbox, { recursive: true, force: true })
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('shortenWorktreeSlug leaves short slugs alone', () => {
|
|
312
|
+
expect(shortenWorktreeSlug('short-name')).toMatchInlineSnapshot(
|
|
313
|
+
`"short-name"`,
|
|
314
|
+
)
|
|
315
|
+
expect(shortenWorktreeSlug('exactly-twenty-chars')).toMatchInlineSnapshot(
|
|
316
|
+
`"exactly-twenty-chars"`,
|
|
317
|
+
)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
test('shortenWorktreeSlug strips vowels from long slugs', () => {
|
|
321
|
+
expect(
|
|
322
|
+
shortenWorktreeSlug('configurable-sidebar-width-by-component'),
|
|
323
|
+
).toMatchInlineSnapshot(`"cnfgrbl-sdbr-wdth-by-cmpnnt"`)
|
|
324
|
+
expect(
|
|
325
|
+
shortenWorktreeSlug('add-dark-mode-toggle-to-settings-page'),
|
|
326
|
+
).toMatchInlineSnapshot(`"add-drk-md-tggl-t-sttngs-pg"`)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
test('formatWorktreeName keeps user-provided slugs verbatim', () => {
|
|
330
|
+
expect(
|
|
331
|
+
formatWorktreeName('Configurable sidebar width by component'),
|
|
332
|
+
).toMatchInlineSnapshot(`"opencode/kimaki-configurable-sidebar-width-by-component"`)
|
|
333
|
+
expect(formatWorktreeName('my-feature')).toMatchInlineSnapshot(`"opencode/kimaki-my-feature"`)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test('formatAutoWorktreeName compresses long auto-derived slugs', () => {
|
|
337
|
+
expect(
|
|
338
|
+
formatAutoWorktreeName('Configurable sidebar width by component'),
|
|
339
|
+
).toMatchInlineSnapshot(`"opencode/kimaki-cnfgrbl-sdbr-wdth-by-cmpnnt"`)
|
|
340
|
+
expect(formatAutoWorktreeName('my-feature')).toMatchInlineSnapshot(`"opencode/kimaki-my-feature"`)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
test('getManagedWorktreeDirectory writes under kimaki data dir and strips prefix', () => {
|
|
344
|
+
const sandbox = createTestRoot()
|
|
345
|
+
try {
|
|
346
|
+
setDataDir(sandbox)
|
|
347
|
+
const dir = getManagedWorktreeDirectory({
|
|
348
|
+
directory: '/Users/test/projects/my-app',
|
|
349
|
+
name: 'opencode/kimaki-cnfgrbl-sdbr-wdth-by-cmpnnt',
|
|
350
|
+
})
|
|
351
|
+
// Must sit inside <dataDir>/worktrees/<8hash>/<basename>
|
|
352
|
+
const rel = path.relative(sandbox, dir)
|
|
353
|
+
const parts = rel.split(path.sep)
|
|
354
|
+
expect({
|
|
355
|
+
topLevel: parts[0],
|
|
356
|
+
hashLength: parts[1]?.length,
|
|
357
|
+
basename: parts[2],
|
|
358
|
+
partsCount: parts.length,
|
|
359
|
+
}).toMatchInlineSnapshot(`
|
|
360
|
+
{
|
|
361
|
+
"basename": "cnfgrbl-sdbr-wdth-by-cmpnnt",
|
|
362
|
+
"hashLength": 8,
|
|
363
|
+
"partsCount": 3,
|
|
364
|
+
"topLevel": "worktrees",
|
|
365
|
+
}
|
|
366
|
+
`)
|
|
367
|
+
} finally {
|
|
368
|
+
fs.rmSync(sandbox, { recursive: true, force: true })
|
|
369
|
+
}
|
|
370
|
+
})
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
describe('parseGitWorktreeListPorcelain', () => {
|
|
374
|
+
test('parses porcelain output, skips main worktree', () => {
|
|
375
|
+
const output = [
|
|
376
|
+
'worktree /Users/me/project',
|
|
377
|
+
'HEAD abc123',
|
|
378
|
+
'branch refs/heads/main',
|
|
379
|
+
'',
|
|
380
|
+
'worktree /Users/me/.local/share/opencode/worktree/hash/opencode-kimaki-feature',
|
|
381
|
+
'HEAD def456',
|
|
382
|
+
'branch refs/heads/opencode/kimaki-feature',
|
|
383
|
+
'',
|
|
384
|
+
'worktree /Users/me/project-manual-wt',
|
|
385
|
+
'HEAD 789abc',
|
|
386
|
+
'branch refs/heads/my-branch',
|
|
387
|
+
'',
|
|
388
|
+
].join('\n')
|
|
389
|
+
|
|
390
|
+
expect(parseGitWorktreeListPorcelain(output)).toMatchInlineSnapshot(`
|
|
391
|
+
[
|
|
392
|
+
{
|
|
393
|
+
"branch": "opencode/kimaki-feature",
|
|
394
|
+
"detached": false,
|
|
395
|
+
"directory": "/Users/me/.local/share/opencode/worktree/hash/opencode-kimaki-feature",
|
|
396
|
+
"head": "def456",
|
|
397
|
+
"locked": false,
|
|
398
|
+
"prunable": false,
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
"branch": "my-branch",
|
|
402
|
+
"detached": false,
|
|
403
|
+
"directory": "/Users/me/project-manual-wt",
|
|
404
|
+
"head": "789abc",
|
|
405
|
+
"locked": false,
|
|
406
|
+
"prunable": false,
|
|
407
|
+
},
|
|
408
|
+
]
|
|
409
|
+
`)
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
test('handles detached HEAD worktrees', () => {
|
|
413
|
+
const output = [
|
|
414
|
+
'worktree /Users/me/project',
|
|
415
|
+
'HEAD abc123',
|
|
416
|
+
'branch refs/heads/main',
|
|
417
|
+
'',
|
|
418
|
+
'worktree /Users/me/detached-wt',
|
|
419
|
+
'HEAD deadbeef',
|
|
420
|
+
'detached',
|
|
421
|
+
'',
|
|
422
|
+
].join('\n')
|
|
423
|
+
|
|
424
|
+
const result = parseGitWorktreeListPorcelain(output)
|
|
425
|
+
expect(result).toMatchInlineSnapshot(`
|
|
426
|
+
[
|
|
427
|
+
{
|
|
428
|
+
"branch": null,
|
|
429
|
+
"detached": true,
|
|
430
|
+
"directory": "/Users/me/detached-wt",
|
|
431
|
+
"head": "deadbeef",
|
|
432
|
+
"locked": false,
|
|
433
|
+
"prunable": false,
|
|
434
|
+
},
|
|
435
|
+
]
|
|
436
|
+
`)
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
test('parses locked and prunable flags', () => {
|
|
440
|
+
const output = [
|
|
441
|
+
'worktree /Users/me/project',
|
|
442
|
+
'HEAD abc123',
|
|
443
|
+
'branch refs/heads/main',
|
|
444
|
+
'',
|
|
445
|
+
'worktree /Users/me/locked-wt',
|
|
446
|
+
'HEAD aaa111',
|
|
447
|
+
'branch refs/heads/feature-locked',
|
|
448
|
+
'locked portable disk',
|
|
449
|
+
'',
|
|
450
|
+
'worktree /Users/me/prunable-wt',
|
|
451
|
+
'HEAD bbb222',
|
|
452
|
+
'branch refs/heads/stale-branch',
|
|
453
|
+
'prunable gitdir file points to non-existent location',
|
|
454
|
+
'',
|
|
455
|
+
].join('\n')
|
|
456
|
+
|
|
457
|
+
expect(parseGitWorktreeListPorcelain(output)).toMatchInlineSnapshot(`
|
|
458
|
+
[
|
|
459
|
+
{
|
|
460
|
+
"branch": "feature-locked",
|
|
461
|
+
"detached": false,
|
|
462
|
+
"directory": "/Users/me/locked-wt",
|
|
463
|
+
"head": "aaa111",
|
|
464
|
+
"locked": true,
|
|
465
|
+
"prunable": false,
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
"branch": "stale-branch",
|
|
469
|
+
"detached": false,
|
|
470
|
+
"directory": "/Users/me/prunable-wt",
|
|
471
|
+
"head": "bbb222",
|
|
472
|
+
"locked": false,
|
|
473
|
+
"prunable": true,
|
|
474
|
+
},
|
|
475
|
+
]
|
|
476
|
+
`)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
test('returns empty array when only main worktree exists', () => {
|
|
480
|
+
const output = [
|
|
481
|
+
'worktree /Users/me/project',
|
|
482
|
+
'HEAD abc123',
|
|
483
|
+
'branch refs/heads/main',
|
|
484
|
+
'',
|
|
485
|
+
].join('\n')
|
|
486
|
+
|
|
487
|
+
expect(parseGitWorktreeListPorcelain(output)).toMatchInlineSnapshot(`[]`)
|
|
488
|
+
})
|
|
224
489
|
})
|
package/src/worktrees.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
import crypto from 'node:crypto'
|
|
6
6
|
import fs from 'node:fs'
|
|
7
|
-
import os from 'node:os'
|
|
8
7
|
import path from 'node:path'
|
|
9
8
|
import * as errore from 'errore'
|
|
9
|
+
import { getDataDir } from './config.js'
|
|
10
10
|
import { execAsync } from './exec-async.js'
|
|
11
11
|
import { createLogger, LogPrefix } from './logger.js'
|
|
12
12
|
|
|
@@ -530,24 +530,38 @@ async function resolveDefaultWorktreeTarget(
|
|
|
530
530
|
return 'HEAD'
|
|
531
531
|
}
|
|
532
532
|
|
|
533
|
-
|
|
533
|
+
/**
|
|
534
|
+
* Build the on-disk directory for a managed worktree.
|
|
535
|
+
*
|
|
536
|
+
* Layout: `<kimakiDataDir>/worktrees/<8charProjectHash>/<basename>`
|
|
537
|
+
*
|
|
538
|
+
* - Lives under the kimaki data dir instead of the long
|
|
539
|
+
* `~/.local/share/opencode/worktree/<40-char-hash>/<name>` path so folder
|
|
540
|
+
* names stay short and readable (agents tend to give up and reuse the old
|
|
541
|
+
* worktree when paths get absurdly long).
|
|
542
|
+
* - The 8-char project hash keeps worktrees from different projects that
|
|
543
|
+
* happen to share a slug from colliding.
|
|
544
|
+
* - Strips the `opencode/kimaki-` (or `opencode-kimaki-`) prefix from the
|
|
545
|
+
* folder name since it's redundant noise on disk. The git branch name
|
|
546
|
+
* itself still uses `opencode/kimaki-<slug>` so merge/cleanup logic is
|
|
547
|
+
* unchanged.
|
|
548
|
+
*/
|
|
549
|
+
export function getManagedWorktreeDirectory({
|
|
534
550
|
directory,
|
|
535
551
|
name,
|
|
536
552
|
}: {
|
|
537
553
|
directory: string
|
|
538
554
|
name: string
|
|
539
555
|
}): string {
|
|
540
|
-
const projectHash = crypto
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
'
|
|
548
|
-
|
|
549
|
-
safeName,
|
|
550
|
-
)
|
|
556
|
+
const projectHash = crypto
|
|
557
|
+
.createHash('sha1')
|
|
558
|
+
.update(directory)
|
|
559
|
+
.digest('hex')
|
|
560
|
+
.slice(0, 8)
|
|
561
|
+
const withoutPrefix = name
|
|
562
|
+
.replace(/^opencode\/kimaki-/, '')
|
|
563
|
+
.replaceAll('/', '-')
|
|
564
|
+
return path.join(getDataDir(), 'worktrees', projectHash, withoutPrefix)
|
|
551
565
|
}
|
|
552
566
|
|
|
553
567
|
/**
|
|
@@ -724,6 +738,8 @@ export async function deleteWorktree({
|
|
|
724
738
|
}: {
|
|
725
739
|
projectDirectory: string
|
|
726
740
|
worktreeDirectory: string
|
|
741
|
+
// Branch name to delete after removing the worktree.
|
|
742
|
+
// Pass empty string for detached HEAD worktrees — branch deletion is skipped.
|
|
727
743
|
worktreeName: string
|
|
728
744
|
}): Promise<void | Error> {
|
|
729
745
|
let removeResult = await git(
|
|
@@ -749,24 +765,27 @@ export async function deleteWorktree({
|
|
|
749
765
|
}
|
|
750
766
|
}
|
|
751
767
|
if (removeResult instanceof Error) {
|
|
752
|
-
return new Error(`Failed to remove worktree ${worktreeName}`, {
|
|
768
|
+
return new Error(`Failed to remove worktree ${worktreeName || worktreeDirectory}`, {
|
|
753
769
|
cause: removeResult,
|
|
754
770
|
})
|
|
755
771
|
}
|
|
756
772
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
773
|
+
// Skip branch deletion for detached HEAD worktrees (no branch to delete)
|
|
774
|
+
if (worktreeName) {
|
|
775
|
+
const deleteBranchResult = await git(
|
|
776
|
+
projectDirectory,
|
|
777
|
+
`branch -d ${JSON.stringify(worktreeName)}`,
|
|
778
|
+
)
|
|
779
|
+
if (deleteBranchResult instanceof Error) {
|
|
780
|
+
return new Error(`Failed to delete branch ${worktreeName}`, {
|
|
781
|
+
cause: deleteBranchResult,
|
|
782
|
+
})
|
|
783
|
+
}
|
|
765
784
|
}
|
|
766
785
|
|
|
767
786
|
const pruneResult = await git(projectDirectory, 'worktree prune')
|
|
768
787
|
if (pruneResult instanceof Error) {
|
|
769
|
-
logger.warn(`Failed to prune worktrees after deleting ${worktreeName}`)
|
|
788
|
+
logger.warn(`Failed to prune worktrees after deleting ${worktreeName || worktreeDirectory}`)
|
|
770
789
|
}
|
|
771
790
|
}
|
|
772
791
|
|
|
@@ -1246,3 +1265,105 @@ export async function validateWorktreeDirectory({
|
|
|
1246
1265
|
|
|
1247
1266
|
return absoluteCandidate
|
|
1248
1267
|
}
|
|
1268
|
+
|
|
1269
|
+
// Parsed entry from `git worktree list --porcelain`.
|
|
1270
|
+
// Represents any worktree (kimaki, opencode, manual) visible to git.
|
|
1271
|
+
export type GitWorktree = {
|
|
1272
|
+
directory: string
|
|
1273
|
+
branch: string | null // null for detached HEAD
|
|
1274
|
+
head: string
|
|
1275
|
+
detached: boolean
|
|
1276
|
+
locked: boolean
|
|
1277
|
+
prunable: boolean
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
type PartialGitWorktree = {
|
|
1281
|
+
directory?: string
|
|
1282
|
+
branch?: string | null
|
|
1283
|
+
head?: string
|
|
1284
|
+
detached?: boolean
|
|
1285
|
+
locked?: boolean
|
|
1286
|
+
prunable?: boolean
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function flushGitWorktreeEntry(current: PartialGitWorktree): GitWorktree | null {
|
|
1290
|
+
if (!current.directory) {
|
|
1291
|
+
return null
|
|
1292
|
+
}
|
|
1293
|
+
return {
|
|
1294
|
+
directory: current.directory,
|
|
1295
|
+
branch: current.branch ?? null,
|
|
1296
|
+
head: current.head ?? '',
|
|
1297
|
+
detached: current.detached ?? false,
|
|
1298
|
+
locked: current.locked ?? false,
|
|
1299
|
+
prunable: current.prunable ?? false,
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// Parse `git worktree list --porcelain` output into structured entries.
|
|
1304
|
+
// Skips the first entry (the main checkout) since that's the project root.
|
|
1305
|
+
export function parseGitWorktreeListPorcelain(
|
|
1306
|
+
output: string,
|
|
1307
|
+
): GitWorktree[] {
|
|
1308
|
+
const entries: GitWorktree[] = []
|
|
1309
|
+
let current: PartialGitWorktree = {}
|
|
1310
|
+
|
|
1311
|
+
for (const line of output.split('\n')) {
|
|
1312
|
+
if (line.startsWith('worktree ')) {
|
|
1313
|
+
const flushed = flushGitWorktreeEntry(current)
|
|
1314
|
+
if (flushed) {
|
|
1315
|
+
entries.push(flushed)
|
|
1316
|
+
}
|
|
1317
|
+
current = { directory: line.slice('worktree '.length) }
|
|
1318
|
+
continue
|
|
1319
|
+
}
|
|
1320
|
+
if (line.startsWith('HEAD ')) {
|
|
1321
|
+
current.head = line.slice('HEAD '.length)
|
|
1322
|
+
continue
|
|
1323
|
+
}
|
|
1324
|
+
if (line.startsWith('branch ')) {
|
|
1325
|
+
// "branch refs/heads/opencode/kimaki-foo" → "opencode/kimaki-foo"
|
|
1326
|
+
current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
|
|
1327
|
+
continue
|
|
1328
|
+
}
|
|
1329
|
+
if (line === 'detached') {
|
|
1330
|
+
current.detached = true
|
|
1331
|
+
continue
|
|
1332
|
+
}
|
|
1333
|
+
// "locked" or "locked <reason>"
|
|
1334
|
+
if (line === 'locked' || line.startsWith('locked ')) {
|
|
1335
|
+
current.locked = true
|
|
1336
|
+
continue
|
|
1337
|
+
}
|
|
1338
|
+
if (line.startsWith('prunable')) {
|
|
1339
|
+
current.prunable = true
|
|
1340
|
+
continue
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
// Flush last entry
|
|
1344
|
+
const flushed = flushGitWorktreeEntry(current)
|
|
1345
|
+
if (flushed) {
|
|
1346
|
+
entries.push(flushed)
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Skip the first entry — it's the main checkout (project root)
|
|
1350
|
+
return entries.slice(1)
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// List all git worktrees for a project directory (excluding the main checkout).
|
|
1354
|
+
// Returns Error on git failure, empty array if no worktrees exist.
|
|
1355
|
+
export async function listGitWorktrees({
|
|
1356
|
+
projectDirectory,
|
|
1357
|
+
timeout,
|
|
1358
|
+
}: {
|
|
1359
|
+
projectDirectory: string
|
|
1360
|
+
timeout?: number
|
|
1361
|
+
}): Promise<GitWorktree[] | Error> {
|
|
1362
|
+
const result = await git(projectDirectory, 'worktree list --porcelain', {
|
|
1363
|
+
timeout,
|
|
1364
|
+
})
|
|
1365
|
+
if (result instanceof Error) {
|
|
1366
|
+
return result
|
|
1367
|
+
}
|
|
1368
|
+
return parseGitWorktreeListPorcelain(result)
|
|
1369
|
+
}
|