/**
* Shell snippets for Step 1 — obtain an accurate review diff.
*
* Common failure mode: comparing against a stale local main/master without
* `git fetch`, so the three-dot range includes unrelated upstream commits
* (or misses them). Always prefer `origin/` after a quiet fetch.
*/
export const DIFF_META_REL = ".pi/pi-review/diff-meta.txt";
export interface ObtainDiffScriptInput {
cwd: string;
diffPath: string;
filesPath: string;
kindPath: string;
metaPath: string;
/** When set, PR path (gh pr diff + git fallback). */
prRef?: string;
}
function q(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
/** Shared: write changed-files.txt + change-kind.txt from the diff file. */
function appendCompanionFiles(lines: string[], filesPath: string, kindPath: string, diffPath: string): void {
lines.push(`# File list (paths only)`);
lines.push(
`(git diff --name-only HEAD 2>/dev/null || true; grep -E '^diff --git ' ${q(diffPath)} | sed -E 's/^diff --git a\\/([^ ]+) b\\/.*/\\1/' ) | sort -u | grep -v '^$' > ${q(filesPath)}`,
);
lines.push(`# change-kind: docs if every path looks like docs/skills/markdown, else code`);
lines.push(
`if grep -Eiv '\\.(md|mdx|txt|rst)$|/docs/|/\\.agents/|/\\.claude/|CHANGELOG|LICENSE|README' ${q(filesPath)} >/dev/null 2>&1; then echo code > ${q(kindPath)}; else echo docs > ${q(kindPath)}; fi`,
);
}
/**
* Resolve remote-tracking default branch ref (origin/main etc.).
* Fetch first so the three-dot base is not a stale local tip.
*/
function appendResolveFreshBase(lines: string[]): void {
lines.push(`# Resolve default branch; prefer origin/ after fetch (avoids stale local main)`);
lines.push(`BASE=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')`);
lines.push(`if [ -z "$BASE" ]; then`);
lines.push(` if git rev-parse --verify refs/heads/main >/dev/null 2>&1; then BASE=main`);
lines.push(` elif git rev-parse --verify refs/heads/master >/dev/null 2>&1; then BASE=master`);
lines.push(` else BASE=$(git symbolic-ref --short HEAD 2>/dev/null || echo HEAD); fi`);
lines.push(`fi`);
lines.push(`git fetch origin "$BASE" --quiet 2>/dev/null || git fetch origin --quiet 2>/dev/null || true`);
lines.push(`if git rev-parse --verify "origin/$BASE" >/dev/null 2>&1; then COMP="origin/$BASE"`);
lines.push(`elif git rev-parse --verify "$BASE" >/dev/null 2>&1; then COMP="$BASE"`);
lines.push(`else COMP=HEAD; fi`);
}
/** Build the Step 1 bash block (no surrounding ```). */
export function buildObtainDiffScript(input: ObtainDiffScriptInput): string {
const { diffPath, filesPath, kindPath, metaPath, prRef } = input;
const lines: string[] = [];
lines.push(`mkdir -p ${q(`${input.cwd}/.pi/pi-review`)}`);
lines.push(`META=${q(metaPath)}`);
if (prRef) {
const ref = q(prRef);
lines.push(`# Prefer GitHub's PR diff (exact merge-base as on GitHub)`);
lines.push(`if gh pr diff ${ref} > ${q(diffPath)} 2>/tmp/pi-review-gh-pr-diff.err \\`);
lines.push(` && test -s ${q(diffPath)}; then`);
lines.push(` echo "mode=gh-pr-diff" > "$META"`);
lines.push(` echo "pr=${prRef.replace(/'/g, "")}" >> "$META"`);
lines.push(`else`);
lines.push(` # Fallback: fetch PR base + head, three-dot against origin/`);
lines.push(` PR_JSON=$(gh pr view ${ref} --json number,baseRefName,headRefOid,baseRefOid 2>/dev/null || true)`);
lines.push(` PR_NUM=$(printf '%s' "$PR_JSON" | sed -n 's/.*"number":[ ]*\\([0-9][0-9]*\\).*/\\1/p' | head -1)`);
lines.push(` PR_BASE=$(printf '%s' "$PR_JSON" | sed -n 's/.*"baseRefName":[ ]*"\\([^"]*\\)".*/\\1/p' | head -1)`);
lines.push(` PR_BASE=\${PR_BASE:-main}`);
lines.push(` git fetch origin "$PR_BASE" --quiet 2>/dev/null || git fetch origin --quiet 2>/dev/null || true`);
lines.push(` if [ -n "$PR_NUM" ]; then`);
lines.push(` git fetch origin "pull/$PR_NUM/head:refs/pi-review/pr-head" --force --quiet 2>/dev/null \\`);
lines.push(` || git fetch origin "pull/$PR_NUM/head" --quiet 2>/dev/null || true`);
lines.push(` fi`);
lines.push(` if git rev-parse --verify "origin/$PR_BASE" >/dev/null 2>&1; then COMP="origin/$PR_BASE"; else COMP="$PR_BASE"; fi`);
lines.push(` if git rev-parse --verify refs/pi-review/pr-head >/dev/null 2>&1; then HEADREF=refs/pi-review/pr-head; else HEADREF=HEAD; fi`);
lines.push(` git diff "$COMP...$HEADREF" > ${q(diffPath)}`);
lines.push(` echo "mode=git-pr-fallback" > "$META"`);
lines.push(` echo "base=$COMP" >> "$META"`);
lines.push(` echo "head=$HEADREF" >> "$META"`);
lines.push(` echo "base_sha=$(git rev-parse "$COMP" 2>/dev/null || true)" >> "$META"`);
lines.push(` echo "head_sha=$(git rev-parse "$HEADREF" 2>/dev/null || true)" >> "$META"`);
lines.push(`fi`);
} else {
lines.push(`# Dirty working tree → review uncommitted work (no base fetch needed)`);
lines.push(`if [ -n "$(git status --porcelain 2>/dev/null)" ]; then`);
lines.push(` # Staged + unstaged vs HEAD (does not invent untracked file patches)`);
lines.push(` git diff HEAD > ${q(diffPath)}`);
lines.push(` echo "mode=uncommitted" > "$META"`);
lines.push(` echo "head_sha=$(git rev-parse HEAD 2>/dev/null || true)" >> "$META"`);
lines.push(`else`);
appendResolveFreshBase(lines);
lines.push(` git diff "$COMP...HEAD" > ${q(diffPath)}`);
lines.push(` echo "mode=vs-default" > "$META"`);
lines.push(` echo "base=$COMP" >> "$META"`);
lines.push(` echo "base_sha=$(git rev-parse "$COMP" 2>/dev/null || true)" >> "$META"`);
lines.push(` echo "head_sha=$(git rev-parse HEAD 2>/dev/null || true)" >> "$META"`);
lines.push(` echo "merge_base=$(git merge-base "$COMP" HEAD 2>/dev/null || true)" >> "$META"`);
lines.push(`fi`);
}
lines.push(`test -s ${q(diffPath)}`);
appendCompanionFiles(lines, filesPath, kindPath, diffPath);
return lines.join("\n");
}