fix(git): carry every failure's reason, and stop undo deleting files
packages/git funnelled every invocation through `catch { return null }`, so a
missing commit identity, a stale index.lock, a pathspec that matched nothing
and a git that was not installed all reached the user as "commit failed" — and
a git that could not run at all was reported as "not a git repository", which
is what a Windows contributor hit after installing Git for Git Bash only.
That null had a second meaning. `fileAtHead` returned it both for "this file
was not in HEAD" and for "we could not ask", so with git unavailable every
edited file was recorded `isNew` and undo deletes what an edit created: on the
codex and opencode paths, Revert deleted pre-existing tracked source files.
`HeadRead` splits the two, `FileDiff.noBaseline` carries the difference, and
`restoreFiles` checks it before `isNew` and refuses.
One `run()` now keeps exit code, stderr and spawn errno, and `failureText`
turns them into the one line a toast has room for. `stdout` is half its input,
not a fallback: `git commit` with nothing staged exits 1 and says so on stdout.
Also here, each its own small correctness fix:
- `cat-file --filters` instead of `show`, so `before` is the working-tree form.
The stored blob is LF whatever the tree is, and undo was writing that back
over CRLF files, rewriting every line ending in them.
- `:(literal)` pathspecs. `git add -- 'a[1].ts'` also stages an unrelated
`a1.ts`, so one edited file could sweep untouched work into the auto-commit.
- A binary blob is `unavailable` rather than decoded through U+FFFD and then
written back corrupted.
- `restoreFiles` cannot throw. `undo` is called straight out of the WebSocket
message handler, so an EACCES there took the whole daemon down.
- `push` and `gh pr create` get a timeout, a closed stdin and
`GIT_TERMINAL_PROMPT=0`. Git Credential Manager opens a GUI behind the
browser on Windows, and the awaiting handler never replied.
- `LC_ALL=C`, so classifying "absent from HEAD" does not depend on git's
messages being untranslated.
- One 64MB buffer ceiling for every call, not just the blob read.
`gitStatus()` is the answer that can be shown to someone: not installed, not a
work tree, bare, no commits yet, no identity. `ghStatus` gains the same
precision and a cwd, since `gh auth status` resolves its host from the remote.
The package had no tests at all, which is how the data-loss bug shipped. It has
34 now, including the reported scenario driven with PATH scrubbed of git.
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
/**
|
||||
* Covers the three ways the Codex path can arrive at a `before` side.
|
||||
* Covers the ways the Codex path can arrive at a `before` side.
|
||||
*
|
||||
* Worth testing specifically because a wrong `before` fails quietly rather than
|
||||
* loudly: `restoreFiles` skips any diff whose `before` is not a string, so the
|
||||
* only symptom is undo reporting "restored 0/1 files" long after the fact.
|
||||
* Worth testing specifically because a wrong `before` picks a different branch
|
||||
* of `restoreFiles`, and one of them is destructive: a string is rewritten,
|
||||
* `isNew` is *deleted*, and `noBaseline` is refused. The dangerous confusion is
|
||||
* between the last two — a baseline that could not be read looks exactly like a
|
||||
* file that never existed, and undo then deletes a tracked file instead of
|
||||
* restoring it.
|
||||
*
|
||||
* This header used to claim `restoreFiles` "skips any diff whose `before` is
|
||||
* not a string", which was only ever true of the non-`isNew` branch.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
@@ -90,6 +96,46 @@ describe("DiffCapture on the Codex path", () => {
|
||||
expect(diff.file).toBe("fresh.txt");
|
||||
expect(diff.before).toBeNull();
|
||||
expect(diff.isNew).toBe(true);
|
||||
expect(diff.noBaseline).toBeFalsy();
|
||||
});
|
||||
|
||||
it("keeps a primed baseline even when the head reader is unavailable", () => {
|
||||
/*
|
||||
* The two are independent, and `runner.ts` gates them separately for this
|
||||
* reason. `git status` answers perfectly well in a repository with no
|
||||
* commits, so priming still yields a real on-disk baseline there — and in a
|
||||
* fresh repo that is every file, since nothing is tracked yet. Only reading
|
||||
* a blob out of HEAD needs a HEAD.
|
||||
*
|
||||
* If this ever regressed to one gate, undo in a fresh repository would
|
||||
* refuse every file instead of restoring it.
|
||||
*/
|
||||
write("dirty.txt", "before the turn\n");
|
||||
const dc = new DiffCapture(repo, () => ({ kind: "unavailable" }));
|
||||
dc.prime([join(repo, "dirty.txt")]);
|
||||
|
||||
write("dirty.txt", "after the turn\n");
|
||||
dc.recordAfterTheFact("dirty.txt");
|
||||
|
||||
const [diff] = dc.finalize();
|
||||
expect(diff.before).toBe("before the turn\n");
|
||||
expect(diff.noBaseline).toBeFalsy();
|
||||
expect(diff.isNew).toBe(false);
|
||||
});
|
||||
|
||||
it("marks a file whose baseline could not be read, rather than calling it new", () => {
|
||||
// The distinction the whole `HeadRead` union exists for. With git
|
||||
// unavailable every read comes back like this, and calling them all `isNew`
|
||||
// is what made undo delete files it had never created.
|
||||
const dc = new DiffCapture(repo, () => ({ kind: "unavailable" }));
|
||||
|
||||
write("committed.txt", "edited by the agent\n");
|
||||
dc.recordAfterTheFact("committed.txt");
|
||||
|
||||
const [diff] = dc.finalize();
|
||||
expect(diff.noBaseline).toBe(true);
|
||||
expect(diff.isNew).toBe(false);
|
||||
expect(diff.before).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves a primed file out of the diff unless something writes to it", () => {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { relative, resolve } from "node:path";
|
||||
import { canonicalPath } from "@airship/git";
|
||||
import { canonicalPath, type HeadRead } from "@airship/git";
|
||||
import type { FileDiff } from "@airship/protocol";
|
||||
import { createPatch } from "diff";
|
||||
import { toPosixPath } from "./paths";
|
||||
@@ -35,15 +35,18 @@ function readSafe(abs: string): string | null {
|
||||
* Line endings, flattened for comparison and diffing only.
|
||||
*
|
||||
* On Windows `core.autocrlf=true` is the Git-for-Windows default, so the
|
||||
* working tree is CRLF while every agent's edit tool writes LF — and the HEAD
|
||||
* blob `fileAtHead` returns for the Codex path is LF too. Comparing those
|
||||
* directly makes every line of every file differ by its terminator, so a
|
||||
* one-line edit renders as a whole-file rewrite with garbage `+N −M` counts and
|
||||
* review comments that anchor to the wrong lines.
|
||||
* working tree is CRLF while every agent's edit tool writes LF. Comparing a
|
||||
* file's pre-edit CRLF form against its post-edit LF form makes every line
|
||||
* differ by its terminator, so a one-line edit renders as a whole-file rewrite
|
||||
* with garbage `+N −M` counts and review comments that anchor to the wrong
|
||||
* lines.
|
||||
*
|
||||
* Only the comparison and the patch are normalized. `FileDiff.before`/`after`
|
||||
* keep the bytes actually on disk, because `restoreFiles` writes `before` back
|
||||
* verbatim and undo has to round-trip exactly.
|
||||
* verbatim and undo has to round-trip exactly. That holds on the git-baseline
|
||||
* path too, but only because `fileAtHead` reads through `cat-file --filters`
|
||||
* rather than `show`: the stored blob is LF whatever the working tree is, and
|
||||
* writing that back over a CRLF file rewrote every line ending in it.
|
||||
*
|
||||
* The leading BOM goes the same way and for the same reason. Visual Studio and
|
||||
* several Windows editors write one; agent edit tools generally do not preserve
|
||||
@@ -79,10 +82,19 @@ function canonical(cwd: string, path: string): string {
|
||||
}
|
||||
|
||||
/** Reads a path's content as of git HEAD. Injected so core need not know how. */
|
||||
export type HeadReader = (absPath: string) => string | null;
|
||||
export type HeadReader = (absPath: string) => HeadRead;
|
||||
|
||||
export class DiffCapture {
|
||||
private readonly before = new Map<string, string | null>();
|
||||
/**
|
||||
* Paths whose `before` side could not be read at all, as opposed to files
|
||||
* that genuinely did not exist at HEAD. Kept beside `before` rather than
|
||||
* folded into it: the map's `null` already means "did not exist", and undo
|
||||
* deletes what did not exist. Two meanings on one value is what let a machine
|
||||
* with no git on PATH turn every edit into a creation and every undo into a
|
||||
* delete.
|
||||
*/
|
||||
private readonly unavailable = new Set<string>();
|
||||
private readonly touched = new Set<string>();
|
||||
/** Keys are canonical, so the root they are made relative to must be too. */
|
||||
private readonly root: string;
|
||||
@@ -136,7 +148,11 @@ export class DiffCapture {
|
||||
recordAfterTheFact(filePath: string): void {
|
||||
const abs = canonical(this.cwd, filePath);
|
||||
if (!this.before.has(abs)) {
|
||||
this.before.set(abs, this.readHead?.(abs) ?? null);
|
||||
const head = this.readHead?.(abs) ?? { kind: "unavailable" as const };
|
||||
if (head.kind === "unavailable") {
|
||||
this.unavailable.add(abs);
|
||||
}
|
||||
this.before.set(abs, head.kind === "content" ? head.text : null);
|
||||
}
|
||||
this.touched.add(abs);
|
||||
}
|
||||
@@ -174,6 +190,7 @@ export class DiffCapture {
|
||||
const rel = toPosixPath(relative(this.root, abs));
|
||||
const patch = createPatch(rel, forDiff(before), forDiff(after));
|
||||
const { additions, deletions } = countChanges(patch);
|
||||
const noBaseline = this.unavailable.has(abs);
|
||||
diffs.push({
|
||||
additions,
|
||||
after,
|
||||
@@ -181,7 +198,10 @@ export class DiffCapture {
|
||||
deletions,
|
||||
file: rel,
|
||||
isDeleted: after === null,
|
||||
isNew: before === null,
|
||||
// A file we could not read a baseline for is not a file the agent
|
||||
// created, and calling it one is what makes undo delete it.
|
||||
isNew: before === null && !noBaseline,
|
||||
...(noBaseline ? { noBaseline: true } : {}),
|
||||
patch,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* result. Provider-specific work lives behind `AgentAdapter` in `./providers`.
|
||||
*/
|
||||
|
||||
import { dirtyFiles, fileAtHead, isGitRepo } from "@airship/git";
|
||||
import { dirtyFiles, fileAtHead, gitStatus } from "@airship/git";
|
||||
import type {
|
||||
AgentKind,
|
||||
AttrEditTarget,
|
||||
@@ -117,14 +117,40 @@ export async function runEdit(
|
||||
// and the pre-turn dirty set is primed as a baseline. Both halves belong
|
||||
// together and neither is provider-specific, so they live here rather than
|
||||
// being copied into every adapter that needs them.
|
||||
//
|
||||
// The per-run check is what makes the common failure legible: when git cannot
|
||||
// run at all, every file would otherwise fail its own read for a reason
|
||||
// nobody sees. Saying "unavailable" once, up front, marks every diff in the
|
||||
// turn `noBaseline` so undo refuses cleanly instead of deleting them. The
|
||||
// per-file answer still matters for the cases that genuinely differ — a path
|
||||
// outside the work tree, a blob that is not text.
|
||||
const git = adapter.needsGitBaseline ? gitStatus(input.cwd) : undefined;
|
||||
/*
|
||||
* Two gates, not one. `git status` answers perfectly well in a repository
|
||||
* with no commits, so priming still gets a real on-disk baseline there, and
|
||||
* in a fresh repo that is every file it holds. Only reading a blob out of
|
||||
* HEAD needs a HEAD to read from.
|
||||
*
|
||||
* What the no-HEAD case still costs: a file the agent *creates* mid-turn was
|
||||
* not in the pre-turn dirty set and has no HEAD to be absent from, so it is
|
||||
* recorded as `unavailable` and undo declines to remove it. That is the
|
||||
* conservative answer and also the correct one — `status -uall` omits
|
||||
* gitignored files, so "absent from the primed set" does not actually prove
|
||||
* the file is new, and guessing wrong here deletes something the user had.
|
||||
*/
|
||||
const canPrime = Boolean(git?.workTree);
|
||||
const canReadHead = Boolean(git?.workTree && git.hasCommits);
|
||||
const diffCapture = new DiffCapture(
|
||||
input.cwd,
|
||||
adapter.needsGitBaseline ? (abs) => fileAtHead(input.cwd, abs) : undefined
|
||||
adapter.needsGitBaseline
|
||||
? (abs) =>
|
||||
canReadHead
|
||||
? fileAtHead(input.cwd, abs)
|
||||
: { kind: "unavailable" as const }
|
||||
: undefined
|
||||
);
|
||||
if (adapter.needsGitBaseline) {
|
||||
diffCapture.prime(
|
||||
isGitRepo(input.cwd) ? dirtyFiles(input.cwd) : new Set<string>()
|
||||
);
|
||||
diffCapture.prime(canPrime ? dirtyFiles(input.cwd) : new Set<string>());
|
||||
}
|
||||
|
||||
// One recorder owns both the persisted array and the live sink, so the
|
||||
|
||||
@@ -15,12 +15,14 @@
|
||||
"build": "tsup src/index.ts --format esm --dts --sourcemap --clean",
|
||||
"clean": "node ../../scripts/clean.mjs dist .turbo",
|
||||
"dev": "tsup src/index.ts --format esm --dts --sourcemap --watch",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@airship/protocol": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.2.0"
|
||||
"@types/node": "^26.2.0",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* The package had no tests at all, which is how it shipped a data-loss bug.
|
||||
*
|
||||
* Two things are pinned here that nothing else can pin. The first is that a
|
||||
* failure keeps its reason: every helper used to funnel through `catch { return
|
||||
* null }`, so a missing commit identity, a stale index.lock, a pathspec that
|
||||
* matched nothing and a git that was not installed all reached the user as
|
||||
* "commit failed". The second is the distinction between "this file was not in
|
||||
* HEAD" and "we could not ask": collapsing those made undo delete tracked
|
||||
* source files on any machine where git could not run.
|
||||
*
|
||||
* Everything runs against a real temp repo and a real git binary. There is no
|
||||
* mock — the whole subject is what the real one does on the day it goes wrong.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { FileDiff } from "@airship/protocol";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
canonicalPath,
|
||||
commitEdit,
|
||||
createBranch,
|
||||
dirtyFiles,
|
||||
failureText,
|
||||
fileAtHead,
|
||||
type GitFailure,
|
||||
gitStatus,
|
||||
isGitRepo,
|
||||
onGitFailure,
|
||||
restoreFiles,
|
||||
} from "./index";
|
||||
|
||||
const VERSION = /^\d+\.\d+/;
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
/** git words this several ways across versions, so match the subject. */
|
||||
const IDENTITY = /email|identity/i;
|
||||
|
||||
let repo: string;
|
||||
/** An empty directory, used as a PATH with no git in it. */
|
||||
let empty: string;
|
||||
|
||||
function git(...args: string[]): void {
|
||||
execFileSync("git", args, { cwd: repo, stdio: "ignore" });
|
||||
}
|
||||
|
||||
function write(rel: string, body: string): void {
|
||||
const path = join(repo, rel);
|
||||
mkdirSync(join(path, ".."), { recursive: true });
|
||||
writeFileSync(path, body);
|
||||
}
|
||||
|
||||
/** A `FileDiff` with only the fields the restore path reads. */
|
||||
function diff(over: Partial<FileDiff> & { file: string }): FileDiff {
|
||||
return {
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
isDeleted: false,
|
||||
isNew: false,
|
||||
patch: "",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
repo = mkdtempSync(join(tmpdir(), "airship-git-test-"));
|
||||
empty = mkdtempSync(join(tmpdir(), "airship-git-nopath-"));
|
||||
// `-b main` keeps `init.defaultBranch` advice off stderr, which would
|
||||
// otherwise be the first line `failureText` picks out of an unrelated failure.
|
||||
git("init", "-q", "-b", "main");
|
||||
// The developer's own global config must not decide what these assert — the
|
||||
// identity case in particular passes vacuously on a machine that has one.
|
||||
// `GIT_CONFIG_GLOBAL=/dev/null` is not portable; an empty file is.
|
||||
writeFileSync(join(repo, ".gitconfig-empty"), "");
|
||||
process.env.GIT_CONFIG_GLOBAL = join(repo, ".gitconfig-empty");
|
||||
process.env.GIT_CONFIG_SYSTEM = join(repo, ".gitconfig-empty");
|
||||
git("config", "user.email", "test@example.com");
|
||||
git("config", "user.name", "Test");
|
||||
git("config", "commit.gpgsign", "false");
|
||||
// Pinned, so a runner or developer with `core.autocrlf=true` does not turn
|
||||
// the non-EOL cases into a coin flip. The CRLF case sets its own rule.
|
||||
git("config", "core.autocrlf", "false");
|
||||
write("committed.txt", "one\ntwo\nthree\n");
|
||||
git("add", "-A");
|
||||
git("commit", "-q", "-m", "initial");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
onGitFailure(undefined);
|
||||
// `delete`, not `= undefined`: assigning to `process.env` coerces, so that
|
||||
// would leave the literal string "undefined" behind as a config path.
|
||||
delete process.env.GIT_CONFIG_GLOBAL;
|
||||
delete process.env.GIT_CONFIG_SYSTEM;
|
||||
for (const dir of [repo, empty]) {
|
||||
// maxRetries: on Windows a git process can still hold a handle inside .git
|
||||
// for a moment after it exits, which surfaces as EBUSY.
|
||||
rmSync(dir, { force: true, maxRetries: 3, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("gitStatus", () => {
|
||||
it("reports a healthy repository", () => {
|
||||
const status = gitStatus(repo);
|
||||
expect(status.installed).toBe(true);
|
||||
expect(status.version).toMatch(VERSION);
|
||||
expect(status.workTree).toBe(true);
|
||||
expect(status.hasCommits).toBe(true);
|
||||
expect(status.identity).toBe(true);
|
||||
expect(status.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still lists dirty files in a repository with no commits", () => {
|
||||
// `runner.ts` gates priming on `workTree` and reading HEAD on `hasCommits`,
|
||||
// separately, because of this: status answers without a HEAD, so a fresh
|
||||
// repository still gets a real on-disk baseline for every file it holds.
|
||||
const fresh = mkdtempSync(join(tmpdir(), "airship-git-nohead-"));
|
||||
execFileSync("git", ["init", "-q"], { cwd: fresh, stdio: "ignore" });
|
||||
writeFileSync(join(fresh, "a.txt"), "hi\n");
|
||||
const dirty = dirtyFiles(fresh);
|
||||
expect(dirty.size).toBe(1);
|
||||
expect([...dirty][0].endsWith("a.txt")).toBe(true);
|
||||
rmSync(fresh, { force: true, maxRetries: 3, recursive: true });
|
||||
});
|
||||
|
||||
it("separates a repository with no commits from a broken one", () => {
|
||||
const fresh = mkdtempSync(join(tmpdir(), "airship-git-fresh-"));
|
||||
execFileSync("git", ["init", "-q"], { cwd: fresh, stdio: "ignore" });
|
||||
const status = gitStatus(fresh);
|
||||
expect(status.workTree).toBe(true);
|
||||
expect(status.hasCommits).toBe(false);
|
||||
expect(status.error).toContain("no commits");
|
||||
rmSync(fresh, { force: true, maxRetries: 3, recursive: true });
|
||||
});
|
||||
|
||||
it("names a bare repository rather than calling it not-a-repository", () => {
|
||||
// The regression this pins: `isGitRepo` answers "false" for a bare repo and
|
||||
// null for a hard failure, and both used to render the same sentence.
|
||||
const bare = mkdtempSync(join(tmpdir(), "airship-git-bare-"));
|
||||
execFileSync("git", ["init", "-q", "--bare"], {
|
||||
cwd: bare,
|
||||
stdio: "ignore",
|
||||
});
|
||||
const status = gitStatus(bare);
|
||||
expect(status.installed).toBe(true);
|
||||
expect(status.workTree).toBe(false);
|
||||
expect(status.error).toContain("bare");
|
||||
rmSync(bare, { force: true, maxRetries: 3, recursive: true });
|
||||
});
|
||||
|
||||
it("reports a directory that is not a repository", () => {
|
||||
const plain = mkdtempSync(join(tmpdir(), "airship-git-plain-"));
|
||||
expect(gitStatus(plain).error).toBe("not a git repository");
|
||||
rmSync(plain, { force: true, maxRetries: 3, recursive: true });
|
||||
});
|
||||
|
||||
it("reports a missing commit identity", () => {
|
||||
git("config", "--unset", "user.email");
|
||||
const status = gitStatus(repo);
|
||||
expect(status.identity).toBe(false);
|
||||
expect(status.error).toContain("identity");
|
||||
expect(status.hint).toContain("user.email");
|
||||
});
|
||||
|
||||
it("does not report a nonexistent cwd as a missing git", () => {
|
||||
// Spawning into a directory that does not exist throws ENOENT, which is
|
||||
// byte-identical to git not being installed. A mistyped --cwd must not be
|
||||
// reported as "install git".
|
||||
const status = gitStatus(join(repo, "no-such-directory"));
|
||||
expect(status.installed).toBe(true);
|
||||
expect(status.error).toBe("not a git repository");
|
||||
});
|
||||
|
||||
it("keeps every reason inside the overlay's one-line tooltip budget", () => {
|
||||
// `GitStatus.error` is rendered in the turn menu's tooltip, where the house
|
||||
// rule is 44 characters. A path in the message would blow that, which is
|
||||
// why the banner adds the directory and this does not.
|
||||
const plain = mkdtempSync(join(tmpdir(), "airship-git-copy-"));
|
||||
for (const status of [gitStatus(plain), gitStatus(repo)]) {
|
||||
expect((status.error ?? "").length).toBeLessThanOrEqual(44);
|
||||
expect(status.error ?? "").not.toContain("—");
|
||||
}
|
||||
rmSync(plain, { force: true, maxRetries: 3, recursive: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("commitEdit", () => {
|
||||
it("returns the new sha", () => {
|
||||
write("committed.txt", "one\ntwo\nfour\n");
|
||||
const result = commitEdit(repo, ["committed.txt"], "change three to four");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.sha).toMatch(SHA);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces a pathspec that matched nothing, without leaking our magic", () => {
|
||||
const result = commitEdit(repo, ["nowhere.txt"], "x");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("did not match any files");
|
||||
expect(result.error).toContain("nowhere.txt");
|
||||
// `:(literal)` is ours, not something the user typed. Leaving it in reads
|
||||
// as a bug in airship rather than as a missing file.
|
||||
expect(result.error).not.toContain("literal");
|
||||
});
|
||||
|
||||
it("surfaces 'nothing to commit', which git prints on stdout", () => {
|
||||
// The case a stderr-only formatter gets wrong. `git commit` exits 1 here and
|
||||
// says nothing on stderr at all, so the message has to come off stdout.
|
||||
const result = commitEdit(repo, ["committed.txt"], "no change");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("nothing to commit, working tree clean");
|
||||
// "On branch main" rides along on stdout and explains nothing; leading with
|
||||
// it buries the half that does.
|
||||
expect(result.error).not.toContain("On branch");
|
||||
});
|
||||
|
||||
it("surfaces a missing commit identity", () => {
|
||||
git("config", "--unset", "user.email");
|
||||
git("config", "--unset", "user.name");
|
||||
// Without this git invents an identity from the hostname and the commit
|
||||
// succeeds, so the interesting case would never run on a machine that has
|
||||
// a resolvable one. `useConfigOnly` is exactly the "do not guess" switch.
|
||||
git("config", "user.useConfigOnly", "true");
|
||||
write("committed.txt", "one\ntwo\nfive\n");
|
||||
const result = commitEdit(repo, ["committed.txt"], "x");
|
||||
expect(result.ok).toBe(false);
|
||||
// git words this several ways across versions ("no email was given and
|
||||
// auto-detection is disabled", "unable to auto-detect email address",
|
||||
// "Author identity unknown"), so match the subject rather than a sentence.
|
||||
expect(result.error).toMatch(IDENTITY);
|
||||
});
|
||||
|
||||
it("refuses outside a repository, and says which problem it is", () => {
|
||||
const plain = mkdtempSync(join(tmpdir(), "airship-git-nc-"));
|
||||
expect(commitEdit(plain, ["a.txt"], "x").error).toBe(
|
||||
"not a git repository"
|
||||
);
|
||||
rmSync(plain, { force: true, maxRetries: 3, recursive: true });
|
||||
});
|
||||
|
||||
it("treats a filename with glob characters literally", () => {
|
||||
// Without `:(literal)`, `git add -- 'a[1].ts'` also stages `a1.ts` — so one
|
||||
// edited file could sweep unrelated work into airship's commit.
|
||||
write("a[1].ts", "bracketed\n");
|
||||
write("a1.ts", "decoy, must not be committed\n");
|
||||
const result = commitEdit(repo, ["a[1].ts"], "literal pathspec");
|
||||
expect(result.ok).toBe(true);
|
||||
const staged = execFileSync(
|
||||
"git",
|
||||
["show", "--name-only", "--format=", "HEAD"],
|
||||
{
|
||||
cwd: repo,
|
||||
encoding: "utf8",
|
||||
}
|
||||
);
|
||||
expect(staged).toContain("a[1].ts");
|
||||
expect(staged).not.toContain("a1.ts\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBranch", () => {
|
||||
it("reports why a branch could not be created", () => {
|
||||
expect(createBranch(repo, "feature").ok).toBe(true);
|
||||
const again = createBranch(repo, "feature");
|
||||
expect(again.ok).toBe(false);
|
||||
expect(again.error).toContain("already exists");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fileAtHead", () => {
|
||||
it("reads a tracked file", () => {
|
||||
const read = fileAtHead(repo, join(repo, "committed.txt"));
|
||||
expect(read).toEqual({ kind: "content", text: "one\ntwo\nthree\n" });
|
||||
});
|
||||
|
||||
it("reports an untracked file as absent, not unavailable", () => {
|
||||
write("fresh.txt", "new\n");
|
||||
expect(fileAtHead(repo, join(repo, "fresh.txt"))).toEqual({
|
||||
kind: "absent",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a path outside the work tree as unavailable, not absent", () => {
|
||||
// A file we cannot even ask about is not a file that did not exist. Calling
|
||||
// it absent marks the diff `isNew`, and undo deletes what an edit created.
|
||||
expect(fileAtHead(repo, join(repo, "..", "outside.txt"))).toEqual({
|
||||
kind: "unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a binary blob as unavailable rather than decoding it", () => {
|
||||
writeFileSync(
|
||||
join(repo, "logo.bin"),
|
||||
Buffer.from([0x00, 0xff, 0xfe, 0x41])
|
||||
);
|
||||
git("add", "-A");
|
||||
git("commit", "-q", "-m", "binary");
|
||||
// Decoded as UTF-8 it would come back full of U+FFFD, and `restoreFiles`
|
||||
// would write that corruption over the user's file.
|
||||
expect(fileAtHead(repo, join(repo, "logo.bin"))).toEqual({
|
||||
kind: "unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the working-tree form, not the stored blob", () => {
|
||||
// `.gitattributes` rather than `core.autocrlf`, so this is the same test on
|
||||
// Linux and Windows. The blob is LF; a checkout of it is CRLF; `before` has
|
||||
// to be the second, because `restoreFiles` writes it straight back to disk.
|
||||
write(".gitattributes", "*.txt text eol=crlf\n");
|
||||
write("crlf.txt", "alpha\nbeta\n");
|
||||
git("add", "-A");
|
||||
git("commit", "-q", "-m", "crlf");
|
||||
const read = fileAtHead(repo, join(repo, "crlf.txt"));
|
||||
expect(read).toEqual({ kind: "content", text: "alpha\r\nbeta\r\n" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreFiles", () => {
|
||||
it("rewrites a file from its before content", () => {
|
||||
write("committed.txt", "edited\n");
|
||||
const result = restoreFiles(repo, [
|
||||
diff({ before: "one\ntwo\nthree\n", file: "committed.txt" }),
|
||||
]);
|
||||
expect(result.restored).toEqual(["committed.txt"]);
|
||||
expect(result.skipped).toEqual([]);
|
||||
expect(fileAtHead(repo, join(repo, "committed.txt"))).toEqual({
|
||||
kind: "content",
|
||||
text: "one\ntwo\nthree\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a file the edit created", () => {
|
||||
write("added.txt", "new\n");
|
||||
const result = restoreFiles(repo, [
|
||||
diff({ file: "added.txt", isNew: true }),
|
||||
]);
|
||||
expect(result.restored).toEqual(["added.txt"]);
|
||||
expect(existsSync(join(repo, "added.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a file with no baseline instead of deleting it", () => {
|
||||
// The headline regression. `noBaseline` is checked before `isNew`, because a
|
||||
// baseline that could not be read used to be indistinguishable from a file
|
||||
// that never existed, and undo deleted the difference.
|
||||
write("committed.txt", "edited by the agent\n");
|
||||
const result = restoreFiles(repo, [
|
||||
diff({ file: "committed.txt", isNew: true, noBaseline: true }),
|
||||
]);
|
||||
expect(result.restored).toEqual([]);
|
||||
expect(result.skipped).toEqual([
|
||||
{
|
||||
file: "committed.txt",
|
||||
reason: "no baseline was captured, so there is nothing to restore",
|
||||
},
|
||||
]);
|
||||
// Still on disk, and still holding the agent's edit: refusing is the point.
|
||||
expect(readFileSync(join(repo, "committed.txt"), "utf8")).toBe(
|
||||
"edited by the agent\n"
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a write failure instead of throwing", () => {
|
||||
// `undo` is called straight out of the WebSocket message handler with no
|
||||
// try/catch of its own, so a throw here takes the whole daemon down with it.
|
||||
//
|
||||
// A directory standing where the file should be, rather than a read-only
|
||||
// bit: the POSIX permission bits do not mean the same thing on NTFS, and
|
||||
// this failure is identical on both.
|
||||
mkdirSync(join(repo, "blocked.txt"), { recursive: true });
|
||||
const result = restoreFiles(repo, [
|
||||
diff({ before: "original\n", file: "blocked.txt" }),
|
||||
]);
|
||||
expect(result.restored).toEqual([]);
|
||||
expect(result.skipped).toHaveLength(1);
|
||||
expect(result.skipped[0].file).toBe("blocked.txt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("failureText", () => {
|
||||
const base: GitFailure = {
|
||||
args: ["commit"],
|
||||
bin: "git",
|
||||
code: 1,
|
||||
stderr: "",
|
||||
stdout: "",
|
||||
};
|
||||
|
||||
it("names a missing binary", () => {
|
||||
expect(failureText({ ...base, code: null, errno: "ENOENT" })).toBe(
|
||||
"git is not installed or not on PATH"
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers git's own marked lines over its progress output", () => {
|
||||
expect(
|
||||
failureText({
|
||||
...base,
|
||||
stderr: "Enumerating objects: 5, done.\nfatal: repository not found",
|
||||
})
|
||||
).toBe("fatal: repository not found");
|
||||
});
|
||||
|
||||
it("falls back to a description when there is no output at all", () => {
|
||||
expect(failureText(base)).toBe("git commit exited 1");
|
||||
});
|
||||
|
||||
it("stays on one line", () => {
|
||||
const text = failureText({
|
||||
...base,
|
||||
stderr: `fatal: ${"x".repeat(400)}`,
|
||||
});
|
||||
expect(text).not.toContain("\n");
|
||||
expect(text.length).toBeLessThanOrEqual(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with git not on PATH", () => {
|
||||
/*
|
||||
* The reported scenario, and the one that used to end in deleted files.
|
||||
*
|
||||
* Git for Windows' "Git from Git Bash only" install option leaves git.exe off
|
||||
* the system PATH, so every call from the daemon fails while `git` works
|
||||
* perfectly in the contributor's own terminal.
|
||||
*
|
||||
* PATH is set to a real empty directory rather than to "", which behaves
|
||||
* differently across platforms. On Windows `process.env` lookup is
|
||||
* case-insensitive, so assigning `PATH` also overrides an existing `Path`;
|
||||
* CreateProcess additionally searches the application directory and the cwd,
|
||||
* neither of which holds a git.
|
||||
*/
|
||||
let saved: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
saved = process.env.PATH;
|
||||
process.env.PATH = empty;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PATH = saved;
|
||||
});
|
||||
|
||||
it("reports git as missing rather than the directory as not-a-repository", () => {
|
||||
const status = gitStatus(repo);
|
||||
expect(status.installed).toBe(false);
|
||||
expect(status.error).toBe("git is not installed or not on PATH");
|
||||
expect(status.hint).toContain("PATH");
|
||||
});
|
||||
|
||||
it("makes fileAtHead unavailable, never absent", () => {
|
||||
// The single most important assertion in this file. `absent` here would
|
||||
// mark every edited file `isNew`, and undo deletes what an edit created.
|
||||
expect(fileAtHead(repo, join(repo, "committed.txt"))).toEqual({
|
||||
kind: "unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
it("makes commitEdit say so", () => {
|
||||
const result = commitEdit(repo, ["committed.txt"], "x");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("git is not installed or not on PATH");
|
||||
});
|
||||
|
||||
it("still answers the yes/no probes without throwing", () => {
|
||||
expect(isGitRepo(repo)).toBe(false);
|
||||
expect(dirtyFiles(repo).size).toBe(0);
|
||||
});
|
||||
|
||||
it("hands every failure to the diagnostic sink", () => {
|
||||
const seen: GitFailure[] = [];
|
||||
onGitFailure((failure) => seen.push(failure));
|
||||
isGitRepo(repo);
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].bin).toBe("git");
|
||||
expect(seen[0].errno).toBe("ENOENT");
|
||||
expect(seen[0].args).toEqual(["rev-parse", "--is-inside-work-tree"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalPath", () => {
|
||||
it("resolves a path that does not exist via its parent", () => {
|
||||
// The create case: `DiffCapture` keys on a file before it is written.
|
||||
expect(canonicalPath(join(repo, "not-yet.txt"))).toBe(
|
||||
join(canonicalPath(repo), "not-yet.txt")
|
||||
);
|
||||
});
|
||||
});
|
||||
+621
-79
@@ -2,6 +2,13 @@
|
||||
* @airship/git — minimal git helpers for the daemon: optional auto-commit of an
|
||||
* accepted edit (with a Conventional-Commits message) and a content-restore
|
||||
* undo that rewrites files to their pre-edit state.
|
||||
*
|
||||
* Every invocation goes through `run`, which never throws and never discards
|
||||
* what went wrong. That is not incidental: this module used to be a wall of
|
||||
* `catch { return null }`, so a missing git, a missing commit identity, a stale
|
||||
* index.lock and a pathspec that matched nothing all reached the user as the
|
||||
* same three words — "commit failed" — and a git that could not run at all was
|
||||
* reported as "not a git repository".
|
||||
*/
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import {
|
||||
@@ -17,38 +24,279 @@ import type { FileDiff } from "@airship/protocol";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function git(cwd: string, args: string[]): string {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
/**
|
||||
* A single blob can exceed Node's 1MB default and would otherwise throw — and
|
||||
* the throw would be indistinguishable from every other failure. Applied to
|
||||
* every invocation rather than only the blob read, because `git push` against a
|
||||
* chatty remote can outrun 1MB of progress output just as easily.
|
||||
*/
|
||||
const MAX_BUFFER = 64 * 1024 * 1024;
|
||||
|
||||
/** How long a network call may block before it is killed. See `tryExec`. */
|
||||
const NETWORK_TIMEOUT_MS = 120_000;
|
||||
|
||||
/**
|
||||
* The environment every child gets.
|
||||
*
|
||||
* `LC_ALL`/`LANG` because git's messages are translated when locales are
|
||||
* installed, and `fileAtHead` classifies "absent from HEAD" by matching git's
|
||||
* own wording — a French checkout would otherwise be read as an unavailable
|
||||
* baseline. It also keeps what the user is shown stable and searchable.
|
||||
*
|
||||
* The prompt-suppressing half matters most on Windows, where Git Credential
|
||||
* Manager opens a *GUI* behind the browser: the daemon is also proxying the
|
||||
* user's dev server, so a push that sits waiting on an invisible dialog hangs
|
||||
* the WebSocket handler with nothing to cancel it.
|
||||
*/
|
||||
const GIT_ENV = {
|
||||
GCM_INTERACTIVE: "never",
|
||||
GH_PROMPT_DISABLED: "1",
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
LANG: "C",
|
||||
LC_ALL: "C",
|
||||
SSH_ASKPASS_REQUIRE: "never",
|
||||
} as const;
|
||||
|
||||
/** Everything a failed invocation knows about itself. */
|
||||
export interface GitFailure {
|
||||
/** The argv after `bin`, so a message can say which command failed. */
|
||||
args: string[];
|
||||
/** The executable — "git", or "gh" on the pull-request path. */
|
||||
bin: string;
|
||||
/** Exit status, or null when the process never started. */
|
||||
code: number | null;
|
||||
cwd?: string;
|
||||
/** `ENOENT` when the binary is not on PATH, `EACCES` when not executable. */
|
||||
errno?: string;
|
||||
stderr: string;
|
||||
/** Not a fallback for `stderr` — see `failureText`. */
|
||||
stdout: string;
|
||||
}
|
||||
|
||||
function tryGit(cwd: string, args: string[]): string | null {
|
||||
try {
|
||||
return git(cwd, args);
|
||||
} catch {
|
||||
return null;
|
||||
type GitRun<T> = { ok: true; stdout: T } | { failure: GitFailure; ok: false };
|
||||
|
||||
/**
|
||||
* A diagnostic tap, for `--debug`. Deliberately *not* how error text reaches
|
||||
* the user: the daemon serves several sockets at once and a module-level sink
|
||||
* delivers a failure out of band from the call that produced it, so two clients
|
||||
* committing at the same time would cross wires and toast each other's error.
|
||||
* Text that must be correlated to a call travels on that call's return value.
|
||||
*/
|
||||
let sink: ((failure: GitFailure) => void) | undefined;
|
||||
|
||||
export function onGitFailure(
|
||||
fn: ((failure: GitFailure) => void) | undefined
|
||||
): void {
|
||||
sink = fn;
|
||||
}
|
||||
|
||||
function text(value: Buffer | string | undefined): string {
|
||||
if (value === undefined) {
|
||||
return "";
|
||||
}
|
||||
return typeof value === "string" ? value : value.toString("utf8");
|
||||
}
|
||||
|
||||
function toFailure(
|
||||
bin: string,
|
||||
args: string[],
|
||||
cwd: string | undefined,
|
||||
err: unknown
|
||||
): GitFailure {
|
||||
// execFileSync throws an object whose `status` is the exit code, and whose
|
||||
// `code` is a spawn errno string only when the process never started.
|
||||
const e = err as {
|
||||
code?: number | string;
|
||||
status?: number | null;
|
||||
stderr?: Buffer | string;
|
||||
stdout?: Buffer | string;
|
||||
};
|
||||
return {
|
||||
args,
|
||||
bin,
|
||||
code: typeof e.status === "number" ? e.status : null,
|
||||
cwd,
|
||||
errno: typeof e.code === "string" ? e.code : undefined,
|
||||
stderr: text(e.stderr),
|
||||
stdout: text(e.stdout),
|
||||
};
|
||||
}
|
||||
|
||||
function fail<T>(failure: GitFailure): GitRun<T> {
|
||||
sink?.(failure);
|
||||
return { failure, ok: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Untrimmed variant. File contents and NUL-delimited porcelain output both
|
||||
* carry significant leading/trailing bytes, so they cannot go through `git()`.
|
||||
* `cwd` is checked before spawning because a directory that does not exist
|
||||
* makes spawn throw `ENOENT` — byte-identical to git not being installed. A
|
||||
* mistyped `--cwd` would otherwise be reported as a missing git, which is the
|
||||
* exact class of wrong answer this module exists to stop giving.
|
||||
*/
|
||||
function missingCwd(bin: string, args: string[], cwd: string): GitFailure {
|
||||
return {
|
||||
args,
|
||||
bin,
|
||||
code: null,
|
||||
cwd,
|
||||
errno: "ENOTDIR",
|
||||
stderr: `${cwd} does not exist`,
|
||||
stdout: "",
|
||||
};
|
||||
}
|
||||
|
||||
function runBin(
|
||||
bin: string,
|
||||
cwd: string | undefined,
|
||||
args: string[]
|
||||
): GitRun<string> {
|
||||
if (cwd !== undefined && !existsSync(cwd)) {
|
||||
return fail(missingCwd(bin, args, cwd));
|
||||
}
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
stdout: execFileSync(bin, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, ...GIT_ENV },
|
||||
maxBuffer: MAX_BUFFER,
|
||||
// stdin closed: a synchronous git must never be able to block on a
|
||||
// prompt. stdout/stderr piped so a failure carries git's own words.
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
return fail(toFailure(bin, args, cwd, err));
|
||||
}
|
||||
}
|
||||
|
||||
function run(cwd: string | undefined, args: string[]): GitRun<string> {
|
||||
return runBin("git", cwd, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* The byte-exact variant, for content that must not be decoded on trust.
|
||||
* `fileAtHead` needs it: a blob that is not valid UTF-8 comes back through
|
||||
* `encoding: "utf8"` full of U+FFFD, and `restoreFiles` would then write that
|
||||
* corruption over the user's file.
|
||||
*/
|
||||
function runBytes(cwd: string, args: string[]): GitRun<Buffer> {
|
||||
if (!existsSync(cwd)) {
|
||||
return fail(missingCwd("git", args, cwd));
|
||||
}
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
stdout: execFileSync("git", args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...GIT_ENV },
|
||||
maxBuffer: MAX_BUFFER,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
return fail(toFailure("git", args, cwd, err));
|
||||
}
|
||||
}
|
||||
|
||||
/** git's own markers for the line that actually explains a failure. */
|
||||
const GIT_MARKER = /^(?:fatal|error|remote|!)\b/;
|
||||
/**
|
||||
* Status preamble that rides along on stdout and never explains anything. `git
|
||||
* commit` with nothing staged prints "On branch main" before the line that
|
||||
* matters, and leading with it buries the answer.
|
||||
*/
|
||||
const GIT_PREAMBLE = /^(?:On branch |Your branch |HEAD detached)/;
|
||||
/** Both terminators: git output reaching us on Windows carries CRLF. */
|
||||
const NEWLINE = /\r?\n/;
|
||||
/**
|
||||
* Our own pathspec magic, stripped back out of anything a user reads.
|
||||
*
|
||||
* `commitEdit` wraps every path in `:(literal)` so a filename holding a glob
|
||||
* character is matched literally. That is an implementation detail, and leaving
|
||||
* it in `pathspec ':(literal)src/app.ts' did not match` reads as a bug in
|
||||
* airship rather than as a missing file.
|
||||
*/
|
||||
const LITERAL_MAGIC = /:\(literal\)/g;
|
||||
const MAX_FAILURE_LINES = 3;
|
||||
const MAX_FAILURE_CHARS = 300;
|
||||
|
||||
/**
|
||||
* The lines most likely to explain the failure.
|
||||
*
|
||||
* Marked lines first, then anything that is not status preamble, then whatever
|
||||
* there is. Each step only applies when it leaves something to say, so a
|
||||
* failure whose entire output is one unmarked preamble line still reports it
|
||||
* rather than reporting nothing.
|
||||
*/
|
||||
function explanatory(lines: string[]): string[] {
|
||||
const marked = lines.filter((line) => GIT_MARKER.test(line));
|
||||
if (marked.length > 0) {
|
||||
return marked;
|
||||
}
|
||||
const plain = lines.filter((line) => !GIT_PREAMBLE.test(line));
|
||||
return plain.length > 0 ? plain : lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one line worth showing a user: git's own words, or ours when it has none.
|
||||
*
|
||||
* `stdout` is not a fallback for `stderr`, it is half the input. `git commit`
|
||||
* with nothing staged exits 1 and prints "nothing to commit, working tree
|
||||
* clean" on *stdout* — the single commonest commit failure, which a
|
||||
* stderr-only formatter renders as the useless "git commit exited 1".
|
||||
*
|
||||
* Capped and joined onto one line because `toast()` in the overlay takes a
|
||||
* plain string and renders a single message. The full text is what `--debug`
|
||||
* is for.
|
||||
*/
|
||||
export function failureText(failure: GitFailure): string {
|
||||
if (failure.errno === "ENOTDIR") {
|
||||
return failure.stderr;
|
||||
}
|
||||
if (failure.errno === "ENOENT") {
|
||||
return `${failure.bin} is not installed or not on PATH`;
|
||||
}
|
||||
if (failure.errno === "EACCES") {
|
||||
return `${failure.bin} is not executable`;
|
||||
}
|
||||
// Windows only. Node has refused to spawn a .bat/.cmd without a shell since
|
||||
// 18.20/20.12, and a bare EINVAL here reads as a bug in airship rather than
|
||||
// as the fixable packaging problem it is. `gh` installed through npm or a
|
||||
// scoop shim is the way to reach this; the official gh.exe is not.
|
||||
if (failure.errno === "EINVAL") {
|
||||
return `${failure.bin} resolves to a .cmd or .bat shim, which cannot be launched directly`;
|
||||
}
|
||||
const lines = `${failure.stderr}\n${failure.stdout}`
|
||||
.split(NEWLINE)
|
||||
.map((line) => line.trim().replace(LITERAL_MAGIC, ""))
|
||||
.filter(Boolean);
|
||||
const chosen = explanatory(lines).slice(0, MAX_FAILURE_LINES);
|
||||
if (chosen.length === 0) {
|
||||
return `${failure.bin} ${failure.args[0] ?? "command"} exited ${failure.code ?? "abnormally"}`;
|
||||
}
|
||||
const joined = chosen.join(" · ");
|
||||
return joined.length > MAX_FAILURE_CHARS
|
||||
? `${joined.slice(0, MAX_FAILURE_CHARS - 1)}…`
|
||||
: joined;
|
||||
}
|
||||
|
||||
/** Lossy by design: for probes whose only question is yes/no. */
|
||||
function tryGit(cwd: string, args: string[]): string | null {
|
||||
const result = run(cwd, args);
|
||||
return result.ok ? result.stdout.trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Untrimmed variant. NUL-delimited porcelain output carries significant
|
||||
* trailing bytes, so it cannot go through `tryGit`.
|
||||
*/
|
||||
function tryGitRaw(cwd: string, args: string[]): string | null {
|
||||
try {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
// A single blob can exceed Node's 1MB default and would otherwise throw.
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const result = run(cwd, args);
|
||||
return result.ok ? result.stdout : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,25 +306,68 @@ function tryGitRaw(cwd: string, args: string[]): string | null {
|
||||
* microseconds and the call sites read better for it. `push` does not: it can
|
||||
* block for seconds on a slow remote, and this process is also proxying the
|
||||
* user's dev server, so a synchronous push would freeze their app mid-edit.
|
||||
*
|
||||
* The timeout is the other half of that. Without it a push waiting on
|
||||
* credentials never returns, the awaiting socket handler never replies, and no
|
||||
* child handle is kept anywhere that could kill it.
|
||||
*/
|
||||
async function tryExec(
|
||||
bin: string,
|
||||
args: string[],
|
||||
cwd: string
|
||||
cwd: string,
|
||||
timeoutMs: number = NETWORK_TIMEOUT_MS
|
||||
): Promise<{ error?: string; ok: boolean; stdout: string }> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(bin, args, { cwd });
|
||||
return { ok: true, stdout: stdout.trim() };
|
||||
} catch (err) {
|
||||
const e = err as { stderr?: string; message?: string };
|
||||
if (!existsSync(cwd)) {
|
||||
return {
|
||||
error: (e.stderr || e.message || "command failed").trim(),
|
||||
error: failureText(missingCwd(bin, args, cwd)),
|
||||
ok: false,
|
||||
stdout: "",
|
||||
};
|
||||
}
|
||||
const pending = execFileAsync(bin, args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...GIT_ENV },
|
||||
killSignal: "SIGKILL",
|
||||
maxBuffer: MAX_BUFFER,
|
||||
timeout: timeoutMs,
|
||||
windowsHide: true,
|
||||
});
|
||||
// Nobody is going to write to it, and git will happily sit reading a pipe.
|
||||
pending.child.stdin?.end();
|
||||
try {
|
||||
const { stdout } = await pending;
|
||||
return { ok: true, stdout: stdout.trim() };
|
||||
} catch (err) {
|
||||
const e = err as { killed?: boolean };
|
||||
if (e.killed) {
|
||||
return {
|
||||
error: `${bin} timed out after ${Math.round(timeoutMs / 1000)}s, most likely waiting on credentials`,
|
||||
ok: false,
|
||||
stdout: "",
|
||||
};
|
||||
}
|
||||
const failure = toFailure(bin, args, cwd, err);
|
||||
// execFile reports the exit status on `code`, where execFileSync uses
|
||||
// `status`; a numeric `code` is therefore an exit, not a spawn errno.
|
||||
const exit = (err as { code?: number | string }).code;
|
||||
if (typeof exit === "number") {
|
||||
failure.code = exit;
|
||||
failure.errno = undefined;
|
||||
}
|
||||
sink?.(failure);
|
||||
return { error: failureText(failure), ok: false, stdout: "" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `cwd` inside a work tree?
|
||||
*
|
||||
* The cheap yes/no, and deliberately nothing more: one `rev-parse`, no reason
|
||||
* attached. It answers `false` for a bare repository, a directory that is not a
|
||||
* repository, and a git that will not run, which is why it is no longer what
|
||||
* anything user-facing asks. `gitStatus` is the answer that can be shown to
|
||||
* someone; this is the guard `commitEdit` takes before paying for one.
|
||||
*/
|
||||
export function isGitRepo(cwd: string): boolean {
|
||||
return tryGit(cwd, ["rev-parse", "--is-inside-work-tree"]) === "true";
|
||||
}
|
||||
@@ -109,9 +400,137 @@ export function hasRemote(cwd: string, name = "origin"): boolean {
|
||||
return Boolean(tryGit(cwd, ["remote", "get-url", name]));
|
||||
}
|
||||
|
||||
export interface GitStatus {
|
||||
/**
|
||||
* The first thing that is wrong, phrased for a user.
|
||||
*
|
||||
* Short, and carrying no path: it is rendered in the overlay's tooltip box,
|
||||
* where the house budget is one line of about 44 characters. Callers with
|
||||
* more room, like the launch banner, add the directory themselves.
|
||||
*/
|
||||
error?: string;
|
||||
/** HEAD resolves. A fresh `git init` has no commits and so no baseline. */
|
||||
hasCommits: boolean;
|
||||
/** The command that fixes `error`. */
|
||||
hint?: string;
|
||||
/** Both halves of the commit identity are configured. */
|
||||
identity: boolean;
|
||||
installed: boolean;
|
||||
/** e.g. "2.43.0". Present whenever `installed`. */
|
||||
version?: string;
|
||||
/** `cwd` is inside a work tree — not bare, not outside a repo. */
|
||||
workTree: boolean;
|
||||
}
|
||||
|
||||
const GIT_VERSION = /(\d+\.\d+\.\d+)/;
|
||||
|
||||
const NOT_INSTALLED: GitStatus = {
|
||||
hasCommits: false,
|
||||
identity: false,
|
||||
installed: false,
|
||||
workTree: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Content of a file as of HEAD, or null when it does not exist there (untracked,
|
||||
* newly added, or outside the repo).
|
||||
* Why git will not work here, in the order the answers depend on each other.
|
||||
*
|
||||
* The same shape as `ghStatus`, and for the same reason its comment gives:
|
||||
* "installed but unusable" is the common case and fails much later with a
|
||||
* confusing message. Up to five spawns, so this is a preflight — `doctor`, the
|
||||
* launch banner, the PR gate and once per turn. Never per edit.
|
||||
*/
|
||||
export function gitStatus(cwd: string): GitStatus {
|
||||
// No `cwd`: "is git installed" has to be answerable when cwd is garbage.
|
||||
const version = run(undefined, ["--version"]);
|
||||
if (!version.ok) {
|
||||
return {
|
||||
...NOT_INSTALLED,
|
||||
error: failureText(version.failure),
|
||||
hint: 'Install Git and make sure `git` is on PATH. On Windows, re-run the installer and choose "Git from the command line".',
|
||||
};
|
||||
}
|
||||
const base = {
|
||||
installed: true,
|
||||
version: GIT_VERSION.exec(version.stdout)?.[1],
|
||||
};
|
||||
const inside = run(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
||||
if (!inside.ok) {
|
||||
return {
|
||||
...base,
|
||||
error: "not a git repository",
|
||||
hasCommits: false,
|
||||
hint: "Run `git init` there, or point --cwd at a repository.",
|
||||
identity: false,
|
||||
workTree: false,
|
||||
};
|
||||
}
|
||||
// "false" rather than a failure: a bare repo answers the question and is
|
||||
// still unusable here. These were the same answer until this function existed.
|
||||
if (inside.stdout.trim() !== "true") {
|
||||
return {
|
||||
...base,
|
||||
error: "this is a bare git repository",
|
||||
hasCommits: false,
|
||||
hint: "Airship edits a working tree; open a normal clone instead.",
|
||||
identity: false,
|
||||
workTree: false,
|
||||
};
|
||||
}
|
||||
const hasCommits = run(cwd, ["rev-parse", "--verify", "--quiet", "HEAD"]).ok;
|
||||
const identity =
|
||||
Boolean(tryGit(cwd, ["config", "--get", "user.email"])) &&
|
||||
Boolean(tryGit(cwd, ["config", "--get", "user.name"]));
|
||||
if (!hasCommits) {
|
||||
return {
|
||||
...base,
|
||||
error: "this repository has no commits yet",
|
||||
hasCommits,
|
||||
hint: "Make one commit. Without HEAD there is no baseline to diff or undo against.",
|
||||
identity,
|
||||
workTree: true,
|
||||
};
|
||||
}
|
||||
if (!identity) {
|
||||
return {
|
||||
...base,
|
||||
error: "git has no commit identity",
|
||||
hasCommits,
|
||||
hint: 'Run `git config --global user.email you@example.com` and `git config --global user.name "Your Name"`.',
|
||||
identity,
|
||||
workTree: true,
|
||||
};
|
||||
}
|
||||
return { ...base, hasCommits, identity, workTree: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a file looked like at HEAD.
|
||||
*
|
||||
* Three answers, not two. "absent" is a fact about HEAD; "unavailable" is a
|
||||
* fact about us — and collapsing them into one null is what let undo delete
|
||||
* tracked files on a machine with no git on PATH: every read failed, every file
|
||||
* was therefore recorded as newly created, and undo deletes what an edit
|
||||
* created. See `restoreFiles`.
|
||||
*/
|
||||
export type HeadRead =
|
||||
| { kind: "absent" }
|
||||
| { kind: "content"; text: string }
|
||||
| { kind: "unavailable" };
|
||||
|
||||
const ABSENT_FROM_HEAD = /does not exist in|exists on disk, but not in/;
|
||||
const UNKNOWN_OPTION = /unknown option|error: unknown switch/;
|
||||
|
||||
/**
|
||||
* Whether this git understands `cat-file --filters`, memoized.
|
||||
*
|
||||
* Deliberately not a version probe: parsing `git --version` costs a spawn and
|
||||
* distro builds spell it in ways worth not depending on ("2.39.5 (Apple
|
||||
* Git-154)"). One wasted invocation on a pre-2.11 git, once per process.
|
||||
*/
|
||||
let filtersSupported: boolean | undefined;
|
||||
|
||||
/**
|
||||
* Content of a file as of HEAD.
|
||||
*
|
||||
* This is how the Codex path reconstructs a diff's `before` side. That backend
|
||||
* has no pre-tool hook, so unlike the Claude path there is no opportunity to
|
||||
@@ -119,23 +538,63 @@ export function hasRemote(cwd: string, name = "origin"): boolean {
|
||||
* changed, the new content is already on disk. For a file that was clean when
|
||||
* the turn started, its HEAD blob is exactly the pre-turn content.
|
||||
*
|
||||
* `cat-file --filters` rather than `show`, because `before` is written straight
|
||||
* back to disk by `restoreFiles` and therefore has to be the *working tree*
|
||||
* form, not the stored blob. `core.autocrlf=true` is the Git-for-Windows
|
||||
* default, so `show` returns LF for a file that is CRLF on disk and undo
|
||||
* silently rewrote every line ending in the file. `--filters` applies the same
|
||||
* conversion a checkout would, including `.gitattributes` and LFS smudge.
|
||||
*
|
||||
* `path` may be absolute or relative to `cwd`; `HEAD:./…` resolves against the
|
||||
* working directory rather than the repo root, so a project root that sits in a
|
||||
* subdirectory of the repo still works.
|
||||
*/
|
||||
export function fileAtHead(cwd: string, path: string): string | null {
|
||||
export function fileAtHead(cwd: string, path: string): HeadRead {
|
||||
// Both sides are canonicalized before being compared. Callers hand us paths
|
||||
// from mixed sources — some resolved through git, some as the user typed
|
||||
// them — and on macOS `/tmp` and `/var` are symlinks, so an uncanonicalized
|
||||
// comparison reads a perfectly normal path as escaping the project.
|
||||
const root = canonicalPath(cwd);
|
||||
const rel = relative(root, canonicalPath(resolve(cwd, path)));
|
||||
// `..` means the path really does escape the working directory; `HEAD:./..`
|
||||
// is not valid git syntax and there is nothing sensible to return.
|
||||
// `..` means the path really does escape the working directory. Unavailable
|
||||
// rather than absent: a file outside the tree may or may not have existed,
|
||||
// and guessing "it did not" is a licence to delete it.
|
||||
if (!rel || rel.startsWith("..")) {
|
||||
return null;
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
return tryGitRaw(cwd, ["show", `HEAD:./${rel.split(sep).join("/")}`]);
|
||||
const spec = `HEAD:./${rel.split(sep).join("/")}`;
|
||||
const result = readBlob(cwd, spec);
|
||||
if (!result.ok) {
|
||||
return ABSENT_FROM_HEAD.test(result.failure.stderr)
|
||||
? { kind: "absent" }
|
||||
: { kind: "unavailable" };
|
||||
}
|
||||
try {
|
||||
// `fatal` is the precise test for "is this losslessly text". A blob that is
|
||||
// not — a PNG, a latin-1 source file — must not be decoded on trust and
|
||||
// then written back over the original.
|
||||
return {
|
||||
kind: "content",
|
||||
text: new TextDecoder("utf8", { fatal: true }).decode(result.stdout),
|
||||
};
|
||||
} catch {
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
function readBlob(cwd: string, spec: string): GitRun<Buffer> {
|
||||
if (filtersSupported !== false) {
|
||||
const filtered = runBytes(cwd, ["cat-file", "--filters", spec]);
|
||||
if (filtered.ok) {
|
||||
filtersSupported = true;
|
||||
return filtered;
|
||||
}
|
||||
if (!UNKNOWN_OPTION.test(filtered.failure.stderr)) {
|
||||
return filtered;
|
||||
}
|
||||
filtersSupported = false;
|
||||
}
|
||||
return runBytes(cwd, ["show", spec]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,7 +605,7 @@ export function fileAtHead(cwd: string, path: string): string | null {
|
||||
* while preserving whatever spelling the caller passed, so `C:\Users\RUNNER~1`
|
||||
* stays short while anything that went through `GetFinalPathNameByHandle`
|
||||
* reads `C:\Users\runneradmin`. Two spellings of one directory make `relative`
|
||||
* below return a `..` path, and `fileAtHead` then reports every file as absent
|
||||
* above return a `..` path, and `fileAtHead` then reports every file as absent
|
||||
* from HEAD — which on the Codex path silently turns each edit into a
|
||||
* whole-file diff with no baseline.
|
||||
*
|
||||
@@ -187,6 +646,9 @@ export function dirtyFiles(cwd: string): Set<string> {
|
||||
// NUL-delimited records: `XY <path>`. A rename or copy spends a second field
|
||||
// on its origin path, which must be consumed as data rather than parsed as
|
||||
// the next status record — hence a cursor rather than a for-of.
|
||||
//
|
||||
// `-z` is also what makes `core.quotepath` a non-issue: with it git emits raw
|
||||
// bytes and never applies C-style quoting.
|
||||
const fields = raw.split("\0");
|
||||
let i = 0;
|
||||
while (i < fields.length) {
|
||||
@@ -208,8 +670,14 @@ export function dirtyFiles(cwd: string): Set<string> {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createBranch(cwd: string, name: string): boolean {
|
||||
return tryGit(cwd, ["checkout", "-b", name]) !== null;
|
||||
export function createBranch(
|
||||
cwd: string,
|
||||
name: string
|
||||
): { error?: string; ok: boolean } {
|
||||
const result = run(cwd, ["checkout", "-b", name]);
|
||||
return result.ok
|
||||
? { ok: true }
|
||||
: { error: failureText(result.failure), ok: false };
|
||||
}
|
||||
|
||||
export interface GhStatus {
|
||||
@@ -218,24 +686,34 @@ export interface GhStatus {
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
/** Whether `gh` is usable. Both halves matter — installed but unauthenticated
|
||||
* is the common case and fails much later with a confusing message. */
|
||||
export function ghStatus(): GhStatus {
|
||||
try {
|
||||
execFileSync("gh", ["--version"], { stdio: "ignore" });
|
||||
} catch {
|
||||
return { authed: false, error: "gh is not installed", installed: false };
|
||||
}
|
||||
try {
|
||||
execFileSync("gh", ["auth", "status"], { stdio: "ignore" });
|
||||
return { authed: true, installed: true };
|
||||
} catch {
|
||||
/**
|
||||
* Whether `gh` is usable. Both halves matter — installed but unauthenticated is
|
||||
* the common case and fails much later with a confusing message.
|
||||
*
|
||||
* `cwd` is passed to `auth status` and not to `--version`: the first resolves
|
||||
* which host to check from the repository's own remote, the second must be
|
||||
* answerable anywhere.
|
||||
*/
|
||||
export function ghStatus(cwd?: string): GhStatus {
|
||||
const version = runBin("gh", undefined, ["--version"]);
|
||||
if (!version.ok) {
|
||||
return {
|
||||
authed: false,
|
||||
error: "gh is installed but not authenticated (`gh auth login`)",
|
||||
installed: true,
|
||||
error: failureText(version.failure),
|
||||
installed: false,
|
||||
};
|
||||
}
|
||||
const auth = runBin("gh", cwd, ["auth", "status"]);
|
||||
if (auth.ok) {
|
||||
return { authed: true, installed: true };
|
||||
}
|
||||
// gh's own text, rather than a guess: an expired token, an unconfigured host
|
||||
// and a network failure are three different problems with three fixes.
|
||||
return {
|
||||
authed: false,
|
||||
error: `gh is installed but not usable: ${failureText(auth.failure)}`,
|
||||
installed: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Push a branch and set upstream. Async — see `tryExec`. */
|
||||
@@ -279,8 +757,9 @@ export async function createPr(
|
||||
if (!result.ok) {
|
||||
return { error: result.error, ok: false };
|
||||
}
|
||||
// `gh` prints the PR URL as its last line.
|
||||
const url = result.stdout.split("\n").filter(Boolean).pop();
|
||||
// `gh` prints the PR URL as its last line. Split on both terminators: the
|
||||
// trim above only reaches the last one.
|
||||
const url = result.stdout.split(NEWLINE).filter(Boolean).pop();
|
||||
return { ok: true, url };
|
||||
}
|
||||
|
||||
@@ -288,52 +767,115 @@ export function currentSha(cwd: string): string | null {
|
||||
return tryGit(cwd, ["rev-parse", "HEAD"]);
|
||||
}
|
||||
|
||||
export interface CommitResult {
|
||||
error?: string;
|
||||
ok: boolean;
|
||||
sha?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage and commit exactly the given files with a Conventional-Commits message.
|
||||
* Returns the new commit sha, or null if nothing was committed.
|
||||
*
|
||||
* Every path is wrapped in `:(literal)` because the arguments after `--` are
|
||||
* pathspecs, not filenames: without it `git add -- 'a[1].ts'` stages both
|
||||
* `a[1].ts` and any file the glob happens to match, so a single edited file can
|
||||
* sweep unrelated work into airship's commit.
|
||||
*/
|
||||
export function commitEdit(
|
||||
cwd: string,
|
||||
files: string[],
|
||||
summary: string
|
||||
): string | null {
|
||||
if (files.length === 0 || !isGitRepo(cwd)) {
|
||||
return null;
|
||||
): CommitResult {
|
||||
if (files.length === 0) {
|
||||
return { error: "nothing to commit", ok: false };
|
||||
}
|
||||
// The cheap predicate first, and the five-spawn diagnosis only once it has
|
||||
// already failed. `isGitRepo` alone is what used to report a git that is not
|
||||
// installed as "not a git repository"; paying for the real answer on the
|
||||
// unhappy path costs nothing anybody waits for.
|
||||
if (!isGitRepo(cwd)) {
|
||||
return { error: gitStatus(cwd).error ?? "git is unavailable", ok: false };
|
||||
}
|
||||
const subject = `chore(airship): ${summary.trim() || "visual edit"}`.slice(
|
||||
0,
|
||||
100
|
||||
);
|
||||
try {
|
||||
git(cwd, ["add", "--", ...files]);
|
||||
git(cwd, ["commit", "--no-verify", "-m", subject, "--", ...files]);
|
||||
return currentSha(cwd);
|
||||
} catch {
|
||||
return null;
|
||||
const specs = files.map((file) => `:(literal)${file}`);
|
||||
const staged = run(cwd, ["add", "--", ...specs]);
|
||||
if (!staged.ok) {
|
||||
return { error: failureText(staged.failure), ok: false };
|
||||
}
|
||||
const committed = run(cwd, [
|
||||
"commit",
|
||||
"--no-verify",
|
||||
"-m",
|
||||
subject,
|
||||
"--",
|
||||
...specs,
|
||||
]);
|
||||
if (!committed.ok) {
|
||||
return { error: failureText(committed.failure), ok: false };
|
||||
}
|
||||
return { ok: true, sha: currentSha(cwd) ?? undefined };
|
||||
}
|
||||
|
||||
export interface RestoreResult {
|
||||
/** Repo-relative paths that were rewritten or deleted. */
|
||||
restored: string[];
|
||||
/** Paths left alone, and why. */
|
||||
skipped: { file: string; reason: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-restore undo: rewrite each touched file back to its pre-edit content
|
||||
* (deleting files the edit created). Pure filesystem — works whether or not the
|
||||
* project is a git repo. Returns the repo-relative paths that were restored.
|
||||
* project is a git repo.
|
||||
*
|
||||
* `noBaseline` is checked *before* `isNew`, and that order is the whole point.
|
||||
* A baseline we could not read used to look exactly like a file that never
|
||||
* existed, so undo deleted tracked source files on any machine where git could
|
||||
* not run. Refusing and saying so is the only safe answer.
|
||||
*
|
||||
* Nothing here may throw: `undo` is called straight out of the WebSocket
|
||||
* message handler, so an EACCES on one read-only file would otherwise take the
|
||||
* whole daemon down with it.
|
||||
*/
|
||||
export function restoreFiles(cwd: string, diffs: FileDiff[]): string[] {
|
||||
export function restoreFiles(cwd: string, diffs: FileDiff[]): RestoreResult {
|
||||
const restored: string[] = [];
|
||||
const skipped: { file: string; reason: string }[] = [];
|
||||
for (const d of diffs) {
|
||||
const abs = resolve(cwd, d.file);
|
||||
if (d.isNew) {
|
||||
if (existsSync(abs)) {
|
||||
rmSync(abs, { force: true });
|
||||
}
|
||||
restored.push(d.file);
|
||||
if (d.noBaseline) {
|
||||
skipped.push({
|
||||
file: d.file,
|
||||
reason: "no baseline was captured, so there is nothing to restore",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (typeof d.before === "string") {
|
||||
mkdirSync(dirname(abs), { recursive: true });
|
||||
writeFileSync(abs, d.before, "utf8");
|
||||
restored.push(d.file);
|
||||
try {
|
||||
if (d.isNew) {
|
||||
if (existsSync(abs)) {
|
||||
rmSync(abs, { force: true });
|
||||
}
|
||||
restored.push(d.file);
|
||||
continue;
|
||||
}
|
||||
if (typeof d.before === "string") {
|
||||
mkdirSync(dirname(abs), { recursive: true });
|
||||
writeFileSync(abs, d.before, "utf8");
|
||||
restored.push(d.file);
|
||||
continue;
|
||||
}
|
||||
skipped.push({
|
||||
file: d.file,
|
||||
reason: "no previous content was recorded",
|
||||
});
|
||||
} catch (err) {
|
||||
skipped.push({
|
||||
file: d.file,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
return restored;
|
||||
return { restored, skipped };
|
||||
}
|
||||
|
||||
@@ -422,6 +422,18 @@ export const FileDiffSchema = z.object({
|
||||
file: z.string(),
|
||||
isDeleted: z.boolean(),
|
||||
isNew: z.boolean(),
|
||||
/**
|
||||
* No `before` side could be captured — git could not answer, not "the file
|
||||
* did not exist".
|
||||
*
|
||||
* Named rather than left implicit, because the two states used to share one
|
||||
* `before: null` and undo read the wrong one: a baseline it could not read
|
||||
* looked exactly like a file the edit had created, so it deleted tracked
|
||||
* source files on any machine where git could not run. `restoreFiles` checks
|
||||
* this before `isNew` and refuses. Optional, so bundles written before this
|
||||
* field existed still parse.
|
||||
*/
|
||||
noBaseline: z.boolean().optional(),
|
||||
patch: z.string(),
|
||||
});
|
||||
export type FileDiff = z.infer<typeof FileDiffSchema>;
|
||||
@@ -654,10 +666,27 @@ export interface JobSnapshot {
|
||||
// WebSocket protocol: server → client
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether git-backed actions can work at all, and why not when they cannot.
|
||||
*
|
||||
* Sent so the overlay can grey out Revert, Commit and Create pull request with
|
||||
* the reason attached, instead of offering them and failing after the click.
|
||||
*/
|
||||
export interface GitHealth {
|
||||
/** What to do about it. Shown under `reason` in the same tooltip. */
|
||||
hint?: string;
|
||||
ok: boolean;
|
||||
/** Absent when `ok`. A sentence, already phrased for a user. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type ServerEvent =
|
||||
/** `defaultAgent` is the daemon's `--agent` setting, so the composer's picker
|
||||
* can render the right backend on first paint instead of guessing. */
|
||||
| { type: "hello"; jobs: JobSnapshot[]; defaultAgent: AgentKind }
|
||||
/** Pushed on connect and again after every turn, since a repo can gain its
|
||||
* first commit — or lose its git — while the overlay is open. */
|
||||
| { type: "git:health"; health: GitHealth }
|
||||
| { type: "job:created"; job: JobSnapshot }
|
||||
/** Coarse one-line status ("Reading foo.tsx"). Self-overwriting by design —
|
||||
* it drives the turn's live status pill, not the transcript body. */
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Which git problems reach the overlay as "these buttons will not work".
|
||||
*
|
||||
* The distinction that matters is between a git that cannot serve the verbs at
|
||||
* all and a repository that simply has no HEAD yet. The second one greys
|
||||
* nothing: committing is what gives a fresh repository its first commit, so a
|
||||
* Commit button disabled for want of a HEAD disables the cure.
|
||||
*/
|
||||
import type { GitStatus } from "@airship/git";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { healthOf } from "./index";
|
||||
|
||||
const HEALTHY: GitStatus = {
|
||||
hasCommits: true,
|
||||
identity: true,
|
||||
installed: true,
|
||||
version: "2.43.0",
|
||||
workTree: true,
|
||||
};
|
||||
|
||||
describe("healthOf", () => {
|
||||
it("is happy with a working repository", () => {
|
||||
expect(healthOf(HEALTHY)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("stays happy in a repository with no commits yet", () => {
|
||||
// The regression this exists for. `git commit` works here; it is the diff
|
||||
// baseline that does not, and that gates Revert through `noBaseline`.
|
||||
expect(
|
||||
healthOf({
|
||||
...HEALTHY,
|
||||
error: "this repository has no commits yet",
|
||||
hasCommits: false,
|
||||
})
|
||||
).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("carries the reason and the fix when git is missing", () => {
|
||||
expect(
|
||||
healthOf({
|
||||
error: "git is not installed or not on PATH",
|
||||
hasCommits: false,
|
||||
hint: "Install Git and make sure `git` is on PATH.",
|
||||
identity: false,
|
||||
installed: false,
|
||||
workTree: false,
|
||||
})
|
||||
).toEqual({
|
||||
hint: "Install Git and make sure `git` is on PATH.",
|
||||
ok: false,
|
||||
reason: "git is not installed or not on PATH",
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the reason when the directory is not a work tree", () => {
|
||||
const health = healthOf({
|
||||
...HEALTHY,
|
||||
error: "this is a bare git repository",
|
||||
hasCommits: false,
|
||||
workTree: false,
|
||||
});
|
||||
expect(health.ok).toBe(false);
|
||||
expect(health.reason).toBe("this is a bare git repository");
|
||||
});
|
||||
|
||||
it("keeps the reason inside the tooltip's one-line budget", () => {
|
||||
// It is rendered as a `data-tip`, where the house rule is 44 characters
|
||||
// and `tooltip.copy.test.ts` in the overlay enforces it for literals.
|
||||
for (const status of [
|
||||
{ ...HEALTHY, error: "not a git repository", workTree: false },
|
||||
{ ...HEALTHY, error: "this is a bare git repository", workTree: false },
|
||||
{
|
||||
...HEALTHY,
|
||||
error: "git is not installed or not on PATH",
|
||||
installed: false,
|
||||
},
|
||||
]) {
|
||||
const { reason } = healthOf(status);
|
||||
expect((reason ?? "").length).toBeLessThanOrEqual(44);
|
||||
expect(reason ?? "").not.toContain("—");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -21,9 +21,10 @@ import {
|
||||
createPr,
|
||||
currentBranch,
|
||||
defaultBranch,
|
||||
type GitStatus,
|
||||
ghStatus,
|
||||
gitStatus,
|
||||
hasRemote,
|
||||
isGitRepo,
|
||||
pushBranch,
|
||||
restoreFiles,
|
||||
} from "@airship/git";
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
type CreateJobRequest,
|
||||
type Effort,
|
||||
type ElementContext,
|
||||
type GitHealth,
|
||||
type JobDiffBundle,
|
||||
type JobStatus,
|
||||
type ModelCatalogue,
|
||||
@@ -63,7 +65,8 @@ export type {
|
||||
} from "@airship/core";
|
||||
/** Re-exported so the CLI depends only on @airship/server. */
|
||||
export { checkAuth, listModels } from "@airship/core";
|
||||
export { isGitRepo } from "@airship/git";
|
||||
export type { GitFailure, GitStatus } from "@airship/git";
|
||||
export { gitStatus, isGitRepo, onGitFailure } from "@airship/git";
|
||||
export type { AgentKind, AirshipSurface, Effort } from "@airship/protocol";
|
||||
|
||||
const WS_PATH = "/__airship/ws";
|
||||
@@ -202,6 +205,19 @@ export async function startServer(opts: ServerOptions): Promise<RunningServer> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Can the overlay offer its git verbs at all?
|
||||
*
|
||||
* Uncached on purpose. It is a handful of `rev-parse` calls, it runs on
|
||||
* connect and after a turn rather than per edit, and a repo can gain its
|
||||
* first commit — or have its git uninstalled — while a tab stays open. A
|
||||
* stale "yes" here is a click that fails for a reason the user was already
|
||||
* told about and can no longer see.
|
||||
*/
|
||||
function gitHealth(): GitHealth {
|
||||
return healthOf(gitStatus(cwd));
|
||||
}
|
||||
|
||||
wss.on("connection", (ws: WebSocket) => {
|
||||
clients.add(ws);
|
||||
send(ws, {
|
||||
@@ -209,6 +225,11 @@ export async function startServer(opts: ServerOptions): Promise<RunningServer> {
|
||||
jobs: jobs.snapshots(),
|
||||
type: "hello",
|
||||
});
|
||||
// Beside the handshake rather than deferred: the transcript can paint a
|
||||
// finished turn's action menu immediately, and a menu that offers Commit
|
||||
// for half a second before greying it out is worse than one that never
|
||||
// offered it.
|
||||
send(ws, { health: gitHealth(), type: "git:health" });
|
||||
// Push the token scan without being asked. The inspector needs it to render
|
||||
// its very first selection, and a request/response round trip would leave
|
||||
// the badges missing for the first element the user clicks. Deferred off the
|
||||
@@ -417,19 +438,23 @@ export async function startServer(opts: ServerOptions): Promise<RunningServer> {
|
||||
status,
|
||||
type: "job:status",
|
||||
});
|
||||
// Before `job:done`, not after. That event is what paints the finished
|
||||
// turn, and the turn menu captures the health it was rendered with — so
|
||||
// sending it afterwards would leave the newest turn a beat behind.
|
||||
broadcast({ health: gitHealth(), type: "git:health" });
|
||||
broadcast({ bundle, jobId: rec.jobId, type: "job:done" });
|
||||
broadcast({ entries: listHistory(cwd), type: "history" });
|
||||
|
||||
if (opts.autoCommit && status === "done" && result.diffs.length) {
|
||||
const sha = commitEdit(
|
||||
const auto = commitEdit(
|
||||
cwd,
|
||||
result.diffs.map((d) => d.file),
|
||||
bundle.summary || displayPrompt
|
||||
);
|
||||
broadcast({
|
||||
error: sha ? undefined : "auto-commit failed",
|
||||
ok: Boolean(sha),
|
||||
sha: sha ?? undefined,
|
||||
error: auto.ok ? undefined : `auto-commit failed: ${auto.error}`,
|
||||
ok: auto.ok,
|
||||
sha: auto.sha,
|
||||
type: "commit:result",
|
||||
});
|
||||
}
|
||||
@@ -468,12 +493,15 @@ export async function startServer(opts: ServerOptions): Promise<RunningServer> {
|
||||
}
|
||||
// Content-restore from the before-state captured by the SDK PreToolUse
|
||||
// hooks — instant and reliable. (SDK `rewindEdit` is the alternative.)
|
||||
const restored = restoreFiles(cwd, bundle.diffs);
|
||||
const ok = restored.length === bundle.diffs.length;
|
||||
const { restored, skipped } = restoreFiles(cwd, bundle.diffs);
|
||||
const ok = skipped.length === 0;
|
||||
broadcast({
|
||||
// The count alone was the whole message, which told the user a number and
|
||||
// nothing they could act on. The first reason is the useful half; the
|
||||
// rest are almost always the same one.
|
||||
error: ok
|
||||
? undefined
|
||||
: `restored ${restored.length}/${bundle.diffs.length} files`,
|
||||
: `restored ${restored.length}/${bundle.diffs.length} files — ${skipped[0].file}: ${skipped[0].reason}`,
|
||||
jobId,
|
||||
ok,
|
||||
type: "undo:result",
|
||||
@@ -496,18 +524,13 @@ export async function startServer(opts: ServerOptions): Promise<RunningServer> {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const sha = commitEdit(
|
||||
const { error, ok, sha } = commitEdit(
|
||||
cwd,
|
||||
bundle.diffs.map((d) => d.file),
|
||||
message || bundle.summary || bundle.prompt
|
||||
);
|
||||
if (!(sha && push)) {
|
||||
send(ws, {
|
||||
error: sha ? undefined : "commit failed",
|
||||
ok: Boolean(sha),
|
||||
sha: sha ?? undefined,
|
||||
type: "commit:result",
|
||||
});
|
||||
if (!(ok && push)) {
|
||||
send(ws, { error, ok, sha, type: "commit:result" });
|
||||
return;
|
||||
}
|
||||
const branch = currentBranch(cwd);
|
||||
@@ -521,6 +544,18 @@ export async function startServer(opts: ServerOptions): Promise<RunningServer> {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Checked here as well as in `prPreflight`: pushing to a remote that does
|
||||
// not exist otherwise spends the network timeout before saying so.
|
||||
if (!hasRemote(cwd)) {
|
||||
send(ws, {
|
||||
error: "committed, but there is no `origin` remote to push to",
|
||||
ok: true,
|
||||
pushed: false,
|
||||
sha,
|
||||
type: "commit:result",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pushed = await pushBranch(cwd, branch);
|
||||
send(ws, {
|
||||
error: pushed.ok
|
||||
@@ -570,13 +605,13 @@ export async function startServer(opts: ServerOptions): Promise<RunningServer> {
|
||||
const { head } = branched;
|
||||
|
||||
const summary = bundle.summary || bundle.prompt;
|
||||
const sha = commitEdit(
|
||||
const committed = commitEdit(
|
||||
cwd,
|
||||
bundle.diffs.map((d) => d.file),
|
||||
summary
|
||||
);
|
||||
if (!sha) {
|
||||
fail("commit", "commit failed");
|
||||
if (!committed.ok) {
|
||||
fail("commit", committed.error ?? "commit failed");
|
||||
return;
|
||||
}
|
||||
const pushed = await pushBranch(cwd, head);
|
||||
@@ -666,6 +701,29 @@ export async function startServer(opts: ServerOptions): Promise<RunningServer> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which git problems stop the overlay's git verbs, and which do not.
|
||||
*
|
||||
* The verbs are commit, push and open a pull request. Deliberately not gated on
|
||||
* `hasCommits`: the first commit in a fresh repository is exactly the thing
|
||||
* that works there, so greying Commit out for want of a HEAD would block the
|
||||
* action that creates one. Having no HEAD costs the diff *baseline*, which is a
|
||||
* per-turn fact carried on `FileDiff.noBaseline` and gates Revert instead.
|
||||
*
|
||||
* Exported for its own test. `gitStatus` short-circuits in the order these are
|
||||
* read, so `error` always describes the field that actually failed here.
|
||||
*/
|
||||
export function healthOf(status: GitStatus): GitHealth {
|
||||
if (status.installed && status.workTree) {
|
||||
return { ok: true };
|
||||
}
|
||||
return {
|
||||
hint: status.hint,
|
||||
ok: false,
|
||||
reason: status.error ?? "git is unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
function preview(prompt: string): string {
|
||||
return prompt.length > 120 ? `${prompt.slice(0, 117)}…` : prompt;
|
||||
}
|
||||
@@ -676,13 +734,19 @@ function prPreflight(cwd: string, bundle: JobDiffBundle | null): string | null {
|
||||
if (!bundle?.diffs.length) {
|
||||
return "nothing to open a pull request for";
|
||||
}
|
||||
if (!isGitRepo(cwd)) {
|
||||
return "not a git repository";
|
||||
// `gitStatus` rather than `isGitRepo`, because this message is the one the
|
||||
// user acts on: a git that is not installed, a bare repo and a directory that
|
||||
// is not a repository all used to arrive here as "not a git repository".
|
||||
const git: GitStatus = gitStatus(cwd);
|
||||
if (!(git.workTree && git.hasCommits)) {
|
||||
return git.error ?? "git is unavailable";
|
||||
}
|
||||
if (!hasRemote(cwd)) {
|
||||
return "no `origin` remote";
|
||||
}
|
||||
const gh = ghStatus();
|
||||
// Scoped to the repo: `gh auth status` resolves which host to check from the
|
||||
// remote, so asking without a cwd can pass while the push still fails.
|
||||
const gh = ghStatus(cwd);
|
||||
if (!gh.authed) {
|
||||
return gh.error ?? "gh is unavailable";
|
||||
}
|
||||
@@ -707,8 +771,12 @@ function ensureFeatureBranch(
|
||||
return { head };
|
||||
}
|
||||
const name = requested || `airship/${jobId.slice(0, 8)}`;
|
||||
if (!createBranch(cwd, name)) {
|
||||
return { error: `could not create branch ${name}`, stage: "branch" };
|
||||
const branched = createBranch(cwd, name);
|
||||
if (!branched.ok) {
|
||||
return {
|
||||
error: `could not create branch ${name}: ${branched.error}`,
|
||||
stage: "branch",
|
||||
};
|
||||
}
|
||||
return { head: name };
|
||||
}
|
||||
|
||||
@@ -174,6 +174,17 @@ describe("the editor server, over raw sockets", () => {
|
||||
expect(reply.startsWith("HTTP/1.1 101 ")).toBe(true);
|
||||
expect(reply).toContain('"type":"hello"');
|
||||
});
|
||||
|
||||
it("reports whether git works, beside the handshake", async () => {
|
||||
// Beside `hello` rather than deferred: the transcript paints a finished
|
||||
// turn's action menu immediately, and a menu that offers Commit for half a
|
||||
// second before greying it out is worse than one that never offered it.
|
||||
const reply = await exchange(
|
||||
port,
|
||||
upgradeRequest("/__airship/ws", { host: `localhost:${port}` })
|
||||
);
|
||||
expect(reply).toContain('"type":"git:health"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Host gate", () => {
|
||||
|
||||
Generated
+3
@@ -210,6 +210,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^26.2.0
|
||||
version: 26.2.0
|
||||
vitest:
|
||||
specifier: ^4.1.10
|
||||
version: 4.1.10(@types/node@26.2.0)(@vitest/browser-playwright@4.1.10)(happy-dom@20.11.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))
|
||||
|
||||
packages/overlay:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user