diffity 0.1.3 → 0.1.5

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 ADDED
@@ -0,0 +1,94 @@
1
+ <img src="./packages/ui/public/brand.svg" width="80" />
2
+
3
+ # diffity
4
+
5
+ [![npm version](https://img.shields.io/npm/v/diffity)](https://www.npmjs.com/package/diffity)
6
+ [![license](https://img.shields.io/npm/l/diffity)](https://github.com/kamranahmedse/diffity/blob/main/LICENSE)
7
+
8
+ Diffity is an agent-agnostic, GitHub-style diff viewer and code review tool.
9
+
10
+ ```bash
11
+ npm install -g diffity
12
+ ```
13
+
14
+ It works with Claude Code, Cursor, Codex, and any AI coding agent.
15
+
16
+ ## See your diffs
17
+
18
+ Run `diffity` inside any git repo — your browser opens with a GitHub-style, syntax-highlighted diff.
19
+
20
+ ```bash
21
+ diffity # working tree changes
22
+ diffity HEAD~1 # last commit
23
+ diffity HEAD~3 # last 3 commits
24
+ diffity main..feature # compare branches
25
+ diffity v1.0.0..v2.0.0 # compare tags
26
+ ```
27
+
28
+ For the working tree, you can leave comments, copy them into your agent with a button and ask it to resolve them. Alternatively, use the skills below to avoid this manual step and let your agent auto-solve them.
29
+
30
+ ## AI code review
31
+
32
+ Install the skills for your coding agent (Claude Code, Cursor, Codex, etc.):
33
+
34
+ ```bash
35
+ npx skills add kamranahmedse/diffity
36
+ ```
37
+
38
+ Then use the slash commands:
39
+
40
+ ```
41
+ # use this skill to open the browser with diff viewer
42
+ # you can review the code yourself and leave comments
43
+ /diffity-start
44
+
45
+ # once done, you can come back to the agent and use the
46
+ # below skill to ask agent to resolve your comments.
47
+ /diffity-resolve
48
+
49
+ # you can use this to have AI review your uncommitted
50
+ # changes and leave comments in the diff viewer
51
+ /diffity-review
52
+ ```
53
+
54
+ The review uses severity tags so you know what matters:
55
+ - `[must-fix]` — Bugs, security issues
56
+ - `[suggestion]` — Meaningful improvements
57
+ - `[nit]` — Style preferences
58
+ - `[question]` — Needs clarification
59
+
60
+ You can focus the review on what you care about: `/diffity-review security` or `/diffity-review performance`
61
+
62
+ ## Multiple projects
63
+
64
+ Diffity supports running multiple projects simultaneously. Each gets its own port automatically:
65
+
66
+ ```bash
67
+ # Terminal 1 — starts on :5391
68
+ cd ~/projects/app && diffity
69
+
70
+ # Terminal 2 — starts on :5392
71
+ cd ~/projects/api && diffity
72
+ ```
73
+
74
+ If you run `diffity` in a repo that already has a running instance, it opens the existing one instead of starting a new server. Use `--new` to kill the existing instance and start fresh.
75
+
76
+ ```bash
77
+ diffity list # show all running instances
78
+ diffity list --json # machine-readable output
79
+ ```
80
+
81
+ ## Options
82
+
83
+ ```
84
+ --port <port> Custom port (default: auto-assigned from 5391)
85
+ --no-open Don't open browser
86
+ --dark Dark mode
87
+ --unified Unified view (default: split)
88
+ --quiet Minimal terminal output
89
+ --new Stop existing instance and start fresh
90
+ ```
91
+
92
+ ## License
93
+
94
+ MIT
package/dist/index.js CHANGED
@@ -3,9 +3,10 @@
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
5
  import { execSync as execSync3 } from "node:child_process";
6
- import { rmSync, existsSync as existsSync2 } from "node:fs";
7
- import { join as join5 } from "node:path";
8
- import { homedir as homedir2 } from "node:os";
6
+ import { rmSync, existsSync as existsSync3 } from "node:fs";
7
+ import { join as join6 } from "node:path";
8
+ import { homedir as homedir3 } from "node:os";
9
+ import { createHash as createHash3 } from "node:crypto";
9
10
  import { createRequire } from "node:module";
10
11
  import open from "open";
11
12
  import pc2 from "picocolors";
@@ -196,8 +197,8 @@ function getRecentCommits(query) {
196
197
  // src/server.ts
197
198
  import { createServer } from "node:http";
198
199
  import { createHash as createHash2 } from "node:crypto";
199
- import { readFileSync as readFileSync2, existsSync } from "node:fs";
200
- import { join as join4, extname } from "node:path";
200
+ import { readFileSync as readFileSync3, existsSync as existsSync2 } from "node:fs";
201
+ import { join as join5, extname } from "node:path";
201
202
  import { fileURLToPath } from "node:url";
202
203
  import { dirname } from "node:path";
203
204
 
@@ -722,6 +723,116 @@ function handleReviewRoute(req, res, pathname, url) {
722
723
  return !1;
723
724
  }
724
725
 
726
+ // src/registry.ts
727
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync, existsSync, statSync, mkdirSync as mkdirSync2 } from "node:fs";
728
+ import { join as join4 } from "node:path";
729
+ import { homedir as homedir2 } from "node:os";
730
+ var DIFFITY_DIR = join4(homedir2(), ".diffity"), REGISTRY_PATH = join4(DIFFITY_DIR, "registry.json"), LOCK_PATH = join4(DIFFITY_DIR, "registry.lock"), LOCK_STALE_MS = 5e3, LOCK_TIMEOUT_MS = 3e3, BASE_PORT = 5391, MAX_PORT_ATTEMPTS = 10;
731
+ function isProcessAlive(pid) {
732
+ try {
733
+ return process.kill(pid, 0), !0;
734
+ } catch {
735
+ return !1;
736
+ }
737
+ }
738
+ function acquireLock() {
739
+ mkdirSync2(DIFFITY_DIR, { recursive: !0 });
740
+ let start = Date.now();
741
+ for (; ; )
742
+ try {
743
+ writeFileSync2(LOCK_PATH, String(process.pid), { flag: "wx" });
744
+ return;
745
+ } catch {
746
+ try {
747
+ let stat = statSync(LOCK_PATH);
748
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
749
+ try {
750
+ unlinkSync(LOCK_PATH);
751
+ } catch {
752
+ }
753
+ continue;
754
+ }
755
+ } catch {
756
+ continue;
757
+ }
758
+ if (Date.now() - start > LOCK_TIMEOUT_MS) {
759
+ try {
760
+ unlinkSync(LOCK_PATH);
761
+ } catch {
762
+ }
763
+ try {
764
+ writeFileSync2(LOCK_PATH, String(process.pid), { flag: "wx" });
765
+ return;
766
+ } catch {
767
+ throw new Error("Could not acquire registry lock");
768
+ }
769
+ }
770
+ let wait = Date.now() + 50;
771
+ for (; Date.now() < wait; )
772
+ ;
773
+ }
774
+ }
775
+ function releaseLock() {
776
+ try {
777
+ unlinkSync(LOCK_PATH);
778
+ } catch {
779
+ }
780
+ }
781
+ function withLock(fn) {
782
+ acquireLock();
783
+ try {
784
+ return fn();
785
+ } finally {
786
+ releaseLock();
787
+ }
788
+ }
789
+ function readRegistryRaw() {
790
+ if (!existsSync(REGISTRY_PATH))
791
+ return [];
792
+ try {
793
+ return JSON.parse(readFileSync2(REGISTRY_PATH, "utf-8"));
794
+ } catch {
795
+ return [];
796
+ }
797
+ }
798
+ function writeRegistryRaw(entries) {
799
+ mkdirSync2(DIFFITY_DIR, { recursive: !0 }), writeFileSync2(REGISTRY_PATH, JSON.stringify(entries, null, 2));
800
+ }
801
+ function cleanStaleEntries(entries) {
802
+ return entries.filter((entry) => isProcessAlive(entry.pid));
803
+ }
804
+ function readRegistry() {
805
+ return withLock(() => {
806
+ let entries = readRegistryRaw(), clean = cleanStaleEntries(entries);
807
+ return clean.length !== entries.length && writeRegistryRaw(clean), clean;
808
+ });
809
+ }
810
+ function registerInstance(entry) {
811
+ withLock(() => {
812
+ let filtered = cleanStaleEntries(readRegistryRaw()).filter((e) => e.pid !== entry.pid);
813
+ filtered.push(entry), writeRegistryRaw(filtered);
814
+ });
815
+ }
816
+ function deregisterInstance(pid) {
817
+ withLock(() => {
818
+ let filtered = readRegistryRaw().filter((e) => e.pid !== pid);
819
+ writeRegistryRaw(filtered);
820
+ });
821
+ }
822
+ function findInstanceForRepo(repoHash) {
823
+ let match = readRegistry().find((e) => e.repoHash === repoHash);
824
+ return match || null;
825
+ }
826
+ function findAvailablePort() {
827
+ let entries = readRegistry(), usedPorts = new Set(entries.map((e) => e.port));
828
+ for (let i = 0; i < MAX_PORT_ATTEMPTS; i++) {
829
+ let candidate = BASE_PORT + i;
830
+ if (!usedPorts.has(candidate))
831
+ return candidate;
832
+ }
833
+ return 0;
834
+ }
835
+
725
836
  // src/server.ts
726
837
  var __dirname = dirname(fileURLToPath(import.meta.url)), MIME_TYPES = {
727
838
  ".html": "text/html",
@@ -739,11 +850,11 @@ function sendError2(res, status, message) {
739
850
  res.writeHead(status, { "Content-Type": "application/json" }), res.end(JSON.stringify({ error: message }));
740
851
  }
741
852
  function serveStatic(res, filePath) {
742
- if (!existsSync(filePath)) {
853
+ if (!existsSync2(filePath)) {
743
854
  sendError2(res, 404, "Not found");
744
855
  return;
745
856
  }
746
- let ext = extname(filePath), mime = MIME_TYPES[ext] || "application/octet-stream", content = readFileSync2(filePath);
857
+ let ext = extname(filePath), mime = MIME_TYPES[ext] || "application/octet-stream", content = readFileSync3(filePath);
747
858
  res.writeHead(200, { "Content-Type": mime }), res.end(content);
748
859
  }
749
860
  function descriptionForRef(ref) {
@@ -780,7 +891,7 @@ function readBody2(req) {
780
891
  });
781
892
  }
782
893
  function startServer(options) {
783
- let { port, diffArgs, description, effectiveRef } = options, sessionId = null, reviewsEnabled = isActionableRef(effectiveRef);
894
+ let { port, portIsExplicit, diffArgs, description, effectiveRef, registryInfo } = options, sessionId = null, reviewsEnabled = isActionableRef(effectiveRef);
784
895
  reviewsEnabled && effectiveRef && (sessionId = findOrCreateSession(effectiveRef).id);
785
896
  let includeUntracked = diffArgs.length === 0;
786
897
  function enrichWithLineCounts(diff, baseRef) {
@@ -801,7 +912,7 @@ function startServer(options) {
801
912
  }
802
913
  return raw;
803
914
  }
804
- let uiDir = join4(__dirname, "ui"), server = createServer(async (req, res) => {
915
+ let uiDir = join5(__dirname, "ui"), server = createServer(async (req, res) => {
805
916
  let url = new URL(req.url || "/", `http://localhost:${port}`), pathname = url.pathname;
806
917
  if (res.setHeader("Access-Control-Allow-Origin", "*"), res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS"), res.setHeader("Access-Control-Allow-Headers", "Content-Type"), req.method === "OPTIONS") {
807
918
  res.writeHead(204), res.end();
@@ -927,17 +1038,28 @@ function startServer(options) {
927
1038
  }
928
1039
  if (handleReviewRoute(req, res, pathname, url))
929
1040
  return;
930
- let filePath = join4(uiDir, pathname === "/" ? "index.html" : pathname);
931
- existsSync(filePath) || (filePath = join4(uiDir, "index.html")), serveStatic(res, filePath);
932
- }), closeFn = () => server.close();
1041
+ let filePath = join5(uiDir, pathname === "/" ? "index.html" : pathname);
1042
+ existsSync2(filePath) || (filePath = join5(uiDir, "index.html")), serveStatic(res, filePath);
1043
+ }), closeFn = () => {
1044
+ deregisterInstance(process.pid), server.close();
1045
+ };
933
1046
  return new Promise((resolve, reject) => {
934
- let retries = 0, maxRetries = 30, onError = (err) => {
935
- err.code === "EADDRINUSE" && retries < maxRetries ? (retries++, server.close(), setTimeout(() => server.listen(port), 500)) : reject(err);
1047
+ let currentPort = port, retries = 0, maxRetries = portIsExplicit ? 0 : 10, onError = (err) => {
1048
+ err.code === "EADDRINUSE" && retries < maxRetries ? (retries++, server.close(), currentPort++, setTimeout(() => server.listen(currentPort), 200)) : err.code === "EADDRINUSE" && portIsExplicit ? reject(new Error(`Port ${port} is already in use`)) : reject(err);
936
1049
  };
937
1050
  server.on("error", onError), server.on("listening", () => {
938
1051
  let addr = server.address();
939
- addr && typeof addr != "string" && resolve({ port: addr.port, close: closeFn });
940
- }), server.listen(port);
1052
+ addr && typeof addr != "string" && (registryInfo && registerInstance({
1053
+ pid: process.pid,
1054
+ port: addr.port,
1055
+ repoRoot: registryInfo.repoRoot,
1056
+ repoHash: registryInfo.repoHash,
1057
+ repoName: registryInfo.repoName,
1058
+ ref: effectiveRef || "work",
1059
+ description: description || "Unstaged changes",
1060
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
1061
+ }), resolve({ port: addr.port, close: closeFn }));
1062
+ }), server.listen(currentPort);
941
1063
  });
942
1064
  }
943
1065
 
@@ -1020,7 +1142,7 @@ Examples:
1020
1142
 
1021
1143
  // src/index.ts
1022
1144
  var require2 = createRequire(import.meta.url), pkg = require2("../package.json"), program = new Command();
1023
- program.name("diffity").description("GitHub-style git diff viewer in the browser").version(pkg.version).argument("[refs...]", "Git refs to diff (e.g. HEAD~3, main, main..feature)").option("--port <port>", "Port to use", "5391").option("--no-open", "Do not open browser automatically").option("--quiet", "Minimal terminal output").option("--dark", "Open in dark mode (default: light)").option("--unified", "Open in unified view (default: split)").addHelpText("after", `
1145
+ program.name("diffity").description("GitHub-style git diff viewer in the browser").version(pkg.version).argument("[refs...]", "Git refs to diff (e.g. HEAD~3, main, main..feature)").option("--port <port>", "Port to use (default: auto-assigned from 5391)", "5391").option("--no-open", "Do not open browser automatically").option("--quiet", "Minimal terminal output").option("--dark", "Open in dark mode (default: light)").option("--unified", "Open in unified view (default: split)").option("--new", "Stop existing instance and start fresh").addHelpText("after", `
1024
1146
  Examples:
1025
1147
  $ diffity Working tree changes
1026
1148
  $ diffity HEAD~1 Last commit vs working tree
@@ -1037,29 +1159,89 @@ Examples:
1037
1159
  let ref = refs[0];
1038
1160
  ref.includes("..") ? (diffArgs.push(ref), description = ref) : (diffArgs.push(ref), description = `Changes from ${ref}`);
1039
1161
  } else refs.length === 2 ? (diffArgs.push(`${refs[0]}..${refs[1]}`), description = `${refs[0]}..${refs[1]}`) : description = "Unstaged changes";
1040
- let port = parseInt(opts.port, 10), effectiveRef;
1162
+ let effectiveRef;
1041
1163
  refs.length > 0 ? effectiveRef = refs.length === 2 ? `${refs[0]}..${refs[1]}` : refs[0] : effectiveRef = "work";
1164
+ let repoRoot = getRepoRoot(), repoHash = createHash3("sha256").update(repoRoot).digest("hex").slice(0, 12), repoName = getRepoName(), existing = findInstanceForRepo(repoHash);
1165
+ if (existing)
1166
+ if (opts.new) {
1167
+ try {
1168
+ process.kill(existing.pid, "SIGTERM");
1169
+ } catch {
1170
+ }
1171
+ deregisterInstance(existing.pid), opts.quiet || console.log(pc2.dim(` Stopped existing instance (pid ${existing.pid})`));
1172
+ } else {
1173
+ let urlParams = new URLSearchParams({ ref: effectiveRef });
1174
+ opts.dark && urlParams.set("theme", "dark"), opts.unified && urlParams.set("view", "unified");
1175
+ let url = `http://localhost:${existing.port}/?${urlParams.toString()}`;
1176
+ opts.quiet || (console.log(""), console.log(pc2.bold(" diffity")), console.log(` ${pc2.dim("Already running for this repo")}`), console.log(""), console.log(` ${pc2.green("\u2192")} ${pc2.cyan(url)}`), console.log("")), opts.open !== !1 && await open(url);
1177
+ return;
1178
+ }
1179
+ let explicitPort = program.getOptionValueSource("port") === "cli", port = explicitPort ? parseInt(opts.port, 10) : findAvailablePort();
1042
1180
  try {
1043
- let { port: actualPort, close } = await startServer({ port, diffArgs, description, effectiveRef }), urlParams = new URLSearchParams({ ref: effectiveRef });
1181
+ let { port: actualPort, close } = await startServer({
1182
+ port,
1183
+ portIsExplicit: explicitPort,
1184
+ diffArgs,
1185
+ description,
1186
+ effectiveRef,
1187
+ registryInfo: { repoRoot, repoHash, repoName }
1188
+ }), urlParams = new URLSearchParams({ ref: effectiveRef });
1044
1189
  opts.dark && urlParams.set("theme", "dark"), opts.unified && urlParams.set("view", "unified");
1045
1190
  let url = `http://localhost:${actualPort}/?${urlParams.toString()}`;
1046
1191
  opts.quiet || (console.log(""), console.log(pc2.bold(" diffity")), console.log(` ${pc2.dim(description)}`), console.log(""), console.log(` ${pc2.green("\u2192")} ${pc2.cyan(url)}`), console.log(` ${pc2.dim("Press Ctrl+C to stop")}`), console.log("")), process.on("SIGINT", () => {
1047
1192
  opts.quiet || console.log(pc2.dim(`
1048
- Shutting down...`)), close(), process.exit(0);
1193
+ Shutting down...`)), deregisterInstance(process.pid), close(), process.exit(0);
1049
1194
  }), process.on("SIGTERM", () => {
1050
- close(), process.exit(0);
1195
+ deregisterInstance(process.pid), close(), process.exit(0);
1051
1196
  }), opts.open !== !1 && await open(url);
1052
1197
  } catch (err) {
1053
1198
  console.error(pc2.red(`Failed to start server: ${err}`)), process.exit(1);
1054
1199
  }
1055
1200
  });
1201
+ program.command("list").description("List all running diffity instances").option("--json", "Output as JSON").action((opts) => {
1202
+ let entries = readRegistry();
1203
+ if (opts.json) {
1204
+ console.log(JSON.stringify(entries, null, 2));
1205
+ return;
1206
+ }
1207
+ if (entries.length === 0) {
1208
+ console.log(pc2.dim("No running diffity instances."));
1209
+ return;
1210
+ }
1211
+ console.log(""), console.log(
1212
+ ` ${pc2.dim("PORT")} ${pc2.dim("PID".padEnd(8))}${pc2.dim("REPO".padEnd(22))}${pc2.dim("REF".padEnd(22))}${pc2.dim("STARTED")}`
1213
+ );
1214
+ for (let entry of entries) {
1215
+ let ago = getTimeAgo(entry.startedAt);
1216
+ console.log(
1217
+ ` ${String(entry.port).padEnd(7)}${String(entry.pid).padEnd(8)}${entry.repoName.slice(0, 20).padEnd(22)}${entry.ref.slice(0, 20).padEnd(22)}${pc2.dim(ago)}`
1218
+ );
1219
+ }
1220
+ console.log("");
1221
+ });
1222
+ function getTimeAgo(isoDate) {
1223
+ let diff = Date.now() - new Date(isoDate).getTime(), seconds = Math.floor(diff / 1e3);
1224
+ if (seconds < 60)
1225
+ return "just now";
1226
+ let minutes = Math.floor(seconds / 60);
1227
+ if (minutes < 60)
1228
+ return `${minutes}m ago`;
1229
+ let hours = Math.floor(minutes / 60);
1230
+ return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`;
1231
+ }
1056
1232
  program.command("prune").description("Remove all diffity data (database, sessions) for all repos").action(() => {
1057
- let dir = join5(homedir2(), ".diffity");
1058
- if (!existsSync2(dir)) {
1233
+ let dir = join6(homedir3(), ".diffity");
1234
+ if (!existsSync3(dir)) {
1059
1235
  console.log(pc2.dim("Nothing to prune."));
1060
1236
  return;
1061
1237
  }
1062
- rmSync(dir, { recursive: !0, force: !0 }), console.log(pc2.green("Pruned all diffity data (~/.diffity)."));
1238
+ let running = readRegistry();
1239
+ for (let entry of running)
1240
+ try {
1241
+ process.kill(entry.pid, "SIGTERM");
1242
+ } catch {
1243
+ }
1244
+ running.length > 0 && console.log(pc2.dim(` Stopped ${running.length} running instance${running.length > 1 ? "s" : ""}.`)), rmSync(dir, { recursive: !0, force: !0 }), console.log(pc2.green("Pruned all diffity data (~/.diffity)."));
1063
1245
  });
1064
1246
  program.command("update").description("Update diffity to the latest version").action(() => {
1065
1247
  try {