@xuhaojun/githunk 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/githunk.js CHANGED
@@ -1,23 +1,95 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // githunk launcher: prefer the prebuilt standalone binary installed as an
4
+ // optional platform package, and fall back to the Node bundle otherwise.
5
+ //
6
+ // npm installs only the optional dependency whose `os`/`cpu` matches this
7
+ // machine, so the binary usually lives at
8
+ // `<install-root>/node_modules/@xuhaojun/githunk-<os>-<arch>/bin/githunk`.
9
+ // The lookup walks up from this file (the hunk `findInstalledBinary` shape in
10
+ // `learn-projects/hunk/bin/hunk.cjs`), which keeps working no matter which
11
+ // Node version owns the invoking shell: the prebuilt binary embeds its own
12
+ // Bun runtime and takes no `--experimental-ffi` flag. `$GITHUNK_BIN_PATH`
13
+ // overrides everything for debugging and custom installs.
14
+
3
15
  import { spawn } from "node:child_process"
16
+ import { existsSync, realpathSync } from "node:fs"
4
17
  import { constants } from "node:os"
18
+ import { dirname, join } from "node:path"
5
19
  import { fileURLToPath } from "node:url"
6
20
 
7
- const cliPath = fileURLToPath(new URL("../dist/githunk.js", import.meta.url))
8
- const child = spawn(process.execPath, ["--experimental-ffi", cliPath, ...process.argv.slice(2)], {
9
- stdio: "inherit",
10
- })
21
+ const PLATFORM_PACKAGES = [
22
+ { packageName: "@xuhaojun/githunk-darwin-arm64", binary: "bin/githunk" },
23
+ { packageName: "@xuhaojun/githunk-darwin-x64", binary: "bin/githunk" },
24
+ { packageName: "@xuhaojun/githunk-linux-arm64", binary: "bin/githunk" },
25
+ { packageName: "@xuhaojun/githunk-linux-x64", binary: "bin/githunk" },
26
+ { packageName: "@xuhaojun/githunk-windows-x64", binary: "bin/githunk.exe" },
27
+ ]
28
+
29
+ function hostCandidates() {
30
+ const platformMap = {
31
+ darwin: "darwin",
32
+ linux: "linux",
33
+ win32: "windows",
34
+ }
35
+ const archMap = {
36
+ x64: "x64",
37
+ arm64: "arm64",
38
+ }
39
+ const platform = platformMap[process.platform]
40
+ const arch = archMap[process.arch]
41
+ if (platform === undefined || arch === undefined) return []
42
+ return PLATFORM_PACKAGES.filter((candidate) => candidate.packageName === `@xuhaojun/githunk-${platform}-${arch}`)
43
+ }
44
+
45
+ /** Find the prebuilt binary installed next to this package, walking up past nested installs. */
46
+ function findInstalledBinary(startDir) {
47
+ let current = startDir
48
+ for (;;) {
49
+ const modulesDir = join(current, "node_modules")
50
+ for (const candidate of hostCandidates()) {
51
+ const resolved = join(modulesDir, ...candidate.packageName.split("/"), candidate.binary)
52
+ if (existsSync(resolved)) return resolved
53
+ }
54
+ const parent = dirname(current)
55
+ if (parent === current) return null
56
+ current = parent
57
+ }
58
+ }
59
+
60
+ function launch(target, args) {
61
+ const child = spawn(target, args, { stdio: "inherit" })
11
62
 
12
- for (const signal of ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"]) {
13
- process.once(signal, () => { child.kill(signal) })
63
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"]) {
64
+ process.once(signal, () => { child.kill(signal) })
65
+ }
66
+
67
+ child.once("error", (error) => {
68
+ process.stderr.write(`githunk: failed to start: ${error instanceof Error ? error.message : String(error)}\n`)
69
+ process.exitCode = 1
70
+ })
71
+ child.once("close", (exitCode, signal) => {
72
+ const signalNumber = signal === null ? undefined : constants.signals[signal]
73
+ process.exitCode = exitCode ?? (signalNumber === undefined ? 1 : 128 + signalNumber)
74
+ })
14
75
  }
15
76
 
16
- child.once("error", (error) => {
17
- process.stderr.write(`githunk: failed to start: ${error instanceof Error ? error.message : String(error)}\n`)
18
- process.exitCode = 1
19
- })
20
- child.once("close", (exitCode, signal) => {
21
- const signalNumber = signal === null ? undefined : constants.signals[signal]
22
- process.exitCode = exitCode ?? (signalNumber === undefined ? 1 : 128 + signalNumber)
23
- })
77
+ const forwardedArgs = process.argv.slice(2)
78
+ const overrideBinary = process.env.GITHUNK_BIN_PATH
79
+ if (typeof overrideBinary === "string" && overrideBinary !== "") {
80
+ launch(overrideBinary, forwardedArgs)
81
+ } else {
82
+ let scriptDir = dirname(fileURLToPath(import.meta.url))
83
+ try {
84
+ scriptDir = realpathSync(scriptDir)
85
+ } catch {
86
+ // Unresolved symlinks just start the walk from the literal directory.
87
+ }
88
+ const prebuiltBinary = findInstalledBinary(scriptDir)
89
+ if (prebuiltBinary !== null) {
90
+ launch(prebuiltBinary, forwardedArgs)
91
+ } else {
92
+ const cliPath = fileURLToPath(new URL("../dist/githunk.js", import.meta.url))
93
+ launch(process.execPath, ["--experimental-ffi", cliPath, ...forwardedArgs])
94
+ }
95
+ }
package/dist/githunk.js CHANGED
@@ -2,35 +2,86 @@
2
2
 
3
3
  // src/cli/args.ts
4
4
  import { Command, CommanderError } from "commander";
5
- import { readFileSync } from "node:fs";
6
- import { dirname, join } from "node:path";
7
- import { fileURLToPath } from "node:url";
8
- var cachedVersion;
9
- function getCliVersion() {
10
- if (cachedVersion !== undefined)
11
- return cachedVersion;
12
- let directory = dirname(fileURLToPath(import.meta.url));
13
- for (let level = 0;level < 4; level += 1) {
14
- try {
15
- const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
16
- if (manifest.name === "@xuhaojun/githunk" && typeof manifest.version === "string") {
17
- cachedVersion = manifest.version;
18
- return cachedVersion;
19
- }
20
- } catch {}
21
- const parent = dirname(directory);
22
- if (parent === directory)
23
- break;
24
- directory = parent;
5
+ // package.json
6
+ var package_default = {
7
+ name: "@xuhaojun/githunk",
8
+ version: "0.2.0",
9
+ description: "A review-first Git TUI combining lazygit's everyday Git workflow with focused hunk review.",
10
+ type: "module",
11
+ bin: {
12
+ githunk: "bin/githunk.js"
13
+ },
14
+ files: [
15
+ "bin",
16
+ "dist/githunk.js",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ scripts: {
21
+ start: "bun run src/main.ts",
22
+ dev: "bun --watch src/main.ts",
23
+ build: "bun build src/cli.ts --target=node --format=esm --packages=external --outfile=dist/githunk.js --banner '#!/usr/bin/env node'",
24
+ "build:bin": "bun run ./scripts/build-bin.ts",
25
+ "build:prebuilt:artifact": "bun run ./scripts/build-prebuilt-artifact.ts",
26
+ "stage:prebuilt:release": "bun run ./scripts/stage-prebuilt-npm.ts --artifact-root ./dist/release/artifacts",
27
+ "check:release-version": "bun run ./scripts/check-release-version.ts",
28
+ "check:prebuilt-pack": "bun run ./scripts/check-prebuilt-pack.ts",
29
+ "publish:prebuilt:npm": "bun run ./scripts/publish-prebuilt-npm.ts",
30
+ "smoke:prebuilt-install": "bun run ./scripts/smoke-prebuilt-install.ts",
31
+ prepack: "bun run build",
32
+ "pack:check": "npm pack --dry-run",
33
+ "spike:selection": "bun run spikes/selection/src/main.ts",
34
+ test: "bun test --path-ignore-patterns='learn-projects/**'",
35
+ typecheck: "tsc --noEmit",
36
+ check: "bun run typecheck && bun run test",
37
+ "bench:review-load": "bun run benchmarks/review-document-load.ts",
38
+ "bench:review-rows": "bun run benchmarks/review-row-plan.ts",
39
+ "bench:review-reconcile": "bun run benchmarks/review-reconcile.ts"
40
+ },
41
+ keywords: [
42
+ "git",
43
+ "tui",
44
+ "code-review",
45
+ "cli"
46
+ ],
47
+ homepage: "https://github.com/XuHaoJun/githunk#readme",
48
+ bugs: {
49
+ url: "https://github.com/XuHaoJun/githunk/issues"
50
+ },
51
+ repository: {
52
+ type: "git",
53
+ url: "git+https://github.com/XuHaoJun/githunk.git"
54
+ },
55
+ license: "MIT",
56
+ engines: {
57
+ node: ">=26.1.0"
58
+ },
59
+ packageManager: "bun@1.4.0",
60
+ publishConfig: {
61
+ access: "public"
62
+ },
63
+ dependencies: {
64
+ "@opentui/core": "0.5.10",
65
+ "@opentui/react": "0.5.10",
66
+ "@pierre/diffs": "1.3.5",
67
+ commander: "^15.0.0",
68
+ react: "19.2.8",
69
+ zod: "^4.4.3"
70
+ },
71
+ devDependencies: {
72
+ "@types/bun": "1.4.0",
73
+ "@types/react": "19.2.8",
74
+ typescript: "5.9.2"
25
75
  }
26
- cachedVersion = "0.0.0-dev";
27
- return cachedVersion;
28
- }
76
+ };
77
+
78
+ // src/cli/args.ts
79
+ var cliVersion = typeof package_default.version === "string" && package_default.version !== "" ? package_default.version : "0.0.0-dev";
29
80
  function parseCliArgs(argv) {
30
81
  let stdout = "";
31
82
  let stderr = "";
32
83
  const program = new Command;
33
- program.name("githunk").description("A review-first Git TUI combining lazygit's everyday Git workflow with focused hunk review.").version(getCliVersion(), "-V, --version", "output the version number").option("-p, --path <dir>", "path to the Git repository to open").argument("[path]", "path to the Git repository to open").exitOverride().configureOutput({
84
+ program.name("githunk").description("A review-first Git TUI combining lazygit's everyday Git workflow with focused hunk review.").version(cliVersion, "-V, --version", "output the version number").option("-p, --path <dir>", "path to the Git repository to open").argument("[path]", "path to the Git repository to open").exitOverride().configureOutput({
34
85
  writeOut: (text) => {
35
86
  stdout += text;
36
87
  },
@@ -978,7 +1029,7 @@ function fingerprintWorkingTreeFile(target, filePatch) {
978
1029
 
979
1030
  // src/storage/local-state-file.ts
980
1031
  import { mkdir, open, rename, stat, unlink, lstat, link, readFile } from "node:fs/promises";
981
- import { dirname as dirname2, isAbsolute, join as join2, resolve } from "node:path";
1032
+ import { dirname, isAbsolute, join, resolve } from "node:path";
982
1033
  import { randomUUID } from "node:crypto";
983
1034
  async function assertNoSymlinkInPath(path, pathKind) {
984
1035
  const absolute = resolve(path);
@@ -1008,7 +1059,7 @@ class LocalStateFile {
1008
1059
  this.pathKind = options.pathKind ?? "state";
1009
1060
  }
1010
1061
  get path() {
1011
- return this.resolvedPath ?? join2(this.runner.cwd, ".git", this.relativePath);
1062
+ return this.resolvedPath ?? join(this.runner.cwd, ".git", this.relativePath);
1012
1063
  }
1013
1064
  async resolvePath() {
1014
1065
  if (this.resolvedPath !== undefined)
@@ -1016,7 +1067,7 @@ class LocalStateFile {
1016
1067
  const output = (await this.runner.run(["rev-parse", "--git-path", this.relativePath], { readOnly: true })).stdout.trim();
1017
1068
  if (output.length === 0)
1018
1069
  throw new Error(`git returned an empty path for ${this.relativePath}`);
1019
- this.resolvedPath = isAbsolute(output) ? output : join2(this.runner.cwd, output);
1070
+ this.resolvedPath = isAbsolute(output) ? output : join(this.runner.cwd, output);
1020
1071
  return this.resolvedPath;
1021
1072
  }
1022
1073
  async readText() {
@@ -1033,7 +1084,7 @@ class LocalStateFile {
1033
1084
  async writeText(text) {
1034
1085
  const path = await this.resolvePath();
1035
1086
  await assertNoSymlinkInPath(path, this.pathKind);
1036
- await mkdir(dirname2(path), { recursive: true, mode: 448 });
1087
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
1037
1088
  const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
1038
1089
  await assertNoSymlinkInPath(temporary, this.pathKind);
1039
1090
  const handle = await open(temporary, "wx", 384);
@@ -1046,7 +1097,7 @@ class LocalStateFile {
1046
1097
  }
1047
1098
  await rename(temporary, path);
1048
1099
  try {
1049
- const directory = await open(dirname2(path), "r");
1100
+ const directory = await open(dirname(path), "r");
1050
1101
  try {
1051
1102
  await directory.sync();
1052
1103
  } finally {
@@ -1070,7 +1121,7 @@ class LocalStateFile {
1070
1121
  async createTextExclusive(text) {
1071
1122
  const path = await this.resolvePath();
1072
1123
  await assertNoSymlinkInPath(path, this.pathKind);
1073
- await mkdir(dirname2(path), { recursive: true, mode: 448 });
1124
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
1074
1125
  const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
1075
1126
  await assertNoSymlinkInPath(temporary, this.pathKind);
1076
1127
  const handle = await open(temporary, "wx", 384);
@@ -1099,7 +1150,7 @@ class LocalStateFile {
1099
1150
  return;
1100
1151
  });
1101
1152
  try {
1102
- const directory = await open(dirname2(path), "r");
1153
+ const directory = await open(dirname(path), "r");
1103
1154
  try {
1104
1155
  await directory.sync();
1105
1156
  } finally {
@@ -2134,7 +2185,7 @@ async function listReflog(runner, options = {}) {
2134
2185
 
2135
2186
  // src/git/worktrees.ts
2136
2187
  import { readFile as readFile2, stat as stat2 } from "node:fs/promises";
2137
- import { dirname as dirname3, join as join3 } from "node:path";
2188
+ import { dirname as dirname2, join as join2 } from "node:path";
2138
2189
  function finalizeEntry(entry) {
2139
2190
  return {
2140
2191
  path: entry.path,
@@ -2236,7 +2287,7 @@ async function resolveRepositoryPaths(runner) {
2236
2287
  const worktreeGitDirPath = lines[1] ?? "";
2237
2288
  const repoGitDirPath = lines[2] ?? "";
2238
2289
  const isSubmodule = (lines[3] ?? "").length > 0;
2239
- const repoPath = worktreeGitDirPath === repoGitDirPath || isSubmodule ? worktreePath : dirname3(repoGitDirPath);
2290
+ const repoPath = worktreeGitDirPath === repoGitDirPath || isSubmodule ? worktreePath : dirname2(repoGitDirPath);
2240
2291
  return { worktreePath, worktreeGitDirPath, repoPath, repoGitDirPath };
2241
2292
  }
2242
2293
  async function isPathMissing(path) {
@@ -2269,11 +2320,11 @@ async function readTrimmedFile(path) {
2269
2320
  }
2270
2321
  async function inProgressBranch(gitDir) {
2271
2322
  for (const directory of ["rebase-merge", "rebase-apply"]) {
2272
- const headName = await readTrimmedFile(join3(gitDir, directory, "head-name"));
2323
+ const headName = await readTrimmedFile(join2(gitDir, directory, "head-name"));
2273
2324
  if (headName !== undefined)
2274
2325
  return headName.replace(/^refs\/heads\//, "");
2275
2326
  }
2276
- return await readTrimmedFile(join3(gitDir, "BISECT_START"));
2327
+ return await readTrimmedFile(join2(gitDir, "BISECT_START"));
2277
2328
  }
2278
2329
  async function listWorktrees(runner) {
2279
2330
  const repositoryPaths = await resolveRepositoryPaths(runner);
@@ -2340,7 +2391,7 @@ function worktreeRemovalRequiresForce(error) {
2340
2391
 
2341
2392
  // src/git/submodules.ts
2342
2393
  import { readFile as readFile3 } from "node:fs/promises";
2343
- import { join as join4, resolve as resolve2 } from "node:path";
2394
+ import { join as join3, resolve as resolve2 } from "node:path";
2344
2395
 
2345
2396
  // src/domain/submodule.ts
2346
2397
  function submoduleFullName(submodule) {
@@ -2395,7 +2446,7 @@ function parseGitModules(raw) {
2395
2446
  }
2396
2447
  async function readGitModules(directory) {
2397
2448
  try {
2398
- return await readFile3(join4(directory, ".gitmodules"), "utf8");
2449
+ return await readFile3(join3(directory, ".gitmodules"), "utf8");
2399
2450
  } catch (error) {
2400
2451
  if (error instanceof Error && "code" in error) {
2401
2452
  const code = error.code;
@@ -2406,7 +2457,7 @@ async function readGitModules(directory) {
2406
2457
  }
2407
2458
  }
2408
2459
  async function collectSubmodules(worktreePath, parentModule, visited) {
2409
- const directory = parentModule === undefined ? worktreePath : join4(worktreePath, submoduleFullPath(parentModule));
2460
+ const directory = parentModule === undefined ? worktreePath : join3(worktreePath, submoduleFullPath(parentModule));
2410
2461
  const resolved = resolve2(directory);
2411
2462
  if (visited.has(resolved))
2412
2463
  return [];
@@ -15418,7 +15469,7 @@ async function loadRefsSnapshot(runner) {
15418
15469
  }
15419
15470
 
15420
15471
  // src/git/editor.ts
15421
- import { join as join5, basename } from "node:path";
15472
+ import { join as join4, basename } from "node:path";
15422
15473
  function standardTerminalPreset(editor) {
15423
15474
  return {
15424
15475
  edit: `${editor} -- {{filename}}`,
@@ -15556,7 +15607,7 @@ async function resolveEditCommand(files, options = {}) {
15556
15607
  function absolutePath(repoRoot, relativePath) {
15557
15608
  if (relativePath.startsWith("/"))
15558
15609
  return relativePath;
15559
- return join5(repoRoot, relativePath);
15610
+ return join4(repoRoot, relativePath);
15560
15611
  }
15561
15612
 
15562
15613
  // src/app/create-app.ts
@@ -15564,7 +15615,7 @@ import { isAbsolute as isAbsolute2, resolve as resolve3 } from "node:path";
15564
15615
 
15565
15616
  // src/app/index-watcher.ts
15566
15617
  import { watch, statSync } from "node:fs";
15567
- import { basename as basename2, dirname as dirname4 } from "node:path";
15618
+ import { basename as basename2, dirname as dirname3 } from "node:path";
15568
15619
  var DEFAULT_INDEX_EVENT_DEBOUNCE_MS = 50;
15569
15620
  var BUSY_RETRY_MS = 50;
15570
15621
  function fingerprint(path) {
@@ -15605,7 +15656,7 @@ class IndexWatcher {
15605
15656
  this.stopped = false;
15606
15657
  this.baseline = fingerprint(this.options.indexPath);
15607
15658
  try {
15608
- const watcher = watch(dirname4(this.options.indexPath), { persistent: false }, (_eventType, filename) => {
15659
+ const watcher = watch(dirname3(this.options.indexPath), { persistent: false }, (_eventType, filename) => {
15609
15660
  if (this.stopped)
15610
15661
  return;
15611
15662
  const name = eventFileName(filename);
@@ -16271,7 +16322,7 @@ function reviewHelp(focus, state) {
16271
16322
  }
16272
16323
 
16273
16324
  // src/ui/review-workspace/review-sidebar.ts
16274
- import { basename as basename3, dirname as dirname5 } from "node:path/posix";
16325
+ import { basename as basename3, dirname as dirname4 } from "node:path/posix";
16275
16326
  function normalizeDiffPath(p) {
16276
16327
  return p?.replace(/[\r\n]+$/u, "");
16277
16328
  }
@@ -16357,7 +16408,7 @@ function buildReviewSidebarEntries(state) {
16357
16408
  let activeGroup;
16358
16409
  visible.forEach((file, index) => {
16359
16410
  const path = formatTerminalPath(normalizeDiffPath(file.path) ?? file.path);
16360
- const group = dirname5(path);
16411
+ const group = dirname4(path);
16361
16412
  if (group !== activeGroup) {
16362
16413
  activeGroup = group;
16363
16414
  entries.push({
package/package.json CHANGED
@@ -1,8 +1,7 @@
1
1
  {
2
2
  "name": "@xuhaojun/githunk",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "A review-first Git TUI combining lazygit's everyday Git workflow with focused hunk review.",
5
- "type": "module",
6
5
  "bin": {
7
6
  "githunk": "bin/githunk.js"
8
7
  },
@@ -12,42 +11,24 @@
12
11
  "README.md",
13
12
  "LICENSE"
14
13
  ],
15
- "scripts": {
16
- "start": "bun run src/main.ts",
17
- "dev": "bun --watch src/main.ts",
18
- "build": "bun build src/cli.ts --target=node --format=esm --packages=external --outfile=dist/githunk.js --banner '#!/usr/bin/env node'",
19
- "prepack": "bun run build",
20
- "pack:check": "npm pack --dry-run",
21
- "spike:selection": "bun run spikes/selection/src/main.ts",
22
- "test": "bun test --path-ignore-patterns='learn-projects/**'",
23
- "typecheck": "tsc --noEmit",
24
- "check": "bun run typecheck && bun run test",
25
- "bench:review-load": "bun run benchmarks/review-document-load.ts",
26
- "bench:review-rows": "bun run benchmarks/review-row-plan.ts",
27
- "bench:review-reconcile": "bun run benchmarks/review-reconcile.ts"
28
- },
14
+ "type": "module",
29
15
  "keywords": [
30
16
  "git",
31
17
  "tui",
32
18
  "code-review",
33
19
  "cli"
34
20
  ],
35
- "homepage": "https://github.com/XuHaoJun/githunk#readme",
36
- "bugs": {
37
- "url": "https://github.com/XuHaoJun/githunk/issues"
38
- },
39
21
  "repository": {
40
22
  "type": "git",
41
23
  "url": "git+https://github.com/XuHaoJun/githunk.git"
42
24
  },
43
- "license": "MIT",
25
+ "homepage": "https://github.com/XuHaoJun/githunk#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/XuHaoJun/githunk/issues"
28
+ },
44
29
  "engines": {
45
30
  "node": ">=26.1.0"
46
31
  },
47
- "packageManager": "bun@1.4.0",
48
- "publishConfig": {
49
- "access": "public"
50
- },
51
32
  "dependencies": {
52
33
  "@opentui/core": "0.5.10",
53
34
  "@opentui/react": "0.5.10",
@@ -56,9 +37,15 @@
56
37
  "react": "19.2.8",
57
38
  "zod": "^4.4.3"
58
39
  },
59
- "devDependencies": {
60
- "@types/bun": "1.4.0",
61
- "@types/react": "19.2.8",
62
- "typescript": "5.9.2"
40
+ "optionalDependencies": {
41
+ "@xuhaojun/githunk-darwin-arm64": "0.2.0",
42
+ "@xuhaojun/githunk-darwin-x64": "0.2.0",
43
+ "@xuhaojun/githunk-linux-arm64": "0.2.0",
44
+ "@xuhaojun/githunk-linux-x64": "0.2.0",
45
+ "@xuhaojun/githunk-windows-x64": "0.2.0"
46
+ },
47
+ "license": "MIT",
48
+ "publishConfig": {
49
+ "access": "public"
63
50
  }
64
51
  }