feat: add pr diff and checkout (task 0014)
All checks were successful
CI / test (pull_request) Successful in 42s

Add the two PR commands that touch content and the local worktree:

- `pr diff <n>` fetches the raw diff from the `.diff` endpoint (forcing a
  text response, which the JSON-defaulting client would otherwise discard),
  truncates at 4000 chars with separate `truncated`/`original_length` fields
  and a prepended `--full` suggestion; `--full` returns the raw diff.
- `pr checkout <n>` reads the PR head branch from the PR fetch and fetches
  `refs/pull/{n}/head` from origin (uniform for same-repo and fork PRs, ADR
  0011), three-cased on local branch state so re-checkout is idempotent and
  divergent local commits fail with `GIT_ERROR` rather than being discarded.

Introduces the `GIT_ERROR` mapping (`runGit`) carrying git's first stderr
line plus a remediation help line, and widens the PR 404 classifier so a
`.diff` path still resolves to `PR_NOT_FOUND`.
This commit is contained in:
2026-07-13 22:15:03 -04:00
parent ba5171bb59
commit 3db6d421d4
8 changed files with 675 additions and 9 deletions

View File

@@ -1,5 +1,6 @@
import type { CliDeps } from "./deps.js";
import { runSubprocess } from "./subprocess.js";
import { axiError } from "./errors.js";
import { runSubprocess, type SubprocessResult } from "./subprocess.js";
export interface RemoteRepo {
host: string;
@@ -62,6 +63,105 @@ export async function currentBranch(deps: CliDeps): Promise<string | null> {
return branch && branch !== "HEAD" ? branch : null;
}
/** git's first non-empty stderr line — the diagnostic surfaced in a GIT_ERROR. */
function firstStderrLine(stderr: string): string {
for (const line of stderr.split("\n")) {
const trimmed = line.trim();
if (trimmed) {
return trimmed;
}
}
return "";
}
/**
* Run git, mapping a spawn failure or non-zero exit to `GIT_ERROR` carrying
* git's first stderr line and the caller's remediation help. `help` names the
* specific fix for the step that failed, so each git step can point at its own.
*/
export async function runGit(
deps: CliDeps,
args: string[],
help: string[],
fallbackMessage?: string,
): Promise<SubprocessResult> {
const result = await runSubprocess("git", args, { cwd: deps.cwd, env: deps.env });
if (result.enoent) {
throw axiError("git is not installed or not on the PATH", "GIT_ERROR", [
"Install git and ensure it is available on the PATH",
]);
}
if (result.code !== 0) {
throw axiError(
firstStderrLine(result.stderr) ||
fallbackMessage ||
`git ${args[0] ?? ""} exited with a non-zero status`,
"GIT_ERROR",
help,
);
}
return result;
}
/** Whether a local branch of this name exists in the working tree's repository. */
export async function branchExists(deps: CliDeps, branch: string): Promise<boolean> {
const result = await runSubprocess(
"git",
["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
{ cwd: deps.cwd, env: deps.env },
);
return !result.enoent && result.code === 0;
}
/**
* Check out a pull request's head commit into a local branch, fetching it from
* origin under `refs/pull/{number}/head` — the ref the base repo exposes for
* same-repo and fork PRs alike (ADR 0011). Three-cased on the local branch's
* state so re-running is idempotent:
* - absent: fetch into the branch, then check it out;
* - present but not checked out: force-fetch (the local branch is defined as a
* mirror of the PR head), then check it out;
* - currently checked out: fetch the ref, then fast-forward merge — local
* commits that diverge from the PR head fail with `GIT_ERROR` rather than
* being discarded silently.
*/
export async function checkoutPullHead(
deps: CliDeps,
number: number,
branch: string,
): Promise<void> {
const ref = `pull/${number}/head`;
const fetchHelp = [
"Check your network connection and that `origin` points at the Gitea instance",
];
const current = await currentBranch(deps);
if (current === branch) {
await runGit(deps, ["fetch", "origin", ref], fetchHelp);
// A ff-only merge fails when local commits diverge from the PR head; that
// surfaces as GIT_ERROR with divergence-specific help, never discarding them.
await runGit(
deps,
["merge", "--ff-only", "FETCH_HEAD"],
[
`The checked-out ${branch} has local commits that are not on the PR head, so it cannot fast-forward — they were left intact`,
"Reconcile them (e.g. `git rebase FETCH_HEAD`), or reset the branch once you have preserved them",
],
"git merge --ff-only could not fast-forward",
);
return;
}
// A fresh branch is fetched into directly; an existing one is force-updated,
// since it is a mirror of the PR head and a moved head is not fast-forwardable.
const exists = await branchExists(deps, branch);
const refspec = exists ? `+${ref}:${branch}` : `${ref}:${branch}`;
await runGit(deps, ["fetch", "origin", refspec], fetchHelp);
await runGit(deps, ["checkout", branch], [
`Commit or stash your changes before checking out ${branch}`,
]);
}
export async function detectRemote(deps: CliDeps): Promise<RemoteRepo | null> {
const result = await runSubprocess("git", ["remote", "get-url", "origin"], {
cwd: deps.cwd,