diff --git a/.changeset/quiet-states-rest.md b/.changeset/quiet-states-rest.md new file mode 100644 index 000000000..b2047791e --- /dev/null +++ b/.changeset/quiet-states-rest.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +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 a4517b881..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 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/packages/hunk/README.md b/packages/hunk/README.md index b96d6a828..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 @@ -195,50 +172,25 @@ You can persist preferences to a config file: - `~/.config/hunk/config.toml` - `.hunk/config.toml` -Example: +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 -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 @@ -295,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 @@ -327,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. @@ -351,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 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 7ff74b478..1552b8a3c 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,32 @@ 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(tmpdir(), "xdg-config"), + XDG_STATE_HOME: join(tmpdir(), "xdg-state"), + } as NodeJS.ProcessEnv; + + 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 XDG state while keeping extensions in the config directory", () => { + const env = { HOME: join(tmpdir(), "home") } as NodeJS.ProcessEnv; + + 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(tmpdir(), "home"), + XDG_STATE_HOME: join("relative", "state"), + } 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(env.HOME!, ".local", "state", "hunk", "state.json")); }); test("falls back to HOME for config and state paths", () => { @@ -29,7 +52,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 +64,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..c5ef3b6b0 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"; /** @@ -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 && isAbsolute(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,8 +61,8 @@ 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. */ diff --git a/packages/hunk/src/extensions/trust.test.ts b/packages/hunk/src/extensions/trust.test.ts index 88f6502fe..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"); @@ -145,7 +159,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..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); } /** @@ -95,7 +96,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/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/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); 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/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/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/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`**