Skip to content

Commit 7804b73

Browse files
clabbycodexbenvinegar
authored
feat: Support jj (#217)
Co-authored-by: Codex <codex@openai.com> Co-authored-by: Ben Vinegar <ben@benv.ca>
1 parent b4429c1 commit 7804b73

35 files changed

Lines changed: 783 additions & 149 deletions

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ jobs:
3030
with:
3131
bun-version: 1.3.10
3232

33+
- name: Install Jujutsu
34+
uses: taiki-e/install-action@v2
35+
with:
36+
tool: jj-cli
37+
3338
- name: Install dependencies
3439
run: bun install --frozen-lockfile
3540

.github/workflows/pr-ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ jobs:
3333
with:
3434
node-version: 22
3535

36+
- name: Install Jujutsu
37+
uses: taiki-e/install-action@v2
38+
with:
39+
tool: jj-cli
40+
3641
- name: Install dependencies
3742
run: bun install --frozen-lockfile
3843

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 `vcs = "jj"` support, enabling `hunk diff [revset]` and `hunk show [revset]`.
10+
911
### Changed
1012

1113
### Fixed

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ hunk show # review the latest commit
5757
hunk show HEAD~1 # review an earlier commit
5858
```
5959

60+
### Working with Jujutsu
61+
62+
Set `vcs = "jj"` in config to use the same `hunk diff [revset]` and `hunk show [revset]` commands with Jujutsu revsets instead. When `vcs` is unset, Hunk uses Git.
63+
64+
To use hunk as jj's pager, run `jj config edit --user` and update:
65+
66+
```toml
67+
[ui]
68+
pager = ["hunk", "pager"]
69+
diff-formatter = ":git"
70+
```
71+
6072
### Working with raw files and patches
6173

6274
```bash
@@ -112,13 +124,14 @@ Example:
112124
```toml
113125
theme = "graphite" # graphite, midnight, paper, ember
114126
mode = "auto" # auto, split, stack
127+
vcs = "git" # git, jj
115128
exclude_untracked = false
116129
line_numbers = true
117130
wrap_lines = false
118131
agent_notes = false
119132
```
120133

121-
`exclude_untracked` affects working-tree `hunk diff` sessions only.
134+
`exclude_untracked` affects Git working-tree `hunk diff` sessions only.
122135

123136
### Git integration
124137

benchmarks/bootstrap-load.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ async function measureGitBootstrap(repoDir: string) {
108108
try {
109109
const endToEndStart = performance.now();
110110
const bootstrap = await loadAppBootstrap({
111-
kind: "git",
111+
kind: "vcs",
112112
staged: false,
113113
options: { mode: "auto" },
114114
});

benchmarks/highlight-prefetch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ function createDiffFile(index: number, marker: string): DiffFile {
5353
function createBootstrap(): AppBootstrap {
5454
return {
5555
input: {
56-
kind: "git",
56+
kind: "vcs",
5757
staged: false,
5858
options: {
5959
mode: "auto",

benchmarks/large-stream-fixture.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export function createLargeSplitStreamBootstrap({
103103
}: LargeSplitStreamFixtureOptions = {}): AppBootstrap {
104104
return {
105105
input: {
106-
kind: "git",
106+
kind: "vcs",
107107
staged: false,
108108
options: {
109109
mode: "auto",

src/core/cli.test.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ describe("parseCli", () => {
8787
]);
8888

8989
expect(parsed).toMatchObject({
90-
kind: "git",
90+
kind: "vcs",
9191
range: "main...feature",
9292
staged: false,
9393
options: {
@@ -107,23 +107,23 @@ describe("parseCli", () => {
107107
const staged = await parseCli(["bun", "hunk", "diff", "--staged"]);
108108
const cached = await parseCli(["bun", "hunk", "diff", "--cached"]);
109109

110-
expect(staged).toMatchObject({ kind: "git", staged: true });
111-
expect(cached).toMatchObject({ kind: "git", staged: true });
110+
expect(staged).toMatchObject({ kind: "vcs", staged: true });
111+
expect(cached).toMatchObject({ kind: "vcs", staged: true });
112112
});
113113

114114
test("parses untracked file toggles for git diff", async () => {
115115
const excluded = await parseCli(["bun", "hunk", "diff", "--exclude-untracked"]);
116116
const included = await parseCli(["bun", "hunk", "diff", "--no-exclude-untracked"]);
117117

118118
expect(excluded).toMatchObject({
119-
kind: "git",
119+
kind: "vcs",
120120
staged: false,
121121
options: {
122122
excludeUntracked: true,
123123
},
124124
});
125125
expect(included).toMatchObject({
126-
kind: "git",
126+
kind: "vcs",
127127
staged: false,
128128
options: {
129129
excludeUntracked: false,
@@ -162,12 +162,22 @@ describe("parseCli", () => {
162162
]);
163163

164164
expect(parsed).toMatchObject({
165-
kind: "git",
165+
kind: "vcs",
166166
range: "main",
167167
pathspecs: ["src/app.ts", "test/app.test.ts"],
168168
});
169169
});
170170

171+
test("parses target followed by pathspecs without a separator", async () => {
172+
const parsed = await parseCli(["bun", "hunk", "diff", "trunk()..@", ".github"]);
173+
174+
expect(parsed).toMatchObject({
175+
kind: "vcs",
176+
range: "trunk()..@",
177+
pathspecs: [".github"],
178+
});
179+
});
180+
171181
test("parses show mode with optional ref and pathspecs", async () => {
172182
const parsed = await parseCli(["bun", "hunk", "show", "HEAD~1", "--", "src/app.ts"]);
173183

@@ -364,7 +374,7 @@ describe("parseCli", () => {
364374
selector: { sessionPath: "/tmp/live-window" },
365375
sourcePath: "/tmp/source-repo",
366376
nextInput: {
367-
kind: "git",
377+
kind: "vcs",
368378
staged: false,
369379
options: {},
370380
},

src/core/cli.ts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,11 @@ function renderCliHelp() {
126126
"Desktop-inspired terminal diff viewer for agent-authored changesets.",
127127
"",
128128
"Commands:",
129-
" hunk diff [ref] [-- <pathspec...>] review working tree changes or compare against a ref",
129+
" hunk diff [target] [-- <pathspec...>] review working tree changes or compare against a target",
130130
" hunk diff --staged [-- <pathspec...>] review staged changes",
131131
" hunk diff <left> <right> compare two concrete files",
132-
" hunk show [ref] [-- <pathspec...>] review the last commit or a given ref",
133-
" hunk stash show [ref] review a stash entry",
132+
" hunk show [target] [-- <pathspec...>] review the last commit or a given target",
133+
" hunk stash show [ref] review a stash entry (git only)",
134134
" hunk patch [file] review a patch file or stdin",
135135
" hunk pager general Git pager wrapper with diff detection",
136136
" hunk difftool <left> <right> [path] review Git difftool file pairs",
@@ -159,6 +159,7 @@ function renderCliHelp() {
159159
"",
160160
"Notes:",
161161
" Run `hunk <command> --help` for command-specific syntax and options.",
162+
' "target" refers to a generic set of changes; it can be a ref (git) or revset (jj)',
162163
"",
163164
].join("\n");
164165
}
@@ -369,7 +370,7 @@ function resolveReloadSelector(
369370
async function parseDiffCommand(tokens: string[], argv: string[]): Promise<ParsedCliInput> {
370371
const { commandTokens, pathspecs } = splitPathspecArgs(tokens);
371372
const command = applyWatchOption(
372-
createCommand("diff", "review Git diffs or compare two concrete files"),
373+
createCommand("diff", "review diffs or compare two concrete files"),
373374
)
374375
.option("--staged", "show staged changes instead of the working tree")
375376
.option("--cached", "alias for --staged")
@@ -402,7 +403,7 @@ async function parseDiffCommand(tokens: string[], argv: string[]): Promise<Parse
402403

403404
if (parsedTargets.length === 0) {
404405
return {
405-
kind: "git",
406+
kind: "vcs",
406407
staged,
407408
pathspecs: normalizedPathspecs,
408409
options,
@@ -411,30 +412,35 @@ async function parseDiffCommand(tokens: string[], argv: string[]): Promise<Parse
411412

412413
if (parsedTargets.length === 1) {
413414
return {
414-
kind: "git",
415+
kind: "vcs",
415416
range: parsedTargets[0],
416417
staged,
417418
pathspecs: normalizedPathspecs,
418419
options,
419420
};
420421
}
421422

422-
if (
423-
parsedTargets.length === 2 &&
424-
!staged &&
425-
!normalizedPathspecs &&
426-
areExistingFiles(parsedTargets[0]!, parsedTargets[1]!)
427-
) {
423+
if (!staged && !normalizedPathspecs) {
424+
if (parsedTargets.length === 2 && areExistingFiles(parsedTargets[0]!, parsedTargets[1]!)) {
425+
return {
426+
kind: "diff",
427+
left: parsedTargets[0]!,
428+
right: parsedTargets[1]!,
429+
options,
430+
};
431+
}
432+
428433
return {
429-
kind: "diff",
430-
left: parsedTargets[0]!,
431-
right: parsedTargets[1]!,
434+
kind: "vcs",
435+
range: parsedTargets[0]!,
436+
staged,
437+
pathspecs: parsedTargets.slice(1),
432438
options,
433439
};
434440
}
435441

436442
throw new Error(
437-
"Use `hunk diff <ref>`, `hunk diff <ref1>..<ref2>`, or `hunk diff <left> <right>` for file comparison.",
443+
"Use `hunk diff [target] [-- pathspec...]`, `hunk diff <left> <right>` for file comparison.",
438444
);
439445
}
440446

src/core/config.test.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,15 +128,15 @@ describe("config resolution", () => {
128128
const cwd = createTempDir("hunk-config-cwd-");
129129
const defaultResolved = resolveConfiguredCliInput(
130130
{
131-
kind: "git",
131+
kind: "vcs",
132132
staged: false,
133133
options: {},
134134
},
135135
{ cwd, env: { HOME: home } },
136136
);
137137
const overriddenResolved = resolveConfiguredCliInput(
138138
{
139-
kind: "git",
139+
kind: "vcs",
140140
staged: false,
141141
options: { excludeUntracked: false },
142142
},
@@ -145,7 +145,7 @@ describe("config resolution", () => {
145145
const noConfigHome = createTempDir("hunk-config-home-");
146146
const fallbackResolved = resolveConfiguredCliInput(
147147
{
148-
kind: "git",
148+
kind: "vcs",
149149
staged: false,
150150
options: {},
151151
},
@@ -157,6 +157,33 @@ describe("config resolution", () => {
157157
expect(fallbackResolved.input.options.excludeUntracked).toBe(false);
158158
});
159159

160+
test("defaults to git VCS mode and accepts jj from config", () => {
161+
const home = createTempDir("hunk-config-home-");
162+
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
163+
writeFileSync(join(home, ".config", "hunk", "config.toml"), 'vcs = "jj"\n');
164+
165+
const cwd = createTempDir("hunk-config-cwd-");
166+
const defaultResolved = resolveConfiguredCliInput(
167+
{
168+
kind: "vcs",
169+
staged: false,
170+
options: {},
171+
},
172+
{ cwd, env: { HOME: createTempDir("hunk-config-empty-home-") } },
173+
);
174+
const configuredResolved = resolveConfiguredCliInput(
175+
{
176+
kind: "vcs",
177+
staged: false,
178+
options: {},
179+
},
180+
{ cwd, env: { HOME: home } },
181+
);
182+
183+
expect(defaultResolved.input.options.vcs).toBe("git");
184+
expect(configuredResolved.input.options.vcs).toBe("jj");
185+
});
186+
160187
test("loadAppBootstrap exposes resolved initial preferences to the UI", async () => {
161188
const home = createTempDir("hunk-config-home-");
162189
const repo = createTempDir("hunk-config-repo-");

0 commit comments

Comments
 (0)