Compare commits

..

2 Commits

Author SHA1 Message Date
d034fce3ea Merge pull request 'feat: add pr diff and checkout (task 0014)' (#14) from task-0014-pr-diff-and-checkout into main
All checks were successful
CI / test (push) Successful in 42s
2026-07-14 07:02:40 -04:00
3db6d421d4 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`.
2026-07-13 22:15:03 -04:00
8 changed files with 675 additions and 9 deletions

View File

@@ -15,9 +15,29 @@ Output: `checkout: { number, branch, status: "ok" }`.
## Acceptance criteria ## Acceptance criteria
- [ ] `pr diff <n>` outputs the diff, adding `truncated: true` and `original_length` when over 4000 chars plus a `--full` next-step suggestion; `--full` returns the raw diff - [x] `pr diff <n>` outputs the diff, adding `truncated: true` and `original_length` when over 4000 chars plus a `--full` next-step suggestion; `--full` returns the raw diff
- [ ] `pr checkout <n>` handles all three local-branch cases and re-running it is idempotent - [x] `pr checkout <n>` handles all three local-branch cases and re-running it is idempotent
- [ ] A checked-out branch that has diverged from the PR head fails with `GIT_ERROR` and an explanatory help line, leaving local commits intact - [x] A checked-out branch that has diverged from the PR head fails with `GIT_ERROR` and an explanatory help line, leaving local commits intact
- [ ] Other git failures (dirty worktree, network) map to `GIT_ERROR` with git's first stderr line - [x] Other git failures (dirty worktree, network) map to `GIT_ERROR` with git's first stderr line
- [ ] Checkout works for a fork PR whose head repo is not a configured remote (via `refs/pull/{n}/head`) - [x] Checkout works for a fork PR whose head repo is not a configured remote (via `refs/pull/{n}/head`)
- [ ] Tests cover diff truncation boundaries and the three checkout cases (git behavior exercised against a scratch repository, API responses from the fixture server) - [x] Tests cover diff truncation boundaries and the three checkout cases (git behavior exercised against a scratch repository, API responses from the fixture server)
## Implementation Notes
The raw diff is fetched through the generated client's `repoDownloadPullDiffOrPatch`, but with `{ format: "text" }` forced per call.
The `giteaApi` wrapper sets `baseApiParams.format: "json"`, so every response otherwise runs through `response.json()` — which would discard a plain-text `.diff` body and leave `data` null.
Forcing `text` reads the diff verbatim.
To let the fixture server return a non-JSON diff body, `FixtureServer`'s route gained a `raw?: string` field, served verbatim as `text/plain` (bypassing the `JSON.stringify` the other fields get).
`PULL_PATH` in `src/errors.ts` was widened from `(\d+)(?:\/|$)` to `(\d+)(?:[./]|$)` so a 404 on `/pulls/{n}.diff` still classifies as `PR_NOT_FOUND` rather than falling through to `REPO_NOT_FOUND` — the diff endpoint's number is followed by a `.` suffix rather than a `/` or end-of-path.
Diff truncation is its own `truncateDiff` in `src/diff.ts`, deliberately not reusing `truncateBody`: it signals the cut with separate `truncated`/`original_length` fields (so the diff text stays a verbatim prefix) rather than the inline hint bodies use, and does no body-cleaning.
For the checked-out-and-diverged case, git's `merge --ff-only` prints its `hint:` lines to stderr before the `fatal:` line, so the surfaced `GIT_ERROR` message is that first `hint:` line; the plain-language divergence explanation and remediation live in the help lines, which is where the acceptance criterion's "explanatory help line" is asserted.
This matches the spec's "carrying git's first stderr line" literally.
`runGit` (the shared git-runner that maps a non-zero exit to `GIT_ERROR` with git's first stderr line) gained an optional `fallbackMessage` argument during the `/review-uncommitted` pass, so the `merge --ff-only` step routes through it instead of re-implementing the enoent/non-zero mapping inline (a Duplicated-Code judgement call the Standards axis raised).
Process note: `/implement` front-loaded the implementation before the test-writer sub-agent authored the tests, so each TDD cycle was green-on-first-run rather than red-first.
Every test was still authored independently by a `general-purpose` sub-agent from the public CLI interface alone (it never read the implementation source), one behavior at a time.

View File

@@ -37,7 +37,8 @@ import {
splitFlag, splitFlag,
} from "../flags.js"; } from "../flags.js";
import { fetchChecks } from "../checks.js"; import { fetchChecks } from "../checks.js";
import { currentBranch } from "../git.js"; import { fetchPullDiff, truncateDiff } from "../diff.js";
import { checkoutPullHead, currentBranch } from "../git.js";
import { resolveLabelIds, resolveMilestoneId } from "../lookup.js"; import { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
import { fetchAllPages, readTotalCount } from "../paginate.js"; import { fetchAllPages, readTotalCount } from "../paginate.js";
import { formatCountLine, renderDetail, renderList, renderScalar, type DetailBlock } from "../render.js"; import { formatCountLine, renderDetail, renderList, renderScalar, type DetailBlock } from "../render.js";
@@ -50,6 +51,8 @@ export const PR_HELP = `usage: gitea-axi pr <command> [flags]
commands: commands:
list List pull requests in the current repository list List pull requests in the current repository
view Show a single pull request's details view Show a single pull request's details
diff Show a pull request's raw diff
checkout Check the pull request's head branch out locally
checks Show a pull request's CI check results checks Show a pull request's CI check results
create Create a pull request create Create a pull request
edit Edit a pull request's title, body, labels, assignees, reviewers, milestone, or base edit Edit a pull request's title, body, labels, assignees, reviewers, milestone, or base
@@ -180,6 +183,34 @@ global flags:
--login <name> Select a tea login profile by name --login <name> Select a tea login profile by name
`; `;
export const PR_DIFF_HELP = `usage: gitea-axi pr diff <number> [flags]
Show a pull request's raw unified diff. The diff is truncated at 4000 chars
unless --full is given.
flags:
--full Return the complete diff without truncating it
--help Show this help
global flags:
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
`;
export const PR_CHECKOUT_HELP = `usage: gitea-axi pr checkout <number>
Check a pull request's head branch out into the current working tree, fetching
it from origin under refs/pull/<number>/head (works for fork PRs too). Re-running
is idempotent.
flags:
--help Show this help
global flags:
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
`;
export const PR_LIST_HELP = `usage: gitea-axi pr list [flags] export const PR_LIST_HELP = `usage: gitea-axi pr list [flags]
List pull requests in the current repository. List pull requests in the current repository.
@@ -911,6 +942,60 @@ async function prChecks(deps: CliDeps, args: string[]): Promise<string> {
}); });
} }
async function prDiff(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_DIFF_HELP;
}
const { flags, positionals } = parseFlags(args, { "--full": { takesValue: false } }, "pr diff");
const number = parsePositionalNumber(positionals, "pr diff", "pull request");
const full = flags["--full"] === true;
const context = await resolveRepoContext(deps);
const api = createClient(context);
const diff = await fetchPullDiff(api, context, number);
const result = truncateDiff(diff, full);
const item: Record<string, unknown> = { number, diff: result.diff };
// The `--full` next step is prepended above the standard `pr view` line only
// when the diff was actually cut short; the separate `truncated`/`original_length`
// fields signal the cut, keeping the diff text a verbatim prefix (spec).
const help = [suggestCommand(context, `pr view ${number}`, "to see the pull request in full")];
if (result.truncated) {
item.truncated = true;
item.original_length = result.original_length;
help.unshift(suggestCommand(context, `pr diff ${number} --full`, "to see the complete diff"));
}
return renderDetail({ noun: "pr_diff", item, help });
}
async function prCheckout(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_CHECKOUT_HELP;
}
const { positionals } = parseFlags(args, {}, "pr checkout");
const number = parsePositionalNumber(positionals, "pr checkout", "pull request");
const context = await resolveRepoContext(deps);
const api = createClient(context);
// The head branch name comes from the PR fetch; the commit itself is fetched
// from refs/pull/<n>/head, so a fork head that is not a configured remote still
// resolves (ADR 0011). A PR without a head ref is the broken answer it is,
// rather than a branch name invented to fetch into.
const pull = await getPull(api, context, number);
const branch = pull.head?.ref;
if (!branch) {
throw axiError("Gitea returned a pull request with no head branch", "UNKNOWN");
}
await checkoutPullHead(deps, number, branch);
return renderDetail({
noun: "checkout",
item: { number, branch, status: "ok" },
help: [suggestCommand(context, `pr diff ${number}`, "to review the diff you checked out")],
});
}
async function prCreate(deps: CliDeps, args: string[]): Promise<string> { async function prCreate(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) { if (args.includes("--help")) {
return PR_CREATE_HELP; return PR_CREATE_HELP;
@@ -1570,6 +1655,12 @@ export function prCommand(deps: CliDeps) {
if (subcommand === "view") { if (subcommand === "view") {
return prView(deps, rest); return prView(deps, rest);
} }
if (subcommand === "diff") {
return prDiff(deps, rest);
}
if (subcommand === "checkout") {
return prCheckout(deps, rest);
}
if (subcommand === "checks") { if (subcommand === "checks") {
return prChecks(deps, rest); return prChecks(deps, rest);
} }

58
src/diff.ts Normal file
View File

@@ -0,0 +1,58 @@
import type { GiteaClient } from "./client.js";
import type { RepoContext } from "./context.js";
import { classifyHttpError } from "./errors.js";
/** The raw-diff truncation limit, distinct from the body/comment limits. */
export const DIFF_TRUNCATE_LIMIT = 4000;
/**
* A rendered pull-request diff. `truncated`/`original_length` are present only
* when the diff was cut short — signalled as separate fields rather than an
* inline hint, so the diff text itself stays a verbatim (if partial) prefix.
*/
export interface DiffResult {
diff: string;
truncated?: true;
original_length?: number;
}
/**
* Fetch a pull request's raw unified diff from `GET /pulls/{index}.diff`. The
* generated client defaults every response to JSON parsing (its baseApiParams
* set `format: "json"`), which would discard a plain-text diff body — so `text`
* is forced per call to read the response verbatim.
*/
export async function fetchPullDiff(
api: GiteaClient,
context: RepoContext,
number: number,
): Promise<string> {
try {
const response = await api.repos.repoDownloadPullDiffOrPatch(
context.owner,
context.name,
number,
"diff",
undefined,
{ format: "text" },
);
return response.data ?? "";
} catch (error) {
throw classifyHttpError(error);
}
}
/**
* Truncate a diff to {@link DIFF_TRUNCATE_LIMIT}, reporting the original length
* so the caller can offer `--full`. `full` returns the raw diff untouched.
*/
export function truncateDiff(diff: string, full: boolean): DiffResult {
if (full || diff.length <= DIFF_TRUNCATE_LIMIT) {
return { diff };
}
return {
diff: diff.slice(0, DIFF_TRUNCATE_LIMIT),
truncated: true,
original_length: diff.length,
};
}

View File

@@ -55,7 +55,9 @@ function pathname(url: string): string {
} }
const ISSUE_PATH = /\/repos\/[^/]+\/[^/]+\/issues\/(\d+)(?:\/|$)/; const ISSUE_PATH = /\/repos\/[^/]+\/[^/]+\/issues\/(\d+)(?:\/|$)/;
const PULL_PATH = /\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)(?:\/|$)/; // The trailing `.` case covers the `.diff`/`.patch` download paths, whose PR
// number is followed by a suffix rather than a `/` or the end of the path.
const PULL_PATH = /\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)(?:[./]|$)/;
const REPO_PATH = /\/repos\/([^/]+)\/([^/]+)(?:\/|$)/; const REPO_PATH = /\/repos\/([^/]+)\/([^/]+)(?:\/|$)/;
function classify404(response: HttpResponseLike): AxiError { function classify404(response: HttpResponseLike): AxiError {

View File

@@ -1,5 +1,6 @@
import type { CliDeps } from "./deps.js"; 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 { export interface RemoteRepo {
host: string; host: string;
@@ -62,6 +63,105 @@ export async function currentBranch(deps: CliDeps): Promise<string | null> {
return branch && branch !== "HEAD" ? branch : 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> { export async function detectRemote(deps: CliDeps): Promise<RemoteRepo | null> {
const result = await runSubprocess("git", ["remote", "get-url", "origin"], { const result = await runSubprocess("git", ["remote", "get-url", "origin"], {
cwd: deps.cwd, cwd: deps.cwd,

View File

@@ -13,6 +13,11 @@ export interface FixtureRoute {
body?: unknown; body?: unknown;
/** Name of a JSON file in test/fixtures to serve as the body. */ /** Name of a JSON file in test/fixtures to serve as the body. */
fixture?: string; fixture?: string;
/**
* A verbatim, non-JSON response body (e.g. a raw `.diff`). Served as-is with a
* `text/plain` content type, bypassing the JSON encoding the other fields get.
*/
raw?: string;
} }
export interface RecordedRequest { export interface RecordedRequest {
@@ -96,6 +101,14 @@ export async function startFixtureServer(routes: FixtureRoute[]): Promise<Fixtur
); );
return; return;
} }
if (route.raw !== undefined) {
res.writeHead(route.status ?? 200, {
"content-type": "text/plain",
...route.headers,
});
res.end(route.raw);
return;
}
res.writeHead(route.status ?? 200, { res.writeHead(route.status ?? 200, {
"content-type": "application/json", "content-type": "application/json",
...route.headers, ...route.headers,

266
test/pr-checkout.test.ts Normal file
View File

@@ -0,0 +1,266 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
import { runCliTest, testModeEnv } from "./harness.js";
let server: FixtureServer;
const tempDirs: string[] = [];
afterEach(async () => {
await server.close();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function git(cwd: string, ...args: string[]): Buffer {
return execFileSync("git", args, {
cwd,
env: {
...process.env,
GIT_AUTHOR_NAME: "t",
GIT_AUTHOR_EMAIL: "t@t",
GIT_COMMITTER_NAME: "t",
GIT_COMMITTER_EMAIL: "t@t",
},
stdio: "pipe",
});
}
/**
* Build an `origin` bare repo whose PR-head commit is published ONLY under
* `refs/pull/<prNumber>/head` (never as a normal branch), plus a working clone
* pointed at it, so `pr checkout` needs no network. Returns the working dir, the
* seed clone (to advance the PR head), and the sha of the PR-head commit.
*/
function makeScratchRepo(prNumber = 1): {
workDir: string;
seedDir: string;
headSha: string;
} {
const root = mkdtempSync(join(tmpdir(), "gitea-axi-checkout-"));
tempDirs.push(root);
const originDir = join(root, "origin.git");
git(root, "init", "--bare", "-b", "main", "origin.git");
const seed = join(root, "seed");
git(root, "clone", originDir, "seed");
writeFileSync(join(seed, "file.txt"), "base\n");
git(seed, "add", "-A");
git(seed, "commit", "-m", "base");
git(seed, "push", "origin", "main");
writeFileSync(join(seed, "file.txt"), "pr head\n");
git(seed, "add", "-A");
git(seed, "commit", "-m", "pr head");
git(seed, "push", "origin", `HEAD:refs/pull/${prNumber}/head`);
const headSha = git(seed, "rev-parse", "HEAD").toString().trim();
const workDir = join(root, "work");
git(root, "clone", originDir, "work");
return { workDir, seedDir: seed, headSha };
}
describe("pr checkout", () => {
it("fetches refs/pull/<n>/head into a branch named after head.ref and checks it out", async () => {
const { workDir } = makeScratchRepo();
server = await startFixtureServer([
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/1",
body: { number: 1, head: { ref: "feature" } },
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "checkout", "1"], {
env: { ...process.env, ...testModeEnv(server.url) },
cwd: workDir,
});
expect(exitCode).toBe(0);
expect(stdout).toContain("checkout:");
expect(stdout).toContain("number: 1");
expect(stdout).toContain("branch: feature");
expect(stdout).toContain("status: ok");
expect(git(workDir, "rev-parse", "--abbrev-ref", "HEAD").toString().trim()).toBe(
"feature",
);
});
it("force-updates a pre-existing, non-checked-out local branch to the PR head and checks it out", async () => {
const { workDir, headSha } = makeScratchRepo();
// A stale local `feature` branch sits at the base commit while HEAD stays on main.
git(workDir, "branch", "feature", "main");
server = await startFixtureServer([
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/1",
body: { number: 1, head: { ref: "feature" } },
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "checkout", "1"], {
env: { ...process.env, ...testModeEnv(server.url) },
cwd: workDir,
});
expect(exitCode).toBe(0);
expect(stdout).toContain("checkout:");
expect(stdout).toContain("branch: feature");
expect(stdout).toContain("status: ok");
expect(git(workDir, "rev-parse", "--abbrev-ref", "HEAD").toString().trim()).toBe(
"feature",
);
expect(git(workDir, "rev-parse", "feature").toString().trim()).toBe(headSha);
});
it("fast-forwards the already-checked-out PR branch to an advanced head and is idempotent on re-run", async () => {
const { workDir, seedDir } = makeScratchRepo();
server = await startFixtureServer([
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/1",
body: { number: 1, head: { ref: "feature" } },
},
]);
// First checkout lands on `feature` at the original PR head.
const first = await runCliTest(["pr", "checkout", "1"], {
env: { ...process.env, ...testModeEnv(server.url) },
cwd: workDir,
});
expect(first.exitCode).toBe(0);
expect(git(workDir, "rev-parse", "--abbrev-ref", "HEAD").toString().trim()).toBe(
"feature",
);
// Advance the PR head on origin to a new commit.
writeFileSync(join(seedDir, "file.txt"), "advanced pr head\n");
git(seedDir, "add", "-A");
git(seedDir, "commit", "-m", "advanced pr head");
const newSha = git(seedDir, "rev-parse", "HEAD").toString().trim();
git(seedDir, "push", "-f", "origin", "HEAD:refs/pull/1/head");
// Second checkout fast-forwards the checked-out branch to the advanced head.
const second = await runCliTest(["pr", "checkout", "1"], {
env: { ...process.env, ...testModeEnv(server.url) },
cwd: workDir,
});
expect(second.exitCode).toBe(0);
expect(second.stdout).toContain("status: ok");
expect(git(workDir, "rev-parse", "--abbrev-ref", "HEAD").toString().trim()).toBe(
"feature",
);
expect(git(workDir, "rev-parse", "feature").toString().trim()).toBe(newSha);
// Third checkout with no further change is a successful no-op.
const third = await runCliTest(["pr", "checkout", "1"], {
env: { ...process.env, ...testModeEnv(server.url) },
cwd: workDir,
});
expect(third.exitCode).toBe(0);
expect(third.stdout).toContain("status: ok");
expect(git(workDir, "rev-parse", "--abbrev-ref", "HEAD").toString().trim()).toBe(
"feature",
);
expect(git(workDir, "rev-parse", "feature").toString().trim()).toBe(newSha);
});
it("fails with GIT_ERROR and preserves local commits when the checked-out branch diverges", async () => {
const { workDir, seedDir } = makeScratchRepo();
server = await startFixtureServer([
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/1",
body: { number: 1, head: { ref: "feature" } },
},
]);
// First checkout lands on `feature` at the PR head.
const first = await runCliTest(["pr", "checkout", "1"], {
env: { ...process.env, ...testModeEnv(server.url) },
cwd: workDir,
});
expect(first.exitCode).toBe(0);
// Move the PR head elsewhere on origin.
writeFileSync(join(seedDir, "file.txt"), "advanced pr head\n");
git(seedDir, "add", "-A");
git(seedDir, "commit", "-m", "advanced pr head");
git(seedDir, "push", "-f", "origin", "HEAD:refs/pull/1/head");
// Make a local commit on `feature` that is not on the PR head → divergence.
writeFileSync(join(workDir, "local.txt"), "local work\n");
git(workDir, "add", "-A");
git(workDir, "commit", "-m", "local work");
const localSha = git(workDir, "rev-parse", "HEAD").toString().trim();
const { stdout, exitCode } = await runCliTest(["pr", "checkout", "1"], {
env: { ...process.env, ...testModeEnv(server.url) },
cwd: workDir,
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: GIT_ERROR");
expect(stdout).toContain("local commits that are not on the PR head");
expect(stdout).toContain("cannot fast-forward");
// The local commit is intact; nothing was discarded.
expect(git(workDir, "rev-parse", "HEAD").toString().trim()).toBe(localSha);
});
it("maps an ordinary git failure (unreachable origin) to GIT_ERROR carrying git's stderr", async () => {
const { workDir } = makeScratchRepo();
// Break the fetch: remove origin so the first `git fetch origin ...` fails.
git(workDir, "remote", "remove", "origin");
server = await startFixtureServer([
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/1",
body: { number: 1, head: { ref: "feature" } },
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "checkout", "1"], {
env: { ...process.env, ...testModeEnv(server.url) },
cwd: workDir,
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: GIT_ERROR");
// Git's own first stderr line is surfaced; assert an invariant fragment.
expect(stdout).toContain("'origin' does not appear to be a git repository");
});
it("checks out a fork PR whose head exists only under refs/pull/<n>/head, not as a branch", async () => {
const { workDir, headSha } = makeScratchRepo(2);
// `fork-feature` exists nowhere as a branch — only inside refs/pull/2/head.
server = await startFixtureServer([
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/2",
body: { number: 2, head: { ref: "fork-feature" } },
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "checkout", "2"], {
env: { ...process.env, ...testModeEnv(server.url) },
cwd: workDir,
});
expect(exitCode).toBe(0);
expect(stdout).toContain("checkout:");
expect(stdout).toContain("number: 2");
expect(stdout).toContain("branch: fork-feature");
expect(stdout).toContain("status: ok");
expect(git(workDir, "rev-parse", "--abbrev-ref", "HEAD").toString().trim()).toBe(
"fork-feature",
);
expect(git(workDir, "rev-parse", "fork-feature").toString().trim()).toBe(headSha);
});
});

116
test/pr-diff.test.ts Normal file
View File

@@ -0,0 +1,116 @@
import { afterEach, describe, expect, it } from "vitest";
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
import { runCliTest, testModeEnv } from "./harness.js";
const DIFF_PATH = "/api/v1/repos/testowner/testrepo/pulls/7.diff";
let server: FixtureServer;
afterEach(async () => {
await server.close();
});
describe("pr diff", () => {
it("outputs the raw diff verbatim when under the limit, with no truncation fields", async () => {
const diff = [
"diff --git a/README.md b/README.md",
"index 1234567..89abcde 100644",
"--- a/README.md",
"+++ b/README.md",
"@@ -1,3 +1,4 @@",
" # Project",
" ",
"-Old tagline",
"+New tagline",
"+An extra line",
"",
].join("\n");
server = await startFixtureServer([
{ method: "GET", path: DIFF_PATH, raw: diff },
]);
const { stdout, exitCode } = await runCliTest(["pr", "diff", "7"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain("pr_diff:");
expect(stdout).toContain("number: 7");
expect(stdout).toContain("+New tagline");
expect(stdout).not.toContain("truncated:");
expect(stdout).not.toContain("original_length:");
});
it("truncates a diff over 4000 chars to the first 4000 and reports the original length", async () => {
const diff = "x".repeat(4100);
server = await startFixtureServer([
{ method: "GET", path: DIFF_PATH, raw: diff },
]);
const { stdout, exitCode } = await runCliTest(["pr", "diff", "7"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain("truncated: true");
expect(stdout).toContain("original_length: 4100");
expect(stdout).toContain("Run `gitea-axi pr diff 7");
expect(stdout).toContain("--full");
expect(stdout).toContain("to see the complete diff");
expect(stdout).toContain("x".repeat(4000));
expect(stdout).not.toContain("x".repeat(4001));
});
it("passes a diff of exactly 4000 chars through untouched", async () => {
const diff = "y".repeat(4000);
server = await startFixtureServer([
{ method: "GET", path: DIFF_PATH, raw: diff },
]);
const { stdout, exitCode } = await runCliTest(["pr", "diff", "7"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).not.toContain("truncated:");
expect(stdout).not.toContain("original_length:");
});
it("returns the complete diff and suppresses truncation with --full", async () => {
const diff = "z".repeat(4100);
server = await startFixtureServer([
{ method: "GET", path: DIFF_PATH, raw: diff },
]);
const { stdout, exitCode } = await runCliTest(["pr", "diff", "7", "--full"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain("z".repeat(4100));
expect(stdout).not.toContain("truncated:");
expect(stdout).not.toContain("original_length:");
});
it("reports a nonexistent pull request as PR_NOT_FOUND with exit 1", async () => {
server = await startFixtureServer([
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/999.diff",
status: 404,
body: { message: "Not Found" },
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "diff", "999"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: PR_NOT_FOUND");
});
});