Compare commits

...
Author SHA1 Message Date
Codeman maintainer 91cea50d9c fix(macos): never run Apple's placeholder git, which opens the developer tools window
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. With the developer tools present (Command
Line Tools on one Mac, Xcode on another) the gate resolves /usr/bin/git,
finds the tools and allows git, in 3 to 5 ms. On a MacBook with no
developer tools, a fresh no-admin install with this fix started the server
and loaded the page with git reported as absent, no window opened, and the
system log holds no request to install the tools; the same install without
the fix logged that request one second after the server started.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 17:02:53 +01:00
10 changed files with 371 additions and 1 deletions

No files matched your search

+1
View File
@@ -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 `<prefix> 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-<letter> 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-<arch>/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/<platform>-<arch>/`**, 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)
+20 -1
View File
@@ -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<GitRun> {
// 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',
+4
View File
@@ -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<string | null> {
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));
+3
View File
@@ -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<string>;
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
+6
View File
@@ -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<WorktreeGitResult>((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<typeof spawn>;
try {
child = spawn('git', args, {
+3
View File
@@ -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<WorkspaceSignals | null> {
const git = async (args: string[]): Promise<string> => {
// 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,
+147
View File
@@ -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;
}
+3
View File
@@ -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<GitResult> {
// 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,
+3
View File
@@ -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 {
+181
View File
@@ -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<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();
});
});