diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 535fe341..3a5b73ee 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -29,6 +29,7 @@ import json import os import re +import stat import subprocess import sys from collections import Counter @@ -37,7 +38,15 @@ # Some plugin hosts launch Python with safe-path isolation enabled. sys.path.insert(0, str(Path(__file__).resolve().parent)) -from rank_preview import DEFAULT_PREVIEW_BYTES, TEXT_CODE_EXTENSIONS, preview_for +from rank_preview import ( + DEFAULT_PREVIEW_BYTES, + TEXT_CODE_EXTENSIONS, + fit_preview_lines, + is_binary_sample, + preview_for, + select_preview_lines, + structural_outline, +) from workbench_target import git_directory_snapshot_paths EXCLUDED_DIRS = { @@ -116,6 +125,34 @@ "yarn.lock", } +SECURITY_RELEVANT_DIFF_FILENAMES = { + ".dockerignore", + "AGENTS.md", + "CLAUDE.md", + "CODEOWNERS", + "Containerfile", + "Dockerfile", + "SECURITY.md", + "compose.yaml", + "compose.yml", + "docker-compose.yaml", + "docker-compose.yml", +} + +SECURITY_RELEVANT_GITHUB_DIFF_DIRECTORIES = { + "actions", + "instructions", + "scripts", + "workflows", +} + +SECURITY_RELEVANT_GITHUB_DIFF_FILENAMES = { + "CODEOWNERS", + "copilot-instructions.md", + "dependabot.yaml", + "dependabot.yml", +} + SHARD_INPUT_GLOB = "rank-shard-*.input.jsonl" SHARD_OUTPUT_GLOB = "rank-shard-*.output.jsonl" SHARD_INPUT_PATTERN = re.compile(r"^rank-shard-([0-9]{4,})\.input\.jsonl$") @@ -288,6 +325,319 @@ def path_is_excluded(path: Path) -> bool: return path.name.endswith((".min.js", ".map")) +def diff_path_is_security_relevant(path: Path) -> bool: + if path.parts[0] in {".circleci", ".devcontainer"}: + return True + if path.name in SECURITY_RELEVANT_DIFF_FILENAMES: + return True + if path.name.startswith(("Dockerfile.", "Containerfile.")): + return True + if path.name.lower().endswith(".dockerfile"): + return True + return ( + len(path.parts) >= 2 + and path.parts[0] == ".github" + and ( + path.parts[1] in SECURITY_RELEVANT_GITHUB_DIFF_DIRECTORIES + or len(path.parts) == 2 + and path.parts[1] in SECURITY_RELEVANT_GITHUB_DIFF_FILENAMES + ) + ) + + +def diff_path_is_included(path: Path) -> bool: + if path.parts in {("docs", "CODEOWNERS"), ("docs", "SECURITY.md")}: + return True + if path.parts[:2] == (".github", "actions"): + return ".git" not in path.parts + if diff_path_is_security_relevant(path): + return not any( + part in EXCLUDED_DIRS + and part + not in { + ".github", + ".circleci", + ".devcontainer", + "build", + "build_config", + "build_configs", + "build-tools", + "build_tools", + "ci", + "test", + "tests", + "testing", + } + for part in path.parts + ) + return not path_is_excluded(path) + + +def confined_diff_preview(repo: Path, path: Path, preview_bytes: int) -> tuple[str, bool]: + try: + if path.is_symlink(): + return "", False + + expected = path.stat(follow_symlinks=False) + if not stat.S_ISREG(expected.st_mode) or expected.st_nlink != 1: + return "", False + + resolved = path.resolve(strict=True) + resolved.relative_to(repo) + resolved_stat = resolved.stat() + expected_identity = (expected.st_dev, expected.st_ino) + if (resolved_stat.st_dev, resolved_stat.st_ino) != expected_identity: + return "", False + + flags = os.O_RDONLY + flags |= getattr(os, "O_BINARY", 0) + flags |= getattr(os, "O_CLOEXEC", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + flags |= getattr(os, "O_NONBLOCK", 0) + + with os.fdopen(os.open(path, flags), "rb") as source: + opened = os.fstat(source.fileno()) + if ( + not stat.S_ISREG(opened.st_mode) + or (opened.st_dev, opened.st_ino) != expected_identity + ): + return "", False + + sample = source.read(4096) + if is_binary_sample(sample) and not sample.startswith((b"\xff\xfe", b"\xfe\xff")): + return "", True + + remaining = source.read(max(0, DIRECT_SCOPE_PREVIEW_READ_BYTES - len(sample))) + except (OSError, ValueError): + return "", False + + return diff_preview_data(path, sample + remaining, preview_bytes) + + +def diff_preview_data(path: Path, data: bytes, preview_bytes: int) -> tuple[str, bool]: + utf16 = data.startswith((b"\xff\xfe", b"\xfe\xff")) + if is_binary_sample(data) and not utf16: + return "", True + + text = data.decode("utf-16" if utf16 else "utf-8", errors="ignore") + outline = structural_outline(path, text) + source_lines = text.splitlines() if path.suffix.lower() in {".yml", ".yaml"} else outline + preview_lines = select_preview_lines(source_lines or text.splitlines()) + return fit_preview_lines(preview_lines, preview_bytes), False + + +def git_blob_preview( + repo: Path, path: Path, object_name: str, preview_bytes: int +) -> tuple[str, bool]: + try: + with subprocess.Popen( + ["git", "--no-replace-objects", "-C", str(repo), "cat-file", "blob", object_name], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) as process: + if process.stdout is None: + raise OSError("Git did not provide object contents") + data = process.stdout.read(4096) + binary = is_binary_sample(data) and not data.startswith((b"\xff\xfe", b"\xfe\xff")) + if not binary: + data += process.stdout.read(DIRECT_SCOPE_PREVIEW_READ_BYTES - len(data)) + process.stdout.close() + if binary or len(data) == DIRECT_SCOPE_PREVIEW_READ_BYTES: + if process.poll() is None: + process.terminate() + elif process.wait(): + raise OSError("Git could not read the requested object") + except OSError as error: + raise SystemExit(f"Could not read changed Git object: {object_name}") from error + return diff_preview_data(path, data, preview_bytes) + + +def revision_diff_preview( + repo: Path, path: Path, revision: str, preview_bytes: int +) -> tuple[str, bool]: + relative = path.relative_to(repo).as_posix() + return git_blob_preview(repo, path, f"{revision}:{relative}", preview_bytes) + + +def local_patch_preview( + repo: Path, path: Path, entry: tuple[str, str] | None, preview_bytes: int +) -> tuple[str, bool]: + worktree_preview, worktree_binary = confined_diff_preview(repo, path, preview_bytes) + if entry is None: + return worktree_preview, worktree_binary + + relative = path.relative_to(repo).as_posix() + comparison = subprocess.run( + ["git", "--no-replace-objects", "--literal-pathspecs", "-C", str(repo), "diff", "--quiet", "--no-ext-diff", "--", relative], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + if comparison.returncode == 0: + return worktree_preview, worktree_binary + if comparison.returncode != 1: + raise SystemExit(f"Could not compare staged and working-tree contents: {relative}") + + staged_preview, staged_binary = git_blob_preview(repo, path, entry[1], preview_bytes) + if staged_binary and worktree_binary: + return "", True + preview = fit_preview_lines( + [ + "Staged Git index:", + staged_preview or "(binary content)", + "Worktree:", + worktree_preview + or ("(deleted)" if not path.exists() else "(binary content)"), + ], + preview_bytes, + ) + return preview, False + + +def submodule_worktree_revision(repo: Path, path: Path) -> str | None: + try: + path.resolve(strict=True).relative_to(repo) + top_level = subprocess.run( + ["git", "--no-replace-objects", "-C", str(path), "rev-parse", "--show-toplevel"], + check=True, + capture_output=True, + text=True, + ) + revision = subprocess.run( + ["git", "--no-replace-objects", "-C", str(path), "rev-parse", "--verify", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + except (OSError, ValueError, subprocess.CalledProcessError): + return None + if Path(top_level.stdout.removesuffix("\n")).resolve() != path.resolve(): + return None + try: + changed = subprocess.run( + ["git", "--no-replace-objects", "-C", str(path), "status", "--porcelain", "-z", "--untracked-files=all"], + check=True, + capture_output=True, + ).stdout + except (OSError, subprocess.CalledProcessError) as error: + raise SystemExit("Could not inspect Git submodule worktree: " + str(path)) from error + if changed: + raise SystemExit("Dirty Git submodules must be reviewed separately: " + str(path)) + return revision.stdout.strip() + + +def unmerged_diff_preview( + repo: Path, path: Path, preview_bytes: int, *, track_worktree_filemode: bool +) -> str: + relative = path.relative_to(repo).as_posix() + result = subprocess.run( + ["git", "--no-replace-objects", "--literal-pathspecs", "-C", str(repo), "ls-files", "--stage", "-z", "--", relative], + check=True, + capture_output=True, + text=True, + ) + entries: list[list[str]] = [] + for entry in result.stdout.split("\0"): + if not entry: + continue + metadata, recorded_path = entry.split("\t", 1) + if recorded_path != relative: + raise SystemExit("Git returned an unexpected changed path: " + recorded_path) + entries.append(metadata.split()) + directory_conflict = path.is_dir() and any(mode == "160000" for mode, _, _ in entries) + directory_revision = None + if directory_conflict: + try: + if path.is_symlink() or (path.exists() and not path.is_dir()): + raise ValueError + if path.exists(): + path.resolve(strict=True).relative_to(repo) + except (OSError, ValueError): + raise SystemExit("Changed diff paths must not contain symbolic links: " + relative) + directory_revision = submodule_worktree_revision(repo, path) + if directory_revision is None: + raise SystemExit("Conflicted Git submodules must be initialized: " + relative) + else: + require_reviewable_diff_path(repo, path, reject_hard_links=True) + + lines: list[str] = [] + for mode, object_name, stage in entries: + if mode == "120000": + raise SystemExit("Changed diff paths must not contain symbolic links: " + relative) + stage_header = f"Git merge stage {stage} (mode {mode}):" + if mode == "160000": + lines.extend((stage_header, f"Git submodule commit {object_name}")) + continue + preview, binary = git_blob_preview(repo, path, object_name, preview_bytes) + lines.extend((stage_header, "(binary content)" if binary else preview)) + + if directory_conflict: + if all(directory_revision != revision for _, revision, _ in entries): + lines.extend(("Worktree:", f"Git submodule commit {directory_revision}")) + else: + current, binary = confined_diff_preview(repo, path, preview_bytes) + if current or binary: + mode = "100755" if path.stat().st_mode & 0o111 else "100644" + heading = f"Worktree (mode {mode}):" if track_worktree_filemode else "Worktree:" + lines.extend((heading, "(binary content)" if binary else current)) + require_reviewable_diff_path(repo, path, reject_hard_links=True) + return fit_preview_lines(lines, preview_bytes) + + +def git_diff_entry(repo: Path, path: Path, mode: str, head: str) -> tuple[str, str] | None: + relative = path.relative_to(repo).as_posix() + arguments = ( + ["ls-tree", "-z", head, "--", relative] + if mode == "revisions" + else ["ls-files", "--stage", "-z", "--", relative] + ) + result = subprocess.run( + ["git", "--no-replace-objects", "--literal-pathspecs", "-C", str(repo), *arguments], + check=True, + capture_output=True, + text=True, + ) + if not result.stdout: + return None + entry, recorded_path = result.stdout.split("\0", 1)[0].split("\t", 1) + if recorded_path != relative: + raise SystemExit(f"Git returned an unexpected changed path: {recorded_path}") + fields = entry.split() + revision = fields[2] if mode == "revisions" else fields[1] + if mode == "local-patch" and fields[0] == "160000": + if path.is_symlink() or (path.exists() and not path.is_dir()): + raise SystemExit("Changed diff paths must not contain symbolic links: " + relative) + if path.is_dir(): + try: + resolve_scope(repo, str(path), expand_user=False, reject_symlinks=True) + except SystemExit as error: + raise SystemExit( + "Changed diff paths must not contain symbolic links: " + relative + ) from error + worktree = submodule_worktree_revision(repo, path) + if worktree is not None and revision != worktree: + revision = f"{revision} (staged); {worktree} (worktree)" + return fields[0], revision + + +def require_reviewable_diff_path( + repo: Path, path: Path, *, reject_hard_links: bool = False +) -> None: + if not path.exists() and not path.is_symlink(): + return + try: + if path.is_symlink() or not path.is_file() or ( + reject_hard_links and path.stat().st_nlink != 1 + ): + raise ValueError + path.resolve(strict=True).relative_to(repo) + except (OSError, ValueError): + raise SystemExit( + "Changed diff paths must not contain symbolic links, hard links, or non-regular files: " + + path.relative_to(repo).as_posix() + ) from None + + def resolve_scope( repo: Path, scope: str, @@ -606,12 +956,14 @@ def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, result = subprocess.run( [ "git", + "--no-replace-objects", "-C", str(repo), "diff", + "--ignore-submodules=none", "--name-status", "-z", - "--diff-filter=ACMRD", + "--diff-filter=ACMRDTU", *diff_args, ], check=True, @@ -627,10 +979,19 @@ def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, while index < len(fields): status = fields[index][0] index += 1 + source = None if status in {"C", "R"}: + source = repo / fields[index] index += 1 path = repo / fields[index] index += 1 + if status == "R" and source is not None: + relative_source = source.relative_to(repo) + if diff_path_is_included(relative_source) and ( + diff_path_is_security_relevant(relative_source) + or not diff_path_is_included(path.relative_to(repo)) + ): + changed.append((source, "D")) changed.append((path, status)) return changed @@ -642,7 +1003,11 @@ def git_changed_paths(repo: Path, base: str, head: str, mode: str) -> list[tuple unstaged = run_git_changed_paths(repo, [base]) staged = run_git_changed_paths(repo, ["--cached", base]) combined = dict(staged) - combined.update(unstaged) + for path, status in unstaged: + if combined.get(path) != "U" and not ( + status == "D" and combined.get(path) not in (None, "D") + ): + combined[path] = status return sorted(combined.items()) raise SystemExit(f"Unknown diff mode: {mode}") @@ -652,20 +1017,121 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: if not repo.is_dir(): raise SystemExit(f"Repo path not found: {repo}") + track_worktree_filemode = False + if args.mode == "local-patch" and os.name != "nt": + filemode = subprocess.run( + ["git", "--no-replace-objects", "-C", str(repo), "config", "--bool", "core.filemode"], + check=False, + capture_output=True, + text=True, + ) + if filemode.returncode not in (0, 1): + raise SystemExit("Could not determine whether Git tracks working-tree file modes.") + track_worktree_filemode = filemode.stdout.strip() != "false" + rows: list[JsonRow] = [] for path, status in git_changed_paths(repo, args.base, args.head, args.mode): rel = path.relative_to(repo) - if path_is_excluded(rel) or path.suffix.lower() not in TEXT_CODE_EXTENSIONS: + if not diff_path_is_included(rel): continue if status == "D": - preview = "" - elif path.is_file(): - preview, is_binary = preview_for(path, args.preview_bytes) - if is_binary: - continue + if args.mode == "local-patch" and (path.exists() or path.is_symlink()): + require_reviewable_diff_path(repo, path, reject_hard_links=True) + raise SystemExit( + "Deleted Git paths must not have working-tree replacements: " + + rel.as_posix() + ) + entry = git_diff_entry(repo, path, "revisions", args.base) + if entry is not None and entry[0] == "160000": + preview = f"Deleted Git submodule commit {entry[1]}" + else: + preview, is_binary = revision_diff_preview( + repo, path, args.base, args.preview_bytes + ) + if is_binary: + if entry is None or entry[0] != "100755": + continue + preview = fit_preview_lines( + [f"Deleted Git file (mode {entry[0]}):", "(binary content)"], + args.preview_bytes, + ) + elif status == "U": + preview = unmerged_diff_preview( + repo, + path, + args.preview_bytes, + track_worktree_filemode=track_worktree_filemode, + ) else: - preview = "" + entry = git_diff_entry(repo, path, args.mode, args.head) + if entry is not None and entry[0] == "160000": + preview = f"Git submodule commit {entry[1]}" + base_entry = git_diff_entry(repo, path, "revisions", args.base) + if base_entry is not None and base_entry[0].startswith("100"): + previous, binary = git_blob_preview( + repo, path, base_entry[1], args.preview_bytes + ) + preview = fit_preview_lines( + [ + f"Previous Git file (mode {base_entry[0]}):", + "(binary content)" if binary else previous, + preview, + ], + args.preview_bytes, + ) + else: + if entry is not None and entry[0] == "120000": + raise SystemExit( + "Changed diff paths must not contain symbolic links: " + rel.as_posix() + ) + require_reviewable_diff_path( + repo, path, reject_hard_links=args.mode == "local-patch" + ) + preview, is_binary = ( + revision_diff_preview(repo, path, args.head, args.preview_bytes) + if args.mode == "revisions" + else local_patch_preview(repo, path, entry, args.preview_bytes) + ) + require_reviewable_diff_path( + repo, path, reject_hard_links=args.mode == "local-patch" + ) + base_entry = git_diff_entry(repo, path, "revisions", args.base) + changes: list[str] = [] + if ( + base_entry is not None + and entry is not None + and base_entry[0].startswith("100") + and entry[0].startswith("100") + ): + modes = [base_entry[0], entry[0]] + if track_worktree_filemode and path.exists(): + worktree_mode = "100755" if path.stat().st_mode & 0o111 else "100644" + if worktree_mode != modes[-1]: + modes.append(worktree_mode) + changes = [ + mode + for index, mode in enumerate(modes) + if index == 0 or mode != modes[index - 1] + ] + if is_binary: + if ( + len(changes) <= 1 + and (entry is None or entry[0] != "100755") + and (base_entry is None or base_entry[0] != "160000") + ): + continue + preview = "(binary content)" + if base_entry is not None and base_entry[0] == "160000": + preview = fit_preview_lines( + [f"Previous Git submodule commit {base_entry[1]}", preview], + args.preview_bytes, + ) + if len(changes) > 1: + preview = fit_preview_lines( + [f"Git file mode: {' → '.join(changes)}", preview], + args.preview_bytes, + ) rows.append({"path": rel.as_posix(), "area": args.area, "preview": preview}) rows.sort(key=lambda row: str(row["path"])) diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts new file mode 100644 index 00000000..6a17ae10 --- /dev/null +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -0,0 +1,314 @@ +import { execFileSync } from "node:child_process"; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { PLUGIN_ROOT } from "./plugin-root.js"; + +type DiffMode = "revisions" | "local-patch"; +type RankInputRow = { path: string; area: string; preview: string }; +type TestRepository = { root: string; repository: string; base: string }; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +function git(repository: string, ...args: string[]): string { + return execFileSync("git", args, { + cwd: repository, + encoding: "utf8", + stdio: "pipe", + }).trim(); +} + +async function writeRepositoryFile( + repository: string, + path: string, + contents: string | Uint8Array, +): Promise { + const destination = join(repository, path); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, contents); +} + +async function createRepository(): Promise { + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-diff-rank-input-")), + ); + temporaryDirectories.push(root); + + const repository = join(root, "repository"); + await mkdir(repository); + git(repository, "init", "-q", "-b", "main"); + git(repository, "config", "user.name", "Codex Security Test"); + git(repository, "config", "user.email", "codex-security@example.invalid"); + git(repository, "config", "commit.gpgsign", "false"); + + await Promise.all([ + writeRepositoryFile(repository, ".gitignore", "node_modules/\nvendor/\n"), + writeRepositoryFile(repository, "AGENTS.md", "Follow existing policy.\n"), + writeRepositoryFile(repository, "docker-compose.yml", "services: {}\n"), + writeRepositoryFile(repository, "src/app.ts", "export const value = 1;\n"), + writeRepositoryFile(repository, "src/remove.py", "print('remove')\n"), + writeRepositoryFile(repository, "src/old.py", "print('rename')\n"), + ]); + git(repository, "add", "."); + git(repository, "commit", "-qm", "initial"); + + return { root, repository, base: git(repository, "rev-parse", "HEAD") }; +} + +async function runDiffRankInput( + fixture: TestRepository, + mode: DiffMode, + head = "HEAD", +): Promise { + const interpreter = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + if (interpreter === null) { + throw new Error("A Python interpreter is required."); + } + + const output = join(fixture.root, `rank-input-${mode}.jsonl`); + execFileSync( + interpreter, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_rank_input.py"), + "make-diff-rank-input", + "--repo", + fixture.repository, + "--base", + fixture.base, + "--mode", + mode, + "--head", + head, + "--out", + output, + ], + { stdio: "pipe" }, + ); + const contents = (await readFile(output, "utf8")).trim(); + return contents + ? contents.split("\n").map((line) => JSON.parse(line) as RankInputRow) + : []; +} + +describe("diff rank input", () => { + test("includes changed security-sensitive files without dependency noise", async () => { + const fixture = await createRepository(); + const included: Record = { + ".circleci/config.yml": "version: 2.1\n", + ".devcontainer/devcontainer.json": '{"image":"example"}\n', + ".github/actions/local/index.js": "runTrustedAction();\n", + ".github/CODEOWNERS": "* @security\n", + ".github/workflows/security.yml": "name: Security\n", + "AGENTS.md": "Review authorization boundaries.\n", + Dockerfile: "FROM scratch\n", + "docs/CODEOWNERS": "* @documentation\n", + "docs/SECURITY.md": "Report vulnerabilities privately.\n", + "infra/main.tf": 'resource "example" "service" {}\n', + "src/app.ts": "export const value = 2;\n", + }; + await Promise.all( + Object.entries(included).map(([path, contents]) => + writeRepositoryFile(fixture.repository, path, contents), + ), + ); + await writeRepositoryFile( + fixture.repository, + "vendor/dependency.py", + "print('external dependency')\n", + ); + git(fixture.repository, "add", "-A"); + git(fixture.repository, "add", "-f", "vendor/dependency.py"); + git(fixture.repository, "commit", "-qm", "change security-sensitive files"); + + const rows = await runDiffRankInput(fixture, "revisions"); + expect(rows.map(({ path }) => path)).toEqual(Object.keys(included).sort()); + expect(rows.every(({ area, preview }) => area === "diff" && preview)).toBe( + true, + ); + }); + + test.skipIf(process.platform === "win32")( + "treats repository-controlled Git pathspecs literally", + async () => { + const fixture = await createRepository(); + const path = ":!literal.py"; + await writeRepositoryFile(fixture.repository, path, "print('literal')\n"); + git(fixture.repository, "--literal-pathspecs", "add", "--", path); + git(fixture.repository, "commit", "-qm", "add literal path"); + + expect( + (await runDiffRankInput(fixture, "revisions")).map((row) => row.path), + ).toContain(path); + }, + ); + + test("includes staged and unstaged files but not untracked paths", async () => { + const fixture = await createRepository(); + await writeRepositoryFile( + fixture.repository, + ".github/workflows/staged.yml", + "name: Staged\n", + ); + git(fixture.repository, "add", ".github/workflows/staged.yml"); + await writeRepositoryFile( + fixture.repository, + "src/app.ts", + "export const value = 2;\n", + ); + await writeRepositoryFile( + fixture.repository, + ".github/workflows/untracked.yml", + "name: Untracked\n", + ); + + expect( + (await runDiffRankInput(fixture, "local-patch")).map(({ path }) => path), + ).toEqual([".github/workflows/staged.yml", "src/app.ts"]); + }); + + test("reads the selected committed head instead of the current checkout", async () => { + const fixture = await createRepository(); + await writeRepositoryFile( + fixture.repository, + "AGENTS.md", + "Require head-only authorization.\n", + ); + git(fixture.repository, "add", "AGENTS.md"); + git(fixture.repository, "commit", "-qm", "change authorization policy"); + const head = git(fixture.repository, "rev-parse", "HEAD"); + git(fixture.repository, "checkout", "--quiet", fixture.base); + + expect(await runDiffRankInput(fixture, "revisions", head)).toEqual([ + { + path: "AGENTS.md", + area: "diff", + preview: "Require head-only authorization.", + }, + ]); + }); + + test("includes both staged and working-tree content when they differ", async () => { + const fixture = await createRepository(); + const workflow = ".github/workflows/deploy.yml"; + await writeRepositoryFile(fixture.repository, workflow, "run: staged\n"); + git(fixture.repository, "add", workflow); + await writeRepositoryFile(fixture.repository, workflow, "run: worktree\n"); + + const [row] = await runDiffRankInput(fixture, "local-patch"); + expect(row?.preview).toContain("Staged Git index:\nrun: staged"); + expect(row?.preview).toContain("Worktree:\nrun: worktree"); + }); + + test("preserves deleted and renamed files using their committed content", async () => { + const fixture = await createRepository(); + await rm(join(fixture.repository, "src/remove.py")); + await rename( + join(fixture.repository, "src/old.py"), + join(fixture.repository, "src/renamed.py"), + ); + git(fixture.repository, "add", "-A"); + git(fixture.repository, "commit", "-qm", "delete and rename source"); + + expect(await runDiffRankInput(fixture, "revisions")).toEqual([ + { path: "src/remove.py", area: "diff", preview: "print('remove')" }, + { path: "src/renamed.py", area: "diff", preview: "print('rename')" }, + ]); + }); + + test("records the pinned commit for local-action submodules", async () => { + const fixture = await createRepository(); + git( + fixture.repository, + "update-index", + "--add", + "--cacheinfo", + `160000,${fixture.base},.github/actions/local`, + ); + git(fixture.repository, "commit", "-qm", "pin local action submodule"); + + expect(await runDiffRankInput(fixture, "revisions")).toEqual([ + { + path: ".github/actions/local", + area: "diff", + preview: `Git submodule commit ${fixture.base}`, + }, + ]); + }); + + test("keeps UTF-16 action scripts while excluding large binary objects", async () => { + const fixture = await createRepository(); + const action = ".github/actions/local/run.ps1"; + await writeRepositoryFile( + fixture.repository, + action, + Buffer.concat([ + Buffer.from([0xff, 0xfe]), + Buffer.from("Invoke-Expression $command\n", "utf16le"), + ]), + ); + await writeRepositoryFile( + fixture.repository, + "src/binary.py", + Buffer.alloc(1024 * 1024), + ); + git(fixture.repository, "add", "-A"); + git(fixture.repository, "commit", "-qm", "add action and binary"); + + expect(await runDiffRankInput(fixture, "revisions")).toEqual([ + { + path: action, + area: "diff", + preview: "Invoke-Expression $command", + }, + ]); + }); + + test.skipIf(process.platform === "win32")( + "rejects staged symlinks pointing outside the repository", + async () => { + const fixture = await createRepository(); + const external = join(fixture.root, "external.py"); + await writeFile(external, "private = True\n"); + await symlink(external, join(fixture.repository, "src/linked.py")); + git(fixture.repository, "add", "src/linked.py"); + + await expect(runDiffRankInput(fixture, "local-patch")).rejects.toThrow( + /symbolic links/, + ); + }, + ); + + test("rejects working-tree replacements after staged deletions", async () => { + const fixture = await createRepository(); + git(fixture.repository, "rm", "--quiet", "--", "src/remove.py"); + await writeRepositoryFile( + fixture.repository, + "src/remove.py", + "print('replacement')\n", + ); + + await expect(runDiffRankInput(fixture, "local-patch")).rejects.toThrow( + /working-tree replacements/, + ); + }); +});