Files
Codeman/src/git-fetch.ts
T
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

511 lines
24 KiB
TypeScript

/**
* @fileoverview `git fetch` for the repositories the Git panel shows, on the user's click
* (`POST /api/sessions/:id/git-fetch`) or on an interval the user turned on
* (`src/git-fetch-scheduler.ts`).
*
* The status module (src/git-workspace-status.ts) is deliberately offline: "behind" is as of the last
* fetch, so a number that is stale can only be made current by fetching. This is the only git command the
* Git panel runs that writes, and the only git command Codeman runs on a timer. Cloning a repository and
* managing worktrees write too, but those are things the user asked for at the moment they asked; this is
* the one that can happen later, by itself. Nothing here commits, checks out, merges or touches the working
* tree: `git fetch` updates remote-tracking
* refs and nothing else, and `FETCH_CONFIG_ARGS` keeps it from starting a gc in a repository an agent is
* already using. It is not harmless in the way a read is: a fetch holds git's ref lock, so an agent's own
* `git pull` in the same clone can collide with it (`cannot lock ref`), which is why one folder is fetched
* by at most one run at a time (`fetchWorkspaceOnce`).
*
* Same split as git-clone.ts: the pure half (`buildFetchArgs`, `describeFetchFailure`, `fetchEnvFor`,
* `gitFetchSettingsFrom`, `gitFetchStatusFrom`) holds every decision and tests without spawning, and the IO
* half runs one bounded `git fetch --quiet` per repository.
*
* Rules the code keeps and the tests pin:
* - The repository set is `resolveWorkspaceRepos`, the single source the panel and the diff route
* already use, so Fetch cannot reach a repository the panel would not show. The Docker rule is
* checked before any git runs: a repository a container can write to is refused outright, because a
* planted config would run on the host, and `git fetch` is exactly the command that can run a
* repository's own hooks (`reference-transaction`, and `pre-auto-gc` through the automatic maintenance
* that follows a fetch) and its config commands such as `credential.helper`, which is why a timer only
* runs it after the user opts in.
* - One run per folder at a time: `fetchWorkspaceOnce` joins a run already going for the same folder
* instead of starting a second one, because two fetches in one clone collide on the ref lock.
* - Bounded: the git timeout from Settings → Bottom bar per repository, `MAX_REPOS_LIMIT` repositories at
* most, a hard wall-clock bound on each spawn, and `FETCH_CONCURRENCY` git processes at a time for the
* whole run: one `Limiter`, shared by the scheduler's tick, not one bound per folder.
* - Non-interactive: `GIT_TERMINAL_PROMPT=0` and BatchMode ssh, so a private repository that needs a
* login fails fast instead of hanging the request on an invisible username prompt. Codeman collects
* no credentials; a repository the user's own git is already signed in to works because that is the
* user's existing setup, so unlike a clone no credential helper is disabled here. That BatchMode ssh is
* a default, never an override: `fetchEnvFor` keeps whatever the repository's `core.sshCommand`, the
* user's own `GIT_SSH_COMMAND` or a `GIT_SSH` already says, because on a machine with several ssh setups
* that choice is usually the one that works. ⚠️ The default still leaves a record: `StrictHostKeyChecking=accept-new`
* writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a
* write.
* - The timeout kills the PROCESS GROUP, not just the `git` parent. A fetch over http spawns
* `git-remote-http` children, and `execFile`'s `timeout` signals the parent only, so the answer can go
* out while a helper is still running, and on a timer that repeats every tick with nobody watching.
* `spawn` with `detached: true` puts the tree in its own group so the escalation (SIGTERM, then SIGKILL
* `FETCH_KILL_ESCALATION_MS` later) reaches the children too, which is why the negative pid is used
* rather than `child.kill()`. Same shape as `runGit` in git-clone.ts, without that module's slot queue:
* fetch is bounded by `FETCH_CONCURRENCY`, not by a clone queue.
* - Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`),
* and the browser writes it with `textContent` only.
*
* @module git-fetch
*/
import { execFile, spawn } from 'node:child_process';
import { basename, relative, resolve } from 'node:path';
import { promisify } from 'node:util';
import { classifyGitFailure, gitNonInteractiveEnv, sanitizeGitOutput, type GitFailureCode } from './git-clone.js';
import {
createLimiter,
DEFAULT_GIT_TIMEOUT_MS,
type Limiter,
resolveOverviewLimits,
resolveWorkspaceRepos,
type GitRunner,
} from './git-workspace-status.js';
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
const execFileAsync = promisify(execFile);
/**
* Git processes in flight at once, for one run, and for a whole scheduler tick, because the tick hands ONE
* bound to every run (see `createLimiter`): each fetch is one git process, and a folder can hold up to
* `MAX_REPOS_LIMIT`. The tick bounds its folders separately, because the same semaphore cannot serve both
* levels: a folder holding a slot would then wait for a slot its own fetch needs.
*/
export const FETCH_CONCURRENCY = 4;
/** A fetch on a busy remote can print a lot; a bound on what we will hold. */
const MAX_FETCH_OUTPUT_BYTES = 2 * 1024 * 1024;
/** git's stderr is kept as a bounded tail (as in git-clone.ts): enough for the reason, not the whole stream. */
const MAX_FETCH_STDERR_BYTES = 8_192;
/** A `git config --get` answer is one line; this is a sanity cap, not an expected size. */
const MAX_SSH_COMMAND_BYTES = 4_096;
/**
* How long the process group gets to answer SIGTERM before the same escalation goes to SIGKILL. Kept as a
* const so a test can say how long it is willing to wait for the group to actually die.
*/
export const FETCH_KILL_ESCALATION_MS = 3_000;
/**
* Config overrides set ahead of the argv, so an unattended fetch cannot do more than move refs: no
* fsmonitor hook, and no automatic maintenance or gc. A fetch is the git command that can run a
* repository's own hooks (`reference-transaction`, and `pre-auto-gc` through the maintenance that follows a
* fetch), and a gc started by a timer in a repository an agent is working in is exactly the kind of extra
* write this module must not do. Kept as a const so a test can pin it without spawning git.
*/
export const FETCH_CONFIG_ARGS = ['-c', 'core.fsmonitor=false', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0'];
/** The interval the user can turn on (Settings → Bottom bar, and Session Options → Git): bounds and default. */
export const DEFAULT_GIT_FETCH_INTERVAL_SECONDS = 300;
export const MIN_GIT_FETCH_INTERVAL_SECONDS = 30;
export const MAX_GIT_FETCH_INTERVAL_SECONDS = 3600;
export interface GitFetchSettings {
enabled: boolean;
intervalSeconds: number;
}
/**
* A settings object as the scheduler should read it: absent means OFF, because nothing runs on a timer
* until the user turns this on, and a number outside the schema's bounds is clamped rather than trusted.
* Pure, so the reading itself is testable without a settings file.
*/
export function gitFetchSettingsFrom(settings: Record<string, unknown> | undefined): GitFetchSettings {
const raw = settings?.gitFetchIntervalSeconds;
const seconds =
typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : DEFAULT_GIT_FETCH_INTERVAL_SECONDS;
return {
enabled: settings?.gitFetchEnabled === true,
intervalSeconds: Math.min(MAX_GIT_FETCH_INTERVAL_SECONDS, Math.max(MIN_GIT_FETCH_INTERVAL_SECONDS, seconds)),
};
}
/**
* The only git command this module runs. Kept as a function so a test can pin the verb: nothing here
* commits, checks out, merges or prunes, and no operand is interpolated: the repository is the process
* `cwd` and the argv is fixed.
*/
export function buildFetchArgs(): string[] {
return ['fetch', '--quiet'];
}
/**
* The `git` process also gets the `FETCH_CONFIG_ARGS` overrides ahead of this argv; they are kept separate
* so pinning the verb here does not have to say anything about maintenance, and so both can be tested
* without spawning.
*
* A repository with no remote is a fact, not a fault. Git's wordings for it differ by version (2.47
* exits quietly and this code is never reached, older git says "no default remote repository"), so the
* branch is pinned on literal fixtures and the code the panel shows is the code this writes.
*/
export type FetchFailureCode = GitFailureCode | 'NO_REMOTE';
export interface FetchFailure {
code: FetchFailureCode;
/** User-facing summary. */
message: string;
/** Tail of git's own stderr, control-stripped and credential-redacted. */
stderr: string;
}
/**
* A thrown git error as the panel should show it. `classifyGitFailure` (git-clone.ts) already knows the
* network reasons (auth, an unreachable host, a missing git), so they are reused here; only its timeout
* wording is written for clones, so the fetch path replaces it with what the user can actually do.
*/
export function describeFetchFailure(err: unknown): FetchFailure {
const e = err as { code?: unknown; stderr?: unknown; message?: string; killed?: boolean };
const stderr = typeof e.stderr === 'string' ? e.stderr : '';
const spawnError = typeof e.message === 'string' ? e.message : undefined;
const clean = sanitizeGitOutput(stderr);
const lower = `${clean}\n${spawnError ?? ''}`.toLowerCase();
if (/no default remote repository|no remote configured|nothing to fetch/.test(lower)) {
return {
code: 'NO_REMOTE',
message: 'This repository has no remote, so there is nothing to fetch.',
stderr: clean,
};
}
const timedOut = e.code === 'ETIMEDOUT' || e.killed === true;
const failure = classifyGitFailure(stderr, timedOut, spawnError);
if (failure.code === 'TIMEOUT')
failure.message = 'Git timed out. Raise “Git status: git timeout” in Settings → Bottom bar, or fetch from a shell.';
return failure;
}
/**
* The environment a fetch gets, as a pure decision so it can be pinned without spawning git.
*
* `gitNonInteractiveEnv` supplies the BatchMode default, and the default is only a default: git reads
* `GIT_SSH_COMMAND` from the environment ahead of the repository's `core.sshCommand`, so setting it would
* override a repository that already chose its own ssh (an alternative host, an identity file, a jump box)
* and break a setup that works today. The server's own `GIT_SSH` is the same kind of answer: git reads it
* after both of those (`GIT_SSH_COMMAND`, then `core.sshCommand`, then `GIT_SSH`), so a written default would
* hide it too. That is the only case where this module makes an ssh choice on the user's behalf, so it is the
* whole decision here: the caller supplies the value it read, and this decides between the user's
* environment and the repository's own answer.
*/
export function fetchEnvFor(base: NodeJS.ProcessEnv, repoSshCommand: string | null): NodeJS.ProcessEnv {
const env = gitNonInteractiveEnv(base);
// `gitNonInteractiveEnv` only writes the default when the environment had nothing, so this is exactly the
// case where it was a choice rather than a pass-through, and the repository's own answer, or a `GIT_SSH` in
// the environment, wins over it.
if (!base.GIT_SSH_COMMAND && (repoSshCommand || base.GIT_SSH)) delete env.GIT_SSH_COMMAND;
return env;
}
/**
* The repository's own `core.sshCommand`, or null. One bounded, read-only `git config --get` per fetch:
* git would honour that config itself if we did not write `GIT_SSH_COMMAND` over it, so honouring it means
* not writing one. `--get` reads local and global config, which is the value git would have used, and no
* hook or filter runs on the way to it.
*
* The read happens inside the same slot and the same wall-clock bound as the fetch it serves, so it cannot
* 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,
timeout: timeoutMs,
maxBuffer: MAX_SSH_COMMAND_BYTES,
env: gitNonInteractiveEnv(),
});
return stdout.trim() || null;
} catch {
// A repository with no such setting, or a read that could not finish: the default is what git would have
// used anyway, and a fetch must not fail because the check for a config value failed.
return null;
}
}
/**
* Fetch is allowed to write refs, so this runner does not carry `--no-optional-locks`: fetch takes a
* required lock anyway.
*
* `spawn`, not `execFile`, because the bound has to reach the whole tree (see the fileoverview): the
* caller is answered at the timeout, and the SIGTERM → SIGKILL escalation is deliberately NOT cancelled
* when the answer goes out, because the promise is a promise about the answer while the group still has to
* die. That is the difference from `runGit` in git-clone.ts, which disarms the escalation at `finish` and so
* can leave a `git-remote-http` child running after a timed-out call.
*
* The thrown error keeps the shape `describeFetchFailure` reads: `stderr` for the reason, `message` for a
* spawn failure (`spawn git ENOENT` → GIT_MISSING), `killed` and `code` for the timeout. A non-zero exit
* rejects the way `execFile` would (`code` is then the exit code), because resolving it would report a real
* failure as a clean run with an empty reason.
*
* `timeoutMs` bounds the `core.sshCommand` read and the fetch TOGETHER: the spawn is armed with what the read
* left, so a `git` that never answers costs one timeout, not two. A read that used the whole bound answers
* 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));
const remainingMs = timeoutMs - (Date.now() - startedAt);
if (remainingMs <= 0) {
throw Object.assign(new Error(`Command failed: git ${args.join(' ')}`), {
stderr: '',
killed: true,
code: 'ETIMEDOUT',
});
}
return new Promise<string>((resolve, reject) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn('git', [...FETCH_CONFIG_ARGS, ...args], {
cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
});
} catch (err) {
reject(err);
return;
}
let stdout = '';
let stderr = '';
let stdoutBytes = 0;
let timedOut = false;
let settled = false;
let killTimer: NodeJS.Timeout | undefined;
const killTree = (signal: NodeJS.Signals) => {
try {
if (child.pid) process.kill(-child.pid, signal);
} catch {
try {
child.kill(signal);
} catch {
/* already gone */
}
}
};
const fail = (spawnError?: string, exitCode?: number | null) => {
if (settled) return;
settled = true;
// Only the timeout timer is disarmed here. On a timeout the escalation is what the caller is waiting
// for, and cancelling it is the bug this runner exists to fix.
clearTimeout(timeoutTimer);
reject(
Object.assign(new Error(spawnError ?? `Command failed: git ${args.join(' ')}`), {
stderr,
killed: timedOut,
code: timedOut ? 'ETIMEDOUT' : (exitCode ?? undefined),
})
);
};
const timeoutTimer = setTimeout(() => {
timedOut = true;
killTree('SIGTERM');
killTimer = setTimeout(() => killTree('SIGKILL'), FETCH_KILL_ESCALATION_MS);
fail();
}, remainingMs);
child.stdout?.on('data', (chunk: Buffer) => {
stdoutBytes += chunk.length;
if (stdoutBytes <= MAX_FETCH_OUTPUT_BYTES) stdout += chunk.toString('utf-8');
});
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf-8');
if (stderr.length > MAX_FETCH_STDERR_BYTES * 2) stderr = stderr.slice(-MAX_FETCH_STDERR_BYTES);
});
const finish = (code: number | null) => {
if (settled) return;
// execFile's contract, which `describeFetchFailure` depends on: a non-zero exit rejects, so a real
// git failure keeps its reason in `stderr` instead of becoming a green row with an empty one. `null`
// means a signal, reachable only when something outside this runner killed the group: our own timeout
// has already answered by then, and `settled` above is what keeps that answer the answer.
if (code !== 0) return fail(undefined, code);
settled = true;
clearTimeout(timeoutTimer);
if (killTimer) clearTimeout(killTimer);
resolve(stdout);
};
child.on('error', (err) => fail(String(err)));
child.on('close', (code) => finish(code));
});
};
export interface GitFetchEntry {
/** Folder name of the repository (its root's basename), as the panel names it. */
name: string;
/** The repository root relative to the working directory: `.`, `..`, `api`, `apps/web`. */
path: string;
ok: boolean;
failure?: FetchFailure;
}
export interface GitFetchReport {
/** `ok` when at least one repository was fetched (or attempted); the other states are as in the overview. */
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
reason?: 'remote' | 'docker' | 'adopted';
error?: string;
/** One entry per repository fetched, in the same order the panel lists them. */
repos: GitFetchEntry[];
/** More than `repoLimit` repositories were found; only the first were fetched. */
reposTruncated: boolean;
/** The most repositories this fetch would list (the caller's setting, or `MAX_REPOS`). */
repoLimit?: number;
/** When the last repository finished, so the panel can say what the "behind" number is now based on. */
fetchedAt: number;
}
/**
* What one run did, in the shape a session keeps and the Session Options Git row shows. `trigger` says
* whether the user clicked or the interval fired, because a number the user did not ask for should read
* differently from one they did.
*/
/** What a run was, and why it happened. */
export type GitFetchTrigger = 'click' | 'interval';
export interface GitFetchStatus {
ranAt: number;
trigger: GitFetchTrigger;
/** As in the report: `ok`, or the state that stopped the run. */
state: GitFetchReport['state'];
/** Repositories this run covered. */
repos: number;
/** How many of them could not be fetched. */
failed: number;
/** The first failure, named: enough for one row without reprinting the whole list. */
firstFailure?: { name: string; code: FetchFailureCode };
}
/**
* One run, with the trigger that caused it. A joiner gets the run already going, so what it reports is that
* run's trigger rather than the one that made it ask: a click that joins an interval run did not fetch
* anything itself, and the row the panel shows has to describe the fetch that actually happened.
*/
export interface GitFetchRun {
report: GitFetchReport;
trigger: GitFetchTrigger;
}
export function gitFetchStatusFrom(report: GitFetchReport, trigger: GitFetchTrigger): GitFetchStatus {
const failed = report.repos.filter((r) => !r.ok);
const status: GitFetchStatus = {
ranAt: report.fetchedAt,
trigger,
state: report.state,
repos: report.repos.length,
failed: failed.length,
};
const first = failed[0];
if (first?.failure) status.firstFailure = { name: first.name, code: first.failure.code };
return status;
}
/** Options for the fetch path: the same repository rules and limits as an overview, without its cache clock. */
export interface GitFetchOptions {
git?: GitRunner;
home?: string;
dockerWorkspaces?: string[];
maxRepos?: number;
timeoutMs?: number;
/**
* The shared slot count for a whole scheduler tick. Passed in so `FETCH_CONCURRENCY` bounds the tick
* rather than each folder separately; without it, a tick over more than `FETCH_CONCURRENCY` folders could
* have that many git processes going at once inside each one.
*/
limiter?: Limiter;
}
export const emptyReport = (state: GitFetchReport['state'], extra: Partial<GitFetchReport> = {}): GitFetchReport => ({
state,
repos: [],
reposTruncated: false,
fetchedAt: Date.now(),
...extra,
});
async function fetchOne(dir: string, git: GitRunner, timeoutMs: number): Promise<FetchFailure | null> {
try {
await git(dir, buildFetchArgs(), { timeoutMs });
return null;
} catch (err) {
return describeFetchFailure(err);
}
}
/**
* Fetch every repository the panel would show for `cwd`, through the same resolver so the two cannot
* disagree. A repository that failed keeps its row with the reason, exactly as an unreadable repository
* stays in the status list: the panel must show that something was not fetched rather than quietly skip it.
*/
export async function fetchGitWorkspaceOverview(cwd: string, opts: GitFetchOptions = {}): Promise<GitFetchReport> {
const where = await resolveWorkspaceRepos(cwd, opts);
if (where.kind === 'docker') return emptyReport('unsupported', { reason: 'docker' });
if (where.kind === 'error') return emptyReport('error', { error: where.error });
const dirs = where.kind === 'enclosing' ? [where.root] : where.dirs;
if (!dirs.length) return emptyReport('not-a-repo');
const { timeoutMs } = resolveOverviewLimits(opts);
const runner = opts.git ?? runFetch;
const run = opts.limiter ?? createLimiter(FETCH_CONCURRENCY);
const failures = await run(dirs, (dir) => fetchOne(dir, runner, timeoutMs));
const repos: GitFetchEntry[] = dirs.map((dir, i) => {
const base = { name: basename(dir), path: relative(cwd, dir) || '.' };
const failure = failures[i];
return failure ? { ...base, ok: false, failure } : { ...base, ok: true };
});
return {
state: 'ok',
repos,
reposTruncated: where.kind === 'children' ? where.truncated : false,
repoLimit: resolveOverviewLimits(opts).maxRepos,
fetchedAt: Date.now(),
};
}
/**
* Runs already going, keyed by the resolved folder. A request for a folder another run is already working on
* JOINS that run instead of starting a second `git fetch` in the same clone: two fetches there collide on
* git's ref lock (`cannot lock ref 'refs/remotes/origin/main'`), which is what a click during a tick would
* otherwise do. A joiner gets the run already going's answer, including its limits and its trigger: the point
* is not to fetch twice, not to fetch with the freshest options, and a click that fetched nothing cannot
* report itself as the fetch.
*
* Keyed by `resolve`, not `realpath`, because `realpath` is itself one of the unbounded fs calls rule #516
* bounds for user-chosen paths; the scheduler probes the folder bounded and calls this. Two paths that
* resolve the same are the same folder here; a workspace reached through a symlink resolves to the link, so
* a link and its target can be fetched by two runs, the accepted trade for keeping the probe bounded.
*/
const lane = new Map<string, Promise<GitFetchRun>>();
export async function fetchWorkspaceOnce(
folder: string,
opts: GitFetchOptions = {},
trigger: GitFetchTrigger
): Promise<GitFetchRun> {
const key = resolve(folder);
const going = lane.get(key);
// The joiner is handed the run already going as it is, trigger included: it did not cause that fetch, so it
// must not report itself as having.
if (going) return going;
const run: Promise<GitFetchRun> = fetchGitWorkspaceOverview(key, opts)
.then((report) => ({ report, trigger }))
.finally(() => {
// Only the run that is still the lane's own: a later run for the same folder must not be dropped by an
// earlier one finishing late.
if (lane.get(key) === run) lane.delete(key);
});
lane.set(key, run);
return run;
}
/** Test seam: an in-memory lane is not cleared by stopping the scheduler, so a test has to clear it itself. */
export function clearGitFetchLane(): void {
lane.clear();
}