From 5c284229fd4e2833f7fa726c77c72429f659a6ed Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 6 Sep 2026 00:16:44 -0400 Subject: [PATCH] refactor: extract bundled Git provider --- .changeset/large-showers-heal.md | 2 + .dependency-cruiser.cjs | 18 ++++++- bun.lock | 11 ++++ knip.json | 4 ++ nix/bun.lock.nix | 1 + package.json | 1 + packages/hunk-git/package.json | 26 +++++++++ .../vcs/git => hunk-git/src}/commands.test.ts | 0 .../vcs/git => hunk-git/src}/commands.ts | 13 +++-- .../vcs/git => hunk-git/src}/history.test.ts | 0 .../vcs/git => hunk-git/src}/history.ts | 0 .../vcs/git => hunk-git/src}/index.test.ts | 0 .../default/vcs/git => hunk-git/src}/index.ts | 0 .../vcs/git => hunk-git/src}/source.test.ts | 0 .../vcs/git => hunk-git/src}/source.ts | 0 .../hunk/src/core/changeset/loaders.test.ts | 2 +- .../hunk/src/extensions/default/vcs/index.ts | 2 +- scripts/hunk-git-package.test.ts | 54 +++++++++++++++++++ scripts/source-boundaries.test.ts | 33 +++++++----- tsconfig.json | 1 + 20 files changed, 145 insertions(+), 23 deletions(-) create mode 100644 .changeset/large-showers-heal.md create mode 100644 packages/hunk-git/package.json rename packages/{hunk/src/extensions/default/vcs/git => hunk-git/src}/commands.test.ts (100%) rename packages/{hunk/src/extensions/default/vcs/git => hunk-git/src}/commands.ts (98%) rename packages/{hunk/src/extensions/default/vcs/git => hunk-git/src}/history.test.ts (100%) rename packages/{hunk/src/extensions/default/vcs/git => hunk-git/src}/history.ts (100%) rename packages/{hunk/src/extensions/default/vcs/git => hunk-git/src}/index.test.ts (100%) rename packages/{hunk/src/extensions/default/vcs/git => hunk-git/src}/index.ts (100%) rename packages/{hunk/src/extensions/default/vcs/git => hunk-git/src}/source.test.ts (100%) rename packages/{hunk/src/extensions/default/vcs/git => hunk-git/src}/source.ts (100%) create mode 100644 scripts/hunk-git-package.test.ts diff --git a/.changeset/large-showers-heal.md b/.changeset/large-showers-heal.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/large-showers-heal.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 4385874e9..186f65780 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -71,6 +71,17 @@ module.exports = { from: { path: "^packages/hunk-vcs/src/" }, to: { path: "^packages/", pathNot: "^packages/hunk-vcs/src/" }, }, + { + name: "hunk-git-stays-on-vcs-contract", + comment: + "@hunk/git owns the Git provider and may reach only its local modules, the public extension contract, and explicit dependency-bottom @hunk/vcs leaves.", + severity: "error", + from: { path: "^packages/hunk-git/src/" }, + to: { + path: "^packages/", + pathNot: "^packages/(hunk-git|hunk-vcs)/src/|^packages/hunk/src/extension-api/", + }, + }, { name: "lib-is-a-leaf", comment: @@ -177,9 +188,12 @@ module.exports = { { name: "packages-stay-standalone", comment: - "Workspace packages are standalone publishable units; they never import the app source tree.", + "Workspace packages are standalone units; bundled providers are governed by their narrower public-contract rules above, while other packages never import the app source tree.", severity: "error", - from: { path: "^packages/(?!hunk/)" }, + from: { + path: "^packages/(?!hunk/)", + pathNot: "^packages/hunk-git/", + }, to: { path: "^packages/hunk/src/" }, }, ], diff --git a/bun.lock b/bun.lock index 21e11d221..d3e7e8a38 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "hunk", "devDependencies": { "@changesets/changelog-github": "^0.7.0", + "@hunk/git": "workspace:*", "@hunk/session-broker": "workspace:*", "@hunk/session-broker-bun": "workspace:*", "@hunk/session-broker-core": "workspace:*", @@ -70,6 +71,14 @@ "@pierre/diffs", ], }, + "packages/hunk-git": { + "name": "@hunk/git", + "version": "0.0.0", + "dependencies": { + "@hunk/vcs": "workspace:*", + "hunkdiff": "workspace:*", + }, + }, "packages/hunk-vcs": { "name": "@hunk/vcs", "version": "0.0.0", @@ -129,6 +138,8 @@ "@hono/node-ws": ["@hono/node-ws@1.3.1", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.11", "hono": "^4.6.0" } }, "sha512-vo/MwCnpJAVHBkGzWjCJ28wF45fYHAfbPZcH2rodZODHtch2GHA94KtMfusmVycTUtsLAsaNsHhtY6P8X3RQsA=="], + "@hunk/git": ["@hunk/git@workspace:packages/hunk-git"], + "@hunk/session-broker": ["@hunk/session-broker@workspace:packages/session-broker"], "@hunk/session-broker-bun": ["@hunk/session-broker-bun@workspace:packages/session-broker-bun"], diff --git a/knip.json b/knip.json index aa80b9ce8..7e1dde9f4 100644 --- a/knip.json +++ b/knip.json @@ -38,6 +38,10 @@ "project": ["src/**/*.{ts,tsx}", "bin/**/*.cjs"], "ignoreExportsUsedInFile": true }, + "packages/hunk-git": { + "entry": ["src/**/*.test.ts"], + "project": ["src/**/*.ts"] + }, "packages/hunk-vcs": { "entry": ["src/**/*.test.ts"], "project": ["src/**/*.ts"] diff --git a/nix/bun.lock.nix b/nix/bun.lock.nix index b76898128..0a4348a83 100644 --- a/nix/bun.lock.nix +++ b/nix/bun.lock.nix @@ -53,6 +53,7 @@ url = "https://registry.npmjs.org/@hono/node-ws/-/node-ws-1.3.1.tgz"; hash = "sha512-vo/MwCnpJAVHBkGzWjCJ28wF45fYHAfbPZcH2rodZODHtch2GHA94KtMfusmVycTUtsLAsaNsHhtY6P8X3RQsA=="; }; + "@hunk/git" = copyPathToStore ../packages/hunk-git; "@hunk/session-broker" = copyPathToStore ../packages/session-broker; "@hunk/session-broker-bun" = copyPathToStore ../packages/session-broker-bun; "@hunk/session-broker-core" = copyPathToStore ../packages/session-broker-core; diff --git a/package.json b/package.json index f745f682e..0f946bde7 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ }, "devDependencies": { "@changesets/changelog-github": "^0.7.0", + "@hunk/git": "workspace:*", "@hunk/session-broker": "workspace:*", "@hunk/session-broker-bun": "workspace:*", "@hunk/session-broker-core": "workspace:*", diff --git a/packages/hunk-git/package.json b/packages/hunk-git/package.json new file mode 100644 index 000000000..92b7604b3 --- /dev/null +++ b/packages/hunk-git/package.json @@ -0,0 +1,26 @@ +{ + "name": "@hunk/git", + "version": "0.0.0", + "private": true, + "description": "Private statically bundled Git provider for Hunk.", + "license": "MIT", + "files": [ + "src" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "dependencies": { + "@hunk/vcs": "workspace:*", + "hunkdiff": "workspace:*" + }, + "engines": { + "bun": ">=1.3.14", + "node": ">=22" + } +} diff --git a/packages/hunk/src/extensions/default/vcs/git/commands.test.ts b/packages/hunk-git/src/commands.test.ts similarity index 100% rename from packages/hunk/src/extensions/default/vcs/git/commands.test.ts rename to packages/hunk-git/src/commands.test.ts diff --git a/packages/hunk/src/extensions/default/vcs/git/commands.ts b/packages/hunk-git/src/commands.ts similarity index 98% rename from packages/hunk/src/extensions/default/vcs/git/commands.ts rename to packages/hunk-git/src/commands.ts index 93135098d..10625ba89 100644 --- a/packages/hunk/src/extensions/default/vcs/git/commands.ts +++ b/packages/hunk-git/src/commands.ts @@ -14,9 +14,8 @@ import { runAbortableCommand } from "@hunk/vcs/async-process"; /** * Every Git command Hunk runs, and the failures they translate into. * - * This is the implementation layer behind the bundled Git backend - * (`packages/hunk/src/extensions/default/vcs/git/`), so nothing here reaches into core, the - * diff engine, or the adapter registry — user-facing failures are raised as the + * This is the implementation layer behind the bundled Git backend. Nothing here + * reaches into core, the diff engine, or the adapter registry — user-facing failures are raised as the * published `HunkExtensionUserError`, which is exactly what a third-party * backend would throw. */ @@ -51,7 +50,7 @@ export interface GitColorMovedOptions { } /** Append Git pathspec arguments only when the caller requested them. */ -export function appendGitPathspecs(args: string[], pathspecs?: string[]) { +function appendGitPathspecs(args: string[], pathspecs?: string[]) { if (!pathspecs || pathspecs.length === 0) { return; } @@ -65,7 +64,7 @@ export function appendGitPathspecs(args: string[], pathspecs?: string[]) { * example `--output=`) that Git would parse as a flag; it fails closed here instead * of reaching the spawned command. */ -export function requireGitRevisionArg(input: GitBackedInput, value: string) { +function requireGitRevisionArg(input: GitBackedInput, value: string) { if (value.length === 0) { throw new HunkExtensionUserError( `\`${formatGitCommandLabel(input)}\` refused an empty revision.`, @@ -306,7 +305,7 @@ export function buildGitStashShowArgs( return withNormalizedDiffPrefixes(withGitMovedLineColorConfig(args, colorMoved)); } -export function formatGitCommandLabel(input: GitBackedInput) { +function formatGitCommandLabel(input: GitBackedInput) { switch (input.kind) { case "vcs": { if (input.staged) { @@ -869,7 +868,7 @@ export function resolveGitRepoRoot( } /** Resolve one commit-ish ref to the exact commit object used for later blob reads. */ -export function resolveGitCommitRef( +function resolveGitCommitRef( input: GitBackedInput, ref: string, options: Omit = {}, diff --git a/packages/hunk/src/extensions/default/vcs/git/history.test.ts b/packages/hunk-git/src/history.test.ts similarity index 100% rename from packages/hunk/src/extensions/default/vcs/git/history.test.ts rename to packages/hunk-git/src/history.test.ts diff --git a/packages/hunk/src/extensions/default/vcs/git/history.ts b/packages/hunk-git/src/history.ts similarity index 100% rename from packages/hunk/src/extensions/default/vcs/git/history.ts rename to packages/hunk-git/src/history.ts diff --git a/packages/hunk/src/extensions/default/vcs/git/index.test.ts b/packages/hunk-git/src/index.test.ts similarity index 100% rename from packages/hunk/src/extensions/default/vcs/git/index.test.ts rename to packages/hunk-git/src/index.test.ts diff --git a/packages/hunk/src/extensions/default/vcs/git/index.ts b/packages/hunk-git/src/index.ts similarity index 100% rename from packages/hunk/src/extensions/default/vcs/git/index.ts rename to packages/hunk-git/src/index.ts diff --git a/packages/hunk/src/extensions/default/vcs/git/source.test.ts b/packages/hunk-git/src/source.test.ts similarity index 100% rename from packages/hunk/src/extensions/default/vcs/git/source.test.ts rename to packages/hunk-git/src/source.test.ts diff --git a/packages/hunk/src/extensions/default/vcs/git/source.ts b/packages/hunk-git/src/source.ts similarity index 100% rename from packages/hunk/src/extensions/default/vcs/git/source.ts rename to packages/hunk-git/src/source.ts diff --git a/packages/hunk/src/core/changeset/loaders.test.ts b/packages/hunk/src/core/changeset/loaders.test.ts index adf10ce2d..380ac4ba4 100644 --- a/packages/hunk/src/core/changeset/loaders.test.ts +++ b/packages/hunk/src/core/changeset/loaders.test.ts @@ -13,7 +13,7 @@ import { join } from "node:path"; import { replaceExtensionFileLanguages } from "./fileLanguage"; import { SourceTextTooLargeError } from "./fileSource"; import { getBundledVcsCatalog } from "../../app/vcsCatalog"; -import { createGitVcsAdapter } from "../../extensions/default/vcs/git"; +import { createGitVcsAdapter } from "@hunk/git"; import { toInternalVcsAdapter } from "../../extensions/runExtension"; import { createVcsCatalog } from "../vcs"; import { loadAppBootstrap as loadCoreAppBootstrap, type LoadAppBootstrapOptions } from "./loaders"; diff --git a/packages/hunk/src/extensions/default/vcs/index.ts b/packages/hunk/src/extensions/default/vcs/index.ts index 7cdc221ce..494032ca6 100644 --- a/packages/hunk/src/extensions/default/vcs/index.ts +++ b/packages/hunk/src/extensions/default/vcs/index.ts @@ -1,4 +1,4 @@ -import gitExtension from "./git"; +import gitExtension from "@hunk/git"; import jjExtension from "./jujutsu"; import slExtension from "./sapling"; import { runExtensionFactory } from "../../runExtension"; diff --git a/scripts/hunk-git-package.test.ts b/scripts/hunk-git-package.test.ts new file mode 100644 index 000000000..77ab0799b --- /dev/null +++ b/scripts/hunk-git-package.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import gitExtension, { GitVcsAdapter } from "@hunk/git"; +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const REPO_ROOT = resolve(import.meta.dir, ".."); +const PACKAGE_ROOT = join(REPO_ROOT, "packages", "hunk-git"); + +describe("@hunk/git package boundary", () => { + test("exports only the explicit Git provider entry", () => { + const manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")) as { + name: string; + private: boolean; + files: string[]; + exports: Record; + dependencies: Record; + }; + + expect(manifest.name).toBe("@hunk/git"); + expect(manifest.private).toBe(true); + expect(manifest.files).toEqual(["src"]); + expect(manifest.exports).toEqual({ + ".": { + types: "./src/index.ts", + import: "./src/index.ts", + }, + }); + expect(manifest.dependencies).toEqual({ + "@hunk/vcs": "workspace:*", + hunkdiff: "workspace:*", + }); + }); + + test("registers the private workspace without leaking it into hunkdiff", () => { + const rootManifest = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8")) as { + devDependencies: Record; + }; + const hunkManifest = readFileSync(join(REPO_ROOT, "packages", "hunk", "package.json"), "utf8"); + const bunLock = readFileSync(join(REPO_ROOT, "bun.lock"), "utf8"); + const nixLock = readFileSync(join(REPO_ROOT, "nix", "bun.lock.nix"), "utf8"); + + expect(rootManifest.devDependencies["@hunk/git"]).toBe("workspace:*"); + expect(hunkManifest).not.toContain("@hunk/git"); + expect(hunkManifest).not.toContain("workspace:"); + expect(bunLock).toContain('"packages/hunk-git": {'); + expect(bunLock).toContain('"@hunk/git@workspace:packages/hunk-git"'); + expect(nixLock).toContain('"@hunk/git" = copyPathToStore ../packages/hunk-git;'); + }); + + test("loads its provider entrypoint through the workspace", () => { + expect(typeof gitExtension).toBe("function"); + expect(GitVcsAdapter.id).toBe("git"); + }); +}); diff --git a/scripts/source-boundaries.test.ts b/scripts/source-boundaries.test.ts index 3a38c472d..64ba69643 100644 --- a/scripts/source-boundaries.test.ts +++ b/scripts/source-boundaries.test.ts @@ -7,6 +7,7 @@ const SRC_ROOT = join(REPO_ROOT, "packages", "hunk", "src"); const CORE_ROOT = join(SRC_ROOT, "core"); const EXTENSIONS_ROOT = join(SRC_ROOT, "extensions"); const BUNDLED_PROVIDER_ROOT = join(EXTENSIONS_ROOT, "default", "vcs"); +const GIT_PACKAGE_ROOT = join(REPO_ROOT, "packages", "hunk-git", "src"); const VCS_PACKAGE_ROOT = join(REPO_ROOT, "packages", "hunk-vcs", "src"); const REVIEW_MODEL_ROOT = join(CORE_ROOT, "review"); // The published extension contract, which the review model may name for the annotation shapes @@ -166,18 +167,25 @@ function unexpectedProviderImports() { "@hunk/vcs/path", "@hunk/vcs/source", ]); - return sourceFiles(BUNDLED_PROVIDER_ROOT).flatMap((path) => - importSpecifiers(path) - // The repository-wide lightweight scanner can also match prose ending in `from "…"`; - // real module specifiers never contain whitespace. - .filter((specifier) => !/\s/.test(specifier)) - .filter( - (specifier) => - !specifier.startsWith(".") && - !specifier.startsWith("node:") && - !allowedImports.has(specifier), - ) - .map((specifier) => `${repoPath(path)} -> ${specifier}`), + return [BUNDLED_PROVIDER_ROOT, GIT_PACKAGE_ROOT].flatMap((providerRoot) => + sourceFiles(providerRoot).flatMap((path) => + importSpecifiers(path) + // The lightweight scanner can match prose ending in `from "…"`; module specifiers + // never contain whitespace. + .filter((specifier) => !/\s/.test(specifier)) + .filter((specifier) => { + if ( + specifier.startsWith(".") || + specifier.startsWith("node:") || + specifier.startsWith("bun:") || + allowedImports.has(specifier) + ) { + return false; + } + return !(path === join(BUNDLED_PROVIDER_ROOT, "index.ts") && specifier === "@hunk/git"); + }) + .map((specifier) => `${repoPath(path)} -> ${specifier}`), + ), ); } @@ -303,6 +311,7 @@ describe("source architecture boundaries", () => { test("keeps bundled providers on the public contract and explicit VCS helper leaves", () => { expect(forbiddenImports(BUNDLED_PROVIDER_ROOT, CORE_ROOT)).toEqual([]); + expect(escapingImports(GIT_PACKAGE_ROOT, [GIT_PACKAGE_ROOT])).toEqual([]); expect(unexpectedProviderImports()).toEqual([]); }); diff --git a/tsconfig.json b/tsconfig.json index 74ca87e2a..f6abe2bba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "types": ["bun", "react"], "baseUrl": ".", "paths": { + "@hunk/git": ["packages/hunk-git/src/index.ts"], "@hunk/session-broker": ["packages/session-broker/src/index.ts"], "@hunk/session-broker-bun": ["packages/session-broker-bun/src/index.ts"], "@hunk/session-broker-core": ["packages/session-broker-core/src/index.ts"],