From 3db6d421d484217cf988cbe0705ed72c438e3803 Mon Sep 17 00:00:00 2001 From: alexion Date: Mon, 13 Jul 2026 22:15:03 -0400 Subject: [PATCH] feat: add pr diff and checkout (task 0014) Add the two PR commands that touch content and the local worktree: - `pr diff ` 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 ` 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`. --- .claude/tasks/0014-pr-diff-and-checkout.md | 32 ++- src/commands/pr.ts | 93 ++++++- src/diff.ts | 58 +++++ src/errors.ts | 4 +- src/git.ts | 102 +++++++- test/fixture-server.ts | 13 + test/pr-checkout.test.ts | 266 +++++++++++++++++++++ test/pr-diff.test.ts | 116 +++++++++ 8 files changed, 675 insertions(+), 9 deletions(-) create mode 100644 src/diff.ts create mode 100644 test/pr-checkout.test.ts create mode 100644 test/pr-diff.test.ts diff --git a/.claude/tasks/0014-pr-diff-and-checkout.md b/.claude/tasks/0014-pr-diff-and-checkout.md index d890b3c..a23c1bd 100644 --- a/.claude/tasks/0014-pr-diff-and-checkout.md +++ b/.claude/tasks/0014-pr-diff-and-checkout.md @@ -15,9 +15,29 @@ Output: `checkout: { number, branch, status: "ok" }`. ## Acceptance criteria -- [ ] `pr diff ` 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 ` 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 -- [ ] 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`) -- [ ] Tests cover diff truncation boundaries and the three checkout cases (git behavior exercised against a scratch repository, API responses from the fixture server) +- [x] `pr diff ` 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 checkout ` handles all three local-branch cases and re-running it is idempotent +- [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 +- [x] Other git failures (dirty worktree, network) map to `GIT_ERROR` with git's first stderr line +- [x] Checkout works for a fork PR whose head repo is not a configured remote (via `refs/pull/{n}/head`) +- [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. diff --git a/src/commands/pr.ts b/src/commands/pr.ts index f387d60..2f1fc2b 100644 --- a/src/commands/pr.ts +++ b/src/commands/pr.ts @@ -37,7 +37,8 @@ import { splitFlag, } from "../flags.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 { fetchAllPages, readTotalCount } from "../paginate.js"; import { formatCountLine, renderDetail, renderList, renderScalar, type DetailBlock } from "../render.js"; @@ -50,6 +51,8 @@ export const PR_HELP = `usage: gitea-axi pr [flags] commands: list List pull requests in the current repository 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 create Create a pull request edit Edit a pull request's title, body, labels, assignees, reviewers, milestone, or base @@ -180,6 +183,34 @@ global flags: --login Select a tea login profile by name `; +export const PR_DIFF_HELP = `usage: gitea-axi pr diff [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 Override the repository detected from the git origin remote + --login Select a tea login profile by name +`; + +export const PR_CHECKOUT_HELP = `usage: gitea-axi pr checkout + +Check a pull request's head branch out into the current working tree, fetching +it from origin under refs/pull//head (works for fork PRs too). Re-running +is idempotent. + +flags: + --help Show this help + +global flags: + -R, --repo Override the repository detected from the git origin remote + --login Select a tea login profile by name +`; + export const PR_LIST_HELP = `usage: gitea-axi pr list [flags] List pull requests in the current repository. @@ -911,6 +942,60 @@ async function prChecks(deps: CliDeps, args: string[]): Promise { }); } +async function prDiff(deps: CliDeps, args: string[]): Promise { + 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 = { 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 { + 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//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 { if (args.includes("--help")) { return PR_CREATE_HELP; @@ -1570,6 +1655,12 @@ export function prCommand(deps: CliDeps) { if (subcommand === "view") { return prView(deps, rest); } + if (subcommand === "diff") { + return prDiff(deps, rest); + } + if (subcommand === "checkout") { + return prCheckout(deps, rest); + } if (subcommand === "checks") { return prChecks(deps, rest); } diff --git a/src/diff.ts b/src/diff.ts new file mode 100644 index 0000000..2edf2db --- /dev/null +++ b/src/diff.ts @@ -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 { + 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, + }; +} diff --git a/src/errors.ts b/src/errors.ts index d8948e5..9b8bc96 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -55,7 +55,9 @@ function pathname(url: string): string { } 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\/([^/]+)\/([^/]+)(?:\/|$)/; function classify404(response: HttpResponseLike): AxiError { diff --git a/src/git.ts b/src/git.ts index 797c4e2..c0bf17b 100644 --- a/src/git.ts +++ b/src/git.ts @@ -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 { 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 { + 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 { + 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 { + 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 { const result = await runSubprocess("git", ["remote", "get-url", "origin"], { cwd: deps.cwd, diff --git a/test/fixture-server.ts b/test/fixture-server.ts index 57e1cbd..b707298 100644 --- a/test/fixture-server.ts +++ b/test/fixture-server.ts @@ -13,6 +13,11 @@ export interface FixtureRoute { body?: unknown; /** Name of a JSON file in test/fixtures to serve as the body. */ 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 { @@ -96,6 +101,14 @@ export async function startFixtureServer(routes: FixtureRoute[]): Promise { + 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//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//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//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); + }); +}); diff --git a/test/pr-diff.test.ts b/test/pr-diff.test.ts new file mode 100644 index 0000000..2efb7b1 --- /dev/null +++ b/test/pr-diff.test.ts @@ -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"); + }); +}); -- 2.47.3