diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d196b38d..0d57af9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,14 @@ jobs: with: tool: jj-cli + - name: Install Sapling + run: | + sudo apt-get install -y xz-utils + sudo mkdir -p /opt/sapling + curl -fsSL "https://github.com/facebook/sapling/releases/download/0.2.20260317-201835%2B0234c21f/sapling-0.2.20260317-201835%2B0234c21f-linux-x64.tar.xz" | sudo tar -xJ -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 6c1723fb..07cb1298 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -78,6 +78,14 @@ jobs: with: tool: jj-cli + - name: Install Sapling + run: | + sudo apt-get install -y xz-utils + sudo mkdir -p /opt/sapling + curl -fsSL "https://github.com/facebook/sapling/releases/download/0.2.20260317-201835%2B0234c21f/sapling-0.2.20260317-201835%2B0234c21f-linux-x64.tar.xz" | sudo tar -xJ -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 d1bdce87..5bef9fbe 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,11 @@ hunk show HEAD~1 # review an earlier commit ### Working with Jujutsu -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 checkouts, so `hunk diff [revset]` and `hunk show [revset]` use jj revsets inside a jj workspace. To override VCS detection, set `vcs = "jj"` in [config](#config). + +### Working with Sapling + +Hunk auto-detects Sapling checkouts, so `hunk diff [revset]` and `hunk show [revset]` use Sapling revsets inside a Sapling workspace. To override VCS detection, set `vcs = "sl"` in [config](#config). ### Working with raw files and patches @@ -121,7 +125,7 @@ Example: ```toml theme = "graphite" # graphite, midnight, paper, ember mode = "auto" # auto, split, stack -vcs = "git" # git, jj +vcs = "git" # git, jj, sl exclude_untracked = false line_numbers = true wrap_lines = false @@ -165,6 +169,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/cli.ts b/src/core/cli.ts index 0b94564c..cc45b6d4 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -159,7 +159,7 @@ function renderCliHelp() { "", "Notes:", " Run `hunk --help` for command-specific syntax and options.", - ' "target" refers to a generic set of changes; it can be a ref (git) or revset (jj)', + ' "target" refers to a generic set of changes; it can be a ref (git), revset (jj), or revset (sl)', "", ].join("\n"); } diff --git a/src/core/config.test.ts b/src/core/config.test.ts index f1e28080..eefc4652 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -31,6 +31,11 @@ function createJjRepo(dir: string) { mkdirSync(join(dir, ".jj"), { recursive: true }); } +function createSlRepo(dir: string) { + mkdirSync(join(dir, ".sl"), { recursive: true }); +} + + function createPatchPagerInput(overrides: Partial = {}): CliInput { return { kind: "patch", @@ -161,11 +166,7 @@ describe("config resolution", () => { expect(fallbackResolved.input.options.excludeUntracked).toBe(false); }); - test("defaults to git VCS mode and accepts jj from config", () => { - const home = createTempDir("hunk-config-home-"); - mkdirSync(join(home, ".config", "hunk"), { recursive: true }); - writeFileSync(join(home, ".config", "hunk", "config.toml"), 'vcs = "jj"\n'); - + test("defaults to git VCS mode and accepts jj and sl from config", () => { const cwd = createTempDir("hunk-config-cwd-"); const defaultResolved = resolveConfiguredCliInput( { @@ -175,30 +176,49 @@ describe("config resolution", () => { }, { cwd, env: { HOME: createTempDir("hunk-config-empty-home-") } }, ); - const configuredResolved = resolveConfiguredCliInput( + + const jjHome = createTempDir("hunk-config-home-jj-"); + mkdirSync(join(jjHome, ".config", "hunk"), { recursive: true }); + writeFileSync(join(jjHome, ".config", "hunk", "config.toml"), 'vcs = "jj"\n'); + const jjResolved = resolveConfiguredCliInput( { kind: "vcs", staged: false, options: {}, }, - { cwd, env: { HOME: home } }, + { cwd, env: { HOME: jjHome } }, + ); + + const slHome = createTempDir("hunk-config-home-sl-"); + mkdirSync(join(slHome, ".config", "hunk"), { recursive: true }); + writeFileSync(join(slHome, ".config", "hunk", "config.toml"), 'vcs = "sl"\n'); + const slResolved = resolveConfiguredCliInput( + { + kind: "vcs", + staged: false, + options: {}, + }, + { cwd, env: { HOME: slHome } }, ); expect(defaultResolved.input.options.vcs).toBe("git"); - expect(configuredResolved.input.options.vcs).toBe("jj"); + expect(jjResolved.input.options.vcs).toBe("jj"); + expect(slResolved.input.options.vcs).toBe("sl"); }); - test("auto-detects jj checkouts before falling back to git mode", () => { + test("auto-detects jj and sl checkouts before falling back to git mode", () => { const home = createTempDir("hunk-config-home-"); const jjRepo = createTempDir("hunk-config-jj-repo-"); const colocatedRepo = createTempDir("hunk-config-colocated-repo-"); const gitRepo = createTempDir("hunk-config-git-repo-"); + const slRepo = createTempDir("hunk-config-sl-repo-"); const plainDir = createTempDir("hunk-config-no-repo-"); createJjRepo(jjRepo); createRepo(colocatedRepo); createJjRepo(colocatedRepo); createRepo(gitRepo); + createSlRepo(slRepo); const input = { kind: "vcs", @@ -216,6 +236,9 @@ describe("config resolution", () => { expect( resolveConfiguredCliInput(input, { cwd: gitRepo, env: { HOME: home } }).input.options.vcs, ).toBe("git"); + expect( + resolveConfiguredCliInput(input, { cwd: slRepo, env: { HOME: home } }).input.options.vcs, + ).toBe("sl"); expect( resolveConfiguredCliInput(input, { cwd: plainDir, env: { HOME: home } }).input.options.vcs, ).toBe("git"); diff --git a/src/core/config.ts b/src/core/config.ts index 21f12fd3..35dd1d8f 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -39,7 +39,7 @@ function normalizeLayoutMode(value: unknown): LayoutMode | undefined { /** Accept only the VCS backends Hunk can load directly. */ function normalizeVcsMode(value: unknown): VcsMode | undefined { - return value === "git" || value === "jj" ? value : undefined; + return value === "git" || value === "jj" || value === "sl" ? value : undefined; } /** Accept only plain booleans from config files. */ @@ -106,7 +106,12 @@ function findRepoRoot(cwd = process.cwd()) { let current = resolve(cwd); for (;;) { - if (fs.existsSync(join(current, ".git")) || fs.existsSync(join(current, ".jj"))) { + if ( + fs.existsSync(join(current, ".git")) || + fs.existsSync(join(current, ".jj")) || + fs.existsSync(join(current, ".sl")) || + fs.existsSync(join(current, ".hg")) + ) { return current; } @@ -121,10 +126,19 @@ function findRepoRoot(cwd = process.cwd()) { /** Choose the VCS backend that best matches the discovered checkout. */ function detectRepoVcsMode(repoRoot?: string): VcsMode { - if (repoRoot && fs.existsSync(join(repoRoot, ".jj"))) { + if (!repoRoot) { + return "git"; + } + + // Prefer jj when colocated with git, matching the existing jj-first precedence. + if (fs.existsSync(join(repoRoot, ".jj"))) { return "jj"; } + if (fs.existsSync(join(repoRoot, ".sl")) || fs.existsSync(join(repoRoot, ".hg"))) { + return "sl"; + } + return "git"; } diff --git a/src/core/loaders.test.ts b/src/core/loaders.test.ts index a458e63a..e1ebe27f 100644 --- a/src/core/loaders.test.ts +++ b/src/core/loaders.test.ts @@ -91,6 +91,30 @@ function createTempJjRepo(prefix: string) { 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; +} + async function runWithHome(home: string, task: () => Promise) { const previousHome = process.env.HOME; process.env.HOME = home; @@ -819,6 +843,43 @@ describe("loadAppBootstrap", () => { }); }); + test("loads Sapling working-copy output and includes untracked files by default", async () => { + const dir = createTempSlRepo("hunk-sl-working-copy-"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 1;\n"); + sl(dir, "commit", "-Aqm", "initial"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 2;\n"); + writeFileSync(join(dir, "note.txt"), "new file\n"); + + const bootstrap = await loadFromRepo(dir, { + kind: "vcs", + staged: false, + options: { mode: "auto", vcs: "sl" }, + }); + + expect(bootstrap.changeset.files.map((file) => file.path)).toEqual(["tracked.ts", "note.txt"]); + expect(bootstrap.changeset.files[1]?.isUntracked).toBe(true); + }); + + test("loads Sapling show output for the current revision", async () => { + const dir = createTempSlRepo("hunk-sl-show-"); + + writeFileSync(join(dir, "show.ts"), "export const before = 1;\n"); + sl(dir, "commit", "-Aqm", "initial"); + writeFileSync(join(dir, "show.ts"), "export const after = 2;\n"); + sl(dir, "commit", "-Aqm", "second"); + + const bootstrap = await loadFromRepo(dir, { + kind: "show", + options: { mode: "auto", vcs: "sl" }, + }); + + expect(bootstrap.changeset.files.map((file) => file.path)).toEqual(["show.ts"]); + expect(bootstrap.changeset.files[0]?.stats.additions).toBe(1); + expect(bootstrap.changeset.files[0]?.stats.deletions).toBe(1); + }); + test("applies pathspec filtering to untracked files in working tree reviews", async () => { const dir = createTempRepo("hunk-git-untracked-pathspec-"); @@ -977,6 +1038,17 @@ describe("loadAppBootstrap", () => { ).rejects.toThrow("`hunk stash show` requires Git VCS mode."); }); + test("rejects stash show when configured for sl", async () => { + const dir = createTempDir("hunk-stash-sl-"); + + await expect( + loadFromRepo(dir, { + kind: "stash-show", + options: { mode: "auto", vcs: "sl" }, + }), + ).rejects.toThrow("`hunk stash show` requires Git VCS mode."); + }); + test("reports a friendly error when no stash entries exist", async () => { const dir = createTempRepo("hunk-stash-empty-"); diff --git a/src/core/loaders.ts b/src/core/loaders.ts index c53e6880..c04503bd 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -29,6 +29,14 @@ import { resolveJjRepoRoot, runJjText, } from "./jj"; +import { + buildSlDiffArgs, + buildSlShowArgs, + createSlStagedError, + listSlUntrackedFiles, + resolveSlRepoRoot, + runSlText, +} from "./sl"; import type { AppBootstrap, AgentContext, @@ -731,6 +739,21 @@ function parseUntrackedPatchFile(patchText: string, filePath: string) { } satisfies FileDiffMetadata; } +/** Build one local text patch for an untracked file without relying on a Git checkout. */ +function buildSyntheticUntrackedPatch(filePath: string, contents: string) { + return createTwoFilesPatch( + "/dev/null", + escapeUntrackedPatchPath(filePath), + "", + contents, + "", + "", + { + context: 3, + }, + ).replaceAll("\r\n", "\n"); +} + /** Build one reviewable diff file for an untracked working-tree file. */ function buildUntrackedDiffFile( input: VcsCommandInput, @@ -774,6 +797,60 @@ function buildUntrackedDiffFile( ); } +/** Build one reviewable diff file for an untracked Sapling working-tree file. */ +async function buildSlUntrackedDiffFile( + filePath: string, + index: number, + repoRoot: string, + sourcePrefix: string, + agentContext: AgentContext | null, +) { + const absolutePath = join(repoRoot, filePath); + const largeFileCheck = inspectLargeUntrackedFile(repoRoot, filePath); + if (largeFileCheck.shouldSkip) { + return buildDiffFile( + createSkippedLargeMetadata(filePath, "new"), + "", + index, + sourcePrefix, + agentContext, + { + isTooLarge: true, + isUntracked: true, + stats: largeFileCheck.stats, + statsTruncated: largeFileCheck.statsTruncated, + }, + ); + } + + if (isProbablyBinaryFile(absolutePath)) { + return buildDiffFile( + createSkippedBinaryMetadata(filePath, "new"), + `Binary file skipped: ${filePath}\n`, + index, + sourcePrefix, + agentContext, + { + isBinary: true, + isUntracked: true, + }, + ); + } + + const patch = buildSyntheticUntrackedPatch(filePath, fs.readFileSync(absolutePath, "utf8")); + + return buildDiffFile( + parseUntrackedPatchFile(patch, filePath), + patch, + index, + sourcePrefix, + agentContext, + { + isUntracked: true, + }, + ); +} + /** Reorder files to follow agent-context narrative order when a sidecar provides one. */ export function orderDiffFiles(files: DiffFile[], agentContext: AgentContext | null) { if (!agentContext || agentContext.files.length === 0) { @@ -1090,13 +1167,76 @@ async function loadJjShowChangeset( ); } +/** Build a changeset from the current Sapling working copy or a revset. */ +async function loadSlDiffChangeset( + input: VcsCommandInput, + agentContext: AgentContext | null, + cwd = process.cwd(), +) { + if (input.staged) { + throw createSlStagedError(input); + } + + const repoRoot = resolveSlRepoRoot(input, { cwd }); + const repoName = basename(repoRoot); + const title = input.range ? `${repoName} ${input.range}` : `${repoName} working copy`; + const trackedChangeset = normalizePatchChangeset( + runSlText({ input, args: buildSlDiffArgs(input), cwd }), + title, + repoRoot, + agentContext, + ); + const untrackedFiles = listSlUntrackedFiles(input, { cwd, repoRoot }); + + if (untrackedFiles.length === 0) { + return trackedChangeset; + } + + const trackedFiles = trackedChangeset.files; + return { + ...trackedChangeset, + files: [ + ...trackedFiles, + ...(await Promise.all( + untrackedFiles.map((filePath, index) => + buildSlUntrackedDiffFile( + filePath, + trackedFiles.length + index, + repoRoot, + repoRoot, + agentContext, + ), + ), + )), + ], + } satisfies Changeset; +} + +/** Build a changeset from one Sapling revset using Git-format patch output. */ +async function loadSlShowChangeset( + input: ShowCommandInput, + agentContext: AgentContext | null, + cwd = process.cwd(), +) { + const repoRoot = resolveSlRepoRoot(input, { cwd }); + const repoName = basename(repoRoot); + const revset = input.ref ?? "."; + + return normalizePatchChangeset( + runSlText({ input, args: buildSlShowArgs(input), cwd }), + `${repoName} show ${revset}`, + repoRoot, + agentContext, + ); +} + /** Build a changeset from `git stash show -p`, which naturally maps to one reviewable patch. */ async function loadStashShowChangeset( input: StashShowCommandInput, agentContext: AgentContext | null, cwd = process.cwd(), ) { - if (input.options.vcs === "jj") { + if (input.options.vcs === "jj" || input.options.vcs === "sl") { throw new HunkUserError("`hunk stash show` requires Git VCS mode.", [ 'Set `vcs = "git"` in Hunk config, then try again.', ]); @@ -1148,13 +1288,17 @@ export async function loadAppBootstrap( changeset = input.options.vcs === "jj" ? await loadJjDiffChangeset(input, agentContext, cwd) - : await loadGitChangeset(input, agentContext, cwd); + : input.options.vcs === "sl" + ? await loadSlDiffChangeset(input, agentContext, cwd) + : await loadGitChangeset(input, agentContext, cwd); break; case "show": changeset = input.options.vcs === "jj" ? await loadJjShowChangeset(input, agentContext, cwd) - : await loadShowChangeset(input, agentContext, cwd); + : input.options.vcs === "sl" + ? await loadSlShowChangeset(input, agentContext, cwd) + : await loadShowChangeset(input, agentContext, cwd); break; case "stash-show": changeset = await loadStashShowChangeset(input, agentContext, cwd); diff --git a/src/core/sl.test.ts b/src/core/sl.test.ts new file mode 100644 index 00000000..cc033929 --- /dev/null +++ b/src/core/sl.test.ts @@ -0,0 +1,102 @@ +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 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("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("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..de234eff --- /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; +} + +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", + "There is no Sapling repository here", + ].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", + "not found", + "parse error", + ].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.'], + ); +} + +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), + ); +} + +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 b79fa395..26206885 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 interface AgentAnnotation { id?: string; diff --git a/src/core/watch.test.ts b/src/core/watch.test.ts index a19d51ef..867357c9 100644 --- a/src/core/watch.test.ts +++ b/src/core/watch.test.ts @@ -44,6 +44,31 @@ function createTempRepo(prefix: string) { 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 = realpathSync(mkdtempSync(join(tmpdir(), prefix))); + tempDirs.push(dir); + + sl(dir, "init", "--git"); + + return dir; +} + function withCwd(cwd: string, callback: () => T) { const previousCwd = process.cwd(); process.chdir(cwd); @@ -143,4 +168,27 @@ describe("computeWatchSignature", () => { expect(changedSignature).not.toEqual(initialSignature); }); + + test("tracks Sapling untracked file changes without embedding full contents", () => { + const dir = createTempSlRepo("hunk-watch-sl-untracked-"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 1;\n"); + sl(dir, "commit", "-Aqm", "initial"); + + const largeMarker = "SAPLING-UNTRACKED-".repeat(1024); + const untrackedPath = join(dir, "large-untracked.txt"); + writeFileSync(untrackedPath, largeMarker); + + const initialSignature = withCwd(dir, () => + computeWatchSignature(createGitInput({ options: { vcs: "sl" } })), + ); + writeFileSync(untrackedPath, `${largeMarker}changed`); + const changedSignature = withCwd(dir, () => + computeWatchSignature(createGitInput({ options: { vcs: "sl" } })), + ); + + expect(initialSignature).not.toContain(largeMarker); + expect(changedSignature).not.toContain(largeMarker); + expect(changedSignature).not.toEqual(initialSignature); + }); }); diff --git a/src/core/watch.ts b/src/core/watch.ts index baa4faaf..ce620827 100644 --- a/src/core/watch.ts +++ b/src/core/watch.ts @@ -9,6 +9,13 @@ import { runGitText, } from "./git"; import { buildJjDiffArgs, buildJjShowArgs, runJjText } from "./jj"; +import { + buildSlDiffArgs, + buildSlShowArgs, + listSlUntrackedFiles, + resolveSlRepoRoot, + runSlText, +} from "./sl"; import type { CliInput } from "./types"; /** Return whether the current input can be rebuilt from files or VCS state without rereading stdin. */ @@ -63,16 +70,50 @@ function jjPatchSignature(input: Extract) { } } +/** Build one exact patch signature for Sapling-backed review inputs. */ +function slWorkingTreeWatchSignature(input: Extract) { + const trackedPatch = runSlText({ input, args: buildSlDiffArgs(input) }); + const repoRoot = resolveSlRepoRoot(input); + const untrackedSignatures = listSlUntrackedFiles(input, { repoRoot }).map( + (filePath) => `untracked:${statSignature(join(repoRoot, filePath))}`, + ); + + return [trackedPatch, ...untrackedSignatures].join("\n---\n"); +} + +/** Build one exact patch signature for Sapling-backed review inputs. */ +function slPatchSignature(input: Extract) { + switch (input.kind) { + case "vcs": + return slWorkingTreeWatchSignature(input); + case "show": + return runSlText({ input, args: buildSlShowArgs(input) }); + } +} + +/** Route to the VCS-specific patch signature builder. */ +function vcsPatchSignature(input: Extract) { + if (input.options.vcs === "jj") { + return jjPatchSignature(input); + } + + if (input.options.vcs === "sl") { + return slPatchSignature(input); + } + + return gitPatchSignature(input); +} + /** Compute a change-detection signature for one watchable input. */ export function computeWatchSignature(input: CliInput) { const parts: string[] = [input.kind]; switch (input.kind) { case "vcs": - parts.push(input.options.vcs === "jj" ? jjPatchSignature(input) : gitPatchSignature(input)); + parts.push(vcsPatchSignature(input)); break; case "show": - parts.push(input.options.vcs === "jj" ? jjPatchSignature(input) : gitPatchSignature(input)); + parts.push(vcsPatchSignature(input)); break; case "stash-show": parts.push(gitPatchSignature(input)); diff --git a/test/cli/entrypoint.test.ts b/test/cli/entrypoint.test.ts index 88632211..7e91839d 100644 --- a/test/cli/entrypoint.test.ts +++ b/test/cli/entrypoint.test.ts @@ -237,6 +237,7 @@ describe("CLI entrypoint contracts", () => { test("prints a friendly git-repo error without a Bun stack trace", () => { const nonRepoDir = mkdtempSync(join(tmpdir(), "hunk-nonrepo-")); + const cleanHome = mkdtempSync(join(tmpdir(), "hunk-cli-home-")); const sourceEntrypoint = join(process.cwd(), "src/main.tsx"); try { @@ -245,7 +246,7 @@ describe("CLI entrypoint contracts", () => { stdin: "ignore", stdout: "pipe", stderr: "pipe", - env: process.env, + env: { ...process.env, HOME: cleanHome }, }); const stdout = Buffer.from(proc.stdout).toString("utf8"); @@ -260,11 +261,13 @@ describe("CLI entrypoint contracts", () => { expect(stderr).not.toContain("Bun v"); } finally { rmSync(nonRepoDir, { recursive: true, force: true }); + rmSync(cleanHome, { recursive: true, force: true }); } }); test("prints a friendly invalid-ref error without a Bun stack trace", () => { const repoDir = mkdtempSync(join(tmpdir(), "hunk-show-cli-")); + const cleanHome = mkdtempSync(join(tmpdir(), "hunk-cli-home-")); const sourceEntrypoint = join(process.cwd(), "src/main.tsx"); try { @@ -280,7 +283,7 @@ describe("CLI entrypoint contracts", () => { stdin: "ignore", stdout: "pipe", stderr: "pipe", - env: process.env, + env: { ...process.env, HOME: cleanHome }, }); const stdout = Buffer.from(proc.stdout).toString("utf8"); @@ -294,6 +297,7 @@ describe("CLI entrypoint contracts", () => { expect(stderr).not.toContain("Bun v"); } finally { rmSync(repoDir, { recursive: true, force: true }); + rmSync(cleanHome, { recursive: true, force: true }); } }); });