@xuhaojun/githunk 0.1.3 → 0.3.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/README.md +23 -2
- package/bin/githunk.js +86 -14
- package/dist/githunk.js +264 -55
- package/package.json +16 -29
package/README.md
CHANGED
|
@@ -5,19 +5,40 @@ A review-first Git TUI that mixes [lazygit](https://github.com/jesseduffield/laz
|
|
|
5
5
|
|
|
6
6
|
## Install
|
|
7
7
|
|
|
8
|
+
No Node.js required — githunk ships as a standalone binary:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
curl -fsSL https://raw.githubusercontent.com/XuHaoJun/githunk/main/install.sh | sh
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
This installs the newest release into `~/.local/bin` (or `$XDG_BIN_HOME`, or `$GITHUNK_INSTALL_DIR`), verifying its checksum first. Pin a version with `sh -s -- 0.2.0`, or pass `--no-modify-path` to skip shell-startup PATH wiring. macOS and Linux only.
|
|
15
|
+
|
|
16
|
+
Alternative via npm (needs Node.js 26.1.0 or newer just to install):
|
|
17
|
+
|
|
8
18
|
```sh
|
|
9
19
|
npm install --global @xuhaojun/githunk
|
|
10
20
|
```
|
|
11
21
|
|
|
12
22
|
## Requirements
|
|
13
23
|
|
|
14
|
-
- Node.js 26.1.0 or newer
|
|
15
24
|
- Git
|
|
16
25
|
- A terminal with interactive TUI support
|
|
17
26
|
|
|
18
27
|
`gh` is optional. When it is installed and authenticated, githunk can show GitHub pull-request status; local Git review works without it.
|
|
19
28
|
|
|
20
|
-
The
|
|
29
|
+
The launcher prefers the prebuilt binary. On platforms without one it falls back to the Node.js bundle, which needs Node.js 26.1.0 or newer; the launcher enables Node's experimental FFI support automatically for OpenTUI.
|
|
30
|
+
|
|
31
|
+
## Update
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
githunk update # newest release
|
|
35
|
+
githunk update 0.3.0 # a specific version
|
|
36
|
+
githunk update --check # report without installing
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Standalone installs replace their own binary after checksum verification. npm installs update through npm instead: `npm update --global @xuhaojun/githunk`.
|
|
40
|
+
|
|
41
|
+
Installer knobs: `GITHUNK_VERSION` pins the version, `GITHUNK_INSTALL_DIR` overrides the install directory, `GITHUNK_NO_MODIFY_PATH=1` skips shell-startup PATH wiring.
|
|
21
42
|
|
|
22
43
|
## Usage
|
|
23
44
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
@@ -1,36 +1,95 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { chmodSync, cpSync, mkdtempSync, renameSync, rmSync } from "node:fs";
|
|
6
|
+
import { writeFile } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join as join6 } from "node:path";
|
|
9
|
+
|
|
3
10
|
// src/cli/args.ts
|
|
4
11
|
import { Command, CommanderError } from "commander";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
12
|
+
// package.json
|
|
13
|
+
var package_default = {
|
|
14
|
+
name: "@xuhaojun/githunk",
|
|
15
|
+
version: "0.3.0",
|
|
16
|
+
description: "A review-first Git TUI combining lazygit's everyday Git workflow with focused hunk review.",
|
|
17
|
+
type: "module",
|
|
18
|
+
bin: {
|
|
19
|
+
githunk: "bin/githunk.js"
|
|
20
|
+
},
|
|
21
|
+
files: [
|
|
22
|
+
"bin",
|
|
23
|
+
"dist/githunk.js",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
scripts: {
|
|
28
|
+
start: "bun run src/main.ts",
|
|
29
|
+
dev: "bun --watch src/main.ts",
|
|
30
|
+
build: "bun build src/cli.ts --target=node --format=esm --packages=external --outfile=dist/githunk.js --banner '#!/usr/bin/env node'",
|
|
31
|
+
"build:bin": "bun run ./scripts/build-bin.ts",
|
|
32
|
+
"build:prebuilt:artifact": "bun run ./scripts/build-prebuilt-artifact.ts",
|
|
33
|
+
"stage:prebuilt:release": "bun run ./scripts/stage-prebuilt-npm.ts --artifact-root ./dist/release/artifacts",
|
|
34
|
+
"check:release-version": "bun run ./scripts/check-release-version.ts",
|
|
35
|
+
"check:prebuilt-pack": "bun run ./scripts/check-prebuilt-pack.ts",
|
|
36
|
+
"publish:prebuilt:npm": "bun run ./scripts/publish-prebuilt-npm.ts",
|
|
37
|
+
"smoke:prebuilt-install": "bun run ./scripts/smoke-prebuilt-install.ts",
|
|
38
|
+
prepack: "bun run build",
|
|
39
|
+
"pack:check": "npm pack --dry-run",
|
|
40
|
+
"spike:selection": "bun run spikes/selection/src/main.ts",
|
|
41
|
+
test: "bun test --path-ignore-patterns='learn-projects/**'",
|
|
42
|
+
typecheck: "tsc --noEmit",
|
|
43
|
+
check: "bun run typecheck && bun run test",
|
|
44
|
+
"bench:review-load": "bun run benchmarks/review-document-load.ts",
|
|
45
|
+
"bench:review-rows": "bun run benchmarks/review-row-plan.ts",
|
|
46
|
+
"bench:review-reconcile": "bun run benchmarks/review-reconcile.ts"
|
|
47
|
+
},
|
|
48
|
+
keywords: [
|
|
49
|
+
"git",
|
|
50
|
+
"tui",
|
|
51
|
+
"code-review",
|
|
52
|
+
"cli"
|
|
53
|
+
],
|
|
54
|
+
homepage: "https://github.com/XuHaoJun/githunk#readme",
|
|
55
|
+
bugs: {
|
|
56
|
+
url: "https://github.com/XuHaoJun/githunk/issues"
|
|
57
|
+
},
|
|
58
|
+
repository: {
|
|
59
|
+
type: "git",
|
|
60
|
+
url: "git+https://github.com/XuHaoJun/githunk.git"
|
|
61
|
+
},
|
|
62
|
+
license: "MIT",
|
|
63
|
+
engines: {
|
|
64
|
+
node: ">=26.1.0"
|
|
65
|
+
},
|
|
66
|
+
packageManager: "bun@1.4.0",
|
|
67
|
+
publishConfig: {
|
|
68
|
+
access: "public"
|
|
69
|
+
},
|
|
70
|
+
dependencies: {
|
|
71
|
+
"@opentui/core": "0.5.10",
|
|
72
|
+
"@opentui/react": "0.5.10",
|
|
73
|
+
"@pierre/diffs": "1.3.5",
|
|
74
|
+
commander: "^15.0.0",
|
|
75
|
+
react: "19.2.8",
|
|
76
|
+
zod: "^4.4.3"
|
|
77
|
+
},
|
|
78
|
+
devDependencies: {
|
|
79
|
+
"@types/bun": "1.4.0",
|
|
80
|
+
"@types/react": "19.2.8",
|
|
81
|
+
typescript: "5.9.2"
|
|
25
82
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// src/cli/args.ts
|
|
86
|
+
var cliVersion = typeof package_default.version === "string" && package_default.version !== "" ? package_default.version : "0.0.0-dev";
|
|
29
87
|
function parseCliArgs(argv) {
|
|
30
88
|
let stdout = "";
|
|
31
89
|
let stderr = "";
|
|
90
|
+
let update;
|
|
32
91
|
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(
|
|
92
|
+
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().action(() => {}).configureOutput({
|
|
34
93
|
writeOut: (text) => {
|
|
35
94
|
stdout += text;
|
|
36
95
|
},
|
|
@@ -38,6 +97,12 @@ function parseCliArgs(argv) {
|
|
|
38
97
|
stderr += text;
|
|
39
98
|
}
|
|
40
99
|
});
|
|
100
|
+
program.command("update").description("update githunk to the newest (or a given) release").argument("[version]", "version to install; the newest release when omitted").option("--check", "report the installed and available versions without installing").action((version, options2) => {
|
|
101
|
+
update = {
|
|
102
|
+
...version === undefined ? {} : { version },
|
|
103
|
+
check: options2.check ?? false
|
|
104
|
+
};
|
|
105
|
+
});
|
|
41
106
|
try {
|
|
42
107
|
program.parse([...argv], { from: "user" });
|
|
43
108
|
} catch (error) {
|
|
@@ -51,12 +116,91 @@ function parseCliArgs(argv) {
|
|
|
51
116
|
}
|
|
52
117
|
throw error;
|
|
53
118
|
}
|
|
119
|
+
if (update !== undefined)
|
|
120
|
+
return { kind: "update", ...update };
|
|
54
121
|
const options = program.opts();
|
|
55
122
|
const positional = program.args[0];
|
|
56
123
|
const startDirectory = options.path ?? positional;
|
|
57
124
|
return startDirectory === undefined ? { kind: "start" } : { kind: "start", startDirectory };
|
|
58
125
|
}
|
|
59
126
|
|
|
127
|
+
// src/cli/update.ts
|
|
128
|
+
import { createHash } from "node:crypto";
|
|
129
|
+
import { basename, join } from "node:path";
|
|
130
|
+
function assetNameFor(platform, arch) {
|
|
131
|
+
const osToken = platform === "linux" ? "linux" : platform === "darwin" ? "darwin" : platform === "win32" ? "windows" : null;
|
|
132
|
+
const archToken = arch === "x64" ? "x64" : arch === "arm64" ? "arm64" : null;
|
|
133
|
+
if (osToken === null || archToken === null)
|
|
134
|
+
return null;
|
|
135
|
+
if (osToken === "windows" && archToken !== "x64")
|
|
136
|
+
return null;
|
|
137
|
+
return `githunk-${osToken}-${archToken}.tar.gz`;
|
|
138
|
+
}
|
|
139
|
+
function normalizeVersion(version) {
|
|
140
|
+
return version.trim().replace(/^v/, "");
|
|
141
|
+
}
|
|
142
|
+
function compareVersions(left, right) {
|
|
143
|
+
const parts = (version) => {
|
|
144
|
+
const [major = "0", minor = "0", patch = "0"] = normalizeVersion(version).split(".");
|
|
145
|
+
return [Number(major) || 0, Number(minor) || 0, Number(patch) || 0];
|
|
146
|
+
};
|
|
147
|
+
const [aMajor, aMinor, aPatch] = parts(left);
|
|
148
|
+
const [bMajor, bMinor, bPatch] = parts(right);
|
|
149
|
+
return aMajor - bMajor || aMinor - bMinor || aPatch - bPatch;
|
|
150
|
+
}
|
|
151
|
+
function isSelfManagedBinary(executablePath) {
|
|
152
|
+
const base = basename(executablePath);
|
|
153
|
+
return base === "githunk" || base === "githunk.exe";
|
|
154
|
+
}
|
|
155
|
+
function verifyChecksum(tarball, checksums, asset) {
|
|
156
|
+
const actual = createHash("sha256").update(tarball).digest("hex");
|
|
157
|
+
const line = checksums.split(`
|
|
158
|
+
`).map((entry) => entry.trim().split(/\s+/)).find((fields) => fields[fields.length - 1] === asset);
|
|
159
|
+
const expected = line?.[0];
|
|
160
|
+
if (expected === undefined || expected === "" || expected.toLowerCase() !== actual) {
|
|
161
|
+
throw new Error(`checksum mismatch for ${asset}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async function applyUpdate(target, asset, env) {
|
|
165
|
+
await env.withTempDir(async (dir) => {
|
|
166
|
+
const { tarball, checksums } = await env.fetchAsset(`v${target}`, asset);
|
|
167
|
+
verifyChecksum(tarball, checksums, asset);
|
|
168
|
+
const archivePath = join(dir, asset);
|
|
169
|
+
await env.writeFile(archivePath, tarball);
|
|
170
|
+
await env.extractTarball(archivePath, dir);
|
|
171
|
+
await env.writeBinary(env.stagedBinary(dir), env.executablePath);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
async function runUpdate(request, env) {
|
|
175
|
+
try {
|
|
176
|
+
if (!isSelfManagedBinary(env.executablePath)) {
|
|
177
|
+
return {
|
|
178
|
+
exitCode: 1,
|
|
179
|
+
message: "githunk was installed via npm — update it with `npm update --global @xuhaojun/githunk`"
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const asset = assetNameFor(env.platform, env.arch);
|
|
183
|
+
if (asset === null) {
|
|
184
|
+
return {
|
|
185
|
+
exitCode: 1,
|
|
186
|
+
message: `no prebuilt githunk binary ships for ${env.platform}-${env.arch} — install with \`npm install -g @xuhaojun/githunk\``
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const current = normalizeVersion(env.installedVersion());
|
|
190
|
+
const target = normalizeVersion(request.version ?? normalizeVersion(await env.fetchReleaseTag()));
|
|
191
|
+
if (compareVersions(target, current) === 0) {
|
|
192
|
+
return { exitCode: 0, message: `githunk ${current} is already up to date` };
|
|
193
|
+
}
|
|
194
|
+
if (request.check) {
|
|
195
|
+
return { exitCode: 0, message: `update available: ${current} -> ${target}` };
|
|
196
|
+
}
|
|
197
|
+
await applyUpdate(target, asset, env);
|
|
198
|
+
return { exitCode: 0, message: `updated githunk ${current} -> ${target}` };
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return { exitCode: 1, message: error instanceof Error ? error.message : String(error) };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
60
204
|
// src/main.ts
|
|
61
205
|
import { resolve as resolve4 } from "node:path";
|
|
62
206
|
import { createCliRenderer } from "@opentui/core";
|
|
@@ -940,12 +1084,12 @@ function reviewStateFor(record, currentFingerprint) {
|
|
|
940
1084
|
}
|
|
941
1085
|
|
|
942
1086
|
// src/review/working-tree-fingerprint.ts
|
|
943
|
-
import { createHash } from "node:crypto";
|
|
1087
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
944
1088
|
function utf8(value) {
|
|
945
1089
|
return new TextEncoder().encode(value);
|
|
946
1090
|
}
|
|
947
1091
|
function sha256Tuple(parts) {
|
|
948
|
-
const hash =
|
|
1092
|
+
const hash = createHash2("sha256");
|
|
949
1093
|
for (const part of parts) {
|
|
950
1094
|
const bytes = utf8(part);
|
|
951
1095
|
const length = Buffer.allocUnsafe(4);
|
|
@@ -978,7 +1122,7 @@ function fingerprintWorkingTreeFile(target, filePatch) {
|
|
|
978
1122
|
|
|
979
1123
|
// src/storage/local-state-file.ts
|
|
980
1124
|
import { mkdir, open, rename, stat, unlink, lstat, link, readFile } from "node:fs/promises";
|
|
981
|
-
import { dirname
|
|
1125
|
+
import { dirname, isAbsolute, join as join2, resolve } from "node:path";
|
|
982
1126
|
import { randomUUID } from "node:crypto";
|
|
983
1127
|
async function assertNoSymlinkInPath(path, pathKind) {
|
|
984
1128
|
const absolute = resolve(path);
|
|
@@ -1033,7 +1177,7 @@ class LocalStateFile {
|
|
|
1033
1177
|
async writeText(text) {
|
|
1034
1178
|
const path = await this.resolvePath();
|
|
1035
1179
|
await assertNoSymlinkInPath(path, this.pathKind);
|
|
1036
|
-
await mkdir(
|
|
1180
|
+
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
1037
1181
|
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
1038
1182
|
await assertNoSymlinkInPath(temporary, this.pathKind);
|
|
1039
1183
|
const handle = await open(temporary, "wx", 384);
|
|
@@ -1046,7 +1190,7 @@ class LocalStateFile {
|
|
|
1046
1190
|
}
|
|
1047
1191
|
await rename(temporary, path);
|
|
1048
1192
|
try {
|
|
1049
|
-
const directory = await open(
|
|
1193
|
+
const directory = await open(dirname(path), "r");
|
|
1050
1194
|
try {
|
|
1051
1195
|
await directory.sync();
|
|
1052
1196
|
} finally {
|
|
@@ -1070,7 +1214,7 @@ class LocalStateFile {
|
|
|
1070
1214
|
async createTextExclusive(text) {
|
|
1071
1215
|
const path = await this.resolvePath();
|
|
1072
1216
|
await assertNoSymlinkInPath(path, this.pathKind);
|
|
1073
|
-
await mkdir(
|
|
1217
|
+
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
1074
1218
|
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
1075
1219
|
await assertNoSymlinkInPath(temporary, this.pathKind);
|
|
1076
1220
|
const handle = await open(temporary, "wx", 384);
|
|
@@ -1099,7 +1243,7 @@ class LocalStateFile {
|
|
|
1099
1243
|
return;
|
|
1100
1244
|
});
|
|
1101
1245
|
try {
|
|
1102
|
-
const directory = await open(
|
|
1246
|
+
const directory = await open(dirname(path), "r");
|
|
1103
1247
|
try {
|
|
1104
1248
|
await directory.sync();
|
|
1105
1249
|
} finally {
|
|
@@ -1925,7 +2069,7 @@ function validateUpstream(upstream) {
|
|
|
1925
2069
|
throw new Error("invalid upstream choice");
|
|
1926
2070
|
}
|
|
1927
2071
|
}
|
|
1928
|
-
async function
|
|
2072
|
+
async function fetch2(runner, remote, options = {}) {
|
|
1929
2073
|
await runner.run(remote === undefined ? ["fetch"] : ["fetch", remote], options.background === true ? { dontLog: true } : { streamOutput: true });
|
|
1930
2074
|
}
|
|
1931
2075
|
async function pull(runner, options = {}) {
|
|
@@ -2134,7 +2278,7 @@ async function listReflog(runner, options = {}) {
|
|
|
2134
2278
|
|
|
2135
2279
|
// src/git/worktrees.ts
|
|
2136
2280
|
import { readFile as readFile2, stat as stat2 } from "node:fs/promises";
|
|
2137
|
-
import { dirname as
|
|
2281
|
+
import { dirname as dirname2, join as join3 } from "node:path";
|
|
2138
2282
|
function finalizeEntry(entry) {
|
|
2139
2283
|
return {
|
|
2140
2284
|
path: entry.path,
|
|
@@ -2236,7 +2380,7 @@ async function resolveRepositoryPaths(runner) {
|
|
|
2236
2380
|
const worktreeGitDirPath = lines[1] ?? "";
|
|
2237
2381
|
const repoGitDirPath = lines[2] ?? "";
|
|
2238
2382
|
const isSubmodule = (lines[3] ?? "").length > 0;
|
|
2239
|
-
const repoPath = worktreeGitDirPath === repoGitDirPath || isSubmodule ? worktreePath :
|
|
2383
|
+
const repoPath = worktreeGitDirPath === repoGitDirPath || isSubmodule ? worktreePath : dirname2(repoGitDirPath);
|
|
2240
2384
|
return { worktreePath, worktreeGitDirPath, repoPath, repoGitDirPath };
|
|
2241
2385
|
}
|
|
2242
2386
|
async function isPathMissing(path) {
|
|
@@ -2890,7 +3034,7 @@ class AppController {
|
|
|
2890
3034
|
return;
|
|
2891
3035
|
if (options.background !== true)
|
|
2892
3036
|
this.logAction(LOG_ACTIONS.fetch);
|
|
2893
|
-
await this.runMutation(() => this.requireRunnerOperation((runner) =>
|
|
3037
|
+
await this.runMutation(() => this.requireRunnerOperation((runner) => fetch2(runner, remote, options)));
|
|
2894
3038
|
}
|
|
2895
3039
|
async pull(options = {}) {
|
|
2896
3040
|
if (!this.ensureWorkingTreeMutation())
|
|
@@ -5884,7 +6028,7 @@ function commitGraphRows(commits, getColor) {
|
|
|
5884
6028
|
}
|
|
5885
6029
|
|
|
5886
6030
|
// src/ui/author-style.ts
|
|
5887
|
-
import { createHash as
|
|
6031
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
5888
6032
|
var initialsCache = new Map;
|
|
5889
6033
|
var colorCache = new Map;
|
|
5890
6034
|
function randInt(bytes, max) {
|
|
@@ -5925,7 +6069,7 @@ function authorColor(authorName) {
|
|
|
5925
6069
|
const cached = colorCache.get(authorName);
|
|
5926
6070
|
if (cached !== undefined)
|
|
5927
6071
|
return cached;
|
|
5928
|
-
const hash = new Uint8Array(
|
|
6072
|
+
const hash = new Uint8Array(createHash3("md5").update(authorName).digest());
|
|
5929
6073
|
const color = hslToHex(randFloat(hash.slice(0, 4)) * 360, 0.6 + 0.4 * randFloat(hash.slice(4, 8)), 0.4 + 0.2 * randFloat(hash.slice(8, 12)));
|
|
5930
6074
|
colorCache.set(authorName, color);
|
|
5931
6075
|
return color;
|
|
@@ -15418,7 +15562,7 @@ async function loadRefsSnapshot(runner) {
|
|
|
15418
15562
|
}
|
|
15419
15563
|
|
|
15420
15564
|
// src/git/editor.ts
|
|
15421
|
-
import { join as join5, basename } from "node:path";
|
|
15565
|
+
import { join as join5, basename as basename2 } from "node:path";
|
|
15422
15566
|
function standardTerminalPreset(editor) {
|
|
15423
15567
|
return {
|
|
15424
15568
|
edit: `${editor} -- {{filename}}`,
|
|
@@ -15511,13 +15655,13 @@ function resolvePlaceholders(template, values) {
|
|
|
15511
15655
|
}
|
|
15512
15656
|
function guessEditorBase(env, gitEditor) {
|
|
15513
15657
|
if (env.GITHUNK_EDITOR !== undefined && env.GITHUNK_EDITOR.trim().length > 0)
|
|
15514
|
-
return
|
|
15658
|
+
return basename2(env.GITHUNK_EDITOR.split(" ")[0].trim());
|
|
15515
15659
|
if (gitEditor !== undefined && gitEditor.trim().length > 0)
|
|
15516
|
-
return
|
|
15660
|
+
return basename2(gitEditor.split(" ")[0].trim());
|
|
15517
15661
|
for (const key of ["GIT_EDITOR", "VISUAL", "EDITOR"]) {
|
|
15518
15662
|
const value = env[key];
|
|
15519
15663
|
if (value !== undefined && value.trim().length > 0)
|
|
15520
|
-
return
|
|
15664
|
+
return basename2(value.split(" ")[0].trim());
|
|
15521
15665
|
}
|
|
15522
15666
|
return "vi";
|
|
15523
15667
|
}
|
|
@@ -15564,7 +15708,7 @@ import { isAbsolute as isAbsolute2, resolve as resolve3 } from "node:path";
|
|
|
15564
15708
|
|
|
15565
15709
|
// src/app/index-watcher.ts
|
|
15566
15710
|
import { watch, statSync } from "node:fs";
|
|
15567
|
-
import { basename as
|
|
15711
|
+
import { basename as basename3, dirname as dirname3 } from "node:path";
|
|
15568
15712
|
var DEFAULT_INDEX_EVENT_DEBOUNCE_MS = 50;
|
|
15569
15713
|
var BUSY_RETRY_MS = 50;
|
|
15570
15714
|
function fingerprint(path) {
|
|
@@ -15594,7 +15738,7 @@ class IndexWatcher {
|
|
|
15594
15738
|
stopped = true;
|
|
15595
15739
|
constructor(options) {
|
|
15596
15740
|
this.options = options;
|
|
15597
|
-
this.indexName =
|
|
15741
|
+
this.indexName = basename3(options.indexPath);
|
|
15598
15742
|
this.lockName = `${this.indexName}.lock`;
|
|
15599
15743
|
this.debounceMs = options.debounceMs ?? DEFAULT_INDEX_EVENT_DEBOUNCE_MS;
|
|
15600
15744
|
this.baseline = undefined;
|
|
@@ -15605,7 +15749,7 @@ class IndexWatcher {
|
|
|
15605
15749
|
this.stopped = false;
|
|
15606
15750
|
this.baseline = fingerprint(this.options.indexPath);
|
|
15607
15751
|
try {
|
|
15608
|
-
const watcher = watch(
|
|
15752
|
+
const watcher = watch(dirname3(this.options.indexPath), { persistent: false }, (_eventType, filename) => {
|
|
15609
15753
|
if (this.stopped)
|
|
15610
15754
|
return;
|
|
15611
15755
|
const name = eventFileName(filename);
|
|
@@ -16271,7 +16415,7 @@ function reviewHelp(focus, state) {
|
|
|
16271
16415
|
}
|
|
16272
16416
|
|
|
16273
16417
|
// src/ui/review-workspace/review-sidebar.ts
|
|
16274
|
-
import { basename as
|
|
16418
|
+
import { basename as basename4, dirname as dirname4 } from "node:path/posix";
|
|
16275
16419
|
function normalizeDiffPath(p) {
|
|
16276
16420
|
return p?.replace(/[\r\n]+$/u, "");
|
|
16277
16421
|
}
|
|
@@ -16300,10 +16444,10 @@ function sidebarFileName(file) {
|
|
|
16300
16444
|
const path = formatTerminalPath(normalizeDiffPath(file.path) ?? file.path);
|
|
16301
16445
|
const previousPath = file.previousPath ? formatTerminalPath(normalizeDiffPath(file.previousPath) ?? file.previousPath) : undefined;
|
|
16302
16446
|
if (previousPath === undefined || previousPath === path) {
|
|
16303
|
-
return
|
|
16447
|
+
return basename4(path);
|
|
16304
16448
|
}
|
|
16305
|
-
const previousName =
|
|
16306
|
-
const nextName =
|
|
16449
|
+
const previousName = basename4(previousPath);
|
|
16450
|
+
const nextName = basename4(path);
|
|
16307
16451
|
return previousName === nextName ? nextName : `${previousName} -> ${nextName}`;
|
|
16308
16452
|
}
|
|
16309
16453
|
function formatSidebarStat(prefix, value, truncated = false) {
|
|
@@ -16357,7 +16501,7 @@ function buildReviewSidebarEntries(state) {
|
|
|
16357
16501
|
let activeGroup;
|
|
16358
16502
|
visible.forEach((file, index) => {
|
|
16359
16503
|
const path = formatTerminalPath(normalizeDiffPath(file.path) ?? file.path);
|
|
16360
|
-
const group =
|
|
16504
|
+
const group = dirname4(path);
|
|
16361
16505
|
if (group !== activeGroup) {
|
|
16362
16506
|
activeGroup = group;
|
|
16363
16507
|
entries.push({
|
|
@@ -17549,9 +17693,9 @@ function syntaxThemeForAppearance(appearance) {
|
|
|
17549
17693
|
import { parsePatchFiles as parsePatchFiles2 } from "@pierre/diffs";
|
|
17550
17694
|
|
|
17551
17695
|
// src/review/core/identity.ts
|
|
17552
|
-
import { createHash as
|
|
17696
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
17553
17697
|
function sha256Tuple2(parts) {
|
|
17554
|
-
const hash =
|
|
17698
|
+
const hash = createHash4("sha256");
|
|
17555
17699
|
for (const part of parts) {
|
|
17556
17700
|
const bytes = new TextEncoder().encode(part);
|
|
17557
17701
|
const length = Buffer.alloc(4);
|
|
@@ -21873,7 +22017,7 @@ class AppScreenController {
|
|
|
21873
22017
|
}
|
|
21874
22018
|
|
|
21875
22019
|
// src/ui/review-workspace/controller.ts
|
|
21876
|
-
import { createHash as
|
|
22020
|
+
import { createHash as createHash6 } from "node:crypto";
|
|
21877
22021
|
|
|
21878
22022
|
// src/review/core/state.ts
|
|
21879
22023
|
function createInitialReviewState(document) {
|
|
@@ -23303,12 +23447,12 @@ function persistedFromReviewState(state) {
|
|
|
23303
23447
|
}
|
|
23304
23448
|
|
|
23305
23449
|
// src/review/storage/review-artifact-store.ts
|
|
23306
|
-
import { createHash as
|
|
23450
|
+
import { createHash as createHash5 } from "node:crypto";
|
|
23307
23451
|
function artifactRelativePath(reviewId, artifactId) {
|
|
23308
23452
|
return `githunk/reviews/${reviewId}/${artifactId}.json`;
|
|
23309
23453
|
}
|
|
23310
23454
|
function artifactDigest(text) {
|
|
23311
|
-
return
|
|
23455
|
+
return createHash5("sha256").update(text, "utf8").digest("hex");
|
|
23312
23456
|
}
|
|
23313
23457
|
function artifactText(artifact) {
|
|
23314
23458
|
return serializeReviewArtifactV1(artifact) + `
|
|
@@ -23817,7 +23961,7 @@ class ReviewWorkspaceController {
|
|
|
23817
23961
|
artifactIdFromMarker = marker.artifactId;
|
|
23818
23962
|
const raw = await this.artifactStore.readRaw(reviewId, marker.artifactId);
|
|
23819
23963
|
if (raw !== undefined) {
|
|
23820
|
-
const digest =
|
|
23964
|
+
const digest = createHash6("sha256").update(raw, "utf8").digest("hex");
|
|
23821
23965
|
if (digest === marker.digest) {
|
|
23822
23966
|
const parsed = JSON.parse(raw);
|
|
23823
23967
|
const res = parseReviewArtifactV1(parsed);
|
|
@@ -24841,6 +24985,64 @@ ${detail}
|
|
|
24841
24985
|
if (false) {}
|
|
24842
24986
|
|
|
24843
24987
|
// src/cli.ts
|
|
24988
|
+
var RELEASES_API = "https://api.github.com/repos/XuHaoJun/githunk/releases/latest";
|
|
24989
|
+
var DOWNLOAD_BASE = "https://github.com/XuHaoJun/githunk/releases/download";
|
|
24990
|
+
async function fetchText(url) {
|
|
24991
|
+
const response = await fetch(url);
|
|
24992
|
+
if (!response.ok)
|
|
24993
|
+
throw new Error(`request failed: ${url} (${response.status})`);
|
|
24994
|
+
return response.text();
|
|
24995
|
+
}
|
|
24996
|
+
async function fetchBytes(url) {
|
|
24997
|
+
const response = await fetch(url);
|
|
24998
|
+
if (!response.ok)
|
|
24999
|
+
throw new Error(`request failed: ${url} (${response.status})`);
|
|
25000
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
25001
|
+
}
|
|
25002
|
+
function productionUpdateEnv() {
|
|
25003
|
+
return {
|
|
25004
|
+
executablePath: process.execPath,
|
|
25005
|
+
platform: process.platform,
|
|
25006
|
+
arch: process.arch,
|
|
25007
|
+
installedVersion: () => {
|
|
25008
|
+
const proc = spawnSync(process.execPath, ["--version"], { encoding: "utf8" });
|
|
25009
|
+
if (proc.status !== 0)
|
|
25010
|
+
throw new Error("could not read the installed version");
|
|
25011
|
+
return proc.stdout.trim();
|
|
25012
|
+
},
|
|
25013
|
+
fetchReleaseTag: async () => {
|
|
25014
|
+
const payload = JSON.parse(await fetchText(RELEASES_API));
|
|
25015
|
+
const tag = typeof payload === "object" && payload !== null && "tag_name" in payload ? payload.tag_name : undefined;
|
|
25016
|
+
if (typeof tag !== "string" || tag === "")
|
|
25017
|
+
throw new Error("could not read the newest release");
|
|
25018
|
+
return tag;
|
|
25019
|
+
},
|
|
25020
|
+
fetchAsset: async (tag, asset) => ({
|
|
25021
|
+
tarball: await fetchBytes(`${DOWNLOAD_BASE}/${tag}/${asset}`),
|
|
25022
|
+
checksums: await fetchText(`${DOWNLOAD_BASE}/${tag}/SHA256SUMS`)
|
|
25023
|
+
}),
|
|
25024
|
+
withTempDir: async (run) => {
|
|
25025
|
+
const dir = mkdtempSync(join6(tmpdir(), "githunk-update-"));
|
|
25026
|
+
try {
|
|
25027
|
+
return await run(dir);
|
|
25028
|
+
} finally {
|
|
25029
|
+
rmSync(dir, { recursive: true, force: true });
|
|
25030
|
+
}
|
|
25031
|
+
},
|
|
25032
|
+
writeFile: (path, data) => writeFile(path, data),
|
|
25033
|
+
extractTarball: async (archivePath, destDir) => {
|
|
25034
|
+
const proc = spawnSync("tar", ["-xzf", archivePath, "-C", destDir]);
|
|
25035
|
+
if (proc.status !== 0)
|
|
25036
|
+
throw new Error("could not extract the release archive (need tar on PATH)");
|
|
25037
|
+
},
|
|
25038
|
+
stagedBinary: (dir) => join6(dir, `githunk-${process.platform === "win32" ? "windows" : process.platform}-${process.arch === "arm64" ? "arm64" : "x64"}`, process.platform === "win32" ? "githunk.exe" : "githunk"),
|
|
25039
|
+
writeBinary: async (stagedPath, destPath) => {
|
|
25040
|
+
cpSync(stagedPath, `${destPath}.new`);
|
|
25041
|
+
chmodSync(`${destPath}.new`, 493);
|
|
25042
|
+
renameSync(`${destPath}.new`, destPath);
|
|
25043
|
+
}
|
|
25044
|
+
};
|
|
25045
|
+
}
|
|
24844
25046
|
var result = parseCliArgs(process.argv.slice(2));
|
|
24845
25047
|
if (result.kind === "help" || result.kind === "version") {
|
|
24846
25048
|
process.stdout.write(result.text.endsWith(`
|
|
@@ -24852,6 +25054,13 @@ if (result.kind === "help" || result.kind === "version") {
|
|
|
24852
25054
|
`) ? result.message : `${result.message}
|
|
24853
25055
|
`);
|
|
24854
25056
|
process.exitCode = result.exitCode;
|
|
25057
|
+
} else if (result.kind === "update") {
|
|
25058
|
+
const outcome = await runUpdate({ ...result.version === undefined ? {} : { version: result.version }, check: result.check }, productionUpdateEnv());
|
|
25059
|
+
const stream = outcome.exitCode === 0 ? process.stdout : process.stderr;
|
|
25060
|
+
stream.write(outcome.message.endsWith(`
|
|
25061
|
+
`) ? outcome.message : `${outcome.message}
|
|
25062
|
+
`);
|
|
25063
|
+
process.exitCode = outcome.exitCode;
|
|
24855
25064
|
} else {
|
|
24856
25065
|
process.exitCode = await startApp(result.startDirectory === undefined ? {} : { startDirectory: result.startDirectory });
|
|
24857
25066
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xuhaojun/githunk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
60
|
-
"@
|
|
61
|
-
"@
|
|
62
|
-
"
|
|
40
|
+
"optionalDependencies": {
|
|
41
|
+
"@xuhaojun/githunk-darwin-arm64": "0.3.0",
|
|
42
|
+
"@xuhaojun/githunk-darwin-x64": "0.3.0",
|
|
43
|
+
"@xuhaojun/githunk-linux-arm64": "0.3.0",
|
|
44
|
+
"@xuhaojun/githunk-linux-x64": "0.3.0",
|
|
45
|
+
"@xuhaojun/githunk-windows-x64": "0.3.0"
|
|
46
|
+
},
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
63
50
|
}
|
|
64
51
|
}
|