oh-my-codex 0.21.11 → 0.21.13

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.
Files changed (59) hide show
  1. package/Cargo.lock +8 -8
  2. package/Cargo.toml +1 -1
  3. package/dist/hud/__tests__/index.test.js +252 -0
  4. package/dist/hud/__tests__/index.test.js.map +1 -1
  5. package/dist/hud/__tests__/spawn-reconcile-with-timeout.test.d.ts +2 -0
  6. package/dist/hud/__tests__/spawn-reconcile-with-timeout.test.d.ts.map +1 -0
  7. package/dist/hud/__tests__/spawn-reconcile-with-timeout.test.js +175 -0
  8. package/dist/hud/__tests__/spawn-reconcile-with-timeout.test.js.map +1 -0
  9. package/dist/hud/__tests__/state.test.js +202 -1
  10. package/dist/hud/__tests__/state.test.js.map +1 -1
  11. package/dist/hud/__tests__/tmux.test.js +103 -7
  12. package/dist/hud/__tests__/tmux.test.js.map +1 -1
  13. package/dist/hud/constants.d.ts +1 -0
  14. package/dist/hud/constants.d.ts.map +1 -1
  15. package/dist/hud/constants.js +3 -0
  16. package/dist/hud/constants.js.map +1 -1
  17. package/dist/hud/index.d.ts +5 -0
  18. package/dist/hud/index.d.ts.map +1 -1
  19. package/dist/hud/index.js +51 -18
  20. package/dist/hud/index.js.map +1 -1
  21. package/dist/hud/spawn-reconcile-with-timeout.d.ts +20 -0
  22. package/dist/hud/spawn-reconcile-with-timeout.d.ts.map +1 -0
  23. package/dist/hud/spawn-reconcile-with-timeout.js +80 -0
  24. package/dist/hud/spawn-reconcile-with-timeout.js.map +1 -0
  25. package/dist/hud/state.d.ts +5 -0
  26. package/dist/hud/state.d.ts.map +1 -1
  27. package/dist/hud/state.js +69 -1
  28. package/dist/hud/state.js.map +1 -1
  29. package/dist/hud/tmux.d.ts +2 -0
  30. package/dist/hud/tmux.d.ts.map +1 -1
  31. package/dist/hud/tmux.js +24 -5
  32. package/dist/hud/tmux.js.map +1 -1
  33. package/dist/team/__tests__/completion-evidence.test.d.ts +2 -0
  34. package/dist/team/__tests__/completion-evidence.test.d.ts.map +1 -0
  35. package/dist/team/__tests__/completion-evidence.test.js +259 -0
  36. package/dist/team/__tests__/completion-evidence.test.js.map +1 -0
  37. package/dist/team/api-interop.d.ts.map +1 -1
  38. package/dist/team/api-interop.js +4 -1
  39. package/dist/team/api-interop.js.map +1 -1
  40. package/dist/team/completion-evidence.d.ts +3 -0
  41. package/dist/team/completion-evidence.d.ts.map +1 -0
  42. package/dist/team/completion-evidence.js +50 -0
  43. package/dist/team/completion-evidence.js.map +1 -0
  44. package/dist/team/state/tasks.d.ts +2 -0
  45. package/dist/team/state/tasks.d.ts.map +1 -1
  46. package/dist/team/state/tasks.js +14 -2
  47. package/dist/team/state/tasks.js.map +1 -1
  48. package/dist/team/state/types.d.ts +3 -1
  49. package/dist/team/state/types.d.ts.map +1 -1
  50. package/dist/team/state/types.js.map +1 -1
  51. package/dist/team/state.d.ts +4 -2
  52. package/dist/team/state.d.ts.map +1 -1
  53. package/dist/team/state.js +12 -1
  54. package/dist/team/state.js.map +1 -1
  55. package/dist/team/worker-bootstrap.d.ts.map +1 -1
  56. package/dist/team/worker-bootstrap.js +3 -0
  57. package/dist/team/worker-bootstrap.js.map +1 -1
  58. package/package.json +1 -1
  59. package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
@@ -0,0 +1,175 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { test } from 'node:test';
3
+ import { spawn } from 'node:child_process';
4
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { spawnReconcileWithTimeout } from '../spawn-reconcile-with-timeout.js';
8
+ test('spawnReconcileWithTimeout passes reconcile authority to the child', async () => {
9
+ const testDir = await mkdtemp(join(tmpdir(), 'omx-hud-reconcile-success-'));
10
+ try {
11
+ const entry = join(testDir, 'reconcile.mjs');
12
+ const reportPath = join(testDir, 'report.json');
13
+ await writeFile(entry, [
14
+ "import { writeFileSync } from 'node:fs';",
15
+ 'writeFileSync(process.env.OMX_TEST_REPORT, JSON.stringify({ args: process.argv.slice(2), repair: process.env.OMX_HUD_WATCH_REPAIR, pane: process.env.TMUX_PANE, leaderPid: process.env.OMX_HUD_WATCH_LEADER_PID }));',
16
+ ].join('\n'));
17
+ const completed = await spawnReconcileWithTimeout({
18
+ omxBinary: entry,
19
+ cwd: testDir,
20
+ env: { ...process.env, OMX_TEST_REPORT: reportPath },
21
+ timeoutMs: 5_000,
22
+ leaderPaneId: '%1',
23
+ leaderPanePid: '101',
24
+ keepAlive: true,
25
+ });
26
+ assert.equal(completed, true);
27
+ assert.deepEqual(JSON.parse(await readFile(reportPath, 'utf8')), {
28
+ args: ['hud', '--reconcile-tmux'],
29
+ repair: '1',
30
+ pane: '%1',
31
+ leaderPid: '101',
32
+ });
33
+ }
34
+ finally {
35
+ await rm(testDir, { recursive: true, force: true });
36
+ }
37
+ });
38
+ test('spawnReconcileWithTimeout kills a hung child and its process tree', async () => {
39
+ const testDir = await mkdtemp(join(tmpdir(), 'omx-hud-reconcile-timeout-'));
40
+ const childPidPath = join(testDir, 'child.pid');
41
+ const grandchildPidPath = join(testDir, 'grandchild.pid');
42
+ const markerPath = join(testDir, 'grandchild-survived');
43
+ try {
44
+ const entry = join(testDir, 'hung-reconcile.mjs');
45
+ await writeFile(entry, [
46
+ "import { spawn } from 'node:child_process';",
47
+ "import { writeFileSync } from 'node:fs';",
48
+ 'writeFileSync(process.env.OMX_TEST_CHILD_PID, String(process.pid));',
49
+ `const grandchild = spawn(process.execPath, ['-e', 'setTimeout(() => require("node:fs").writeFileSync(process.env.OMX_TEST_MARKER, "alive"), 1200); setInterval(() => {}, 1000);'], { stdio: 'ignore', env: process.env });`,
50
+ 'writeFileSync(process.env.OMX_TEST_GRANDCHILD_PID, String(grandchild.pid));',
51
+ 'setInterval(() => {}, 1000);',
52
+ ].join('\n'));
53
+ const startedAt = Date.now();
54
+ const completed = await spawnReconcileWithTimeout({
55
+ omxBinary: entry,
56
+ cwd: testDir,
57
+ env: {
58
+ ...process.env,
59
+ OMX_TEST_CHILD_PID: childPidPath,
60
+ OMX_TEST_GRANDCHILD_PID: grandchildPidPath,
61
+ OMX_TEST_MARKER: markerPath,
62
+ },
63
+ timeoutMs: 500,
64
+ keepAlive: true,
65
+ });
66
+ assert.equal(completed, false);
67
+ assert.ok(Date.now() - startedAt < 7_000, 'the hung reconcile is bounded');
68
+ assert.ok((await readFile(childPidPath, 'utf8')).trim().length > 0, 'reconcile child started');
69
+ assert.ok((await readFile(grandchildPidPath, 'utf8')).trim().length > 0, 'reconcile grandchild started');
70
+ await new Promise((resolve) => setTimeout(resolve, 1_300));
71
+ await assert.rejects(readFile(markerPath), { code: 'ENOENT' }, 'the grandchild was killed with the reconcile child');
72
+ }
73
+ finally {
74
+ for (const pidPath of [childPidPath, grandchildPidPath]) {
75
+ const pid = Number(await readFile(pidPath, 'utf8').catch(() => ''));
76
+ if (Number.isSafeInteger(pid) && pid > 0) {
77
+ try {
78
+ process.kill(pid, 'SIGKILL');
79
+ }
80
+ catch {
81
+ // The timeout may already have killed the process.
82
+ }
83
+ }
84
+ }
85
+ await rm(testDir, { recursive: true, force: true });
86
+ }
87
+ });
88
+ test('spawnReconcileWithTimeout kills its detached child when the wrapper receives SIGTERM', async () => {
89
+ if (process.platform === 'win32')
90
+ return;
91
+ const testDir = await mkdtemp(join(tmpdir(), 'omx-hud-reconcile-sigterm-'));
92
+ const entry = join(testDir, 'hung-reconcile.mjs');
93
+ const wrapper = join(testDir, 'reconcile-wrapper.mjs');
94
+ const childPidPath = join(testDir, 'child.pid');
95
+ const markerPath = join(testDir, 'child-survived');
96
+ let wrapperProcess;
97
+ let childPid = 0;
98
+ try {
99
+ await writeFile(entry, [
100
+ "import { writeFileSync } from 'node:fs';",
101
+ 'writeFileSync(process.env.OMX_TEST_CHILD_PID, String(process.pid));',
102
+ 'setTimeout(() => writeFileSync(process.env.OMX_TEST_MARKER, "alive"), 1_500);',
103
+ 'setInterval(() => {}, 1_000);',
104
+ ].join('\n'));
105
+ await writeFile(wrapper, [
106
+ `import { spawnReconcileWithTimeout } from '${new URL('../spawn-reconcile-with-timeout.js', import.meta.url).href}';`,
107
+ 'await spawnReconcileWithTimeout({',
108
+ ' omxBinary: process.env.OMX_TEST_ENTRY,',
109
+ ' cwd: process.env.OMX_TEST_DIR,',
110
+ ' env: process.env,',
111
+ ' timeoutMs: 5_000,',
112
+ ' keepAlive: true,',
113
+ '});',
114
+ ].join('\n'));
115
+ const childWrapper = spawn(process.execPath, [wrapper], {
116
+ cwd: testDir,
117
+ env: {
118
+ ...process.env,
119
+ OMX_TEST_ENTRY: entry,
120
+ OMX_TEST_DIR: testDir,
121
+ OMX_TEST_CHILD_PID: childPidPath,
122
+ OMX_TEST_MARKER: markerPath,
123
+ },
124
+ stdio: 'ignore',
125
+ });
126
+ wrapperProcess = childWrapper;
127
+ const deadline = Date.now() + 3_000;
128
+ while (Date.now() < deadline && childPid === 0) {
129
+ const pid = await readFile(childPidPath, 'utf8').catch(() => '');
130
+ childPid = Number(pid.trim()) || 0;
131
+ if (childPid === 0)
132
+ await new Promise((resolve) => setTimeout(resolve, 10));
133
+ }
134
+ assert.ok(childPid > 0, 'the detached reconcile child started');
135
+ const closed = new Promise((resolve, reject) => {
136
+ childWrapper.once('error', reject);
137
+ childWrapper.once('close', (code, signal) => resolve({ code, signal }));
138
+ });
139
+ assert.equal(childWrapper.kill('SIGTERM'), true);
140
+ const result = await closed;
141
+ assert.equal(result.signal, null);
142
+ assert.equal(result.code, 0);
143
+ await new Promise((resolve) => setTimeout(resolve, 1_800));
144
+ await assert.rejects(readFile(markerPath), { code: 'ENOENT' }, 'the detached child was killed with its wrapper');
145
+ }
146
+ finally {
147
+ wrapperProcess?.kill('SIGKILL');
148
+ if (childPid > 0) {
149
+ try {
150
+ process.kill(-childPid, 'SIGKILL');
151
+ }
152
+ catch {
153
+ // The wrapper signal handler may already have killed the reconcile process group.
154
+ }
155
+ }
156
+ await rm(testDir, { recursive: true, force: true });
157
+ }
158
+ });
159
+ test('spawnReconcileWithTimeout returns false when the reconcile child fails', async () => {
160
+ const testDir = await mkdtemp(join(tmpdir(), 'omx-hud-reconcile-error-'));
161
+ try {
162
+ const result = await spawnReconcileWithTimeout({
163
+ omxBinary: join(testDir, 'missing-entry.mjs'),
164
+ cwd: testDir,
165
+ env: process.env,
166
+ timeoutMs: 5_000,
167
+ keepAlive: true,
168
+ });
169
+ assert.equal(result, false);
170
+ }
171
+ finally {
172
+ await rm(testDir, { recursive: true, force: true });
173
+ }
174
+ });
175
+ //# sourceMappingURL=spawn-reconcile-with-timeout.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spawn-reconcile-with-timeout.test.js","sourceRoot":"","sources":["../../../src/hud/__tests__/spawn-reconcile-with-timeout.test.ts"],"names":[],"mappings":"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"}
@@ -2,7 +2,7 @@ import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import { existsSync } from 'node:fs';
5
- import { mkdtemp, mkdir, rm, stat, writeFile } from 'node:fs/promises';
5
+ import { mkdtemp, mkdir, rename, rm, stat, utimes, writeFile } from 'node:fs/promises';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { basename, join, relative } from 'node:path';
8
8
  import { renderHud } from '../render.js';
@@ -297,6 +297,207 @@ describe('buildGitBranchLabel', () => {
297
297
  });
298
298
  });
299
299
  });
300
+ function countingGitRunner(state) {
301
+ const calls = [];
302
+ const runner = (_cwd, args) => {
303
+ const command = args.join(' ');
304
+ calls.push(command);
305
+ if (command === 'rev-parse --abbrev-ref HEAD')
306
+ return state.branch;
307
+ if (command === 'remote get-url origin')
308
+ return state.remoteUrl;
309
+ throw new Error('Unexpected command: git ' + command);
310
+ };
311
+ return { runner, calls };
312
+ }
313
+ function remoteConfig(url) {
314
+ return ['[remote "origin"]', ` url = ${url}`, ''].join('\n');
315
+ }
316
+ async function createPlainGitDirFixture(cwd, branch, url) {
317
+ const gitDir = join(cwd, '.git');
318
+ await mkdir(gitDir, { recursive: true });
319
+ await writeFile(join(gitDir, 'HEAD'), `ref: refs/heads/${branch}\n`);
320
+ await writeFile(join(gitDir, 'config'), remoteConfig(url));
321
+ }
322
+ // git replaces HEAD and config by renaming a freshly written <file>.lock over them.
323
+ async function rewriteLikeGit(path, content) {
324
+ await writeFile(`${path}.lock`, content);
325
+ await rename(`${path}.lock`, path);
326
+ }
327
+ describe('buildGitBranchLabel caching', () => {
328
+ it('reuses the label while HEAD and config are unchanged', async () => {
329
+ await withTempRepo('omx-hud-label-cache-hit-', async (cwd) => {
330
+ await createPlainGitDirFixture(cwd, 'main', 'https://github.com/acme/first-repo.git');
331
+ const { runner, calls } = countingGitRunner({ branch: 'main', remoteUrl: 'https://github.com/acme/first-repo.git' });
332
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'first-repo/main');
333
+ assert.equal(calls.length, 2);
334
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'first-repo/main');
335
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'first-repo/main');
336
+ assert.equal(calls.length, 2);
337
+ });
338
+ });
339
+ it('recomputes an unchanged label once the cache entry is 30 seconds old', async (t) => {
340
+ await withTempRepo('omx-hud-label-cache-ttl-', async (cwd) => {
341
+ await createPlainGitDirFixture(cwd, 'main', 'https://github.com/acme/first-repo.git');
342
+ const state = { branch: 'main', remoteUrl: 'https://github.com/acme/first-repo.git' };
343
+ const { runner, calls } = countingGitRunner(state);
344
+ let now = 1_700_000_000_000;
345
+ t.mock.method(Date, 'now', () => now);
346
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'first-repo/main');
347
+ // A change the fingerprint cannot see, such as an insteadOf rule in the global config.
348
+ state.remoteUrl = 'https://github.com/acme/rewritten-repo.git';
349
+ now += 29_999;
350
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'first-repo/main');
351
+ assert.equal(calls.length, 2);
352
+ now += 1;
353
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'rewritten-repo/main');
354
+ assert.equal(calls.length, 4);
355
+ });
356
+ });
357
+ it('recomputes after a backward clock adjustment', async (t) => {
358
+ await withTempRepo('omx-hud-label-cache-clock-back-', async (cwd) => {
359
+ await createPlainGitDirFixture(cwd, 'main', 'https://github.com/acme/first-repo.git');
360
+ const state = { branch: 'main', remoteUrl: 'https://github.com/acme/first-repo.git' };
361
+ const { runner, calls } = countingGitRunner(state);
362
+ let now = 1_700_000_000_000;
363
+ t.mock.method(Date, 'now', () => now);
364
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'first-repo/main');
365
+ assert.equal(calls.length, 2);
366
+ // Simulate a backward clock adjustment (e.g., NTP correction)
367
+ now -= 5_000;
368
+ // A change the fingerprint cannot see. The cache should be invalidated
369
+ // even though the age is negative, preventing stale labels during time adjustments.
370
+ state.remoteUrl = 'https://github.com/acme/rewritten-repo.git';
371
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'rewritten-repo/main');
372
+ assert.equal(calls.length, 4);
373
+ });
374
+ });
375
+ it('recomputes after HEAD is replaced, even with the same size and mtime', async () => {
376
+ await withTempRepo('omx-hud-label-cache-head-', async (cwd) => {
377
+ await createPlainGitDirFixture(cwd, 'main', 'https://github.com/acme/first-repo.git');
378
+ const headPath = join(cwd, '.git', 'HEAD');
379
+ const fixedSeconds = 1_700_000_000;
380
+ await utimes(headPath, fixedSeconds, fixedSeconds);
381
+ const state = { branch: 'main', remoteUrl: 'https://github.com/acme/first-repo.git' };
382
+ const { runner, calls } = countingGitRunner(state);
383
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'first-repo/main');
384
+ const before = await stat(headPath);
385
+ state.branch = 'next';
386
+ await writeFile(`${headPath}.lock`, 'ref: refs/heads/next\n');
387
+ await utimes(`${headPath}.lock`, fixedSeconds, fixedSeconds);
388
+ await rename(`${headPath}.lock`, headPath);
389
+ const after = await stat(headPath);
390
+ assert.equal(after.size, before.size);
391
+ assert.equal(after.mtimeMs, before.mtimeMs);
392
+ assert.notEqual(after.ino, before.ino);
393
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'first-repo/next');
394
+ assert.equal(calls.length, 4);
395
+ });
396
+ });
397
+ it('recomputes after the remote url in config changes', async () => {
398
+ await withTempRepo('omx-hud-label-cache-remote-', async (cwd) => {
399
+ await createPlainGitDirFixture(cwd, 'main', 'https://github.com/acme/first-repo.git');
400
+ const state = { branch: 'main', remoteUrl: 'https://github.com/acme/first-repo.git' };
401
+ const { runner, calls } = countingGitRunner(state);
402
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'first-repo/main');
403
+ state.remoteUrl = 'https://github.com/acme/renamed-repository.git';
404
+ await rewriteLikeGit(join(cwd, '.git', 'config'), remoteConfig(state.remoteUrl));
405
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'renamed-repository/main');
406
+ assert.equal(calls.length, 4);
407
+ });
408
+ });
409
+ it('recomputes after the common-dir config of a worktree changes', async () => {
410
+ await withTempRepo('omx-hud-label-cache-worktree-', async (cwd) => {
411
+ await createWorktreePointerFixture(cwd);
412
+ const state = { branch: 'worktree-branch', remoteUrl: 'git@github.com:acme/worktree-repo.git' };
413
+ const { runner, calls } = countingGitRunner(state);
414
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'worktree-repo/worktree-branch');
415
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'worktree-repo/worktree-branch');
416
+ assert.equal(calls.length, 2);
417
+ state.remoteUrl = 'git@github.com:acme/moved-repository.git';
418
+ await rewriteLikeGit(join(cwd, '.git-admin', 'config'), remoteConfig(state.remoteUrl));
419
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'moved-repository/worktree-branch');
420
+ assert.equal(calls.length, 4);
421
+ });
422
+ });
423
+ it('keeps one cached label per git display config', async () => {
424
+ await withTempRepo('omx-hud-label-cache-config-', async (cwd) => {
425
+ await createPlainGitDirFixture(cwd, 'main', 'https://github.com/acme/first-repo.git');
426
+ const { runner, calls } = countingGitRunner({ branch: 'main', remoteUrl: 'https://github.com/acme/first-repo.git' });
427
+ const branchOnly = { preset: 'focused', git: { display: 'branch' }, statusLine: { preset: 'focused' } };
428
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'first-repo/main');
429
+ assert.equal(buildGitBranchLabel(cwd, branchOnly, runner), 'main');
430
+ assert.equal(calls.length, 3);
431
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'first-repo/main');
432
+ assert.equal(buildGitBranchLabel(cwd, branchOnly, runner), 'main');
433
+ assert.equal(calls.length, 3);
434
+ });
435
+ });
436
+ it('does not cache when there is no git layout', async () => {
437
+ await withTempRepo('omx-hud-label-cache-nogit-', async (cwd) => {
438
+ const { runner, calls } = countingGitRunner({ branch: 'main', remoteUrl: 'https://github.com/acme/first-repo.git' });
439
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'first-repo/main');
440
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'first-repo/main');
441
+ assert.equal(calls.length, 4);
442
+ });
443
+ });
444
+ it('does not cache a null label', async () => {
445
+ await withTempRepo('omx-hud-label-cache-null-', async (cwd) => {
446
+ await createPlainGitDirFixture(cwd, 'main', 'https://github.com/acme/first-repo.git');
447
+ const state = { branch: null, remoteUrl: 'https://github.com/acme/first-repo.git' };
448
+ const { runner, calls } = countingGitRunner(state);
449
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), null);
450
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), null);
451
+ assert.equal(calls.length, 2);
452
+ // The first commit creates refs/heads/main without touching HEAD or config.
453
+ state.branch = 'main';
454
+ assert.equal(buildGitBranchLabel(cwd, undefined, runner), 'first-repo/main');
455
+ });
456
+ });
457
+ it('shows a branch switch and a remote change from real git on the next call', { skip: process.platform === 'win32' }, async () => {
458
+ await withTempRepo('omx-hud-label-cache-realgit-', async (cwd) => {
459
+ initGitRepo(cwd);
460
+ assert.equal(buildGitBranchLabel(cwd), 'origin-repo/safe-branch');
461
+ execFileSync('git', ['checkout', '-b', 'other-branch'], { cwd, stdio: 'ignore' });
462
+ assert.equal(buildGitBranchLabel(cwd), 'origin-repo/other-branch');
463
+ execFileSync('git', ['remote', 'set-url', 'origin', 'https://github.com/acme/renamed-repo.git'], { cwd, stdio: 'ignore' });
464
+ assert.equal(buildGitBranchLabel(cwd), 'renamed-repo/other-branch');
465
+ execFileSync('git', ['checkout', '--detach'], { cwd, stdio: 'ignore' });
466
+ assert.equal(buildGitBranchLabel(cwd), 'renamed-repo/HEAD');
467
+ });
468
+ });
469
+ it('shows a branch switch in a reftable repository and its linked worktree', { skip: process.platform === 'win32' }, async (t) => {
470
+ await withTempRepo('omx-hud-label-cache-reftable-', async (cwd) => {
471
+ try {
472
+ execFileSync('git', ['init', '--ref-format=reftable'], { cwd, stdio: 'ignore' });
473
+ }
474
+ catch {
475
+ t.skip('git does not support --ref-format=reftable');
476
+ return;
477
+ }
478
+ const git = (dir, ...args) => execFileSync('git', args, { cwd: dir, stdio: 'ignore' });
479
+ git(cwd, 'config', 'user.email', 'test@example.com');
480
+ git(cwd, 'config', 'user.name', 'Test User');
481
+ git(cwd, 'remote', 'add', 'origin', 'https://github.com/acme/reftable-repo.git');
482
+ git(cwd, 'checkout', '-b', 'first-branch');
483
+ git(cwd, 'commit', '--allow-empty', '-m', 'init');
484
+ const headPath = join(cwd, '.git', 'HEAD');
485
+ const headBefore = await stat(headPath);
486
+ assert.equal(buildGitBranchLabel(cwd), 'reftable-repo/first-branch');
487
+ git(cwd, 'checkout', '-b', 'second-branch');
488
+ const headAfter = await stat(headPath);
489
+ assert.equal(headAfter.ino, headBefore.ino);
490
+ assert.equal(headAfter.mtimeMs, headBefore.mtimeMs);
491
+ assert.equal(buildGitBranchLabel(cwd), 'reftable-repo/second-branch');
492
+ const worktree = join(cwd, 'linked');
493
+ git(cwd, 'worktree', 'add', '-b', 'linked-branch', worktree);
494
+ assert.equal(buildGitBranchLabel(worktree), 'reftable-repo/linked-branch');
495
+ git(worktree, 'checkout', '-b', 'linked-next');
496
+ assert.equal(buildGitBranchLabel(worktree), 'reftable-repo/linked-next');
497
+ assert.equal(buildGitBranchLabel(cwd), 'reftable-repo/second-branch');
498
+ });
499
+ });
500
+ });
300
501
  describe('readUltragoalState', { concurrency: false }, () => {
301
502
  it('summarizes active ultragoal progress from goals.json', async () => {
302
503
  await withTempRepo('omx-hud-ultragoal-', async (cwd) => {