From 6df85537a653da32e4d364738e4207d81b3019be Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sun, 30 Aug 2026 23:51:51 +0800 Subject: [PATCH 1/7] fix: keep persistent state out of config directory --- .changeset/quiet-states-rest.md | 5 ++++ packages/hunk/src/core/run/paths.test.ts | 28 +++++++++++++++---- packages/hunk/src/core/run/paths.ts | 18 +++++++++--- .../hunk/src/extensions/discovery.test.ts | 2 +- .../src/extensions/manage/install.test.ts | 2 +- packages/hunk/src/extensions/startup.test.ts | 12 ++++---- 6 files changed, 50 insertions(+), 17 deletions(-) create mode 100644 .changeset/quiet-states-rest.md diff --git a/.changeset/quiet-states-rest.md b/.changeset/quiet-states-rest.md new file mode 100644 index 000000000..5a04b4089 --- /dev/null +++ b/.changeset/quiet-states-rest.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Store Hunk state and globally installed extensions under the XDG state directory. diff --git a/packages/hunk/src/core/run/paths.test.ts b/packages/hunk/src/core/run/paths.test.ts index 7ff74b478..0b5d830eb 100644 --- a/packages/hunk/src/core/run/paths.test.ts +++ b/packages/hunk/src/core/run/paths.test.ts @@ -9,6 +9,7 @@ import { resolveCanonicalPath, resolveGlobalConfigPath, resolveAppStatePath, + resolveGlobalExtensionsDir, } from "./paths"; function createTempRoot(prefix: string) { @@ -17,10 +18,25 @@ function createTempRoot(prefix: string) { describe("paths", () => { test("resolves XDG config and state paths", () => { - const env = { XDG_CONFIG_HOME: join("/tmp", "xdg-home") } as NodeJS.ProcessEnv; + const env = { + XDG_CONFIG_HOME: join("/tmp", "xdg-config"), + XDG_STATE_HOME: join("/tmp", "xdg-state"), + } as NodeJS.ProcessEnv; + + expect(resolveGlobalConfigPath(env)).toBe(join("/tmp", "xdg-config", "hunk", "config.toml")); + expect(resolveAppStatePath(env)).toBe(join("/tmp", "xdg-state", "hunk", "state.json")); + expect(resolveGlobalExtensionsDir(env)).toBe(join("/tmp", "xdg-state", "hunk", "extensions")); + }); + + test("falls back to the platform-independent XDG state location", () => { + const env = { HOME: join("/tmp", "home") } as NodeJS.ProcessEnv; - expect(resolveGlobalConfigPath(env)).toBe(join("/tmp", "xdg-home", "hunk", "config.toml")); - expect(resolveAppStatePath(env)).toBe(join("/tmp", "xdg-home", "hunk", "state.json")); + expect(resolveAppStatePath(env)).toBe( + join("/tmp", "home", ".local", "state", "hunk", "state.json"), + ); + expect(resolveGlobalExtensionsDir(env)).toBe( + join("/tmp", "home", ".local", "state", "hunk", "extensions"), + ); }); test("falls back to HOME for config and state paths", () => { @@ -29,7 +45,9 @@ describe("paths", () => { expect(resolveGlobalConfigPath(env)).toBe( join("/tmp", "home", ".config", "hunk", "config.toml"), ); - expect(resolveAppStatePath(env)).toBe(join("/tmp", "home", ".config", "hunk", "state.json")); + expect(resolveAppStatePath(env)).toBe( + join("/tmp", "home", ".local", "state", "hunk", "state.json"), + ); }); test("falls back to USERPROFILE when HOME is unavailable", () => { @@ -39,7 +57,7 @@ describe("paths", () => { join("/tmp", "windows-profile", ".config", "hunk", "config.toml"), ); expect(resolveAppStatePath(env)).toBe( - join("/tmp", "windows-profile", ".config", "hunk", "state.json"), + join("/tmp", "windows-profile", ".local", "state", "hunk", "state.json"), ); }); diff --git a/packages/hunk/src/core/run/paths.ts b/packages/hunk/src/core/run/paths.ts index b18203255..acc7d3730 100644 --- a/packages/hunk/src/core/run/paths.ts +++ b/packages/hunk/src/core/run/paths.ts @@ -43,6 +43,16 @@ export function resolveUserConfigDir(env: NodeJS.ProcessEnv = process.env) { return undefined; } +/** Resolve the base state directory Hunk should use for user-scoped files. */ +export function resolveUserStateDir(env: NodeJS.ProcessEnv = process.env) { + if (env.XDG_STATE_HOME) { + return env.XDG_STATE_HOME; + } + + const home = env.HOME || env.USERPROFILE; + return home ? join(home, ".local", "state") : undefined; +} + /** Resolve the global Hunk config file path from the current environment. */ export function resolveGlobalConfigPath(env: NodeJS.ProcessEnv = process.env) { const configDir = resolveUserConfigDir(env); @@ -51,14 +61,14 @@ export function resolveGlobalConfigPath(env: NodeJS.ProcessEnv = process.env) { /** Resolve the persisted Hunk state file path from the current environment. */ export function resolveAppStatePath(env: NodeJS.ProcessEnv = process.env) { - const configDir = resolveUserConfigDir(env); - return configDir ? join(configDir, "hunk", "state.json") : undefined; + const stateDir = resolveUserStateDir(env); + return stateDir ? join(stateDir, "hunk", "state.json") : undefined; } /** Resolve the user-scoped directory Hunk scans for globally installed extensions. */ export function resolveGlobalExtensionsDir(env: NodeJS.ProcessEnv = process.env) { - const configDir = resolveUserConfigDir(env); - return configDir ? join(configDir, "hunk", "extensions") : undefined; + const stateDir = resolveUserStateDir(env); + return stateDir ? join(stateDir, "hunk", "extensions") : undefined; } /** diff --git a/packages/hunk/src/extensions/discovery.test.ts b/packages/hunk/src/extensions/discovery.test.ts index 479ef52c8..429fd7420 100644 --- a/packages/hunk/src/extensions/discovery.test.ts +++ b/packages/hunk/src/extensions/discovery.test.ts @@ -264,7 +264,7 @@ describe("extension discovery", () => { const candidates = discoverExtensions({ cwd: home, repoRoot: undefined, - env: { XDG_CONFIG_HOME: home } as NodeJS.ProcessEnv, + env: { XDG_STATE_HOME: home } as NodeJS.ProcessEnv, }); expect(candidates).toEqual([{ id: "themed", path: globalPath, origin: "global" }]); diff --git a/packages/hunk/src/extensions/manage/install.test.ts b/packages/hunk/src/extensions/manage/install.test.ts index fe4256965..15308e929 100644 --- a/packages/hunk/src/extensions/manage/install.test.ts +++ b/packages/hunk/src/extensions/manage/install.test.ts @@ -230,7 +230,7 @@ describe("hunk extension command runner", () => { stdout: (text: string) => out.push(text), stderr: (text: string) => err.push(text), ...(confirmAnswer !== undefined ? { confirm: async () => confirmAnswer } : {}), - env: { XDG_CONFIG_HOME: configDir } as NodeJS.ProcessEnv, + env: { XDG_STATE_HOME: configDir } as NodeJS.ProcessEnv, }, }; } diff --git a/packages/hunk/src/extensions/startup.test.ts b/packages/hunk/src/extensions/startup.test.ts index dda49d2d6..ee4cf1be8 100644 --- a/packages/hunk/src/extensions/startup.test.ts +++ b/packages/hunk/src/extensions/startup.test.ts @@ -50,7 +50,7 @@ describe("extension startup", () => { const result = await loadStartupExtensions({ extensions: createExtensionsConfig({ enabled: false }), cwd: home, - env: { XDG_CONFIG_HOME: home } as NodeJS.ProcessEnv, + env: { XDG_STATE_HOME: home } as NodeJS.ProcessEnv, }); expect(result.registry.cliCommands.map((entry) => entry.command.name)).toEqual(["gh"]); @@ -149,7 +149,7 @@ describe("extension startup", () => { extensionConfigs: { themed: { themeId: "midnight" } }, }), cwd: home, - env: { XDG_CONFIG_HOME: home } as NodeJS.ProcessEnv, + env: { XDG_STATE_HOME: home } as NodeJS.ProcessEnv, hostOverrides: { repoRoot: undefined }, }); @@ -208,7 +208,7 @@ export default function (hunk) { const provisional = await loadStartupExtensions({ extensions: createExtensionsConfig(), cwd: repo, - env: { XDG_CONFIG_HOME: configHome } as NodeJS.ProcessEnv, + env: { XDG_STATE_HOME: configHome } as NodeJS.ProcessEnv, deferEventBusBinding: true, }); const repoExtensions = join(repo, ".hunk", "extensions"); @@ -226,7 +226,7 @@ export default function (hunk) { const final = await loadStartupExtensions({ extensions: createExtensionsConfig(), cwd: repo, - env: { XDG_CONFIG_HOME: configHome } as NodeJS.ProcessEnv, + env: { XDG_STATE_HOME: configHome } as NodeJS.ProcessEnv, projectRoot: repo, previousLoad: provisional, hostOverrides: { resolveRepoTrustImpl: () => "trusted" }, @@ -253,12 +253,12 @@ export default function (hunk) { const provisional = await loadStartupExtensions({ extensions: createExtensionsConfig({ extensionConfigs: { configured: { value: 1 } } }), cwd: home, - env: { XDG_CONFIG_HOME: home } as NodeJS.ProcessEnv, + env: { XDG_STATE_HOME: home } as NodeJS.ProcessEnv, }); await loadStartupExtensions({ extensions: createExtensionsConfig({ extensionConfigs: { configured: { value: 2 } } }), cwd: home, - env: { XDG_CONFIG_HOME: home } as NodeJS.ProcessEnv, + env: { XDG_STATE_HOME: home } as NodeJS.ProcessEnv, previousLoad: provisional, }); From 3e4c4cb837c6f1e4556c08a4c0da4aa7042e96de Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Tue, 22 Sep 2026 16:22:24 +0800 Subject: [PATCH 2/7] fix: align extension state with XDG state directory --- README.md | 7 ++-- docs/extension-system-exploration.md | 4 +-- docs/extensions.md | 16 ++++----- examples/extensions/inline-edit/README.md | 4 +-- .../extensions/rendered-markdown/README.md | 6 ++-- examples/extensions/vim-navigation/README.md | 4 +-- packages/hunk/README.md | 7 ++-- packages/hunk/skills/hunk-extensions/SKILL.md | 19 ++++++----- packages/hunk/src/extensions/manage/cli.ts | 2 +- packages/hunk/src/extensions/trust.test.ts | 2 +- packages/hunk/src/extensions/trust.ts | 2 +- test/pty/harness.ts | 33 ++++++++++--------- .../marketing/FeatureShowcase.astro | 2 +- .../docs/docs/extend/custom-sidebars.md | 2 +- .../content/docs/docs/extend/extensions.md | 12 +++---- website/tests/marketing-smoke.spec.ts | 2 +- 16 files changed, 66 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index a4517b881..6d584de88 100644 --- a/README.md +++ b/README.md @@ -237,15 +237,16 @@ The extension API is experimental and may change in breaking ways between minor releases while it stabilizes; breaking changes are called out in release notes. -Hunk loads plain TypeScript extensions from `~/.config/hunk/extensions/`, from a -repository's `.hunk/extensions/` (after you explicitly trust that repository), +Hunk loads plain TypeScript extensions from `$XDG_STATE_HOME/hunk/extensions/` +(defaulting to `~/.local/state/hunk/extensions/`), from a repository's +`.hunk/extensions/` (after you explicitly trust that repository), and from `--extension ` for development. `--no-extensions` turns those off for one run; Hunk's own bundled backends (Git, Jujutsu, and Sapling) stay loaded. Extensions can add commands, themes, VCS backends, custom sidebars, and more. For example, filter lockfiles out of a review: ```ts -// ~/.config/hunk/extensions/collapse-lockfiles.ts +// ~/.local/state/hunk/extensions/collapse-lockfiles.ts import type { HunkExtensionAPI } from "hunkdiff/extension"; export default function (hunk: HunkExtensionAPI) { diff --git a/docs/extension-system-exploration.md b/docs/extension-system-exploration.md index 37ac2682d..c7c7245dc 100644 --- a/docs/extension-system-exploration.md +++ b/docs/extension-system-exploration.md @@ -159,7 +159,7 @@ Mirror pi's model closely; it is proven and our users overlap with pi's. ### Extension form and discovery ````ts -// ~/.config/hunk/extensions/copy-as-suggestion.ts +// ~/.local/state/hunk/extensions/copy-as-suggestion.ts import type { HunkExtensionAPI } from "hunkdiff/extension"; export default function (hunk: HunkExtensionAPI) { @@ -178,7 +178,7 @@ export default function (hunk: HunkExtensionAPI) { } ```` -- Discovery: `~/.config/hunk/extensions/*.ts` and `*/index.ts` (global, follows +- Discovery: `~/.local/state/hunk/extensions/*.ts` and `*/index.ts` (global, follows our existing XDG path logic in `packages/hunk/src/core/run/paths.ts`), `.hunk/extensions/` (repo-local, **trust-gated**, same posture as pi's project trust), explicit `[extensions] paths = [...]` in `config.toml`, and a `--extension ` diff --git a/docs/extensions.md b/docs/extensions.md index 656242ea1..2722fd223 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -6,7 +6,7 @@ object. An entry may stand alone or be declared by a folder's optional `package.json` manifest; no build step is required. ```ts -// ~/.config/hunk/extensions/hello.ts +// ~/.local/state/hunk/extensions/hello.ts import type { HunkExtensionAPI } from "hunkdiff/extension"; export default function (hunk: HunkExtensionAPI) { @@ -37,7 +37,7 @@ keeps its origin even if the same file is also discovered somewhere else. | ----- | ---------------------------------------------------- | --------------------- | | 1 | `--extension ` (repeatable) | runs immediately | | 2 | `[extensions] paths` in your user config | runs immediately | -| 3 | `~/.config/hunk/extensions/` | runs immediately | +| 3 | `~/.local/state/hunk/extensions/` | runs immediately | | 4 | `.hunk/extensions/` in the repo under review | **prompts for trust** | | 4 | `[extensions] paths` in the repo `.hunk/config.toml` | **prompts for trust** | @@ -55,7 +55,7 @@ order, so a folder shipping both a source and a built entry resolves the same everywhere). The manifest field is `hunk`: ```text -~/.config/hunk/extensions/my-ext/ +~/.local/state/hunk/extensions/my-ext/ package.json # {"hunk": {"extensions": ["./src/index.ts"]}} node_modules/ # bun install / npm install, right here src/ @@ -141,7 +141,7 @@ repository's own README. Extensions can be shared as Git repositories or npm packages. `hunk extension install` installs either source into a managed directory under -`~/.config/hunk/extensions/installed/`, validates its extension entries, and +`~/.local/state/hunk/extensions/installed/`, validates its extension entries, and records the requested source plus its resolved Git commit or npm version. Git and local-checkout sources keep the repository name as their install directory: @@ -192,7 +192,7 @@ and punctuation, stays filesystem-safe, and does not change with the version. Use that ID in `[extension.]` configuration; multi-entry manifests retain the existing entry-derived IDs. Failed acquisition or validation leaves the previous install intact. Managed installs never collide with -extensions you copied into `~/.config/hunk/extensions/` by hand, and the +extensions you copied into `~/.local/state/hunk/extensions/` by hand, and the installer refuses to overwrite an unmanaged directory of the same name. ### Publishing an extension @@ -968,7 +968,7 @@ Render a React component on the `left`, `right`, `top`, or `bottom` edge of the review. Pair it with `registerCommand` so a key opens it: ```tsx -// ~/.config/hunk/extensions/flat-pane.tsx +// ~/.local/state/hunk/extensions/flat-pane.tsx import { useMemo } from "react"; import type { ExtensionPaneProps, HunkExtensionAPI } from "hunkdiff/extension"; @@ -1395,7 +1395,7 @@ geometry. The installable [`examples/extensions/rendered-markdown/`](../examples/extensions/rendered-markdown/) uses this contract for a parsed Markdown preview. It is intentionally not bundled -or loaded by default; copy the folder into `~/.config/hunk/extensions/`, install +or loaded by default; copy the folder into `~/.local/state/hunk/extensions/`, install its dependency there, and its View entry and `F8` command become available. ```ts @@ -2561,7 +2561,7 @@ Collapse lockfiles and generated output out of every review, and say how many files were hidden. ```ts -// ~/.config/hunk/extensions/collapse-generated.ts +// ~/.local/state/hunk/extensions/collapse-generated.ts import type { HunkExtensionAPI } from "hunkdiff/extension"; /** Match one path against a `*`-only glob, anchored at both ends. */ diff --git a/examples/extensions/inline-edit/README.md b/examples/extensions/inline-edit/README.md index cf286fb7c..6a9616a9f 100644 --- a/examples/extensions/inline-edit/README.md +++ b/examples/extensions/inline-edit/README.md @@ -24,8 +24,8 @@ bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/inl Copy the whole folder — it has no dependencies: ```bash -mkdir -p ~/.config/hunk/extensions -cp -R examples/extensions/inline-edit ~/.config/hunk/extensions/ +mkdir -p ~/.local/state/hunk/extensions +cp -R examples/extensions/inline-edit ~/.local/state/hunk/extensions/ ``` Hunk discovers the folder automatically on later launches. Open **View** and choose **File presentation: Inline edit**, or press `Ctrl-E`. The command is named `inline-edit.edit` for `[keybindings]` customization. diff --git a/examples/extensions/rendered-markdown/README.md b/examples/extensions/rendered-markdown/README.md index 53883bcb7..bd888f473 100644 --- a/examples/extensions/rendered-markdown/README.md +++ b/examples/extensions/rendered-markdown/README.md @@ -19,9 +19,9 @@ bun run packages/hunk/src/main.tsx -- diff \ Copy the whole folder, then install its local dependency: ```bash -mkdir -p ~/.config/hunk/extensions -cp -R examples/extensions/rendered-markdown ~/.config/hunk/extensions/ -cd ~/.config/hunk/extensions/rendered-markdown +mkdir -p ~/.local/state/hunk/extensions +cp -R examples/extensions/rendered-markdown ~/.local/state/hunk/extensions/ +cd ~/.local/state/hunk/extensions/rendered-markdown bun install ``` diff --git a/examples/extensions/vim-navigation/README.md b/examples/extensions/vim-navigation/README.md index d350f554f..a05b29c9d 100644 --- a/examples/extensions/vim-navigation/README.md +++ b/examples/extensions/vim-navigation/README.md @@ -15,8 +15,8 @@ Press `F6` or choose **Extensions → Toggle Vim navigation**. The persistent st ## Install it globally ```bash -mkdir -p ~/.config/hunk/extensions -cp -R examples/extensions/vim-navigation ~/.config/hunk/extensions/ +mkdir -p ~/.local/state/hunk/extensions +cp -R examples/extensions/vim-navigation ~/.local/state/hunk/extensions/ ``` ## Keys diff --git a/packages/hunk/README.md b/packages/hunk/README.md index b96d6a828..6e38a8d57 100644 --- a/packages/hunk/README.md +++ b/packages/hunk/README.md @@ -290,8 +290,9 @@ The extension API is experimental and may change in breaking ways between minor releases while it stabilizes; breaking changes are called out in release notes. -Hunk loads plain TypeScript extensions from `~/.config/hunk/extensions/`, from a -repository's `.hunk/extensions/` (after you explicitly trust that repository), +Hunk loads plain TypeScript extensions from `$XDG_STATE_HOME/hunk/extensions/` +(defaulting to `~/.local/state/hunk/extensions/`), from a repository's +`.hunk/extensions/` (after you explicitly trust that repository), and from `--extension ` for development. `--no-extensions` turns those off for one run; Hunk's own bundled backends (Git, Jujutsu, and Sapling) stay loaded. @@ -302,7 +303,7 @@ the file-navigation sidebar with its own React component, react to lifecycle events, and show transient messages: ```ts -// ~/.config/hunk/extensions/collapse-lockfiles.ts +// ~/.local/state/hunk/extensions/collapse-lockfiles.ts import type { HunkExtensionAPI } from "hunkdiff/extension"; export default function (hunk: HunkExtensionAPI) { diff --git a/packages/hunk/skills/hunk-extensions/SKILL.md b/packages/hunk/skills/hunk-extensions/SKILL.md index ea8c057b6..afc640d63 100644 --- a/packages/hunk/skills/hunk-extensions/SKILL.md +++ b/packages/hunk/skills/hunk-extensions/SKILL.md @@ -10,7 +10,7 @@ factory**. Hunk imports it at startup and hands it an API object. No build step, no manifest required. ```ts -// ~/.config/hunk/extensions/hello.ts +// ~/.local/state/hunk/extensions/hello.ts import type { HunkExtensionAPI } from "hunkdiff/extension"; export default function (hunk: HunkExtensionAPI) { @@ -52,12 +52,15 @@ The examples, by what they demonstrate: ## Where extensions live -| Source | Trust | -| ------------------------------------------ | ---------------- | -| `--extension ` (repeatable) | runs immediately | -| `[extensions] paths` in user config | runs immediately | -| `~/.config/hunk/extensions/` (XDG-aware) | runs immediately | -| `.hunk/extensions/` or repo-config `paths` | **trust prompt** | +| Source | Trust | +| --------------------------------------------- | ---------------- | +| `--extension ` (repeatable) | runs immediately | +| `[extensions] paths` in user config | runs immediately | +| `~/.local/state/hunk/extensions/` (XDG-aware) | runs immediately | +| `.hunk/extensions/` or repo-config `paths` | **trust prompt** | + +The global source uses `$XDG_STATE_HOME` when it is set, and otherwise falls +back to `~/.local/state` (or the platform home directory equivalent). Only the repo-local group is gated. Everything else — including `--extension`, even when its path points inside the repository under review — is read as @@ -75,7 +78,7 @@ every machine that loads it — keep a repo-shared extension dependency-free. Shared extensions install from git with `hunk extension install ` (`owner/repo[@ref]`, `git:host/path[@ref]`, a git URL, or a local path) into -`~/.config/hunk/extensions/installed//`, where they load with global +`~/.local/state/hunk/extensions/installed//`, where they load with global origin; `list`, `update`, and `remove` manage them. Declared `dependencies` are `bun install`ed at install time. The manifest may state `{"hunk": {"apiVersion": N}}` — the minimum extension API version — and an older diff --git a/packages/hunk/src/extensions/manage/cli.ts b/packages/hunk/src/extensions/manage/cli.ts index 83ad7aaaf..69a62ceeb 100644 --- a/packages/hunk/src/extensions/manage/cli.ts +++ b/packages/hunk/src/extensions/manage/cli.ts @@ -45,7 +45,7 @@ function requireInstalledRoot(env: NodeJS.ProcessEnv) { const installedRoot = resolveInstalledExtensionsRoot(env); if (!installedRoot) { throw new HunkUserError( - "Could not resolve the extension install directory because HOME/XDG_CONFIG_HOME is unset.", + "Could not resolve the extension install directory because HOME/XDG_STATE_HOME is unset.", ); } diff --git a/packages/hunk/src/extensions/trust.test.ts b/packages/hunk/src/extensions/trust.test.ts index 88f6502fe..58193e4df 100644 --- a/packages/hunk/src/extensions/trust.test.ts +++ b/packages/hunk/src/extensions/trust.test.ts @@ -145,7 +145,7 @@ describe("extension trust", () => { expect(readExtensionTrust({ env: {} })).toEqual({}); expect(resolveRepoTrust("/repo", { env: {} })).toBe("unknown"); expect(() => writeExtensionTrust("/repo", "trusted", { env: {} })).toThrow( - "HOME/XDG_CONFIG_HOME", + "HOME/XDG_STATE_HOME", ); }); diff --git a/packages/hunk/src/extensions/trust.ts b/packages/hunk/src/extensions/trust.ts index 9d7421d75..8c8f11085 100644 --- a/packages/hunk/src/extensions/trust.ts +++ b/packages/hunk/src/extensions/trust.ts @@ -95,7 +95,7 @@ export function writeExtensionTrust( ) { const statePath = resolveStatePath(options); if (!statePath) { - throw new Error("Could not resolve the Hunk state path because HOME/XDG_CONFIG_HOME is unset."); + throw new Error("Could not resolve the Hunk state path because HOME/XDG_STATE_HOME is unset."); } updateAppStateRecord(statePath, { diff --git a/test/pty/harness.ts b/test/pty/harness.ts index 5309f2ebf..33a84fd6d 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -322,13 +322,28 @@ export function createPtyHarness() { } // Isolate every launch from the developer's ambient user config/state so PTY snapshots assert - // against built-in defaults instead of whatever ~/.config/hunk/config.toml happens to set. + // against built-in defaults instead of whatever the user's Hunk files happen to set. let isolatedConfigHome: string | undefined; function configHome() { isolatedConfigHome ??= makeTempDir("hunk-tuistory-config-"); return isolatedConfigHome; } + /** Isolate config and state while preserving explicit per-launch overrides. */ + function launchEnvironment(optionsEnv?: Record) { + const isolatedConfig = optionsEnv?.XDG_CONFIG_HOME ?? configHome(); + const isolatedState = optionsEnv?.XDG_STATE_HOME ?? isolatedConfig; + + return { + ...process.env, + XDG_CONFIG_HOME: isolatedConfig, + XDG_STATE_HOME: isolatedState, + HUNK_MCP_DISABLE: "1", + HUNK_DISABLE_UPDATE_NOTICE: "1", + ...optionsEnv, + }; + } + function cleanup() { while (tempDirs.length > 0) { const dir = tempDirs.pop(); @@ -1125,13 +1140,7 @@ end cwd: options.cwd ?? repoRoot, cols: options.cols ?? 140, rows: options.rows ?? 24, - env: { - ...process.env, - XDG_CONFIG_HOME: configHome(), - HUNK_MCP_DISABLE: "1", - HUNK_DISABLE_UPDATE_NOTICE: "1", - ...options.env, - }, + env: launchEnvironment(options.env), }; return startTestTerminalSession(tuistory, launchOptions, options.testTerminalResponder); @@ -1158,13 +1167,7 @@ end cwd: options.cwd ?? repoRoot, cols: options.cols ?? 140, rows: options.rows ?? 24, - env: { - ...process.env, - XDG_CONFIG_HOME: configHome(), - HUNK_MCP_DISABLE: "1", - HUNK_DISABLE_UPDATE_NOTICE: "1", - ...options.env, - }, + env: launchEnvironment(options.env), }, options.testTerminalResponder, ); diff --git a/website/src/components/marketing/FeatureShowcase.astro b/website/src/components/marketing/FeatureShowcase.astro index 106bd7067..1bc0ff52e 100644 --- a/website/src/components/marketing/FeatureShowcase.astro +++ b/website/src/components/marketing/FeatureShowcase.astro @@ -160,7 +160,7 @@ const showcase: (ShowcaseFeature | ShowcaseQuote)[] = [ linkText: "Writing extensions", media: { kind: "code", - file: "~/.config/hunk/extensions/hello.ts", + file: "~/.local/state/hunk/extensions/hello.ts", source: extensionSample, label: "A complete Hunk extension: a TypeScript file that registers a command bound to f6", }, diff --git a/website/src/content/docs/docs/extend/custom-sidebars.md b/website/src/content/docs/docs/extend/custom-sidebars.md index 8b0180c9d..ec00f8213 100644 --- a/website/src/content/docs/docs/extend/custom-sidebars.md +++ b/website/src/content/docs/docs/extend/custom-sidebars.md @@ -6,7 +6,7 @@ description: Render React panes around Hunk's review stream. `hunk.registerPane(pane)` renders a React component on the left, right, top, or bottom of the review. Pair it with [`registerCommand`](/docs/extend/extension-api/#hunkregistercommandcommand-handler) so a key opens it: ```tsx -// ~/.config/hunk/extensions/flat-sidebar.tsx +// ~/.local/state/hunk/extensions/flat-sidebar.tsx import { useMemo } from "react"; import type { ExtensionPaneProps, HunkExtensionAPI } from "hunkdiff/extension"; diff --git a/website/src/content/docs/docs/extend/extensions.md b/website/src/content/docs/docs/extend/extensions.md index 249aa7ef1..6e43ee083 100644 --- a/website/src/content/docs/docs/extend/extensions.md +++ b/website/src/content/docs/docs/extend/extensions.md @@ -6,7 +6,7 @@ description: Load plain TypeScript extensions, understand discovery and trust, a A Hunk extension is one TypeScript (or JavaScript) file that default-exports a function. Hunk imports it at startup and hands it an API object. No build step is required. ```ts -// ~/.config/hunk/extensions/hello.ts +// ~/.local/state/hunk/extensions/hello.ts import type { HunkExtensionAPI } from "hunkdiff/extension"; export default function (hunk: HunkExtensionAPI) { @@ -28,7 +28,7 @@ Writing one with a coding agent? `hunk skill path hunk-extensions` prints a bund | ----- | ---------------------------------------------------- | --------------------- | | 1 | `--extension ` (repeatable) | immediately | | 2 | `[extensions] paths` in your user config | immediately | -| 3 | `~/.config/hunk/extensions/` | immediately | +| 3 | `~/.local/state/hunk/extensions/` | immediately | | 4 | `.hunk/extensions/` in the repo under review | after [trust](#trust) | | 4 | `[extensions] paths` in the repo `.hunk/config.toml` | after [trust](#trust) | @@ -44,7 +44,7 @@ Writing one with a coding agent? `hunk skill path hunk-extensions` prints a bund A folder is an extension if its `package.json` declares entries under the `hunk` field, or failing that if it has an `index.{ts,tsx,js,jsx,mjs}` (in that preference order): ```text -~/.config/hunk/extensions/my-ext/ +~/.local/state/hunk/extensions/my-ext/ package.json # {"hunk": {"extensions": ["./src/index.ts"]}} node_modules/ # bun install / npm install, right here src/ @@ -69,7 +69,7 @@ Ids start with a letter or digit, then letters, digits, `-`, or `_`. `hunk`, `gi ## Installing shared extensions -Extensions are shared as Git repositories or npm packages. `hunk extension install` installs either source under `~/.config/hunk/extensions/installed/`, validates its entries, and records the requested source and resolved commit or package version: +Extensions are shared as Git repositories or npm packages. `hunk extension install` installs either source under `~/.local/state/hunk/extensions/installed/`, validates its entries, and records the requested source and resolved commit or package version: ```bash hunk extension install acme/hunk-word-diff # GitHub shorthand @@ -84,7 +84,7 @@ hunk extension install npm:@acme/hunk-word-diff@next - `hunk extension list` shows every managed install with its version, commit, and source. - `hunk extension update [name]` refreshes one install (or all of them) from its recorded source. Git refs and exact npm versions stay pinned; npm ranges and tags are re-resolved. -- `hunk extension remove ` deletes the install and its record. Hand-copied extensions in `~/.config/hunk/extensions/` are never touched. +- `hunk extension remove ` deletes the install and its record. Hand-copied extensions in `~/.local/state/hunk/extensions/` are never touched. Installing is the consent step: extensions run with your full user permissions, so a fresh install asks for confirmation (or takes `--yes`) after naming the source. Only install sources you trust. Managed installs then load through the global group above — same precedence, no further prompts. @@ -169,7 +169,7 @@ some_key = "some value" Collapse lockfiles and generated output out of every review, and say how many files were hidden. ```ts -// ~/.config/hunk/extensions/collapse-generated.ts +// ~/.local/state/hunk/extensions/collapse-generated.ts import type { HunkExtensionAPI } from "hunkdiff/extension"; /** Match one path against a `*`-only glob, anchored at both ends. */ diff --git a/website/tests/marketing-smoke.spec.ts b/website/tests/marketing-smoke.spec.ts index 8b8397d1c..506c85ae7 100644 --- a/website/tests/marketing-smoke.spec.ts +++ b/website/tests/marketing-smoke.spec.ts @@ -379,7 +379,7 @@ test("the extensions row carries a real code sample as its media", async ({ page // extensions into. const row = page.locator(".show-item").filter({ hasText: "Extend it however you want" }); await expect(row.locator(".show-media.show-code")).toHaveCount(1); - await expect(row.locator(".paper-bar .pt")).toHaveText("~/.config/hunk/extensions/hello.ts"); + await expect(row.locator(".paper-bar .pt")).toHaveText("~/.local/state/hunk/extensions/hello.ts"); await expect(row.locator("pre")).toContainText('from "hunkdiff/extension"'); await expect(row.getByRole("link", { name: /Writing extensions/ })).toHaveAttribute( "href", From 4023647bbd1f1ed0d13817aa2ec87d4de0e4e509 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sun, 11 Oct 2026 09:00:26 +0800 Subject: [PATCH 3/7] fix: ignore relative XDG state directories --- README.md | 3 ++- docs/extensions.md | 6 +++++- packages/hunk/README.md | 3 ++- packages/hunk/skills/hunk-extensions/SKILL.md | 7 ++++--- packages/hunk/src/core/run/paths.test.ts | 11 +++++++++++ packages/hunk/src/core/run/paths.ts | 4 ++-- website/src/content/docs/docs/extend/extensions.md | 3 ++- 7 files changed, 28 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6d584de88..24f02c34a 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,8 @@ minor releases while it stabilizes; breaking changes are called out in release notes. Hunk loads plain TypeScript extensions from `$XDG_STATE_HOME/hunk/extensions/` -(defaulting to `~/.local/state/hunk/extensions/`), from a repository's +when `$XDG_STATE_HOME` is absolute, or from `~/.local/state/hunk/extensions/` +otherwise. It also loads from a repository's `.hunk/extensions/` (after you explicitly trust that repository), and from `--extension ` for development. `--no-extensions` turns those off for one run; Hunk's own bundled backends (Git, Jujutsu, and Sapling) stay loaded. diff --git a/docs/extensions.md b/docs/extensions.md index 2722fd223..c59774df4 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -41,6 +41,9 @@ keeps its origin even if the same file is also discovered somewhere else. | 4 | `.hunk/extensions/` in the repo under review | **prompts for trust** | | 4 | `[extensions] paths` in the repo `.hunk/config.toml` | **prompts for trust** | +The global source uses an absolute `$XDG_STATE_HOME` when set; otherwise it +falls back to `~/.local/state` (or the platform home directory equivalent). + The two repo-local sources share a group number because they are one group: both are repo-controlled, so they share a trust decision and their paths are sorted together rather than one source being loaded ahead of the other. @@ -297,7 +300,8 @@ Run this repository's extensions? asked again next time. - **Never** records a denial so Hunk stops offering. -Decisions are stored per repository root in `~/.config/hunk/state.json`. The +Decisions are stored per repository root in `$XDG_STATE_HOME/hunk/state.json` +(defaulting to `~/.local/state/hunk/state.json`). The prompt is a normal dialog over the review stream, not a gate in front of it: you can dismiss it and keep reviewing. diff --git a/packages/hunk/README.md b/packages/hunk/README.md index 6e38a8d57..c9bda00de 100644 --- a/packages/hunk/README.md +++ b/packages/hunk/README.md @@ -291,7 +291,8 @@ minor releases while it stabilizes; breaking changes are called out in release notes. Hunk loads plain TypeScript extensions from `$XDG_STATE_HOME/hunk/extensions/` -(defaulting to `~/.local/state/hunk/extensions/`), from a repository's +when `$XDG_STATE_HOME` is absolute, or from `~/.local/state/hunk/extensions/` +otherwise. It also loads from a repository's `.hunk/extensions/` (after you explicitly trust that repository), and from `--extension ` for development. `--no-extensions` turns those off for one run; Hunk's own bundled backends (Git, Jujutsu, and Sapling) stay loaded. diff --git a/packages/hunk/skills/hunk-extensions/SKILL.md b/packages/hunk/skills/hunk-extensions/SKILL.md index afc640d63..b5df6e6f5 100644 --- a/packages/hunk/skills/hunk-extensions/SKILL.md +++ b/packages/hunk/skills/hunk-extensions/SKILL.md @@ -59,8 +59,8 @@ The examples, by what they demonstrate: | `~/.local/state/hunk/extensions/` (XDG-aware) | runs immediately | | `.hunk/extensions/` or repo-config `paths` | **trust prompt** | -The global source uses `$XDG_STATE_HOME` when it is set, and otherwise falls -back to `~/.local/state` (or the platform home directory equivalent). +The global source uses an absolute `$XDG_STATE_HOME` when set; otherwise it +falls back to `~/.local/state` (or the platform home directory equivalent). Only the repo-local group is gated. Everything else — including `--extension`, even when its path points inside the repository under review — is read as @@ -353,7 +353,8 @@ Practical checks, in order of cost: - Notice naming the extension → id rejected (reserved, malformed, or already claimed), import failure, missing default export, or a throwing factory. - Repo-local extension silently absent → the trust prompt was dismissed or denied; - decisions are stored per repo root in `~/.config/hunk/state.json`. + decisions are stored per repo root in `$XDG_STATE_HOME/hunk/state.json`, + defaulting to `~/.local/state/hunk/state.json`. - Pane closes with a toast → the component threw; a second React copy is the usual cause. - Pane or file view never appears → nothing opened it (no `defaultOpen`, no diff --git a/packages/hunk/src/core/run/paths.test.ts b/packages/hunk/src/core/run/paths.test.ts index 0b5d830eb..57e087db4 100644 --- a/packages/hunk/src/core/run/paths.test.ts +++ b/packages/hunk/src/core/run/paths.test.ts @@ -39,6 +39,17 @@ describe("paths", () => { ); }); + test("ignores a relative XDG_STATE_HOME", () => { + const env = { + HOME: join("/tmp", "home"), + XDG_STATE_HOME: join("relative", "state"), + } as NodeJS.ProcessEnv; + + expect(resolveAppStatePath(env)).toBe( + join("/tmp", "home", ".local", "state", "hunk", "state.json"), + ); + }); + test("falls back to HOME for config and state paths", () => { const env = { HOME: join("/tmp", "home") } as NodeJS.ProcessEnv; diff --git a/packages/hunk/src/core/run/paths.ts b/packages/hunk/src/core/run/paths.ts index acc7d3730..04b688861 100644 --- a/packages/hunk/src/core/run/paths.ts +++ b/packages/hunk/src/core/run/paths.ts @@ -1,5 +1,5 @@ import fs from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; export { resolveCanonicalPath } from "@hunk/vcs/path"; /** @@ -45,7 +45,7 @@ export function resolveUserConfigDir(env: NodeJS.ProcessEnv = process.env) { /** Resolve the base state directory Hunk should use for user-scoped files. */ export function resolveUserStateDir(env: NodeJS.ProcessEnv = process.env) { - if (env.XDG_STATE_HOME) { + if (env.XDG_STATE_HOME && isAbsolute(env.XDG_STATE_HOME)) { return env.XDG_STATE_HOME; } diff --git a/website/src/content/docs/docs/extend/extensions.md b/website/src/content/docs/docs/extend/extensions.md index 6e43ee083..01b6f6c4a 100644 --- a/website/src/content/docs/docs/extend/extensions.md +++ b/website/src/content/docs/docs/extend/extensions.md @@ -32,6 +32,7 @@ Writing one with a coding agent? `hunk skill path hunk-extensions` prints a bund | 4 | `.hunk/extensions/` in the repo under review | after [trust](#trust) | | 4 | `[extensions] paths` in the repo `.hunk/config.toml` | after [trust](#trust) | +- The global source uses an absolute `$XDG_STATE_HOME` when set; otherwise it falls back to `~/.local/state` (or the platform home directory equivalent). - Groups load in order; within a group, entries sort alphabetically by resolved path. The first occurrence of a path wins. - The two repo-local sources are one group: one trust decision, one sort order. - A directory source matches `*.ts`, `*.tsx`, `*.js`, `*.jsx`, `*.mjs` directly inside it, plus one level of folder extensions. @@ -130,7 +131,7 @@ Run this repository's extensions? **Trust** records the decision and reloads the session; **not now** asks again next time; **never** stops the offers. The prompt is a dialog over the review stream, not a gate in front of it — dismiss it and keep reviewing. -Decisions are stored per repository root in `~/.config/hunk/state.json`, keyed by path (the VS Code workspace-trust model). A different repository later occupying a trusted path inherits the decision; clear the entry if that matters for a path you reuse. +Decisions are stored per repository root in `$XDG_STATE_HOME/hunk/state.json` (defaulting to `~/.local/state/hunk/state.json`), keyed by path (the VS Code workspace-trust model). A different repository later occupying a trusted path inherits the decision; clear the entry if that matters for a path you reuse. ## Failure isolation From a122279c6c262175f19850ec1eacd0dc876eb661 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 12 Oct 2026 14:03:50 -0400 Subject: [PATCH 4/7] test: align current extension coverage with XDG state paths --- packages/hunk/src/extensions/manage/npm.test.ts | 12 ++++++------ packages/hunk/src/extensions/startup.test.ts | 6 +++--- test/cli/npm-extension-install.test.ts | 6 ++++-- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/hunk/src/extensions/manage/npm.test.ts b/packages/hunk/src/extensions/manage/npm.test.ts index 3b41b84a1..4add95817 100644 --- a/packages/hunk/src/extensions/manage/npm.test.ts +++ b/packages/hunk/src/extensions/manage/npm.test.ts @@ -496,15 +496,15 @@ test("mixed Git/npm updates preserve legacy records and keep colliding display n registry.publish("same-name", "1.1.0"); const out: string[] = []; const io = { - env: { ...context.env, XDG_CONFIG_HOME: join(root, "config") }, + env: { ...context.env, XDG_STATE_HOME: join(root, "state") }, stdout: (text: string) => out.push(text), stderr: () => {}, }; // Point command storage at the same managed root used above. - const configInstalled = join(root, "config", "hunk", "extensions", "installed"); - mkdirSync(join(configInstalled, ".."), { recursive: true }); - renameSync(context.installedRoot, configInstalled); - context.installedRoot = configInstalled; + const stateInstalled = join(root, "state", "hunk", "extensions", "installed"); + mkdirSync(join(stateInstalled, ".."), { recursive: true }); + renameSync(context.installedRoot, stateInstalled); + context.installedRoot = stateInstalled; await runExtensionManageCommand({ kind: "extension-manage", action: "update" }, io); expect(out.join("")).toContain("Updated same-name to v1.1.0"); expect(out.join("")).toContain("same-name is already up to date"); @@ -521,7 +521,7 @@ test("npm uses the existing command confirmation, output, update and removal flo registry.publish("hunk-test-command", "1.0.0"); const out: string[] = []; const io = { - env: { ...registry.env, XDG_CONFIG_HOME: root }, + env: { ...registry.env, XDG_STATE_HOME: root }, stdout: (text: string) => out.push(text), stderr: () => {}, confirm: async () => false, diff --git a/packages/hunk/src/extensions/startup.test.ts b/packages/hunk/src/extensions/startup.test.ts index ee4cf1be8..d5786bef9 100644 --- a/packages/hunk/src/extensions/startup.test.ts +++ b/packages/hunk/src/extensions/startup.test.ts @@ -172,7 +172,7 @@ describe("extension startup", () => { const result = await loadStartupExtensions({ extensions: createExtensionsConfig(), cwd: home, - env: { XDG_CONFIG_HOME: home } as NodeJS.ProcessEnv, + env: { XDG_STATE_HOME: home } as NodeJS.ProcessEnv, }); const commands = resolveExtensionCliCommands(result.registry); @@ -282,7 +282,7 @@ export default function (hunk) { const provisional = await loadStartupExtensions({ extensions: createExtensionsConfig(), cwd: home, - env: { XDG_CONFIG_HOME: home } as NodeJS.ProcessEnv, + env: { XDG_STATE_HOME: home } as NodeJS.ProcessEnv, }); const rebuilt = await loadStartupExtensions({ extensions: createExtensionsConfig({ @@ -290,7 +290,7 @@ export default function (hunk) { disabled: ["selected"], }), cwd: home, - env: { XDG_CONFIG_HOME: home } as NodeJS.ProcessEnv, + env: { XDG_STATE_HOME: home } as NodeJS.ProcessEnv, previousLoad: provisional, }); diff --git a/test/cli/npm-extension-install.test.ts b/test/cli/npm-extension-install.test.ts index fe1fbe49d..fb36fd703 100644 --- a/test/cli/npm-extension-install.test.ts +++ b/test/cli/npm-extension-install.test.ts @@ -72,10 +72,12 @@ export default function (hunk) { }`, }; const config = join(root, "config"); + const state = join(root, "state"); const env = { ...process.env, ...registry.env, XDG_CONFIG_HOME: config, + XDG_STATE_HOME: state, HUNK_TEST_PROJECT_TOKEN: authToken, }; @@ -107,12 +109,12 @@ export default function (hunk) { "v1.0.0 -> v1.1.0", ); expect(run(["npm-test-command"])).toContain("Host API and dependency OK: 42"); - const recordsPath = join(config, "hunk", "extensions", "installed", "records.json"); + const recordsPath = join(state, "hunk", "extensions", "installed", "records.json"); const before = readFileSync(recordsPath, "utf8"); expect(before).not.toContain(authToken); const installName = Object.keys(JSON.parse(before))[0]!; expect( - existsSync(join(config, "hunk", "extensions", "installed", installName, ".npmrc")), + existsSync(join(state, "hunk", "extensions", "installed", installName, ".npmrc")), ).toBe(false); registry.publish("@hunk-cli-test/guide", "1.2.0", { manifest: { hunk: { extensions: ["./missing.ts"] } }, From c6163cde72ade1d9e4b32767544346ae58c63b19 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 12 Oct 2026 21:08:25 -0400 Subject: [PATCH 5/7] fix: preserve legacy state and keep extension locations unchanged --- .changeset/quiet-states-rest.md | 2 +- README.md | 13 ++- docs/extension-system-exploration.md | 4 +- docs/extensions.md | 22 ++-- examples/extensions/inline-edit/README.md | 4 +- .../extensions/rendered-markdown/README.md | 6 +- examples/extensions/vim-navigation/README.md | 4 +- packages/hunk/README.md | 13 ++- packages/hunk/skills/hunk-extensions/SKILL.md | 22 ++-- .../src/core/process/appStateLocation.test.ts | 106 ++++++++++++++++++ .../hunk/src/core/process/appStateLocation.ts | 42 +++++++ .../src/core/process/updateNotice.test.ts | 29 +++++ .../hunk/src/core/process/updateNotice.ts | 6 +- packages/hunk/src/core/run/paths.test.ts | 30 +++-- packages/hunk/src/core/run/paths.ts | 4 +- .../hunk/src/extensions/discovery.test.ts | 2 +- packages/hunk/src/extensions/manage/cli.ts | 2 +- .../src/extensions/manage/install.test.ts | 2 +- .../hunk/src/extensions/manage/npm.test.ts | 12 +- packages/hunk/src/extensions/startup.test.ts | 18 +-- packages/hunk/src/extensions/trust.test.ts | 14 +++ packages/hunk/src/extensions/trust.ts | 5 +- scripts/generate/generate-docs.ts | 2 + test/cli/npm-extension-install.test.ts | 6 +- test/pty/extensions-integration.test.ts | 42 ++++++- test/pty/status-integration.test.ts | 8 +- .../marketing/FeatureShowcase.astro | 2 +- .../docs/docs/extend/custom-sidebars.md | 2 +- .../content/docs/docs/extend/extensions.md | 15 ++- .../src/content/docs/docs/reference/config.md | 2 + website/tests/marketing-smoke.spec.ts | 2 +- 31 files changed, 338 insertions(+), 105 deletions(-) create mode 100644 packages/hunk/src/core/process/appStateLocation.test.ts create mode 100644 packages/hunk/src/core/process/appStateLocation.ts diff --git a/.changeset/quiet-states-rest.md b/.changeset/quiet-states-rest.md index 5a04b4089..b2047791e 100644 --- a/.changeset/quiet-states-rest.md +++ b/.changeset/quiet-states-rest.md @@ -2,4 +2,4 @@ "hunkdiff": patch --- -Store Hunk state and globally installed extensions under the XDG state directory. +Store persistent Hunk state under the XDG state directory while preserving existing state and global extension locations. diff --git a/README.md b/README.md index 24f02c34a..2a866c0ac 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,11 @@ You can persist preferences to a config file: - `~/.config/hunk/config.toml` - `.hunk/config.toml` +Persistent state uses `$XDG_STATE_HOME/hunk/state.json` when the variable is +absolute, or `~/.local/state/hunk/state.json` otherwise. Hunk preserves existing +state by copying the old config-directory file on first use, keeping the original +and falling back to it if migration fails. Global extension paths are unchanged. + For example: ```toml @@ -237,17 +242,15 @@ The extension API is experimental and may change in breaking ways between minor releases while it stabilizes; breaking changes are called out in release notes. -Hunk loads plain TypeScript extensions from `$XDG_STATE_HOME/hunk/extensions/` -when `$XDG_STATE_HOME` is absolute, or from `~/.local/state/hunk/extensions/` -otherwise. It also loads from a repository's -`.hunk/extensions/` (after you explicitly trust that repository), +Hunk loads plain TypeScript extensions from `~/.config/hunk/extensions/`, from a +repository's `.hunk/extensions/` (after you explicitly trust that repository), and from `--extension ` for development. `--no-extensions` turns those off for one run; Hunk's own bundled backends (Git, Jujutsu, and Sapling) stay loaded. Extensions can add commands, themes, VCS backends, custom sidebars, and more. For example, filter lockfiles out of a review: ```ts -// ~/.local/state/hunk/extensions/collapse-lockfiles.ts +// ~/.config/hunk/extensions/collapse-lockfiles.ts import type { HunkExtensionAPI } from "hunkdiff/extension"; export default function (hunk: HunkExtensionAPI) { diff --git a/docs/extension-system-exploration.md b/docs/extension-system-exploration.md index c7c7245dc..37ac2682d 100644 --- a/docs/extension-system-exploration.md +++ b/docs/extension-system-exploration.md @@ -159,7 +159,7 @@ Mirror pi's model closely; it is proven and our users overlap with pi's. ### Extension form and discovery ````ts -// ~/.local/state/hunk/extensions/copy-as-suggestion.ts +// ~/.config/hunk/extensions/copy-as-suggestion.ts import type { HunkExtensionAPI } from "hunkdiff/extension"; export default function (hunk: HunkExtensionAPI) { @@ -178,7 +178,7 @@ export default function (hunk: HunkExtensionAPI) { } ```` -- Discovery: `~/.local/state/hunk/extensions/*.ts` and `*/index.ts` (global, follows +- Discovery: `~/.config/hunk/extensions/*.ts` and `*/index.ts` (global, follows our existing XDG path logic in `packages/hunk/src/core/run/paths.ts`), `.hunk/extensions/` (repo-local, **trust-gated**, same posture as pi's project trust), explicit `[extensions] paths = [...]` in `config.toml`, and a `--extension ` diff --git a/docs/extensions.md b/docs/extensions.md index c59774df4..656242ea1 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -6,7 +6,7 @@ object. An entry may stand alone or be declared by a folder's optional `package.json` manifest; no build step is required. ```ts -// ~/.local/state/hunk/extensions/hello.ts +// ~/.config/hunk/extensions/hello.ts import type { HunkExtensionAPI } from "hunkdiff/extension"; export default function (hunk: HunkExtensionAPI) { @@ -37,13 +37,10 @@ keeps its origin even if the same file is also discovered somewhere else. | ----- | ---------------------------------------------------- | --------------------- | | 1 | `--extension ` (repeatable) | runs immediately | | 2 | `[extensions] paths` in your user config | runs immediately | -| 3 | `~/.local/state/hunk/extensions/` | runs immediately | +| 3 | `~/.config/hunk/extensions/` | runs immediately | | 4 | `.hunk/extensions/` in the repo under review | **prompts for trust** | | 4 | `[extensions] paths` in the repo `.hunk/config.toml` | **prompts for trust** | -The global source uses an absolute `$XDG_STATE_HOME` when set; otherwise it -falls back to `~/.local/state` (or the platform home directory equivalent). - The two repo-local sources share a group number because they are one group: both are repo-controlled, so they share a trust decision and their paths are sorted together rather than one source being loaded ahead of the other. @@ -58,7 +55,7 @@ order, so a folder shipping both a source and a built entry resolves the same everywhere). The manifest field is `hunk`: ```text -~/.local/state/hunk/extensions/my-ext/ +~/.config/hunk/extensions/my-ext/ package.json # {"hunk": {"extensions": ["./src/index.ts"]}} node_modules/ # bun install / npm install, right here src/ @@ -144,7 +141,7 @@ repository's own README. Extensions can be shared as Git repositories or npm packages. `hunk extension install` installs either source into a managed directory under -`~/.local/state/hunk/extensions/installed/`, validates its extension entries, and +`~/.config/hunk/extensions/installed/`, validates its extension entries, and records the requested source plus its resolved Git commit or npm version. Git and local-checkout sources keep the repository name as their install directory: @@ -195,7 +192,7 @@ and punctuation, stays filesystem-safe, and does not change with the version. Use that ID in `[extension.]` configuration; multi-entry manifests retain the existing entry-derived IDs. Failed acquisition or validation leaves the previous install intact. Managed installs never collide with -extensions you copied into `~/.local/state/hunk/extensions/` by hand, and the +extensions you copied into `~/.config/hunk/extensions/` by hand, and the installer refuses to overwrite an unmanaged directory of the same name. ### Publishing an extension @@ -300,8 +297,7 @@ Run this repository's extensions? asked again next time. - **Never** records a denial so Hunk stops offering. -Decisions are stored per repository root in `$XDG_STATE_HOME/hunk/state.json` -(defaulting to `~/.local/state/hunk/state.json`). The +Decisions are stored per repository root in `~/.config/hunk/state.json`. The prompt is a normal dialog over the review stream, not a gate in front of it: you can dismiss it and keep reviewing. @@ -972,7 +968,7 @@ Render a React component on the `left`, `right`, `top`, or `bottom` edge of the review. Pair it with `registerCommand` so a key opens it: ```tsx -// ~/.local/state/hunk/extensions/flat-pane.tsx +// ~/.config/hunk/extensions/flat-pane.tsx import { useMemo } from "react"; import type { ExtensionPaneProps, HunkExtensionAPI } from "hunkdiff/extension"; @@ -1399,7 +1395,7 @@ geometry. The installable [`examples/extensions/rendered-markdown/`](../examples/extensions/rendered-markdown/) uses this contract for a parsed Markdown preview. It is intentionally not bundled -or loaded by default; copy the folder into `~/.local/state/hunk/extensions/`, install +or loaded by default; copy the folder into `~/.config/hunk/extensions/`, install its dependency there, and its View entry and `F8` command become available. ```ts @@ -2565,7 +2561,7 @@ Collapse lockfiles and generated output out of every review, and say how many files were hidden. ```ts -// ~/.local/state/hunk/extensions/collapse-generated.ts +// ~/.config/hunk/extensions/collapse-generated.ts import type { HunkExtensionAPI } from "hunkdiff/extension"; /** Match one path against a `*`-only glob, anchored at both ends. */ diff --git a/examples/extensions/inline-edit/README.md b/examples/extensions/inline-edit/README.md index 6a9616a9f..cf286fb7c 100644 --- a/examples/extensions/inline-edit/README.md +++ b/examples/extensions/inline-edit/README.md @@ -24,8 +24,8 @@ bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/inl Copy the whole folder — it has no dependencies: ```bash -mkdir -p ~/.local/state/hunk/extensions -cp -R examples/extensions/inline-edit ~/.local/state/hunk/extensions/ +mkdir -p ~/.config/hunk/extensions +cp -R examples/extensions/inline-edit ~/.config/hunk/extensions/ ``` Hunk discovers the folder automatically on later launches. Open **View** and choose **File presentation: Inline edit**, or press `Ctrl-E`. The command is named `inline-edit.edit` for `[keybindings]` customization. diff --git a/examples/extensions/rendered-markdown/README.md b/examples/extensions/rendered-markdown/README.md index bd888f473..53883bcb7 100644 --- a/examples/extensions/rendered-markdown/README.md +++ b/examples/extensions/rendered-markdown/README.md @@ -19,9 +19,9 @@ bun run packages/hunk/src/main.tsx -- diff \ Copy the whole folder, then install its local dependency: ```bash -mkdir -p ~/.local/state/hunk/extensions -cp -R examples/extensions/rendered-markdown ~/.local/state/hunk/extensions/ -cd ~/.local/state/hunk/extensions/rendered-markdown +mkdir -p ~/.config/hunk/extensions +cp -R examples/extensions/rendered-markdown ~/.config/hunk/extensions/ +cd ~/.config/hunk/extensions/rendered-markdown bun install ``` diff --git a/examples/extensions/vim-navigation/README.md b/examples/extensions/vim-navigation/README.md index a05b29c9d..d350f554f 100644 --- a/examples/extensions/vim-navigation/README.md +++ b/examples/extensions/vim-navigation/README.md @@ -15,8 +15,8 @@ Press `F6` or choose **Extensions → Toggle Vim navigation**. The persistent st ## Install it globally ```bash -mkdir -p ~/.local/state/hunk/extensions -cp -R examples/extensions/vim-navigation ~/.local/state/hunk/extensions/ +mkdir -p ~/.config/hunk/extensions +cp -R examples/extensions/vim-navigation ~/.config/hunk/extensions/ ``` ## Keys diff --git a/packages/hunk/README.md b/packages/hunk/README.md index c9bda00de..6f0d28b64 100644 --- a/packages/hunk/README.md +++ b/packages/hunk/README.md @@ -195,6 +195,11 @@ You can persist preferences to a config file: - `~/.config/hunk/config.toml` - `.hunk/config.toml` +Persistent state uses `$XDG_STATE_HOME/hunk/state.json` when the variable is +absolute, or `~/.local/state/hunk/state.json` otherwise. Hunk preserves existing +state by copying the old config-directory file on first use, keeping the original +and falling back to it if migration fails. Global extension paths are unchanged. + Example: ```toml @@ -290,10 +295,8 @@ The extension API is experimental and may change in breaking ways between minor releases while it stabilizes; breaking changes are called out in release notes. -Hunk loads plain TypeScript extensions from `$XDG_STATE_HOME/hunk/extensions/` -when `$XDG_STATE_HOME` is absolute, or from `~/.local/state/hunk/extensions/` -otherwise. It also loads from a repository's -`.hunk/extensions/` (after you explicitly trust that repository), +Hunk loads plain TypeScript extensions from `~/.config/hunk/extensions/`, from a +repository's `.hunk/extensions/` (after you explicitly trust that repository), and from `--extension ` for development. `--no-extensions` turns those off for one run; Hunk's own bundled backends (Git, Jujutsu, and Sapling) stay loaded. @@ -304,7 +307,7 @@ the file-navigation sidebar with its own React component, react to lifecycle events, and show transient messages: ```ts -// ~/.local/state/hunk/extensions/collapse-lockfiles.ts +// ~/.config/hunk/extensions/collapse-lockfiles.ts import type { HunkExtensionAPI } from "hunkdiff/extension"; export default function (hunk: HunkExtensionAPI) { diff --git a/packages/hunk/skills/hunk-extensions/SKILL.md b/packages/hunk/skills/hunk-extensions/SKILL.md index b5df6e6f5..ea8c057b6 100644 --- a/packages/hunk/skills/hunk-extensions/SKILL.md +++ b/packages/hunk/skills/hunk-extensions/SKILL.md @@ -10,7 +10,7 @@ factory**. Hunk imports it at startup and hands it an API object. No build step, no manifest required. ```ts -// ~/.local/state/hunk/extensions/hello.ts +// ~/.config/hunk/extensions/hello.ts import type { HunkExtensionAPI } from "hunkdiff/extension"; export default function (hunk: HunkExtensionAPI) { @@ -52,15 +52,12 @@ The examples, by what they demonstrate: ## Where extensions live -| Source | Trust | -| --------------------------------------------- | ---------------- | -| `--extension ` (repeatable) | runs immediately | -| `[extensions] paths` in user config | runs immediately | -| `~/.local/state/hunk/extensions/` (XDG-aware) | runs immediately | -| `.hunk/extensions/` or repo-config `paths` | **trust prompt** | - -The global source uses an absolute `$XDG_STATE_HOME` when set; otherwise it -falls back to `~/.local/state` (or the platform home directory equivalent). +| Source | Trust | +| ------------------------------------------ | ---------------- | +| `--extension ` (repeatable) | runs immediately | +| `[extensions] paths` in user config | runs immediately | +| `~/.config/hunk/extensions/` (XDG-aware) | runs immediately | +| `.hunk/extensions/` or repo-config `paths` | **trust prompt** | Only the repo-local group is gated. Everything else — including `--extension`, even when its path points inside the repository under review — is read as @@ -78,7 +75,7 @@ every machine that loads it — keep a repo-shared extension dependency-free. Shared extensions install from git with `hunk extension install ` (`owner/repo[@ref]`, `git:host/path[@ref]`, a git URL, or a local path) into -`~/.local/state/hunk/extensions/installed//`, where they load with global +`~/.config/hunk/extensions/installed//`, where they load with global origin; `list`, `update`, and `remove` manage them. Declared `dependencies` are `bun install`ed at install time. The manifest may state `{"hunk": {"apiVersion": N}}` — the minimum extension API version — and an older @@ -353,8 +350,7 @@ Practical checks, in order of cost: - Notice naming the extension → id rejected (reserved, malformed, or already claimed), import failure, missing default export, or a throwing factory. - Repo-local extension silently absent → the trust prompt was dismissed or denied; - decisions are stored per repo root in `$XDG_STATE_HOME/hunk/state.json`, - defaulting to `~/.local/state/hunk/state.json`. + decisions are stored per repo root in `~/.config/hunk/state.json`. - Pane closes with a toast → the component threw; a second React copy is the usual cause. - Pane or file view never appears → nothing opened it (no `defaultOpen`, no diff --git a/packages/hunk/src/core/process/appStateLocation.test.ts b/packages/hunk/src/core/process/appStateLocation.test.ts new file mode 100644 index 000000000..6495e377c --- /dev/null +++ b/packages/hunk/src/core/process/appStateLocation.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { prepareAppStatePath } from "./appStateLocation"; +import { readAppStateRecord, writeAppStateRecord } from "./appStateFile"; + +const roots: string[] = []; + +/** Create distinct legacy and XDG state locations for one migration test. */ +function createTestStateLocations() { + const root = mkdtempSync(join(tmpdir(), "hunk-state-migration-")); + roots.push(root); + const env = { XDG_CONFIG_HOME: join(root, "config"), XDG_STATE_HOME: join(root, "state") }; + return { + root, + env, + legacy: join(env.XDG_CONFIG_HOME, "hunk", "state.json"), + target: join(env.XDG_STATE_HOME, "hunk", "state.json"), + }; +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("app state migration", () => { + test("copies complete legacy state, keeps the original, and leaves no staging files", () => { + const { env, legacy, target } = createTestStateLocations(); + const record = { + extensionTrust: { repo: "denied" }, + updateNotice: { lastCheck: 123 }, + other: true, + }; + writeAppStateRecord(legacy, record); + + expect(prepareAppStatePath(env)).toBe(target); + expect(readAppStateRecord(target)).toEqual(record); + expect(readFileSync(target, "utf8")).toBe(readFileSync(legacy, "utf8")); + expect(readdirSync(join(env.XDG_STATE_HOME, "hunk"))).toEqual(["state.json"]); + if (process.platform !== "win32") { + expect(statSync(target).mode & 0o777).toBe(0o600); + } + }); + + test("never replaces existing state, even when it is empty or corrupt", () => { + for (const contents of ["", "not json", '{"new":true}']) { + const { env, legacy, target } = createTestStateLocations(); + writeAppStateRecord(legacy, { old: true }); + writeAppStateRecord(target, {}); + writeFileSync(target, contents); + + expect(prepareAppStatePath(env)).toBe(target); + expect(readFileSync(target, "utf8")).toBe(contents); + } + }); + + test("does not create state for a fresh install or an unavailable home", () => { + const { env, target } = createTestStateLocations(); + + expect(prepareAppStatePath(env)).toBe(target); + expect(existsSync(target)).toBe(false); + expect(prepareAppStatePath({})).toBeUndefined(); + }); + + test("falls back to legacy state when the destination cannot be created", () => { + const { env, legacy, target } = createTestStateLocations(); + writeAppStateRecord(legacy, { remembered: true }); + writeFileSync(env.XDG_STATE_HOME, "blocks directory creation"); + + expect(prepareAppStatePath(env)).toBe(legacy); + expect(readAppStateRecord(legacy)).toEqual({ remembered: true }); + expect(existsSync(target)).toBe(false); + }); + + test("uses home fallbacks and ignores relative XDG state directories", () => { + const { root } = createTestStateLocations(); + const env = { HOME: root, XDG_STATE_HOME: "relative-state" }; + const legacy = join(root, ".config", "hunk", "state.json"); + const target = join(root, ".local", "state", "hunk", "state.json"); + writeAppStateRecord(legacy, { remembered: true }); + + expect(prepareAppStatePath(env)).toBe(target); + expect(readAppStateRecord(target)).toEqual({ remembered: true }); + }); + + test("preserves corrupt legacy bytes for existing quarantine handling", () => { + const { env, legacy, target } = createTestStateLocations(); + writeAppStateRecord(legacy, {}); + writeFileSync(legacy, "broken state"); + + expect(prepareAppStatePath(env)).toBe(target); + expect(readFileSync(target, "utf8")).toBe("broken state"); + expect(readFileSync(legacy, "utf8")).toBe("broken state"); + }); +}); diff --git a/packages/hunk/src/core/process/appStateLocation.ts b/packages/hunk/src/core/process/appStateLocation.ts new file mode 100644 index 000000000..debe894aa --- /dev/null +++ b/packages/hunk/src/core/process/appStateLocation.ts @@ -0,0 +1,42 @@ +/** + * Prepares persistent state for update notices and repository extension trust. + * Copies legacy config-directory state on first use without replacing existing state; + * path policy stays in run/paths and record parsing/writes stay in appStateFile. + */ +import { randomUUID } from "node:crypto"; +import { existsSync, linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { resolveAppStatePath, resolveUserConfigDir } from "../run/paths"; + +/** Preserve legacy state until a complete copy can be published at the new location. */ +export function prepareAppStatePath(env: NodeJS.ProcessEnv = process.env) { + const path = resolveAppStatePath(env); + if (!path || existsSync(path)) { + return path; + } + + const configDir = resolveUserConfigDir(env); + const legacyPath = configDir ? join(configDir, "hunk", "state.json") : undefined; + if (!legacyPath || legacyPath === path || !existsSync(legacyPath)) { + return path; + } + + const tempPath = `${path}.${randomUUID()}.tmp`; + try { + const contents = readFileSync(legacyPath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(tempPath, contents, { mode: 0o600, flag: "wx" }); + // A hard link publishes complete bytes atomically and never replaces a concurrent writer. + linkSync(tempPath, path); + return path; + } catch { + // Keep using the legacy file if migration is unavailable; never reset remembered decisions. + return existsSync(path) ? path : legacyPath; + } finally { + try { + rmSync(tempPath, { force: true }); + } catch { + // Cleanup must not prevent use of either successfully published or legacy state. + } + } +} diff --git a/packages/hunk/src/core/process/updateNotice.test.ts b/packages/hunk/src/core/process/updateNotice.test.ts index 11f8f5992..f6be5b9a8 100644 --- a/packages/hunk/src/core/process/updateNotice.test.ts +++ b/packages/hunk/src/core/process/updateNotice.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveStartupUpdateNotice } from "./updateNotice"; +import { readAppStateRecord, writeAppStateRecord } from "./appStateFile"; /** Build one JSON response that mimics the npm dist-tags payload. */ function createDistTagsResponse(tags: Record, status = 200) { @@ -43,6 +44,34 @@ async function withTempStatePath(run: (statePath: string) => Promise) { } describe("startup update notice", () => { + test("preserves the legacy release-check interval when switching to XDG state", async () => { + await withTempStatePath(async (rootPath) => { + const env = { XDG_CONFIG_HOME: `${rootPath}-config`, XDG_STATE_HOME: `${rootPath}-state` }; + const legacy = join(env.XDG_CONFIG_HOME, "hunk", "state.json"); + const target = join(env.XDG_STATE_HOME, "hunk", "state.json"); + const now = Date.now(); + const record = { lastReleaseCheckAt: new Date(now).toISOString(), other: "preserved" }; + writeAppStateRecord(legacy, record); + let fetchCount = 0; + + expect( + await resolveStartupUpdateNotice({ + env, + now: () => now, + resolveExecutablePath: () => NPM_EXECUTABLE_PATH, + resolveInstalledVersion: () => "0.7.0", + fetchImpl: async () => { + fetchCount += 1; + return createDistTagsResponse({ latest: "0.7.1" }); + }, + }), + ).toBeNull(); + expect(fetchCount).toBe(0); + expect(readAppStateRecord(target)).toMatchObject(record); + expect(readAppStateRecord(legacy)).toEqual(record); + }); + }); + test("prefers latest for stable installs when latest is newer", async () => { await withTempStatePath(async (statePath) => { await expect( diff --git a/packages/hunk/src/core/process/updateNotice.ts b/packages/hunk/src/core/process/updateNotice.ts index 958327be1..d1a68bbc5 100644 --- a/packages/hunk/src/core/process/updateNotice.ts +++ b/packages/hunk/src/core/process/updateNotice.ts @@ -6,7 +6,7 @@ import { type FetchImpl, type UpdateChannel, } from "../install/latestRelease"; -import { resolveAppStatePath } from "../run/paths"; +import { prepareAppStatePath } from "./appStateLocation"; import type { StartupNotice } from "./startupNotice"; import { isComparableVersion, @@ -194,7 +194,7 @@ function resolveStartupSkillRefreshNotice(deps: UpdateNoticeDeps = {}): StartupN return null; } - const statePath = deps.statePath ?? resolveAppStatePath(deps.env ?? process.env); + const statePath = deps.statePath ?? prepareAppStatePath(deps.env ?? process.env); if (!statePath) { return null; } @@ -246,7 +246,7 @@ export async function resolveStartupUpdateNotice( return null; } - const statePath = deps.statePath ?? resolveAppStatePath(env); + const statePath = deps.statePath ?? prepareAppStatePath(env); if (statePath && !claimStartupReleaseCheck(statePath, (deps.now ?? Date.now)())) { return null; } diff --git a/packages/hunk/src/core/run/paths.test.ts b/packages/hunk/src/core/run/paths.test.ts index 57e087db4..1552b8a3c 100644 --- a/packages/hunk/src/core/run/paths.test.ts +++ b/packages/hunk/src/core/run/paths.test.ts @@ -19,35 +19,31 @@ function createTempRoot(prefix: string) { describe("paths", () => { test("resolves XDG config and state paths", () => { const env = { - XDG_CONFIG_HOME: join("/tmp", "xdg-config"), - XDG_STATE_HOME: join("/tmp", "xdg-state"), + XDG_CONFIG_HOME: join(tmpdir(), "xdg-config"), + XDG_STATE_HOME: join(tmpdir(), "xdg-state"), } as NodeJS.ProcessEnv; - expect(resolveGlobalConfigPath(env)).toBe(join("/tmp", "xdg-config", "hunk", "config.toml")); - expect(resolveAppStatePath(env)).toBe(join("/tmp", "xdg-state", "hunk", "state.json")); - expect(resolveGlobalExtensionsDir(env)).toBe(join("/tmp", "xdg-state", "hunk", "extensions")); + expect(resolveGlobalConfigPath(env)).toBe(join(tmpdir(), "xdg-config", "hunk", "config.toml")); + expect(resolveAppStatePath(env)).toBe(join(tmpdir(), "xdg-state", "hunk", "state.json")); + expect(resolveGlobalExtensionsDir(env)).toBe( + join(tmpdir(), "xdg-config", "hunk", "extensions"), + ); }); - test("falls back to the platform-independent XDG state location", () => { - const env = { HOME: join("/tmp", "home") } as NodeJS.ProcessEnv; + test("falls back to XDG state while keeping extensions in the config directory", () => { + const env = { HOME: join(tmpdir(), "home") } as NodeJS.ProcessEnv; - expect(resolveAppStatePath(env)).toBe( - join("/tmp", "home", ".local", "state", "hunk", "state.json"), - ); - expect(resolveGlobalExtensionsDir(env)).toBe( - join("/tmp", "home", ".local", "state", "hunk", "extensions"), - ); + expect(resolveAppStatePath(env)).toBe(join(env.HOME!, ".local", "state", "hunk", "state.json")); + expect(resolveGlobalExtensionsDir(env)).toBe(join(env.HOME!, ".config", "hunk", "extensions")); }); test("ignores a relative XDG_STATE_HOME", () => { const env = { - HOME: join("/tmp", "home"), + HOME: join(tmpdir(), "home"), XDG_STATE_HOME: join("relative", "state"), } as NodeJS.ProcessEnv; - expect(resolveAppStatePath(env)).toBe( - join("/tmp", "home", ".local", "state", "hunk", "state.json"), - ); + expect(resolveAppStatePath(env)).toBe(join(env.HOME!, ".local", "state", "hunk", "state.json")); }); test("falls back to HOME for config and state paths", () => { diff --git a/packages/hunk/src/core/run/paths.ts b/packages/hunk/src/core/run/paths.ts index 04b688861..c5ef3b6b0 100644 --- a/packages/hunk/src/core/run/paths.ts +++ b/packages/hunk/src/core/run/paths.ts @@ -67,8 +67,8 @@ export function resolveAppStatePath(env: NodeJS.ProcessEnv = process.env) { /** Resolve the user-scoped directory Hunk scans for globally installed extensions. */ export function resolveGlobalExtensionsDir(env: NodeJS.ProcessEnv = process.env) { - const stateDir = resolveUserStateDir(env); - return stateDir ? join(stateDir, "hunk", "extensions") : undefined; + const configDir = resolveUserConfigDir(env); + return configDir ? join(configDir, "hunk", "extensions") : undefined; } /** diff --git a/packages/hunk/src/extensions/discovery.test.ts b/packages/hunk/src/extensions/discovery.test.ts index 429fd7420..479ef52c8 100644 --- a/packages/hunk/src/extensions/discovery.test.ts +++ b/packages/hunk/src/extensions/discovery.test.ts @@ -264,7 +264,7 @@ describe("extension discovery", () => { const candidates = discoverExtensions({ cwd: home, repoRoot: undefined, - env: { XDG_STATE_HOME: home } as NodeJS.ProcessEnv, + env: { XDG_CONFIG_HOME: home } as NodeJS.ProcessEnv, }); expect(candidates).toEqual([{ id: "themed", path: globalPath, origin: "global" }]); diff --git a/packages/hunk/src/extensions/manage/cli.ts b/packages/hunk/src/extensions/manage/cli.ts index 69a62ceeb..83ad7aaaf 100644 --- a/packages/hunk/src/extensions/manage/cli.ts +++ b/packages/hunk/src/extensions/manage/cli.ts @@ -45,7 +45,7 @@ function requireInstalledRoot(env: NodeJS.ProcessEnv) { const installedRoot = resolveInstalledExtensionsRoot(env); if (!installedRoot) { throw new HunkUserError( - "Could not resolve the extension install directory because HOME/XDG_STATE_HOME is unset.", + "Could not resolve the extension install directory because HOME/XDG_CONFIG_HOME is unset.", ); } diff --git a/packages/hunk/src/extensions/manage/install.test.ts b/packages/hunk/src/extensions/manage/install.test.ts index 15308e929..fe4256965 100644 --- a/packages/hunk/src/extensions/manage/install.test.ts +++ b/packages/hunk/src/extensions/manage/install.test.ts @@ -230,7 +230,7 @@ describe("hunk extension command runner", () => { stdout: (text: string) => out.push(text), stderr: (text: string) => err.push(text), ...(confirmAnswer !== undefined ? { confirm: async () => confirmAnswer } : {}), - env: { XDG_STATE_HOME: configDir } as NodeJS.ProcessEnv, + env: { XDG_CONFIG_HOME: configDir } as NodeJS.ProcessEnv, }, }; } diff --git a/packages/hunk/src/extensions/manage/npm.test.ts b/packages/hunk/src/extensions/manage/npm.test.ts index 4add95817..3b41b84a1 100644 --- a/packages/hunk/src/extensions/manage/npm.test.ts +++ b/packages/hunk/src/extensions/manage/npm.test.ts @@ -496,15 +496,15 @@ test("mixed Git/npm updates preserve legacy records and keep colliding display n registry.publish("same-name", "1.1.0"); const out: string[] = []; const io = { - env: { ...context.env, XDG_STATE_HOME: join(root, "state") }, + env: { ...context.env, XDG_CONFIG_HOME: join(root, "config") }, stdout: (text: string) => out.push(text), stderr: () => {}, }; // Point command storage at the same managed root used above. - const stateInstalled = join(root, "state", "hunk", "extensions", "installed"); - mkdirSync(join(stateInstalled, ".."), { recursive: true }); - renameSync(context.installedRoot, stateInstalled); - context.installedRoot = stateInstalled; + const configInstalled = join(root, "config", "hunk", "extensions", "installed"); + mkdirSync(join(configInstalled, ".."), { recursive: true }); + renameSync(context.installedRoot, configInstalled); + context.installedRoot = configInstalled; await runExtensionManageCommand({ kind: "extension-manage", action: "update" }, io); expect(out.join("")).toContain("Updated same-name to v1.1.0"); expect(out.join("")).toContain("same-name is already up to date"); @@ -521,7 +521,7 @@ test("npm uses the existing command confirmation, output, update and removal flo registry.publish("hunk-test-command", "1.0.0"); const out: string[] = []; const io = { - env: { ...registry.env, XDG_STATE_HOME: root }, + env: { ...registry.env, XDG_CONFIG_HOME: root }, stdout: (text: string) => out.push(text), stderr: () => {}, confirm: async () => false, diff --git a/packages/hunk/src/extensions/startup.test.ts b/packages/hunk/src/extensions/startup.test.ts index d5786bef9..dda49d2d6 100644 --- a/packages/hunk/src/extensions/startup.test.ts +++ b/packages/hunk/src/extensions/startup.test.ts @@ -50,7 +50,7 @@ describe("extension startup", () => { const result = await loadStartupExtensions({ extensions: createExtensionsConfig({ enabled: false }), cwd: home, - env: { XDG_STATE_HOME: home } as NodeJS.ProcessEnv, + env: { XDG_CONFIG_HOME: home } as NodeJS.ProcessEnv, }); expect(result.registry.cliCommands.map((entry) => entry.command.name)).toEqual(["gh"]); @@ -149,7 +149,7 @@ describe("extension startup", () => { extensionConfigs: { themed: { themeId: "midnight" } }, }), cwd: home, - env: { XDG_STATE_HOME: home } as NodeJS.ProcessEnv, + env: { XDG_CONFIG_HOME: home } as NodeJS.ProcessEnv, hostOverrides: { repoRoot: undefined }, }); @@ -172,7 +172,7 @@ describe("extension startup", () => { const result = await loadStartupExtensions({ extensions: createExtensionsConfig(), cwd: home, - env: { XDG_STATE_HOME: home } as NodeJS.ProcessEnv, + env: { XDG_CONFIG_HOME: home } as NodeJS.ProcessEnv, }); const commands = resolveExtensionCliCommands(result.registry); @@ -208,7 +208,7 @@ export default function (hunk) { const provisional = await loadStartupExtensions({ extensions: createExtensionsConfig(), cwd: repo, - env: { XDG_STATE_HOME: configHome } as NodeJS.ProcessEnv, + env: { XDG_CONFIG_HOME: configHome } as NodeJS.ProcessEnv, deferEventBusBinding: true, }); const repoExtensions = join(repo, ".hunk", "extensions"); @@ -226,7 +226,7 @@ export default function (hunk) { const final = await loadStartupExtensions({ extensions: createExtensionsConfig(), cwd: repo, - env: { XDG_STATE_HOME: configHome } as NodeJS.ProcessEnv, + env: { XDG_CONFIG_HOME: configHome } as NodeJS.ProcessEnv, projectRoot: repo, previousLoad: provisional, hostOverrides: { resolveRepoTrustImpl: () => "trusted" }, @@ -253,12 +253,12 @@ export default function (hunk) { const provisional = await loadStartupExtensions({ extensions: createExtensionsConfig({ extensionConfigs: { configured: { value: 1 } } }), cwd: home, - env: { XDG_STATE_HOME: home } as NodeJS.ProcessEnv, + env: { XDG_CONFIG_HOME: home } as NodeJS.ProcessEnv, }); await loadStartupExtensions({ extensions: createExtensionsConfig({ extensionConfigs: { configured: { value: 2 } } }), cwd: home, - env: { XDG_STATE_HOME: home } as NodeJS.ProcessEnv, + env: { XDG_CONFIG_HOME: home } as NodeJS.ProcessEnv, previousLoad: provisional, }); @@ -282,7 +282,7 @@ export default function (hunk) { const provisional = await loadStartupExtensions({ extensions: createExtensionsConfig(), cwd: home, - env: { XDG_STATE_HOME: home } as NodeJS.ProcessEnv, + env: { XDG_CONFIG_HOME: home } as NodeJS.ProcessEnv, }); const rebuilt = await loadStartupExtensions({ extensions: createExtensionsConfig({ @@ -290,7 +290,7 @@ export default function (hunk) { disabled: ["selected"], }), cwd: home, - env: { XDG_STATE_HOME: home } as NodeJS.ProcessEnv, + env: { XDG_CONFIG_HOME: home } as NodeJS.ProcessEnv, previousLoad: provisional, }); diff --git a/packages/hunk/src/extensions/trust.test.ts b/packages/hunk/src/extensions/trust.test.ts index 58193e4df..7d5f130a7 100644 --- a/packages/hunk/src/extensions/trust.test.ts +++ b/packages/hunk/src/extensions/trust.test.ts @@ -40,6 +40,20 @@ afterEach(() => { }); describe("extension trust", () => { + test("keeps trust decisions across upgrade and writes subsequent decisions to new state", () => { + const root = createTempDir("hunk-trust-migration-"); + const env = { XDG_CONFIG_HOME: join(root, "config"), XDG_STATE_HOME: join(root, "state") }; + const legacy = join(env.XDG_CONFIG_HOME, "hunk", "state.json"); + const target = join(env.XDG_STATE_HOME, "hunk", "state.json"); + const repo = join(root, "repo"); + writeExtensionTrust(repo, "trusted", { statePath: legacy }); + + expect(resolveRepoTrust(repo, { env })).toBe("trusted"); + writeExtensionTrust(repo, "denied", { env }); + expect(resolveRepoTrust(repo, { statePath: target })).toBe("denied"); + expect(resolveRepoTrust(repo, { statePath: legacy })).toBe("trusted"); + }); + test("round-trips trust decisions through the shared state file", () => { const stateDir = createTempDir("hunk-trust-state-"); const statePath = join(stateDir, "state.json"); diff --git a/packages/hunk/src/extensions/trust.ts b/packages/hunk/src/extensions/trust.ts index 8c8f11085..4d6c96de4 100644 --- a/packages/hunk/src/extensions/trust.ts +++ b/packages/hunk/src/extensions/trust.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; import { readAppStateRecord, updateAppStateRecord } from "../core/process/appStateFile"; -import { resolveCanonicalPath, resolveAppStatePath } from "../core/run/paths"; +import { prepareAppStatePath } from "../core/process/appStateLocation"; +import { resolveCanonicalPath } from "../core/run/paths"; /** * Repo-local extensions run arbitrary code from the repository under review, @@ -23,7 +24,7 @@ export interface ExtensionTrustOptions { /** Resolve the state file backing trust decisions, if the environment has one. */ function resolveStatePath(options: ExtensionTrustOptions) { - return options.statePath ?? resolveAppStatePath(options.env ?? process.env); + return options.statePath ?? prepareAppStatePath(options.env ?? process.env); } /** diff --git a/scripts/generate/generate-docs.ts b/scripts/generate/generate-docs.ts index ba0b3132e..08f98e332 100644 --- a/scripts/generate/generate-docs.ts +++ b/scripts/generate/generate-docs.ts @@ -306,6 +306,8 @@ Layers apply from lowest to highest precedence: The user path is \`$XDG_CONFIG_HOME/hunk/config.toml\` when configured. Otherwise Hunk uses \`$HOME/.config/hunk/config.toml\` or \`%USERPROFILE%/.config/hunk/config.toml\`; if none of those environment variables are set, no user config is loaded. Custom theme tables are root-only; preference keys may appear at top level, in a command table, or in \`[pager]\`. +Persistent application state uses an absolute \`$XDG_STATE_HOME/hunk/state.json\`, falling back to \`~/.local/state/hunk/state.json\` (or the platform home equivalent). On first use, Hunk copies legacy state from the config directory only when the new file is absent, retains the original, and continues using it if migration fails. Existing new state always takes precedence. Global extensions remain in \`$XDG_CONFIG_HOME/hunk/extensions/\` or \`~/.config/hunk/extensions/\`. + ## Preference keys ${optionBlocks.join("\n\n---\n\n")} diff --git a/test/cli/npm-extension-install.test.ts b/test/cli/npm-extension-install.test.ts index fb36fd703..fe1fbe49d 100644 --- a/test/cli/npm-extension-install.test.ts +++ b/test/cli/npm-extension-install.test.ts @@ -72,12 +72,10 @@ export default function (hunk) { }`, }; const config = join(root, "config"); - const state = join(root, "state"); const env = { ...process.env, ...registry.env, XDG_CONFIG_HOME: config, - XDG_STATE_HOME: state, HUNK_TEST_PROJECT_TOKEN: authToken, }; @@ -109,12 +107,12 @@ export default function (hunk) { "v1.0.0 -> v1.1.0", ); expect(run(["npm-test-command"])).toContain("Host API and dependency OK: 42"); - const recordsPath = join(state, "hunk", "extensions", "installed", "records.json"); + const recordsPath = join(config, "hunk", "extensions", "installed", "records.json"); const before = readFileSync(recordsPath, "utf8"); expect(before).not.toContain(authToken); const installName = Object.keys(JSON.parse(before))[0]!; expect( - existsSync(join(state, "hunk", "extensions", "installed", installName, ".npmrc")), + existsSync(join(config, "hunk", "extensions", "installed", installName, ".npmrc")), ).toBe(false); registry.publish("@hunk-cli-test/guide", "1.2.0", { manifest: { hunk: { extensions: ["./missing.ts"] } }, diff --git a/test/pty/extensions-integration.test.ts b/test/pty/extensions-integration.test.ts index 974ef83e3..c278f61f9 100644 --- a/test/pty/extensions-integration.test.ts +++ b/test/pty/extensions-integration.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { createPtyHarness, dragMouse, lineIndexOf } from "./harness"; @@ -465,6 +465,46 @@ describe("PTY extensions", () => { } }); + test("upgrading migrates remembered trust without prompting or moving global extensions", async () => { + const configHome = harness.createIsolatedConfigHome(); + const stateHome = harness.createIsolatedConfigHome(); + const fixture = harness.createRepoExtensionFixture(TRANSFORM_EXTENSION_SOURCE); + mkdirSync(join(configHome, "hunk"), { recursive: true }); + const legacyPath = join(configHome, "hunk", "state.json"); + const legacy = JSON.stringify({ extensionTrust: { [trustKey(fixture.dir)]: "trusted" } }); + writeFileSync(legacyPath, legacy); + const globalDir = join(configHome, "hunk", "extensions"); + mkdirSync(globalDir); + writeFileSync( + join(globalDir, "global.ts"), + `export default function (hunk) { + hunk.on("startup", (_event, ctx) => ctx.notify("GLOBAL CONFIG EXTENSION ACTIVE")); +}`, + ); + const session = await harness.launchHunk({ + args: ["diff", "--mode", "unified"], + cwd: fixture.dir, + cols: 140, + rows: 24, + env: { XDG_CONFIG_HOME: configHome, XDG_STATE_HOME: stateHome }, + }); + + try { + const snapshot = await harness.waitForSnapshot( + session, + (text) => + text.includes("REPO EXTENSION ACTIVE") && text.includes("GLOBAL CONFIG EXTENSION ACTIVE"), + 20_000, + ); + expect(snapshot).not.toContain("Run this repository's extensions?"); + expect(readTrustState(stateHome)[trustKey(fixture.dir)]).toBe("trusted"); + expect(readFileSync(legacyPath, "utf8")).toBe(legacy); + expect(existsSync(join(stateHome, "hunk", "extensions"))).toBe(false); + } finally { + session.close(); + } + }); + test("trust prompt runs repo extensions after the user trusts the repository", async () => { const configHome = harness.createIsolatedConfigHome(); const fixture = harness.createRepoExtensionFixture(TRANSFORM_EXTENSION_SOURCE); diff --git a/test/pty/status-integration.test.ts b/test/pty/status-integration.test.ts index 5f1f2e563..d6c0a7d3b 100644 --- a/test/pty/status-integration.test.ts +++ b/test/pty/status-integration.test.ts @@ -385,7 +385,12 @@ test("enabled trusted launch extensions span real broker-mounted sibling reviews join(sibling, ".hunk", "extensions", "forbidden.ts"), `import { writeFileSync } from "node:fs"; export default () => writeFileSync(${JSON.stringify(forbidden)}, "ran");`, ); - const env = { ...process.env, XDG_CONFIG_HOME: configHome, XDG_RUNTIME_DIR: configHome }; + const env = { + ...process.env, + XDG_CONFIG_HOME: configHome, + XDG_STATE_HOME: configHome, + XDG_RUNTIME_DIR: configHome, + }; writeExtensionTrust(cwd, "trusted", { env }); expect(resolveRepoTrust(sibling, { env })).toBe("unknown"); writeFileSync(join(sibling, "alpha.ts"), "export const siblingBrokerTarget = 42;\n"); @@ -398,6 +403,7 @@ test("enabled trusted launch extensions span real broker-mounted sibling reviews env: { XDG_CONFIG_HOME: configHome, XDG_RUNTIME_DIR: configHome, + XDG_STATE_HOME: configHome, HUNK_MCP_DISABLE: "0", HUNK_MCP_PORT: String(port), }, diff --git a/website/src/components/marketing/FeatureShowcase.astro b/website/src/components/marketing/FeatureShowcase.astro index 1bc0ff52e..106bd7067 100644 --- a/website/src/components/marketing/FeatureShowcase.astro +++ b/website/src/components/marketing/FeatureShowcase.astro @@ -160,7 +160,7 @@ const showcase: (ShowcaseFeature | ShowcaseQuote)[] = [ linkText: "Writing extensions", media: { kind: "code", - file: "~/.local/state/hunk/extensions/hello.ts", + file: "~/.config/hunk/extensions/hello.ts", source: extensionSample, label: "A complete Hunk extension: a TypeScript file that registers a command bound to f6", }, diff --git a/website/src/content/docs/docs/extend/custom-sidebars.md b/website/src/content/docs/docs/extend/custom-sidebars.md index ec00f8213..8b0180c9d 100644 --- a/website/src/content/docs/docs/extend/custom-sidebars.md +++ b/website/src/content/docs/docs/extend/custom-sidebars.md @@ -6,7 +6,7 @@ description: Render React panes around Hunk's review stream. `hunk.registerPane(pane)` renders a React component on the left, right, top, or bottom of the review. Pair it with [`registerCommand`](/docs/extend/extension-api/#hunkregistercommandcommand-handler) so a key opens it: ```tsx -// ~/.local/state/hunk/extensions/flat-sidebar.tsx +// ~/.config/hunk/extensions/flat-sidebar.tsx import { useMemo } from "react"; import type { ExtensionPaneProps, HunkExtensionAPI } from "hunkdiff/extension"; diff --git a/website/src/content/docs/docs/extend/extensions.md b/website/src/content/docs/docs/extend/extensions.md index 01b6f6c4a..249aa7ef1 100644 --- a/website/src/content/docs/docs/extend/extensions.md +++ b/website/src/content/docs/docs/extend/extensions.md @@ -6,7 +6,7 @@ description: Load plain TypeScript extensions, understand discovery and trust, a A Hunk extension is one TypeScript (or JavaScript) file that default-exports a function. Hunk imports it at startup and hands it an API object. No build step is required. ```ts -// ~/.local/state/hunk/extensions/hello.ts +// ~/.config/hunk/extensions/hello.ts import type { HunkExtensionAPI } from "hunkdiff/extension"; export default function (hunk: HunkExtensionAPI) { @@ -28,11 +28,10 @@ Writing one with a coding agent? `hunk skill path hunk-extensions` prints a bund | ----- | ---------------------------------------------------- | --------------------- | | 1 | `--extension ` (repeatable) | immediately | | 2 | `[extensions] paths` in your user config | immediately | -| 3 | `~/.local/state/hunk/extensions/` | immediately | +| 3 | `~/.config/hunk/extensions/` | immediately | | 4 | `.hunk/extensions/` in the repo under review | after [trust](#trust) | | 4 | `[extensions] paths` in the repo `.hunk/config.toml` | after [trust](#trust) | -- The global source uses an absolute `$XDG_STATE_HOME` when set; otherwise it falls back to `~/.local/state` (or the platform home directory equivalent). - Groups load in order; within a group, entries sort alphabetically by resolved path. The first occurrence of a path wins. - The two repo-local sources are one group: one trust decision, one sort order. - A directory source matches `*.ts`, `*.tsx`, `*.js`, `*.jsx`, `*.mjs` directly inside it, plus one level of folder extensions. @@ -45,7 +44,7 @@ Writing one with a coding agent? `hunk skill path hunk-extensions` prints a bund A folder is an extension if its `package.json` declares entries under the `hunk` field, or failing that if it has an `index.{ts,tsx,js,jsx,mjs}` (in that preference order): ```text -~/.local/state/hunk/extensions/my-ext/ +~/.config/hunk/extensions/my-ext/ package.json # {"hunk": {"extensions": ["./src/index.ts"]}} node_modules/ # bun install / npm install, right here src/ @@ -70,7 +69,7 @@ Ids start with a letter or digit, then letters, digits, `-`, or `_`. `hunk`, `gi ## Installing shared extensions -Extensions are shared as Git repositories or npm packages. `hunk extension install` installs either source under `~/.local/state/hunk/extensions/installed/`, validates its entries, and records the requested source and resolved commit or package version: +Extensions are shared as Git repositories or npm packages. `hunk extension install` installs either source under `~/.config/hunk/extensions/installed/`, validates its entries, and records the requested source and resolved commit or package version: ```bash hunk extension install acme/hunk-word-diff # GitHub shorthand @@ -85,7 +84,7 @@ hunk extension install npm:@acme/hunk-word-diff@next - `hunk extension list` shows every managed install with its version, commit, and source. - `hunk extension update [name]` refreshes one install (or all of them) from its recorded source. Git refs and exact npm versions stay pinned; npm ranges and tags are re-resolved. -- `hunk extension remove ` deletes the install and its record. Hand-copied extensions in `~/.local/state/hunk/extensions/` are never touched. +- `hunk extension remove ` deletes the install and its record. Hand-copied extensions in `~/.config/hunk/extensions/` are never touched. Installing is the consent step: extensions run with your full user permissions, so a fresh install asks for confirmation (or takes `--yes`) after naming the source. Only install sources you trust. Managed installs then load through the global group above — same precedence, no further prompts. @@ -131,7 +130,7 @@ Run this repository's extensions? **Trust** records the decision and reloads the session; **not now** asks again next time; **never** stops the offers. The prompt is a dialog over the review stream, not a gate in front of it — dismiss it and keep reviewing. -Decisions are stored per repository root in `$XDG_STATE_HOME/hunk/state.json` (defaulting to `~/.local/state/hunk/state.json`), keyed by path (the VS Code workspace-trust model). A different repository later occupying a trusted path inherits the decision; clear the entry if that matters for a path you reuse. +Decisions are stored per repository root in `~/.config/hunk/state.json`, keyed by path (the VS Code workspace-trust model). A different repository later occupying a trusted path inherits the decision; clear the entry if that matters for a path you reuse. ## Failure isolation @@ -170,7 +169,7 @@ some_key = "some value" Collapse lockfiles and generated output out of every review, and say how many files were hidden. ```ts -// ~/.local/state/hunk/extensions/collapse-generated.ts +// ~/.config/hunk/extensions/collapse-generated.ts import type { HunkExtensionAPI } from "hunkdiff/extension"; /** Match one path against a `*`-only glob, anchored at both ends. */ diff --git a/website/src/content/docs/docs/reference/config.md b/website/src/content/docs/docs/reference/config.md index 23b07d97a..64ce1a7c6 100644 --- a/website/src/content/docs/docs/reference/config.md +++ b/website/src/content/docs/docs/reference/config.md @@ -18,6 +18,8 @@ Layers apply from lowest to highest precedence: The user path is `$XDG_CONFIG_HOME/hunk/config.toml` when configured. Otherwise Hunk uses `$HOME/.config/hunk/config.toml` or `%USERPROFILE%/.config/hunk/config.toml`; if none of those environment variables are set, no user config is loaded. Custom theme tables are root-only; preference keys may appear at top level, in a command table, or in `[pager]`. +Persistent application state uses an absolute `$XDG_STATE_HOME/hunk/state.json`, falling back to `~/.local/state/hunk/state.json` (or the platform home equivalent). On first use, Hunk copies legacy state from the config directory only when the new file is absent, retains the original, and continues using it if migration fails. Existing new state always takes precedence. Global extensions remain in `$XDG_CONFIG_HOME/hunk/extensions/` or `~/.config/hunk/extensions/`. + ## Preference keys **`mode`** diff --git a/website/tests/marketing-smoke.spec.ts b/website/tests/marketing-smoke.spec.ts index 506c85ae7..8b8397d1c 100644 --- a/website/tests/marketing-smoke.spec.ts +++ b/website/tests/marketing-smoke.spec.ts @@ -379,7 +379,7 @@ test("the extensions row carries a real code sample as its media", async ({ page // extensions into. const row = page.locator(".show-item").filter({ hasText: "Extend it however you want" }); await expect(row.locator(".show-media.show-code")).toHaveCount(1); - await expect(row.locator(".paper-bar .pt")).toHaveText("~/.local/state/hunk/extensions/hello.ts"); + await expect(row.locator(".paper-bar .pt")).toHaveText("~/.config/hunk/extensions/hello.ts"); await expect(row.locator("pre")).toContainText('from "hunkdiff/extension"'); await expect(row.getByRole("link", { name: /Writing extensions/ })).toHaveAttribute( "href", From c217a505b42add69219e10f1cfb3d0de5969fe0e Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 12 Oct 2026 22:15:01 -0400 Subject: [PATCH 6/7] test: await click paint and allow Windows jj startup headroom --- packages/hunk-jj/src/source.test.ts | 41 ++++++++++++++++------------- test/pty/comment-target.test.ts | 8 +++++- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/packages/hunk-jj/src/source.test.ts b/packages/hunk-jj/src/source.test.ts index 70bf06904..5506ec6d2 100644 --- a/packages/hunk-jj/src/source.test.ts +++ b/packages/hunk-jj/src/source.test.ts @@ -62,25 +62,30 @@ const jjTest = Bun.which("jj") ? test : test.skip; const unixJjTest = Bun.which("jj") && process.platform !== "win32" ? test : test.skip; describe("Jujutsu source reading", () => { - jjTest("reads exact commit contents through `jj file show`", async () => { - const repoRoot = createTempJjRepo("hunk-source-jj-"); - const filePath = "-note [exact] file.txt"; - writeFileSync(join(repoRoot, filePath), "first revision\n"); - writeFileSync(join(repoRoot, "-note e file.txt"), "glob decoy\n"); - jj(repoRoot, "commit", "-m", "first"); - const firstCommitId = jj(repoRoot, "log", "--no-graph", "-r", "@-", "-T", "commit_id"); - - writeFileSync(join(repoRoot, filePath), "second revision\n"); - jj(repoRoot, "commit", "-m", "second"); - const secondCommitId = jj(repoRoot, "log", "--no-graph", "-r", "@-", "-T", "commit_id"); + // Repository setup plus seven jj launches can exceed Bun's 5s default on Windows runners. + jjTest( + "reads exact commit contents through `jj file show`", + async () => { + const repoRoot = createTempJjRepo("hunk-source-jj-"); + const filePath = "-note [exact] file.txt"; + writeFileSync(join(repoRoot, filePath), "first revision\n"); + writeFileSync(join(repoRoot, "-note e file.txt"), "glob decoy\n"); + jj(repoRoot, "commit", "-m", "first"); + const firstCommitId = jj(repoRoot, "log", "--no-graph", "-r", "@-", "-T", "commit_id"); + + writeFileSync(join(repoRoot, filePath), "second revision\n"); + jj(repoRoot, "commit", "-m", "second"); + const secondCommitId = jj(repoRoot, "log", "--no-graph", "-r", "@-", "-T", "commit_id"); - await expect( - readJjFileSource({ repoRoot, commitId: firstCommitId, path: filePath }), - ).resolves.toBe("first revision\n"); - await expect( - readJjFileSource({ repoRoot, commitId: secondCommitId, path: filePath }), - ).resolves.toBe("second revision\n"); - }); + await expect( + readJjFileSource({ repoRoot, commitId: firstCommitId, path: filePath }), + ).resolves.toBe("first revision\n"); + await expect( + readJjFileSource({ repoRoot, commitId: secondCommitId, path: filePath }), + ).resolves.toBe("second revision\n"); + }, + process.platform === "win32" ? 20_000 : 5_000, + ); unixJjTest("reads Unix filenames containing backslashes", async () => { const repoRoot = createTempJjRepo("hunk-source-jj-backslash-"); diff --git a/test/pty/comment-target.test.ts b/test/pty/comment-target.test.ts index a7719fbd3..bc301f11a 100644 --- a/test/pty/comment-target.test.ts +++ b/test/pty/comment-target.test.ts @@ -36,7 +36,13 @@ describe("PTY comment target", () => { const beforeClick = rowCellBackgrounds(session, 6).slice(12, 40); session.writeRaw("\x1b[<0;9;7M"); session.writeRaw("\x1b[<0;9;7m"); - await session.waitIdle({ timeout: 300 }); + // Output idleness can precede the click's committed paint on a busy runner. + await harness.waitForSnapshot( + session, + () => + JSON.stringify(rowCellBackgrounds(session, 6).slice(12, 40)) !== + JSON.stringify(beforeClick), + ); const clickedPaint = rowCellBackgrounds(session, 6).slice(12, 40); expect(clickedPaint).not.toEqual(beforeClick); From 6e81bafbf73bde2de2793b7b25d32e4e05ddeaec Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 12 Oct 2026 22:23:51 -0400 Subject: [PATCH 7/7] docs: sync package README with canonical root copy --- packages/hunk/README.md | 96 ++++++++--------------------------------- 1 file changed, 19 insertions(+), 77 deletions(-) diff --git a/packages/hunk/README.md b/packages/hunk/README.md index 6f0d28b64..2a866c0ac 100644 --- a/packages/hunk/README.md +++ b/packages/hunk/README.md @@ -1,6 +1,6 @@ # hunk -Hunk is a review-first terminal diff viewer for agent-authored changesets, built on [OpenTUI](https://github.com/anomalyco/opentui) and [Pierre diffs](https://www.npmjs.com/package/@pierre/diffs). +Hunk is an interactive terminal diff viewer for reviewing code changes, built on [OpenTUI](https://github.com/anomalyco/opentui) and [Pierre diffs](https://www.npmjs.com/package/@pierre/diffs). **[hunk.dev](https://hunk.dev)** · [Documentation](https://hunk.dev/docs/) @@ -32,7 +32,7 @@ Hunk is a review-first terminal diff viewer for agent-authored changesets, built ## Install -The default installation method on macOS and Linux downloads a standalone binary and installs it into `~/.hunk`. It checks the archive against the release checksum when both `SHA256SUMS` and a supported checksum tool are available, and warns otherwise. Release discovery uses Hunk's anonymous aggregate endpoint with direct GitHub fallback: +On macOS and Linux, install the standalone binary: ```bash curl -fsSL https://hunk.dev/install.sh | sh @@ -60,7 +60,7 @@ Requirements: ### Update Hunk -Starting with Hunk 0.20, npm, Homebrew, and default install-script installs use Hunk’s canonical update command: +For npm, Homebrew, and default install-script installs (Hunk 0.20+): ```bash hunk update # install the newest release @@ -79,7 +79,7 @@ hunk --version # print the installed version ### Working with Git -Hunk mirrors Git's diff-style commands, but opens the changeset in a review UI instead of plain text. +Use familiar Git commands to open changes in Hunk: ```bash hunk diff # review current repo changes, including untracked files @@ -88,29 +88,14 @@ hunk show # review the latest commit hunk show HEAD~1 # review an earlier commit hunk log # browse history on a terminal; print when redirected hunk log --static # force static output, paging when needed -hunk status # live workspace compass and sibling worktrees (Git) +hunk status # view workspace status and sibling worktrees (Git) hunk status --static # print one read-only workspace snapshot hunk status --json # emit one versioned workspace snapshot ``` -`hunk status` uses provider-owned read-only workspace facts; no fetch or repository mutation is performed. -See [workspace status](docs/status.md) for navigation, live refresh, JSON and fetch-age provenance. - -`hunk log` is one auto-responsive, read-only history surface, not a repository manager. On a -terminal it opens the desktop history browser; pipes and redirects receive shell-native static -records automatically, and `--static` forces static output that pages only when needed. The selected VCS adapter -owns traversal, filtering, refs, and how a history item opens for review; the bundled Git and -Jujutsu adapters both implement that public capability. Static output keeps full commit, author, -date, message, branch/bookmark, remote, and tag details; `--oneline` provides compact records, and -`--theme` uses the same palette as Hunk review. Interactive history groups commits by local-calendar -day with account-like author handles and relative times, while keeping commit ids right-aligned and -clickable. Enable **Graph view** from the View menu to replace day groups with commit-topology lanes. -Press `v` and move with `Up`/`Down` or `k`/`j` to select a contiguous range. `Shift+Up`/`Shift+Down`, uppercase `K`/`J`, and Shift-click extend directly. `Escape` collapses the selection; opening the range -reviews the inclusive cumulative change from the oldest commit's parent through the newest commit. -History also shares review's `b`/`f` full-page and `u`/`d` half-page movement keys. -Range selection is disabled with `--all` or author, message, date, and path filters because traversal can interleave or hide commits. -After opening a commit or range, quit its normal Hunk review to return to the same selection. -History controls are configurable through canonical `hunk.history.*` [keybindings](https://hunk.dev/docs/configure/keybindings/). +`hunk status` is read-only: it does not fetch or change the repository. See [workspace status](docs/status.md) for details. + +`hunk log` opens an interactive history browser in a terminal and prints text when piped or redirected. Use `--oneline` for compact output. In the browser, enable **Graph view** from the View menu to see commit branches, or press `v` to select a commit range. Quit a commit review to return to history. ### Working with GitHub @@ -140,11 +125,7 @@ hunk open README.md # view a complete, syntax-highlighted document hunk open path/to/project # browse another directory without requiring a repository ``` -The read-only browser loads directories and documents lazily and refreshes watched content. -Use arrows and Enter to navigate the tree, Tab to switch focus, `i` to show hidden/Git-ignored -entries, and `e` to open the displayed file in `$EDITOR`. Themes, menus, line numbers, and wrapping -use Hunk's normal controls. Symlinks are listed but not followed; binary, unreadable, missing, -and oversized files (over 1 MiB) show placeholders. See [the open workflow](docs/open.md) for details. +Use arrows and Enter to navigate, Tab to switch focus, `i` to show hidden/Git-ignored entries, and `e` to edit the displayed file in `$EDITOR`. See [the open workflow](docs/open.md) for details and file limits. ### Working with raw files and patches @@ -154,8 +135,6 @@ hunk diff --files before.ts after.ts --watch # auto-reload when either file chan git diff --no-color | hunk patch - # review a patch from stdin ``` -Watch mode remains continuous. Direct-file and Git-backed reviews normally use filesystem observation to refresh promptly, with periodic polling retained as a fallback for missed events or unavailable watchers. Jujutsu and Sapling reviews currently use polling rather than filesystem observation. - ### Working with agents 1. Open Hunk in another terminal with `hunk diff` or `hunk show`. @@ -168,7 +147,7 @@ A good generic prompt is: Load the Hunk skill and use it for this review. Run `hunk skill path` to get the skill path. ``` -For the full live-session and `--agent-context` workflow guide, see [the agent workflow guide](https://github.com/modem-dev/hunk/blob/main/docs/agent-workflows.md). Experimental rich STML note bodies require starting the review with `--experimental`; plain agent notes remain the default. +See [the agent workflow guide](docs/agent-workflows.md) for live sessions, `--agent-context`, and experimental rich notes. ## Feature comparison @@ -184,8 +163,6 @@ For the full live-session and `--agent-context` workflow guide, see [the agent w | Structural diffing | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | | Pager-compatible mode | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | -Hunk is optimized for reviewing a full changeset interactively. - ## Advanced ### Config @@ -200,50 +177,20 @@ absolute, or `~/.local/state/hunk/state.json` otherwise. Hunk preserves existing state by copying the old config-directory file on first use, keeping the original and falling back to it if migration fails. Global extension paths are unchanged. -Example: +For example: ```toml -theme = "terminal" # terminal, auto, any built-in theme id, or custom -mode = "auto" # auto, split, unified -vcs = "git" # git, jj, sl -watch = false -exclude_untracked = false +theme = "terminal" +mode = "auto" # auto, split, unified line_numbers = true -tab_width = 4 # tab stops, 1-16 -file_gap = 1 # rows between files, including the ─ rule; 0 hides it -hunk_gap = 0 # blank rows before later hunks -wheel_scroll_lines = "auto" # auto acceleration, or a fixed 1-10 rows per event -scroll_off = 5 # rows the current line keeps clear of the viewport edge while stepping: 0-40 wrap_lines = false -menu_bar = true -animations = true -sidebar = "auto" # "auto", true, false -agent_notes = false -prompt_save_view_preferences = true -transparent_background = false ``` -Choose a built-in theme, `auto`, or a custom theme with `theme`. See -[the theme guide](https://hunk.dev/docs/configure/themes/) for automatic selection, custom theme tables, -syntax scopes, and legacy syntax-table migration. - -`exclude_untracked` affects Git/Sapling working-tree `hunk diff` sessions only. -`tab_width` controls source-code tab stops and can be overridden with `-x4` or `--tab-width 4`. -`file_gap` is separator height between files, including the `─` rule; `hunk_gap` is blank rows before later hunks. -`wheel_scroll_lines` is a user-only preference and can be overridden with `--wheel-scroll-lines 3`. -`scroll_off` mirrors Vim's `scrolloff`: it keeps the current line clear of the viewport edge -while stepping through a diff. It defaults to 5 rows; use `--scroll-off 0` to disable the margin. -Set `animations = false` to make panes open and close immediately. -`prompt_save_view_preferences = false` disables the quit prompt for saving changed view preferences. -`transparent_background` can also be written as `transparentBackground`. +See the [config reference](https://hunk.dev/docs/reference/config/) for all options and defaults, or the [theme guide](https://hunk.dev/docs/configure/themes/) to customize colors. ### Keybindings -Every keyboard shortcut is a named command, and a `[keybindings]` table in your -user config remaps command ids to the keys you want them on — several keys per -command, exclusive claims over defaults, and `false` to unbind. See -[the keybinding guide](https://hunk.dev/docs/configure/keybindings/) for the rules, the chord grammar, -and the full table of built-in commands and their default keys. +Remap shortcuts with a `[keybindings]` table in your user config. See [the keybinding guide](https://hunk.dev/docs/configure/keybindings/) for available commands, default keys, and examples. ### Git integration @@ -300,11 +247,7 @@ repository's `.hunk/extensions/` (after you explicitly trust that repository), and from `--extension ` for development. `--no-extensions` turns those off for one run; Hunk's own bundled backends (Git, Jujutsu, and Sapling) stay loaded. -An extension can add generic top-level CLI workflows, contribute themes and -file-extension → language mappings, add a VCS backend, rewrite the changeset -before review (collapse lockfiles, reorder files by review priority), replace -the file-navigation sidebar with its own React component, react to lifecycle -events, and show transient messages: +Extensions can add commands, themes, VCS backends, custom sidebars, and more. For example, filter lockfiles out of a review: ```ts // ~/.config/hunk/extensions/collapse-lockfiles.ts @@ -332,11 +275,10 @@ Browse community extensions at publish yours by pushing the extension to a repository root and adding that topic. -See [the extension guide](https://hunk.dev/docs/extend/extensions/) for the full API, the trust model, -publishing guidance, and the `[extensions]` / `[extension.]` config reference. +See [the extension guide](https://hunk.dev/docs/extend/extensions/) for the API, configuration, and publishing instructions. Installable examples include [review triage](https://github.com/modem-dev/hunk/tree/main/examples/extensions/review-triage), -[authoritative review snapshot export](https://github.com/modem-dev/hunk/tree/main/examples/extensions/review-snapshot-export), an optional +[review snapshot export](https://github.com/modem-dev/hunk/tree/main/examples/extensions/review-snapshot-export), an optional [rendered Markdown file view](https://github.com/modem-dev/hunk/tree/main/examples/extensions/rendered-markdown), and a [Vim navigation mode](https://github.com/modem-dev/hunk/tree/main/examples/extensions/vim-navigation) built from public semantic commands. @@ -356,7 +298,7 @@ Each example includes the exact command to run from the repository root. 💬 _Chat with users/contributors on the [Modem Discord server](https://discord.gg/WZFjaP6Gt8)_ -For source setup, tests, packaging checks, and repo architecture, see [the contribution guide](https://github.com/modem-dev/hunk/blob/main/CONTRIBUTING.md). +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and contribution guidelines. ## Sponsor