@otto-assistant/bridge 0.4.102 → 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 +9 -0
- 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 +9 -0
- 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
|
@@ -748,6 +748,115 @@ e2eTest('thread message queue ordering', () => {
|
|
|
748
748
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
749
749
|
`);
|
|
750
750
|
}, 12_000);
|
|
751
|
+
test('/clear-queue position clears only that queued message', async () => {
|
|
752
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
753
|
+
content: 'Reply with exactly: clear-queue-setup',
|
|
754
|
+
});
|
|
755
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
756
|
+
timeout: 4_000,
|
|
757
|
+
predicate: (t) => {
|
|
758
|
+
return t.name === 'Reply with exactly: clear-queue-setup';
|
|
759
|
+
},
|
|
760
|
+
});
|
|
761
|
+
const th = discord.thread(thread.id);
|
|
762
|
+
await th.waitForBotReply({ timeout: 4_000 });
|
|
763
|
+
await waitForFooterMessage({
|
|
764
|
+
discord,
|
|
765
|
+
threadId: thread.id,
|
|
766
|
+
timeout: 4_000,
|
|
767
|
+
});
|
|
768
|
+
await th.user(TEST_USER_ID).runSlashCommand({
|
|
769
|
+
name: 'queue',
|
|
770
|
+
options: [{ name: 'message', type: 3, value: 'Reply with exactly: race-final' }],
|
|
771
|
+
});
|
|
772
|
+
const { id: secondQueueInteractionId } = await th.user(TEST_USER_ID)
|
|
773
|
+
.runSlashCommand({
|
|
774
|
+
name: 'queue',
|
|
775
|
+
options: [{ name: 'message', type: 3, value: 'Reply with exactly: removed-queued-message' }],
|
|
776
|
+
});
|
|
777
|
+
const secondQueueAck = await th.waitForInteractionAck({
|
|
778
|
+
interactionId: secondQueueInteractionId,
|
|
779
|
+
timeout: 4_000,
|
|
780
|
+
});
|
|
781
|
+
if (!secondQueueAck.messageId) {
|
|
782
|
+
throw new Error('Expected second /queue response message id');
|
|
783
|
+
}
|
|
784
|
+
const secondQueueAckMessage = await waitForMessageById({
|
|
785
|
+
discord,
|
|
786
|
+
threadId: thread.id,
|
|
787
|
+
messageId: secondQueueAck.messageId,
|
|
788
|
+
timeout: 4_000,
|
|
789
|
+
});
|
|
790
|
+
expect(secondQueueAckMessage.content).toContain('Queued message (position 1)');
|
|
791
|
+
const { id: thirdQueueInteractionId } = await th.user(TEST_USER_ID).runSlashCommand({
|
|
792
|
+
name: 'queue',
|
|
793
|
+
options: [{ name: 'message', type: 3, value: 'Reply with exactly: kept-queued-message' }],
|
|
794
|
+
});
|
|
795
|
+
const thirdQueueAck = await th.waitForInteractionAck({
|
|
796
|
+
interactionId: thirdQueueInteractionId,
|
|
797
|
+
timeout: 4_000,
|
|
798
|
+
});
|
|
799
|
+
if (!thirdQueueAck.messageId) {
|
|
800
|
+
throw new Error('Expected third /queue response message id');
|
|
801
|
+
}
|
|
802
|
+
const thirdQueueAckMessage = await waitForMessageById({
|
|
803
|
+
discord,
|
|
804
|
+
threadId: thread.id,
|
|
805
|
+
messageId: thirdQueueAck.messageId,
|
|
806
|
+
timeout: 4_000,
|
|
807
|
+
});
|
|
808
|
+
expect(thirdQueueAckMessage.content).toContain('Queued message (position 2)');
|
|
809
|
+
const { id: clearInteractionId } = await th.user(TEST_USER_ID).runSlashCommand({
|
|
810
|
+
name: 'clear-queue',
|
|
811
|
+
options: [{ name: 'position', type: 4, value: 1 }],
|
|
812
|
+
});
|
|
813
|
+
const clearAck = await th.waitForInteractionAck({
|
|
814
|
+
interactionId: clearInteractionId,
|
|
815
|
+
timeout: 4_000,
|
|
816
|
+
});
|
|
817
|
+
if (!clearAck.messageId) {
|
|
818
|
+
throw new Error('Expected /clear-queue response message id');
|
|
819
|
+
}
|
|
820
|
+
const clearAckMessage = await waitForMessageById({
|
|
821
|
+
discord,
|
|
822
|
+
threadId: thread.id,
|
|
823
|
+
messageId: clearAck.messageId,
|
|
824
|
+
timeout: 4_000,
|
|
825
|
+
});
|
|
826
|
+
expect(clearAckMessage.content).toBe('Cleared queued message at position 1');
|
|
827
|
+
await waitForBotMessageContaining({
|
|
828
|
+
discord,
|
|
829
|
+
threadId: thread.id,
|
|
830
|
+
userId: TEST_USER_ID,
|
|
831
|
+
text: '» **queue-tester:** Reply with exactly: kept-queued-message',
|
|
832
|
+
afterMessageId: clearAckMessage.id,
|
|
833
|
+
timeout: 8_000,
|
|
834
|
+
});
|
|
835
|
+
await waitForFooterMessage({
|
|
836
|
+
discord,
|
|
837
|
+
threadId: thread.id,
|
|
838
|
+
timeout: 8_000,
|
|
839
|
+
afterMessageIncludes: '⬥ ok',
|
|
840
|
+
afterAuthorId: discord.botUserId,
|
|
841
|
+
});
|
|
842
|
+
const threadText = await th.text();
|
|
843
|
+
expect(threadText).toMatchInlineSnapshot(`
|
|
844
|
+
"--- from: user (queue-tester)
|
|
845
|
+
Reply with exactly: clear-queue-setup
|
|
846
|
+
--- from: assistant (TestBot)
|
|
847
|
+
⬥ ok
|
|
848
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
849
|
+
» **queue-tester:** Reply with exactly: race-final
|
|
850
|
+
Queued message (position 1)
|
|
851
|
+
Queued message (position 2)
|
|
852
|
+
Cleared queued message at position 1
|
|
853
|
+
⬥ race-final
|
|
854
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
855
|
+
» **queue-tester:** Reply with exactly: kept-queued-message"
|
|
856
|
+
`);
|
|
857
|
+
expect(threadText).not.toContain('removed-queued-message');
|
|
858
|
+
expect(threadText).toContain('kept-queued-message');
|
|
859
|
+
}, 12_000);
|
|
751
860
|
test('queued message waits for running session and then processes next', async () => {
|
|
752
861
|
// When a new message arrives while a session is running, it queues and
|
|
753
862
|
// runs after the in-flight request completes.
|
|
@@ -293,7 +293,10 @@ describe('worktree lifecycle', () => {
|
|
|
293
293
|
expect(runtimeAfter).toBe(runtimeBefore);
|
|
294
294
|
// sdkDirectory should now point to the worktree path
|
|
295
295
|
expect(runtimeAfter.sdkDirectory).not.toBe(directories.projectDirectory);
|
|
296
|
-
|
|
296
|
+
// Folder name drops the `opencode-kimaki-` prefix (branch name keeps it).
|
|
297
|
+
// See getManagedWorktreeDirectory in worktrees.ts.
|
|
298
|
+
expect(runtimeAfter.sdkDirectory).toContain(WORKTREE_NAME);
|
|
299
|
+
expect(runtimeAfter.sdkDirectory).toContain(`${path.sep}worktrees${path.sep}`);
|
|
297
300
|
// Snapshot uses dynamic worktree name so we verify structure, not exact text
|
|
298
301
|
const text = await th.text();
|
|
299
302
|
expect(text).toContain('Reply with exactly: before-worktree');
|
package/dist/worktrees.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// submodule initialization, and git diff transfer utilities.
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
|
-
import os from 'node:os';
|
|
7
6
|
import path from 'node:path';
|
|
8
7
|
import * as errore from 'errore';
|
|
8
|
+
import { getDataDir } from './config.js';
|
|
9
9
|
import { execAsync } from './exec-async.js';
|
|
10
10
|
import { createLogger, LogPrefix } from './logger.js';
|
|
11
11
|
export { execAsync } from './exec-async.js';
|
|
@@ -396,10 +396,32 @@ async function validateSubmodulePointers(directory) {
|
|
|
396
396
|
async function resolveDefaultWorktreeTarget(directory) {
|
|
397
397
|
return 'HEAD';
|
|
398
398
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
399
|
+
/**
|
|
400
|
+
* Build the on-disk directory for a managed worktree.
|
|
401
|
+
*
|
|
402
|
+
* Layout: `<kimakiDataDir>/worktrees/<8charProjectHash>/<basename>`
|
|
403
|
+
*
|
|
404
|
+
* - Lives under the kimaki data dir instead of the long
|
|
405
|
+
* `~/.local/share/opencode/worktree/<40-char-hash>/<name>` path so folder
|
|
406
|
+
* names stay short and readable (agents tend to give up and reuse the old
|
|
407
|
+
* worktree when paths get absurdly long).
|
|
408
|
+
* - The 8-char project hash keeps worktrees from different projects that
|
|
409
|
+
* happen to share a slug from colliding.
|
|
410
|
+
* - Strips the `opencode/kimaki-` (or `opencode-kimaki-`) prefix from the
|
|
411
|
+
* folder name since it's redundant noise on disk. The git branch name
|
|
412
|
+
* itself still uses `opencode/kimaki-<slug>` so merge/cleanup logic is
|
|
413
|
+
* unchanged.
|
|
414
|
+
*/
|
|
415
|
+
export function getManagedWorktreeDirectory({ directory, name, }) {
|
|
416
|
+
const projectHash = crypto
|
|
417
|
+
.createHash('sha1')
|
|
418
|
+
.update(directory)
|
|
419
|
+
.digest('hex')
|
|
420
|
+
.slice(0, 8);
|
|
421
|
+
const withoutPrefix = name
|
|
422
|
+
.replace(/^opencode\/kimaki-/, '')
|
|
423
|
+
.replaceAll('/', '-');
|
|
424
|
+
return path.join(getDataDir(), 'worktrees', projectHash, withoutPrefix);
|
|
403
425
|
}
|
|
404
426
|
/**
|
|
405
427
|
* Create a worktree using git and initialize git submodules.
|
|
@@ -527,19 +549,22 @@ export async function deleteWorktree({ projectDirectory, worktreeDirectory, work
|
|
|
527
549
|
}
|
|
528
550
|
}
|
|
529
551
|
if (removeResult instanceof Error) {
|
|
530
|
-
return new Error(`Failed to remove worktree ${worktreeName}`, {
|
|
552
|
+
return new Error(`Failed to remove worktree ${worktreeName || worktreeDirectory}`, {
|
|
531
553
|
cause: removeResult,
|
|
532
554
|
});
|
|
533
555
|
}
|
|
534
|
-
|
|
535
|
-
if (
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
556
|
+
// Skip branch deletion for detached HEAD worktrees (no branch to delete)
|
|
557
|
+
if (worktreeName) {
|
|
558
|
+
const deleteBranchResult = await git(projectDirectory, `branch -d ${JSON.stringify(worktreeName)}`);
|
|
559
|
+
if (deleteBranchResult instanceof Error) {
|
|
560
|
+
return new Error(`Failed to delete branch ${worktreeName}`, {
|
|
561
|
+
cause: deleteBranchResult,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
539
564
|
}
|
|
540
565
|
const pruneResult = await git(projectDirectory, 'worktree prune');
|
|
541
566
|
if (pruneResult instanceof Error) {
|
|
542
|
-
logger.warn(`Failed to prune worktrees after deleting ${worktreeName}`);
|
|
567
|
+
logger.warn(`Failed to prune worktrees after deleting ${worktreeName || worktreeDirectory}`);
|
|
543
568
|
}
|
|
544
569
|
}
|
|
545
570
|
export async function isDirty(dir, opts) {
|
|
@@ -894,3 +919,72 @@ export async function validateWorktreeDirectory({ projectDirectory, candidatePat
|
|
|
894
919
|
}
|
|
895
920
|
return absoluteCandidate;
|
|
896
921
|
}
|
|
922
|
+
function flushGitWorktreeEntry(current) {
|
|
923
|
+
if (!current.directory) {
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
return {
|
|
927
|
+
directory: current.directory,
|
|
928
|
+
branch: current.branch ?? null,
|
|
929
|
+
head: current.head ?? '',
|
|
930
|
+
detached: current.detached ?? false,
|
|
931
|
+
locked: current.locked ?? false,
|
|
932
|
+
prunable: current.prunable ?? false,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
// Parse `git worktree list --porcelain` output into structured entries.
|
|
936
|
+
// Skips the first entry (the main checkout) since that's the project root.
|
|
937
|
+
export function parseGitWorktreeListPorcelain(output) {
|
|
938
|
+
const entries = [];
|
|
939
|
+
let current = {};
|
|
940
|
+
for (const line of output.split('\n')) {
|
|
941
|
+
if (line.startsWith('worktree ')) {
|
|
942
|
+
const flushed = flushGitWorktreeEntry(current);
|
|
943
|
+
if (flushed) {
|
|
944
|
+
entries.push(flushed);
|
|
945
|
+
}
|
|
946
|
+
current = { directory: line.slice('worktree '.length) };
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
if (line.startsWith('HEAD ')) {
|
|
950
|
+
current.head = line.slice('HEAD '.length);
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
if (line.startsWith('branch ')) {
|
|
954
|
+
// "branch refs/heads/opencode/kimaki-foo" → "opencode/kimaki-foo"
|
|
955
|
+
current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
if (line === 'detached') {
|
|
959
|
+
current.detached = true;
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
// "locked" or "locked <reason>"
|
|
963
|
+
if (line === 'locked' || line.startsWith('locked ')) {
|
|
964
|
+
current.locked = true;
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
if (line.startsWith('prunable')) {
|
|
968
|
+
current.prunable = true;
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
// Flush last entry
|
|
973
|
+
const flushed = flushGitWorktreeEntry(current);
|
|
974
|
+
if (flushed) {
|
|
975
|
+
entries.push(flushed);
|
|
976
|
+
}
|
|
977
|
+
// Skip the first entry — it's the main checkout (project root)
|
|
978
|
+
return entries.slice(1);
|
|
979
|
+
}
|
|
980
|
+
// List all git worktrees for a project directory (excluding the main checkout).
|
|
981
|
+
// Returns Error on git failure, empty array if no worktrees exist.
|
|
982
|
+
export async function listGitWorktrees({ projectDirectory, timeout, }) {
|
|
983
|
+
const result = await git(projectDirectory, 'worktree list --porcelain', {
|
|
984
|
+
timeout,
|
|
985
|
+
});
|
|
986
|
+
if (result instanceof Error) {
|
|
987
|
+
return result;
|
|
988
|
+
}
|
|
989
|
+
return parseGitWorktreeListPorcelain(result);
|
|
990
|
+
}
|
package/dist/worktrees.test.js
CHANGED
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { describe, expect, test } from 'vitest';
|
|
6
|
-
import { buildSubmoduleReferencePlan, createWorktreeWithSubmodules, execAsync, parseGitmodulesFileContent, } from './worktrees.js';
|
|
6
|
+
import { buildSubmoduleReferencePlan, createWorktreeWithSubmodules, execAsync, getManagedWorktreeDirectory, parseGitmodulesFileContent, parseGitWorktreeListPorcelain, } from './worktrees.js';
|
|
7
|
+
import { formatAutoWorktreeName, formatWorktreeName, shortenWorktreeSlug, } from './commands/new-worktree.js';
|
|
8
|
+
import { setDataDir } from './config.js';
|
|
7
9
|
const GIT_TIMEOUT_MS = 60_000;
|
|
8
|
-
function
|
|
9
|
-
|
|
10
|
+
async function git({ cwd, args, }) {
|
|
11
|
+
const command = `git ${args
|
|
10
12
|
.map((arg) => {
|
|
11
13
|
return JSON.stringify(arg);
|
|
12
14
|
})
|
|
13
15
|
.join(' ')}`;
|
|
14
|
-
|
|
15
|
-
async function git({ cwd, args, }) {
|
|
16
|
-
const result = await execAsync(gitCommand(args), {
|
|
16
|
+
const result = await execAsync(command, {
|
|
17
17
|
cwd,
|
|
18
18
|
timeout: GIT_TIMEOUT_MS,
|
|
19
19
|
});
|
|
@@ -186,4 +186,230 @@ describe('worktrees', () => {
|
|
|
186
186
|
fs.rmSync(sandbox, { recursive: true, force: true });
|
|
187
187
|
}
|
|
188
188
|
});
|
|
189
|
+
test('createWorktreeWithSubmodules uses current HEAD even when origin does not have the commit', async () => {
|
|
190
|
+
const sandbox = createTestRoot();
|
|
191
|
+
const parentRemote = path.join(sandbox, 'parent-remote.git');
|
|
192
|
+
const parentLocal = path.join(sandbox, 'parent-local');
|
|
193
|
+
const worktreeName = `opencode/kimaki-local-head-${Date.now()}`;
|
|
194
|
+
let createdWorktreeDirectory = '';
|
|
195
|
+
try {
|
|
196
|
+
await git({ cwd: sandbox, args: ['init', '--bare', '-b', 'main', parentRemote] });
|
|
197
|
+
await git({ cwd: sandbox, args: ['clone', parentRemote, parentLocal] });
|
|
198
|
+
await git({
|
|
199
|
+
cwd: parentLocal,
|
|
200
|
+
args: ['config', 'user.email', 'kimaki-tests@example.com'],
|
|
201
|
+
});
|
|
202
|
+
await git({
|
|
203
|
+
cwd: parentLocal,
|
|
204
|
+
args: ['config', 'user.name', 'Kimaki Tests'],
|
|
205
|
+
});
|
|
206
|
+
fs.writeFileSync(path.join(parentLocal, 'README.md'), 'v1\n', 'utf-8');
|
|
207
|
+
await git({ cwd: parentLocal, args: ['add', 'README.md'] });
|
|
208
|
+
await git({ cwd: parentLocal, args: ['commit', '-m', 'v1'] });
|
|
209
|
+
await git({ cwd: parentLocal, args: ['push', 'origin', 'HEAD:main'] });
|
|
210
|
+
fs.writeFileSync(path.join(parentLocal, 'README.md'), 'v2-local-only\n', 'utf-8');
|
|
211
|
+
await git({ cwd: parentLocal, args: ['commit', '-am', 'v2 local only'] });
|
|
212
|
+
const localHeadSha = await git({
|
|
213
|
+
cwd: parentLocal,
|
|
214
|
+
args: ['rev-parse', 'HEAD'],
|
|
215
|
+
});
|
|
216
|
+
const originHeadSha = await git({
|
|
217
|
+
cwd: parentLocal,
|
|
218
|
+
args: ['rev-parse', 'origin/main'],
|
|
219
|
+
});
|
|
220
|
+
const worktreeResult = await createWorktreeWithSubmodules({
|
|
221
|
+
directory: parentLocal,
|
|
222
|
+
name: worktreeName,
|
|
223
|
+
});
|
|
224
|
+
if (worktreeResult instanceof Error) {
|
|
225
|
+
throw worktreeResult;
|
|
226
|
+
}
|
|
227
|
+
createdWorktreeDirectory = worktreeResult.directory;
|
|
228
|
+
const worktreeHeadSha = await git({
|
|
229
|
+
cwd: createdWorktreeDirectory,
|
|
230
|
+
args: ['rev-parse', 'HEAD'],
|
|
231
|
+
});
|
|
232
|
+
expect({
|
|
233
|
+
localHeadShaLength: localHeadSha.length,
|
|
234
|
+
originHeadShaLength: originHeadSha.length,
|
|
235
|
+
worktreeHeadShaLength: worktreeHeadSha.length,
|
|
236
|
+
usesLocalOnlyHead: localHeadSha === worktreeHeadSha,
|
|
237
|
+
differsFromOrigin: localHeadSha !== originHeadSha,
|
|
238
|
+
}).toMatchInlineSnapshot(`
|
|
239
|
+
{
|
|
240
|
+
"differsFromOrigin": true,
|
|
241
|
+
"localHeadShaLength": 40,
|
|
242
|
+
"originHeadShaLength": 40,
|
|
243
|
+
"usesLocalOnlyHead": true,
|
|
244
|
+
"worktreeHeadShaLength": 40,
|
|
245
|
+
}
|
|
246
|
+
`);
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
if (createdWorktreeDirectory) {
|
|
250
|
+
await git({
|
|
251
|
+
cwd: parentLocal,
|
|
252
|
+
args: ['worktree', 'remove', '--force', createdWorktreeDirectory],
|
|
253
|
+
}).catch(() => {
|
|
254
|
+
return '';
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
fs.rmSync(sandbox, { recursive: true, force: true });
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
test('shortenWorktreeSlug leaves short slugs alone', () => {
|
|
261
|
+
expect(shortenWorktreeSlug('short-name')).toMatchInlineSnapshot(`"short-name"`);
|
|
262
|
+
expect(shortenWorktreeSlug('exactly-twenty-chars')).toMatchInlineSnapshot(`"exactly-twenty-chars"`);
|
|
263
|
+
});
|
|
264
|
+
test('shortenWorktreeSlug strips vowels from long slugs', () => {
|
|
265
|
+
expect(shortenWorktreeSlug('configurable-sidebar-width-by-component')).toMatchInlineSnapshot(`"cnfgrbl-sdbr-wdth-by-cmpnnt"`);
|
|
266
|
+
expect(shortenWorktreeSlug('add-dark-mode-toggle-to-settings-page')).toMatchInlineSnapshot(`"add-drk-md-tggl-t-sttngs-pg"`);
|
|
267
|
+
});
|
|
268
|
+
test('formatWorktreeName keeps user-provided slugs verbatim', () => {
|
|
269
|
+
expect(formatWorktreeName('Configurable sidebar width by component')).toMatchInlineSnapshot(`"opencode/kimaki-configurable-sidebar-width-by-component"`);
|
|
270
|
+
expect(formatWorktreeName('my-feature')).toMatchInlineSnapshot(`"opencode/kimaki-my-feature"`);
|
|
271
|
+
});
|
|
272
|
+
test('formatAutoWorktreeName compresses long auto-derived slugs', () => {
|
|
273
|
+
expect(formatAutoWorktreeName('Configurable sidebar width by component')).toMatchInlineSnapshot(`"opencode/kimaki-cnfgrbl-sdbr-wdth-by-cmpnnt"`);
|
|
274
|
+
expect(formatAutoWorktreeName('my-feature')).toMatchInlineSnapshot(`"opencode/kimaki-my-feature"`);
|
|
275
|
+
});
|
|
276
|
+
test('getManagedWorktreeDirectory writes under kimaki data dir and strips prefix', () => {
|
|
277
|
+
const sandbox = createTestRoot();
|
|
278
|
+
try {
|
|
279
|
+
setDataDir(sandbox);
|
|
280
|
+
const dir = getManagedWorktreeDirectory({
|
|
281
|
+
directory: '/Users/test/projects/my-app',
|
|
282
|
+
name: 'opencode/kimaki-cnfgrbl-sdbr-wdth-by-cmpnnt',
|
|
283
|
+
});
|
|
284
|
+
// Must sit inside <dataDir>/worktrees/<8hash>/<basename>
|
|
285
|
+
const rel = path.relative(sandbox, dir);
|
|
286
|
+
const parts = rel.split(path.sep);
|
|
287
|
+
expect({
|
|
288
|
+
topLevel: parts[0],
|
|
289
|
+
hashLength: parts[1]?.length,
|
|
290
|
+
basename: parts[2],
|
|
291
|
+
partsCount: parts.length,
|
|
292
|
+
}).toMatchInlineSnapshot(`
|
|
293
|
+
{
|
|
294
|
+
"basename": "cnfgrbl-sdbr-wdth-by-cmpnnt",
|
|
295
|
+
"hashLength": 8,
|
|
296
|
+
"partsCount": 3,
|
|
297
|
+
"topLevel": "worktrees",
|
|
298
|
+
}
|
|
299
|
+
`);
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
fs.rmSync(sandbox, { recursive: true, force: true });
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
describe('parseGitWorktreeListPorcelain', () => {
|
|
307
|
+
test('parses porcelain output, skips main worktree', () => {
|
|
308
|
+
const output = [
|
|
309
|
+
'worktree /Users/me/project',
|
|
310
|
+
'HEAD abc123',
|
|
311
|
+
'branch refs/heads/main',
|
|
312
|
+
'',
|
|
313
|
+
'worktree /Users/me/.local/share/opencode/worktree/hash/opencode-kimaki-feature',
|
|
314
|
+
'HEAD def456',
|
|
315
|
+
'branch refs/heads/opencode/kimaki-feature',
|
|
316
|
+
'',
|
|
317
|
+
'worktree /Users/me/project-manual-wt',
|
|
318
|
+
'HEAD 789abc',
|
|
319
|
+
'branch refs/heads/my-branch',
|
|
320
|
+
'',
|
|
321
|
+
].join('\n');
|
|
322
|
+
expect(parseGitWorktreeListPorcelain(output)).toMatchInlineSnapshot(`
|
|
323
|
+
[
|
|
324
|
+
{
|
|
325
|
+
"branch": "opencode/kimaki-feature",
|
|
326
|
+
"detached": false,
|
|
327
|
+
"directory": "/Users/me/.local/share/opencode/worktree/hash/opencode-kimaki-feature",
|
|
328
|
+
"head": "def456",
|
|
329
|
+
"locked": false,
|
|
330
|
+
"prunable": false,
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
"branch": "my-branch",
|
|
334
|
+
"detached": false,
|
|
335
|
+
"directory": "/Users/me/project-manual-wt",
|
|
336
|
+
"head": "789abc",
|
|
337
|
+
"locked": false,
|
|
338
|
+
"prunable": false,
|
|
339
|
+
},
|
|
340
|
+
]
|
|
341
|
+
`);
|
|
342
|
+
});
|
|
343
|
+
test('handles detached HEAD worktrees', () => {
|
|
344
|
+
const output = [
|
|
345
|
+
'worktree /Users/me/project',
|
|
346
|
+
'HEAD abc123',
|
|
347
|
+
'branch refs/heads/main',
|
|
348
|
+
'',
|
|
349
|
+
'worktree /Users/me/detached-wt',
|
|
350
|
+
'HEAD deadbeef',
|
|
351
|
+
'detached',
|
|
352
|
+
'',
|
|
353
|
+
].join('\n');
|
|
354
|
+
const result = parseGitWorktreeListPorcelain(output);
|
|
355
|
+
expect(result).toMatchInlineSnapshot(`
|
|
356
|
+
[
|
|
357
|
+
{
|
|
358
|
+
"branch": null,
|
|
359
|
+
"detached": true,
|
|
360
|
+
"directory": "/Users/me/detached-wt",
|
|
361
|
+
"head": "deadbeef",
|
|
362
|
+
"locked": false,
|
|
363
|
+
"prunable": false,
|
|
364
|
+
},
|
|
365
|
+
]
|
|
366
|
+
`);
|
|
367
|
+
});
|
|
368
|
+
test('parses locked and prunable flags', () => {
|
|
369
|
+
const output = [
|
|
370
|
+
'worktree /Users/me/project',
|
|
371
|
+
'HEAD abc123',
|
|
372
|
+
'branch refs/heads/main',
|
|
373
|
+
'',
|
|
374
|
+
'worktree /Users/me/locked-wt',
|
|
375
|
+
'HEAD aaa111',
|
|
376
|
+
'branch refs/heads/feature-locked',
|
|
377
|
+
'locked portable disk',
|
|
378
|
+
'',
|
|
379
|
+
'worktree /Users/me/prunable-wt',
|
|
380
|
+
'HEAD bbb222',
|
|
381
|
+
'branch refs/heads/stale-branch',
|
|
382
|
+
'prunable gitdir file points to non-existent location',
|
|
383
|
+
'',
|
|
384
|
+
].join('\n');
|
|
385
|
+
expect(parseGitWorktreeListPorcelain(output)).toMatchInlineSnapshot(`
|
|
386
|
+
[
|
|
387
|
+
{
|
|
388
|
+
"branch": "feature-locked",
|
|
389
|
+
"detached": false,
|
|
390
|
+
"directory": "/Users/me/locked-wt",
|
|
391
|
+
"head": "aaa111",
|
|
392
|
+
"locked": true,
|
|
393
|
+
"prunable": false,
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
"branch": "stale-branch",
|
|
397
|
+
"detached": false,
|
|
398
|
+
"directory": "/Users/me/prunable-wt",
|
|
399
|
+
"head": "bbb222",
|
|
400
|
+
"locked": false,
|
|
401
|
+
"prunable": true,
|
|
402
|
+
},
|
|
403
|
+
]
|
|
404
|
+
`);
|
|
405
|
+
});
|
|
406
|
+
test('returns empty array when only main worktree exists', () => {
|
|
407
|
+
const output = [
|
|
408
|
+
'worktree /Users/me/project',
|
|
409
|
+
'HEAD abc123',
|
|
410
|
+
'branch refs/heads/main',
|
|
411
|
+
'',
|
|
412
|
+
].join('\n');
|
|
413
|
+
expect(parseGitWorktreeListPorcelain(output)).toMatchInlineSnapshot(`[]`);
|
|
414
|
+
});
|
|
189
415
|
});
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@otto-assistant/bridge",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.103",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "tsx src/bin.ts",
|
|
8
8
|
"prepublishOnly": "pnpm generate && pnpm tsc",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"discord.js": "^14.25.1",
|
|
71
71
|
"domhandler": "^6.0.1",
|
|
72
72
|
"errore": "workspace:^",
|
|
73
|
-
"goke": "^6.
|
|
73
|
+
"goke": "^6.6.0",
|
|
74
74
|
"htmlparser2": "^12.0.0",
|
|
75
75
|
"kitty-graphics-agent": "^0.0.5",
|
|
76
76
|
"libsql": "^0.5.22",
|