mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
feat(ui): git status indicator in the bottom bar, with a panel of uncommitted and unpushed work
Optional and per-device (showGitStatus, default off). GET /api/sessions/:id/git-status is read-only and offline (no fetch, --no-optional-locks), skips remote and Docker sessions, caps its lists, and single-flights concurrent polls. The toolbar indicator shows uncommitted files, commits not pushed, or a check; clicking opens a draggable panel in the style of the Files window. Which repositories: the enclosing one when there is one; otherwise every repository up to two levels below the working directory (capped, skipping dot-folders and node_modules, never following symlinks), each in a collapsible section, with the indicator summing them. A repository that merely sits above the workspace and is the home folder or higher (a dotfiles repo) is ignored. Git-supplied text is only ever written with textContent. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
ffaa5ee80c
commit
bfc164a262
@@ -0,0 +1,286 @@
|
||||
/** @fileoverview Bottom-bar Git indicator and panel, end to end: real server, real Chromium, a real git repo with a remote. */
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3192;
|
||||
const ENV = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'T',
|
||||
GIT_AUTHOR_EMAIL: 't@example.com',
|
||||
GIT_COMMITTER_NAME: 'T',
|
||||
GIT_COMMITTER_EMAIL: 't@example.com',
|
||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
};
|
||||
const git = (cwd: string, ...args: string[]) =>
|
||||
execFileSync('git', ['-c', 'protocol.file.allow=always', ...args], { cwd, env: ENV, stdio: 'ignore' });
|
||||
|
||||
describe('Git status indicator in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
let root: string;
|
||||
let repo: string;
|
||||
let plain: string;
|
||||
let repoSession: string;
|
||||
let plainSession: string;
|
||||
const gitStatusRequests: string[] = [];
|
||||
const settingsPutStatuses: number[] = [];
|
||||
|
||||
const write = (rel: string, text = 'x\n') => writeFileSync(join(repo, rel), text);
|
||||
const commitAll = (msg: string) => {
|
||||
git(repo, 'add', '-A');
|
||||
git(repo, 'commit', '-q', '-m', msg);
|
||||
};
|
||||
const label = () => page.evaluate(() => document.querySelector('#gitStatusBtn .git-status-label')?.textContent ?? '');
|
||||
const buttonVisible = () => page.evaluate(() => !document.getElementById('gitStatusBtn')!.hidden);
|
||||
const refresh = () => page.evaluate(() => (window as any).app.refreshGitStatusNow());
|
||||
const setSetting = async (on: boolean) => {
|
||||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||
if ((await page.isChecked('#appSettingsShowGitStatus')) !== on)
|
||||
await page.click('label.switch:has(#appSettingsShowGitStatus)');
|
||||
await page.evaluate(() => (window as any).app.saveAppSettings());
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => (window as any).app.closeAppSettings());
|
||||
};
|
||||
const createSession = (dir: string) =>
|
||||
page.evaluate(async (workingDir) => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, mode: 'shell' }),
|
||||
});
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id as string;
|
||||
}, dir);
|
||||
const select = (id: string) =>
|
||||
page.evaluate(async (sid) => {
|
||||
const app = (window as any).app;
|
||||
for (let i = 0; i < 100 && !app.sessions.has(sid); i++) await new Promise((r) => setTimeout(r, 100));
|
||||
await app.selectSession(sid);
|
||||
}, id);
|
||||
|
||||
beforeAll(async () => {
|
||||
root = mkdtempSync(join(homedir(), 'git-ui-'));
|
||||
const bare = join(root, 'origin.git');
|
||||
repo = join(root, 'repo');
|
||||
plain = join(root, 'plain');
|
||||
mkdirSync(repo);
|
||||
mkdirSync(plain);
|
||||
git(root, 'init', '-q', '--bare', '-b', 'main', bare);
|
||||
git(repo, 'init', '-q', '-b', 'main');
|
||||
git(repo, 'remote', 'add', 'origin', bare);
|
||||
write('a.txt', '1\n');
|
||||
commitAll('base');
|
||||
git(repo, 'push', '-q', '-u', 'origin', 'main');
|
||||
// Dirty: a modified file, an untracked file, and one commit nobody has pushed.
|
||||
write('b.txt', 'b\n');
|
||||
commitAll('add b (not pushed)');
|
||||
write('a.txt', '2\n');
|
||||
write('new file.txt', 'n\n');
|
||||
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
page.on('request', (r) => {
|
||||
// The API route only: the page also loads /git-status-ui.js, whose URL contains the same words.
|
||||
if (/\/api\/sessions\/[^/]+\/git-status/.test(r.url())) gitStatusRequests.push(r.url());
|
||||
});
|
||||
page.on('response', (r) => {
|
||||
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
|
||||
});
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
repoSession = await createSession(repo);
|
||||
plainSession = await createSession(plain);
|
||||
await select(repoSession);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
it('is off by default: no button and no request to the git-status route', async () => {
|
||||
await page.waitForTimeout(2500); // longer than one tick
|
||||
expect(await buttonVisible()).toBe(false);
|
||||
expect(gitStatusRequests).toEqual([]);
|
||||
});
|
||||
|
||||
it('turning it on through Settings saves cleanly (the key must not reach the strict PUT) and shows the counts', async () => {
|
||||
await setSetting(true);
|
||||
expect(settingsPutStatuses.length).toBeGreaterThan(0);
|
||||
expect(settingsPutStatuses.every((s) => s === 200)).toBe(true);
|
||||
await page.waitForFunction(() => !document.getElementById('gitStatusBtn')!.hidden, null, { timeout: 15000 });
|
||||
expect(await label()).toContain('● 2'); // a.txt modified + new file.txt untracked
|
||||
expect(await label()).toContain('↑ 1'); // one commit not pushed
|
||||
const title = await page.getAttribute('#gitStatusBtn', 'title');
|
||||
expect(title).toMatch(/main/);
|
||||
expect(title).toMatch(/2 uncommitted files/);
|
||||
expect(title).toMatch(/1 commit not pushed/);
|
||||
expect(await page.getAttribute('#gitStatusBtn', 'class')).toMatch(/git-status--dirty/);
|
||||
// The toolbar skin rules out-rank a bare class, so check the colour the user actually sees.
|
||||
const colour = await page.evaluate(() => getComputedStyle(document.getElementById('gitStatusBtn')!).color);
|
||||
expect(colour).toBe('rgb(224, 160, 48)');
|
||||
});
|
||||
|
||||
it('sits at the right of the bottom bar, beside the version', async () => {
|
||||
const inRight = await page.evaluate(() => !!document.querySelector('.toolbar-right #gitStatusBtn'));
|
||||
expect(inRight).toBe(true);
|
||||
const order = await page.evaluate(() => {
|
||||
const right = document.querySelector('.toolbar-right')!;
|
||||
return [...right.children].map((c) => c.id).filter(Boolean);
|
||||
});
|
||||
expect(order.indexOf('gitStatusBtn')).toBeLessThan(order.indexOf('versionDisplay'));
|
||||
});
|
||||
|
||||
it('opens a panel listing the uncommitted files and the unpushed commit', async () => {
|
||||
await page.click('#gitStatusBtn');
|
||||
await page.waitForSelector('#gitStatusPanel.visible');
|
||||
await page.waitForFunction(() => /Not staged/.test(document.getElementById('gitStatusBody')!.textContent ?? ''));
|
||||
const body = (await page.textContent('#gitStatusBody')) ?? '';
|
||||
expect(body).toContain('main → origin/main');
|
||||
expect(body).toContain('Uncommitted changes (2)');
|
||||
expect(body).toMatch(/Not staged \(1\)/);
|
||||
expect(body).toMatch(/Untracked \(1\)/);
|
||||
expect(body).toContain('a.txt');
|
||||
expect(body).toContain('new file.txt');
|
||||
expect(body).toContain('Not pushed (1)');
|
||||
expect(body).toContain('add b (not pushed)');
|
||||
expect(await page.textContent('#gitStatusBranch')).toBe('main');
|
||||
expect(await page.textContent('#gitStatusFooter')).toMatch(/Read-only/);
|
||||
expect(await page.getAttribute('#gitStatusBtn', 'aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('renders hostile file names as text, never as markup', async () => {
|
||||
write('<img src=x onerror=window.__pwned=1>.txt');
|
||||
await refresh();
|
||||
await page.waitForFunction(() => /onerror/.test(document.getElementById('gitStatusBody')!.textContent ?? ''));
|
||||
expect(await page.$('#gitStatusBody img')).toBeNull();
|
||||
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clicking a file previews it by its absolute path; a deleted file is not clickable', async () => {
|
||||
await page.evaluate(() => {
|
||||
(window as any).__previewed = [];
|
||||
(window as any).app.openFilePreview = (p: string) => (window as any).__previewed.push(p);
|
||||
});
|
||||
await page.click('.git-status-file:has-text("a.txt")');
|
||||
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([join(repo, 'a.txt')]);
|
||||
rmSync(join(repo, 'b.txt'));
|
||||
await refresh();
|
||||
await page.waitForFunction(() =>
|
||||
/Deleted|b\.txt/.test(document.getElementById('gitStatusBody')!.textContent ?? '')
|
||||
);
|
||||
const deleted = page.locator('.git-status-file:has(.git-status-badge--D)');
|
||||
expect(await deleted.count()).toBe(1);
|
||||
expect(await deleted.first().getAttribute('role')).toBeNull();
|
||||
});
|
||||
|
||||
it('drags by the header', async () => {
|
||||
const before = await page.evaluate(() => document.getElementById('gitStatusPanel')!.getBoundingClientRect().left);
|
||||
const box = (await page.locator('.git-status-header').boundingBox())!;
|
||||
await page.mouse.move(box.x + 40, box.y + 10);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x - 140, box.y + 70, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
const after = await page.evaluate(() => document.getElementById('gitStatusPanel')!.getBoundingClientRect().left);
|
||||
expect(after).toBeLessThan(before - 100);
|
||||
});
|
||||
|
||||
it('once everything is committed and pushed the button says so, and the panel agrees', async () => {
|
||||
rmSync(join(repo, '<img src=x onerror=window.__pwned=1>.txt'));
|
||||
git(repo, 'checkout', '-q', '--', '.');
|
||||
git(repo, 'clean', '-fdq');
|
||||
git(repo, 'push', '-q');
|
||||
await refresh();
|
||||
// A manual refresh bypasses the server's short cache, so this shows the push at once (not at the
|
||||
// next 15 s poll, which is what a cached answer would mean).
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('#gitStatusBtn .git-status-label')?.textContent ?? '').includes('✓'),
|
||||
null,
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
expect(await page.getAttribute('#gitStatusBtn', 'class')).toMatch(/git-status--clean/);
|
||||
// Toolbar buttons animate colour changes, so wait for the transition rather than racing it.
|
||||
await page.waitForFunction(
|
||||
() => getComputedStyle(document.getElementById('gitStatusBtn')!).color !== 'rgb(224, 160, 48)'
|
||||
);
|
||||
const body = (await page.textContent('#gitStatusBody')) ?? '';
|
||||
expect(body).toContain('Nothing uncommitted.');
|
||||
expect(body).toContain('Every commit on this branch is on a remote.');
|
||||
});
|
||||
|
||||
it('shows nothing for a session whose folder is not a repository, and follows the active session', async () => {
|
||||
await select(plainSession);
|
||||
await page.waitForFunction(() => document.getElementById('gitStatusBtn')!.hidden, null, { timeout: 15000 });
|
||||
// The open panel must stop showing the previous repo at once, then say why there is nothing.
|
||||
await page.waitForFunction(
|
||||
() => /No git repository here/.test(document.getElementById('gitStatusBody')!.textContent ?? ''),
|
||||
null,
|
||||
{
|
||||
timeout: 15000,
|
||||
}
|
||||
);
|
||||
expect(await page.textContent('#gitStatusBody')).not.toContain('a.txt');
|
||||
await select(repoSession);
|
||||
await page.waitForFunction(() => !document.getElementById('gitStatusBtn')!.hidden, null, { timeout: 15000 });
|
||||
});
|
||||
|
||||
it('a folder that holds several repositories shows each one, and the indicator adds them up', async () => {
|
||||
const parent = join(root, 'monorepo-ish');
|
||||
mkdirSync(parent);
|
||||
for (const name of ['api', 'web']) {
|
||||
const r = join(parent, name);
|
||||
mkdirSync(r);
|
||||
git(r, 'init', '-q', '-b', 'main');
|
||||
writeFileSync(join(r, 'f.txt'), '1\n');
|
||||
git(r, 'add', '-A');
|
||||
git(r, 'commit', '-q', '-m', 'init');
|
||||
}
|
||||
writeFileSync(join(parent, 'api', 'dirty.txt'), 'x');
|
||||
writeFileSync(join(parent, 'api', 'dirty2.txt'), 'y');
|
||||
const multiSession = await createSession(parent);
|
||||
await select(multiSession);
|
||||
await page.waitForFunction(() => document.querySelectorAll('#gitStatusBody .git-status-repo').length === 2, null, {
|
||||
timeout: 15000,
|
||||
});
|
||||
expect(await page.textContent('#gitStatusBranch')).toBe('2 repositories');
|
||||
expect(await label()).toContain('● 2'); // only api is dirty, with two files
|
||||
expect(await page.getAttribute('#gitStatusBtn', 'title')).toMatch(/Git \(2 repositories\)/);
|
||||
const names = await page.$$eval('.git-status-repo-name', (els) => els.map((e) => e.textContent));
|
||||
expect(names).toEqual(['api', 'web']);
|
||||
// The one with something outstanding is open; the clean one is collapsed.
|
||||
const open = await page.$$eval('.git-status-repo', (els) => els.map((e) => (e as HTMLDetailsElement).open));
|
||||
expect(open).toEqual([true, false]);
|
||||
const apiBody = (await page.textContent('.git-status-repo:nth-of-type(1)')) ?? '';
|
||||
expect(apiBody).toContain('dirty.txt');
|
||||
expect(apiBody).toContain('dirty2.txt');
|
||||
await select(repoSession);
|
||||
await page.waitForFunction(() => document.querySelectorAll('#gitStatusBody .git-status-repo').length === 0, null, {
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
it('closing the panel resets it; turning the setting off hides the button, closes the panel and stops polling', async () => {
|
||||
await page.click('.git-status-actions button[aria-label="Close git status"]');
|
||||
expect(await page.isVisible('#gitStatusPanel')).toBe(false);
|
||||
await page.click('#gitStatusBtn');
|
||||
expect(await page.isVisible('#gitStatusPanel')).toBe(true);
|
||||
await setSetting(false);
|
||||
expect(await buttonVisible()).toBe(false);
|
||||
expect(await page.isVisible('#gitStatusPanel')).toBe(false);
|
||||
expect(await page.evaluate(() => (window as any).app._gitStatusTimer)).toBeNull();
|
||||
const before = gitStatusRequests.length;
|
||||
await page.waitForTimeout(3000);
|
||||
expect(gitStatusRequests.length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,662 @@
|
||||
// @vitest-environment node
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
clearGitStatusCache,
|
||||
getGitWorkspaceStatus,
|
||||
MAX_FILES,
|
||||
parseCommitLog,
|
||||
parsePorcelainV2,
|
||||
type GitRunner,
|
||||
} from '../src/git-workspace-status.js';
|
||||
|
||||
const NUL = '\0';
|
||||
|
||||
describe('parsePorcelainV2', () => {
|
||||
const header = (extra: string[] = []) =>
|
||||
['# branch.oid abc123', '# branch.head main', '# branch.upstream origin/main', '# branch.ab +2 -1', ...extra].join(
|
||||
NUL
|
||||
) + NUL;
|
||||
|
||||
it('reads the branch, upstream and ahead/behind', () => {
|
||||
const p = parsePorcelainV2(header());
|
||||
expect(p).toMatchObject({
|
||||
branch: 'main',
|
||||
detached: false,
|
||||
upstream: 'origin/main',
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
files: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a detached HEAD and a branch with no upstream (no branch.ab line either)', () => {
|
||||
expect(parsePorcelainV2(['# branch.oid x', '# branch.head (detached)'].join(NUL) + NUL)).toMatchObject({
|
||||
branch: null,
|
||||
detached: true,
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
});
|
||||
expect(parsePorcelainV2(['# branch.oid x', '# branch.head feature'].join(NUL) + NUL)).toMatchObject({
|
||||
branch: 'feature',
|
||||
upstream: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('turns an entry that is staged AND modified in the tree into one row per kind', () => {
|
||||
const line = '1 MM N... 100644 100644 100644 aaa bbb src/a.ts';
|
||||
const files = parsePorcelainV2(header([line])).files;
|
||||
expect(files.map((f) => [f.kind, f.index, f.worktree, f.path])).toEqual([
|
||||
['staged', 'M', 'M', 'src/a.ts'],
|
||||
['unstaged', 'M', 'M', 'src/a.ts'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('classifies staged-only, unstaged-only, added and deleted', () => {
|
||||
const lines = [
|
||||
'1 M. N... 100644 100644 100644 a b staged.ts',
|
||||
'1 .M N... 100644 100644 100644 a b unstaged.ts',
|
||||
'1 A. N... 000000 100644 100644 0 b added.ts',
|
||||
'1 .D N... 100644 100644 000000 a b gone.ts',
|
||||
];
|
||||
const files = parsePorcelainV2(header(lines)).files;
|
||||
expect(files.map((f) => `${f.kind}:${f.index}${f.worktree}:${f.path}`)).toEqual([
|
||||
'staged:M.:staged.ts',
|
||||
'unstaged:.M:unstaged.ts',
|
||||
'staged:A.:added.ts',
|
||||
'unstaged:.D:gone.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads a rename with its original path from the following token', () => {
|
||||
const text = header(['2 R. N... 100644 100644 100644 a b R100 new name.ts' + NUL + 'old name.ts']);
|
||||
expect(parsePorcelainV2(text).files).toEqual([
|
||||
{ path: 'new name.ts', origPath: 'old name.ts', index: 'R', worktree: '.', kind: 'staged' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads unmerged and untracked entries, and keeps odd names intact', () => {
|
||||
const text = header([
|
||||
'u UU N... 100644 100644 100644 100644 a b c conflict.ts',
|
||||
'? with space.txt',
|
||||
'? quote"and\'tick.txt',
|
||||
'? new\nline.txt',
|
||||
'? dir/',
|
||||
]);
|
||||
const files = parsePorcelainV2(text).files;
|
||||
expect(files.map((f) => [f.kind, f.path])).toEqual([
|
||||
['conflicted', 'conflict.ts'],
|
||||
['untracked', 'with space.txt'],
|
||||
['untracked', 'quote"and\'tick.txt'],
|
||||
['untracked', 'new\nline.txt'],
|
||||
['untracked', 'dir/'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips unknown lines and survives empty input', () => {
|
||||
expect(parsePorcelainV2('')).toMatchObject({ files: [], branch: null });
|
||||
expect(parsePorcelainV2('! ignored.log' + NUL + 'weird line' + NUL).files).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCommitLog', () => {
|
||||
it('reads hash, author, time and subject, including unicode and empty input', () => {
|
||||
const text = 'abc1234\x1fAda\x1f1700000000\x1ffix: café\x1e\ndef5678\x1fBob\x1f1700000100\x1fsecond\x1e';
|
||||
expect(parseCommitLog(text)).toEqual([
|
||||
{ hash: 'abc1234', author: 'Ada', time: 1700000000, subject: 'fix: café' },
|
||||
{ hash: 'def5678', author: 'Bob', time: 1700000100, subject: 'second' },
|
||||
]);
|
||||
expect(parseCommitLog('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real git
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GIT_ENV = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'T',
|
||||
GIT_AUTHOR_EMAIL: 't@example.com',
|
||||
GIT_COMMITTER_NAME: 'T',
|
||||
GIT_COMMITTER_EMAIL: 't@example.com',
|
||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
};
|
||||
const git = (cwd: string, ...args: string[]): string =>
|
||||
execFileSync('git', ['-c', 'commit.gpgsign=false', '-c', 'protocol.file.allow=always', ...args], {
|
||||
cwd,
|
||||
env: GIT_ENV,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let root: string;
|
||||
let repo: string;
|
||||
const write = (rel: string, text = 'x\n', dir = repo) => {
|
||||
const f = join(dir, rel);
|
||||
mkdirSync(join(f, '..'), { recursive: true });
|
||||
writeFileSync(f, text);
|
||||
};
|
||||
const commit = (msg: string, dir = repo) => {
|
||||
git(dir, 'add', '-A');
|
||||
git(dir, 'commit', '-q', '-m', msg);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
clearGitStatusCache();
|
||||
root = mkdtempSync(join(tmpdir(), 'git-status-'));
|
||||
repo = join(root, 'repo');
|
||||
mkdirSync(repo);
|
||||
git(repo, 'init', '-q', '-b', 'main');
|
||||
});
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
describe('getGitWorkspaceStatus against a real repository', () => {
|
||||
it('is not-a-repo outside a repository', async () => {
|
||||
const plain = join(root, 'plain');
|
||||
mkdirSync(plain);
|
||||
expect((await getGitWorkspaceStatus(plain)).state).toBe('not-a-repo');
|
||||
});
|
||||
|
||||
it('reports a folder that no longer exists as not-a-repo or an error, never a crash', async () => {
|
||||
const s = await getGitWorkspaceStatus(join(root, 'gone'));
|
||||
expect(['not-a-repo', 'error']).toContain(s.state);
|
||||
});
|
||||
|
||||
it('a fresh repo: on its branch, untracked files, no remote, nothing "unpushed"', async () => {
|
||||
write('a.txt');
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s).toMatchObject({ state: 'ok', branch: 'main', upstream: null, hasRemote: false, unpushedCount: 0 });
|
||||
expect(s.counts).toMatchObject({ untracked: 1, uncommitted: 1, staged: 0, unstaged: 0 });
|
||||
});
|
||||
|
||||
it('a clean repo with commits but no remote has nothing to push (not "every commit")', async () => {
|
||||
write('a.txt');
|
||||
commit('one');
|
||||
write('b.txt');
|
||||
commit('two');
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s.counts.uncommitted).toBe(0);
|
||||
expect(s).toMatchObject({ hasRemote: false, unpushedCount: 0, unpushed: [] });
|
||||
});
|
||||
|
||||
it('separates staged, unstaged and untracked, and counts a staged+modified file once as uncommitted', async () => {
|
||||
write('tracked.txt', '1\n');
|
||||
write('both.txt', '1\n');
|
||||
write('removed.txt', '1\n');
|
||||
commit('base');
|
||||
write('both.txt', '2\n');
|
||||
git(repo, 'add', 'both.txt');
|
||||
write('both.txt', '3\n'); // staged AND modified again
|
||||
write('tracked.txt', '2\n'); // unstaged only
|
||||
git(repo, 'rm', '-q', 'removed.txt'); // staged delete
|
||||
write('new.txt'); // untracked
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s.counts).toMatchObject({ staged: 2, unstaged: 2, untracked: 1, conflicted: 0, uncommitted: 4 });
|
||||
const row = (kind: string, path: string) => s.files.find((f) => f.kind === kind && f.path === path);
|
||||
expect(row('staged', 'both.txt')).toBeTruthy();
|
||||
expect(row('unstaged', 'both.txt')).toBeTruthy();
|
||||
expect(row('staged', 'removed.txt')?.index).toBe('D');
|
||||
expect(row('untracked', 'new.txt')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('reports a staged rename with its original path', async () => {
|
||||
write('old.txt', 'content that is long enough to be detected as a rename\n'.repeat(5));
|
||||
commit('base');
|
||||
git(repo, 'mv', 'old.txt', 'new.txt');
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s.files).toContainEqual(
|
||||
expect.objectContaining({ path: 'new.txt', origPath: 'old.txt', index: 'R', kind: 'staged' })
|
||||
);
|
||||
});
|
||||
|
||||
it('reports merge conflicts', async () => {
|
||||
write('c.txt', 'base\n');
|
||||
commit('base');
|
||||
git(repo, 'checkout', '-q', '-b', 'other');
|
||||
write('c.txt', 'other\n');
|
||||
commit('other');
|
||||
git(repo, 'checkout', '-q', 'main');
|
||||
write('c.txt', 'main\n');
|
||||
commit('main');
|
||||
expect(() => git(repo, 'merge', 'other')).toThrow();
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s.counts.conflicted).toBe(1);
|
||||
expect(s.files.find((f) => f.kind === 'conflicted')?.path).toBe('c.txt');
|
||||
});
|
||||
|
||||
it('handles file names with spaces, quotes and unicode', async () => {
|
||||
write('with space.txt');
|
||||
write('quote"d.txt');
|
||||
write('café ☕.txt');
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s.files.map((f) => f.path).sort()).toEqual(['café ☕.txt', 'quote"d.txt', 'with space.txt']);
|
||||
});
|
||||
|
||||
it('caps the file list but keeps the counts exact', async () => {
|
||||
for (let i = 0; i < MAX_FILES + 20; i++) write(`f${i}.txt`);
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s.files).toHaveLength(MAX_FILES);
|
||||
expect(s.filesTruncated).toBe(true);
|
||||
expect(s.counts.untracked).toBe(MAX_FILES + 20);
|
||||
expect(s.counts.uncommitted).toBe(MAX_FILES + 20);
|
||||
});
|
||||
|
||||
it('counts stashes', async () => {
|
||||
write('a.txt', '1\n');
|
||||
commit('base');
|
||||
write('a.txt', '2\n');
|
||||
git(repo, 'stash', '-q');
|
||||
expect((await getGitWorkspaceStatus(repo)).counts.stashes).toBe(1);
|
||||
});
|
||||
|
||||
it('reports a detached HEAD', async () => {
|
||||
write('a.txt');
|
||||
commit('one');
|
||||
git(repo, 'checkout', '-q', '--detach');
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s).toMatchObject({ detached: true, branch: null });
|
||||
});
|
||||
|
||||
describe('with a remote', () => {
|
||||
let bare: string;
|
||||
beforeEach(() => {
|
||||
bare = join(root, 'origin.git');
|
||||
git(root, 'init', '-q', '--bare', '-b', 'main', bare);
|
||||
git(repo, 'remote', 'add', 'origin', bare);
|
||||
write('a.txt', '1\n');
|
||||
commit('first');
|
||||
git(repo, 'push', '-q', '-u', 'origin', 'main');
|
||||
});
|
||||
|
||||
it('in sync: nothing ahead, nothing unpushed', async () => {
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s).toMatchObject({ upstream: 'origin/main', ahead: 0, behind: 0, hasRemote: true, unpushedCount: 0 });
|
||||
});
|
||||
|
||||
it('lists commits that are ahead of the upstream, newest first, with the subject and author', async () => {
|
||||
write('b.txt');
|
||||
commit('second: add b');
|
||||
write('c.txt');
|
||||
commit('third: add c');
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s).toMatchObject({ ahead: 2, unpushedCount: 2 });
|
||||
expect(s.unpushed.map((c) => c.subject)).toEqual(['third: add c', 'second: add b']);
|
||||
expect(s.unpushed[0]).toMatchObject({ author: 'T' });
|
||||
expect(s.unpushed[0].hash).toMatch(/^[0-9a-f]{7,}$/);
|
||||
expect(s.unpushed[0].time).toBeGreaterThan(1_600_000_000);
|
||||
});
|
||||
|
||||
it('behind reflects the last fetch only: it never fetches on its own', async () => {
|
||||
const other = join(root, 'other');
|
||||
git(root, 'clone', '-q', bare, other);
|
||||
write('theirs.txt', 'x\n', other);
|
||||
commit('theirs', other);
|
||||
git(other, 'push', '-q', 'origin', 'main');
|
||||
expect((await getGitWorkspaceStatus(repo)).behind).toBe(0); // not fetched yet
|
||||
clearGitStatusCache();
|
||||
git(repo, 'fetch', '-q');
|
||||
expect((await getGitWorkspaceStatus(repo)).behind).toBe(1);
|
||||
});
|
||||
|
||||
it('a branch with no upstream lists what no remote has', async () => {
|
||||
git(repo, 'checkout', '-q', '-b', 'feature');
|
||||
write('f1.txt');
|
||||
commit('f1');
|
||||
write('f2.txt');
|
||||
commit('f2');
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s).toMatchObject({ branch: 'feature', upstream: null, hasRemote: true, unpushedCount: 2 });
|
||||
expect(s.unpushed.map((c) => c.subject)).toEqual(['f2', 'f1']);
|
||||
});
|
||||
|
||||
it('a pushed branch is not reported as unpushed once it has an upstream', async () => {
|
||||
git(repo, 'checkout', '-q', '-b', 'feature');
|
||||
write('f1.txt');
|
||||
commit('f1');
|
||||
git(repo, 'push', '-q', '-u', 'origin', 'feature');
|
||||
expect((await getGitWorkspaceStatus(repo)).unpushedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('it only reads', () => {
|
||||
it('does not rewrite the index or leave a lock, even when stat data is stale', async () => {
|
||||
write('a.txt', '1\n');
|
||||
commit('base');
|
||||
const before = readFileSync(join(repo, '.git', 'index'));
|
||||
// Touching a tracked file makes a plain `git status` want to refresh the index.
|
||||
const now = new Date();
|
||||
const { utimesSync } = await import('node:fs');
|
||||
utimesSync(join(repo, 'a.txt'), now, new Date(now.getTime() + 5000));
|
||||
await getGitWorkspaceStatus(repo);
|
||||
expect(readFileSync(join(repo, '.git', 'index')).equals(before)).toBe(true);
|
||||
expect(existsSync(join(repo, '.git', 'index.lock'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not run a repository-configured fsmonitor hook (and a plain git status would have)', async () => {
|
||||
write('a.txt', '1\n');
|
||||
commit('base');
|
||||
const hook = join(root, 'fsmonitor.sh');
|
||||
const marker = join(root, 'fsmonitor-ran');
|
||||
writeFileSync(hook, `#!/bin/sh\necho ran >> '${marker}'\nprintf ''\n`);
|
||||
chmodSync(hook, 0o755);
|
||||
git(repo, 'config', 'core.fsmonitor', hook);
|
||||
// Control: git itself runs it for a plain status, so the assertion below is not vacuous.
|
||||
git(repo, 'status', '--short');
|
||||
expect(existsSync(marker)).toBe(true);
|
||||
rmSync(marker);
|
||||
await getGitWorkspaceStatus(repo);
|
||||
expect(existsSync(marker)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache and error mapping (fake runner)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('caching and failures', () => {
|
||||
const okRunner = (calls: string[][] = []): GitRunner =>
|
||||
vi.fn(async (_cwd, args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === 'status') return ['# branch.oid x', '# branch.head main'].join(NUL) + NUL;
|
||||
return '';
|
||||
});
|
||||
|
||||
it('never runs a git command that writes or touches the network', async () => {
|
||||
const calls: string[][] = [];
|
||||
await getGitWorkspaceStatus('/w/never-network', { git: okRunner(calls) });
|
||||
const verbs = new Set(calls.map((a) => a[0]));
|
||||
for (const forbidden of ['fetch', 'pull', 'push', 'commit', 'add', 'checkout', 'reset', 'clean', 'gc']) {
|
||||
expect(verbs.has(forbidden), forbidden).toBe(false);
|
||||
}
|
||||
expect([...verbs].sort()).toEqual(['log', 'remote', 'rev-list', 'rev-parse', 'stash', 'status']);
|
||||
});
|
||||
|
||||
it('shares one in-flight computation between concurrent callers', async () => {
|
||||
const calls: string[][] = [];
|
||||
const git = okRunner(calls);
|
||||
const [a, b, c] = await Promise.all([1, 2, 3].map(() => getGitWorkspaceStatus('/w/shared', { git })));
|
||||
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
|
||||
expect(a).toBe(b);
|
||||
expect(b).toBe(c);
|
||||
});
|
||||
|
||||
it('reuses a fresh result, recomputes after the TTL, and keeps folders apart', async () => {
|
||||
let t = 1_000_000;
|
||||
const calls: string[][] = [];
|
||||
const git = okRunner(calls);
|
||||
const get = (cwd: string) => getGitWorkspaceStatus(cwd, { git, now: () => t });
|
||||
await get('/w/a');
|
||||
t += 1000;
|
||||
await get('/w/a');
|
||||
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
|
||||
await get('/w/b');
|
||||
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(2);
|
||||
t += 10_000;
|
||||
await get('/w/a');
|
||||
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('`fresh` skips the reuse of a recent result, but still joins a computation already running', async () => {
|
||||
let t = 1_000_000;
|
||||
const calls: string[][] = [];
|
||||
const git = okRunner(calls);
|
||||
const get = (fresh: boolean) => getGitWorkspaceStatus('/w/fresh', { git, now: () => t, fresh });
|
||||
await get(false);
|
||||
t += 500;
|
||||
await get(false); // reused
|
||||
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
|
||||
await get(true); // a person pressed Refresh
|
||||
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(2);
|
||||
clearGitStatusCache();
|
||||
calls.length = 0;
|
||||
await Promise.all([get(true), get(true), get(true)]);
|
||||
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('maps "not a git repository" to not-a-repo', async () => {
|
||||
const git: GitRunner = async () => {
|
||||
throw Object.assign(new Error('x'), {
|
||||
stderr: 'fatal: not a git repository (or any of the parent directories): .git',
|
||||
});
|
||||
};
|
||||
expect((await getGitWorkspaceStatus('/w/none', { git })).state).toBe('not-a-repo');
|
||||
});
|
||||
|
||||
it('reports a missing git binary and a timeout as short errors', async () => {
|
||||
const enoent: GitRunner = async () => {
|
||||
throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' });
|
||||
};
|
||||
expect(await getGitWorkspaceStatus('/w/e1', { git: enoent })).toMatchObject({
|
||||
state: 'error',
|
||||
error: expect.stringMatching(/not installed/),
|
||||
});
|
||||
const slow: GitRunner = async () => {
|
||||
throw Object.assign(new Error('timed out'), { killed: true });
|
||||
};
|
||||
expect(await getGitWorkspaceStatus('/w/e2', { git: slow })).toMatchObject({
|
||||
state: 'error',
|
||||
error: 'git timed out',
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts credentials embedded in a remote URL from an error message', async () => {
|
||||
const git: GitRunner = async () => {
|
||||
throw Object.assign(new Error('x'), {
|
||||
stderr: "fatal: unable to access 'https://user:ghp_SECRET@github.com/o/r.git/'",
|
||||
});
|
||||
};
|
||||
const s = await getGitWorkspaceStatus('/w/redact', { git });
|
||||
expect(s.state).toBe('error');
|
||||
expect(s.error).not.toContain('ghp_SECRET');
|
||||
expect(s.error).toContain('***:***@');
|
||||
});
|
||||
|
||||
it('survives the secondary calls failing: status still comes back', async () => {
|
||||
const git: GitRunner = async (_cwd, args) => {
|
||||
if (args[0] === 'status')
|
||||
return (
|
||||
['# branch.oid x', '# branch.head main', '# branch.upstream o/main', '# branch.ab +3 -0'].join(NUL) + NUL
|
||||
);
|
||||
throw new Error('boom');
|
||||
};
|
||||
const s = await getGitWorkspaceStatus('/w/partial', { git });
|
||||
expect(s).toMatchObject({ state: 'ok', branch: 'main', ahead: 3, unpushedCount: 0, unpushed: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Which repositories: getGitWorkspaceOverview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { mkdirSync as mkdir, symlinkSync as symlink } from 'node:fs';
|
||||
import {
|
||||
discoverChildRepos,
|
||||
getGitWorkspaceOverview,
|
||||
isUnrelatedAncestor,
|
||||
MAX_REPOS,
|
||||
} from '../src/git-workspace-status.js';
|
||||
|
||||
describe('getGitWorkspaceOverview', () => {
|
||||
let top: string;
|
||||
let home: string;
|
||||
const repoAt = (p: string): string => {
|
||||
mkdir(p, { recursive: true });
|
||||
git(p, 'init', '-q', '-b', 'main');
|
||||
writeFileSync(join(p, 'f.txt'), '1\n');
|
||||
git(p, 'add', '-A');
|
||||
git(p, 'commit', '-q', '-m', 'c');
|
||||
return p;
|
||||
};
|
||||
const names = (o: { repos: { path: string }[] }) => o.repos.map((r) => r.path);
|
||||
|
||||
beforeEach(() => {
|
||||
top = mkdtempSync(join(tmpdir(), 'git-overview-'));
|
||||
home = join(top, 'home');
|
||||
mkdir(home, { recursive: true });
|
||||
clearGitStatusCache();
|
||||
});
|
||||
afterEach(() => rmSync(top, { recursive: true, force: true }));
|
||||
|
||||
it('a folder that holds several repositories reports each, alphabetically, with its own status', async () => {
|
||||
const ws = join(home, 'case');
|
||||
repoAt(join(ws, 'web'));
|
||||
repoAt(join(ws, 'api'));
|
||||
writeFileSync(join(ws, 'api', 'dirty.txt'), 'x');
|
||||
const o = await getGitWorkspaceOverview(ws, { home });
|
||||
expect(o.state).toBe('ok');
|
||||
expect(names(o)).toEqual(['api', 'web']);
|
||||
expect(o.repos.map((r) => r.name)).toEqual(['api', 'web']);
|
||||
expect(o.repos[0].status.counts.uncommitted).toBe(1);
|
||||
expect(o.repos[1].status.counts.uncommitted).toBe(0);
|
||||
expect(o.reposTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('finds repositories two levels down but not three', async () => {
|
||||
const ws = join(home, 'case');
|
||||
repoAt(join(ws, 'apps', 'web'));
|
||||
repoAt(join(ws, 'a', 'b', 'too-deep'));
|
||||
const o = await getGitWorkspaceOverview(ws, { home });
|
||||
expect(names(o)).toEqual([join('apps', 'web')]);
|
||||
});
|
||||
|
||||
it('a subfolder of a repository reports the whole enclosing repository, naming where it is', async () => {
|
||||
const r = repoAt(join(home, 'proj'));
|
||||
mkdir(join(r, 'src', 'deep'), { recursive: true });
|
||||
writeFileSync(join(r, 'top.txt'), 'x');
|
||||
const o = await getGitWorkspaceOverview(join(r, 'src', 'deep'), { home });
|
||||
expect(o.repos).toHaveLength(1);
|
||||
expect(o.repos[0]).toMatchObject({ name: 'proj', path: join('..', '..') });
|
||||
expect(o.repos[0].status.files.map((f) => f.path)).toContain('top.txt');
|
||||
});
|
||||
|
||||
it('inside a repository it does not go looking for nested ones (they are just an untracked folder to the outer repo)', async () => {
|
||||
const r = repoAt(join(home, 'outer'));
|
||||
repoAt(join(r, 'vendor-ish', 'inner'));
|
||||
const o = await getGitWorkspaceOverview(r, { home });
|
||||
expect(names(o)).toEqual(['.']);
|
||||
expect(o.repos[0].status.files.map((f) => f.path)).toEqual(['vendor-ish/']);
|
||||
});
|
||||
|
||||
it('a session started inside the nested repository reports that one', async () => {
|
||||
const r = repoAt(join(home, 'outer'));
|
||||
const inner = repoAt(join(r, 'vendor-ish', 'inner'));
|
||||
const o = await getGitWorkspaceOverview(inner, { home });
|
||||
expect(o.repos[0].name).toBe('inner');
|
||||
});
|
||||
|
||||
it('ignores a repository that merely sits above the workspace and is the home folder (a dotfiles repo)', async () => {
|
||||
repoAt(home);
|
||||
writeFileSync(join(home, 'zshrc'), 'x'); // dirty, and nothing to do with this session
|
||||
const ws = join(home, 'codeman-cases', 'my-case');
|
||||
mkdir(ws, { recursive: true });
|
||||
const none = await getGitWorkspaceOverview(ws, { home });
|
||||
expect(none.state).toBe('not-a-repo');
|
||||
// ...but a project below the workspace is still found.
|
||||
repoAt(join(ws, 'real-project'));
|
||||
clearGitStatusCache();
|
||||
const some = await getGitWorkspaceOverview(ws, { home, fresh: true });
|
||||
expect(names(some)).toEqual(['real-project']);
|
||||
});
|
||||
|
||||
it('ignores a repository above the home folder (including a repo at the very top)', async () => {
|
||||
repoAt(top); // contains `home`, so it is above it
|
||||
const ws = join(home, 'case');
|
||||
mkdir(ws, { recursive: true });
|
||||
expect((await getGitWorkspaceOverview(ws, { home })).state).toBe('not-a-repo');
|
||||
});
|
||||
|
||||
it('but a workspace that IS the repository root is the session’s repository, even when that is the home folder', async () => {
|
||||
repoAt(home);
|
||||
const o = await getGitWorkspaceOverview(home, { home });
|
||||
expect(o.state).toBe('ok');
|
||||
expect(o.repos[0].path).toBe('.');
|
||||
});
|
||||
|
||||
it('accepts an enclosing repository that is below the home folder, and one outside the home folder entirely', async () => {
|
||||
const r = repoAt(join(home, 'proj'));
|
||||
mkdir(join(r, 'sub'), { recursive: true });
|
||||
expect((await getGitWorkspaceOverview(join(r, 'sub'), { home })).state).toBe('ok');
|
||||
const elsewhere = repoAt(join(top, 'elsewhere', 'proj'));
|
||||
clearGitStatusCache();
|
||||
expect((await getGitWorkspaceOverview(elsewhere, { home })).state).toBe('ok');
|
||||
});
|
||||
|
||||
it('skips node_modules, dot-folders and symbolic links when looking for repositories', async () => {
|
||||
const ws = join(home, 'case');
|
||||
repoAt(join(ws, 'node_modules', 'pkg'));
|
||||
repoAt(join(ws, '.hidden', 'secret'));
|
||||
const outside = repoAt(join(top, 'outside'));
|
||||
mkdir(ws, { recursive: true });
|
||||
symlink(outside, join(ws, 'linked'));
|
||||
repoAt(join(ws, 'real'));
|
||||
const o = await getGitWorkspaceOverview(ws, { home });
|
||||
expect(names(o)).toEqual(['real']);
|
||||
});
|
||||
|
||||
it('counts a worktree (its .git is a file) as a repository', async () => {
|
||||
const main = repoAt(join(top, 'main-repo'));
|
||||
const ws = join(home, 'case');
|
||||
mkdir(ws, { recursive: true });
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'feature', join(ws, 'wt'));
|
||||
const o = await getGitWorkspaceOverview(ws, { home });
|
||||
expect(names(o)).toEqual(['wt']);
|
||||
expect(o.repos[0].status.branch).toBe('feature');
|
||||
});
|
||||
|
||||
it('caps how many repositories it reports and says so', async () => {
|
||||
const ws = join(home, 'case');
|
||||
for (let i = 0; i < MAX_REPOS + 3; i++) repoAt(join(ws, `p${String(i).padStart(2, '0')}`));
|
||||
const o = await getGitWorkspaceOverview(ws, { home });
|
||||
expect(o.repos).toHaveLength(MAX_REPOS);
|
||||
expect(o.reposTruncated).toBe(true);
|
||||
expect(names(o)[0]).toBe('p00');
|
||||
});
|
||||
|
||||
it('is not-a-repo when there is no repository here or below, and reports a git failure as an error', async () => {
|
||||
const ws = join(home, 'empty');
|
||||
mkdir(join(ws, 'a'), { recursive: true });
|
||||
expect((await getGitWorkspaceOverview(ws, { home })).state).toBe('not-a-repo');
|
||||
clearGitStatusCache(); // the answer above is cached for this folder
|
||||
const broken: GitRunner = async () => {
|
||||
throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' });
|
||||
};
|
||||
expect(await getGitWorkspaceOverview(ws, { home, git: broken })).toMatchObject({ state: 'error', repos: [] });
|
||||
});
|
||||
|
||||
it('re-scans for repositories only every so often, unless a person asked for fresh', async () => {
|
||||
let t = 1_000_000;
|
||||
const ws = join(home, 'case');
|
||||
mkdir(ws, { recursive: true });
|
||||
repoAt(join(ws, 'one'));
|
||||
const get = (fresh = false) => getGitWorkspaceOverview(ws, { home, now: () => t, fresh });
|
||||
expect(names(await get())).toEqual(['one']);
|
||||
repoAt(join(ws, 'two'));
|
||||
t += 5000;
|
||||
expect(names(await get())).toEqual(['one']); // list reused
|
||||
expect(names(await get(true))).toEqual(['one', 'two']); // Refresh sees it
|
||||
t += 60_000;
|
||||
expect(names(await get())).toEqual(['one', 'two']); // and so does the next scan
|
||||
});
|
||||
|
||||
it('discoverChildRepos never reads below a repository it found', async () => {
|
||||
const ws = join(home, 'case');
|
||||
repoAt(join(ws, 'outer'));
|
||||
repoAt(join(ws, 'outer', 'inner'));
|
||||
expect((await discoverChildRepos(ws)).dirs.map((d) => d.slice(ws.length + 1))).toEqual(['outer']);
|
||||
});
|
||||
|
||||
it('isUnrelatedAncestor: home or above, but never the workspace root itself', async () => {
|
||||
mkdir(join(home, 'proj'), { recursive: true });
|
||||
expect(await isUnrelatedAncestor(home, join(home, 'proj'), home)).toBe(true);
|
||||
expect(await isUnrelatedAncestor(top, join(home, 'proj'), home)).toBe(true);
|
||||
expect(await isUnrelatedAncestor('/', join(home, 'proj'), home)).toBe(true);
|
||||
expect(await isUnrelatedAncestor(join(home, 'proj'), join(home, 'proj', 'x'), home)).toBe(false);
|
||||
expect(await isUnrelatedAncestor(home, home, home)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @fileoverview GET /api/sessions/:id/git-status: the git snapshot behind the bottom-bar Git
|
||||
* indicator. Real git for the happy path; an injected runner for the cases that must not run git at
|
||||
* all (remote and Docker sessions). Port: N/A (app.inject()).
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js';
|
||||
import { clearGitStatusCache, type GitRunner } from '../../src/git-workspace-status.js';
|
||||
|
||||
const ENV = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'T',
|
||||
GIT_AUTHOR_EMAIL: 't@example.com',
|
||||
GIT_COMMITTER_NAME: 'T',
|
||||
GIT_COMMITTER_EMAIL: 't@example.com',
|
||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
};
|
||||
const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, env: ENV, stdio: 'ignore' });
|
||||
|
||||
let dir: string;
|
||||
let session: Record<string, unknown>;
|
||||
|
||||
async function setup(opts: { git?: GitRunner; authUser?: { username: string; role: 'admin' | 'user' } } = {}) {
|
||||
const h = await createRouteTestHarness((app, ctx) => registerGitStatusRoutes(app, ctx, opts.git), {
|
||||
authUser: opts.authUser,
|
||||
});
|
||||
session = h.ctx._session as unknown as Record<string, unknown>;
|
||||
session.workingDir = dir;
|
||||
return h;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearGitStatusCache();
|
||||
dir = mkdtempSync(join(tmpdir(), 'git-status-route-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/git-status', () => {
|
||||
it('returns the snapshot of the session workspace in the success envelope', async () => {
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
writeFileSync(join(dir, 'a.txt'), '1\n');
|
||||
git(dir, 'add', '-A');
|
||||
git(dir, 'commit', '-q', '-m', 'base');
|
||||
writeFileSync(join(dir, 'a.txt'), '2\n');
|
||||
writeFileSync(join(dir, 'new.txt'), 'n\n');
|
||||
const { app } = await setup();
|
||||
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.state).toBe('ok');
|
||||
expect(body.data.repos).toHaveLength(1);
|
||||
const repo = body.data.repos[0];
|
||||
expect(repo).toMatchObject({ path: '.', status: { state: 'ok', branch: 'main' } });
|
||||
expect(repo.status.counts).toMatchObject({ unstaged: 1, untracked: 1, uncommitted: 2 });
|
||||
expect(repo.status.files.map((f: { path: string }) => f.path).sort()).toEqual(['a.txt', 'new.txt']);
|
||||
});
|
||||
|
||||
it('reports each repository found below a folder that holds several projects', async () => {
|
||||
for (const name of ['api', 'web']) {
|
||||
mkdirSync(join(dir, name));
|
||||
git(join(dir, name), 'init', '-q', '-b', 'main');
|
||||
}
|
||||
writeFileSync(join(dir, 'api', 'dirty.txt'), 'x');
|
||||
const { app } = await setup();
|
||||
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||
const data = res.json().data;
|
||||
expect(data.state).toBe('ok');
|
||||
expect(data.repos.map((r: { name: string; path: string }) => [r.name, r.path])).toEqual([
|
||||
['api', 'api'],
|
||||
['web', 'web'],
|
||||
]);
|
||||
expect(data.repos[0].status.counts.uncommitted).toBe(1);
|
||||
expect(data.repos[1].status.counts.uncommitted).toBe(0);
|
||||
});
|
||||
|
||||
it('answers not-a-repo for a folder that is not a repository', async () => {
|
||||
const { app } = await setup();
|
||||
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||
expect(res.json().data.state).toBe('not-a-repo');
|
||||
});
|
||||
|
||||
it('404s an unknown session', async () => {
|
||||
const { app } = await setup();
|
||||
const res = await app.inject({ method: 'GET', url: '/api/sessions/nope/git-status' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('reuses a recent result for a poll, and recomputes for ?fresh=1', async () => {
|
||||
const runner = vi.fn<GitRunner>(async () => '');
|
||||
const { app } = await setup({ git: runner });
|
||||
const statusCalls = () => runner.mock.calls.filter(([, args]) => args[0] === 'status').length;
|
||||
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||
expect(statusCalls()).toBe(1);
|
||||
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status?fresh=1' });
|
||||
expect(statusCalls()).toBe(2);
|
||||
});
|
||||
|
||||
it('runs git in the session working directory', async () => {
|
||||
const runner = vi.fn<GitRunner>(async () => '');
|
||||
const { app } = await setup({ git: runner });
|
||||
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||
expect(runner).toHaveBeenCalled();
|
||||
for (const [cwd] of runner.mock.calls) expect(cwd).toBe(dir);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['remote', { host: 'h', user: 'u' }],
|
||||
['docker', { container: 'c' }],
|
||||
])('does not run git for a %s session and says it is unsupported', async (kind, value) => {
|
||||
const runner = vi.fn<GitRunner>(async () => '');
|
||||
const { app } = await setup({ git: runner });
|
||||
session[kind] = value;
|
||||
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toMatchObject({ state: 'unsupported', reason: kind });
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('multi-user: another user’s session is not found, and git is not run for it', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const runner = vi.fn<GitRunner>(async () => '');
|
||||
const { app } = await setup({ git: runner, authUser: { username: 'bob', role: 'user' } });
|
||||
session.owner = 'alice';
|
||||
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('multi-user: the owner and an admin can read it', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const runner = vi.fn<GitRunner>(async () => '');
|
||||
for (const authUser of [
|
||||
{ username: 'alice', role: 'user' as const },
|
||||
{ username: 'root', role: 'admin' as const },
|
||||
]) {
|
||||
clearGitStatusCache();
|
||||
const { app } = await setup({ git: runner, authUser });
|
||||
session.owner = 'alice';
|
||||
expect((await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' })).statusCode).toBe(
|
||||
200
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user