codepiper 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/.env.example +28 -0
  2. package/CHANGELOG.md +10 -0
  3. package/LEGAL_NOTICE.md +39 -0
  4. package/LICENSE +21 -0
  5. package/README.md +524 -0
  6. package/package.json +90 -0
  7. package/packages/cli/package.json +13 -0
  8. package/packages/cli/src/commands/analytics.ts +157 -0
  9. package/packages/cli/src/commands/attach.ts +299 -0
  10. package/packages/cli/src/commands/audit.ts +50 -0
  11. package/packages/cli/src/commands/auth.ts +261 -0
  12. package/packages/cli/src/commands/daemon.ts +162 -0
  13. package/packages/cli/src/commands/doctor.ts +303 -0
  14. package/packages/cli/src/commands/env-set.ts +162 -0
  15. package/packages/cli/src/commands/hook-forward.ts +268 -0
  16. package/packages/cli/src/commands/keys.ts +77 -0
  17. package/packages/cli/src/commands/kill.ts +19 -0
  18. package/packages/cli/src/commands/logs.ts +419 -0
  19. package/packages/cli/src/commands/model.ts +172 -0
  20. package/packages/cli/src/commands/policy-set.ts +185 -0
  21. package/packages/cli/src/commands/policy.ts +227 -0
  22. package/packages/cli/src/commands/providers.ts +114 -0
  23. package/packages/cli/src/commands/resize.ts +34 -0
  24. package/packages/cli/src/commands/send.ts +184 -0
  25. package/packages/cli/src/commands/sessions.ts +202 -0
  26. package/packages/cli/src/commands/slash.ts +92 -0
  27. package/packages/cli/src/commands/start.ts +243 -0
  28. package/packages/cli/src/commands/stop.ts +19 -0
  29. package/packages/cli/src/commands/tail.ts +137 -0
  30. package/packages/cli/src/commands/workflow.ts +786 -0
  31. package/packages/cli/src/commands/workspace.ts +127 -0
  32. package/packages/cli/src/lib/api.ts +78 -0
  33. package/packages/cli/src/lib/args.ts +72 -0
  34. package/packages/cli/src/lib/format.ts +93 -0
  35. package/packages/cli/src/main.ts +563 -0
  36. package/packages/core/package.json +7 -0
  37. package/packages/core/src/config.ts +30 -0
  38. package/packages/core/src/errors.ts +38 -0
  39. package/packages/core/src/eventBus.ts +56 -0
  40. package/packages/core/src/eventBusAdapter.ts +143 -0
  41. package/packages/core/src/index.ts +10 -0
  42. package/packages/core/src/sqliteEventBus.ts +336 -0
  43. package/packages/core/src/types.ts +63 -0
  44. package/packages/daemon/package.json +11 -0
  45. package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
  46. package/packages/daemon/src/api/authRoutes.ts +344 -0
  47. package/packages/daemon/src/api/bodyLimit.ts +133 -0
  48. package/packages/daemon/src/api/envSetRoutes.ts +170 -0
  49. package/packages/daemon/src/api/gitRoutes.ts +409 -0
  50. package/packages/daemon/src/api/hooks.ts +588 -0
  51. package/packages/daemon/src/api/inputPolicy.ts +249 -0
  52. package/packages/daemon/src/api/notificationRoutes.ts +532 -0
  53. package/packages/daemon/src/api/policyRoutes.ts +234 -0
  54. package/packages/daemon/src/api/policySetRoutes.ts +445 -0
  55. package/packages/daemon/src/api/routeUtils.ts +28 -0
  56. package/packages/daemon/src/api/routes.ts +1004 -0
  57. package/packages/daemon/src/api/server.ts +1388 -0
  58. package/packages/daemon/src/api/settingsRoutes.ts +367 -0
  59. package/packages/daemon/src/api/sqliteErrors.ts +47 -0
  60. package/packages/daemon/src/api/stt.ts +143 -0
  61. package/packages/daemon/src/api/terminalRoutes.ts +200 -0
  62. package/packages/daemon/src/api/validation.ts +287 -0
  63. package/packages/daemon/src/api/validationRoutes.ts +174 -0
  64. package/packages/daemon/src/api/workflowRoutes.ts +567 -0
  65. package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
  66. package/packages/daemon/src/api/ws.ts +1588 -0
  67. package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
  68. package/packages/daemon/src/auth/authMiddleware.ts +305 -0
  69. package/packages/daemon/src/auth/authService.ts +496 -0
  70. package/packages/daemon/src/auth/rateLimiter.ts +137 -0
  71. package/packages/daemon/src/config/pricing.ts +79 -0
  72. package/packages/daemon/src/crypto/encryption.ts +196 -0
  73. package/packages/daemon/src/db/db.ts +2745 -0
  74. package/packages/daemon/src/db/index.ts +16 -0
  75. package/packages/daemon/src/db/migrations.ts +182 -0
  76. package/packages/daemon/src/db/policyDb.ts +349 -0
  77. package/packages/daemon/src/db/schema.sql +408 -0
  78. package/packages/daemon/src/db/workflowDb.ts +464 -0
  79. package/packages/daemon/src/git/gitUtils.ts +544 -0
  80. package/packages/daemon/src/index.ts +6 -0
  81. package/packages/daemon/src/main.ts +525 -0
  82. package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
  83. package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
  84. package/packages/daemon/src/providers/registry.ts +111 -0
  85. package/packages/daemon/src/providers/types.ts +82 -0
  86. package/packages/daemon/src/sessions/auditLogger.ts +103 -0
  87. package/packages/daemon/src/sessions/policyEngine.ts +165 -0
  88. package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
  89. package/packages/daemon/src/sessions/policyTypes.ts +94 -0
  90. package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
  91. package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
  92. package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
  93. package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
  94. package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
  95. package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
  96. package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
  97. package/packages/daemon/src/workflows/contextManager.ts +83 -0
  98. package/packages/daemon/src/workflows/index.ts +31 -0
  99. package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
  100. package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
  101. package/packages/daemon/src/workflows/workflowParser.ts +217 -0
  102. package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
  103. package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
  104. package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
  105. package/packages/providers/claude-code/package.json +11 -0
  106. package/packages/providers/claude-code/src/index.ts +7 -0
  107. package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
  108. package/packages/providers/claude-code/src/provider.ts +311 -0
  109. package/packages/web/dist/android-chrome-192x192.png +0 -0
  110. package/packages/web/dist/android-chrome-512x512.png +0 -0
  111. package/packages/web/dist/apple-touch-icon.png +0 -0
  112. package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
  113. package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
  114. package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
  115. package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
  116. package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
  117. package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
  118. package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  119. package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
  120. package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
  121. package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
  122. package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
  123. package/packages/web/dist/assets/index-hgphORiw.js +204 -0
  124. package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
  125. package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
  126. package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
  127. package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
  128. package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
  129. package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
  130. package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
  131. package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
  132. package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
  133. package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
  134. package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
  135. package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
  136. package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
  137. package/packages/web/dist/favicon.ico +0 -0
  138. package/packages/web/dist/icon.svg +1 -0
  139. package/packages/web/dist/index.html +29 -0
  140. package/packages/web/dist/manifest.json +29 -0
  141. package/packages/web/dist/og-image.png +0 -0
  142. package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
  143. package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
  144. package/packages/web/dist/originals/apple-touch-icon.png +0 -0
  145. package/packages/web/dist/originals/favicon.ico +0 -0
  146. package/packages/web/dist/piper.svg +1 -0
  147. package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
  148. package/packages/web/dist/sw.js +257 -0
  149. package/scripts/postinstall-link-workspaces.mjs +58 -0
@@ -0,0 +1,544 @@
1
+ /**
2
+ * GitUtils - Git operations for worktree management and repo inspection
3
+ *
4
+ * Wraps git commands via Bun.spawnSync for worktree management,
5
+ * branch inspection, repo state queries, and diff generation.
6
+ * All functions are async for future-proofing, though the underlying
7
+ * calls are synchronous.
8
+ */
9
+
10
+ import * as path from "node:path";
11
+
12
+ export interface GitStatusEntry {
13
+ path: string;
14
+ indexStatus: string;
15
+ workTreeStatus: string;
16
+ isUntracked: boolean;
17
+ isRenamed: boolean;
18
+ origPath?: string;
19
+ }
20
+
21
+ export interface GitLogEntry {
22
+ hash: string;
23
+ shortHash: string;
24
+ author: string;
25
+ email: string;
26
+ date: string;
27
+ message: string;
28
+ }
29
+
30
+ export interface GitDiffStatEntry {
31
+ path: string;
32
+ additions: number;
33
+ deletions: number;
34
+ }
35
+
36
+ interface GitCommandResult {
37
+ stdout: string;
38
+ stderr: string;
39
+ exitCode: number;
40
+ }
41
+
42
+ /**
43
+ * Execute a git command synchronously and return parsed output.
44
+ */
45
+ function runGit(args: string[], cwd?: string): GitCommandResult {
46
+ const cmdArgs = cwd ? ["-C", cwd, ...args] : args;
47
+ const proc = Bun.spawnSync(["git", ...cmdArgs], {
48
+ stdout: "pipe",
49
+ stderr: "pipe",
50
+ });
51
+ return {
52
+ stdout: proc.stdout.toString("utf-8").trimEnd(),
53
+ stderr: proc.stderr.toString("utf-8").trim(),
54
+ exitCode: proc.exitCode,
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Execute a git command and throw on failure.
60
+ */
61
+ function runGitOrThrow(args: string[], cwd?: string, errorPrefix?: string): string {
62
+ const result = runGit(args, cwd);
63
+ if (result.exitCode !== 0) {
64
+ const prefix = errorPrefix ?? `git ${args[0]} failed`;
65
+ throw new Error(`${prefix}: ${result.stderr || `exit code ${result.exitCode}`}`);
66
+ }
67
+ return result.stdout;
68
+ }
69
+
70
+ /**
71
+ * Execute a git command and return raw stdout bytes (for binary files).
72
+ */
73
+ function runGitRawOrThrow(args: string[], cwd?: string, errorPrefix?: string): Uint8Array {
74
+ const cmdArgs = cwd ? ["-C", cwd, ...args] : args;
75
+ const proc = Bun.spawnSync(["git", ...cmdArgs], {
76
+ stdout: "pipe",
77
+ stderr: "pipe",
78
+ });
79
+ if (proc.exitCode !== 0) {
80
+ const prefix = errorPrefix ?? `git ${args[0]} failed`;
81
+ throw new Error(`${prefix}: ${proc.stderr.toString("utf-8").trim()}`);
82
+ }
83
+ return new Uint8Array(proc.stdout);
84
+ }
85
+
86
+ function parseNumstatCount(value: string | undefined): number {
87
+ if (!value || value === "-") {
88
+ return 0;
89
+ }
90
+ const parsed = Number.parseInt(value, 10);
91
+ return Number.isNaN(parsed) ? 0 : parsed;
92
+ }
93
+
94
+ /**
95
+ * Validate a git ref (commit hash, branch name, tag) to prevent command injection.
96
+ * Allows: alphanumeric, dots, dashes, underscores, slashes, tildes, carets, braces, @
97
+ */
98
+ const SAFE_REF_PATTERN = /^[a-zA-Z0-9._\-/~^{}@:]+$/;
99
+
100
+ export function validateGitRef(ref: string): void {
101
+ if (!ref || ref.length > 256) {
102
+ throw new Error("Invalid git ref: empty or too long");
103
+ }
104
+ if (!SAFE_REF_PATTERN.test(ref)) {
105
+ throw new Error(`Invalid git ref: contains disallowed characters: ${ref}`);
106
+ }
107
+ if (ref.includes("..") && !ref.match(/^[a-f0-9]+\.\.[a-f0-9]+$/)) {
108
+ // Allow commit ranges (abc123..def456) but reject path traversal
109
+ if (ref.includes("../")) {
110
+ throw new Error(`Invalid git ref: path traversal not allowed: ${ref}`);
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Validate a file path to prevent path traversal attacks.
117
+ * Rejects paths containing ".." components.
118
+ */
119
+ export function validateFilePath(filePath: string): void {
120
+ if (!filePath) {
121
+ throw new Error("Invalid file path: empty");
122
+ }
123
+ if (filePath.length > 1024) {
124
+ throw new Error("Invalid file path: too long");
125
+ }
126
+ // Reject ".." path components
127
+ const normalized = path.normalize(filePath);
128
+ if (normalized.startsWith("..") || normalized.includes("/../") || normalized.endsWith("/..")) {
129
+ throw new Error(`Invalid file path: path traversal not allowed: ${filePath}`);
130
+ }
131
+ // Reject absolute paths
132
+ if (path.isAbsolute(filePath)) {
133
+ throw new Error(`Invalid file path: absolute paths not allowed: ${filePath}`);
134
+ }
135
+ }
136
+
137
+ export const GitUtils = {
138
+ /**
139
+ * Check if path is inside a git repo.
140
+ */
141
+ async isGitRepo(dirPath: string): Promise<boolean> {
142
+ const result = runGit(["rev-parse", "--is-inside-work-tree"], dirPath);
143
+ return result.exitCode === 0 && result.stdout === "true";
144
+ },
145
+
146
+ /**
147
+ * Get root of the git repo containing dirPath.
148
+ */
149
+ async getRepoRoot(dirPath: string): Promise<string> {
150
+ return runGitOrThrow(["rev-parse", "--show-toplevel"], dirPath, "Failed to get repo root");
151
+ },
152
+
153
+ /**
154
+ * Get current branch name.
155
+ * Returns "HEAD" if in detached HEAD state.
156
+ */
157
+ async getCurrentBranch(dirPath: string): Promise<string> {
158
+ return runGitOrThrow(
159
+ ["rev-parse", "--abbrev-ref", "HEAD"],
160
+ dirPath,
161
+ "Failed to get current branch"
162
+ );
163
+ },
164
+
165
+ /**
166
+ * List all local branches.
167
+ */
168
+ async listBranches(repoPath: string): Promise<string[]> {
169
+ const output = runGitOrThrow(
170
+ ["branch", "--format=%(refname:short)"],
171
+ repoPath,
172
+ "Failed to list branches"
173
+ );
174
+ if (!output) return [];
175
+ return output.split("\n").filter((b) => b.length > 0);
176
+ },
177
+
178
+ /**
179
+ * Check if a branch exists locally.
180
+ */
181
+ async branchExists(repoPath: string, branch: string): Promise<boolean> {
182
+ const result = runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoPath);
183
+ return result.exitCode === 0;
184
+ },
185
+
186
+ /**
187
+ * Check if a branch is checked out in any worktree.
188
+ * Returns the worktree path if found.
189
+ */
190
+ async isBranchCheckedOut(
191
+ repoPath: string,
192
+ branch: string
193
+ ): Promise<{ checkedOut: boolean; worktreePath?: string }> {
194
+ const output = runGitOrThrow(
195
+ ["worktree", "list", "--porcelain"],
196
+ repoPath,
197
+ "Failed to list worktrees"
198
+ );
199
+
200
+ const targetRef = `refs/heads/${branch}`;
201
+ const lines = output.split("\n");
202
+ let currentWorktreePath: string | undefined;
203
+
204
+ for (const line of lines) {
205
+ if (line.startsWith("worktree ")) {
206
+ currentWorktreePath = line.slice("worktree ".length);
207
+ } else if (line === `branch ${targetRef}`) {
208
+ return { checkedOut: true, worktreePath: currentWorktreePath };
209
+ }
210
+ }
211
+
212
+ return { checkedOut: false };
213
+ },
214
+
215
+ /**
216
+ * Check for uncommitted changes (staged, unstaged, or untracked).
217
+ */
218
+ async hasUncommittedChanges(repoPath: string): Promise<boolean> {
219
+ const output = runGitOrThrow(
220
+ ["status", "--porcelain"],
221
+ repoPath,
222
+ "Failed to check uncommitted changes"
223
+ );
224
+ return output.length > 0;
225
+ },
226
+
227
+ /**
228
+ * Create a git worktree.
229
+ *
230
+ * If createBranch is true, creates a new branch at startPoint (or HEAD).
231
+ * If createBranch is false, checks out an existing branch.
232
+ */
233
+ async createWorktree(opts: {
234
+ repoPath: string;
235
+ worktreePath: string;
236
+ branch: string;
237
+ createBranch: boolean;
238
+ startPoint?: string;
239
+ }): Promise<void> {
240
+ const { repoPath, worktreePath, branch, createBranch, startPoint } = opts;
241
+
242
+ if (createBranch) {
243
+ const args = ["worktree", "add", "-b", branch, worktreePath, startPoint ?? "HEAD"];
244
+ runGitOrThrow(args, repoPath, "Failed to create worktree with new branch");
245
+ } else {
246
+ const args = ["worktree", "add", worktreePath, branch];
247
+ runGitOrThrow(args, repoPath, "Failed to create worktree");
248
+ }
249
+ },
250
+
251
+ /**
252
+ * Remove a git worktree.
253
+ */
254
+ async removeWorktree(repoPath: string, worktreePath: string, force = false): Promise<void> {
255
+ const args = ["worktree", "remove"];
256
+ if (force) {
257
+ args.push("--force");
258
+ }
259
+ args.push(worktreePath);
260
+ runGitOrThrow(args, repoPath, "Failed to remove worktree");
261
+ },
262
+
263
+ /**
264
+ * Stash changes in a worktree before removal.
265
+ * Returns true if changes were stashed, false if working tree was clean.
266
+ */
267
+ async stashChanges(worktreePath: string, sessionId: string): Promise<boolean> {
268
+ const hasChanges = await GitUtils.hasUncommittedChanges(worktreePath);
269
+ if (!hasChanges) {
270
+ return false;
271
+ }
272
+
273
+ runGitOrThrow(
274
+ ["stash", "push", "-m", `codepiper-session-${sessionId}`],
275
+ worktreePath,
276
+ "Failed to stash changes"
277
+ );
278
+ return true;
279
+ },
280
+
281
+ /**
282
+ * Generate worktree path for a session.
283
+ * Pattern: <repoParentDir>/<repoBasename>-worktrees/<sessionId>/
284
+ */
285
+ getWorktreePath(repoPath: string, sessionId: string): string {
286
+ const parentDir = path.dirname(repoPath);
287
+ const repoName = path.basename(repoPath);
288
+ return path.join(parentDir, `${repoName}-worktrees`, sessionId);
289
+ },
290
+
291
+ /**
292
+ * Get working tree status (staged, unstaged, untracked files).
293
+ */
294
+ async getStatus(dirPath: string): Promise<GitStatusEntry[]> {
295
+ const output = runGitOrThrow(
296
+ ["status", "--porcelain=v1", "-uall"],
297
+ dirPath,
298
+ "Failed to get status"
299
+ );
300
+ if (!output) return [];
301
+
302
+ const entries: GitStatusEntry[] = [];
303
+ for (const line of output.split("\n")) {
304
+ if (line.length < 4) continue;
305
+ const indexStatus = line[0] ?? "";
306
+ const workTreeStatus = line[1] ?? "";
307
+ const isRenamed = indexStatus === "R" || workTreeStatus === "R";
308
+ let filePath: string;
309
+ let origPath: string | undefined;
310
+
311
+ if (isRenamed) {
312
+ const parts = line.slice(3).split(" -> ");
313
+ const fromPath = parts[0] ?? "";
314
+ const toPath = parts[1];
315
+ origPath = fromPath;
316
+ filePath = toPath ?? fromPath;
317
+ } else {
318
+ filePath = line.slice(3);
319
+ }
320
+
321
+ entries.push({
322
+ path: filePath,
323
+ indexStatus: indexStatus === " " ? "" : indexStatus,
324
+ workTreeStatus: workTreeStatus === " " ? "" : workTreeStatus,
325
+ isUntracked: indexStatus === "?" && workTreeStatus === "?",
326
+ isRenamed,
327
+ origPath,
328
+ });
329
+ }
330
+ return entries;
331
+ },
332
+
333
+ /**
334
+ * Get commit log.
335
+ */
336
+ async getLog(dirPath: string, opts?: { limit?: number; since?: string }): Promise<GitLogEntry[]> {
337
+ const args = ["log", "--format=%H%x00%h%x00%an%x00%ae%x00%aI%x00%s"];
338
+ if (opts?.limit) {
339
+ args.push(`-n`, String(opts.limit));
340
+ } else {
341
+ args.push("-n", "50");
342
+ }
343
+ if (opts?.since) {
344
+ args.push(`--since=${opts.since}`);
345
+ }
346
+
347
+ const output = runGitOrThrow(args, dirPath, "Failed to get log");
348
+ if (!output) return [];
349
+
350
+ return output.split("\n").map((line) => {
351
+ const [hash = "", shortHash = "", author = "", email = "", date = "", ...messageParts] =
352
+ line.split("\0");
353
+ return {
354
+ hash,
355
+ shortHash,
356
+ author,
357
+ email,
358
+ date,
359
+ message: messageParts.join("\0"),
360
+ };
361
+ });
362
+ },
363
+
364
+ /**
365
+ * Get unified diff for working tree changes or between commits.
366
+ */
367
+ async getDiff(
368
+ dirPath: string,
369
+ opts?: {
370
+ staged?: boolean;
371
+ commitA?: string;
372
+ commitB?: string;
373
+ path?: string;
374
+ }
375
+ ): Promise<string> {
376
+ if (opts?.commitA) validateGitRef(opts.commitA);
377
+ if (opts?.commitB) validateGitRef(opts.commitB);
378
+
379
+ const args = ["diff"];
380
+
381
+ if (opts?.staged) {
382
+ args.push("--cached");
383
+ } else if (opts?.commitA && opts?.commitB) {
384
+ args.push(opts.commitA, opts.commitB);
385
+ } else if (opts?.commitA) {
386
+ args.push(opts.commitA);
387
+ }
388
+
389
+ if (opts?.path) {
390
+ args.push("--", opts.path);
391
+ }
392
+
393
+ return runGitOrThrow(args, dirPath, "Failed to get diff");
394
+ },
395
+
396
+ /**
397
+ * Get file content at a specific git ref.
398
+ */
399
+ async getFileAtRef(dirPath: string, ref: string, filePath: string): Promise<string> {
400
+ validateGitRef(ref);
401
+ return runGitOrThrow(
402
+ ["show", `${ref}:${filePath}`],
403
+ dirPath,
404
+ `Failed to get file at ref ${ref}`
405
+ );
406
+ },
407
+
408
+ /**
409
+ * Get raw file bytes at a specific git ref (for binary files like images).
410
+ * Pre-checks blob size to prevent unbounded memory consumption.
411
+ */
412
+ async getFileAtRefRaw(
413
+ dirPath: string,
414
+ ref: string,
415
+ filePath: string,
416
+ maxBytes = 50 * 1024 * 1024
417
+ ): Promise<Uint8Array> {
418
+ const sizeStr = runGitOrThrow(
419
+ ["cat-file", "-s", `${ref}:${filePath}`],
420
+ dirPath,
421
+ `Failed to get file size at ref ${ref}`
422
+ );
423
+ const sizeBytes = Number.parseInt(sizeStr, 10);
424
+ if (sizeBytes > maxBytes) {
425
+ throw new Error(
426
+ `File too large: ${sizeBytes} bytes (max ${maxBytes}). Refusing to load into memory.`
427
+ );
428
+ }
429
+ return runGitRawOrThrow(
430
+ ["show", `${ref}:${filePath}`],
431
+ dirPath,
432
+ `Failed to get file at ref ${ref}`
433
+ );
434
+ },
435
+
436
+ /**
437
+ * Read a file from the working tree on disk.
438
+ */
439
+ async getFileFromWorkingTree(dirPath: string, filePath: string): Promise<string> {
440
+ const repoRoot = path.resolve(dirPath);
441
+ const fullPath = path.resolve(repoRoot, filePath);
442
+ // Ensure resolved path is within the repo directory
443
+ if (!fullPath.startsWith(repoRoot + path.sep)) {
444
+ throw new Error("File path escapes repository directory");
445
+ }
446
+ const file = Bun.file(fullPath);
447
+ if (!(await file.exists())) {
448
+ throw new Error(`File not found: ${filePath}`);
449
+ }
450
+ return file.text();
451
+ },
452
+
453
+ /**
454
+ * Stage files.
455
+ */
456
+ async stageFiles(dirPath: string, paths: string[]): Promise<void> {
457
+ runGitOrThrow(["add", "--", ...paths], dirPath, "Failed to stage files");
458
+ },
459
+
460
+ /**
461
+ * Unstage files.
462
+ */
463
+ async unstageFiles(dirPath: string, paths: string[]): Promise<void> {
464
+ runGitOrThrow(["reset", "HEAD", "--", ...paths], dirPath, "Failed to unstage files");
465
+ },
466
+
467
+ /**
468
+ * Get diff stat (files changed with addition/deletion counts) for a commit.
469
+ */
470
+ async getDiffStat(dirPath: string, ref: string): Promise<GitDiffStatEntry[]> {
471
+ validateGitRef(ref);
472
+ const output = runGitOrThrow(
473
+ ["diff-tree", "--no-commit-id", "-r", "--numstat", ref],
474
+ dirPath,
475
+ "Failed to get diff stat"
476
+ );
477
+ if (!output) return [];
478
+
479
+ return output
480
+ .split("\n")
481
+ .filter((line) => line.length > 0)
482
+ .map((line) => {
483
+ const [additions, deletions, ...pathParts] = line.split("\t");
484
+ return {
485
+ path: pathParts.join("\t"),
486
+ additions: parseNumstatCount(additions),
487
+ deletions: parseNumstatCount(deletions),
488
+ };
489
+ });
490
+ },
491
+
492
+ /**
493
+ * Get diff stat between two refs (e.g., branch comparison).
494
+ * Uses three-dot notation (merge-base) like GitHub PRs.
495
+ */
496
+ async getDiffStatBetweenRefs(
497
+ dirPath: string,
498
+ refA: string,
499
+ refB: string
500
+ ): Promise<GitDiffStatEntry[]> {
501
+ validateGitRef(refA);
502
+ validateGitRef(refB);
503
+ const output = runGitOrThrow(
504
+ ["diff", "--numstat", `${refA}...${refB}`],
505
+ dirPath,
506
+ "Failed to get diff stat between refs"
507
+ );
508
+ if (!output) return [];
509
+
510
+ return output
511
+ .split("\n")
512
+ .filter((line) => line.length > 0)
513
+ .map((line) => {
514
+ const [additions, deletions, ...pathParts] = line.split("\t");
515
+ return {
516
+ path: pathParts.join("\t"),
517
+ additions: parseNumstatCount(additions),
518
+ deletions: parseNumstatCount(deletions),
519
+ };
520
+ });
521
+ },
522
+
523
+ /**
524
+ * Get diff between a commit and its parent (single commit changes).
525
+ */
526
+ async getCommitDiff(dirPath: string, commitHash: string, filePath?: string): Promise<string> {
527
+ const args = ["diff", `${commitHash}^`, commitHash];
528
+ if (filePath) {
529
+ args.push("--", filePath);
530
+ }
531
+ // If commit has no parent (initial commit), use empty tree
532
+ const result = runGit(args, dirPath);
533
+ if (result.exitCode !== 0) {
534
+ // Try against empty tree for initial commit
535
+ const emptyTree = "4b825dc642cb6eb9a060e54bf899d15363da7b23";
536
+ return runGitOrThrow(
537
+ ["diff", emptyTree, commitHash, ...(filePath ? ["--", filePath] : [])],
538
+ dirPath,
539
+ "Failed to get commit diff"
540
+ );
541
+ }
542
+ return result.stdout;
543
+ },
544
+ };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Daemon package exports
3
+ */
4
+
5
+ export type { PTYProcessOptions } from "./sessions/ptyProcess";
6
+ export { PTYProcess } from "./sessions/ptyProcess";