mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
On a Mac without the developer tools, /usr/bin/git is a placeholder: running it, even as `git --version`, makes macOS open its "install the command line developer tools" window. The server asked "is git here?" exactly that way on the first page load (isGitAvailable() from renderIndexHtml), so a no-admin install on such a Mac ended with that window one second after the server started, and it would have come back at every start. Found on a new MacBook on 2026-11-06: the system log names the Codeman server as the parent of that git process. One gate now stands in front of every git the server starts (src/utils/git-availability.ts). On macOS it resolves git on the PATH itself; when that is the placeholder, `xcode-select -p` must name a developer folder that holds a git of its own. xcode-select only prints and never opens a window. Without the tools, git counts as absent and is never started: every caller gets the ENOENT it already answers with "git is not installed". A refusal is asked again after a minute, so installing the tools later needs no server restart for the git features, and isGitAvailable() no longer memoizes that one refusal. Gated: the clone and worktree runners, the Git panel's status and fetch, the update check and repository status, and Read My Mind's workspace signals. test/git-spawn-gate.test.ts covers the decision and scans src/ for a git spawn in a file that does not call the gate, with the reviewed list of sites pinned. Checked for real on macOS 27 (Command Line Tools) and on a Mac with Xcode: both resolve /usr/bin/git, find the tools and allow git, in 3 to 5 ms. The refusal itself is covered by the unit tests; it still needs a run on a Mac without the tools. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
182 lines
6.9 KiB
TypeScript
182 lines
6.9 KiB
TypeScript
/**
|
||
* @fileoverview The gate in front of every `git` the server starts (src/utils/git-availability.ts).
|
||
*
|
||
* On a Mac without the developer tools, `/usr/bin/git` is Apple's placeholder: running it, even as
|
||
* `git --version`, opens the system's "install the command line developer tools" window. The server did
|
||
* exactly that on the first page load after a no-admin install (found 2026-11-06 on a new MacBook: the
|
||
* system log named the Codeman server as the parent of that `git`).
|
||
*
|
||
* Two halves: the pure decision, and a scan of `src/` that fails when a file starts git without going
|
||
* through the gate, so the next git caller cannot bring the window back.
|
||
*/
|
||
|
||
import { readdirSync, readFileSync } from 'node:fs';
|
||
import { join, relative, sep } from 'node:path';
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
APPLE_GIT_PLACEHOLDER,
|
||
developerDirHoldsGit,
|
||
findOnPath,
|
||
gitMissingError,
|
||
gitSpawnAllowed,
|
||
gitSpawnWouldPrompt,
|
||
resetGitGateForTests,
|
||
} from '../src/utils/git-availability.js';
|
||
|
||
const never = (): boolean => {
|
||
throw new Error('must not be asked');
|
||
};
|
||
|
||
describe('gitSpawnWouldPrompt', () => {
|
||
it('is true only for Apple’s placeholder on a Mac without developer tools', () => {
|
||
expect(
|
||
gitSpawnWouldPrompt({ platform: 'darwin', gitPath: APPLE_GIT_PLACEHOLDER, developerToolsPresent: () => false })
|
||
).toBe(true);
|
||
});
|
||
|
||
it('lets the placeholder run once the developer tools are there', () => {
|
||
expect(
|
||
gitSpawnWouldPrompt({ platform: 'darwin', gitPath: APPLE_GIT_PLACEHOLDER, developerToolsPresent: () => true })
|
||
).toBe(false);
|
||
});
|
||
|
||
it('never asks about developer tools for a git that is not the placeholder', () => {
|
||
for (const gitPath of ['/opt/homebrew/bin/git', '/usr/local/bin/git', '/nix/store/x/bin/git']) {
|
||
expect(gitSpawnWouldPrompt({ platform: 'darwin', gitPath, developerToolsPresent: never })).toBe(false);
|
||
}
|
||
});
|
||
|
||
it('leaves a Mac with no git on the PATH to the spawn’s own ENOENT', () => {
|
||
expect(gitSpawnWouldPrompt({ platform: 'darwin', gitPath: null, developerToolsPresent: never })).toBe(false);
|
||
});
|
||
|
||
it('is never true off macOS, where /usr/bin/git is a real git', () => {
|
||
for (const platform of ['linux', 'win32', 'freebsd'] as const) {
|
||
expect(gitSpawnWouldPrompt({ platform, gitPath: APPLE_GIT_PLACEHOLDER, developerToolsPresent: never })).toBe(
|
||
false
|
||
);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('findOnPath', () => {
|
||
const has =
|
||
(...paths: string[]) =>
|
||
(p: string) =>
|
||
paths.includes(p);
|
||
|
||
it('returns the first match in PATH order', () => {
|
||
const exists = has('/opt/homebrew/bin/git', '/usr/bin/git');
|
||
expect(findOnPath('git', '/opt/homebrew/bin:/usr/bin', exists)).toBe('/opt/homebrew/bin/git');
|
||
expect(findOnPath('git', '/usr/bin:/opt/homebrew/bin', exists)).toBe('/usr/bin/git');
|
||
});
|
||
|
||
it('skips empty and relative entries, which mean the current directory', () => {
|
||
const exists = has('git', 'bin/git', '/usr/bin/git');
|
||
expect(findOnPath('git', ':.:bin:/usr/bin', exists)).toBe('/usr/bin/git');
|
||
});
|
||
|
||
it('is null when nothing matches or there is no PATH', () => {
|
||
expect(findOnPath('git', '/a:/b', () => false)).toBeNull();
|
||
expect(findOnPath('git', undefined, () => true)).toBeNull();
|
||
expect(findOnPath('git', '', () => true)).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('developerDirHoldsGit', () => {
|
||
it('needs a git inside the folder xcode-select names', () => {
|
||
const exists = (p: string) => p === '/Library/Developer/CommandLineTools/usr/bin/git';
|
||
expect(developerDirHoldsGit('/Library/Developer/CommandLineTools\n', exists)).toBe(true);
|
||
// A selected Xcode that was deleted since: the placeholder would open its window after all.
|
||
expect(developerDirHoldsGit('/Applications/Xcode.app/Contents/Developer\n', exists)).toBe(false);
|
||
});
|
||
|
||
it('refuses empty and relative answers', () => {
|
||
expect(developerDirHoldsGit('', () => true)).toBe(false);
|
||
expect(developerDirHoldsGit(' \n', () => true)).toBe(false);
|
||
expect(developerDirHoldsGit('relative/dir', () => true)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('gitMissingError', () => {
|
||
it('reads as the ENOENT every caller already answers with "git is not installed"', () => {
|
||
const err = gitMissingError();
|
||
expect(err.code).toBe('ENOENT');
|
||
expect(err.message).toBe('spawn git ENOENT');
|
||
expect(String(err)).toMatch(/enoent/i); // git-worktrees.ts and git-clone.ts match the string form
|
||
});
|
||
});
|
||
|
||
describe('gitSpawnAllowed', () => {
|
||
it.skipIf(process.platform === 'darwin')('is always true off macOS and does no IO there', () => {
|
||
resetGitGateForTests();
|
||
expect(gitSpawnAllowed()).toBe(true);
|
||
expect(gitSpawnAllowed()).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ─── Scan: no git spawn outside the gate ─────────────────────────────────────
|
||
|
||
const SRC = join(__dirname, '..', 'src');
|
||
|
||
/** A call that starts `git` as the program (argv form; the server never builds a git shell string). */
|
||
const GIT_SPAWN = /\b(?:spawn|spawnSync|execFile|execFileSync|execFileAsync|exec|execSync|tryExec)\(\s*['"`]git['"`]/g;
|
||
|
||
/**
|
||
* Files that start git, with how many call sites each has. A new site in one of them, or a new file,
|
||
* fails here on purpose: add `gitSpawnAllowed()` in front of it (see the module's fileoverview), then
|
||
* update this table.
|
||
*/
|
||
const GATED: Record<string, number> = {
|
||
'git-clone.ts': 2,
|
||
'git-fetch.ts': 2,
|
||
'git-workspace-status.ts': 1,
|
||
'git-worktrees.ts': 1,
|
||
'readmymind-collectors.ts': 1,
|
||
'web/repo-status.ts': 1,
|
||
'web/self-update.ts': 8,
|
||
};
|
||
|
||
function walk(dir: string, out: string[] = []): string[] {
|
||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||
const full = join(dir, entry.name);
|
||
if (entry.isDirectory()) {
|
||
if (entry.name === 'public' || entry.name === 'node_modules') continue;
|
||
walk(full, out);
|
||
} else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
|
||
out.push(full);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
describe('every git the server starts goes through the gate', () => {
|
||
const found: Record<string, number> = {};
|
||
const ungated: string[] = [];
|
||
for (const file of walk(SRC)) {
|
||
const text = readFileSync(file, 'utf8');
|
||
const sites = text.match(GIT_SPAWN)?.length ?? 0;
|
||
if (!sites) continue;
|
||
const rel = relative(SRC, file).split(sep).join('/');
|
||
found[rel] = sites;
|
||
if (!/\bgitSpawnAllowed\(\)/.test(text)) ungated.push(rel);
|
||
}
|
||
|
||
it('finds the spawn sites at all (the pattern still matches the code)', () => {
|
||
expect(Object.keys(found).length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('has no file that starts git without calling gitSpawnAllowed()', () => {
|
||
expect(ungated).toEqual([]);
|
||
});
|
||
|
||
it('matches the reviewed list of sites, so a new one is looked at', () => {
|
||
expect(found).toEqual(GATED);
|
||
});
|
||
|
||
it('keeps the gate itself free of git', () => {
|
||
const gate = readFileSync(join(SRC, 'utils', 'git-availability.ts'), 'utf8');
|
||
expect(gate.match(GIT_SPAWN)).toBeNull();
|
||
});
|
||
});
|