项目文件夹

文件
Jarrod Watts adec51ef12 feat: add safe Jujutsu status indicators (#685)
Add opt-in Jujutsu status to compact and expanded layouts with prompt-safe repository probing, bounded non-interactive execution, strict output parsing, Git fallback, and maintainer-reviewed documentation and tests.\n\nPreserves the contribution and commit history from #684.
2026-07-21 12:37:32 +10:00

268 行
9.5 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { isJjRepo, getJjStatus } from '../dist/jj.js';
function hasJj() {
try {
execFileSync('jj', ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
const jjAvailable = hasJj();
const skipReason = jjAvailable ? false : 'jj binary not installed';
test('isJjRepo returns false when cwd is undefined', () => {
assert.equal(isJjRepo(undefined), false);
});
test('isJjRepo returns false for a non-jj directory', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-nojj-'));
try {
assert.equal(isJjRepo(dir), false);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('getJjStatus returns null when cwd is undefined', async () => {
const result = await getJjStatus(undefined);
assert.equal(result, null);
});
test('getJjStatus returns null for a non-jj directory', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-nojj-'));
try {
const result = await getJjStatus(dir);
assert.equal(result, null);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('getJjStatus uses a fixed read-only bounded invocation', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-jj-runner-'));
let invocation;
try {
const result = await getJjStatus(dir, async (file, args, options) => {
invocation = { file, args: [...args], options };
return { stdout: 'qpvuntsm\x1fmain\x1f0\x1f0\n' };
});
assert.equal(result?.branch, 'main');
assert.equal(invocation?.file, 'jj');
assert.deepEqual(invocation?.args.slice(0, 4), [
'--ignore-working-copy',
'--at-operation=@',
'--no-pager',
'log',
]);
assert.deepEqual(invocation?.args.slice(4, 10), [
'-r', '@', '--no-graph', '--color', 'never', '-T',
]);
assert.match(invocation?.args[10] ?? '', /local_bookmarks/);
assert.equal(invocation?.options.cwd, await realpath(dir));
assert.equal(invocation?.options.timeout, 2000);
assert.equal(invocation?.options.maxBuffer, 16 * 1024);
assert.equal(invocation?.options.encoding, 'utf8');
assert.equal(invocation?.options.shell, false);
assert.equal(invocation?.options.windowsHide, true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('getJjStatus strictly validates field count and boolean flags', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-jj-parse-'));
try {
for (const stdout of [
'change\x1fmain\x1f0',
'change\x1fmain\x1f0\x1f0\x1fextra',
'change\x1fmain\x1fyes\x1f0',
'change\x1fmain\x1f0\x1f2',
'\x1fmain\x1f0\x1f0',
'change\x1fmain\x1f0\x1f0\nextra',
'change\x1fmain\x1e\x1f0\x1f0',
]) {
const result = await getJjStatus(dir, async () => ({ stdout }));
assert.equal(result, null, `expected malformed output to be rejected: ${JSON.stringify(stdout)}`);
}
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('getJjStatus preserves commas and separates local bookmarks safely', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-jj-bookmarks-'));
try {
const result = await getJjStatus(
dir,
async () => ({ stdout: 'qpvuntsm\x1ffeature,one\x1efeature-two\x1f1\x1f0' }),
);
assert.equal(result?.branch, 'feature,one');
assert.equal(result?.isDirty, true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('getJjStatus sanitizes terminal controls and caps the chosen label', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-jj-label-'));
try {
const unsafe = `safe\x1b]8;;https://evil.example\x07link\x1b]8;;\x07\u202E${'x'.repeat(100)}`;
const result = await getJjStatus(
dir,
async () => ({ stdout: `qpvuntsm\x1f${unsafe}\x1f0\x1f1` }),
);
assert.ok(result);
assert.equal(result.branch.length, 64);
assert.equal(result.branch.includes('\x1b'), false);
assert.equal(result.branch.includes('\u202E'), false);
assert.equal(result.conflict, true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('isJjRepo resolves the cwd before walking', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-jj-realpath-'));
const link = `${dir}-link`;
try {
await mkdir(path.join(dir, '.jj'));
await symlink(dir, link, 'dir');
assert.equal(isJjRepo(link), true);
} finally {
await rm(link, { recursive: true, force: true });
await rm(dir, { recursive: true, force: true });
}
});
test('isJjRepo rejects a symlinked .jj marker', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-jj-symlink-'));
const markerTarget = await mkdtemp(path.join(tmpdir(), 'claude-hud-jj-marker-'));
try {
await symlink(markerTarget, path.join(dir, '.jj'), 'dir');
assert.equal(isJjRepo(dir), false);
} finally {
await rm(dir, { recursive: true, force: true });
await rm(markerTarget, { recursive: true, force: true });
}
});
test('isJjRepo stops at a nearer .git marker', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-jj-boundary-'));
const nested = path.join(dir, 'nested');
const deep = path.join(nested, 'deep');
try {
await mkdir(path.join(dir, '.jj'));
await mkdir(deep, { recursive: true });
await writeFile(path.join(nested, '.git'), 'gitdir: elsewhere\n');
assert.equal(isJjRepo(deep), false);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('isJjRepo prefers a same-directory .jj marker over .git', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-jj-colocated-'));
try {
await mkdir(path.join(dir, '.jj'));
await mkdir(path.join(dir, '.git'));
assert.equal(isJjRepo(dir), true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('isJjRepo and getJjStatus detect a real jj repo', { skip: skipReason }, async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-jj-'));
try {
execFileSync('jj', ['git', 'init'], { cwd: dir, stdio: 'ignore' });
assert.equal(isJjRepo(dir), true);
const result = await getJjStatus(dir);
assert.equal(result?.vcs, 'jj');
assert.equal(result?.conflict, false);
assert.equal(result?.isDirty, false);
assert.ok(result?.branch, `expected an anonymous change id, got ${JSON.stringify(result)}`);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('getJjStatus reports isDirty after an uncommitted change', { skip: skipReason }, async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-jj-'));
try {
execFileSync('jj', ['git', 'init'], { cwd: dir, stdio: 'ignore' });
await writeFile(path.join(dir, 'a.txt'), 'hello');
const result = await getJjStatus(dir);
assert.equal(result?.isDirty, true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('getJjStatus reports the bookmark name at @ when one exists', { skip: skipReason }, async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-jj-'));
try {
execFileSync('jj', ['git', 'init'], { cwd: dir, stdio: 'ignore' });
execFileSync('jj', ['bookmark', 'create', 'mybookmark', '-r', '@'], { cwd: dir, stdio: 'ignore' });
const result = await getJjStatus(dir);
assert.equal(result?.branch, 'mybookmark');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('isJjRepo detects a jj repo from a nested subdirectory', { skip: skipReason }, async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-jj-'));
try {
execFileSync('jj', ['git', 'init'], { cwd: dir, stdio: 'ignore' });
const nested = path.join(dir, 'a', 'b', 'c');
await mkdir(nested, { recursive: true });
assert.equal(isJjRepo(nested), true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('getJjStatus detects a genuine conflict', { skip: skipReason }, async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-jj-'));
try {
execFileSync('jj', ['git', 'init'], { cwd: dir, stdio: 'ignore' });
await writeFile(path.join(dir, 'f.txt'), 'original\n');
execFileSync('jj', ['commit', '-m', 'initial'], { cwd: dir, stdio: 'ignore' });
const initialId = execFileSync(
'jj',
['log', '-r', 'heads(::@- ~ root())', '--no-graph', '-T', 'change_id.shortest(8)'],
{ cwd: dir, encoding: 'utf8' }
).trim();
execFileSync('jj', ['new', initialId, '-m', 'A'], { cwd: dir, stdio: 'ignore' });
await writeFile(path.join(dir, 'f.txt'), 'A-version\n');
const aId = execFileSync('jj', ['log', '-r', '@', '--no-graph', '-T', 'change_id.shortest(8)'], { cwd: dir, encoding: 'utf8' }).trim();
execFileSync('jj', ['new', initialId, '-m', 'B'], { cwd: dir, stdio: 'ignore' });
await writeFile(path.join(dir, 'f.txt'), 'B-version\n');
const bId = execFileSync('jj', ['log', '-r', '@', '--no-graph', '-T', 'change_id.shortest(8)'], { cwd: dir, encoding: 'utf8' }).trim();
execFileSync('jj', ['rebase', '-r', bId, '-d', aId], { cwd: dir, stdio: 'ignore' });
execFileSync('jj', ['edit', bId], { cwd: dir, stdio: 'ignore' });
const result = await getJjStatus(dir);
assert.equal(result?.conflict, true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});