Skip to content

Commit 7c0d0bc

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 585e0cf commit 7c0d0bc

11 files changed

Lines changed: 732 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ jobs:
3535
with:
3636
tool: jj-cli
3737

38+
- name: Install Sapling
39+
run: |
40+
sudo apt-get install -y xz-utils
41+
sudo mkdir -p /opt/sapling
42+
SAPLING_URL="https://github.com/facebook/sapling/releases/download/0.2.20260317-201835%2B0234c21f/sapling-0.2.20260317-201835%2B0234c21f-linux-x64.tar.xz"
43+
SAPLING_SHA256="2dca0b964a2f3e2e3ad3f4f01794dc7e829de4aedddb2798a7bae883ce6cd71f"
44+
curl -fsSL "$SAPLING_URL" -o /tmp/sapling.tar.xz
45+
echo "$SAPLING_SHA256 /tmp/sapling.tar.xz" | sha256sum --check
46+
sudo tar -xJf /tmp/sapling.tar.xz -C /opt/sapling
47+
sudo ln -s /opt/sapling/sl /usr/local/bin/sl
48+
sl version
49+
3850
- name: Install dependencies
3951
run: bun install --frozen-lockfile
4052

.github/workflows/pr-ci.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ jobs:
7878
with:
7979
tool: jj-cli
8080

81+
- name: Install Sapling
82+
run: |
83+
sudo apt-get install -y xz-utils
84+
sudo mkdir -p /opt/sapling
85+
SAPLING_URL="https://github.com/facebook/sapling/releases/download/0.2.20260317-201835%2B0234c21f/sapling-0.2.20260317-201835%2B0234c21f-linux-x64.tar.xz"
86+
SAPLING_SHA256="2dca0b964a2f3e2e3ad3f4f01794dc7e829de4aedddb2798a7bae883ce6cd71f"
87+
curl -fsSL "$SAPLING_URL" -o /tmp/sapling.tar.xz
88+
echo "$SAPLING_SHA256 /tmp/sapling.tar.xz" | sha256sum --check
89+
sudo tar -xJf /tmp/sapling.tar.xz -C /opt/sapling
90+
sudo ln -s /opt/sapling/sl /usr/local/bin/sl
91+
sl version
92+
8193
- name: Install dependencies
8294
run: bun install --frozen-lockfile
8395

README.md

Lines changed: 12 additions & 3 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 = "jj"` or `vcs = "sl"` in [config](#config).
7171

7272
### Working with raw files and patches
7373

@@ -121,7 +121,7 @@ Example:
121121
```toml
122122
theme = "graphite" # graphite, midnight, paper, ember
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
@@ -166,6 +166,15 @@ pager = ["hunk", "pager"]
166166
diff-formatter = ":git"
167167
```
168168

169+
### Sapling pager integration
170+
171+
To use Hunk as Sapling's pager, run `sl config -u` and update:
172+
173+
```ini
174+
[pager]
175+
pager = hunk pager
176+
```
177+
169178
### OpenTUI component
170179

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

src/core/loaders.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,7 @@ function buildUntrackedDiffFile(
661661
sourcePrefix: string,
662662
agentContext: AgentContext | null,
663663
) {
664+
const absolutePath = join(repoRoot, filePath);
664665
const largeFileCheck = inspectLargeUntrackedFile(repoRoot, filePath);
665666
if (largeFileCheck.shouldSkip) {
666667
return buildDiffFile(
@@ -678,6 +679,40 @@ function buildUntrackedDiffFile(
678679
);
679680
}
680681

682+
if (input.options.vcs === "sl") {
683+
if (isProbablyBinaryFile(absolutePath)) {
684+
return buildDiffFile(
685+
createSkippedBinaryMetadata(filePath, "new"),
686+
`Binary file skipped: ${filePath}\n`,
687+
index,
688+
sourcePrefix,
689+
agentContext,
690+
{ isBinary: true, isUntracked: true },
691+
);
692+
}
693+
694+
const patch = createTwoFilesPatch(
695+
"/dev/null",
696+
escapeUntrackedPatchPath(filePath),
697+
"",
698+
fs.readFileSync(absolutePath, "utf8"),
699+
"",
700+
"",
701+
{ context: 3 },
702+
).replaceAll("\r\n", "\n");
703+
704+
return buildDiffFile(
705+
parseUntrackedPatchFile(patch, filePath),
706+
patch,
707+
index,
708+
sourcePrefix,
709+
agentContext,
710+
{
711+
isUntracked: true,
712+
},
713+
);
714+
}
715+
681716
const patch = normalizeUntrackedPatchHeaders(
682717
runGitUntrackedFileDiffText(input, filePath, { repoRoot }),
683718
filePath,

src/core/sl.test.ts

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

0 commit comments

Comments
 (0)