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
|
* Worth testing specifically because a wrong `before` picks a different branch
|
||||||
* loudly: `restoreFiles` skips any diff whose `before` is not a string, so the
|
* of `restoreFiles`, and one of them is destructive: a string is rewritten,
|
||||||
* only symptom is undo reporting "restored 0/1 files" long after the fact.
|
* `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 { execFileSync } from "node:child_process";
|
||||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
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.file).toBe("fresh.txt");
|
||||||
expect(diff.before).toBeNull();
|
expect(diff.before).toBeNull();
|
||||||
expect(diff.isNew).toBe(true);
|
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", () => {
|
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 { existsSync, readFileSync } from "node:fs";
|
||||||
import { relative, resolve } from "node:path";
|
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 type { FileDiff } from "@airship/protocol";
|
||||||
import { createPatch } from "diff";
|
import { createPatch } from "diff";
|
||||||
import { toPosixPath } from "./paths";
|
import { toPosixPath } from "./paths";
|
||||||
@@ -35,15 +35,18 @@ function readSafe(abs: string): string | null {
|
|||||||
* Line endings, flattened for comparison and diffing only.
|
* Line endings, flattened for comparison and diffing only.
|
||||||
*
|
*
|
||||||
* On Windows `core.autocrlf=true` is the Git-for-Windows default, so the
|
* 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
|
* working tree is CRLF while every agent's edit tool writes LF. Comparing a
|
||||||
* blob `fileAtHead` returns for the Codex path is LF too. Comparing those
|
* file's pre-edit CRLF form against its post-edit LF form makes every line
|
||||||
* directly makes every line of every file differ by its terminator, so a
|
* differ by its terminator, so a one-line edit renders as a whole-file rewrite
|
||||||
* one-line edit renders as a whole-file rewrite with garbage `+N −M` counts and
|
* with garbage `+N −M` counts and review comments that anchor to the wrong
|
||||||
* review comments that anchor to the wrong lines.
|
* lines.
|
||||||
*
|
*
|
||||||
* Only the comparison and the patch are normalized. `FileDiff.before`/`after`
|
* Only the comparison and the patch are normalized. `FileDiff.before`/`after`
|
||||||
* keep the bytes actually on disk, because `restoreFiles` writes `before` back
|
* 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
|
* 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
|
* 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. */
|
/** 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 {
|
export class DiffCapture {
|
||||||
private readonly before = new Map<string, string | null>();
|
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>();
|
private readonly touched = new Set<string>();
|
||||||
/** Keys are canonical, so the root they are made relative to must be too. */
|
/** Keys are canonical, so the root they are made relative to must be too. */
|
||||||
private readonly root: string;
|
private readonly root: string;
|
||||||
@@ -136,7 +148,11 @@ export class DiffCapture {
|
|||||||
recordAfterTheFact(filePath: string): void {
|
recordAfterTheFact(filePath: string): void {
|
||||||
const abs = canonical(this.cwd, filePath);
|
const abs = canonical(this.cwd, filePath);
|
||||||
if (!this.before.has(abs)) {
|
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);
|
this.touched.add(abs);
|
||||||
}
|
}
|
||||||
@@ -174,6 +190,7 @@ export class DiffCapture {
|
|||||||
const rel = toPosixPath(relative(this.root, abs));
|
const rel = toPosixPath(relative(this.root, abs));
|
||||||
const patch = createPatch(rel, forDiff(before), forDiff(after));
|
const patch = createPatch(rel, forDiff(before), forDiff(after));
|
||||||
const { additions, deletions } = countChanges(patch);
|
const { additions, deletions } = countChanges(patch);
|
||||||
|
const noBaseline = this.unavailable.has(abs);
|
||||||
diffs.push({
|
diffs.push({
|
||||||
additions,
|
additions,
|
||||||
after,
|
after,
|
||||||
@@ -181,7 +198,10 @@ export class DiffCapture {
|
|||||||
deletions,
|
deletions,
|
||||||
file: rel,
|
file: rel,
|
||||||
isDeleted: after === null,
|
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,
|
patch,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* result. Provider-specific work lives behind `AgentAdapter` in `./providers`.
|
* 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 {
|
import type {
|
||||||
AgentKind,
|
AgentKind,
|
||||||
AttrEditTarget,
|
AttrEditTarget,
|
||||||
@@ -117,14 +117,40 @@ export async function runEdit(
|
|||||||
// and the pre-turn dirty set is primed as a baseline. Both halves belong
|
// 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
|
// together and neither is provider-specific, so they live here rather than
|
||||||
// being copied into every adapter that needs them.
|
// 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(
|
const diffCapture = new DiffCapture(
|
||||||
input.cwd,
|
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) {
|
if (adapter.needsGitBaseline) {
|
||||||
diffCapture.prime(
|
diffCapture.prime(canPrime ? dirtyFiles(input.cwd) : new Set<string>());
|
||||||
isGitRepo(input.cwd) ? dirtyFiles(input.cwd) : new Set<string>()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One recorder owns both the persisted array and the live sink, so the
|
// 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",
|
"build": "tsup src/index.ts --format esm --dts --sourcemap --clean",
|
||||||
"clean": "node ../../scripts/clean.mjs dist .turbo",
|
"clean": "node ../../scripts/clean.mjs dist .turbo",
|
||||||
"dev": "tsup src/index.ts --format esm --dts --sourcemap --watch",
|
"dev": "tsup src/index.ts --format esm --dts --sourcemap --watch",
|
||||||
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@airship/protocol": "workspace:*"
|
"@airship/protocol": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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
|
* @airship/git — minimal git helpers for the daemon: optional auto-commit of an
|
||||||
* accepted edit (with a Conventional-Commits message) and a content-restore
|
* accepted edit (with a Conventional-Commits message) and a content-restore
|
||||||
* undo that rewrites files to their pre-edit state.
|
* 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 { execFile, execFileSync } from "node:child_process";
|
||||||
import {
|
import {
|
||||||
@@ -17,38 +24,279 @@ import type { FileDiff } from "@airship/protocol";
|
|||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
function git(cwd: string, args: string[]): string {
|
/**
|
||||||
return execFileSync("git", args, {
|
* A single blob can exceed Node's 1MB default and would otherwise throw — and
|
||||||
cwd,
|
* the throw would be indistinguishable from every other failure. Applied to
|
||||||
encoding: "utf8",
|
* every invocation rather than only the blob read, because `git push` against a
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
* chatty remote can outrun 1MB of progress output just as easily.
|
||||||
}).trim();
|
*/
|
||||||
|
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 {
|
type GitRun<T> = { ok: true; stdout: T } | { failure: GitFailure; ok: false };
|
||||||
try {
|
|
||||||
return git(cwd, args);
|
/**
|
||||||
} catch {
|
* A diagnostic tap, for `--debug`. Deliberately *not* how error text reaches
|
||||||
return null;
|
* 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
|
* `cwd` is checked before spawning because a directory that does not exist
|
||||||
* carry significant leading/trailing bytes, so they cannot go through `git()`.
|
* 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 {
|
function tryGitRaw(cwd: string, args: string[]): string | null {
|
||||||
try {
|
const result = run(cwd, args);
|
||||||
return execFileSync("git", args, {
|
return result.ok ? result.stdout : null;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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
|
* 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
|
* 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.
|
* 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(
|
async function tryExec(
|
||||||
bin: string,
|
bin: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
cwd: string
|
cwd: string,
|
||||||
|
timeoutMs: number = NETWORK_TIMEOUT_MS
|
||||||
): Promise<{ error?: string; ok: boolean; stdout: string }> {
|
): Promise<{ error?: string; ok: boolean; stdout: string }> {
|
||||||
try {
|
if (!existsSync(cwd)) {
|
||||||
const { stdout } = await execFileAsync(bin, args, { cwd });
|
|
||||||
return { ok: true, stdout: stdout.trim() };
|
|
||||||
} catch (err) {
|
|
||||||
const e = err as { stderr?: string; message?: string };
|
|
||||||
return {
|
return {
|
||||||
error: (e.stderr || e.message || "command failed").trim(),
|
error: failureText(missingCwd(bin, args, cwd)),
|
||||||
ok: false,
|
ok: false,
|
||||||
stdout: "",
|
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 {
|
export function isGitRepo(cwd: string): boolean {
|
||||||
return tryGit(cwd, ["rev-parse", "--is-inside-work-tree"]) === "true";
|
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]));
|
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,
|
* Why git will not work here, in the order the answers depend on each other.
|
||||||
* newly added, or outside the repo).
|
*
|
||||||
|
* 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
|
* 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
|
* 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
|
* 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.
|
* 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
|
* `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
|
* working directory rather than the repo root, so a project root that sits in a
|
||||||
* subdirectory of the repo still works.
|
* 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
|
// Both sides are canonicalized before being compared. Callers hand us paths
|
||||||
// from mixed sources — some resolved through git, some as the user typed
|
// from mixed sources — some resolved through git, some as the user typed
|
||||||
// them — and on macOS `/tmp` and `/var` are symlinks, so an uncanonicalized
|
// them — and on macOS `/tmp` and `/var` are symlinks, so an uncanonicalized
|
||||||
// comparison reads a perfectly normal path as escaping the project.
|
// comparison reads a perfectly normal path as escaping the project.
|
||||||
const root = canonicalPath(cwd);
|
const root = canonicalPath(cwd);
|
||||||
const rel = relative(root, canonicalPath(resolve(cwd, path)));
|
const rel = relative(root, canonicalPath(resolve(cwd, path)));
|
||||||
// `..` means the path really does escape the working directory; `HEAD:./..`
|
// `..` means the path really does escape the working directory. Unavailable
|
||||||
// is not valid git syntax and there is nothing sensible to return.
|
// 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("..")) {
|
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`
|
* while preserving whatever spelling the caller passed, so `C:\Users\RUNNER~1`
|
||||||
* stays short while anything that went through `GetFinalPathNameByHandle`
|
* stays short while anything that went through `GetFinalPathNameByHandle`
|
||||||
* reads `C:\Users\runneradmin`. Two spellings of one directory make `relative`
|
* 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
|
* from HEAD — which on the Codex path silently turns each edit into a
|
||||||
* whole-file diff with no baseline.
|
* 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
|
// 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
|
// 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.
|
// 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");
|
const fields = raw.split("\0");
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < fields.length) {
|
while (i < fields.length) {
|
||||||
@@ -208,8 +670,14 @@ export function dirtyFiles(cwd: string): Set<string> {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBranch(cwd: string, name: string): boolean {
|
export function createBranch(
|
||||||
return tryGit(cwd, ["checkout", "-b", name]) !== null;
|
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 {
|
export interface GhStatus {
|
||||||
@@ -218,24 +686,34 @@ export interface GhStatus {
|
|||||||
installed: boolean;
|
installed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether `gh` is usable. Both halves matter — installed but unauthenticated
|
/**
|
||||||
* is the common case and fails much later with a confusing message. */
|
* Whether `gh` is usable. Both halves matter — installed but unauthenticated is
|
||||||
export function ghStatus(): GhStatus {
|
* the common case and fails much later with a confusing message.
|
||||||
try {
|
*
|
||||||
execFileSync("gh", ["--version"], { stdio: "ignore" });
|
* `cwd` is passed to `auth status` and not to `--version`: the first resolves
|
||||||
} catch {
|
* which host to check from the repository's own remote, the second must be
|
||||||
return { authed: false, error: "gh is not installed", installed: false };
|
* answerable anywhere.
|
||||||
}
|
*/
|
||||||
try {
|
export function ghStatus(cwd?: string): GhStatus {
|
||||||
execFileSync("gh", ["auth", "status"], { stdio: "ignore" });
|
const version = runBin("gh", undefined, ["--version"]);
|
||||||
return { authed: true, installed: true };
|
if (!version.ok) {
|
||||||
} catch {
|
|
||||||
return {
|
return {
|
||||||
authed: false,
|
authed: false,
|
||||||
error: "gh is installed but not authenticated (`gh auth login`)",
|
error: failureText(version.failure),
|
||||||
installed: true,
|
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`. */
|
/** Push a branch and set upstream. Async — see `tryExec`. */
|
||||||
@@ -279,8 +757,9 @@ export async function createPr(
|
|||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
return { error: result.error, ok: false };
|
return { error: result.error, ok: false };
|
||||||
}
|
}
|
||||||
// `gh` prints the PR URL as its last line.
|
// `gh` prints the PR URL as its last line. Split on both terminators: the
|
||||||
const url = result.stdout.split("\n").filter(Boolean).pop();
|
// trim above only reaches the last one.
|
||||||
|
const url = result.stdout.split(NEWLINE).filter(Boolean).pop();
|
||||||
return { ok: true, url };
|
return { ok: true, url };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,52 +767,115 @@ export function currentSha(cwd: string): string | null {
|
|||||||
return tryGit(cwd, ["rev-parse", "HEAD"]);
|
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.
|
* 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(
|
export function commitEdit(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
files: string[],
|
files: string[],
|
||||||
summary: string
|
summary: string
|
||||||
): string | null {
|
): CommitResult {
|
||||||
if (files.length === 0 || !isGitRepo(cwd)) {
|
if (files.length === 0) {
|
||||||
return null;
|
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(
|
const subject = `chore(airship): ${summary.trim() || "visual edit"}`.slice(
|
||||||
0,
|
0,
|
||||||
100
|
100
|
||||||
);
|
);
|
||||||
try {
|
const specs = files.map((file) => `:(literal)${file}`);
|
||||||
git(cwd, ["add", "--", ...files]);
|
const staged = run(cwd, ["add", "--", ...specs]);
|
||||||
git(cwd, ["commit", "--no-verify", "-m", subject, "--", ...files]);
|
if (!staged.ok) {
|
||||||
return currentSha(cwd);
|
return { error: failureText(staged.failure), ok: false };
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
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
|
* 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
|
* (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 restored: string[] = [];
|
||||||
|
const skipped: { file: string; reason: string }[] = [];
|
||||||
for (const d of diffs) {
|
for (const d of diffs) {
|
||||||
const abs = resolve(cwd, d.file);
|
const abs = resolve(cwd, d.file);
|
||||||
if (d.isNew) {
|
if (d.noBaseline) {
|
||||||
if (existsSync(abs)) {
|
skipped.push({
|
||||||
rmSync(abs, { force: true });
|
file: d.file,
|
||||||
}
|
reason: "no baseline was captured, so there is nothing to restore",
|
||||||
restored.push(d.file);
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (typeof d.before === "string") {
|
try {
|
||||||
mkdirSync(dirname(abs), { recursive: true });
|
if (d.isNew) {
|
||||||
writeFileSync(abs, d.before, "utf8");
|
if (existsSync(abs)) {
|
||||||
restored.push(d.file);
|
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(),
|
file: z.string(),
|
||||||
isDeleted: z.boolean(),
|
isDeleted: z.boolean(),
|
||||||
isNew: 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(),
|
patch: z.string(),
|
||||||
});
|
});
|
||||||
export type FileDiff = z.infer<typeof FileDiffSchema>;
|
export type FileDiff = z.infer<typeof FileDiffSchema>;
|
||||||
@@ -654,10 +666,27 @@ export interface JobSnapshot {
|
|||||||
// WebSocket protocol: server → client
|
// 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 =
|
export type ServerEvent =
|
||||||
/** `defaultAgent` is the daemon's `--agent` setting, so the composer's picker
|
/** `defaultAgent` is the daemon's `--agent` setting, so the composer's picker
|
||||||
* can render the right backend on first paint instead of guessing. */
|
* can render the right backend on first paint instead of guessing. */
|
||||||
| { type: "hello"; jobs: JobSnapshot[]; defaultAgent: AgentKind }
|
| { 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 }
|
| { type: "job:created"; job: JobSnapshot }
|
||||||
/** Coarse one-line status ("Reading foo.tsx"). Self-overwriting by design —
|
/** Coarse one-line status ("Reading foo.tsx"). Self-overwriting by design —
|
||||||
* it drives the turn's live status pill, not the transcript body. */
|
* 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,
|
createPr,
|
||||||
currentBranch,
|
currentBranch,
|
||||||
defaultBranch,
|
defaultBranch,
|
||||||
|
type GitStatus,
|
||||||
ghStatus,
|
ghStatus,
|
||||||
|
gitStatus,
|
||||||
hasRemote,
|
hasRemote,
|
||||||
isGitRepo,
|
|
||||||
pushBranch,
|
pushBranch,
|
||||||
restoreFiles,
|
restoreFiles,
|
||||||
} from "@airship/git";
|
} from "@airship/git";
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
type CreateJobRequest,
|
type CreateJobRequest,
|
||||||
type Effort,
|
type Effort,
|
||||||
type ElementContext,
|
type ElementContext,
|
||||||
|
type GitHealth,
|
||||||
type JobDiffBundle,
|
type JobDiffBundle,
|
||||||
type JobStatus,
|
type JobStatus,
|
||||||
type ModelCatalogue,
|
type ModelCatalogue,
|
||||||
@@ -63,7 +65,8 @@ export type {
|
|||||||
} from "@airship/core";
|
} from "@airship/core";
|
||||||
/** Re-exported so the CLI depends only on @airship/server. */
|
/** Re-exported so the CLI depends only on @airship/server. */
|
||||||
export { checkAuth, listModels } from "@airship/core";
|
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";
|
export type { AgentKind, AirshipSurface, Effort } from "@airship/protocol";
|
||||||
|
|
||||||
const WS_PATH = "/__airship/ws";
|
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) => {
|
wss.on("connection", (ws: WebSocket) => {
|
||||||
clients.add(ws);
|
clients.add(ws);
|
||||||
send(ws, {
|
send(ws, {
|
||||||
@@ -209,6 +225,11 @@ export async function startServer(opts: ServerOptions): Promise<RunningServer> {
|
|||||||
jobs: jobs.snapshots(),
|
jobs: jobs.snapshots(),
|
||||||
type: "hello",
|
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
|
// 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
|
// 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
|
// 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,
|
status,
|
||||||
type: "job: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({ bundle, jobId: rec.jobId, type: "job:done" });
|
||||||
broadcast({ entries: listHistory(cwd), type: "history" });
|
broadcast({ entries: listHistory(cwd), type: "history" });
|
||||||
|
|
||||||
if (opts.autoCommit && status === "done" && result.diffs.length) {
|
if (opts.autoCommit && status === "done" && result.diffs.length) {
|
||||||
const sha = commitEdit(
|
const auto = commitEdit(
|
||||||
cwd,
|
cwd,
|
||||||
result.diffs.map((d) => d.file),
|
result.diffs.map((d) => d.file),
|
||||||
bundle.summary || displayPrompt
|
bundle.summary || displayPrompt
|
||||||
);
|
);
|
||||||
broadcast({
|
broadcast({
|
||||||
error: sha ? undefined : "auto-commit failed",
|
error: auto.ok ? undefined : `auto-commit failed: ${auto.error}`,
|
||||||
ok: Boolean(sha),
|
ok: auto.ok,
|
||||||
sha: sha ?? undefined,
|
sha: auto.sha,
|
||||||
type: "commit:result",
|
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
|
// Content-restore from the before-state captured by the SDK PreToolUse
|
||||||
// hooks — instant and reliable. (SDK `rewindEdit` is the alternative.)
|
// hooks — instant and reliable. (SDK `rewindEdit` is the alternative.)
|
||||||
const restored = restoreFiles(cwd, bundle.diffs);
|
const { restored, skipped } = restoreFiles(cwd, bundle.diffs);
|
||||||
const ok = restored.length === bundle.diffs.length;
|
const ok = skipped.length === 0;
|
||||||
broadcast({
|
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
|
error: ok
|
||||||
? undefined
|
? undefined
|
||||||
: `restored ${restored.length}/${bundle.diffs.length} files`,
|
: `restored ${restored.length}/${bundle.diffs.length} files — ${skipped[0].file}: ${skipped[0].reason}`,
|
||||||
jobId,
|
jobId,
|
||||||
ok,
|
ok,
|
||||||
type: "undo:result",
|
type: "undo:result",
|
||||||
@@ -496,18 +524,13 @@ export async function startServer(opts: ServerOptions): Promise<RunningServer> {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sha = commitEdit(
|
const { error, ok, sha } = commitEdit(
|
||||||
cwd,
|
cwd,
|
||||||
bundle.diffs.map((d) => d.file),
|
bundle.diffs.map((d) => d.file),
|
||||||
message || bundle.summary || bundle.prompt
|
message || bundle.summary || bundle.prompt
|
||||||
);
|
);
|
||||||
if (!(sha && push)) {
|
if (!(ok && push)) {
|
||||||
send(ws, {
|
send(ws, { error, ok, sha, type: "commit:result" });
|
||||||
error: sha ? undefined : "commit failed",
|
|
||||||
ok: Boolean(sha),
|
|
||||||
sha: sha ?? undefined,
|
|
||||||
type: "commit:result",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const branch = currentBranch(cwd);
|
const branch = currentBranch(cwd);
|
||||||
@@ -521,6 +544,18 @@ export async function startServer(opts: ServerOptions): Promise<RunningServer> {
|
|||||||
});
|
});
|
||||||
return;
|
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);
|
const pushed = await pushBranch(cwd, branch);
|
||||||
send(ws, {
|
send(ws, {
|
||||||
error: pushed.ok
|
error: pushed.ok
|
||||||
@@ -570,13 +605,13 @@ export async function startServer(opts: ServerOptions): Promise<RunningServer> {
|
|||||||
const { head } = branched;
|
const { head } = branched;
|
||||||
|
|
||||||
const summary = bundle.summary || bundle.prompt;
|
const summary = bundle.summary || bundle.prompt;
|
||||||
const sha = commitEdit(
|
const committed = commitEdit(
|
||||||
cwd,
|
cwd,
|
||||||
bundle.diffs.map((d) => d.file),
|
bundle.diffs.map((d) => d.file),
|
||||||
summary
|
summary
|
||||||
);
|
);
|
||||||
if (!sha) {
|
if (!committed.ok) {
|
||||||
fail("commit", "commit failed");
|
fail("commit", committed.error ?? "commit failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pushed = await pushBranch(cwd, head);
|
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 {
|
function preview(prompt: string): string {
|
||||||
return prompt.length > 120 ? `${prompt.slice(0, 117)}…` : prompt;
|
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) {
|
if (!bundle?.diffs.length) {
|
||||||
return "nothing to open a pull request for";
|
return "nothing to open a pull request for";
|
||||||
}
|
}
|
||||||
if (!isGitRepo(cwd)) {
|
// `gitStatus` rather than `isGitRepo`, because this message is the one the
|
||||||
return "not a git repository";
|
// 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)) {
|
if (!hasRemote(cwd)) {
|
||||||
return "no `origin` remote";
|
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) {
|
if (!gh.authed) {
|
||||||
return gh.error ?? "gh is unavailable";
|
return gh.error ?? "gh is unavailable";
|
||||||
}
|
}
|
||||||
@@ -707,8 +771,12 @@ function ensureFeatureBranch(
|
|||||||
return { head };
|
return { head };
|
||||||
}
|
}
|
||||||
const name = requested || `airship/${jobId.slice(0, 8)}`;
|
const name = requested || `airship/${jobId.slice(0, 8)}`;
|
||||||
if (!createBranch(cwd, name)) {
|
const branched = createBranch(cwd, name);
|
||||||
return { error: `could not create branch ${name}`, stage: "branch" };
|
if (!branched.ok) {
|
||||||
|
return {
|
||||||
|
error: `could not create branch ${name}: ${branched.error}`,
|
||||||
|
stage: "branch",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { head: name };
|
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.startsWith("HTTP/1.1 101 ")).toBe(true);
|
||||||
expect(reply).toContain('"type":"hello"');
|
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", () => {
|
describe("the Host gate", () => {
|
||||||
|
|||||||
Generated
+3
@@ -210,6 +210,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^26.2.0
|
specifier: ^26.2.0
|
||||||
version: 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:
|
packages/overlay:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user