Skip to content

Commit b67a03d

Browse files
committed
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.
1 parent 59da517 commit b67a03d

15 files changed

Lines changed: 814 additions & 19 deletions

File tree

.github/workflows/ci.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ jobs:
4848
with:
4949
tool: jj-cli
5050

51+
- name: Install Sapling
52+
run: |
53+
sudo apt-get install -y xz-utils
54+
sudo mkdir -p /opt/sapling
55+
SAPLING_URL="https://github.com/facebook/sapling/releases/download/0.2.20260317-201835%2B0234c21f/sapling-0.2.20260317-201835%2B0234c21f-linux-x64.tar.xz"
56+
SAPLING_SHA256="2dca0b964a2f3e2e3ad3f4f01794dc7e829de4aedddb2798a7bae883ce6cd71f"
57+
curl -fsSL "$SAPLING_URL" -o /tmp/sapling.tar.xz
58+
echo "$SAPLING_SHA256 /tmp/sapling.tar.xz" | sha256sum --check
59+
sudo tar -xJf /tmp/sapling.tar.xz -C /opt/sapling
60+
sudo ln -s /opt/sapling/sl /usr/local/bin/sl
61+
sl version
62+
5163
- name: Install dependencies
5264
run: bun install --frozen-lockfile
5365

.github/workflows/pr-ci.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,18 @@ jobs:
9393
with:
9494
tool: jj-cli
9595

96+
- name: Install Sapling
97+
run: |
98+
sudo apt-get install -y xz-utils
99+
sudo mkdir -p /opt/sapling
100+
SAPLING_URL="https://github.com/facebook/sapling/releases/download/0.2.20260317-201835%2B0234c21f/sapling-0.2.20260317-201835%2B0234c21f-linux-x64.tar.xz"
101+
SAPLING_SHA256="2dca0b964a2f3e2e3ad3f4f01794dc7e829de4aedddb2798a7bae883ce6cd71f"
102+
curl -fsSL "$SAPLING_URL" -o /tmp/sapling.tar.xz
103+
echo "$SAPLING_SHA256 /tmp/sapling.tar.xz" | sha256sum --check
104+
sudo tar -xJf /tmp/sapling.tar.xz -C /opt/sapling
105+
sudo ln -s /opt/sapling/sl /usr/local/bin/sl
106+
sl version
107+
96108
- name: Install dependencies
97109
run: bun install --frozen-lockfile
98110

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ All notable user-visible changes to Hunk are documented in this file.
66

77
### Added
88

9+
- Added Sapling VCS backend support for `hunk diff` and `hunk show`.
10+
911
### Changed
1012

1113
### Fixed

README.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@ hunk show # review the latest commit
6565
hunk show HEAD~1 # review an earlier commit
6666
```
6767

68-
### Working with Jujutsu
68+
### Working with Jujutsu and Sapling
6969

70-
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).
70+
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 = "git"` or `vcs = "jj"` or `vcs = "sl"` in [config](#config).
7171

7272
### Working with raw files and patches
7373

@@ -121,15 +121,15 @@ Example:
121121
```toml
122122
theme = "graphite" # graphite, midnight, paper, ember, catppuccin-latte, catppuccin-mocha, custom
123123
mode = "auto" # auto, split, stack
124-
vcs = "git" # git, jj
124+
vcs = "git" # git, jj, sl
125125
watch = false
126126
exclude_untracked = false
127127
line_numbers = true
128128
wrap_lines = false
129129
agent_notes = false
130130
```
131131

132-
`exclude_untracked` affects Git working-tree `hunk diff` sessions only.
132+
`exclude_untracked` affects Git/Sapling working-tree `hunk diff` sessions only.
133133

134134
Custom themes can inherit from any built-in base theme and override only the colors you care about:
135135

@@ -186,6 +186,15 @@ pager = ["hunk", "pager"]
186186
diff-formatter = ":git"
187187
```
188188

189+
### Sapling pager integration
190+
191+
To use Hunk as Sapling's pager, run `sl config -u` and update:
192+
193+
```ini
194+
[pager]
195+
pager = hunk pager
196+
```
197+
189198
### OpenTUI component
190199

191200
Hunk also publishes `HunkDiffView` and lower-level primitives from `hunkdiff/opentui` for embedding the same diff renderer in your own OpenTUI app.

src/core/git.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fs from "node:fs";
22
import { join } from "node:path";
33
import { HunkUserError } from "./errors";
4+
import { escapeUntrackedPatchPath } from "./patch/normalize";
45
import type { VcsCommandInput, ShowCommandInput, StashShowCommandInput } from "./types";
56

67
export type GitBackedInput = VcsCommandInput | ShowCommandInput | StashShowCommandInput;
@@ -456,15 +457,6 @@ export function listGitUntrackedFiles(
456457
);
457458
}
458459

459-
/** Escape only the filename characters that break unified-diff header parsing. */
460-
function escapeUntrackedPatchPath(path: string) {
461-
return path
462-
.replaceAll("\\", "\\\\")
463-
.replaceAll("\t", "\\t")
464-
.replaceAll("\n", "\\n")
465-
.replaceAll("\r", "\\r");
466-
}
467-
468460
/** Rewrite Git's quoted untracked-file headers into parser-friendly paths. */
469461
export function normalizeUntrackedPatchHeaders(patchText: string, filePath: string) {
470462
const safePath = escapeUntrackedPatchPath(filePath);

src/core/loaders.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const tempDirs: string[] = [];
1010

1111
// Jujutsu subprocess setup can exceed Bun's default 5s test timeout on Windows CI.
1212
const JjLoaderIntegrationTestTimeoutMs = 20_000;
13+
// Sapling subprocess setup can exceed Bun's default 5s test timeout on slower machines.
14+
const SlLoaderIntegrationTestTimeoutMs = 20_000;
1315

1416
function cleanupTempDirs() {
1517
while (tempDirs.length > 0) {
@@ -76,6 +78,22 @@ function jj(cwd: string, ...cmd: string[]) {
7678
return Buffer.from(proc.stdout).toString("utf8");
7779
}
7880

81+
function sl(cwd: string, ...cmd: string[]) {
82+
const proc = Bun.spawnSync(["sl", "--noninteractive", "--color", "never", ...cmd], {
83+
cwd,
84+
stdout: "pipe",
85+
stderr: "pipe",
86+
stdin: "ignore",
87+
});
88+
89+
if (proc.exitCode !== 0) {
90+
const stderr = Buffer.from(proc.stderr).toString("utf8");
91+
throw new Error(stderr.trim() || `sl ${cmd.join(" ")} failed`);
92+
}
93+
94+
return Buffer.from(proc.stdout).toString("utf8");
95+
}
96+
7997
function createTempRepo(prefix: string) {
8098
const dir = createTempDir(prefix);
8199

@@ -95,8 +113,19 @@ function createTempJjRepo(prefix: string) {
95113
return dir;
96114
}
97115

116+
function createTempSlRepo(prefix: string) {
117+
const dir = createTempDir(prefix);
118+
119+
sl(dir, "init", "--git");
120+
sl(dir, "config", "--local", "ui.username", "Test User <test@example.com>");
121+
122+
return dir;
123+
}
124+
98125
// Keep jj-backed loader coverage opt-in on machines that have the external CLI installed.
99126
const jjTest = Bun.which("jj") ? test : test.skip;
127+
// Keep sl-backed loader coverage opt-in on machines that have the external CLI installed.
128+
const slTest = Bun.which("sl") ? test : test.skip;
100129

101130
async function runWithHome<T>(home: string, task: () => Promise<T>) {
102131
const previousHome = process.env.HOME;
@@ -317,6 +346,30 @@ describe("loadAppBootstrap", () => {
317346
expect(bootstrap.changeset.files[1]?.patch).toContain("new file mode");
318347
});
319348

349+
slTest(
350+
"includes Sapling unknown files in working copy reviews",
351+
async () => {
352+
const dir = createTempSlRepo("hunk-sl-untracked-");
353+
354+
writeFileSync(join(dir, "tracked.ts"), "export const value = 1;\n");
355+
sl(dir, "add", "tracked.ts");
356+
sl(dir, "commit", "-m", "initial");
357+
358+
writeFileSync(join(dir, "new-file.ts"), "export const added = true;\n");
359+
360+
const bootstrap = await loadFromRepo(dir, {
361+
kind: "vcs",
362+
staged: false,
363+
options: { mode: "auto", vcs: "sl" },
364+
});
365+
366+
const file = bootstrap.changeset.files[0];
367+
expect(bootstrap.changeset.files.map((entry) => entry.path)).toEqual(["new-file.ts"]);
368+
expect(file?.isUntracked).toBe(true);
369+
},
370+
SlLoaderIntegrationTestTimeoutMs,
371+
);
372+
320373
test("keeps generated large tracked diffs as skipped placeholders", async () => {
321374
const dir = createTempRepo("hunk-git-large-tracked-");
322375

src/core/loaders.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
import { createFileSourceFetcher, type FileSourceSpec } from "./fileSource";
1919
import { normalizeUntrackedPatchHeaders, runGitUntrackedFileDiffText } from "./git";
2020
import { splitPatchIntoFileChunks, findPatchChunk } from "./patch/chunks";
21-
import { normalizePatchText } from "./patch/normalize";
21+
import { escapeUntrackedPatchPath, normalizePatchText } from "./patch/normalize";
2222
import { createUnsupportedVcsOperationError, getVcsAdapter, operationFromInput } from "./vcs";
2323
import type {
2424
AppBootstrap,
@@ -182,6 +182,7 @@ function buildUntrackedDiffFile(
182182
agentContext: AgentContext | null,
183183
gitExecutable = "git",
184184
) {
185+
const absolutePath = join(repoRoot, filePath);
185186
const largeFileCheck = inspectLargeUntrackedFile(repoRoot, filePath);
186187
if (largeFileCheck.shouldSkip) {
187188
return buildDiffFile(
@@ -199,6 +200,40 @@ function buildUntrackedDiffFile(
199200
);
200201
}
201202

203+
if (input.options.vcs === "sl") {
204+
if (isProbablyBinaryFile(absolutePath)) {
205+
return buildDiffFile(
206+
createSkippedBinaryMetadata(filePath, "new"),
207+
`Binary file skipped: ${filePath}\n`,
208+
index,
209+
sourcePrefix,
210+
agentContext,
211+
{ isBinary: true, isUntracked: true },
212+
);
213+
}
214+
215+
const patch = createTwoFilesPatch(
216+
"/dev/null",
217+
escapeUntrackedPatchPath(filePath),
218+
"",
219+
fs.readFileSync(absolutePath, "utf8"),
220+
"",
221+
"",
222+
{ context: 3 },
223+
).replaceAll("\r\n", "\n");
224+
225+
return buildDiffFile(
226+
parseUntrackedPatchFile(patch, filePath),
227+
patch,
228+
index,
229+
sourcePrefix,
230+
agentContext,
231+
{
232+
isUntracked: true,
233+
},
234+
);
235+
}
236+
202237
const patch = normalizeUntrackedPatchHeaders(
203238
runGitUntrackedFileDiffText(input, filePath, { repoRoot, gitExecutable }),
204239
filePath,

src/core/patch/normalize.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import { normalizeGitPatchPrefixes } from "./gitFormat";
22
import { stripGitLogMetadata } from "./gitLog";
33

4+
/** Escape only path characters that break unified-diff header parsing. */
5+
export function escapeUntrackedPatchPath(path: string) {
6+
return path
7+
.replaceAll("\\", "\\\\")
8+
.replaceAll("\t", "\\t")
9+
.replaceAll("\n", "\\n")
10+
.replaceAll("\r", "\\r");
11+
}
12+
413
/** Remove terminal escape sequences so Git-colored pager input still parses as plain patch text. */
514
export function stripTerminalControl(text: string) {
615
return text

src/core/sl.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { buildSlDiffArgs, runSlText } from "./sl";
6+
7+
const slAvailable = (() => {
8+
try {
9+
return (
10+
Bun.spawnSync(["sl", "version"], { stdin: "ignore", stdout: "ignore", stderr: "ignore" })
11+
.exitCode === 0
12+
);
13+
} catch {
14+
return false;
15+
}
16+
})();
17+
const tempDirs: string[] = [];
18+
19+
function cleanupTempDirs() {
20+
while (tempDirs.length > 0) {
21+
const dir = tempDirs.pop();
22+
if (dir) {
23+
rmSync(dir, { recursive: true, force: true });
24+
}
25+
}
26+
}
27+
28+
function createTempDir(prefix: string) {
29+
const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
30+
tempDirs.push(dir);
31+
return dir;
32+
}
33+
34+
function sl(cwd: string, ...cmd: string[]) {
35+
const proc = Bun.spawnSync(["sl", "--noninteractive", "--color", "never", ...cmd], {
36+
cwd,
37+
stdout: "pipe",
38+
stderr: "pipe",
39+
stdin: "ignore",
40+
});
41+
42+
if (proc.exitCode !== 0) {
43+
const stderr = Buffer.from(proc.stderr).toString("utf8");
44+
throw new Error(stderr.trim() || `sl ${cmd.join(" ")} failed`);
45+
}
46+
47+
return Buffer.from(proc.stdout).toString("utf8");
48+
}
49+
50+
function createTempSlRepo(prefix: string) {
51+
const dir = createTempDir(prefix);
52+
sl(dir, "init", "--git");
53+
sl(dir, "config", "--local", "ui.username", "Test User <test@example.com>");
54+
return dir;
55+
}
56+
57+
afterEach(() => {
58+
cleanupTempDirs();
59+
});
60+
61+
describe("sl command helpers", () => {
62+
test("reports a friendly error when sl is not installed or not on PATH", () => {
63+
expect(() =>
64+
runSlText({
65+
input: {
66+
kind: "vcs",
67+
staged: false,
68+
options: { mode: "auto", vcs: "sl" },
69+
},
70+
args: ["root"],
71+
slExecutable: "definitely-not-a-real-sl-binary",
72+
}),
73+
).toThrow(
74+
'Sapling is required for `hunk diff` when `vcs = "sl"`, but `definitely-not-a-real-sl-binary` was not found in PATH.',
75+
);
76+
});
77+
78+
test.skipIf(!slAvailable)("reports a friendly error outside a sl repository", () => {
79+
const dir = createTempDir("hunk-sl-nonrepo-");
80+
81+
expect(() =>
82+
runSlText({
83+
input: {
84+
kind: "vcs",
85+
staged: false,
86+
options: { mode: "auto", vcs: "sl" },
87+
},
88+
args: ["root"],
89+
cwd: dir,
90+
}),
91+
).toThrow('`hunk diff` must be run inside a Sapling repository when `vcs = "sl"`.');
92+
});
93+
94+
test.skipIf(!slAvailable)("reports a friendly error for invalid revsets", () => {
95+
const dir = createTempSlRepo("hunk-sl-invalid-revset-");
96+
const input = {
97+
kind: "vcs" as const,
98+
range: "missing_revision",
99+
staged: false,
100+
options: { mode: "auto" as const, vcs: "sl" as const },
101+
};
102+
103+
expect(() =>
104+
runSlText({
105+
input,
106+
args: buildSlDiffArgs(input),
107+
cwd: dir,
108+
}),
109+
).toThrow("`hunk diff missing_revision` could not resolve Sapling revset `missing_revision`.");
110+
});
111+
});

0 commit comments

Comments
 (0)