Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
zzl0 marked this conversation as resolved.

- name: Install dependencies
run: bun install --frozen-lockfile

Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions src/core/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down
105 changes: 105 additions & 0 deletions src/core/sl.test.ts
Original file line number Diff line number Diff line change
@@ -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`.");
});
});
Loading