cli, seat, harness: a project clone in each role workspace (#1585)
Row 91, round 3. Darkwing built it, Dewey approved it (comment 28150) on manifest ca570607 (8 paths, base2b2a9e14). At each launch the host makes or updates a git clone of the project in the role's workspace, and the session prompt names each clone. Round 3 closes Filbert's round 2 escape: a clone with a submodule is kept as it is, and no host git recurses into one (decision 130 thread). Live at the next bus restart; the prompt lines appear only when the launcher passes checkouts. Sage gate onfc6d5822plus the patch, 14:18:25Z to 14:22:27Z: bus 115, business 60, cli 131, control-board 124, conversation 182, discord 178, harness 67, ledger 78, mosaic 69, queue 150, runs 43, seat 89, tasks 89, webui 104, all fail 0. Every scripts/test-*.sh exited 0; test-release ran 14 cases. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -233,6 +233,62 @@ The sandbox doesn't confine the network, abstract Unix sockets, or the
|
||||
user: a session runs as the host's UID. `packages/harness/README.md`
|
||||
("Limits") says what that leaves open.
|
||||
|
||||
### The project clones (#1585)
|
||||
|
||||
An instance's workspace holds one git clone per project in the business
|
||||
file, at `<workspace>/<project id>/`. The launcher calls `ensureCheckouts`
|
||||
(`src/checkout.mjs`) at each launch, before it builds the bundle, so a
|
||||
session starts in the project and not beside it. The host runs git here,
|
||||
outside the sandbox:
|
||||
|
||||
| The clone | What the host does | Outcome |
|
||||
|---|---|---|
|
||||
| doesn't exist | `git clone --no-hardlinks --template=` of the root, at the branch the root has checked out, into `.<project>.clone-<hex>` beside it, then renamed into place | `cloned` |
|
||||
| is clean, on the root's branch | fetches its origin and fast-forwards | `updated`, or `current` when nothing moved |
|
||||
| anything else | nothing | `kept`, with a reason |
|
||||
|
||||
The host never resets, cleans, deletes or force-updates a clone, and never
|
||||
writes the root. A clone's origin is the root's path; no credential or
|
||||
forge remote goes into it, and the root's own remotes and config don't
|
||||
reach it.
|
||||
|
||||
A kept clone's reason is one of:
|
||||
|
||||
| Reason | The clone |
|
||||
|---|---|
|
||||
| `not-a-clone` | isn't a directory with a `.git` directory; or under `.git` there is a symlink, a special file, a file with a second hard link, a `commondir` or an `objects/info/alternates` |
|
||||
| `hooks-present` | has a file in `.git/hooks` |
|
||||
| `submodule` | has a `.git/modules`, or a gitlink (a submodule entry) in its index |
|
||||
| `config-changed` | has a `.git/config` that isn't a regular file of at most 64 KiB, or that holds a key a clone doesn't write: anything beyond `core.repositoryformatversion`, `core.filemode`, `core.bare`, `core.logallrefupdates`, the origin's one URL (the root) and fetch line, and `branch.<name>.remote` (`origin`) and `.merge` |
|
||||
| `root-unreadable`, `root-detached` | is fine, but the root can't be read or has no branch checked out |
|
||||
| `other-branch` | is on another branch, or on none |
|
||||
| `dirty` | has a change, staged or not, or an untracked file |
|
||||
| `status-failed`, `fetch-failed` | made `git ls-files`, `git status` or the fetch fail |
|
||||
| `not-fast-forward` | has commits the root's branch doesn't |
|
||||
|
||||
The session can write its clone, so the host treats every clone as
|
||||
hostile. The checks above run before any git command in an existing clone,
|
||||
and git doesn't run in a clone that fails one. Every git call also runs
|
||||
with only the host's `PATH`, `HOME` at a directory that doesn't exist, no
|
||||
system or global config, hooks at `/dev/null`, `core.fsmonitor` off and
|
||||
only the `file` transport. A submodule has its own config, which none of
|
||||
those checks reads and which can name a filter that `git status` would run,
|
||||
so the host never recurses into one: a clone with a submodule is kept, and
|
||||
`status` runs with `--ignore-submodules=all` and every call with
|
||||
`submodule.recurse=false`. The root is read with `GIT_CEILING_DIRECTORIES`
|
||||
at its parent, so a root that isn't a repository is never taken for the
|
||||
one above it. Each git call is killed after 100 s.
|
||||
|
||||
The checks hold only while nothing else writes the clone. Each workspace
|
||||
is one instance's, and the launcher refuses `instance-running` before it
|
||||
gets here, so no session of that instance runs while the host checks and
|
||||
updates its clone.
|
||||
|
||||
A missing clone that can't be made (the root can't be read, has no branch,
|
||||
or the clone fails) rejects with a `CheckoutError` naming the project, and
|
||||
the temp directory is removed. The launcher refuses the launch with
|
||||
`checkout-failed` (`packages/cli/README.md`, "Sessions").
|
||||
|
||||
## Exit codes
|
||||
|
||||
`launch` exits with the launch script's own code once the script runs.
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
// The project checkouts in a role workspace (#1585). At every launch the
|
||||
// bus host makes sure each project of the business file has a git clone at
|
||||
// `<workspace>/<project id>/`, before it builds the bundle. The host runs
|
||||
// git here, outside the session's sandbox:
|
||||
//
|
||||
// no clone yet `git clone --no-hardlinks` of the project's root, at
|
||||
// the branch the root has checked out, into a temp
|
||||
// directory beside it and then renamed into place; a
|
||||
// clone that fails is removed and refuses the launch
|
||||
// a clean clone on fetched from its origin and fast-forwarded
|
||||
// the root's branch
|
||||
// anything else left exactly as it is, with the reason
|
||||
//
|
||||
// The host never resets, cleans, deletes or force-updates a clone, and
|
||||
// never writes the root. A clone's origin is the root's path; no credential
|
||||
// or forge remote goes into it.
|
||||
//
|
||||
// The session can write its clone, so the host treats every clone as
|
||||
// hostile. A git config key, a hook or a redirected .git would otherwise
|
||||
// run a command or write a file outside the sandbox when the host runs git
|
||||
// there. So every git call gets an environment without the system and
|
||||
// global config, hooks pointed at /dev/null, fsmonitor off and only local
|
||||
// transports. And before git runs in an existing clone, the host checks
|
||||
// that the clone and its .git are real directories, that nothing under
|
||||
// .git is a symlink, a special file or a file with a second hard link,
|
||||
// that no commondir or alternates file sends git elsewhere, that
|
||||
// .git/hooks is empty, and that .git/config is a small regular file
|
||||
// holding only what a clone writes (allowed() below). A clone that fails a
|
||||
// check is kept, and git doesn't run in it.
|
||||
//
|
||||
// Those checks cover the clone's own .git and nothing else. A submodule
|
||||
// has its own config, which can name a filter driver, and git status
|
||||
// recurses into a populated submodule by running a second git in it. So
|
||||
// a clone with a gitlink in its index or anything under .git/modules is
|
||||
// kept too, and no host git recurses into a submodule: status ignores
|
||||
// them and submodule.recurse is off for every call.
|
||||
//
|
||||
// The checks hold only while nothing else writes the clone. The launcher
|
||||
// calls this for an instance only when no session of that instance runs
|
||||
// (its instance-running refusal), and each workspace is one instance's.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { lstatSync, readdirSync, renameSync, rmSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
export const GIT_TIMEOUT_MS = 100_000;
|
||||
// A clone's config is a few hundred bytes, a little more per branch.
|
||||
export const CONFIG_MAX_BYTES = 64 * 1024;
|
||||
|
||||
export class CheckoutError extends Error {
|
||||
constructor(project, message) {
|
||||
super(message);
|
||||
this.project = project;
|
||||
}
|
||||
}
|
||||
|
||||
// Ahead of every git subcommand. Command-line config wins over any file.
|
||||
const SAFE = [
|
||||
"-c", "core.hooksPath=/dev/null",
|
||||
"-c", "core.fsmonitor=false",
|
||||
"-c", "protocol.allow=never",
|
||||
"-c", "protocol.file.allow=always",
|
||||
"-c", "submodule.recurse=false",
|
||||
];
|
||||
|
||||
// The environment git runs with: the host's PATH and nothing else of it.
|
||||
// HOME doesn't exist, so no user file (attributes, ignore) is read either.
|
||||
export function gitEnv(env = process.env) {
|
||||
return {
|
||||
PATH: env.PATH ?? "/usr/bin:/bin",
|
||||
HOME: "/nonexistent",
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_CONFIG_GLOBAL: "/dev/null",
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
GIT_MERGE_AUTOEDIT: "no",
|
||||
LC_ALL: "C",
|
||||
};
|
||||
}
|
||||
|
||||
// Async, so a long clone doesn't hold up the rest of the bus host.
|
||||
function git(args, env, extra = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("git", [...SAFE, ...args], { env: { ...gitEnv(env), ...extra }, timeout: GIT_TIMEOUT_MS, killSignal: "SIGKILL", stdio: ["ignore", "pipe", "pipe"] });
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.stdout.setEncoding("utf8").on("data", (d) => (out += d));
|
||||
child.stderr.setEncoding("utf8").on("data", (d) => (err += d));
|
||||
// A git that can't start may not emit close, so error settles it too.
|
||||
child.on("error", (e) => resolve({ ok: false, out, why: e.code ?? e.message }));
|
||||
child.on("close", (code, signal) => resolve({ ok: code === 0, out, why: signal ? `killed by ${signal}` : err.trim().split("\n").pop() || `exit ${code}` }));
|
||||
});
|
||||
}
|
||||
|
||||
const inClone = (dest) => [`--git-dir=${join(dest, ".git")}`, `--work-tree=${dest}`];
|
||||
|
||||
// The keys `git clone` writes, and what each may hold. branch.<name>.remote
|
||||
// and .merge are the ones `git checkout -b`, `git branch -u` and the like
|
||||
// add. Every other key keeps the clone.
|
||||
const FETCH = "+refs/heads/*:refs/remotes/origin/*";
|
||||
function allowed(key, value, root) {
|
||||
const branch = /^branch\..+\.(remote|merge)$/.exec(key);
|
||||
if (branch) return branch[1] === "remote" ? value === "origin" : /^refs\/heads\/./.test(value ?? "");
|
||||
switch (key) {
|
||||
case "core.repositoryformatversion": return value === "0";
|
||||
case "core.filemode": return value === "true" || value === "false";
|
||||
case "core.bare": return value === "false";
|
||||
case "core.logallrefupdates": return value === "true";
|
||||
case "remote.origin.url": return value === root;
|
||||
case "remote.origin.fetch": return value === FETCH;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Why the clone at `dest` isn't safe for the host to run git in, or null.
|
||||
async function unsafe(dest, root, env) {
|
||||
const isDir = (p) => {
|
||||
try {
|
||||
return lstatSync(p).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if (!isDir(dest) || !isDir(join(dest, ".git"))) return "not-a-clone";
|
||||
// A second hard link would let a host write through to a file outside.
|
||||
const walk = (dir) => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const st = lstatSync(join(dir, name));
|
||||
if (st.isDirectory()) {
|
||||
if (walk(join(dir, name))) return true;
|
||||
} else if (!st.isFile() || st.nlink !== 1) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
try {
|
||||
if (walk(join(dest, ".git"))) return "not-a-clone";
|
||||
} catch {
|
||||
return "not-a-clone";
|
||||
}
|
||||
// A clone never has .git/modules; `git submodule` in the session makes one.
|
||||
try {
|
||||
lstatSync(join(dest, ".git", "modules"));
|
||||
return "submodule";
|
||||
} catch {}
|
||||
for (const f of ["commondir", join("objects", "info", "alternates")]) {
|
||||
try {
|
||||
lstatSync(join(dest, ".git", f));
|
||||
return "not-a-clone";
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
if (readdirSync(join(dest, ".git", "hooks")).length > 0) return "hooks-present";
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOENT") return "not-a-clone";
|
||||
}
|
||||
try {
|
||||
const st = lstatSync(join(dest, ".git", "config"));
|
||||
if (!st.isFile() || st.size > CONFIG_MAX_BYTES) return "config-changed";
|
||||
} catch {
|
||||
return "config-changed";
|
||||
}
|
||||
const list = await git(["config", "--file", join(dest, ".git", "config"), "--list", "--null"], env);
|
||||
if (!list.ok) return "config-changed";
|
||||
const seen = new Set();
|
||||
for (const entry of list.out.split("\0").filter(Boolean)) {
|
||||
const nl = entry.indexOf("\n");
|
||||
const [key, value] = nl < 0 ? [entry, null] : [entry.slice(0, nl), entry.slice(nl + 1)];
|
||||
if (seen.has(key) || !allowed(key, value, root)) return "config-changed";
|
||||
seen.add(key);
|
||||
}
|
||||
if (!seen.has("remote.origin.url")) return "config-changed";
|
||||
return null;
|
||||
}
|
||||
|
||||
// The branch the root has checked out, null when it has none, or an error.
|
||||
// The ceiling stops git finding a repository above a root that isn't one.
|
||||
async function rootBranch(root, env) {
|
||||
const ceiling = { GIT_CEILING_DIRECTORIES: dirname(root) };
|
||||
const r = await git(["-C", root, "symbolic-ref", "-q", "--short", "HEAD"], env, ceiling);
|
||||
if (r.ok) return { branch: r.out.trim() };
|
||||
// symbolic-ref -q exits 1 with no output on a detached HEAD.
|
||||
const head = await git(["-C", root, "rev-parse", "--verify", "-q", "HEAD"], env, ceiling);
|
||||
return head.ok ? { branch: null } : { error: r.why };
|
||||
}
|
||||
|
||||
async function clone(project, root, workspace, dest, env) {
|
||||
const at = await rootBranch(root, env);
|
||||
if (at.error) throw new CheckoutError(project, `the root ${root} can't be read: ${at.error}`);
|
||||
if (!at.branch) throw new CheckoutError(project, `the root ${root} has no branch checked out`);
|
||||
// A project id never starts with a dot, so the temp name is no project's.
|
||||
const tmp = join(workspace, `.${project}.clone-${randomBytes(4).toString("hex")}`);
|
||||
try {
|
||||
const r = await git(["clone", "-q", "--no-hardlinks", "--template=", "--branch", at.branch, "--", root, tmp], env);
|
||||
if (!r.ok) throw new CheckoutError(project, `git clone of ${root} failed: ${r.why}`);
|
||||
renameSync(tmp, dest);
|
||||
} catch (e) {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
if (e instanceof CheckoutError) throw e;
|
||||
throw new CheckoutError(project, `the clone of ${root} couldn't be put in place: ${e.message}`);
|
||||
}
|
||||
return { outcome: "cloned", branch: at.branch };
|
||||
}
|
||||
|
||||
async function update(root, dest, env) {
|
||||
const problem = await unsafe(dest, root, env);
|
||||
if (problem) return { outcome: "kept", reason: problem };
|
||||
// ls-files reads only the index: no filter, hook or submodule runs.
|
||||
const staged = await git([...inClone(dest), "ls-files", "--stage", "-z"], env);
|
||||
if (!staged.ok) return { outcome: "kept", reason: "status-failed" };
|
||||
if (staged.out.split("\0").some((e) => e.startsWith("160000 "))) return { outcome: "kept", reason: "submodule" };
|
||||
const at = await rootBranch(root, env);
|
||||
if (at.error) return { outcome: "kept", reason: "root-unreadable" };
|
||||
if (!at.branch) return { outcome: "kept", reason: "root-detached" };
|
||||
const head = await git([...inClone(dest), "symbolic-ref", "-q", "--short", "HEAD"], env);
|
||||
if (!head.ok || head.out.trim() !== at.branch) return { outcome: "kept", reason: "other-branch" };
|
||||
const status = await git(["--no-optional-locks", ...inClone(dest), "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=all"], env);
|
||||
if (!status.ok) return { outcome: "kept", reason: "status-failed" };
|
||||
if (status.out !== "") return { outcome: "kept", reason: "dirty" };
|
||||
const before = (await git([...inClone(dest), "rev-parse", "HEAD"], env)).out.trim();
|
||||
if (!(await git([...inClone(dest), "fetch", "-q", "--no-tags", "--no-recurse-submodules", "origin"], env)).ok) return { outcome: "kept", reason: "fetch-failed" };
|
||||
if (!(await git([...inClone(dest), "merge", "-q", "--ff-only", "--no-edit", `refs/remotes/origin/${at.branch}`], env)).ok) return { outcome: "kept", reason: "not-fast-forward" };
|
||||
const after = (await git([...inClone(dest), "rev-parse", "HEAD"], env)).out.trim();
|
||||
return { outcome: before === after ? "current" : "updated", branch: at.branch };
|
||||
}
|
||||
|
||||
// await ensureCheckouts({ workspace, projects, env }) for the business file's
|
||||
// projects ({ <id>: { root } }). Returns one { project, path, outcome,
|
||||
// reason?, branch? } per project, outcome cloned, updated, current or kept.
|
||||
// Rejects with CheckoutError when a missing clone can't be made.
|
||||
export async function ensureCheckouts({ workspace, projects, env = process.env }) {
|
||||
const out = [];
|
||||
for (const [project, { root }] of Object.entries(projects)) {
|
||||
const dest = join(workspace, project);
|
||||
let exists = true;
|
||||
try {
|
||||
lstatSync(dest);
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOENT") throw new CheckoutError(project, `${dest} can't be read: ${e.code}`);
|
||||
exists = false;
|
||||
}
|
||||
out.push({ project, path: dest, ...(exists ? await update(root, dest, env) : await clone(project, root, workspace, dest, env)) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
// The project checkouts in a role workspace (src/checkout.mjs), against
|
||||
// real git repositories in a temp directory. Every case also checks that
|
||||
// the root's files and refs are byte for byte what they were.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { CONFIG_MAX_BYTES, CheckoutError, ensureCheckouts, gitEnv } from "../src/checkout.mjs";
|
||||
|
||||
function tmp(t) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "mosaic-checkout-"));
|
||||
t.after(() => {
|
||||
spawnSync("chmod", ["-R", "u+rwx", dir]);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
return dir;
|
||||
}
|
||||
|
||||
// git for the test's own setup, with no user config.
|
||||
function git(cwd, ...args) {
|
||||
const r = spawnSync("git", ["-c", "user.name=t", "-c", "[email protected]", ...args], { cwd, encoding: "utf8", env: gitEnv() });
|
||||
assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
// A repository on `main` with one commit.
|
||||
function repo(dir, name = "root") {
|
||||
const root = join(dir, name);
|
||||
mkdirSync(root);
|
||||
git(root, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(root, "README"), "one\n");
|
||||
git(root, "add", "README");
|
||||
git(root, "commit", "-q", "-m", "one");
|
||||
return root;
|
||||
}
|
||||
|
||||
function commit(root, text) {
|
||||
writeFileSync(join(root, "README"), text);
|
||||
git(root, "commit", "-q", "-am", text.trim());
|
||||
return git(root, "rev-parse", "HEAD");
|
||||
}
|
||||
|
||||
// Every path under `dir` with its type, mode, link count and contents or
|
||||
// link target.
|
||||
function fingerprint(dir) {
|
||||
const h = createHash("sha256");
|
||||
const walk = (d) => {
|
||||
for (const name of readdirSync(d).sort()) {
|
||||
const p = join(d, name);
|
||||
const st = lstatSync(p);
|
||||
h.update(`${p}\0${st.mode}\0${st.nlink}\0`);
|
||||
if (st.isDirectory()) walk(p);
|
||||
else if (st.isSymbolicLink()) h.update(readlinkSync(p));
|
||||
else if (st.isFile()) h.update(readFileSync(p));
|
||||
}
|
||||
};
|
||||
walk(dir);
|
||||
return h.digest("hex");
|
||||
}
|
||||
|
||||
// A root, an empty workspace, and `ensure()` for the one project `stack`.
|
||||
function world(t) {
|
||||
const dir = tmp(t);
|
||||
const root = repo(dir);
|
||||
const workspace = join(dir, "ws");
|
||||
mkdirSync(workspace, { mode: 0o700 });
|
||||
const projects = { stack: { root } };
|
||||
return { dir, root, workspace, clone: join(workspace, "stack"), ensure: () => ensureCheckouts({ workspace, projects }) };
|
||||
}
|
||||
|
||||
// ensure() returning its one checkout, with the root unchanged by it.
|
||||
async function once(w) {
|
||||
const before = fingerprint(w.root);
|
||||
const list = await w.ensure();
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(fingerprint(w.root), before, "the root is unchanged");
|
||||
return list[0];
|
||||
}
|
||||
|
||||
test("a first launch clones the root at its branch, with the root's path as origin", async (t) => {
|
||||
const w = world(t);
|
||||
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "cloned", branch: "main" });
|
||||
assert.equal(git(w.clone, "symbolic-ref", "--short", "HEAD"), "main");
|
||||
assert.equal(git(w.clone, "rev-parse", "HEAD"), git(w.root, "rev-parse", "HEAD"));
|
||||
assert.equal(git(w.clone, "remote", "get-url", "origin"), w.root);
|
||||
assert.equal(readFileSync(join(w.clone, "README"), "utf8"), "one\n");
|
||||
assert.deepEqual(readdirSync(w.workspace), ["stack"], "no temp directory is left");
|
||||
// --no-hardlinks: no file under .git is shared with the root, and no hook came with it.
|
||||
const files = readdirSync(join(w.clone, ".git"), { recursive: true }).map((p) => join(w.clone, ".git", p)).filter((p) => lstatSync(p).isFile());
|
||||
assert.ok(files.some((p) => p.includes("/objects/")));
|
||||
for (const p of files) assert.equal(lstatSync(p).nlink, 1, p);
|
||||
assert.equal(existsSync(join(w.clone, ".git", "hooks")), false);
|
||||
});
|
||||
|
||||
// A host stopped mid-clone must not leave a half clone where the next
|
||||
// launch takes it for the session's. A git on PATH runs the real one, then
|
||||
// notes whether <workspace>/stack exists yet.
|
||||
test("the clone is not at its place until git clone has finished", async (t) => {
|
||||
const w = world(t);
|
||||
const bin = join(w.dir, "bin");
|
||||
mkdirSync(bin);
|
||||
const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
|
||||
const seen = join(w.dir, "seen");
|
||||
writeFileSync(join(bin, "git"), `#!/bin/sh\n'${real}' "$@"; s=$?\ncase " $* " in *" clone "*) if [ -e '${w.clone}' ]; then echo there; else echo absent; fi >> '${seen}';; esac\nexit $s\n`, { mode: 0o755 });
|
||||
const list = await ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: { PATH: `${bin}:${process.env.PATH}` } });
|
||||
assert.equal(list[0].outcome, "cloned");
|
||||
assert.equal(readFileSync(seen, "utf8"), "absent\n");
|
||||
assert.deepEqual(readdirSync(w.workspace), ["stack"]);
|
||||
});
|
||||
|
||||
test("a later launch fast-forwards a clean clone after a commit to the root, then finds it current", async (t) => {
|
||||
const w = world(t);
|
||||
await once(w);
|
||||
const head = commit(w.root, "two\n");
|
||||
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "updated", branch: "main" });
|
||||
assert.equal(git(w.clone, "rev-parse", "HEAD"), head);
|
||||
assert.equal(readFileSync(join(w.clone, "README"), "utf8"), "two\n");
|
||||
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "current", branch: "main" });
|
||||
assert.equal(git(w.clone, "status", "--porcelain"), "");
|
||||
});
|
||||
|
||||
test("a clone ahead of the root keeps its commits and counts as current", async (t) => {
|
||||
const w = world(t);
|
||||
await once(w);
|
||||
const mine = commit(w.clone, "mine\n");
|
||||
assert.equal((await once(w)).outcome, "current");
|
||||
assert.equal(git(w.clone, "rev-parse", "HEAD"), mine);
|
||||
});
|
||||
|
||||
test("a dirty clone, one on another branch and one diverged are kept as they are", async (t) => {
|
||||
for (const [name, edit, reason] of [
|
||||
["a modified file", (c) => writeFileSync(join(c, "README"), "mine\n"), "dirty"],
|
||||
["an untracked file", (c) => writeFileSync(join(c, "NEW"), "mine\n"), "dirty"],
|
||||
["a staged file", (c) => (writeFileSync(join(c, "NEW"), "x"), git(c, "add", "NEW")), "dirty"],
|
||||
["another branch", (c) => git(c, "checkout", "-q", "-b", "work"), "other-branch"],
|
||||
["a detached HEAD", (c) => git(c, "checkout", "-q", "--detach"), "other-branch"],
|
||||
["a diverged branch", (c) => commit(c, "mine\n"), "not-fast-forward"],
|
||||
]) {
|
||||
await t.test(name, async (t) => {
|
||||
const w = world(t);
|
||||
await once(w);
|
||||
edit(w.clone);
|
||||
commit(w.root, "two\n");
|
||||
const head = git(w.clone, "rev-parse", "HEAD");
|
||||
const before = reason === "not-fast-forward" ? null : fingerprint(w.clone);
|
||||
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "kept", reason });
|
||||
assert.equal(git(w.clone, "rev-parse", "HEAD"), head, "HEAD didn't move");
|
||||
// A diverged clone gets the fetch into refs/remotes, and nothing else.
|
||||
if (before) assert.equal(fingerprint(w.clone), before, "the clone is exactly as it was");
|
||||
else assert.equal(readFileSync(join(w.clone, "README"), "utf8"), "mine\n");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// A repository outside the workspace whose config and hooks run a command
|
||||
// that touches `marker`, for the cases that point git there.
|
||||
function bait(dir, marker) {
|
||||
const evil = repo(dir, "evil");
|
||||
const script = join(dir, "run.sh");
|
||||
writeFileSync(script, `#!/bin/sh\ntouch '${marker}'\n`, { mode: 0o755 });
|
||||
git(evil, "config", "core.fsmonitor", script);
|
||||
git(evil, "config", "core.hooksPath", join(evil, ".git", "hooks"));
|
||||
mkdirSync(join(evil, ".git", "hooks"), { recursive: true });
|
||||
for (const h of ["post-merge", "post-checkout", "reference-transaction", "pre-auto-gc", "post-index-change"]) writeFileSync(join(evil, ".git", "hooks", h), `#!/bin/sh\ntouch '${marker}'\n`, { mode: 0o755 });
|
||||
return { evil, script };
|
||||
}
|
||||
|
||||
// A repository at <dir>/sub whose own config names a clean filter that
|
||||
// runs the bait script, applied to a tracked file left modified at the same
|
||||
// size, so any git status inside it must hash the file and run the filter.
|
||||
// Returns its commit.
|
||||
function nested(dir, b) {
|
||||
const sub = join(dir, "sub");
|
||||
mkdirSync(sub);
|
||||
git(sub, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(sub, "data"), "one\n");
|
||||
writeFileSync(join(sub, ".gitattributes"), "data filter=x\n");
|
||||
git(sub, "add", "data", ".gitattributes");
|
||||
git(sub, "commit", "-q", "-m", "sub");
|
||||
git(sub, "config", "filter.x.clean", b.script);
|
||||
writeFileSync(join(sub, "data"), "ONE\n");
|
||||
return git(sub, "rev-parse", "HEAD");
|
||||
}
|
||||
const gitlink = (c, b) => git(c, "update-index", "--add", "--cacheinfo", `160000,${nested(c, b)},sub`);
|
||||
// <clone>/sub carries its own gitlink to <clone>/sub/sub, which alone names the filter.
|
||||
const gitlinkNested = (c, b) => {
|
||||
const outer = join(c, "sub");
|
||||
mkdirSync(outer);
|
||||
git(outer, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(outer, "f"), "x\n");
|
||||
git(outer, "add", "f");
|
||||
gitlink(outer, b);
|
||||
git(outer, "commit", "-q", "-m", "outer");
|
||||
git(c, "update-index", "--add", "--cacheinfo", `160000,${git(outer, "rev-parse", "HEAD")},sub`);
|
||||
};
|
||||
const gitmodules = (c) => (writeFileSync(join(c, ".gitmodules"), '[submodule "sub"]\n\tpath = sub\n\turl = ./sub\n'), git(c, "add", ".gitmodules"));
|
||||
|
||||
// Each way a session could make the host's git run a command or write
|
||||
// outside the clone. Every one keeps the clone, nothing runs, and neither
|
||||
// the root nor the bait repository changes.
|
||||
test("a clone the session rigged is kept, and git runs nothing and writes nothing outside it", async (t) => {
|
||||
const egit = (b) => join(b.evil, ".git");
|
||||
for (const [name, edit, reason] of [
|
||||
["core.fsmonitor", (c, b) => git(c, "config", "core.fsmonitor", b.script), "config-changed"],
|
||||
["core.hooksPath", (c, b) => git(c, "config", "core.hooksPath", join(egit(b), "hooks")), "config-changed"],
|
||||
["a hook file", (c, b) => {
|
||||
mkdirSync(join(c, ".git", "hooks"));
|
||||
writeFileSync(join(c, ".git", "hooks", "post-merge"), readFileSync(join(egit(b), "hooks", "post-merge")), { mode: 0o755 });
|
||||
}, "hooks-present"],
|
||||
["include.path", (c, b) => git(c, "config", "include.path", join(egit(b), "config")), "config-changed"],
|
||||
["includeIf", (c, b) => git(c, "config", "includeIf.gitdir:/.path", join(egit(b), "config")), "config-changed"],
|
||||
["a filter driver", (c, b) => {
|
||||
git(c, "config", "filter.x.smudge", `${b.script}; cat`);
|
||||
mkdirSync(join(c, ".git", "info"), { recursive: true });
|
||||
writeFileSync(join(c, ".git", "info", "attributes"), "* filter=x\n");
|
||||
}, "config-changed"],
|
||||
["origin moved", (c, b) => git(c, "remote", "set-url", "origin", b.evil), "config-changed"],
|
||||
["a second origin url", (c, b) => git(c, "config", "--add", "remote.origin.url", b.evil), "config-changed"],
|
||||
["the origin url twice, both the root", (c) => git(c, "config", "--add", "remote.origin.url", git(c, "config", "--get", "remote.origin.url")), "config-changed"],
|
||||
["an ext:: origin", (c, b) => git(c, "remote", "set-url", "origin", `ext::${b.script}`), "config-changed"],
|
||||
["a branch tracking another remote", (c) => git(c, "config", "branch.main.remote", "elsewhere"), "config-changed"],
|
||||
["a branch merging a tag", (c) => git(c, "config", "branch.main.merge", "refs/tags/x"), "config-changed"],
|
||||
["a fetch line onto local branches", (c) => git(c, "config", "remote.origin.fetch", "+refs/heads/*:refs/heads/*"), "config-changed"],
|
||||
["no origin", (c) => git(c, "remote", "remove", "origin"), "config-changed"],
|
||||
["a config file over the size cap", (c) => writeFileSync(join(c, ".git", "config"), `${readFileSync(join(c, ".git", "config"), "utf8")}#${"x".repeat(CONFIG_MAX_BYTES)}\n`), "config-changed"],
|
||||
["config a symlink to /dev/zero", (c) => (rmSync(join(c, ".git", "config")), symlinkSync("/dev/zero", join(c, ".git", "config"))), "not-a-clone"],
|
||||
["the clone a symlink to the bait", (c, b) => (rmSync(c, { recursive: true }), symlinkSync(b.evil, c)), "not-a-clone"],
|
||||
["the clone a file", (c) => (rmSync(c, { recursive: true }), writeFileSync(c, "x")), "not-a-clone"],
|
||||
["the clone a plain directory", (c) => rmSync(join(c, ".git"), { recursive: true }), "not-a-clone"],
|
||||
[".git a gitdir: file", (c, b) => (rmSync(join(c, ".git"), { recursive: true }), writeFileSync(join(c, ".git"), `gitdir: ${egit(b)}\n`)), "not-a-clone"],
|
||||
[".git a symlink", (c, b) => (rmSync(join(c, ".git"), { recursive: true }), symlinkSync(egit(b), join(c, ".git"))), "not-a-clone"],
|
||||
[".git/logs a symlink", (c, b) => (rmSync(join(c, ".git", "logs"), { recursive: true }), symlinkSync(join(egit(b), "logs"), join(c, ".git", "logs"))), "not-a-clone"],
|
||||
[".git/refs/heads a symlink", (c, b) => (rmSync(join(c, ".git", "refs", "heads"), { recursive: true }), symlinkSync(join(egit(b), "refs", "heads"), join(c, ".git", "refs", "heads"))), "not-a-clone"],
|
||||
[".git/objects a symlink", (c, b) => (rmSync(join(c, ".git", "objects"), { recursive: true }), symlinkSync(join(egit(b), "objects"), join(c, ".git", "objects"))), "not-a-clone"],
|
||||
["a reflog hard-linked to a file outside", (c, b) => (rmSync(join(c, ".git", "logs", "HEAD")), linkSync(join(egit(b), "logs", "HEAD"), join(c, ".git", "logs", "HEAD"))), "not-a-clone"],
|
||||
["HEAD a fifo", (c) => (rmSync(join(c, ".git", "HEAD")), spawnSync("mkfifo", [join(c, ".git", "HEAD")])), "not-a-clone"],
|
||||
["a commondir file", (c, b) => writeFileSync(join(c, ".git", "commondir"), `${egit(b)}\n`), "not-a-clone"],
|
||||
["an alternates file", (c, b) => (mkdirSync(join(c, ".git", "objects", "info"), { recursive: true }), writeFileSync(join(c, ".git", "objects", "info", "alternates"), `${join(egit(b), "objects")}\n`)), "not-a-clone"],
|
||||
// A submodule's own config is outside every check above, and git status
|
||||
// runs a second git inside a populated submodule.
|
||||
["a gitlink staged, no .gitmodules", gitlink, "submodule"],
|
||||
["a gitlink and .gitmodules staged", (c, b) => (gitlink(c, b), gitmodules(c)), "submodule"],
|
||||
["a gitlink to a repository with its own gitlink", gitlinkNested, "submodule"],
|
||||
["a gitlink and .gitmodules committed", (c, b) => (gitlink(c, b), gitmodules(c), git(c, "commit", "-q", "-m", "sub")), "submodule"],
|
||||
["a gitlink and submodule.active in config", (c, b) => (gitlink(c, b), gitmodules(c), git(c, "config", "submodule.active", ".")), "config-changed"],
|
||||
["a .git/modules with no gitlink", (c, b) => {
|
||||
const m = join(c, ".git", "modules", "sub");
|
||||
git(c, "init", "-q", "--bare", "--template=", m);
|
||||
git(m, "config", "filter.x.clean", b.script);
|
||||
}, "submodule"],
|
||||
]) {
|
||||
await t.test(name, async (t) => {
|
||||
const w = world(t);
|
||||
const marker = join(w.dir, "RAN");
|
||||
const b = bait(w.dir, marker);
|
||||
await once(w);
|
||||
edit(w.clone, b);
|
||||
commit(w.root, "two\n");
|
||||
const evilBefore = fingerprint(b.evil);
|
||||
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "kept", reason });
|
||||
assert.equal(existsSync(marker), false, "nothing the session wrote ran");
|
||||
assert.equal(fingerprint(b.evil), evilBefore, "nothing outside the clone was written");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("a root that can't be cloned refuses with the project id and leaves nothing behind", async (t) => {
|
||||
for (const [name, make, why] of [
|
||||
["missing", (w) => rmSync(w.root, { recursive: true }), /can't be read/],
|
||||
["not a repository", (w) => rmSync(join(w.root, ".git"), { recursive: true }), /can't be read/],
|
||||
["a plain directory inside another repository", (w) => {
|
||||
rmSync(join(w.root, ".git"), { recursive: true });
|
||||
git(w.dir, "init", "-q", "-b", "main");
|
||||
git(w.dir, "commit", "-q", "--allow-empty", "-m", "outer");
|
||||
}, /can't be read/],
|
||||
["unreadable", (w) => chmodSync(w.root, 0o000), /can't be read/],
|
||||
["on no branch", (w) => git(w.root, "checkout", "-q", "--detach"), /no branch checked out/],
|
||||
["missing an object", (w) => {
|
||||
// git starts writing the clone, then can't check the file out.
|
||||
const blob = git(w.root, "rev-parse", "HEAD:README");
|
||||
rmSync(join(w.root, ".git", "objects", blob.slice(0, 2), blob.slice(2)));
|
||||
}, /git clone of .* failed/],
|
||||
]) {
|
||||
await t.test(name, async (t) => {
|
||||
const w = world(t);
|
||||
make(w);
|
||||
await assert.rejects(w.ensure, (e) => e instanceof CheckoutError && e.project === "stack" && why.test(e.message));
|
||||
assert.deepEqual(readdirSync(w.workspace), [], "no clone and no temp directory");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("a clone whose root is on no branch, or gone, is kept and named", async (t) => {
|
||||
const w = world(t);
|
||||
await once(w);
|
||||
git(w.root, "checkout", "-q", "--detach");
|
||||
assert.equal((await once(w)).reason, "root-detached");
|
||||
rmSync(join(w.root, ".git"), { recursive: true });
|
||||
// The clone's config still names this path, so its check passes and the root's read fails.
|
||||
assert.deepEqual(await w.ensure(), [{ project: "stack", path: w.clone, outcome: "kept", reason: "root-unreadable" }]);
|
||||
});
|
||||
|
||||
test("no credential or forge remote of the root reaches a clone", async (t) => {
|
||||
const w = world(t);
|
||||
git(w.root, "remote", "add", "origin", "https://bot:[email protected]/acme/stack.git");
|
||||
git(w.root, "remote", "add", "gitea", "ssh://[email protected]/acme/stack.git");
|
||||
git(w.root, "config", "http.extraHeader", "Authorization: token s3cr3t-token");
|
||||
const secret = () => spawnSync("grep", ["-rlE", "s3cr3t|git\\.example", join(w.clone, ".git")], { encoding: "utf8" }).stdout;
|
||||
await once(w);
|
||||
assert.equal(git(w.clone, "remote"), "origin");
|
||||
assert.equal(git(w.clone, "remote", "get-url", "origin"), w.root);
|
||||
assert.equal(secret(), "");
|
||||
// And the fast-forward keeps it that way.
|
||||
commit(w.root, "two\n");
|
||||
assert.equal((await once(w)).outcome, "updated");
|
||||
assert.equal(secret(), "");
|
||||
});
|
||||
|
||||
test("every project in the business file gets its own clone at its own branch", async (t) => {
|
||||
const dir = tmp(t);
|
||||
const a = repo(dir, "a");
|
||||
const b = repo(dir, "b");
|
||||
git(b, "checkout", "-q", "-b", "dev");
|
||||
const workspace = join(dir, "ws");
|
||||
mkdirSync(workspace);
|
||||
const list = await ensureCheckouts({ workspace, projects: { alpha: { root: a }, beta: { root: b } } });
|
||||
assert.deepEqual(list.map((c) => [c.project, c.path, c.outcome, c.branch]), [["alpha", join(workspace, "alpha"), "cloned", "main"], ["beta", join(workspace, "beta"), "cloned", "dev"]]);
|
||||
assert.equal(git(join(workspace, "beta"), "symbolic-ref", "--short", "HEAD"), "dev");
|
||||
});
|
||||
Reference in New Issue
Block a user