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.
Files changed (123) hide show
  1. package/.next/standalone/apps/web/.next/BUILD_ID +1 -1
  2. package/.next/standalone/apps/web/.next/app-path-routes-manifest.json +2 -1
  3. package/.next/standalone/apps/web/.next/build-manifest.json +3 -3
  4. package/.next/standalone/apps/web/.next/prerender-manifest.json +3 -3
  5. package/.next/standalone/apps/web/.next/routes-manifest.json +9 -3
  6. package/.next/standalone/apps/web/.next/server/app/_global-error/page.js +2 -2
  7. package/.next/standalone/apps/web/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/apps/web/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/apps/web/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  11. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  13. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  14. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/standalone/apps/web/.next/server/app/_not-found/page.js +2 -2
  16. package/.next/standalone/apps/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/apps/web/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/apps/web/.next/server/app/_not-found.rsc +11 -11
  20. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_full.segment.rsc +11 -11
  21. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  22. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_index.segment.rsc +6 -6
  23. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  24. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  25. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  26. package/.next/standalone/apps/web/.next/server/app/api/comments/route.js +3 -2
  27. package/.next/standalone/apps/web/.next/server/app/api/comments/route.js.nft.json +1 -1
  28. package/.next/standalone/apps/web/.next/server/app/api/diff/route.js +3 -4
  29. package/.next/standalone/apps/web/.next/server/app/api/diff/route.js.nft.json +1 -1
  30. package/.next/standalone/apps/web/.next/server/app/api/discard/route.js +3 -3
  31. package/.next/standalone/apps/web/.next/server/app/api/discard/route.js.nft.json +1 -1
  32. package/.next/standalone/apps/web/.next/server/app/api/file/route.js +3 -3
  33. package/.next/standalone/apps/web/.next/server/app/api/file/route.js.nft.json +1 -1
  34. package/.next/standalone/apps/web/.next/server/app/api/files/route.js +3 -3
  35. package/.next/standalone/apps/web/.next/server/app/api/files/route.js.nft.json +1 -1
  36. package/.next/standalone/apps/web/.next/server/app/api/health/route/app-paths-manifest.json +3 -0
  37. package/.next/standalone/apps/web/.next/server/app/api/health/route.js +6 -0
  38. package/.next/standalone/apps/web/.next/server/app/api/health/route.js.nft.json +1 -0
  39. package/.next/standalone/apps/web/.next/server/app/api/health/route_client-reference-manifest.js +3 -0
  40. package/.next/standalone/apps/web/.next/server/app/api/watch/route/app-paths-manifest.json +3 -0
  41. package/.next/standalone/apps/web/.next/server/app/api/watch/route/build-manifest.json +9 -0
  42. package/.next/standalone/apps/web/.next/server/app/api/watch/route/server-reference-manifest.json +4 -0
  43. package/.next/standalone/apps/web/.next/server/app/api/watch/route.js +6 -0
  44. package/.next/standalone/apps/web/.next/server/app/api/watch/route.js.map +5 -0
  45. package/.next/standalone/apps/web/.next/server/app/api/{open → watch}/route.js.nft.json +1 -1
  46. package/.next/standalone/apps/web/.next/server/app/api/watch/route_client-reference-manifest.js +3 -0
  47. package/.next/standalone/apps/web/.next/server/app/favicon.ico/route.js +2 -1
  48. package/.next/standalone/apps/web/.next/server/app/favicon.ico/route.js.nft.json +1 -1
  49. package/.next/standalone/apps/web/.next/server/app/index.html +1 -1
  50. package/.next/standalone/apps/web/.next/server/app/index.rsc +12 -12
  51. package/.next/standalone/apps/web/.next/server/app/index.segments/__PAGE__.segment.rsc +3 -3
  52. package/.next/standalone/apps/web/.next/server/app/index.segments/_full.segment.rsc +12 -12
  53. package/.next/standalone/apps/web/.next/server/app/index.segments/_head.segment.rsc +4 -4
  54. package/.next/standalone/apps/web/.next/server/app/index.segments/_index.segment.rsc +6 -6
  55. package/.next/standalone/apps/web/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  56. package/.next/standalone/apps/web/.next/server/app/page.js +2 -2
  57. package/.next/standalone/apps/web/.next/server/app/page.js.nft.json +1 -1
  58. package/.next/standalone/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/apps/web/.next/server/app-paths-manifest.json +2 -1
  60. package/.next/standalone/apps/web/.next/server/chunks/0fuv_next_0e28~xv._.js +13 -0
  61. package/.next/standalone/apps/web/.next/server/chunks/[externals]__11vad82._.js +3 -0
  62. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0.w.o06._.js +4 -0
  63. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__01xaw-k._.js +4 -0
  64. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__09jw.a~._.js +4 -0
  65. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0f~jrb-._.js +3 -0
  66. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0gx-yyt._.js +4 -0
  67. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0h91.i1._.js +3 -0
  68. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0pdu84y._.js +3 -0
  69. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0rv27~3._.js +4 -0
  70. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__110z8gu._.js +20 -0
  71. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__11uz7s9._.js +3 -0
  72. package/.next/standalone/apps/web/.next/server/chunks/{_0r24f4c._.js → _0jcmjdn._.js} +31 -4
  73. package/.next/standalone/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_comments_route_actions_0cubv9d.js +1 -1
  74. package/.next/standalone/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_diff_route_actions_03v97p2.js +1 -1
  75. package/.next/standalone/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_discard_route_actions_0atmzwp.js +1 -1
  76. package/.next/standalone/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_file_route_actions_00.gqla.js +1 -1
  77. package/.next/standalone/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_files_route_actions_0ywjjl0.js +1 -1
  78. package/.next/standalone/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_health_route_actions_08i7v0h.js +3 -0
  79. package/.next/standalone/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_watch_route_actions_0k9olin.js +3 -0
  80. package/.next/standalone/apps/web/.next/server/chunks/apps_web__next-internal_server_app_favicon_ico_route_actions_0h5n1et.js +1 -1
  81. package/.next/standalone/apps/web/.next/server/chunks/ssr/{0fuv_next_dist_0msbqso._.js → [root-of-the-server]__0djkqf8._.js} +3 -3
  82. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0giwc4b._.js +1 -1
  83. package/.next/standalone/apps/web/.next/server/chunks/ssr/{[root-of-the-server]__0licei1._.js → [root-of-the-server]__0t1vjyl._.js} +2 -2
  84. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0oc3qg_._.js +31 -3
  85. package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web__next-internal_server_app__global-error_page_actions_0.u5cfa.js +1 -1
  86. package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web__next-internal_server_app__not-found_page_actions_0appun9.js +1 -1
  87. package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web__next-internal_server_app_page_actions_0rm5_5w.js +1 -1
  88. package/.next/standalone/apps/web/.next/server/functions-config-manifest.json +13 -1
  89. package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +3 -3
  90. package/.next/standalone/apps/web/.next/server/middleware.js +5 -0
  91. package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
  92. package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
  93. package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
  94. package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
  95. package/.next/standalone/apps/web/.next/static/chunks/0_87y4ku2u.g6.js +44 -0
  96. package/.next/standalone/apps/web/.next/static/chunks/0lo6jalz5wqe-.css +3 -0
  97. package/.next/standalone/apps/web/.next/static/chunks/{080bf48.keyld.js → 0tp4_-fc0.o0m.js} +1 -1
  98. package/.next/standalone/apps/web/.next/static/chunks/{0qp8t.3t~v6um.js → 129j.vkoufmaw.js} +1 -1
  99. package/.next/standalone/apps/web/.next/static/f0Q3TSdyAWb6O9i9BUoq8/_clientMiddlewareManifest.js +6 -0
  100. package/.next/standalone/apps/web/package.json +10 -4
  101. package/.next/standalone/apps/web/server.js +7 -1
  102. package/bin/diffhub.mjs +913 -77
  103. package/package.json +10 -4
  104. package/.next/standalone/apps/web/.next/server/app/api/open/route/app-paths-manifest.json +0 -3
  105. package/.next/standalone/apps/web/.next/server/app/api/open/route.js +0 -6
  106. package/.next/standalone/apps/web/.next/server/app/api/open/route_client-reference-manifest.js +0 -3
  107. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__01.zj5h._.js +0 -3
  108. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__05ejtyr._.js +0 -3
  109. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0e2dp4h._.js +0 -3
  110. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0egk6ui._.js +0 -3
  111. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0i6i-~n._.js +0 -3
  112. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0l9skgg._.js +0 -3
  113. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0sv4hr9._.js +0 -3
  114. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0tbrp5x._.js +0 -13
  115. package/.next/standalone/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_open_route_actions_0pjyc8r.js +0 -3
  116. package/.next/standalone/apps/web/.next/static/50OFqtYzVfKmfsBAKOjr3/_clientMiddlewareManifest.js +0 -1
  117. package/.next/standalone/apps/web/.next/static/chunks/0b4pujbysgmxg.js +0 -16
  118. package/.next/standalone/apps/web/.next/static/chunks/130i667qy-j80.css +0 -3
  119. /package/.next/standalone/apps/web/.next/server/app/api/{open → health}/route/build-manifest.json +0 -0
  120. /package/.next/standalone/apps/web/.next/server/app/api/{open → health}/route/server-reference-manifest.json +0 -0
  121. /package/.next/standalone/apps/web/.next/server/app/api/{open → health}/route.js.map +0 -0
  122. /package/.next/standalone/apps/web/.next/static/{50OFqtYzVfKmfsBAKOjr3 → f0Q3TSdyAWb6O9i9BUoq8}/_buildManifest.js +0 -0
  123. /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 { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
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 (port, maxMs = 15_000) => {
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 || res.status === 404) {
45
- return true;
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
- // -- CLI setup ---------------------------------------------------------------
115
+ // -- Shared setup ------------------------------------------------------------
82
116
 
83
- program
84
- .name("diffhub")
85
- .description("GitHub PR-style local diff viewer")
86
- .version("0.1.0")
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 repoPath = findRepoRoot(inputPath);
111
- if (!repoPath) {
112
- console.error(`❌ Not a git repository: ${inputPath}`);
113
- console.error(` Run from inside a git repo, or pass --repo:`);
114
- console.error(` diffhub --repo /path/to/your-repo`);
115
- process.exit(1);
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
- // outputFileTracingRoot is set to the monorepo root, so Next.js places the server at
119
- // .next/standalone/apps/web/server.js (mirroring the workspace path).
120
- const appDir = resolve(__dirname, "..");
121
- const serverPath = join(appDir, ".next", "standalone", "apps", "web", "server.js");
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
- if (!existsSync(serverPath)) {
124
- console.error("❌ No production build found.");
125
- console.error(" Run: npm run build");
126
- process.exit(1);
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 standaloneDir = resolve(serverPath, "..");
130
- syncStandaloneAssets(appDir, standaloneDir);
131
- const port = await findFreePort(Number.parseInt(opts.port, 10));
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
- // -- Startup banner ----------------------------------------------------------
163
+ const createServerBootId = (repoPath, baseBranch) =>
164
+ createHash("sha1")
165
+ .update(`${repoPath}:${baseBranch}:${Date.now()}:${Math.random()}`)
166
+ .digest("hex");
134
167
 
135
- console.log(` diffhub\n`);
136
- console.log(` Repo ${repoPath}`);
137
- if (baseBranch) {
138
- console.log(` Base ${baseBranch}`);
139
- }
140
- console.log(` URL http://localhost:${port}`);
141
- console.log(`\n Press Ctrl+C to stop\n`);
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
- // -- Start server ------------------------------------------------------------
224
+ const rawInsertions = raw.slice(cursor, insertionsEnd);
225
+ const rawDeletions = raw.slice(insertionsEnd + 1, deletionsEnd);
226
+ cursor = deletionsEnd + 1;
144
227
 
145
- const server = spawn("node", ["server.js"], {
146
- cwd: standaloneDir,
147
- env: {
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
- server.on("error", (err) => {
159
- console.error("Failed to start server:", err.message);
160
- process.exit(1);
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
- // -- Open browser when ready -------------------------------------------------
931
+ const repoPath = validateRepo(inputPath);
932
+ validateBuild();
933
+ syncStandaloneAssets(appDir, standaloneDir);
164
934
 
165
- if (opts.open !== false) {
935
+ const port = derivePort(repoPath);
166
936
  const url = `http://localhost:${port}`;
167
- const ready = await waitForServer(port);
168
- if (ready) {
169
- const opener =
170
- { darwin: "open", linux: "xdg-open", win32: "start" }[process.platform] ?? "xdg-open";
171
- spawn(opener, [url], {
172
- detached: true,
173
- shell: process.platform === "win32",
174
- stdio: "ignore",
175
- }).unref();
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
- // -- Graceful shutdown -------------------------------------------------------
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.on("SIGINT", () => {
182
- server.kill();
183
- process.exit(0);
184
- });
1020
+ program.parse(process.argv);