diffhub 0.1.5 → 0.1.6
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/.next/standalone/apps/web/.next/BUILD_ID +1 -1
- package/.next/standalone/apps/web/.next/app-path-routes-manifest.json +2 -1
- package/.next/standalone/apps/web/.next/build-manifest.json +3 -3
- package/.next/standalone/apps/web/.next/prerender-manifest.json +3 -3
- package/.next/standalone/apps/web/.next/routes-manifest.json +9 -3
- package/.next/standalone/apps/web/.next/server/app/_global-error/page.js +2 -2
- package/.next/standalone/apps/web/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found/page.js +2 -2
- package/.next/standalone/apps/web/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.rsc +11 -11
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_full.segment.rsc +11 -11
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_index.segment.rsc +6 -6
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/api/comments/route.js +3 -2
- package/.next/standalone/apps/web/.next/server/app/api/comments/route.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/api/diff/route.js +3 -4
- package/.next/standalone/apps/web/.next/server/app/api/diff/route.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/api/discard/route.js +3 -3
- package/.next/standalone/apps/web/.next/server/app/api/discard/route.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/api/file/route.js +3 -3
- package/.next/standalone/apps/web/.next/server/app/api/file/route.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/api/files/route.js +3 -3
- package/.next/standalone/apps/web/.next/server/app/api/files/route.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/api/health/route/app-paths-manifest.json +3 -0
- package/.next/standalone/apps/web/.next/server/app/api/health/route.js +6 -0
- package/.next/standalone/apps/web/.next/server/app/api/health/route.js.nft.json +1 -0
- package/.next/standalone/apps/web/.next/server/app/api/health/route_client-reference-manifest.js +3 -0
- package/.next/standalone/apps/web/.next/server/app/api/watch/route/app-paths-manifest.json +3 -0
- package/.next/standalone/apps/web/.next/server/app/api/watch/route/build-manifest.json +9 -0
- package/.next/standalone/apps/web/.next/server/app/api/watch/route/server-reference-manifest.json +4 -0
- package/.next/standalone/apps/web/.next/server/app/api/watch/route.js +6 -0
- package/.next/standalone/apps/web/.next/server/app/api/watch/route.js.map +5 -0
- package/.next/standalone/apps/web/.next/server/app/api/{open → watch}/route.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/api/watch/route_client-reference-manifest.js +3 -0
- package/.next/standalone/apps/web/.next/server/app/favicon.ico/route.js +2 -1
- package/.next/standalone/apps/web/.next/server/app/favicon.ico/route.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/index.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/index.rsc +12 -12
- package/.next/standalone/apps/web/.next/server/app/index.segments/__PAGE__.segment.rsc +3 -3
- package/.next/standalone/apps/web/.next/server/app/index.segments/_full.segment.rsc +12 -12
- package/.next/standalone/apps/web/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/apps/web/.next/server/app/index.segments/_index.segment.rsc +6 -6
- package/.next/standalone/apps/web/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/page.js +2 -2
- package/.next/standalone/apps/web/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/app-paths-manifest.json +2 -1
- package/.next/standalone/apps/web/.next/server/chunks/0fuv_next_0e28~xv._.js +13 -0
- package/.next/standalone/apps/web/.next/server/chunks/[externals]__11vad82._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0.w.o06._.js +4 -0
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__01xaw-k._.js +4 -0
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__09jw.a~._.js +4 -0
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0f~jrb-._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0gx-yyt._.js +4 -0
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0h91.i1._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0pdu84y._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0rv27~3._.js +4 -0
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__110z8gu._.js +20 -0
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__11uz7s9._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/{_0r24f4c._.js → _0jcmjdn._.js} +31 -4
- package/.next/standalone/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_comments_route_actions_0cubv9d.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_diff_route_actions_03v97p2.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_discard_route_actions_0atmzwp.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_file_route_actions_00.gqla.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_files_route_actions_0ywjjl0.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_health_route_actions_08i7v0h.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_watch_route_actions_0k9olin.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/apps_web__next-internal_server_app_favicon_ico_route_actions_0h5n1et.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{0fuv_next_dist_0msbqso._.js → [root-of-the-server]__0djkqf8._.js} +3 -3
- package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0giwc4b._.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{[root-of-the-server]__0licei1._.js → [root-of-the-server]__0t1vjyl._.js} +2 -2
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0oc3qg_._.js +31 -3
- package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web__next-internal_server_app__global-error_page_actions_0.u5cfa.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web__next-internal_server_app__not-found_page_actions_0appun9.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web__next-internal_server_app_page_actions_0rm5_5w.js +1 -1
- package/.next/standalone/apps/web/.next/server/functions-config-manifest.json +13 -1
- package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/apps/web/.next/server/middleware.js +5 -0
- package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
- package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
- package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/apps/web/.next/static/chunks/0_87y4ku2u.g6.js +44 -0
- package/.next/standalone/apps/web/.next/static/chunks/0lo6jalz5wqe-.css +3 -0
- package/.next/standalone/apps/web/.next/static/chunks/{080bf48.keyld.js → 0tp4_-fc0.o0m.js} +1 -1
- package/.next/standalone/apps/web/.next/static/chunks/{0qp8t.3t~v6um.js → 129j.vkoufmaw.js} +1 -1
- package/.next/standalone/apps/web/.next/static/f0Q3TSdyAWb6O9i9BUoq8/_clientMiddlewareManifest.js +6 -0
- package/.next/standalone/apps/web/package.json +10 -4
- package/.next/standalone/apps/web/server.js +7 -1
- package/bin/diffhub.mjs +913 -77
- package/package.json +10 -4
- package/.next/standalone/apps/web/.next/server/app/api/open/route/app-paths-manifest.json +0 -3
- package/.next/standalone/apps/web/.next/server/app/api/open/route.js +0 -6
- package/.next/standalone/apps/web/.next/server/app/api/open/route_client-reference-manifest.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__01.zj5h._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__05ejtyr._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0e2dp4h._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0egk6ui._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0i6i-~n._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0l9skgg._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0sv4hr9._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0tbrp5x._.js +0 -13
- package/.next/standalone/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_open_route_actions_0pjyc8r.js +0 -3
- package/.next/standalone/apps/web/.next/static/50OFqtYzVfKmfsBAKOjr3/_clientMiddlewareManifest.js +0 -1
- package/.next/standalone/apps/web/.next/static/chunks/0b4pujbysgmxg.js +0 -16
- package/.next/standalone/apps/web/.next/static/chunks/130i667qy-j80.css +0 -3
- /package/.next/standalone/apps/web/.next/server/app/api/{open → health}/route/build-manifest.json +0 -0
- /package/.next/standalone/apps/web/.next/server/app/api/{open → health}/route/server-reference-manifest.json +0 -0
- /package/.next/standalone/apps/web/.next/server/app/api/{open → health}/route.js.map +0 -0
- /package/.next/standalone/apps/web/.next/static/{50OFqtYzVfKmfsBAKOjr3 → f0Q3TSdyAWb6O9i9BUoq8}/_buildManifest.js +0 -0
- /package/.next/standalone/apps/web/.next/static/{50OFqtYzVfKmfsBAKOjr3 → f0Q3TSdyAWb6O9i9BUoq8}/_ssgManifest.js +0 -0
package/bin/diffhub.mjs
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { watch } from "chokidar";
|
|
2
3
|
import { program } from "commander";
|
|
3
|
-
import { execFileSync, spawn } from "node:child_process";
|
|
4
|
-
import {
|
|
4
|
+
import { execFile as execFileCb, execFileSync, spawn } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import {
|
|
7
|
+
cpSync,
|
|
8
|
+
createWriteStream,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
unlinkSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
5
17
|
import { createServer } from "node:net";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
6
19
|
import { join, resolve } from "node:path";
|
|
20
|
+
import { promisify } from "node:util";
|
|
7
21
|
|
|
22
|
+
const execFile = promisify(execFileCb);
|
|
8
23
|
const __dirname = import.meta.dirname;
|
|
24
|
+
const PREFERRED_BASE_BRANCHES = ["main", "master", "develop", "dev"];
|
|
9
25
|
|
|
10
26
|
// Fast-fail on unsupported Node.js versions
|
|
11
27
|
const nodeMajor = Number.parseInt(process.version.slice(1).split(".")[0], 10);
|
|
@@ -36,13 +52,31 @@ const findFreePort = async (start) => {
|
|
|
36
52
|
return start;
|
|
37
53
|
};
|
|
38
54
|
|
|
39
|
-
const waitForServer = async (
|
|
55
|
+
const waitForServer = async (
|
|
56
|
+
port,
|
|
57
|
+
maxMs = 15_000,
|
|
58
|
+
expectedPid = null,
|
|
59
|
+
expectedBootId = null,
|
|
60
|
+
expectedRepoPath = null,
|
|
61
|
+
) => {
|
|
40
62
|
const deadline = Date.now() + maxMs;
|
|
41
63
|
while (Date.now() < deadline) {
|
|
42
64
|
try {
|
|
43
|
-
const res = await fetch(`http://127.0.0.1:${port}`);
|
|
44
|
-
if (res.ok
|
|
45
|
-
|
|
65
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/health`);
|
|
66
|
+
if (res.ok) {
|
|
67
|
+
const data = await res.json();
|
|
68
|
+
const bootMatches = expectedBootId === null || data.bootId === expectedBootId;
|
|
69
|
+
const repoMatches = expectedRepoPath === null || data.repoPath === expectedRepoPath;
|
|
70
|
+
if (!bootMatches || !repoMatches) {
|
|
71
|
+
// wrong server on the fixed port
|
|
72
|
+
} else if (expectedPid === null) {
|
|
73
|
+
return true;
|
|
74
|
+
} else {
|
|
75
|
+
const pids = await getListeningPids(port);
|
|
76
|
+
if (pids.includes(expectedPid)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
46
80
|
}
|
|
47
81
|
} catch {
|
|
48
82
|
// empty
|
|
@@ -78,23 +112,13 @@ const syncStandaloneAssets = (appDir, standaloneDir) => {
|
|
|
78
112
|
}
|
|
79
113
|
};
|
|
80
114
|
|
|
81
|
-
// --
|
|
115
|
+
// -- Shared setup ------------------------------------------------------------
|
|
82
116
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
.option("-p, --port <port>", "Port to serve on", "2047")
|
|
88
|
-
.option("-r, --repo <path>", "Git repository path (defaults to cwd)")
|
|
89
|
-
.option("-b, --base <branch>", "Base branch to diff against (defaults to main/master)")
|
|
90
|
-
.option("--no-open", "Don't open browser automatically")
|
|
91
|
-
.parse(process.argv);
|
|
92
|
-
|
|
93
|
-
const opts = program.opts();
|
|
94
|
-
const inputPath = resolve(opts.repo ?? process.cwd());
|
|
95
|
-
const baseBranch = opts.base ?? "";
|
|
117
|
+
const appDir = resolve(__dirname, "..");
|
|
118
|
+
const serverPath = join(appDir, ".next", "standalone", "apps", "web", "server.js");
|
|
119
|
+
const standaloneDir = resolve(serverPath, "..");
|
|
120
|
+
const CMUX_PATH = "/Applications/cmux.app/Contents/Resources/bin/cmux";
|
|
96
121
|
|
|
97
|
-
// Find git repo root (works from any subdirectory)
|
|
98
122
|
const findRepoRoot = (startPath) => {
|
|
99
123
|
try {
|
|
100
124
|
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
@@ -107,78 +131,890 @@ const findRepoRoot = (startPath) => {
|
|
|
107
131
|
}
|
|
108
132
|
};
|
|
109
133
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
134
|
+
const validateRepo = (inputPath) => {
|
|
135
|
+
const root = findRepoRoot(inputPath);
|
|
136
|
+
if (!root) {
|
|
137
|
+
console.error(`❌ Not a git repository: ${inputPath}`);
|
|
138
|
+
console.error(` Run from inside a git repo, or pass --repo:`);
|
|
139
|
+
console.error(` diffhub --repo /path/to/your-repo`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
return root;
|
|
143
|
+
};
|
|
117
144
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
145
|
+
const validateBuild = () => {
|
|
146
|
+
if (!existsSync(serverPath)) {
|
|
147
|
+
console.error("❌ No production build found.");
|
|
148
|
+
console.error(" Run: npm run build");
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
122
152
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
153
|
+
const getCmuxServerLogPath = (repoPath) => {
|
|
154
|
+
const hash = createHash("md5").update(repoPath).digest("hex").slice(0, 8);
|
|
155
|
+
return join(tmpdir(), `diffhub-cmux-${hash}.log`);
|
|
156
|
+
};
|
|
128
157
|
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
158
|
+
const getCmuxWriterPidPath = (repoPath) => {
|
|
159
|
+
const hash = createHash("md5").update(repoPath).digest("hex").slice(0, 8);
|
|
160
|
+
return join(tmpdir(), `diffhub-cmux-writer-${hash}.pid`);
|
|
161
|
+
};
|
|
132
162
|
|
|
133
|
-
|
|
163
|
+
const createServerBootId = (repoPath, baseBranch) =>
|
|
164
|
+
createHash("sha1")
|
|
165
|
+
.update(`${repoPath}:${baseBranch}:${Date.now()}:${Math.random()}`)
|
|
166
|
+
.digest("hex");
|
|
134
167
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
168
|
+
const clearRepoSnapshotFiles = (repoPath) => {
|
|
169
|
+
const prefix = `diffhub-snapshot-${createHash("sha1").update(repoPath).digest("hex")}-`;
|
|
170
|
+
for (const entry of readdirSync(tmpdir())) {
|
|
171
|
+
if (entry.startsWith(prefix)) {
|
|
172
|
+
rmSync(join(tmpdir(), entry), { force: true });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const getSnapshotCachePath = (repoPath, base, mode, whitespace) => {
|
|
178
|
+
const cacheKey = JSON.stringify({
|
|
179
|
+
base: base ?? "",
|
|
180
|
+
mode: mode ?? "",
|
|
181
|
+
whitespace: whitespace ?? "",
|
|
182
|
+
});
|
|
183
|
+
const suffix = createHash("sha1").update(cacheKey).digest("hex");
|
|
184
|
+
const prefix = `diffhub-snapshot-${createHash("sha1").update(repoPath).digest("hex")}-`;
|
|
185
|
+
return join(tmpdir(), `${prefix}${suffix}.json`);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const runGitSnapshotCommand = async (repoPath, args) => {
|
|
189
|
+
const { stdout } = await execFile("git", args, {
|
|
190
|
+
cwd: repoPath,
|
|
191
|
+
encoding: "utf-8",
|
|
192
|
+
env: {
|
|
193
|
+
...process.env,
|
|
194
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
195
|
+
},
|
|
196
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
197
|
+
});
|
|
198
|
+
return stdout;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const splitGitLines = (output) =>
|
|
202
|
+
output
|
|
203
|
+
.split("\n")
|
|
204
|
+
.map((line) => line.trim())
|
|
205
|
+
.filter(Boolean);
|
|
206
|
+
|
|
207
|
+
const parseDiffStats = (raw) => {
|
|
208
|
+
const files = [];
|
|
209
|
+
let insertions = 0;
|
|
210
|
+
let deletions = 0;
|
|
211
|
+
let cursor = 0;
|
|
212
|
+
|
|
213
|
+
while (cursor < raw.length) {
|
|
214
|
+
const insertionsEnd = raw.indexOf("\t", cursor);
|
|
215
|
+
if (insertionsEnd === -1) {
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const deletionsEnd = raw.indexOf("\t", insertionsEnd + 1);
|
|
220
|
+
if (deletionsEnd === -1) {
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
142
223
|
|
|
143
|
-
|
|
224
|
+
const rawInsertions = raw.slice(cursor, insertionsEnd);
|
|
225
|
+
const rawDeletions = raw.slice(insertionsEnd + 1, deletionsEnd);
|
|
226
|
+
cursor = deletionsEnd + 1;
|
|
144
227
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
228
|
+
let file = "";
|
|
229
|
+
if (raw[cursor] === "\0") {
|
|
230
|
+
cursor += 1;
|
|
231
|
+
|
|
232
|
+
const oldPathEnd = raw.indexOf("\0", cursor);
|
|
233
|
+
if (oldPathEnd === -1) {
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
cursor = oldPathEnd + 1;
|
|
238
|
+
const newPathEnd = raw.indexOf("\0", cursor);
|
|
239
|
+
if (newPathEnd === -1) {
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
file = raw.slice(cursor, newPathEnd);
|
|
244
|
+
cursor = newPathEnd + 1;
|
|
245
|
+
} else {
|
|
246
|
+
const fileEnd = raw.indexOf("\0", cursor);
|
|
247
|
+
if (fileEnd === -1) {
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
file = raw.slice(cursor, fileEnd);
|
|
252
|
+
cursor = fileEnd + 1;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const binary = rawInsertions === "-" || rawDeletions === "-";
|
|
256
|
+
const fileInsertions = binary ? 0 : Number.parseInt(rawInsertions, 10) || 0;
|
|
257
|
+
const fileDeletions = binary ? 0 : Number.parseInt(rawDeletions, 10) || 0;
|
|
258
|
+
|
|
259
|
+
files.push({
|
|
260
|
+
binary,
|
|
261
|
+
changes: fileInsertions + fileDeletions,
|
|
262
|
+
deletions: fileDeletions,
|
|
263
|
+
file,
|
|
264
|
+
insertions: fileInsertions,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
insertions += fileInsertions;
|
|
268
|
+
deletions += fileDeletions;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return { deletions, files, insertions };
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const splitPatchByFile = (patch) => {
|
|
275
|
+
const patches = {};
|
|
276
|
+
const headerPattern = /^diff --git a\/(.+?) b\/(.+)$/gm;
|
|
277
|
+
const entries = [];
|
|
278
|
+
|
|
279
|
+
let match = headerPattern.exec(patch);
|
|
280
|
+
while (match) {
|
|
281
|
+
entries.push({ file: match[2], start: match.index });
|
|
282
|
+
match = headerPattern.exec(patch);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
for (const [index, entry] of entries.entries()) {
|
|
286
|
+
const nextStart = entries[index + 1]?.start ?? patch.length;
|
|
287
|
+
const filePatch = patch.slice(entry.start, nextStart).trimEnd();
|
|
288
|
+
patches[entry.file] = filePatch ? `${filePatch}\n` : "";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return patches;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const createSnapshotGeneration = (bootId, fingerprint, mergeBase) =>
|
|
295
|
+
createHash("sha1").update(`${bootId}:${fingerprint}:${mergeBase}`).digest("hex");
|
|
296
|
+
|
|
297
|
+
const resolveBaseBranch = async (repoPath, explicitBaseBranch) => {
|
|
298
|
+
if (explicitBaseBranch) {
|
|
299
|
+
return explicitBaseBranch;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const remoteBranches = splitGitLines(
|
|
303
|
+
await runGitSnapshotCommand(repoPath, ["branch", "-r", "--format=%(refname:short)"]),
|
|
304
|
+
);
|
|
305
|
+
for (const name of PREFERRED_BASE_BRANCHES) {
|
|
306
|
+
if (remoteBranches.includes(`origin/${name}`)) {
|
|
307
|
+
return `origin/${name}`;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const localBranches = splitGitLines(
|
|
312
|
+
await runGitSnapshotCommand(repoPath, ["branch", "--format=%(refname:short)"]),
|
|
313
|
+
);
|
|
314
|
+
for (const name of PREFERRED_BASE_BRANCHES) {
|
|
315
|
+
if (localBranches.includes(name)) {
|
|
316
|
+
return name;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return "origin/main";
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const buildSnapshot = async (repoPath, explicitBaseBranch, mode, serverBootId) => {
|
|
324
|
+
const branchOutput = await runGitSnapshotCommand(repoPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
325
|
+
const branch = branchOutput.trim();
|
|
326
|
+
const baseBranch =
|
|
327
|
+
mode === "uncommitted" ? "HEAD" : await resolveBaseBranch(repoPath, explicitBaseBranch);
|
|
328
|
+
let mergeBase = "HEAD";
|
|
329
|
+
if (mode !== "uncommitted") {
|
|
330
|
+
const mergeBaseOutput = await runGitSnapshotCommand(repoPath, [
|
|
331
|
+
"merge-base",
|
|
332
|
+
"HEAD",
|
|
333
|
+
baseBranch,
|
|
334
|
+
]);
|
|
335
|
+
mergeBase = mergeBaseOutput.trim();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const diffArgs = [mergeBase];
|
|
339
|
+
const fullPatch = await runGitSnapshotCommand(repoPath, ["diff", ...diffArgs]);
|
|
340
|
+
const rawSummary = await runGitSnapshotCommand(repoPath, [
|
|
341
|
+
"diff",
|
|
342
|
+
"--numstat",
|
|
343
|
+
"-z",
|
|
344
|
+
"-M",
|
|
345
|
+
...diffArgs,
|
|
346
|
+
]);
|
|
347
|
+
const summary = parseDiffStats(rawSummary);
|
|
348
|
+
const fingerprint = createHash("sha1").update(fullPatch).digest("hex");
|
|
349
|
+
const createdAt = Date.now();
|
|
350
|
+
const generation = createSnapshotGeneration(serverBootId, fingerprint, mergeBase);
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
baseBranch,
|
|
354
|
+
branch,
|
|
355
|
+
deletions: summary.deletions,
|
|
356
|
+
files: summary.files,
|
|
357
|
+
fingerprint,
|
|
358
|
+
fullPatch,
|
|
359
|
+
generation,
|
|
360
|
+
insertions: summary.insertions,
|
|
361
|
+
mergeBase,
|
|
362
|
+
metadata: {
|
|
363
|
+
bootId: serverBootId,
|
|
364
|
+
createdAt,
|
|
365
|
+
repoPath,
|
|
366
|
+
},
|
|
367
|
+
patchByFile: splitPatchByFile(fullPatch),
|
|
368
|
+
};
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const shouldIgnoreWatchPath = (pathToCheck, repoPath) => {
|
|
372
|
+
const normalizedRepoPath = repoPath.endsWith("/") ? repoPath : `${repoPath}/`;
|
|
373
|
+
const relativePath = pathToCheck.startsWith(normalizedRepoPath)
|
|
374
|
+
? pathToCheck.slice(normalizedRepoPath.length).replaceAll("\\", "/")
|
|
375
|
+
: pathToCheck.replaceAll("\\", "/");
|
|
376
|
+
|
|
377
|
+
if (!relativePath || relativePath.startsWith("..")) {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
for (const ignoredRoot of [".next", ".turbo", "node_modules"]) {
|
|
382
|
+
if (relativePath === ignoredRoot || relativePath.startsWith(`${ignoredRoot}/`)) {
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (
|
|
388
|
+
(relativePath === ".git" || relativePath.startsWith(".git/")) &&
|
|
389
|
+
relativePath !== ".git/HEAD" &&
|
|
390
|
+
relativePath !== ".git/index" &&
|
|
391
|
+
relativePath !== ".git/packed-refs" &&
|
|
392
|
+
relativePath !== ".git/refs" &&
|
|
393
|
+
!relativePath.startsWith(".git/refs/")
|
|
394
|
+
) {
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return false;
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const startSnapshotWriter = async (repoPath, explicitBaseBranch, serverBootId) => {
|
|
402
|
+
const gitDir = join(repoPath, ".git");
|
|
403
|
+
const watchTargets = [
|
|
404
|
+
repoPath,
|
|
405
|
+
join(gitDir, "HEAD"),
|
|
406
|
+
join(gitDir, "index"),
|
|
407
|
+
join(gitDir, "packed-refs"),
|
|
408
|
+
join(gitDir, "refs"),
|
|
409
|
+
].filter(existsSync);
|
|
410
|
+
|
|
411
|
+
let writeTimer = null;
|
|
412
|
+
let writeInFlight = null;
|
|
413
|
+
let queuedWrite = false;
|
|
414
|
+
|
|
415
|
+
const writeSnapshots = async () => {
|
|
416
|
+
const snapshots = await Promise.all([
|
|
417
|
+
buildSnapshot(repoPath, explicitBaseBranch, undefined, serverBootId),
|
|
418
|
+
buildSnapshot(repoPath, explicitBaseBranch, "uncommitted", serverBootId),
|
|
419
|
+
]);
|
|
420
|
+
|
|
421
|
+
writeFileSync(getSnapshotCachePath(repoPath), JSON.stringify(snapshots[0]), "utf-8");
|
|
422
|
+
writeFileSync(
|
|
423
|
+
getSnapshotCachePath(repoPath, undefined, "uncommitted"),
|
|
424
|
+
JSON.stringify(snapshots[1]),
|
|
425
|
+
"utf-8",
|
|
426
|
+
);
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const queueWrite = async () => {
|
|
430
|
+
if (writeInFlight) {
|
|
431
|
+
queuedWrite = true;
|
|
432
|
+
await writeInFlight;
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
writeInFlight = (async () => {
|
|
437
|
+
try {
|
|
438
|
+
await writeSnapshots();
|
|
439
|
+
} finally {
|
|
440
|
+
writeInFlight = null;
|
|
441
|
+
}
|
|
442
|
+
})();
|
|
443
|
+
|
|
444
|
+
await writeInFlight;
|
|
445
|
+
|
|
446
|
+
if (queuedWrite) {
|
|
447
|
+
queuedWrite = false;
|
|
448
|
+
await queueWrite();
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
await queueWrite();
|
|
453
|
+
|
|
454
|
+
const watcher = watch(watchTargets, {
|
|
455
|
+
ignoreInitial: true,
|
|
456
|
+
ignored: (pathToCheck) => shouldIgnoreWatchPath(pathToCheck, repoPath),
|
|
457
|
+
persistent: true,
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const scheduleWrite = () => {
|
|
461
|
+
if (writeTimer) {
|
|
462
|
+
clearTimeout(writeTimer);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
writeTimer = setTimeout(() => {
|
|
466
|
+
writeTimer = null;
|
|
467
|
+
|
|
468
|
+
void (async () => {
|
|
469
|
+
try {
|
|
470
|
+
await queueWrite();
|
|
471
|
+
} catch (error) {
|
|
472
|
+
console.error("[diffhub] snapshot writer failed", { error });
|
|
473
|
+
}
|
|
474
|
+
})();
|
|
475
|
+
}, 150);
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
watcher.on("add", scheduleWrite);
|
|
479
|
+
watcher.on("addDir", scheduleWrite);
|
|
480
|
+
watcher.on("change", scheduleWrite);
|
|
481
|
+
watcher.on("unlink", scheduleWrite);
|
|
482
|
+
watcher.on("unlinkDir", scheduleWrite);
|
|
483
|
+
watcher.on("error", (error) => {
|
|
484
|
+
console.error("[diffhub] snapshot writer watch failed", { error });
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
return async () => {
|
|
488
|
+
if (writeTimer) {
|
|
489
|
+
clearTimeout(writeTimer);
|
|
490
|
+
writeTimer = null;
|
|
491
|
+
}
|
|
492
|
+
await watcher.close();
|
|
493
|
+
};
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const listSnapshotWriterProcesses = async (repoPath) => {
|
|
497
|
+
try {
|
|
498
|
+
const { stdout } = await execFile("ps", ["-axo", "pid=,command="], {
|
|
499
|
+
encoding: "utf-8",
|
|
500
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const repoMatcher = `--repo ${repoPath}`;
|
|
504
|
+
return stdout
|
|
505
|
+
.split("\n")
|
|
506
|
+
.map((line) => line.trim())
|
|
507
|
+
.filter(Boolean)
|
|
508
|
+
.map((line) => {
|
|
509
|
+
const firstSpace = line.indexOf(" ");
|
|
510
|
+
if (firstSpace === -1) {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const pid = Number.parseInt(line.slice(0, firstSpace), 10);
|
|
515
|
+
const command = line.slice(firstSpace + 1);
|
|
516
|
+
if (
|
|
517
|
+
!Number.isInteger(pid) ||
|
|
518
|
+
pid <= 0 ||
|
|
519
|
+
pid === process.pid ||
|
|
520
|
+
!command.includes("internal-snapshot-writer") ||
|
|
521
|
+
!command.includes(repoMatcher)
|
|
522
|
+
) {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return { command, pid };
|
|
527
|
+
})
|
|
528
|
+
.filter(Boolean);
|
|
529
|
+
} catch {
|
|
530
|
+
return [];
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const waitForProcessesToExit = async (pids, maxMs = 3000) => {
|
|
535
|
+
if (pids.length === 0) {
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const deadline = Date.now() + maxMs;
|
|
540
|
+
while (Date.now() < deadline) {
|
|
541
|
+
const stillRunning = pids.filter((pid) => {
|
|
542
|
+
try {
|
|
543
|
+
process.kill(pid, 0);
|
|
544
|
+
return true;
|
|
545
|
+
} catch {
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
if (stillRunning.length === 0) {
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// oxlint-disable-next-line promise/avoid-new
|
|
555
|
+
await new Promise((_resolve) => {
|
|
556
|
+
setTimeout(_resolve, 100);
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return false;
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const stopSnapshotWriterProcess = async (repoPath) => {
|
|
564
|
+
const pidPath = getCmuxWriterPidPath(repoPath);
|
|
565
|
+
const targetPids = new Set();
|
|
566
|
+
|
|
567
|
+
try {
|
|
568
|
+
const pid = Number.parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
|
|
569
|
+
if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) {
|
|
570
|
+
targetPids.add(pid);
|
|
571
|
+
}
|
|
572
|
+
} catch {
|
|
573
|
+
// empty
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const runningWriters = await listSnapshotWriterProcesses(repoPath);
|
|
577
|
+
for (const writer of runningWriters) {
|
|
578
|
+
targetPids.add(writer.pid);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
for (const pid of targetPids) {
|
|
582
|
+
try {
|
|
583
|
+
process.kill(pid, "SIGTERM");
|
|
584
|
+
} catch {
|
|
585
|
+
// empty
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const remainingPids = [...targetPids];
|
|
590
|
+
const exited = await waitForProcessesToExit(remainingPids);
|
|
591
|
+
if (!exited) {
|
|
592
|
+
for (const pid of remainingPids) {
|
|
593
|
+
try {
|
|
594
|
+
process.kill(pid, "SIGKILL");
|
|
595
|
+
} catch {
|
|
596
|
+
// empty
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
await waitForProcessesToExit(remainingPids, 1000);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
unlinkSync(pidPath);
|
|
605
|
+
} catch {
|
|
606
|
+
// empty
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const REPO_POINTER = "/tmp/diffhub-active-repo";
|
|
611
|
+
|
|
612
|
+
const readRepoPointer = () => {
|
|
613
|
+
try {
|
|
614
|
+
const repoPath = readFileSync(REPO_POINTER, "utf-8").trim();
|
|
615
|
+
return repoPath || null;
|
|
616
|
+
} catch {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const writeRepoPointer = (repoPath) => {
|
|
622
|
+
writeFileSync(REPO_POINTER, `${repoPath}\n`);
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
const getListeningPids = async (port) => {
|
|
626
|
+
try {
|
|
627
|
+
const { stdout } = await execFile("lsof", [`-nP`, `-tiTCP:${port}`, `-sTCP:LISTEN`], {
|
|
628
|
+
encoding: "utf-8",
|
|
629
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
630
|
+
});
|
|
631
|
+
return [
|
|
632
|
+
...new Set(
|
|
633
|
+
stdout
|
|
634
|
+
.split(/\s+/)
|
|
635
|
+
.map((pid) => Number.parseInt(pid, 10))
|
|
636
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0),
|
|
637
|
+
),
|
|
638
|
+
];
|
|
639
|
+
} catch {
|
|
640
|
+
return [];
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const waitForPortRelease = async (port, maxMs = 5000) => {
|
|
645
|
+
const deadline = Date.now() + maxMs;
|
|
646
|
+
while (Date.now() < deadline) {
|
|
647
|
+
const listeningPids = await getListeningPids(port);
|
|
648
|
+
if (listeningPids.length === 0) {
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
651
|
+
// oxlint-disable-next-line promise/avoid-new
|
|
652
|
+
await new Promise((_resolve) => {
|
|
653
|
+
setTimeout(_resolve, 200);
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
return false;
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const stopListeningProcesses = async (port) => {
|
|
660
|
+
const pids = await getListeningPids(port);
|
|
661
|
+
const targets = pids.filter((pid) => pid !== process.pid);
|
|
662
|
+
|
|
663
|
+
if (targets.length === 0) {
|
|
664
|
+
return [];
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
for (const pid of targets) {
|
|
668
|
+
try {
|
|
669
|
+
process.kill(pid, "SIGTERM");
|
|
670
|
+
} catch {
|
|
671
|
+
// empty
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (await waitForPortRelease(port, 3000)) {
|
|
676
|
+
return targets;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
for (const pid of targets) {
|
|
680
|
+
try {
|
|
681
|
+
process.kill(pid, "SIGKILL");
|
|
682
|
+
} catch {
|
|
683
|
+
// empty
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
await waitForPortRelease(port, 3000);
|
|
688
|
+
return targets;
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
const getServerHealth = async (port) => {
|
|
692
|
+
try {
|
|
693
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/health`);
|
|
694
|
+
if (!res.ok) {
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return await res.json();
|
|
699
|
+
} catch {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
const stopServeServersForRepo = async (repoPath, startPort, portCount = 10) => {
|
|
705
|
+
const stoppedPorts = [];
|
|
706
|
+
|
|
707
|
+
for (let port = startPort; port < startPort + portCount; port += 1) {
|
|
708
|
+
const health = await getServerHealth(port);
|
|
709
|
+
if (!health || health.cmux || health.repoPath !== repoPath) {
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const stopped = await stopListeningProcesses(port);
|
|
714
|
+
if (stopped.length > 0) {
|
|
715
|
+
stoppedPorts.push(port);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return stoppedPorts;
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
const syncCmuxRepoPointer = (repoPath) => {
|
|
723
|
+
const previousPointer = readRepoPointer();
|
|
724
|
+
writeRepoPointer(repoPath);
|
|
725
|
+
|
|
726
|
+
return () => {
|
|
727
|
+
try {
|
|
728
|
+
const currentPointer = readRepoPointer();
|
|
729
|
+
if (currentPointer !== null && currentPointer !== repoPath) {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (previousPointer === null) {
|
|
734
|
+
rmSync(REPO_POINTER, { force: true });
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
writeRepoPointer(previousPointer);
|
|
739
|
+
} catch {
|
|
740
|
+
// empty
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
const startServer = (repoPath, baseBranch, port, options = {}) => {
|
|
746
|
+
const {
|
|
747
|
+
cmux = false,
|
|
748
|
+
detached = false,
|
|
749
|
+
disableWatch,
|
|
750
|
+
logPath,
|
|
751
|
+
serverBootId = createServerBootId(repoPath, baseBranch),
|
|
752
|
+
} = options;
|
|
753
|
+
const shouldDisableWatch = disableWatch ?? Boolean(logPath);
|
|
754
|
+
let stdio = ["ignore", "inherit", "inherit"];
|
|
755
|
+
let logStream = null;
|
|
756
|
+
|
|
757
|
+
if (logPath) {
|
|
758
|
+
writeFileSync(logPath, "");
|
|
759
|
+
stdio = ["ignore", "pipe", "pipe"];
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const serverEnv = {
|
|
148
763
|
...process.env,
|
|
149
|
-
DIFFHUB_REPO: repoPath,
|
|
150
764
|
...(baseBranch ? { DIFFHUB_BASE: baseBranch } : {}),
|
|
765
|
+
...(cmux ? { DIFFHUB_CMUX: "1" } : {}),
|
|
766
|
+
...(logPath ? { DIFFHUB_DISABLE_PRERENDER: "1" } : {}),
|
|
767
|
+
DIFFHUB_REPO: repoPath,
|
|
768
|
+
DIFFHUB_SERVER_BOOT_ID: serverBootId,
|
|
151
769
|
HOSTNAME: "127.0.0.1",
|
|
152
770
|
NODE_ENV: "production",
|
|
153
771
|
PORT: String(port),
|
|
154
|
-
}
|
|
155
|
-
stdio: "inherit",
|
|
156
|
-
});
|
|
772
|
+
};
|
|
157
773
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
774
|
+
if (shouldDisableWatch) {
|
|
775
|
+
serverEnv.DIFFHUB_DISABLE_WATCH = "1";
|
|
776
|
+
} else {
|
|
777
|
+
delete serverEnv.DIFFHUB_DISABLE_WATCH;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const server = spawn("node", ["server.js"], {
|
|
781
|
+
cwd: standaloneDir,
|
|
782
|
+
detached,
|
|
783
|
+
env: serverEnv,
|
|
784
|
+
stdio,
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
if (logPath) {
|
|
788
|
+
logStream = createWriteStream(logPath, { flags: "a" });
|
|
789
|
+
|
|
790
|
+
if (server.stdout) {
|
|
791
|
+
server.stdout.pipe(logStream);
|
|
792
|
+
}
|
|
793
|
+
if (server.stderr) {
|
|
794
|
+
server.stderr.pipe(logStream);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (detached) {
|
|
798
|
+
server.unref();
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
server.on("exit", () => {
|
|
802
|
+
if (logStream) {
|
|
803
|
+
logStream.end();
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
server.on("error", (err) => {
|
|
809
|
+
console.error("Failed to start server:", err.message);
|
|
810
|
+
process.exit(1);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
return { bootId: serverBootId, server };
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
const derivePort = (repoPath) => {
|
|
817
|
+
const hash = createHash("md5").update(repoPath).digest("hex");
|
|
818
|
+
const num = Number.parseInt(hash.slice(0, 8), 16) % 10_000;
|
|
819
|
+
return 20_000 + num;
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
// -- cmux utilities ----------------------------------------------------------
|
|
823
|
+
|
|
824
|
+
const cmuxExec = async (args) => {
|
|
825
|
+
const { stdout } = await execFile(CMUX_PATH, args, {
|
|
826
|
+
encoding: "utf-8",
|
|
827
|
+
env: process.env,
|
|
828
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
829
|
+
});
|
|
830
|
+
return stdout;
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
const cmuxNotify = (title, body) => cmuxExec(["notify", "--title", title, "--body", body]);
|
|
834
|
+
|
|
835
|
+
const cmuxOpenSplit = async (url) => {
|
|
836
|
+
const out = await cmuxExec(["--json", "browser", "open-split", url]);
|
|
837
|
+
const match = out.match(/"surface_ref"\s*:\s*"(surface:[^"]+)"/);
|
|
838
|
+
return match?.[1] ?? null;
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
const cmuxSurfaceAlive = async (surfaceRef) => {
|
|
842
|
+
try {
|
|
843
|
+
const out = await cmuxExec(["surface-health"]);
|
|
844
|
+
return out.includes(surfaceRef);
|
|
845
|
+
} catch {
|
|
846
|
+
return false;
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
const sleep = (ms) =>
|
|
851
|
+
// oxlint-disable-next-line promise/avoid-new
|
|
852
|
+
new Promise((_resolve) => {
|
|
853
|
+
setTimeout(_resolve, ms);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
const internalSnapshotWriterAction = async (opts) => {
|
|
857
|
+
const repoPath = validateRepo(resolve(opts.repo));
|
|
858
|
+
const stopSnapshotWriter = await startSnapshotWriter(repoPath, opts.base ?? "", opts.bootId);
|
|
859
|
+
|
|
860
|
+
const cleanup = async () => {
|
|
861
|
+
await stopSnapshotWriter();
|
|
862
|
+
process.exit(0);
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
process.on("SIGINT", cleanup);
|
|
866
|
+
process.on("SIGTERM", cleanup);
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
// -- serve action (default) --------------------------------------------------
|
|
870
|
+
|
|
871
|
+
const serveAction = async (opts) => {
|
|
872
|
+
const inputPath = resolve(opts.repo ?? process.cwd());
|
|
873
|
+
const baseBranch = opts.base ?? "";
|
|
874
|
+
const requestedPort = Number.parseInt(opts.port, 10);
|
|
875
|
+
|
|
876
|
+
const repoPath = validateRepo(inputPath);
|
|
877
|
+
validateBuild();
|
|
878
|
+
syncStandaloneAssets(appDir, standaloneDir);
|
|
879
|
+
|
|
880
|
+
const replacedPorts = await stopServeServersForRepo(repoPath, requestedPort);
|
|
881
|
+
const port = await findFreePort(requestedPort);
|
|
882
|
+
|
|
883
|
+
console.log(` diffhub\n`);
|
|
884
|
+
console.log(` Repo ${repoPath}`);
|
|
885
|
+
if (baseBranch) {
|
|
886
|
+
console.log(` Base ${baseBranch}`);
|
|
887
|
+
}
|
|
888
|
+
if (replacedPorts.length > 0) {
|
|
889
|
+
console.log(` Reused ${replacedPorts.map((value) => `:${value}`).join(", ")}`);
|
|
890
|
+
}
|
|
891
|
+
console.log(` URL http://localhost:${port}`);
|
|
892
|
+
console.log(`\n Press Ctrl+C to stop\n`);
|
|
893
|
+
|
|
894
|
+
const { server } = startServer(repoPath, baseBranch, port);
|
|
895
|
+
|
|
896
|
+
if (opts.open !== false) {
|
|
897
|
+
const url = `http://localhost:${port}`;
|
|
898
|
+
const ready = await waitForServer(port);
|
|
899
|
+
if (ready) {
|
|
900
|
+
const opener =
|
|
901
|
+
{ darwin: "open", linux: "xdg-open", win32: "start" }[process.platform] ?? "xdg-open";
|
|
902
|
+
spawn(opener, [url], {
|
|
903
|
+
detached: true,
|
|
904
|
+
shell: process.platform === "win32",
|
|
905
|
+
stdio: "ignore",
|
|
906
|
+
}).unref();
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const cleanup = () => {
|
|
911
|
+
server.kill();
|
|
912
|
+
process.exit(0);
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
process.on("SIGINT", cleanup);
|
|
916
|
+
process.on("SIGTERM", cleanup);
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
// -- cmux action -------------------------------------------------------------
|
|
920
|
+
|
|
921
|
+
const cmuxAction = async (opts) => {
|
|
922
|
+
if (!existsSync(CMUX_PATH)) {
|
|
923
|
+
console.error("❌ cmux not found at", CMUX_PATH);
|
|
924
|
+
console.error(" Install cmux: https://cmux.app/");
|
|
925
|
+
process.exit(1);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const inputPath = resolve(opts.repo ?? process.cwd());
|
|
929
|
+
const baseBranch = opts.base ?? "";
|
|
162
930
|
|
|
163
|
-
|
|
931
|
+
const repoPath = validateRepo(inputPath);
|
|
932
|
+
validateBuild();
|
|
933
|
+
syncStandaloneAssets(appDir, standaloneDir);
|
|
164
934
|
|
|
165
|
-
|
|
935
|
+
const port = derivePort(repoPath);
|
|
166
936
|
const url = `http://localhost:${port}`;
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
937
|
+
const serverLogPath = getCmuxServerLogPath(repoPath);
|
|
938
|
+
const restoreRepoPointer = syncCmuxRepoPointer(repoPath);
|
|
939
|
+
const serverBootId = createServerBootId(repoPath, baseBranch);
|
|
940
|
+
|
|
941
|
+
await stopListeningProcesses(port);
|
|
942
|
+
await stopSnapshotWriterProcess(repoPath);
|
|
943
|
+
clearRepoSnapshotFiles(repoPath);
|
|
944
|
+
|
|
945
|
+
await cmuxNotify("diffhub", "Starting server...");
|
|
946
|
+
|
|
947
|
+
// Let the server handle file watching and diff computation directly.
|
|
948
|
+
// The external snapshot writer is not used — it hits EBADF errors on
|
|
949
|
+
// macOS when chokidar's FSEvents interacts with child_process spawning.
|
|
950
|
+
// The server's built-in fs.watch + async spawn pipeline works reliably.
|
|
951
|
+
const { bootId, server } = startServer(repoPath, baseBranch, port, {
|
|
952
|
+
cmux: true,
|
|
953
|
+
detached: true,
|
|
954
|
+
disableWatch: false,
|
|
955
|
+
logPath: serverLogPath,
|
|
956
|
+
serverBootId,
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
const cleanup = () => {
|
|
960
|
+
server.kill();
|
|
961
|
+
restoreRepoPointer();
|
|
962
|
+
process.exit(0);
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
process.on("SIGINT", cleanup);
|
|
966
|
+
process.on("SIGTERM", cleanup);
|
|
967
|
+
|
|
968
|
+
const ready = await waitForServer(port, 15_000, server.pid, bootId, repoPath);
|
|
969
|
+
if (!ready) {
|
|
970
|
+
await cmuxNotify("diffhub", "Server failed to start");
|
|
971
|
+
cleanup();
|
|
972
|
+
return;
|
|
176
973
|
}
|
|
177
|
-
}
|
|
178
974
|
|
|
179
|
-
|
|
975
|
+
await cmuxNotify("diffhub", `Opening diff: ${repoPath}`);
|
|
976
|
+
|
|
977
|
+
const surfaceRef = await cmuxOpenSplit(url);
|
|
978
|
+
if (!surfaceRef) {
|
|
979
|
+
console.log("Browser opened (surface tracking unavailable)");
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
console.log(`Opened surface ${surfaceRef} — waiting for it to close...`);
|
|
984
|
+
console.log(`Server log: ${serverLogPath}`);
|
|
985
|
+
|
|
986
|
+
while (await cmuxSurfaceAlive(surfaceRef)) {
|
|
987
|
+
await sleep(1000);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
cleanup();
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
// -- CLI setup ---------------------------------------------------------------
|
|
994
|
+
|
|
995
|
+
program.name("diffhub").description("GitHub PR-style local diff viewer").version("0.1.0");
|
|
996
|
+
|
|
997
|
+
program
|
|
998
|
+
.command("serve", { isDefault: true })
|
|
999
|
+
.description("Start diffhub server")
|
|
1000
|
+
.option("-p, --port <port>", "Port to serve on", "2047")
|
|
1001
|
+
.option("-r, --repo <path>", "Git repository path (defaults to cwd)")
|
|
1002
|
+
.option("-b, --base <branch>", "Base branch to diff against (defaults to main/master)")
|
|
1003
|
+
.option("--no-open", "Don't open browser automatically")
|
|
1004
|
+
.action(serveAction);
|
|
1005
|
+
|
|
1006
|
+
program
|
|
1007
|
+
.command("cmux")
|
|
1008
|
+
.description("Open in cmux browser split pane")
|
|
1009
|
+
.option("-r, --repo <path>", "Git repository path (defaults to cwd)")
|
|
1010
|
+
.option("-b, --base <branch>", "Base branch to diff against")
|
|
1011
|
+
.action(cmuxAction);
|
|
1012
|
+
|
|
1013
|
+
program
|
|
1014
|
+
.command("internal-snapshot-writer")
|
|
1015
|
+
.option("-r, --repo <path>", "Git repository path")
|
|
1016
|
+
.option("-b, --base <branch>", "Base branch to diff against")
|
|
1017
|
+
.requiredOption("--boot-id <id>", "Boot identifier for snapshot generation")
|
|
1018
|
+
.action(internalSnapshotWriterAction);
|
|
180
1019
|
|
|
181
|
-
process.
|
|
182
|
-
server.kill();
|
|
183
|
-
process.exit(0);
|
|
184
|
-
});
|
|
1020
|
+
program.parse(process.argv);
|