Skip to content
Draft
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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ function renderCliHelp() {
"",
"Notes:",
" Run `hunk <command> --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");
}
Expand Down
41 changes: 32 additions & 9 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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["options"]> = {}): CliInput {
return {
kind: "patch",
Expand Down Expand Up @@ -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(
{
Expand All @@ -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",
Expand All @@ -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");
Expand Down
20 changes: 17 additions & 3 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
}

Expand All @@ -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";
}

Expand Down
72 changes: 72 additions & 0 deletions src/core/loaders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(home: string, task: () => Promise<T>) {
const previousHome = process.env.HOME;
process.env.HOME = home;
Expand Down Expand Up @@ -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-");

Expand Down Expand Up @@ -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-");

Expand Down
Loading