From 7c0d0bc5e2081eaa39cb7dffe1cf71ef11208f78 Mon Sep 17 00:00:00 2001 From: Zhaolong Zhu Date: Fri, 15 May 2026 01:36:35 -0400 Subject: [PATCH] feat: add Sapling (sl) VCS backend support Sapling joins git and jj as a supported VCS mode. Set `vcs = "sl"` in config to use `hunk diff` and `hunk show` with Sapling revsets. Repos using `.sl` or `.hg` directories are auto-detected for repo-local config. CI workflows install Sapling for test coverage. --- .github/workflows/ci.yml | 12 ++ .github/workflows/pr-ci.yml | 12 ++ README.md | 15 +- src/core/loaders.ts | 35 +++++ src/core/sl.test.ts | 105 ++++++++++++++ src/core/sl.ts | 273 ++++++++++++++++++++++++++++++++++++ src/core/types.ts | 2 +- src/core/vcs/index.test.ts | 7 +- src/core/vcs/index.ts | 3 +- src/core/vcs/sl.test.ts | 146 +++++++++++++++++++ src/core/vcs/sl.ts | 130 +++++++++++++++++ 11 files changed, 732 insertions(+), 8 deletions(-) create mode 100644 src/core/sl.test.ts create mode 100644 src/core/sl.ts create mode 100644 src/core/vcs/sl.test.ts create mode 100644 src/core/vcs/sl.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d44e63c..ff2e23b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,18 @@ jobs: with: tool: jj-cli + - name: Install Sapling + run: | + sudo apt-get install -y xz-utils + sudo mkdir -p /opt/sapling + SAPLING_URL="https://github.com/facebook/sapling/releases/download/0.2.20260317-201835%2B0234c21f/sapling-0.2.20260317-201835%2B0234c21f-linux-x64.tar.xz" + SAPLING_SHA256="2dca0b964a2f3e2e3ad3f4f01794dc7e829de4aedddb2798a7bae883ce6cd71f" + curl -fsSL "$SAPLING_URL" -o /tmp/sapling.tar.xz + echo "$SAPLING_SHA256 /tmp/sapling.tar.xz" | sha256sum --check + sudo tar -xJf /tmp/sapling.tar.xz -C /opt/sapling + sudo ln -s /opt/sapling/sl /usr/local/bin/sl + sl version + - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 6fd8968e..3d86f11f 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -78,6 +78,18 @@ jobs: with: tool: jj-cli + - name: Install Sapling + run: | + sudo apt-get install -y xz-utils + sudo mkdir -p /opt/sapling + SAPLING_URL="https://github.com/facebook/sapling/releases/download/0.2.20260317-201835%2B0234c21f/sapling-0.2.20260317-201835%2B0234c21f-linux-x64.tar.xz" + SAPLING_SHA256="2dca0b964a2f3e2e3ad3f4f01794dc7e829de4aedddb2798a7bae883ce6cd71f" + curl -fsSL "$SAPLING_URL" -o /tmp/sapling.tar.xz + echo "$SAPLING_SHA256 /tmp/sapling.tar.xz" | sha256sum --check + sudo tar -xJf /tmp/sapling.tar.xz -C /opt/sapling + sudo ln -s /opt/sapling/sl /usr/local/bin/sl + sl version + - name: Install dependencies run: bun install --frozen-lockfile diff --git a/README.md b/README.md index b363e456..c3c166ac 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,9 @@ hunk show # review the latest commit hunk show HEAD~1 # review an earlier commit ``` -### Working with Jujutsu +### Working with Jujutsu and Sapling -Hunk auto-detects Jujutsu checkouts, so `hunk diff [revset]` and `hunk show [revset]` use jj revsets inside a jj workspace. To override VCS detection, set `vcs = "git"` or `vcs = "jj"` in [config](#config). +Hunk auto-detects Jujutsu and Sapling checkouts, so `hunk diff [revset]` and `hunk show [revset]` use native revsets inside jj or Sapling workspaces. To override VCS detection, set `vcs = "jj"` or `vcs = "sl"` in [config](#config). ### Working with raw files and patches @@ -121,7 +121,7 @@ Example: ```toml theme = "graphite" # graphite, midnight, paper, ember mode = "auto" # auto, split, stack -vcs = "git" # git, jj +vcs = "git" # git, jj, sl watch = false exclude_untracked = false line_numbers = true @@ -166,6 +166,15 @@ pager = ["hunk", "pager"] diff-formatter = ":git" ``` +### Sapling pager integration + +To use Hunk as Sapling's pager, run `sl config -u` and update: + +```ini +[pager] +pager = hunk pager +``` + ### OpenTUI component Hunk also publishes `HunkDiffView` and lower-level primitives from `hunkdiff/opentui` for embedding the same diff renderer in your own OpenTUI app. diff --git a/src/core/loaders.ts b/src/core/loaders.ts index a2131287..de95f188 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -661,6 +661,7 @@ function buildUntrackedDiffFile( sourcePrefix: string, agentContext: AgentContext | null, ) { + const absolutePath = join(repoRoot, filePath); const largeFileCheck = inspectLargeUntrackedFile(repoRoot, filePath); if (largeFileCheck.shouldSkip) { return buildDiffFile( @@ -678,6 +679,40 @@ function buildUntrackedDiffFile( ); } + if (input.options.vcs === "sl") { + if (isProbablyBinaryFile(absolutePath)) { + return buildDiffFile( + createSkippedBinaryMetadata(filePath, "new"), + `Binary file skipped: ${filePath}\n`, + index, + sourcePrefix, + agentContext, + { isBinary: true, isUntracked: true }, + ); + } + + const patch = createTwoFilesPatch( + "/dev/null", + escapeUntrackedPatchPath(filePath), + "", + fs.readFileSync(absolutePath, "utf8"), + "", + "", + { context: 3 }, + ).replaceAll("\r\n", "\n"); + + return buildDiffFile( + parseUntrackedPatchFile(patch, filePath), + patch, + index, + sourcePrefix, + agentContext, + { + isUntracked: true, + }, + ); + } + const patch = normalizeUntrackedPatchHeaders( runGitUntrackedFileDiffText(input, filePath, { repoRoot }), filePath, diff --git a/src/core/sl.test.ts b/src/core/sl.test.ts new file mode 100644 index 00000000..4cbac5b9 --- /dev/null +++ b/src/core/sl.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSlDiffArgs, runSlText } from "./sl"; + +const slAvailable = + Bun.spawnSync(["sl", "version"], { stdin: "ignore", stdout: "ignore", stderr: "ignore" }) + .exitCode === 0; +const tempDirs: string[] = []; + +function cleanupTempDirs() { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +} + +function createTempDir(prefix: string) { + const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix))); + tempDirs.push(dir); + return dir; +} + +function sl(cwd: string, ...cmd: string[]) { + const proc = Bun.spawnSync(["sl", "--noninteractive", "--color", "never", ...cmd], { + cwd, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + + if (proc.exitCode !== 0) { + const stderr = Buffer.from(proc.stderr).toString("utf8"); + throw new Error(stderr.trim() || `sl ${cmd.join(" ")} failed`); + } + + return Buffer.from(proc.stdout).toString("utf8"); +} + +function createTempSlRepo(prefix: string) { + const dir = createTempDir(prefix); + + sl(dir, "init", "--git"); + + return dir; +} + +afterEach(() => { + cleanupTempDirs(); +}); + +describe("sl command helpers", () => { + test("reports a friendly error when sl is not installed or not on PATH", () => { + expect(() => + runSlText({ + input: { + kind: "vcs", + staged: false, + options: { mode: "auto", vcs: "sl" }, + }, + args: ["root"], + slExecutable: "definitely-not-a-real-sl-binary", + }), + ).toThrow( + 'Sapling is required for `hunk diff` when `vcs = "sl"`, but `definitely-not-a-real-sl-binary` was not found in PATH.', + ); + }); + + test.skipIf(!slAvailable)("reports a friendly error outside a sl repository", () => { + const dir = createTempDir("hunk-sl-nonrepo-"); + + expect(() => + runSlText({ + input: { + kind: "vcs", + staged: false, + options: { mode: "auto", vcs: "sl" }, + }, + args: ["root"], + cwd: dir, + }), + ).toThrow('`hunk diff` must be run inside a Sapling repository when `vcs = "sl"`.'); + }); + + test.skipIf(!slAvailable)("reports a friendly error for invalid revsets", () => { + const dir = createTempSlRepo("hunk-sl-invalid-revset-"); + const input = { + kind: "vcs" as const, + range: "missing_revision", + staged: false, + options: { mode: "auto" as const, vcs: "sl" as const }, + }; + + expect(() => + runSlText({ + input, + args: buildSlDiffArgs(input), + cwd: dir, + }), + ).toThrow("`hunk diff missing_revision` could not resolve Sapling revset `missing_revision`."); + }); +}); diff --git a/src/core/sl.ts b/src/core/sl.ts new file mode 100644 index 00000000..f1841c63 --- /dev/null +++ b/src/core/sl.ts @@ -0,0 +1,273 @@ +import fs from "node:fs"; +import { join } from "node:path"; +import { HunkUserError } from "./errors"; +import type { VcsCommandInput, ShowCommandInput } from "./types"; + +export type SlBackedInput = VcsCommandInput | ShowCommandInput; + +export interface RunSlTextOptions { + input: SlBackedInput; + args: string[]; + cwd?: string; + slExecutable?: string; +} + +/** Append Sapling pathspec arguments only when the caller requested path filtering. */ +function appendSlPathspecs(args: string[], pathspecs?: string[]) { + if (!pathspecs || pathspecs.length === 0) { + return; + } + + args.push("--", ...pathspecs); +} + +/** Build the `sl diff --git` arguments for working-copy and revset reviews. */ +export function buildSlDiffArgs(input: VcsCommandInput) { + const args = ["diff", "--git"]; + + if (input.range) { + args.push("-r", input.range); + } + + appendSlPathspecs(args, input.pathspecs); + return args; +} + +/** Build the `sl diff --git --change` arguments used for `hunk show` in Sapling mode. */ +export function buildSlShowArgs(input: ShowCommandInput) { + const args = ["diff", "--git", "--change", input.ref ?? "."]; + + appendSlPathspecs(args, input.pathspecs); + return args; +} + +/** Build the status query used to discover Sapling unknown files for working-copy review. */ +function buildSlStatusArgs(input: VcsCommandInput) { + const args = ["status", "--unknown", "--print0", "--root-relative"]; + + appendSlPathspecs(args, input.pathspecs); + return args; +} + +/** Format a user-facing label for the Sapling command being run. */ +export function formatSlCommandLabel(input: SlBackedInput) { + if (input.kind === "vcs") { + if (input.staged) { + return "hunk diff --staged"; + } + + return input.range ? `hunk diff ${input.range}` : "hunk diff"; + } + + return input.ref ? `hunk show ${input.ref}` : "hunk show"; +} + +function trimSlPrefix(message: string) { + return message.replace(/^(abort|error):\s*/i, "").trim(); +} + +function firstSlErrorLine(stderr: string) { + const line = stderr + .split("\n") + .map((entry) => entry.trim()) + .find(Boolean); + + return trimSlPrefix((line ?? stderr.trim()) || "Sapling command failed."); +} + +function isMissingSlRepoMessage(stderr: string) { + return ["is not inside a repository", "not in a repository", "no repository found"].some( + (fragment) => stderr.toLowerCase().includes(fragment.toLowerCase()), + ); +} + +function isInvalidRevsetMessage(stderr: string) { + return [ + "unknown revision", + "ambiguous identifier", + "can't find revision", + "is not a valid revision", + "revision not found", + "syntax error in revset", + ].some((fragment) => stderr.toLowerCase().includes(fragment.toLowerCase())); +} + +function createMissingSlExecutableError(input: SlBackedInput, slExecutable: string) { + return new HunkUserError( + `Sapling is required for \`${formatSlCommandLabel(input)}\` when \`vcs = "sl"\`, but \`${slExecutable}\` was not found in PATH.`, + ['Install Sapling or set `vcs = "git"` in Hunk config, then try again.'], + ); +} + +function createMissingSlRepoError(input: SlBackedInput) { + return new HunkUserError( + `\`${formatSlCommandLabel(input)}\` must be run inside a Sapling repository when \`vcs = "sl"\`.`, + ['Run the command from a Sapling checkout, or set `vcs = "git"` in Hunk config.'], + ); +} + +/** Return the user-facing error when `--staged` is used with Sapling. */ +export function createSlStagedError(input: VcsCommandInput) { + return new HunkUserError( + `\`${formatSlCommandLabel(input)}\` requires Git VCS mode because Sapling has no staging area.`, + ['Remove `--staged`, or set `vcs = "git"` in Hunk config.'], + ); +} + +function createInvalidRevsetError(input: SlBackedInput) { + const revset = input.kind === "vcs" ? input.range : (input.ref ?? "."); + return new HunkUserError( + `\`${formatSlCommandLabel(input)}\` could not resolve Sapling revset \`${revset}\`.`, + ["Check the revset and try again."], + ); +} + +function createGenericSlError(input: SlBackedInput, stderr: string) { + return new HunkUserError(`\`${formatSlCommandLabel(input)}\` failed.`, [ + firstSlErrorLine(stderr), + ]); +} + +function translateSlSpawnFailure( + input: SlBackedInput, + error: unknown, + slExecutable: string, +): Error { + if (error instanceof HunkUserError) { + return error; + } + + if (error instanceof Error && error.message.includes("Executable not found in $PATH")) { + return createMissingSlExecutableError(input, slExecutable); + } + + return error instanceof Error ? error : new Error(String(error)); +} + +function translateSlExitFailure(input: SlBackedInput, stderr: string) { + if (isMissingSlRepoMessage(stderr)) { + return createMissingSlRepoError(input); + } + + if (isInvalidRevsetMessage(stderr)) { + return createInvalidRevsetError(input); + } + + return createGenericSlError(input, stderr); +} + +/** Spawn one Sapling command and accept only declared non-error exit codes. */ +function runSlCommand({ input, args, cwd = process.cwd(), slExecutable = "sl" }: RunSlTextOptions) { + let proc: ReturnType; + const command = [slExecutable, "--noninteractive", "--color", "never", ...args]; + + try { + proc = Bun.spawnSync(command, { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + } catch (error) { + throw translateSlSpawnFailure(input, error, slExecutable); + } + + const stdout = Buffer.from(proc.stdout ?? []).toString("utf8"); + const stderr = Buffer.from(proc.stderr ?? []).toString("utf8"); + + if (proc.exitCode !== 0) { + throw translateSlExitFailure(input, stderr.trim() || `Command failed: ${command.join(" ")}`); + } + + return { + stdout, + exitCode: proc.exitCode, + }; +} + +/** Run a Sapling command and translate common failures into user-facing Hunk errors. */ +export function runSlText(options: RunSlTextOptions) { + return runSlCommand(options).stdout; +} + +/** Return whether working-copy review should synthesize unknown Sapling files into the patch stream. */ +function shouldIncludeUntrackedFiles(input: VcsCommandInput) { + return !input.staged && input.options.excludeUntracked !== true; +} + +/** Parse `sl status --unknown --print0` output down to repo-root-relative file paths. */ +function parseUntrackedFilePaths(statusText: string) { + return statusText + .split("\0") + .filter(Boolean) + .flatMap((entry) => (entry.startsWith("? ") ? [entry.slice(2)] : [])); +} + +/** Return whether one untracked path can be synthesized into a file diff. */ +function isReviewableUntrackedPath(repoRoot: string, filePath: string) { + const absolutePath = join(repoRoot, filePath); + + let pathInfo: fs.Stats; + try { + pathInfo = fs.lstatSync(absolutePath); + } catch { + return true; + } + + if (pathInfo.isDirectory()) { + return false; + } + + if (!pathInfo.isSymbolicLink()) { + return true; + } + + try { + return !fs.statSync(absolutePath).isDirectory(); + } catch { + return true; + } +} + +/** Return the repo-root-relative unknown files for a working-copy Sapling review. */ +export function listSlUntrackedFiles( + input: VcsCommandInput, + { + cwd = process.cwd(), + repoRoot, + slExecutable = "sl", + }: Omit & { repoRoot?: string } = {}, +) { + if (!shouldIncludeUntrackedFiles(input)) { + return []; + } + + const statusText = runSlText({ + input, + args: buildSlStatusArgs(input), + cwd, + slExecutable, + }); + + const untrackedFiles = parseUntrackedFilePaths(statusText); + if (untrackedFiles.length === 0) { + return []; + } + + const normalizedRepoRoot = repoRoot ?? resolveSlRepoRoot(input, { cwd, slExecutable }); + return untrackedFiles.filter((filePath) => + isReviewableUntrackedPath(normalizedRepoRoot, filePath), + ); +} + +/** Resolve the repo root by running `sl root`. */ +export function resolveSlRepoRoot( + input: SlBackedInput, + options: Omit = {}, +) { + return runSlText({ + input, + args: ["root"], + ...options, + }).trim(); +} diff --git a/src/core/types.ts b/src/core/types.ts index 51442aaf..8c3a7489 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,7 +1,7 @@ import type { FileDiffMetadata } from "@pierre/diffs"; export type LayoutMode = "auto" | "split" | "stack"; -export type VcsMode = "git" | "jj"; +export type VcsMode = "git" | "jj" | "sl"; export type TerminalThemeMode = "light" | "dark"; export interface AgentAnnotation { diff --git a/src/core/vcs/index.test.ts b/src/core/vcs/index.test.ts index 3517e7f8..9f33aaa4 100644 --- a/src/core/vcs/index.test.ts +++ b/src/core/vcs/index.test.ts @@ -32,16 +32,17 @@ afterEach(() => { }); describe("VCS adapter registry", () => { - test("registers Git and Jujutsu adapters", () => { - expect(vcsAdapters.map((adapter) => adapter.id)).toEqual(["jj", "git"]); + test("registers Git, Jujutsu, and Sapling adapters", () => { + expect(vcsAdapters.map((adapter) => adapter.id)).toEqual(["jj", "sl", "git"]); expect(getVcsAdapter("git").capabilities.reviewOperations.has("stash-show")).toBe(true); expect(getVcsAdapter("jj").capabilities.reviewOperations.has("stash-show")).toBe(false); + expect(getVcsAdapter("sl").capabilities.reviewOperations.has("stash-show")).toBe(false); }); test("validates VCS ids from the registered adapter list", () => { expect(isVcsId("git")).toBe(true); expect(isVcsId("jj")).toBe(true); - expect(isVcsId("sl")).toBe(false); + expect(isVcsId("sl")).toBe(true); expect(isVcsId("hg")).toBe(false); }); diff --git a/src/core/vcs/index.ts b/src/core/vcs/index.ts index ea1cb0fc..3bc71dcc 100644 --- a/src/core/vcs/index.ts +++ b/src/core/vcs/index.ts @@ -2,9 +2,10 @@ import { dirname, resolve } from "node:path"; import { HunkUserError } from "../errors"; import { gitAdapter } from "./git"; import { jjAdapter } from "./jj"; +import { slAdapter } from "./sl"; import type { VcsAdapter, VcsDetection, VcsId, VcsReviewInput, VcsReviewOperation } from "./types"; -export const vcsAdapters: VcsAdapter[] = [jjAdapter, gitAdapter]; +export const vcsAdapters: VcsAdapter[] = [jjAdapter, slAdapter, gitAdapter]; export function getVcsAdapter(id: VcsId): VcsAdapter { const adapter = vcsAdapters.find((candidate) => candidate.id === id); diff --git a/src/core/vcs/sl.test.ts b/src/core/vcs/sl.test.ts new file mode 100644 index 00000000..1de86223 --- /dev/null +++ b/src/core/vcs/sl.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { platform, tmpdir } from "node:os"; +import { join } from "node:path"; +import { slAdapter } from "./sl"; +import type { ShowCommandInput, StashShowCommandInput, VcsCommandInput } from "../types"; + +const slAvailable = + Bun.spawnSync(["sl", "version"], { stdin: "ignore", stdout: "ignore", stderr: "ignore" }) + .exitCode === 0; +const tempDirs: string[] = []; +const SlAdapterIntegrationTestTimeoutMs = 20_000; + +function createTempDir(prefix: string) { + const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix))); + tempDirs.push(dir); + return dir; +} + +/** Normalize Windows short/long temp path spellings before path equality assertions. */ +function normalizeComparablePath(path: string) { + const resolvedPath = platform() === "win32" ? realpathSync.native(path) : path; + return resolvedPath.replace(/\\/g, "/"); +} + +function sl(cwd: string, ...cmd: string[]) { + const proc = Bun.spawnSync(["sl", "--noninteractive", "--color", "never", ...cmd], { + cwd, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + + if (proc.exitCode !== 0) { + const stderr = Buffer.from(proc.stderr).toString("utf8"); + throw new Error(stderr.trim() || `sl ${cmd.join(" ")} failed`); + } + + return Buffer.from(proc.stdout).toString("utf8"); +} + +function createTempSlRepo(prefix: string) { + const dir = createTempDir(prefix); + sl(dir, "init", "--git"); + return dir; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +describe("slAdapter", () => { + test("detects Sapling repositories from nested directories", () => { + const repo = createTempDir("hunk-sl-adapter-detect-"); + mkdirSync(join(repo, ".sl")); + const nested = join(repo, "src", "nested"); + mkdirSync(nested, { recursive: true }); + + expect(slAdapter.detect(nested)).toEqual({ id: "sl", repoRoot: repo }); + }); + + test("auto-detects .hg directories with treestate as Sapling", () => { + const repo = createTempDir("hunk-sl-adapter-hg-treestate-"); + mkdirSync(join(repo, ".hg")); + writeFileSync(join(repo, ".hg", "requires"), "revlogv1\nstore\ntreestate\n"); + + expect(slAdapter.detect(repo)).toEqual({ id: "sl", repoRoot: repo }); + }); + + test("does not auto-detect .hg directories without treestate", () => { + const repo = createTempDir("hunk-sl-adapter-hg-upstream-"); + mkdirSync(join(repo, ".hg")); + writeFileSync(join(repo, ".hg", "requires"), "revlogv1\nstore\n"); + + expect(slAdapter.detect(repo)).toBeNull(); + }); + + test.skipIf(!slAvailable)( + "loads working-copy and revision patches through neutral operations", + async () => { + const repo = createTempSlRepo("hunk-sl-adapter-review-"); + writeFileSync(join(repo, "file.txt"), "one\n"); + sl(repo, "add", "file.txt"); + sl(repo, "commit", "-m", "initial"); + writeFileSync(join(repo, "file.txt"), "two\n"); + + const diffInput = { + kind: "vcs", + staged: false, + options: { vcs: "sl" }, + } satisfies VcsCommandInput; + const diffResult = await slAdapter.loadReview( + { kind: "working-tree-diff", input: diffInput }, + { cwd: repo }, + ); + + expect(normalizeComparablePath(diffResult.repoRoot)).toBe(normalizeComparablePath(repo)); + expect(diffResult.title).toContain("working copy"); + expect(diffResult.patchText).toContain("diff --git a/file.txt b/file.txt"); + expect(diffResult.patchText).toContain("+two"); + + const showInput = { + kind: "show", + ref: ".", + options: { vcs: "sl" }, + } satisfies ShowCommandInput; + const showResult = await slAdapter.loadReview( + { kind: "revision-show", input: showInput }, + { cwd: repo }, + ); + + expect(showResult.title).toContain("show ."); + expect(showResult.patchText).toContain("diff --git a/file.txt b/file.txt"); + }, + SlAdapterIntegrationTestTimeoutMs, + ); + + test.skipIf(!slAvailable)( + "rejects staged and stash operations", + async () => { + const repo = createTempSlRepo("hunk-sl-adapter-unsupported-"); + const stagedInput = { + kind: "vcs", + staged: true, + options: { vcs: "sl" }, + } satisfies VcsCommandInput; + const stashInput = { + kind: "stash-show", + options: { vcs: "sl" }, + } satisfies StashShowCommandInput; + + await expect( + slAdapter.loadReview({ kind: "working-tree-diff", input: stagedInput }, { cwd: repo }), + ).rejects.toThrow("Sapling has no staging area"); + await expect( + slAdapter.loadReview({ kind: "stash-show", input: stashInput }, { cwd: repo }), + ).rejects.toThrow("requires Git VCS mode"); + }, + SlAdapterIntegrationTestTimeoutMs, + ); +}); diff --git a/src/core/vcs/sl.ts b/src/core/vcs/sl.ts new file mode 100644 index 00000000..8147fae9 --- /dev/null +++ b/src/core/vcs/sl.ts @@ -0,0 +1,130 @@ +import fs from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { HunkUserError } from "../errors"; +import { + buildSlDiffArgs, + buildSlShowArgs, + createSlStagedError, + listSlUntrackedFiles, + resolveSlRepoRoot, + runSlText, +} from "../sl"; +import type { VcsAdapter } from "./types"; + +/** Return the last path segment for review titles. */ +function basename(path: string) { + return path.split(/[\\/]/).filter(Boolean).pop() ?? path; +} + +/** Return whether a `.hg` directory belongs to Sapling rather than upstream Mercurial. */ +function isSaplingHgRepo(hgDir: string) { + try { + return fs.readFileSync(join(hgDir, "requires"), "utf8").split("\n").includes("treestate"); + } catch { + return false; + } +} + +/** Walk upward to detect a Sapling workspace marker. `.sl` always matches; + * `.hg` only matches when `.hg/requires` contains `treestate` (Sapling-specific). */ +function detectSlRepo(cwd: string) { + let current = resolve(cwd); + for (;;) { + if (fs.existsSync(join(current, ".sl"))) { + return { id: "sl" as const, repoRoot: current }; + } + const hgDir = join(current, ".hg"); + if (fs.existsSync(hgDir) && isSaplingHgRepo(hgDir)) { + return { id: "sl" as const, repoRoot: current }; + } + const parent = dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +/** Return the user-facing error for Sapling operations that only Git supports. */ +function createSlUnsupportedStashShowError() { + return new HunkUserError("`hunk stash show` requires Git VCS mode.", [ + 'Set `vcs = "git"` in Hunk config, then try again.', + ]); +} + +/** Format one file stat into a stable signature fragment, or mark the path missing. */ +function statSignature(path: string) { + if (!fs.existsSync(path)) { + return `${path}:missing`; + } + + const stat = fs.statSync(path); + return `${path}:${stat.size}:${stat.mtimeMs}:${stat.ino}`; +} + +/** VCS adapter translating neutral review operations to Sapling commands. */ +export const slAdapter: VcsAdapter = { + id: "sl", + name: "Sapling", + capabilities: { + reviewOperations: new Set(["working-tree-diff", "revision-show"]), + stagedDiff: false, + watchSignatures: true, + }, + + detect: detectSlRepo, + + async loadReview(operation, { cwd }) { + switch (operation.kind) { + case "working-tree-diff": { + const input = operation.input; + if (input.staged) { + throw createSlStagedError(input); + } + const repoRoot = resolveSlRepoRoot(input, { cwd }); + const repoName = basename(repoRoot); + return { + repoRoot, + sourceLabel: repoRoot, + title: input.range ? `${repoName} ${input.range}` : `${repoName} working copy`, + patchText: runSlText({ input, args: buildSlDiffArgs(input), cwd }), + untrackedFiles: listSlUntrackedFiles(input, { cwd, repoRoot }), + }; + } + case "revision-show": { + const input = operation.input; + const repoRoot = resolveSlRepoRoot(input, { cwd }); + const repoName = basename(repoRoot); + const revset = input.ref ?? "."; + return { + repoRoot, + sourceLabel: repoRoot, + title: `${repoName} show ${revset}`, + patchText: runSlText({ input, args: buildSlShowArgs(input), cwd }), + }; + } + case "stash-show": + throw createSlUnsupportedStashShowError(); + } + }, + + watchSignature(operation, { cwd }) { + switch (operation.kind) { + case "working-tree-diff": { + const input = operation.input; + const trackedPatch = runSlText({ input, args: buildSlDiffArgs(input), cwd }); + const repoRoot = resolveSlRepoRoot(input, { cwd }); + const untrackedSignatures = listSlUntrackedFiles(input, { cwd, repoRoot }).map( + (filePath) => `untracked:${statSignature(join(repoRoot, filePath))}`, + ); + return [trackedPatch, ...untrackedSignatures].join("\n---\n"); + } + case "revision-show": { + const input = operation.input; + return runSlText({ input, args: buildSlShowArgs(input), cwd }); + } + case "stash-show": + throw createSlUnsupportedStashShowError(); + } + }, +};