Files
Codeman/test/git-fetch.test.ts
T
Codeman maintainer c04fa97f78 chore: replace the em-dashes this release added with plain punctuation
The merged PRs (mostly #714's git fetch) added about 120 em-dashes in
comments, docs, test names and three settings strings. Each one is now a
comma, colon, semicolon or parentheses. The English settings strings in
index.html and their i18n.js keys change together, and the zh-CN value
for the fetch interval uses full-width parentheses instead of a double
dash. The one left is the regex in constants.js that matches a dash
typed as a word, which is behaviour, not punctuation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:11:19 +01:00

771 lines
35 KiB
TypeScript

/**
* @fileoverview Tests for the Git panel's Fetch button (src/git-fetch.ts): the only git command the Git panel
* runs that writes, and the only one that can run without being asked, which is why it is a click, or an
* interval the user turned on, and why the writing and the hooks it can run are pinned here. Cloning and
* worktree management write too, but they write when the user asked.
*
* Four halves, mirroring the module:
*
* 1. The PURE half: the fixed argv, the `-c` overrides the runner adds ahead of it, the ssh environment the
* runner refuses to override (`fetchEnvFor`), the failure classification, and the two reads the interval
* scheduler depends on: what the settings say (`gitFetchSettingsFrom`) and what a run did
* (`gitFetchStatusFrom`). No spawning.
* 2. The IO half: a REAL git fetching a REAL local bare repo, so that "remote-tracking refs only" is
* proven against git's actual behavior: HEAD, the index and the working tree must be exactly what they
* were while the remote-tracking ref moves. The rest of the half uses an injected runner over real
* repositories on disk, because what is being pinned is WHICH repositories git is allowed to touch.
* 3. The process group: a timed-out fetch has to take its `git-remote-http` helper with it, against a
* server that accepts and never answers, so the assertion is about what is still RUNNING after the answer
* went out. A test that only checked the returned error would pass with the old `execFile` runner, which
* signals the parent only.
* 4. The join (`fetchWorkspaceOnce`): one run per folder at a time, because two fetches in one clone
* collide on git's ref lock, the bound is ONE limiter shared with the scheduler's tick rather than a bound
* each run gets for itself, and the trigger a join reports is the joined run's, not the joiner's.
*
* Port: N/A (no Codeman server). The process-group test listens on its own loopback socket, on a port the OS
* picks, because the point of that test is a remote that never answers.
*/
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { createServer, type AddressInfo } from 'node:http';
import type { Socket } from 'node:net';
import { tmpdir } from 'node:os';
import { basename, join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { classifyGitFailure, isGitAvailable } from '../src/git-clone.js';
import {
FETCH_CONCURRENCY,
FETCH_CONFIG_ARGS,
FETCH_KILL_ESCALATION_MS,
buildFetchArgs,
clearGitFetchLane,
describeFetchFailure,
fetchEnvFor,
fetchGitWorkspaceOverview,
fetchWorkspaceOnce,
gitFetchSettingsFrom,
gitFetchStatusFrom,
runFetch,
type GitFetchReport,
} from '../src/git-fetch.js';
import { createLimiter, runGit, type GitRunner } from '../src/git-workspace-status.js';
const ENV = {
...process.env,
// As the production runner: git's messages are matched in English, and nothing reads the host's config.
LC_ALL: 'C',
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, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
// ─── The pure half ───────────────────────────────────────────────────────────
describe('buildFetchArgs', () => {
it('pins the argv to a fetch and nothing else', () => {
expect(buildFetchArgs()).toEqual(['fetch', '--quiet']);
// Not just the first word: the whole argv is fixed, so no verb that changes the repository can slip in.
for (const verb of ['commit', 'pull', 'merge', 'checkout', 'reset', 'clean', 'prune', 'gc', 'amend']) {
expect(buildFetchArgs().join(' ')).not.toContain(verb);
}
});
it('names no repository, so the repository can only be the process cwd', () => {
// An operand-shaped argument would let a path or a URL choose the repository; the fixed argv cannot.
for (const arg of buildFetchArgs()) {
expect(arg.startsWith('-')).toBe(arg === '--quiet');
}
});
});
describe('describeFetchFailure', () => {
it("calls a repository with no remote a fact, not a fault, in each of git's wordings", () => {
for (const stderr of [
'fatal: no default remote repository -- use --remote or specify a repository',
'No remote configured',
'fatal: nothing to fetch from remote repository',
]) {
const failure = describeFetchFailure({ stderr });
expect(failure.code).toBe('NO_REMOTE');
expect(failure.message).toMatch(/no remote/i);
expect(failure.message).not.toMatch(/fail|error|unable/i);
}
});
it('writes its own timeout message, because the one to reuse was written for clones', () => {
const failure = describeFetchFailure({ code: 'ETIMEDOUT', stderr: 'fatal: repository not found' });
expect(failure.code).toBe('TIMEOUT');
expect(failure.message).toMatch(/Git status: git timeout/);
expect(failure.message).toMatch(/fetch from a shell/i);
expect(failure.message).not.toBe(classifyGitFailure('fatal: repository not found', true).message);
expect(describeFetchFailure({ killed: true, stderr: '' }).code).toBe('TIMEOUT');
});
it('reuses the network reasons it already knows', () => {
const prompt = "fatal: could not read Username for 'https://github.com': terminal prompts disabled";
expect(describeFetchFailure({ stderr: prompt }).code).toBe('AUTH_REQUIRED');
expect(describeFetchFailure({ stderr: 'git@github.com: Permission denied (publickey).' }).code).toBe(
'AUTH_REQUIRED'
);
expect(describeFetchFailure({ message: 'Error: spawn git ENOENT' }).code).toBe('GIT_MISSING');
expect(describeFetchFailure({ stderr: 'fatal: unable to access: Could not resolve host: nope.invalid' }).code).toBe(
'HOST_UNREACHABLE'
);
});
it('hands the client a redacted, control-stripped tail, never what git printed', () => {
const failure = describeFetchFailure({
stderr: "fatal: unable to access 'https://bob:ghp_secret@github.com/x.git/'\u001b[31m",
});
expect(failure.stderr).toBe("fatal: unable to access 'https://***:***@github.com/x.git/'[31m");
});
});
// ─── The two reads the interval feature depends on ───────────────────────────
describe('gitFetchSettingsFrom', () => {
it('is off until the user turns it on, and 300 s when nothing was chosen', () => {
// Absent means off: a quiet install must not start running git on a timer nobody asked for.
for (const settings of [undefined, {}, { gitFetchEnabled: false }, { gitFetchEnabled: 'true' }]) {
expect(gitFetchSettingsFrom(settings).enabled).toBe(false);
}
expect(gitFetchSettingsFrom({ gitFetchEnabled: true }).enabled).toBe(true);
expect(gitFetchSettingsFrom({}).intervalSeconds).toBe(300);
});
it('clamps a number outside the schema instead of trusting it, because a persisted value can predate the bounds', () => {
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 1 }).intervalSeconds).toBe(30);
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 99999 }).intervalSeconds).toBe(3600);
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 120.6 }).intervalSeconds).toBe(121);
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: -300 }).intervalSeconds).toBe(30);
// A hand-edited 0 is still a number it can act on, and acting on it means the smallest interval that runs.
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 0 }).intervalSeconds).toBe(30);
});
it('falls back to the default on a value it cannot act on, which is what a hand-edited settings.json can hold', () => {
for (const raw of ['300', undefined, null, NaN, Infinity, true]) {
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: raw }).intervalSeconds, String(raw)).toBe(300);
}
});
});
describe('gitFetchStatusFrom', () => {
it('counts what a run covered and what it could not do, and names the first failure', () => {
const report: GitFetchReport = {
state: 'ok',
repos: [
{ name: 'Codeman', path: '.', ok: true },
{ name: 'api', path: 'api', ok: false, failure: { code: 'NO_REMOTE', message: 'no remote', stderr: '' } },
],
reposTruncated: false,
fetchedAt: 1700,
};
expect(gitFetchStatusFrom(report, 'interval')).toEqual({
ranAt: 1700,
trigger: 'interval',
state: 'ok',
repos: 2,
failed: 1,
firstFailure: { name: 'api', code: 'NO_REMOTE' },
});
});
it('keeps the trigger, because a number the user did not ask for should read differently from one they did', () => {
const empty: GitFetchReport = { state: 'ok', repos: [], reposTruncated: false, fetchedAt: 1000 };
expect(gitFetchStatusFrom(empty, 'click').trigger).toBe('click');
expect(gitFetchStatusFrom(empty, 'interval').trigger).toBe('interval');
});
it('records a state that stopped the run, with nothing to count and no failure to name', () => {
const status = gitFetchStatusFrom(
{ state: 'unsupported', reason: 'docker', repos: [], reposTruncated: false, fetchedAt: 5 },
'interval'
);
expect(status.state).toBe('unsupported');
expect(status.repos).toBe(0);
expect(status.failed).toBe(0);
expect(status.firstFailure).toBeUndefined();
});
});
describe('FETCH_CONFIG_ARGS', () => {
it('turns off the hooks and the maintenance a fetch can otherwise start, pinned without spawning git', () => {
expect(FETCH_CONFIG_ARGS).toEqual([
'-c',
'core.fsmonitor=false',
'-c',
'maintenance.auto=false',
'-c',
'gc.auto=0',
]);
// Read as pairs: a `-c` and the setting it overrides, and every override is one of these three.
for (let i = 0; i < FETCH_CONFIG_ARGS.length; i += 2) {
expect(FETCH_CONFIG_ARGS[i]).toBe('-c');
expect(FETCH_CONFIG_ARGS[i + 1]).toMatch(/^(core\.fsmonitor|maintenance\.auto|gc\.auto)=/);
}
expect(FETCH_CONFIG_ARGS.filter((a) => a !== '-c').sort()).toEqual([
'core.fsmonitor=false',
'gc.auto=0',
'maintenance.auto=false',
]);
});
it('is kept apart from the pinned argv so both can be read without spawning, and the runner composes them in that order', () => {
// The argv the recorded calls pin stays the verb alone: the overrides come from the runner, not from here.
for (const arg of buildFetchArgs()) expect(arg).not.toBe('-c');
// Overrides before the verb, which is where git will take them.
expect([...FETCH_CONFIG_ARGS, ...buildFetchArgs()]).toEqual([...FETCH_CONFIG_ARGS, 'fetch', '--quiet']);
});
});
describe('fetchEnvFor', () => {
const BASE = { HOME: '/home/t', PATH: '/usr/bin' };
it('writes the BatchMode default when nothing else already answered', () => {
const env = fetchEnvFor(BASE, null);
expect(env.GIT_SSH_COMMAND).toContain('BatchMode=yes');
expect(env.GIT_TERMINAL_PROMPT).toBe('0');
});
it('does not override a repository that already chose its own ssh, because that choice is usually the one that works', () => {
const env = fetchEnvFor(BASE, 'ssh -i /home/t/work_key -o ProxyJump=gw');
// The whole point: nothing is written, so git takes the repository's value from its own config.
expect(env.GIT_SSH_COMMAND).toBeUndefined();
// Honouring the repository's ssh is not the same as letting it prompt, so the rest still applies.
expect(env.GIT_TERMINAL_PROMPT).toBe('0');
expect(env.SSH_ASKPASS_REQUIRE).toBe('never');
});
it("the user's own GIT_SSH_COMMAND still beats the repository's, because that is git's rule and not this module's to second-guess", () => {
const env = fetchEnvFor({ ...BASE, GIT_SSH_COMMAND: 'ssh -i /home/t/personal' }, 'ssh -i /repo/key');
expect(env.GIT_SSH_COMMAND).toBe('ssh -i /home/t/personal');
});
it("leaves a GIT_SSH in the server's environment to git, which reads it last and would never see it under a written default", () => {
const env = fetchEnvFor({ ...BASE, GIT_SSH: '/home/t/bin/work-ssh' }, null);
expect(env.GIT_SSH_COMMAND).toBeUndefined();
expect(env.GIT_SSH).toBe('/home/t/bin/work-ssh');
expect(env.GIT_TERMINAL_PROMPT).toBe('0');
});
});
// ─── Real git, real local repository ─────────────────────────────────────────
const gitPresent = isGitAvailable();
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'git-fetch-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
function initRepo(path: string, remote?: string) {
mkdirSync(path, { recursive: true });
git(path, 'init', '-q', '-b', 'main');
// So a `file://` remote stays allowed for the production runner too, not only for this test's own git calls.
git(path, 'config', 'protocol.file.allow', 'always');
writeFileSync(join(path, 'f.txt'), '1\n');
git(path, 'add', '-A');
git(path, 'commit', '-q', '-m', 'init');
if (remote) git(path, 'remote', 'add', 'origin', remote);
return path;
}
/** Records what each repository was asked to do, and lets real git answer the rest. */
function recordingRunner() {
const calls: Array<[string, string[]]> = [];
const runner: GitRunner = async (cwd, args, opts) => {
calls.push([cwd, args]);
return runGit(cwd, args, opts);
};
return { calls, runner };
}
describe.skipIf(!gitPresent)('fetchGitWorkspaceOverview (real git)', () => {
let work: string;
const snapshot = (path: string) => ({
head: git(path, 'rev-parse', 'HEAD').trim(),
tree: readdirSync(path, { withFileTypes: true })
.filter((e) => e.name !== '.git' && e.isFile())
.sort((a, b) => a.name.localeCompare(b.name))
.map((e) => `${e.name}=${readFileSync(join(path, e.name), 'utf8')}`),
// The global option has to come before the verb, exactly as the read-only status module runs it.
index: git(path, '--no-optional-locks', 'status', '--porcelain=v2').trim(),
});
beforeEach(() => {
const origin = join(dir, 'origin.git');
mkdirSync(origin);
git(origin, 'init', '--bare', '--quiet', '-b', 'main');
work = initRepo(join(dir, 'work'), origin);
git(work, 'push', '-q', 'origin', 'main');
// A commit made elsewhere and pushed: work's origin/main cannot hear about it until a fetch runs.
// This has to be a clone of origin, not a fresh init: a commit on its own root commit is no
// descendant of what origin already holds, so pushing it is rejected as non-fast-forward.
const other = join(dir, 'other');
git(dir, 'clone', '-q', origin, 'other');
git(other, 'config', 'protocol.file.allow', 'always');
writeFileSync(join(other, 'g.txt'), '2\n');
git(other, 'add', '-A');
git(other, 'commit', '-q', '-m', 'second');
git(other, 'push', '-q', 'origin', 'main');
// Left dirty on purpose, so the snapshot has something proving it was not touched.
writeFileSync(join(work, 'untracked.txt'), 'u\n');
writeFileSync(join(work, 'f.txt'), 'modified\n');
});
it('fetches through the production runner, moving remote-tracking refs and writing nothing else', async () => {
// A push does not update the remote-tracking ref, so the fetch is what has to move it: 0 before, 1 after.
expect(git(work, 'rev-list', '--count', 'main..origin/main').trim()).toBe('0');
const before = snapshot(work);
const report = await fetchGitWorkspaceOverview(work);
expect(report.state).toBe('ok');
expect(report.repos).toHaveLength(1);
expect(report.repos[0]).toMatchObject({ name: 'work', path: '.', ok: true });
expect(report.fetchedAt).toBeGreaterThan(0);
expect(snapshot(work)).toEqual(before); // HEAD, the working tree and the index are exactly as they were
expect(git(work, 'rev-list', '--count', 'main..origin/main').trim()).toBe('1');
});
it('fetches exactly the repositories the panel would show, in the panel order', async () => {
const folder = join(dir, 'folder');
for (const name of ['api', 'web']) initRepo(join(folder, name));
mkdirSync(join(folder, 'node_modules', 'pkg'), { recursive: true }); // never a project the panel lists
const { calls, runner } = recordingRunner();
const report = await fetchGitWorkspaceOverview(folder, { git: runner });
expect(report.state).toBe('ok');
expect(report.repos.map((r) => [r.name, r.path])).toEqual([
['api', 'api'],
['web', 'web'],
]);
// Only the fetches, not the resolver's own `rev-parse`: the repositories touched are the point.
// A bounded run does not promise which one starts first, so compare the set.
expect(calls.filter(([, args]) => args[0] === 'fetch').sort()).toEqual([
[join(folder, 'api'), ['fetch', '--quiet']],
[join(folder, 'web'), ['fetch', '--quiet']],
]);
});
it('never reaches an unrelated repository above the workspace', async () => {
const home = join(dir, 'home');
initRepo(home);
const caseDir = join(home, 'case');
mkdirSync(caseDir);
const { calls, runner } = recordingRunner();
const report = await fetchGitWorkspaceOverview(caseDir, { git: runner, home });
expect(report.state).toBe('not-a-repo');
expect(calls.filter(([, args]) => args[0] === 'fetch')).toEqual([]);
});
it('refuses a repository at or inside a Docker case workspace before any git runs', async () => {
initRepo(join(dir, 'repo'));
const { calls, runner } = recordingRunner();
const report = await fetchGitWorkspaceOverview(dir, { git: runner, dockerWorkspaces: [dir] });
expect(report).toMatchObject({ state: 'unsupported', reason: 'docker' });
expect(calls).toEqual([]);
});
it('keeps a repository that could not be fetched, with its reason', async () => {
for (const name of ['ok', 'private']) initRepo(join(dir, 'folder', name));
const runner: GitRunner = async (cwd) => {
if (basename(cwd) === 'private') {
throw Object.assign(new Error('Command failed'), {
stderr: "fatal: could not read Username for 'https://github.com': terminal prompts disabled",
});
}
return '';
};
const report = await fetchGitWorkspaceOverview(join(dir, 'folder'), { git: runner });
expect(report.state).toBe('ok');
const priv = report.repos.find((r) => r.name === 'private');
expect(priv?.ok).toBe(false);
expect(priv?.failure?.code).toBe('AUTH_REQUIRED');
expect(priv?.failure?.message).toMatch(/authentication/i);
expect(report.repos.find((r) => r.name === 'ok')?.ok).toBe(true);
});
it("runs at most FETCH_CONCURRENCY fetches at a time, with the setting's timeout", async () => {
const folder = join(dir, 'folder');
for (let i = 0; i < FETCH_CONCURRENCY + 2; i++) initRepo(join(folder, `r${i}`));
let inFlight = 0;
let max = 0;
const seenTimeouts = new Set<number | undefined>();
const runner: GitRunner = async (cwd, args, opts) => {
seenTimeouts.add(opts?.timeoutMs);
inFlight++;
max = Math.max(max, inFlight);
await new Promise((r) => setTimeout(r, 10));
inFlight--;
return '';
};
const report = await fetchGitWorkspaceOverview(folder, { git: runner, timeoutMs: 7000 });
expect(report.repos).toHaveLength(FETCH_CONCURRENCY + 2);
expect(report.repos.every((r) => r.ok)).toBe(true);
expect(max).toBe(FETCH_CONCURRENCY);
expect(seenTimeouts).toEqual(new Set([7000]));
});
it('answers not-a-repo for a folder that is not a repository, running no fetch', async () => {
const plain = join(dir, 'plain');
mkdirSync(plain);
const { calls, runner } = recordingRunner();
expect(await fetchGitWorkspaceOverview(plain, { git: runner })).toMatchObject({ state: 'not-a-repo', repos: [] });
expect(calls.filter(([, args]) => args[0] === 'fetch')).toEqual([]);
});
});
// ─── The whole tree, not just the git parent ─────────────────────────────────
/** One process as /proc reports it. `null` means it is gone, which is what the last assertion is about. */
interface Proc {
pid: number;
comm: string;
ppid: number;
/** The PROCESS GROUP. `detached: true` puts the fetch in its own, so the negative pid reaches the children. */
pgrp: number;
cmdline: string;
}
/**
* Every process on the machine, as /proc reports it, so this half can only run on Linux, and the fields are
* counted from the closing parenthesis because `comm` can hold spaces.
*
* Membership of the GROUP is what is checked, not parentage: a helper that outlives its parent stops being a
* child of this test process, and a test that looked for children would pass for the wrong reason.
*/
function scanProc(): Proc[] {
const out: Proc[] = [];
for (const entry of readdirSync('/proc')) {
if (!/^\d+$/.test(entry)) continue;
const pid = Number(entry);
try {
const stat = readFileSync(join('/proc', entry, 'stat'), 'utf8');
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
out.push({
pid,
comm: readFileSync(join('/proc', entry, 'comm'), 'utf8').trim(),
ppid: Number(fields[1]),
pgrp: Number(fields[2]),
cmdline: readFileSync(join('/proc', entry, 'cmdline'), 'utf8')
.replace(/\0/g, ' ')
.trim(),
});
} catch {
/* gone between the listing and the read */
}
}
return out;
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe.skipIf(!gitPresent)('runFetch (the whole tree, not just the git parent)', () => {
it.skipIf(!existsSync('/proc/self/environ'))(
'kills the helper processes too, so nothing is left running after the timeout',
async () => {
const server = createServer(() => {
// Accept and never answer: git's http helper connects, waits, and stays.
});
const sockets = new Set<Socket>();
server.on('connection', (s) => {
sockets.add(s);
s.on('close', () => sockets.delete(s));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as AddressInfo).port;
try {
const repo = initRepo(join(dir, 'hang'), `http://127.0.0.1:${port}/x.git`);
// The handler goes on the moment the promise is made, not when it is read: the timeout fires while the
// tree is still being looked at, and a rejection with nothing listening to it becomes an unhandled
// rejection instead of this test's answer.
const answered = runFetch(repo, buildFetchArgs(), { timeoutMs: 1_500 }).catch((e) => e);
// The tree has to be a tree before the timeout, or nothing is being proven: git itself, and the helper
// it spawns for an http remote. The helper is a grandchild (`git fetch` runs `git remote-http`, which
// spawns `git-remote-http`), so the group, not the parentage, is what proves it. Each process is
// remembered once found rather than re-scanned: after the kill the group is empty, and a test that kept
// looking would report a tree it had already destroyed. The `git config` read the runner does first is
// a `git` too, so the fetch is found by what it was asked to do.
let parent: Proc | null = null;
let helper: Proc | null = null;
for (let i = 0; i < 60 && !(parent && helper); i++) {
const live = scanProc();
parent =
parent ??
live.find((p) => p.comm === 'git' && p.cmdline.includes('fetch') && p.ppid === process.pid) ??
null;
helper =
helper ??
(parent
? live.find((p) => p.pgrp === parent!.pgrp && p.pid !== parent!.pid && p.cmdline.includes('remote-http'))
: null);
if (!helper) await sleep(25);
}
expect(parent, 'the fetch was never running').not.toBeNull();
expect(helper, 'nothing else in the fetch group, so there was no tree to kill').not.toBeNull();
// `detached: true` puts the fetch in a group of its own, which is why the negative pid is the right
// thing to signal: a helper outside that group would survive a `child.kill()`.
expect(parent!.pgrp).toBe(parent!.pid);
expect(helper!.pgrp).toBe(parent!.pgrp);
const err = (await answered) as { code?: string; killed?: boolean };
expect(err.code).toBe('ETIMEDOUT');
expect(err.killed).toBe(true);
// The answer went out before the tree died: that is the whole point of the fix, and it is why the
// escalation is given its own time here instead of being cancelled when the promise settles.
const deadline = Date.now() + FETCH_KILL_ESCALATION_MS + 2_000;
while (Date.now() < deadline && scanProc().some((p) => p.pgrp === parent!.pgrp)) await sleep(50);
expect(scanProc().some((p) => p.pgrp === parent!.pgrp)).toBe(false);
// Nothing of this test's own is left running either.
await sleep(200);
expect(scanProc().filter((p) => p.ppid === process.pid)).toEqual([]);
} finally {
for (const s of sockets) s.destroy();
server.close();
}
}
);
it('rejects on a non-zero exit instead of resolving empty stdout, so a failure keeps a reason to be read', async () => {
// A remote git cannot read. A repository with no remote is not usable for this: `git fetch --quiet` exits 0
// there on the git this was checked against (2.47.3), so resolving empty stdout would have been the right
// answer and the test would prove nothing. A remote pointing at a path that is not a repository exits 128
// and says why, which is the case the row needs a reason for.
const repo = initRepo(join(dir, 'unreadable-remote'), 'file:///tmp/definitely-not-a-repo-xyz');
let err: unknown = null;
try {
await runFetch(repo, buildFetchArgs());
} catch (e) {
err = e;
}
expect(err, 'a fetch that failed resolved instead of rejecting').not.toBeNull();
const e = err as { code?: unknown; stderr?: string; killed?: boolean };
// The exit code, not the timeout marker, so a caller can still tell a failure from a stall.
expect(e.code).toBe(128);
expect(e.killed).toBe(false);
expect(e.stderr).toMatch(/does not appear to be a git repository/);
expect(describeFetchFailure(err).code).toBe('NOT_FOUND');
});
});
// ─── One bound for the config read and the fetch ─────────────────────────────
describe.skipIf(process.platform === 'win32')('runFetch (one bound for the config read and the fetch)', () => {
it('answers at the timeout when git never answers, not at twice it, because the core.sshCommand read spends part of the bound', async () => {
// A `git` that never answers anything, first on PATH, so the read ahead of the fetch hangs exactly as long as
// the fetch would. Both spawns take PATH from process.env at call time. It records its arguments, so the test
// can tell which of the two it was asked to run.
const bin = join(dir, 'bin');
const calls = join(dir, 'calls');
mkdirSync(bin);
writeFileSync(join(bin, 'git'), `#!/bin/sh\necho "$*" >> '${calls}'\nexec sleep 30\n`, { mode: 0o755 });
const savedPath = process.env.PATH;
process.env.PATH = `${bin}:${savedPath ?? ''}`;
try {
const started = Date.now();
const err = (await runFetch(dir, buildFetchArgs(), { timeoutMs: 1_000 }).catch((e) => e)) as {
code?: unknown;
killed?: boolean;
};
const elapsed = Date.now() - started;
expect(err.code).toBe('ETIMEDOUT');
expect(err.killed).toBe(true);
expect(describeFetchFailure(err).code).toBe('TIMEOUT');
// One bound, not one per spawn: the read used all of it, so the fetch got nothing and was not started
// (2019 ms here before the fix, when the fetch was armed with a fresh timeout of its own).
expect(elapsed).toBeGreaterThanOrEqual(950);
expect(elapsed).toBeLessThan(1_600);
expect(readFileSync(calls, 'utf8').trim().split('\n')).toEqual(['config --get core.sshCommand']);
} finally {
if (savedPath === undefined) delete process.env.PATH;
else process.env.PATH = savedPath;
}
});
});
// ─── One run per folder, and one bound for the whole tick ────────────────────
describe.skipIf(!gitPresent)('fetchWorkspaceOnce (the join)', () => {
// The resolver still runs real git, so these need real repositories on disk; what is injected is only the
// fetch, because what is being pinned here is the joining, not git's answer.
beforeEach(() => {
// The lane is module state: a run left in it by the previous test would be joined by the next one.
clearGitFetchLane();
});
it('joins a run already going for the same folder instead of starting a second git fetch in the same clone', async () => {
const folder = join(dir, 'folder');
initRepo(join(folder, 'api'));
let releaseRun = () => {};
const held = new Promise<void>((resolve) => (releaseRun = resolve));
let running = () => {};
const runStarted = new Promise<void>((resolve) => (running = resolve));
let firstRunFetches = 0;
const firstRunner: GitRunner = async (cwd, args) => {
if (args[0] === 'fetch') {
firstRunFetches++;
running();
await held;
}
return '';
};
const first = fetchWorkspaceOnce(folder, { git: firstRunner }, 'interval');
await runStarted; // the run is going, and still going
// A click while that run is working the folder: it joins, so it never touches git at all, not even the
// resolver's own probe, because it does not resolve anything for itself.
let joinerCalls = 0;
const joinerRunner: GitRunner = async () => {
joinerCalls++;
return '';
};
const joiner = fetchWorkspaceOnce(folder, { git: joinerRunner }, 'click');
releaseRun();
const answer = await first;
// The joiner gets that same run's answer, including its limits: the point is not to fetch twice.
expect(await joiner).toBe(answer);
expect(joinerCalls).toBe(0);
expect(firstRunFetches).toBe(1);
});
it('a join reports the trigger of the run it joined, not the trigger that made it ask, because a click that fetched nothing cannot claim the fetch', async () => {
const folder = join(dir, 'folder');
initRepo(join(folder, 'api'));
let releaseRun = () => {};
const held = new Promise<void>((resolve) => (releaseRun = resolve));
let running = () => {};
const runStarted = new Promise<void>((resolve) => (running = resolve));
const runner: GitRunner = async (cwd, args) => {
if (args[0] === 'fetch') {
running();
await held;
}
return '';
};
// The tick got there first; the click is the joiner.
const intervalRun = fetchWorkspaceOnce(folder, { git: runner }, 'interval');
await runStarted;
const joinerRun = fetchWorkspaceOnce(folder, { git: async () => '' }, 'click');
releaseRun();
const run = await intervalRun;
expect(await joinerRun).toBe(run); // one run, not two answers
expect(run.trigger).toBe('interval');
// The row the panel shows is built from the run that ran, so it names the interval.
expect(gitFetchStatusFrom(run.report, run.trigger).trigger).toBe('interval');
});
it('keys the join on the resolved folder, so the same folder written two ways is one run', async () => {
const folder = join(dir, 'folder');
initRepo(join(folder, 'api'));
let releaseRun = () => {};
const held = new Promise<void>((resolve) => (releaseRun = resolve));
let running = () => {};
const runStarted = new Promise<void>((resolve) => (running = resolve));
let fetches = 0;
const firstRunner: GitRunner = async (cwd, args) => {
if (args[0] === 'fetch') {
fetches++;
running();
await held;
}
return '';
};
const first = fetchWorkspaceOnce(folder, { git: firstRunner }, 'click');
await runStarted;
// Two sessions on one folder reach it by different spellings; the scheduler keys by `resolve`.
let joinerCalls = 0;
const joinerRunner: GitRunner = async () => {
joinerCalls++;
return '';
};
const second = fetchWorkspaceOnce(join(dir, 'folder', '.'), { git: joinerRunner }, 'click');
releaseRun();
expect(await second).toBe(await first);
expect(joinerCalls).toBe(0);
expect(fetches).toBe(1);
});
it('releases the folder when a run lands, so the next click is a new run and not an old answer', async () => {
const folder = join(dir, 'folder');
initRepo(join(folder, 'api'));
let fetches = 0;
const runner: GitRunner = async (cwd, args) => {
if (args[0] === 'fetch') fetches++;
return '';
};
const firstRun = await fetchWorkspaceOnce(folder, { git: runner }, 'interval');
expect(fetches).toBe(1);
const secondRun = await fetchWorkspaceOnce(folder, { git: runner }, 'click');
expect(fetches).toBe(2);
// A run that is its own reports its own trigger; only a joiner borrows the trigger of the run it joined.
expect(firstRun.trigger).toBe('interval');
expect(secondRun.trigger).toBe('click');
});
it('bounds a whole tick with the limiter it is handed, because two runs given a bound each would get twice the bound', async () => {
const left = join(dir, 'left');
const right = join(dir, 'right');
for (let i = 0; i < FETCH_CONCURRENCY + 1; i++) {
initRepo(join(left, `l${i}`));
initRepo(join(right, `r${i}`));
}
const held = () => new Promise((resolve) => setTimeout(resolve, 10));
// Only the fetches go through the limiter, so only they are counted here: the resolver's own probe is
// not a bounded git process.
const counting =
(state: { live: number; peak: number }): GitRunner =>
async (cwd, args) => {
if (args[0] !== 'fetch') return '';
state.live++;
state.peak = Math.max(state.peak, state.live);
await held();
state.live--;
return '';
};
// The scheduler hands every folder in the tick the SAME limiter, so the bound is the tick's, not the run's.
const shared = { live: 0, peak: 0 };
const sharedRunner = counting(shared);
const limiter = createLimiter(FETCH_CONCURRENCY);
await Promise.all([
fetchGitWorkspaceOverview(left, { git: sharedRunner, limiter }),
fetchGitWorkspaceOverview(right, { git: sharedRunner, limiter }),
]);
expect(shared.peak).toBe(FETCH_CONCURRENCY);
// Without a shared limiter each run bounds only its own call, which is the failure the scheduler is there
// to prevent: two folders at FETCH_CONCURRENCY each, so twice as many git processes as the rule says.
const alone = { live: 0, peak: 0 };
const aloneRunner = counting(alone);
await Promise.all([
fetchGitWorkspaceOverview(left, { git: aloneRunner }),
fetchGitWorkspaceOverview(right, { git: aloneRunner }),
]);
expect(alone.peak).toBe(2 * FETCH_CONCURRENCY);
});
});