Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1320,7 +1320,9 @@ Creation prints JSON with the ID and the detached, locked checkout path under
`.worktrees/`, and records the `--scratch` paths. Names are unique per
repository; where several repositories use one, the other commands take the ID. `run` restores a released
checkout, runs the command there, and once it exits kills any descendants it
left behind. When no other run remains in the checkout, it releases it: the
left behind. The command gets Quest's environment without Git's
repository-location variables, such as `GIT_DIR` and `GIT_INDEX_FILE`, which a
Git hook would otherwise point at the repository it ran in; `-c` config is kept. When no other run remains in the checkout, it releases it: the
declared scratch is deleted and the checkout keeps only its source. Put a whole
job, such as an agent session or a script that installs, builds, and tests, in
one run, since the next run starts without scratch.
Expand Down
1 change: 0 additions & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ earlier "Later work" milestone became m2.

- [Open kix.quest](/quest/m1/open.md) - the Worker moves to kix.quest and any owner may enroll
- [Release with worktree hooks](/quest/m1/release.md) - tag a release that has `quest worktree hook`, so SETUP.md's release routes work
- [Git location variables](/quest/m1/git-env.md) - every Git call on an explicit path ignores a hook's inherited `GIT_DIR` and index
- [Kix for any repository](/quest/m1/kix/README.md) - onboarding, read-only CI checks, contributor sandboxing, and the maintainer guide
- [Quest log theme](/quest/m1/theme/README.md) - an MMO-style identity, copy, board, landing page, and CLI output for the demo and launch
- [Launch material](/quest/m1/launch.md) - README, quickstart, demo, comparison, and launch-post drafts
Expand Down
60 changes: 0 additions & 60 deletions quest/m1/git-env.md

This file was deleted.

17 changes: 5 additions & 12 deletions src/cli/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,7 @@
// variables, and reads the agent's session, reply, and cost. Its own arguments
// name the harness, worktree, key, model, effort, and permission mode, so a
// permission classifier reading the call sees the whole launch.
import {
type ChildProcess,
type ChildProcessWithoutNullStreams,
execFileSync,
spawn,
spawnSync,
} from "node:child_process";
import { type ChildProcess, type ChildProcessWithoutNullStreams, spawn, spawnSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import {
chmodSync,
Expand All @@ -31,6 +25,7 @@ import { constants, tmpdir } from "node:os";
import { join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";

import { discoveryEnv, git } from "./git";
import { alive, programCommand, programEnvironment, requireProgram, signalProcess } from "./program";
import { systemdScopes } from "./supervise";
import { Worktrees, save } from "./worktree";
Expand Down Expand Up @@ -291,10 +286,7 @@ const digest = (value: string) => createHash("sha256").update(value).digest("hex
export function spawnDirectory(base: string, worktree: string): string {
let common: string;
try {
common = execFileSync("git", ["-C", worktree, "rev-parse", "--path-format=absolute", "--git-common-dir"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
common = git(worktree, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
} catch {
throw new AgentInputError(`${worktree} is not inside a Git repository`);
}
Expand Down Expand Up @@ -677,7 +669,8 @@ export async function runAgent(
}),
);
};
const env = programEnvironment(launchEnvironment(deps.env, harness.keyVariable, key));
// The agent works in its own checkout, so a location inherited from a Git hook would name the wrong one.
const env = discoveryEnv(programEnvironment(launchEnvironment(deps.env, harness.keyVariable, key)));
if (command === undefined) {
supervised = checkouts!.run(checkout!, [program, ...args], { env, spawned: connect });
supervised.catch(reject);
Expand Down
26 changes: 5 additions & 21 deletions src/cli/claims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
// PR's head, or an open PR from a branch of that name in any repository.
// Commands that must not disturb held work look here first, and `quest start`
// records a claim the same way, judging readiness by the upstream tree.
import { execFileSync } from "node:child_process";

import { handedBack, landed } from "../core";
import { git } from "./git";
import {
type GitHubDependencies,
type Repository,
Expand Down Expand Up @@ -99,7 +98,7 @@ export async function upstream(root: string, remote: Remote, deps: ClaimDependen
? self.repository
: (await repositoryInfo(client, pushed)).repository;
// The README where this checkout's tree sits, which may be below the top level.
const prefix = gitOutput(root, ["rev-parse", "--show-prefix"]);
const prefix = git(root, ["rev-parse", "--show-prefix"]);
let up = self;
while (up.parent !== null && (await has(client, up.parent, `${prefix}quest/README.md`)))
up = await repositoryInfo(client, up.parent);
Expand Down Expand Up @@ -154,7 +153,7 @@ export function remoteBranches(root: string, remote: string): Map<string, string
/** The refs on `remote` matching `patterns`, by name, with their tips. */
function listRemote(root: string, remote: string, patterns: string[]): Map<string, string> {
const refs = new Map<string, string>();
for (const line of gitOutput(root, ["ls-remote", remote, ...patterns]).split("\n")) {
for (const line of git(root, ["ls-remote", remote, ...patterns]).split("\n")) {
if (line === "") continue;
const [sha, ref] = line.split("\t");
refs.set(ref, sha);
Expand Down Expand Up @@ -341,23 +340,8 @@ export function upstreamRefs(root: string, remote: string): UpstreamRefs {
/** Make `sha` from `remote` available locally. */
export function ensure(root: string, remote: string, sha: string): void {
try {
gitOutput(root, ["cat-file", "-e", `${sha}^{commit}`]);
git(root, ["cat-file", "-e", `${sha}^{commit}`]);
} catch {
gitOutput(root, ["fetch", "--quiet", "--no-tags", "--no-write-fetch-head", remote, sha]);
}
}

/** Run git in `root` and return its trimmed stdout; an unset config key is empty rather than an error. */
export function gitOutput(root: string, args: string[]): string {
try {
return execFileSync("git", ["-C", root, ...args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
}).trim();
} catch (error) {
if (args[0] === "config" && (error as { status?: number }).status === 1) return "";
const stderr = String((error as { stderr?: unknown }).stderr ?? "").trim();
throw new Error(`git ${args[0]} failed${stderr ? `: ${stderr}` : ""}`);
git(root, ["fetch", "--quiet", "--no-tags", "--no-write-fetch-head", remote, sha]);
}
}
48 changes: 48 additions & 0 deletions src/cli/git.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Git run on an explicit path. Quest may itself run from a Git hook or `git submodule foreach`, whose
// repository-local variables would otherwise point every such call at the repository that started it.
import { execFileSync, spawnSync } from "node:child_process";

let located: string[] | undefined;
/**
* `env` without the repository-local variables a Git hook or `git submodule foreach` exports, such as
* GIT_DIR, GIT_WORK_TREE and GIT_INDEX_FILE, which override discovery from a path. Git drops the same
* ones when it enters another repository, and keeps `-c` config.
*/
export function discoveryEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
located ??= execFileSync("git", ["rev-parse", "--local-env-vars"], { encoding: "utf8" })
.split("\n")
.filter((name) => name && name !== "GIT_CONFIG_PARAMETERS" && name !== "GIT_CONFIG_COUNT");
const result = { ...env };
for (const name of located) delete result[name];
return result;
}

/** A Git command that exited unsuccessfully, with what it printed. */
export class GitError extends Error {
constructor(
command: string,
readonly status: number | null,
readonly stdout: string,
readonly stderr: string,
) {
super(`git ${command} failed${stderr.trim() ? `: ${stderr.trim()}` : ""}`);
}
}

/**
* Git run in the repository at `root`, without prompting, returning its output without trailing newlines.
* A `config` lookup that matches nothing is empty rather than an error.
*/
export function git(root: string, args: string[], input?: string): string {
const result = spawnSync("git", ["-C", root, ...args], {
encoding: "utf8",
env: { ...discoveryEnv(), GIT_TERMINAL_PROMPT: "0" },
input,
maxBuffer: 1 << 30,
stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
});
if (result.error) throw result.error;
if (result.status === 0) return result.stdout.replace(/\n+$/, "");
if (result.status === 1 && args[0] === "config") return "";
throw new GitError(args[0], result.status, result.stdout, result.stderr);
}
11 changes: 1 addition & 10 deletions src/cli/github.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// The GitHub client shared by the commands that talk to GitHub, outside the repository-independent core.
import { execFileSync } from "node:child_process";
import { setTimeout as sleep } from "node:timers/promises";
import { git } from "./git";

const API = "https://api.github.com";
const MAX_SLEEP = 2_147_483_647;
Expand Down Expand Up @@ -149,16 +150,6 @@ function rewritten(root: string, name: string): { fetch: string; push: string }
return { fetch: urls.fetch, push: urls.push ?? urls.fetch };
}

/** Run git in `root` for its output; a lookup that matches nothing, which exits 1, is empty. */
function git(root: string, args: string[]): string {
try {
return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
} catch (error) {
if (args[0] === "config" && (error as { status?: number }).status === 1) return "";
throw error;
}
}

/** Read `name` like `readRemote`, failing when there is no such remote. */
export function requireRemote(root: string, name: string): Remote {
const remote = readRemote(root, name);
Expand Down
2 changes: 2 additions & 0 deletions src/cli/land-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
requireRemote,
string,
} from "./github";
import { discoveryEnv } from "./git";
import { contains, git, gitEnvironment, tryGit } from "./land";
import { Refusal } from "./start";

Expand Down Expand Up @@ -180,6 +181,7 @@ function userGit(root: string, args: string[], input?: string): string {
cwd: root,
input,
encoding: "utf8",
env: discoveryEnv(),
});
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr.trim()}`);
Expand Down
2 changes: 2 additions & 0 deletions src/cli/land-fix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { spawn, spawnSync } from "node:child_process";
import { constants, closeSync, existsSync, fstatSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import prompt from "../../assets/land-fix.md";
import { discoveryEnv } from "./git";
import { programEnvironment, requireProgram } from "./program";

export class FixerAgentError extends Error {
Expand Down Expand Up @@ -229,6 +230,7 @@ export async function fix(
const head = spawnSync("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${ref}^{commit}`], {
cwd: root,
encoding: "utf8",
env: discoveryEnv(),
}).stdout.trim();
if (!/^[a-f0-9]{40,64}$/.test(head)) throw new Error(`fix needs a ${ref} branch`);
return `- #${number}: branch ${ref} at ${head}`;
Expand Down
7 changes: 6 additions & 1 deletion src/cli/landed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { spawnSync } from "node:child_process";

import { type Finding, branchQuest } from "../core";
import { discoveryEnv } from "./git";

/**
* A finding when `branch` (default: the current branch) is a quest's branch
Expand Down Expand Up @@ -52,7 +53,11 @@ function currentBranch(root: string): string {
}

function git(root: string, args: string[]): { status: number | null; stdout: string; stderr: string } {
const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
const result = spawnSync("git", ["-C", root, ...args], {
encoding: "utf8",
env: discoveryEnv(),
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error) throw new Error(`running git: ${result.error.message}`);
return result;
}
3 changes: 2 additions & 1 deletion src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,7 +756,8 @@ export function main(argv: string[], out: Output): number | Promise<number> {
.command("run")
.summary("Run a command in a checkout, then release it once every process stops")
.description(
"Run a command in the checkout, restoring it first when released. The command and its descendants " +
"Run a command in the checkout, restoring it first when released, without the repository-location " +
"variables, such as GIT_DIR, that a Git hook exports. The command and its descendants " +
"run in a new, delegated systemd user scope; once the command exits, descendants still in the scope " +
"are killed. A `quest gate` inside it owns its command through a child cgroup of the scope. " +
"When no other supervised run remains, the scratch declared at creation is pruned. Put the whole job, " +
Expand Down
Loading
Loading