mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
fix(git-status): landing fixes (#537)
- A cached list of repositories below a folder is re-checked against the Docker case workspaces as they are now, so a repository linked as a Docker workspace within the 30 s list cache is no longer inspected. - A diff past runGit's 8 MB output bound is cut short from git's partial output instead of failing with a 500. - The browser test waits for its slow route handler on unroute (unrouteAll behavior 'wait'), so a late route.continue() cannot fail the run. - "Upstream is gone" now reads "Upstream not on remote", true for a branch that was never pushed as well as one deleted on the remote; docs mirrored. - The diff route checks the repository against the workspace's own cached repository list (findWorkspaceRepo) and refreshes only that repository, instead of a fresh status of every repository in the folder. - CLAUDE.md: a Key Patterns entry for the git read surface and its rules. - The enclosing repository is identified with one cached rev-parse before any full status, so an unrelated repository above the workspace costs one process and its failure no longer hides the repositories below. - Wiki: the bottom-bar indicator moves out of the header-controls table. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -389,7 +389,9 @@ describe('Git status indicator in a real browser', () => {
|
||||
await page.waitForFunction(() => !!(window as any).app._currentGitStatus());
|
||||
expect(await page.evaluate(() => (window as any).app._gitStatusInFlight)).toBe(false);
|
||||
page.setDefaultTimeout(30000);
|
||||
await page.unroute('**/api/sessions/*/git-status*');
|
||||
// The first read may still be asleep in the handler: wait for it to continue, or its late
|
||||
// route.continue() lands after the route is gone and fails the run as an unhandled rejection.
|
||||
await page.unrouteAll({ behavior: 'wait' });
|
||||
expect(await buttonVisible()).toBe(true);
|
||||
// Turning the setting off closed the panel; reopen it for the tests that follow.
|
||||
await page.click('#gitStatusBtn');
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
MAX_FILES,
|
||||
parseCommitLog,
|
||||
parsePorcelainV2,
|
||||
runGit,
|
||||
type GitRunner,
|
||||
} from '../src/git-workspace-status.js';
|
||||
|
||||
@@ -591,6 +592,23 @@ describe('getGitWorkspaceOverview', () => {
|
||||
expect(names(some)).toEqual(['real-project']);
|
||||
});
|
||||
|
||||
it('identifies an unrelated repository above the workspace before its status runs, so a failure there cannot hide the repositories below', async () => {
|
||||
repoAt(home);
|
||||
const ws = join(home, 'case');
|
||||
repoAt(join(ws, 'proj'));
|
||||
const calls: Array<[string, string]> = [];
|
||||
const spy: GitRunner = async (cwd, args) => {
|
||||
calls.push([cwd, args[0]]);
|
||||
if (cwd === ws && args[0] === 'status') throw Object.assign(new Error('timed out'), { killed: true });
|
||||
return runGit(cwd, args);
|
||||
};
|
||||
const o = await getGitWorkspaceOverview(ws, { home, git: spy });
|
||||
expect(o.state).toBe('ok');
|
||||
expect(names(o)).toEqual(['proj']);
|
||||
// The dotfiles repository costs one rev-parse, never a full status.
|
||||
expect(calls.filter(([cwd]) => cwd === ws).map(([, verb]) => verb)).toEqual(['rev-parse']);
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -752,6 +770,30 @@ describe('getGitFileDiff', () => {
|
||||
expect(cut.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
|
||||
expect(cut.diff.endsWith('x')).toBe(true);
|
||||
});
|
||||
|
||||
it('cuts a diff that overflowed the output bound instead of failing it', async () => {
|
||||
const partial = ('+' + 'y'.repeat(99) + '\n').repeat(Math.ceil(MAX_DIFF_BYTES / 100) * 2);
|
||||
const git = vi.fn(async () => {
|
||||
throw Object.assign(new RangeError('stdout maxBuffer length exceeded'), {
|
||||
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
|
||||
stdout: partial,
|
||||
});
|
||||
});
|
||||
for (const kind of ['unstaged', 'untracked'] as const) {
|
||||
const d = await getGitFileDiff('/r', { path: 'huge.txt', kind }, { git });
|
||||
expect(d.truncated).toBe(true);
|
||||
expect(d.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
|
||||
expect(d.diff.endsWith('y')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('a real untracked file past the output bound comes back cut short, not as an error', async () => {
|
||||
writeFileSync(join(repo, 'huge.txt'), ('z'.repeat(99) + '\n').repeat(100_000)); // ~10 MB, over runGit's 8 MB
|
||||
const d = await getGitFileDiff(repo, { path: 'huge.txt', kind: 'untracked' });
|
||||
expect(d.truncated).toBe(true);
|
||||
expect(d.diff.startsWith('diff --git')).toBe(true);
|
||||
expect(d.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('Docker case workspaces are never inspected', () => {
|
||||
@@ -812,6 +854,30 @@ describe('Docker case workspaces are never inspected', () => {
|
||||
expect(existsSync(join(dock, 'RAN'))).toBe(false);
|
||||
});
|
||||
|
||||
it('re-checks a cached list of repositories against the Docker workspaces as they are now', async () => {
|
||||
let t = 1_000_000;
|
||||
const ws = join(home, 'case');
|
||||
repoAt(join(ws, 'a'));
|
||||
repoAt(join(ws, 'b'));
|
||||
const cwds: string[] = [];
|
||||
const spy: GitRunner = (cwd, args) => {
|
||||
cwds.push(cwd);
|
||||
return runGit(cwd, args);
|
||||
};
|
||||
const first = await getGitWorkspaceOverview(ws, { home, git: spy, now: () => t });
|
||||
expect(first.repos.map((r) => r.path)).toEqual(['a', 'b']);
|
||||
t += 5000; // past the status cache, well inside the list's 30 s
|
||||
cwds.length = 0;
|
||||
const o = await getGitWorkspaceOverview(ws, {
|
||||
home,
|
||||
git: spy,
|
||||
now: () => t,
|
||||
dockerWorkspaces: [join(ws, 'a')],
|
||||
});
|
||||
expect(o.repos.map((r) => r.path)).toEqual(['b']);
|
||||
expect(cwds).not.toContain(join(ws, 'a'));
|
||||
});
|
||||
|
||||
it('sees through a symlink to the workspace', async () => {
|
||||
const dock = booby(join(home, 'dock'));
|
||||
const ws = join(home, 'case');
|
||||
|
||||
@@ -10,7 +10,7 @@ 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';
|
||||
import { clearGitStatusCache, runGit, type GitRunner } from '../../src/git-workspace-status.js';
|
||||
|
||||
const ENV = {
|
||||
...process.env,
|
||||
@@ -101,14 +101,17 @@ describe('GET /api/sessions/:id/git-status', () => {
|
||||
});
|
||||
|
||||
it('reuses a recent result for a poll, and recomputes for ?fresh=1', async () => {
|
||||
const runner = vi.fn<GitRunner>(async () => '');
|
||||
// The workspace is the root of its repository, as real git would say.
|
||||
const runner = vi.fn<GitRunner>(async (cwd, args) => (args[0] === 'rev-parse' ? `${cwd}\n` : ''));
|
||||
const { app } = await setup({ git: runner });
|
||||
const statusCalls = () => runner.mock.calls.filter(([, args]) => args[0] === 'status').length;
|
||||
const calls = (verb: string) => runner.mock.calls.filter(([, args]) => args[0] === verb).length;
|
||||
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||
const revParses = calls('rev-parse');
|
||||
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||
expect(statusCalls()).toBe(1);
|
||||
expect(calls('status')).toBe(1);
|
||||
expect(calls('rev-parse')).toBe(revParses); // the enclosing repository is reused too
|
||||
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status?fresh=1' });
|
||||
expect(statusCalls()).toBe(2);
|
||||
expect(calls('status')).toBe(2);
|
||||
});
|
||||
|
||||
it('runs git in the session working directory', async () => {
|
||||
@@ -270,3 +273,28 @@ describe('GET /api/sessions/:id/git-diff', () => {
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/git-diff in a folder of several repositories', () => {
|
||||
it('checks the repository against the listed ones and re-reads only that one', async () => {
|
||||
for (const name of ['api', 'web']) {
|
||||
const r = join(dir, name);
|
||||
mkdirSync(r);
|
||||
git(r, 'init', '-q', '-b', 'main');
|
||||
writeFileSync(join(r, 'f.txt'), `${name}\n`);
|
||||
}
|
||||
const calls: Array<[string, string]> = [];
|
||||
const runner: GitRunner = async (cwd, args) => {
|
||||
calls.push([cwd, args[0]]);
|
||||
return runGit(cwd, args);
|
||||
};
|
||||
const { app } = await setup({ git: runner });
|
||||
const overview = (await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' })).json().data;
|
||||
const api = overview.repos.find((r: { name: string }) => r.name === 'api').status.repoRoot;
|
||||
calls.length = 0;
|
||||
const q = new URLSearchParams({ repo: api, path: 'f.txt', kind: 'untracked' });
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/test-session-1/git-diff?${q}` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.diff).toContain('+api');
|
||||
expect(calls.filter(([, verb]) => verb === 'status').map(([cwd]) => cwd)).toEqual([api]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user