diff --git a/CLAUDE.md b/CLAUDE.md index 58e0ce05..0e1f7f2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,6 +104,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph - **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger) - **The prefix table on Codeman's socket holds only ` send-prefix`** — browser tabs are tmux attach clients, and tmux's stock bindings let Ctrl+B `)`/`s` move a tab onto another session. `createSession` resets the table, and `reconcileSessions()` at boot for sessions that survived an upgrade (`src/tmux-prefix-table.ts`); `root` and `C- detach-client` (the TUI attach's way out) are left alone, so F1 and that alias reach browser tabs for the length of an attach. Alt+1..9 are `if-shell` bindings that `switch-client` only for the TUI's own client (`client_tty` plus a one-shot pid hook), so a browser tab's Esc-then-digit does not move it. Docker and owned-remote nested servers get the same reduction (`C-q send-prefix` only) in their create chain. ⚠️ So `Ctrl+B d` does not exist there: anything attaching a user's terminal claims its own detach key (F1 in `root`, as `codeman tui` and `scripts/tmux-manager.sh` do). → [architecture-invariants#the-prefix-table-on-codemans-socket-holds-only-send-prefix](docs/architecture-invariants.md#the-prefix-table-on-codemans-socket-holds-only-send-prefix) - **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. (The installer's no-admin route cannot install one, so there it stops before downloading anything and names what is missing.) ⚠️ Look in **`prebuilds/-/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable) +- **Never start `git` without `gitSpawnAllowed()`** (`utils/git-availability.ts`): on a Mac without the developer tools `/usr/bin/git` is Apple's placeholder, and running it, even as `git --version`, opens macOS's "install the command line developer tools" window. The server did that on the first page load after a no-admin install (`isGitAvailable()` from `renderIndexHtml`) and would have again at every start. The gate resolves `git` on the PATH itself and, for the placeholder, proves the tools first with `xcode-select -p`, which never opens a window; a refusal is answered as ENOENT (`gitMissingError()`), which every caller already reads as "git is not installed". `test/git-spawn-gate.test.ts` pins the list of spawn sites, so a new one fails until it is gated. `install.sh` follows the same rule for the same reason. - **User-chosen paths are probed BOUNDED, never with `existsSync`/`statSync`** (#516): a linked case or a `workingDir` can sit on a network mount that stopped answering. A synchronous check there freezes the whole server, and an unbounded async `stat`/`lstat`/`readFile` holds one of libuv's threadpool workers (4 by default, shared with every `fs`, `dns.lookup` and `crypto` call) until the mount returns. On a request or spawn path use `probePath()`/`probePathKind()` (`utils/bounded-path-probe.ts`, read its `@fileoverview`). ⚠️ `unknown` is NEVER `absent`: nothing is created, scaffolded or 404'd on it. Bulk scans keep the stall cap; a request for ONE path the user named may pass `{ pastCap: true }`, which still stops at the ceiling that keeps one worker free; and a helper that would otherwise touch the path skips whatever is still `unknown`. User-facing errors for it go through `describeUnknownPath()`, so a refused probe is not reported as a broken folder. - **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture) diff --git a/src/git-clone.ts b/src/git-clone.ts index 513d6a11..5c6ed694 100644 --- a/src/git-clone.ts +++ b/src/git-clone.ts @@ -54,6 +54,7 @@ import { lstat, readdir, rename, rm } from 'node:fs/promises'; import { basename, dirname, join } from 'node:path'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { probePathKind } from './utils/bounded-path-probe.js'; +import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js'; // ─── Tunables ──────────────────────────────────────────────────────────────── @@ -728,6 +729,17 @@ interface GitRun { * signal is used rather than `child.kill()`. */ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number): Promise { + // Never Apple's placeholder git on a Mac without developer tools: answered as a missing git. + if (!gitSpawnAllowed()) { + return { + stdout: '', + stderr: '', + code: null, + timedOut: false, + started: false, + spawnError: String(gitMissingError()), + }; + } // The queue wait spends the SAME deadline as the operation: `timeoutMs` is a // promise about the whole call, not about git's runtime after some unbounded // wait. A full queue is refused outright rather than queued. @@ -814,10 +826,17 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number) } } -/** Is a usable `git` on this machine? Memoized: the answer cannot change without a restart. */ +/** + * Is a usable `git` on this machine? Memoized: the answer cannot change without a restart. The one + * exception is a Mac whose only git is Apple's placeholder with no developer tools behind it: running + * that, even as `git --version`, opens macOS's "install the developer tools" window, so it is never + * run, and that refusal is not memoized here (the gate keeps its own short memory), so the page finds + * git once the tools are installed. + */ let gitAvailable: boolean | null = null; export function isGitAvailable(): boolean { if (gitAvailable !== null) return gitAvailable; + if (!gitSpawnAllowed()) return false; try { execFileSync('git', ['--version'], { encoding: 'utf-8', diff --git a/src/git-fetch.ts b/src/git-fetch.ts index cfd201f7..a36c5f33 100644 --- a/src/git-fetch.ts +++ b/src/git-fetch.ts @@ -65,6 +65,7 @@ import { resolveWorkspaceRepos, type GitRunner, } from './git-workspace-status.js'; +import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js'; const execFileAsync = promisify(execFile); @@ -205,6 +206,7 @@ export function fetchEnvFor(base: NodeJS.ProcessEnv, repoSshCommand: string | nu * add a fifth git process or push a fetch past the user's timeout: it spends part of it. */ export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promise { + if (!gitSpawnAllowed()) return null; try { const { stdout } = await execFileAsync('git', ['config', '--get', 'core.sshCommand'], { cwd, @@ -240,6 +242,8 @@ export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promis * as a timeout without starting the fetch. */ export const runFetch: GitRunner = async (cwd, args, opts) => { + // Never Apple's placeholder git on a Mac without developer tools: answered as GIT_MISSING. + if (!gitSpawnAllowed()) throw gitMissingError(); const timeoutMs = opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS; const startedAt = Date.now(); const env = fetchEnvFor(process.env, await readRepoSshCommand(cwd, timeoutMs)); diff --git a/src/git-workspace-status.ts b/src/git-workspace-status.ts index 9be051be..257cc07d 100644 --- a/src/git-workspace-status.ts +++ b/src/git-workspace-status.ts @@ -50,6 +50,7 @@ import { homedir } from 'node:os'; import { basename, join, relative, sep } from 'node:path'; import { promisify } from 'node:util'; import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js'; +import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js'; const execFileAsync = promisify(execFile); @@ -265,6 +266,8 @@ export function parseCommitLog(text: string): GitCommitEntry[] { export type GitRunner = (cwd: string, args: string[], opts?: { timeoutMs?: number }) => Promise; export const runGit: GitRunner = async (cwd, args, opts) => { + // Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts). + if (!gitSpawnAllowed()) throw gitMissingError(); const { stdout } = await execFileAsync( 'git', // --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or diff --git a/src/git-worktrees.ts b/src/git-worktrees.ts index ba707f8d..9164db73 100644 --- a/src/git-worktrees.ts +++ b/src/git-worktrees.ts @@ -62,6 +62,7 @@ import { homedir } from 'node:os'; import { join, resolve, sep } from 'node:path'; import { gitNonInteractiveEnv, isSafeGitRef, sanitizeGitOutput } from './git-clone.js'; import { isInsideAny, isUnrelatedAncestor } from './git-workspace-status.js'; +import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js'; // ─── Limits ────────────────────────────────────────────────────────────────── @@ -371,6 +372,11 @@ const READ_ONLY_GIT_OPTIONS = ['--no-optional-locks', '-c', 'core.fsmonitor=fals */ export const runWorktreeGit: WorktreeGitRunner = (cwd, args, opts) => new Promise((resolveRun) => { + // Never Apple's placeholder git on a Mac without developer tools: answered as "git is not installed". + if (!gitSpawnAllowed()) { + resolveRun({ code: null, stdout: '', stderr: '', timedOut: false, spawnError: String(gitMissingError()) }); + return; + } let child: ReturnType; try { child = spawn('git', args, { diff --git a/src/readmymind-collectors.ts b/src/readmymind-collectors.ts index 0e074964..8b0e0810 100644 --- a/src/readmymind-collectors.ts +++ b/src/readmymind-collectors.ts @@ -21,6 +21,7 @@ import { open, readdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { promisify } from 'node:util'; import type { PredictionToolCall, WorkspaceSignals } from './readmymind-context.js'; +import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js'; const execFileAsync = promisify(execFile); @@ -150,6 +151,8 @@ const MAX_STATUS_LINES = 30; */ export async function collectWorkspaceSignals(workingDir: string): Promise { const git = async (args: string[]): Promise => { + // Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts). + if (!gitSpawnAllowed()) throw gitMissingError(); const { stdout } = await execFileAsync('git', args, { cwd: workingDir, timeout: GIT_TIMEOUT_MS, diff --git a/src/utils/git-availability.ts b/src/utils/git-availability.ts new file mode 100644 index 00000000..67724539 --- /dev/null +++ b/src/utils/git-availability.ts @@ -0,0 +1,147 @@ +/** + * @fileoverview The ONE gate in front of every `git` the server starts. + * + * macOS ships `/usr/bin/git` as a placeholder. On a Mac without the developer tools (Xcode or the + * Command Line Tools), running it, even as `git --version`, makes macOS open its "install the command + * line developer tools" window and exit non-zero. The server used to ask "is git here?" that way on the + * first page load, so a user who had just installed Codeman without git and without an administrator + * password got that window one second after the install finished, and again at every login. + * `install.sh` never runs the placeholder for the same reason. + * + * So on macOS, when `git` on the server's PATH resolves to that placeholder, the developer tools are + * proven first: `xcode-select -p` (a real binary that prints and never opens a window) must name a + * folder that holds a git of its own. Otherwise git counts as absent and is never started. + * + * Every site that starts git calls `gitSpawnAllowed()` first and answers a refusal the way it already + * answers a machine with no git (`gitMissingError()` carries `code: 'ENOENT'` for that). + * `test/git-spawn-gate.test.ts` fails on a git spawn in a file that does not go through this gate. + * + * The decision is pure (`gitSpawnWouldPrompt`, `findOnPath`); the IO around it is two bounded steps, a + * PATH scan and one `xcode-select -p` (a few milliseconds, capped at 3 s), and only on macOS. A "yes" is + * kept for the life of the process; a "no" is asked again after `GIT_GATE_NEGATIVE_TTL_MS`, so + * installing the developer tools later needs no server restart for the git features (the page's own + * "git is available" flag stays memoized per process, like every other availability flag there). + * + * @module utils/git-availability + */ + +import { execFileSync } from 'node:child_process'; +import { realpathSync } from 'node:fs'; +import { delimiter, isAbsolute, join } from 'node:path'; +import { isExecutableRegularFile } from './cli-executable-resolver.js'; + +/** Apple's placeholder. A real git (Homebrew, MacPorts, Nix) sits elsewhere on the PATH. */ +export const APPLE_GIT_PLACEHOLDER = '/usr/bin/git'; + +const XCODE_SELECT = '/usr/bin/xcode-select'; +const XCODE_SELECT_TIMEOUT_MS = 3_000; + +/** How long a refusal is kept before the machine is looked at again. */ +export const GIT_GATE_NEGATIVE_TTL_MS = 60_000; + +/** + * The first `name` on a PATH-style list that `isExecutable` accepts, or null. Pure. + * Empty and relative entries are skipped: they mean "the current directory" of whoever runs the + * command, which this lookup cannot know. + */ +export function findOnPath( + name: string, + pathEnv: string | undefined, + isExecutable: (path: string) => boolean +): string | null { + for (const dir of (pathEnv ?? '').split(delimiter)) { + if (!dir || !isAbsolute(dir)) continue; + const candidate = join(dir, name); + if (isExecutable(candidate)) return candidate; + } + return null; +} + +export interface GitGateFacts { + platform: NodeJS.Platform; + /** What `git` resolves to on the server's PATH (symlinks followed), or null when there is none. */ + gitPath: string | null; + /** Asked only when it decides the answer, so other machines never pay for it. */ + developerToolsPresent: () => boolean; +} + +/** + * Would starting `git` open macOS's "install the developer tools" window? Pure. + * Only Apple's placeholder does that, and only while no developer tools are installed. No git on the + * PATH at all is not this function's case: the spawn then fails with ENOENT, which every caller handles. + */ +export function gitSpawnWouldPrompt(facts: GitGateFacts): boolean { + if (facts.platform !== 'darwin') return false; + if (facts.gitPath !== APPLE_GIT_PLACEHOLDER) return false; + return !facts.developerToolsPresent(); +} + +/** + * The developer folder `xcode-select -p` names must hold a git of its own (`usr/bin/git`, true for both + * Xcode and the Command Line Tools). A selected folder that has since been deleted would otherwise read + * as "tools present" and the placeholder would open its window after all. + */ +export function developerDirHoldsGit(developerDir: string, isExecutable: (path: string) => boolean): boolean { + const dir = developerDir.trim(); + if (!dir || !isAbsolute(dir)) return false; + return isExecutable(join(dir, 'usr', 'bin', 'git')); +} + +function resolveGitOnPath(): string | null { + const found = findOnPath('git', process.env.PATH, isExecutableRegularFile); + if (!found) return null; + try { + return realpathSync(found); + } catch { + return found; + } +} + +function developerToolsPresent(): boolean { + try { + const out = execFileSync(XCODE_SELECT, ['-p'], { + encoding: 'utf8', + timeout: XCODE_SELECT_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + }); + return developerDirHoldsGit(out, isExecutableRegularFile); + } catch { + return false; // exit 2: no developer folder. Never the placeholder's window. + } +} + +let allowed: boolean | null = null; +let refusedAt = 0; + +/** + * May the server start `git` right now? False only on a Mac whose only git is Apple's placeholder with + * no developer tools behind it. Callers answer false as "git is not installed". + */ +export function gitSpawnAllowed(): boolean { + if (allowed === true) return true; + if (allowed === false && Date.now() - refusedAt < GIT_GATE_NEGATIVE_TTL_MS) return false; + const platform = process.platform; + allowed = !gitSpawnWouldPrompt({ + platform, + gitPath: platform === 'darwin' ? resolveGitOnPath() : null, + developerToolsPresent, + }); + if (!allowed) refusedAt = Date.now(); + return allowed; +} + +/** The error a refused spawn stands in for: what Node raises when there is no `git` to start. */ +export function gitMissingError(): NodeJS.ErrnoException { + const err: NodeJS.ErrnoException = new Error('spawn git ENOENT'); + err.code = 'ENOENT'; + err.errno = -2; + err.syscall = 'spawn git'; + err.path = 'git'; + return err; +} + +/** Test seam: forget the remembered answer. */ +export function resetGitGateForTests(): void { + allowed = null; + refusedAt = 0; +} diff --git a/src/web/repo-status.ts b/src/web/repo-status.ts index 2456a772..6fa4d02a 100644 --- a/src/web/repo-status.ts +++ b/src/web/repo-status.ts @@ -41,6 +41,7 @@ import { createRequire } from 'node:module'; import { promisify } from 'node:util'; import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; import { gitNonInteractiveEnv, redactGitCredentials } from '../git-clone.js'; +import { gitSpawnAllowed } from '../utils/git-availability.js'; import { getInstallInfo } from './self-update.js'; import type { RepoIncomingCommit, RepoRemoteRole, RepoRemoteStatus, RepositoryStatusResult } from '../types/update.js'; @@ -242,6 +243,8 @@ interface GitResult { * spawn would block the event loop for the whole wait. */ async function runGit(args: string[], cwd: string, timeout = EXEC_TIMEOUT_MS): Promise { + // Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts). + if (!gitSpawnAllowed()) return { ok: false, stdout: '', stderr: 'git is not installed' }; try { const { stdout } = await execFileAsync('git', args, { cwd, diff --git a/src/web/self-update.ts b/src/web/self-update.ts index e01920a7..ba25d509 100644 --- a/src/web/self-update.ts +++ b/src/web/self-update.ts @@ -41,6 +41,7 @@ import { createRequire } from 'node:module'; import { dataPath } from '../config/instance.js'; import { LAUNCHD_LABEL, SYSTEMD_UNIT } from '../config/service-names.js'; import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { gitSpawnAllowed } from '../utils/git-availability.js'; import type { EnvironmentBlocker, EnvironmentGate, @@ -429,6 +430,8 @@ export function reconcileUpdateOnBoot(now = Date.now()): void { /** Run a command, returning trimmed stdout, or null on any error. */ function tryExec(cmd: string, args: string[], cwd?: string, timeout = EXEC_TIMEOUT_MS): string | null { + // Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts). + if (cmd === 'git' && !gitSpawnAllowed()) return null; try { return execFileSync(cmd, args, { cwd, encoding: 'utf-8', timeout, stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { diff --git a/test/git-spawn-gate.test.ts b/test/git-spawn-gate.test.ts new file mode 100644 index 00000000..832b1f90 --- /dev/null +++ b/test/git-spawn-gate.test.ts @@ -0,0 +1,181 @@ +/** + * @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 = { + '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 = {}; + 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(); + }); +});