Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and

## [Unreleased]

### Removed

- Jujutsu support. `dunk` now targets Git only — there's no more `vcs` config key, jj workspace auto-detection, or jj revset handling for `dunk diff`/`dunk show`. Use a colocated Git checkout if you work in jj.

## [0.13.0] - 2026-05-14

### Added
Expand Down
23 changes: 1 addition & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,34 +145,14 @@ git config --global alias.dshow "-c core.pager=\"dunk pager\" show"

## Whole-branch review

`dunk diff --branch` shows everything that differs between the current branch and its base — committed history, staged work, unstaged edits, and untracked files — in one review. Pick the base explicitly with `--branch=<ref>`, or let `dunk` resolve it: explicit flag → `[branch_review] base` in `.dunk/config.toml` → `origin/HEAD` → `origin/main` / `main` / `origin/master` / `master` / `origin/trunk` / `trunk`. The resolved base is shown in the status bar so auto-detection is never silent. Works in Jujutsu too (uses a `fork_point(@ | "<base>")` revset).
`dunk diff --branch` shows everything that differs between the current branch and its base — committed history, staged work, unstaged edits, and untracked files — in one review. Pick the base explicitly with `--branch=<ref>`, or let `dunk` resolve it: explicit flag → `[branch_review] base` in `.dunk/config.toml` → `origin/HEAD` → `origin/main` / `main` / `origin/master` / `master` / `origin/trunk` / `trunk`. The resolved base is shown in the status bar so auto-detection is never silent.

```toml
# .dunk/config.toml
[branch_review]
base = "origin/main"
```

## Jujutsu

`dunk` auto-detects Jujutsu workspaces. Inside one, `dunk diff [revset]` and `dunk show [revset]` use jj revsets.

To force a backend, set `vcs = "git"` or `vcs = "jj"` in [config](#config).

To use `dunk` as jj’s pager, run:

```bash
jj config edit --user
```

Then add:

```toml
[ui]
pager = ["dunk", "pager"]
diff-formatter = ":git"
```

## Config

`dunk` reads config from either location:
Expand All @@ -185,7 +165,6 @@ Example:
```toml
theme = "graphite" # graphite, midnight, paper, ember
mode = "auto" # auto, split, stack
vcs = "git" # git, jj
exclude_untracked = false
line_numbers = false
wrap_lines = true
Expand Down
28 changes: 1 addition & 27 deletions src/core/branchReview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveGitBranchBase, resolveJjBranchBase } from "./branchReview";
import { resolveGitBranchBase } from "./branchReview";
import type { VcsCommandInput } from "./types";

const tempDirs: string[] = [];
Expand Down Expand Up @@ -149,29 +149,3 @@ describe("resolveGitBranchBase", () => {
).toThrow(/common ancestor/);
});
});

describe("resolveJjBranchBase", () => {
test("wraps an explicit base in a fork_point revset", () => {
const resolved = resolveJjBranchBase(
buildVcsInput({ branchReview: { explicitBase: "origin/main" } }),
);

expect(resolved.displayBase).toBe("origin/main");
expect(resolved.jjFromRevset).toBe('fork_point(@ | "origin/main")');
});

test("falls back to trunk() when nothing else is configured", () => {
const resolved = resolveJjBranchBase(buildVcsInput({ branchReview: {} }));

expect(resolved.displayBase).toBe("trunk()");
expect(resolved.jjFromRevset).toBe('fork_point(@ | "trunk()")');
});

test("escapes embedded quotes so the revset stays parseable", () => {
const resolved = resolveJjBranchBase(
buildVcsInput({ branchReview: { explicitBase: 'weird"name' } }),
);

expect(resolved.jjFromRevset).toBe('fork_point(@ | "weird\\"name")');
});
});
26 changes: 0 additions & 26 deletions src/core/branchReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,10 @@ const GIT_BASE_FALLBACK_REFS = [
* - `displayBase` is what we show to the user (e.g. "origin/main").
* - `gitMergeBaseSha` is the SHA of `git merge-base <displayBase> HEAD`, used as the diff target
* so the diff is "working tree vs the common ancestor" rather than "working tree vs the tip".
* - `jjFromRevset` is the `--from` argument for `jj diff` — `fork_point(<displayBase>)`, which
* gives the same merge-base semantics that GitHub's three-dot view uses.
*/
export interface ResolvedBranchBase {
displayBase: string;
gitMergeBaseSha?: string;
jjFromRevset?: string;
}

/** Trim and discard one-line output from `git rev-parse` / `git merge-base`. */
Expand Down Expand Up @@ -255,26 +252,3 @@ export function resolveGitBranchBase(

return resolveExplicitGitBase(detected, cwd, gitExecutable);
}

/** Build the Jujutsu `fork_point` revset used as the `--from` argument for branch review. */
function jjForkPointRevset(base: string) {
// Jujutsu's revset language uses double quotes for literal strings, so wrap whatever the user
// (or config) named so revsets with slashes or hyphens — e.g. "origin/main" — parse cleanly.
const escaped = base.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `fork_point(@ | "${escaped}")`;
}

/** Resolve a Jujutsu branch-review base. */
export function resolveJjBranchBase(input: VcsCommandInput): ResolvedBranchBase {
const explicitBase = input.branchReview?.explicitBase;
const configuredBase = input.options.branchReviewBase;
const base = explicitBase ?? configuredBase ?? "trunk()";

// For Jujutsu we keep resolution lazy: the actual revset evaluation happens inside `jj diff`,
// which already raises a clear "Revision not found" / "Failed to parse revset" error when the
// base is unresolvable. Catching that here would duplicate jj.ts error translation.
return {
displayBase: base,
jjFromRevset: jjForkPointRevset(base),
};
}
2 changes: 1 addition & 1 deletion src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ function renderCliHelp() {
"",
"Notes:",
" Run `dunk <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 any git ref or commit range',
"",
].join("\n");
}
Expand Down
2 changes: 1 addition & 1 deletion src/core/cliComments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ describe("dunk comments CLI", () => {
test("commands fail clearly when the cwd is not a repo", () => {
const dir = mkdtempSync(join(tmpdir(), "dunk-cli-no-repo-"));
tempDirs.push(dir);
expect(() => runCommentsList("text", { cwd: dir })).toThrow(/git or jj repository/);
expect(() => runCommentsList("text", { cwd: dir })).toThrow(/Not inside a git repository/);
});

test("renderCommentsHelp lists every subcommand", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/core/cliComments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ interface CommentsShowOptions extends CommentsListOptions {
function requireRepoRoot(cwd?: string): string {
const repoRoot = findRepoRoot(cwd ?? process.cwd());
if (!repoRoot) {
throw new DunkUserError("Not inside a git or jj repository.", [
throw new DunkUserError("Not inside a git repository.", [
"`dunk comments` reads `.dunk/comments.json` from the repo root; run it from inside a checkout.",
]);
}
Expand Down
84 changes: 0 additions & 84 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@ function createRepo(dir: string) {
mkdirSync(join(dir, ".git"), { recursive: true });
}

function createJjRepo(dir: string) {
mkdirSync(join(dir, ".jj"), { recursive: true });
}

function createPatchPagerInput(overrides: Partial<CliInput["options"]> = {}): CliInput {
return {
kind: "patch",
Expand Down Expand Up @@ -160,86 +156,6 @@ 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", "dunk"), { recursive: true });
writeFileSync(join(home, ".config", "dunk", "config.toml"), 'vcs = "jj"\n');

const cwd = createTempDir("hunk-config-cwd-");
const defaultResolved = resolveConfiguredCliInput(
{
kind: "vcs",
staged: false,
options: {},
},
{ cwd, env: { HOME: createTempDir("hunk-config-empty-home-") } },
);
const configuredResolved = resolveConfiguredCliInput(
{
kind: "vcs",
staged: false,
options: {},
},
{ cwd, env: { HOME: home } },
);

expect(defaultResolved.input.options.vcs).toBe("git");
expect(configuredResolved.input.options.vcs).toBe("jj");
});

test("auto-detects jj 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 plainDir = createTempDir("hunk-config-no-repo-");

createJjRepo(jjRepo);
createRepo(colocatedRepo);
createJjRepo(colocatedRepo);
createRepo(gitRepo);

const input = {
kind: "vcs",
staged: false,
options: {},
} satisfies CliInput;

expect(
resolveConfiguredCliInput(input, { cwd: jjRepo, env: { HOME: home } }).input.options.vcs,
).toBe("jj");
expect(
resolveConfiguredCliInput(input, { cwd: colocatedRepo, env: { HOME: home } }).input.options
.vcs,
).toBe("jj");
expect(
resolveConfiguredCliInput(input, { cwd: gitRepo, env: { HOME: home } }).input.options.vcs,
).toBe("git");
expect(
resolveConfiguredCliInput(input, { cwd: plainDir, env: { HOME: home } }).input.options.vcs,
).toBe("git");
});

test("explicit config overrides auto-detected jj mode", () => {
const home = createTempDir("hunk-config-home-");
const repo = createTempDir("hunk-config-jj-repo-");
createJjRepo(repo);

mkdirSync(join(repo, ".dunk"), { recursive: true });
writeFileSync(join(repo, ".dunk", "config.toml"), 'vcs = "git"\n');

const resolved = resolveConfiguredCliInput(
{
kind: "vcs",
staged: false,
options: {},
},
{ cwd: repo, env: { HOME: home } },
);

expect(resolved.input.options.vcs).toBe("git");
});

test("loadAppBootstrap exposes resolved initial preferences to the UI", async () => {
const home = createTempDir("hunk-config-home-");
const repo = createTempDir("hunk-config-repo-");
Expand Down
28 changes: 2 additions & 26 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,7 @@ import fs from "node:fs";
import { dirname, join, resolve } from "node:path";
import { DUNK_CONFIG_RELATIVE_PATH } from "./dunkPaths";
import { resolveGlobalConfigPath } from "./paths";
import type {
CliInput,
CommonOptions,
LayoutMode,
PersistedViewPreferences,
VcsMode,
} from "./types";
import type { CliInput, CommonOptions, LayoutMode, PersistedViewPreferences } from "./types";

export const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = {
mode: "auto",
Expand Down Expand Up @@ -38,11 +32,6 @@ function normalizeLayoutMode(value: unknown): LayoutMode | undefined {
return value === "auto" || value === "split" || value === "stack" ? value : undefined;
}

/** Accept only the VCS backends dunk can load directly. */
function normalizeVcsMode(value: unknown): VcsMode | undefined {
return value === "git" || value === "jj" ? value : undefined;
}

/** Accept only plain booleans from config files. */
function normalizeBoolean(value: unknown) {
return typeof value === "boolean" ? value : undefined;
Expand All @@ -62,7 +51,6 @@ function readConfigPreferences(source: Record<string, unknown>): CommonOptions {

return {
mode: normalizeLayoutMode(source.mode),
vcs: normalizeVcsMode(source.vcs),
theme: normalizeString(source.theme),
excludeUntracked: normalizeBoolean(source.exclude_untracked),
lineNumbers: normalizeBoolean(source.line_numbers),
Expand All @@ -78,7 +66,6 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti
return {
...base,
mode: overrides.mode ?? base.mode,
vcs: overrides.vcs ?? base.vcs,
theme: overrides.theme ?? base.theme,
pager: overrides.pager ?? base.pager,
watch: overrides.watch ?? base.watch,
Expand Down Expand Up @@ -113,7 +100,7 @@ export 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"))) {
return current;
}

Expand All @@ -126,15 +113,6 @@ export 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"))) {
return "jj";
}

return "git";
}

/** Parse one TOML config file into a plain object. */
function readTomlRecord(path: string) {
if (!fs.existsSync(path)) {
Expand All @@ -160,7 +138,6 @@ export function resolveConfiguredCliInput(

let resolvedOptions: CommonOptions = {
mode: DEFAULT_VIEW_PREFERENCES.mode,
vcs: detectRepoVcsMode(repoRoot),
// Keep the built-in theme default explicit so stdin-backed startup paths do not depend on
// renderer theme-mode detection for their initial palette.
theme: "graphite",
Expand Down Expand Up @@ -194,7 +171,6 @@ export function resolveConfiguredCliInput(
pager: input.options.pager ?? false,
watch: input.options.watch ?? false,
excludeUntracked: resolvedOptions.excludeUntracked ?? false,
vcs: resolvedOptions.vcs ?? "git",
};

return {
Expand Down
Loading
Loading