diff-easy 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 (172) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +142 -0
  3. package/dist/cli/cmux.d.ts +9 -0
  4. package/dist/cli/cmux.js +274 -0
  5. package/dist/cli/comment.d.ts +2 -0
  6. package/dist/cli/comment.js +91 -0
  7. package/dist/cli/github.d.ts +84 -0
  8. package/dist/cli/github.js +364 -0
  9. package/dist/cli/index.d.ts +2 -0
  10. package/dist/cli/index.js +397 -0
  11. package/dist/cli/turns.d.ts +14 -0
  12. package/dist/cli/turns.js +333 -0
  13. package/dist/cli/utils.d.ts +27 -0
  14. package/dist/cli/utils.js +204 -0
  15. package/dist/client/assets/arc-CHYjdFTk.js +1 -0
  16. package/dist/client/assets/architecture-7EHR7CIX-weHBYDgM.js +1 -0
  17. package/dist/client/assets/architectureDiagram-3BPJPVTR-DRa2msuJ.js +36 -0
  18. package/dist/client/assets/array-BifhSqXX.js +1 -0
  19. package/dist/client/assets/blockDiagram-GPEHLZMM-BTdKsXy5.js +132 -0
  20. package/dist/client/assets/c4Diagram-AAUBKEIU-CwTvCkOg.js +10 -0
  21. package/dist/client/assets/channel-Cv40nQW1.js +1 -0
  22. package/dist/client/assets/chunk-2J33WTMH-BFsjrTtc.js +1 -0
  23. package/dist/client/assets/chunk-3OPIFGDE-V_xVw1dq.js +62 -0
  24. package/dist/client/assets/chunk-4BX2VUAB-DoLO6LLy.js +1 -0
  25. package/dist/client/assets/chunk-55IACEB6-CHgBwbdB.js +1 -0
  26. package/dist/client/assets/chunk-5ZQYHXKU-DMixAgQG.js +2 -0
  27. package/dist/client/assets/chunk-727SXJPM-C-3FZTmm.js +206 -0
  28. package/dist/client/assets/chunk-AQP2D5EJ-DpQZhXt-.js +231 -0
  29. package/dist/client/assets/chunk-BSJP7CBP-BnO9E_G4.js +1 -0
  30. package/dist/client/assets/chunk-CSCIHK7Q-BxItphyO.js +124 -0
  31. package/dist/client/assets/chunk-FMBD7UC4-4hEcfvfT.js +15 -0
  32. package/dist/client/assets/chunk-KSCS5N6A-DnArkpOI.js +10 -0
  33. package/dist/client/assets/chunk-L5ZTLDWV-DQoYJ1BW.js +1 -0
  34. package/dist/client/assets/chunk-LZXEDZCA-CdCevBX-.js +2 -0
  35. package/dist/client/assets/chunk-ND2GUHAM-Dt89U_nU.js +1 -0
  36. package/dist/client/assets/chunk-NNHCCRGN-DlpIbxXb.js +159 -0
  37. package/dist/client/assets/chunk-NZK2D7GU-C2Ev_f_w.js +1 -0
  38. package/dist/client/assets/chunk-O5CBEL6O-BeZ3MFnJ.js +70 -0
  39. package/dist/client/assets/chunk-QZHKN3VN-BmDOSWHp.js +1 -0
  40. package/dist/client/assets/chunk-WU5MYG2G-gLuInTk4.js +1 -0
  41. package/dist/client/assets/chunk-XPW4576I-DPbDc8_T.js +32 -0
  42. package/dist/client/assets/classDiagram-4FO5ZUOK-DOpVKUrb.js +1 -0
  43. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-DOpVKUrb.js +1 -0
  44. package/dist/client/assets/cose-bilkent-S5V4N54A-CCPyalms.js +1 -0
  45. package/dist/client/assets/cytoscape.esm-FqbQrHcz.js +321 -0
  46. package/dist/client/assets/dagre-BM42HDAG-6jrYMjX3.js +4 -0
  47. package/dist/client/assets/dagre-Bx709z4p.js +1 -0
  48. package/dist/client/assets/defaultLocale-C8Fc0cco.js +1 -0
  49. package/dist/client/assets/diagram-2AECGRRQ-dCQ_KoXl.js +43 -0
  50. package/dist/client/assets/diagram-5GNKFQAL-B3Xj4-XE.js +10 -0
  51. package/dist/client/assets/diagram-KO2AKTUF-DaGgF2yL.js +3 -0
  52. package/dist/client/assets/diagram-LMA3HP47-CF_c4tPT.js +24 -0
  53. package/dist/client/assets/diagram-OG6HWLK6-BgBHbJkT.js +24 -0
  54. package/dist/client/assets/dist-7wVkzZne.js +1 -0
  55. package/dist/client/assets/erDiagram-TEJ5UH35-BZgX1grR.js +85 -0
  56. package/dist/client/assets/eventmodeling-FCH6USID-BaI6RNKr.js +1 -0
  57. package/dist/client/assets/flowDiagram-I6XJVG4X-BFNUa12V.js +162 -0
  58. package/dist/client/assets/ganttDiagram-6RSMTGT7-C8t6BThI.js +292 -0
  59. package/dist/client/assets/gitGraph-WXDBUCRP-BlicfDkM.js +1 -0
  60. package/dist/client/assets/gitGraphDiagram-PVQCEYII-BmnIXuXP.js +106 -0
  61. package/dist/client/assets/graphlib-B8gBHxth.js +1 -0
  62. package/dist/client/assets/index-BfVtbKTZ.css +2 -0
  63. package/dist/client/assets/index-p8zzfjdu.js +87 -0
  64. package/dist/client/assets/info-J43DQDTF-DHLG38N3.js +1 -0
  65. package/dist/client/assets/infoDiagram-5YYISTIA-oQ-lq-45.js +2 -0
  66. package/dist/client/assets/init-D6jRqBbL.js +1 -0
  67. package/dist/client/assets/ishikawaDiagram-YF4QCWOH-DrODlBLD.js +70 -0
  68. package/dist/client/assets/journeyDiagram-JHISSGLW-Bxg5wCQz.js +139 -0
  69. package/dist/client/assets/kanban-definition-UN3LZRKU-DAdvUDIB.js +89 -0
  70. package/dist/client/assets/katex-Vhh-h91d.js +257 -0
  71. package/dist/client/assets/line-D8kNd46u.js +1 -0
  72. package/dist/client/assets/linear-jwGZSrg4.js +1 -0
  73. package/dist/client/assets/mermaid-parser.core-PVPfUfyU.js +4 -0
  74. package/dist/client/assets/mermaid.core-DwdP6CFg.js +9 -0
  75. package/dist/client/assets/mindmap-definition-RKZ34NQL-DeOoL56Q.js +96 -0
  76. package/dist/client/assets/ordinal-hYBb2elL.js +1 -0
  77. package/dist/client/assets/packet-YPE3B663-BBEaxdJP.js +1 -0
  78. package/dist/client/assets/path-BWPyau1x.js +1 -0
  79. package/dist/client/assets/pie-LRSECV5Y-fvTRFm-2.js +1 -0
  80. package/dist/client/assets/pieDiagram-4H26LBE5-oep1px2r.js +30 -0
  81. package/dist/client/assets/prism-bash-D6zCJ74D.js +1 -0
  82. package/dist/client/assets/prism-clojure-_z-zoNcE.js +1 -0
  83. package/dist/client/assets/prism-csharp-XPn2bw0d.js +1 -0
  84. package/dist/client/assets/prism-dart-CDv16Fbu.js +1 -0
  85. package/dist/client/assets/prism-elixir-DhHhV5Rj.js +1 -0
  86. package/dist/client/assets/prism-haskell-5hq1Nh9c.js +1 -0
  87. package/dist/client/assets/prism-hcl-B6Z0Ouoo.js +1 -0
  88. package/dist/client/assets/prism-java-6wtusvIl.js +1 -0
  89. package/dist/client/assets/prism-markup-templating-KkSMBj6Y.js +1 -0
  90. package/dist/client/assets/prism-nix-C88dTX93.js +1 -0
  91. package/dist/client/assets/prism-perl-Lsbi34sH.js +1 -0
  92. package/dist/client/assets/prism-php-Co4fM40K.js +1 -0
  93. package/dist/client/assets/prism-protobuf-yzSHlhwy.js +1 -0
  94. package/dist/client/assets/prism-ruby-DFCASkuD.js +1 -0
  95. package/dist/client/assets/prism-scala-VkM3wCOL.js +1 -0
  96. package/dist/client/assets/prism-solidity-DTom4k5p.js +1 -0
  97. package/dist/client/assets/prism-sql-CYhVDMEE.js +1 -0
  98. package/dist/client/assets/prism-vim-BgpkjCK5.js +1 -0
  99. package/dist/client/assets/quadrantDiagram-W4KKPZXB-D9l66CHb.js +7 -0
  100. package/dist/client/assets/radar-GUYGQ44K-DAU5EPVd.js +1 -0
  101. package/dist/client/assets/requirementDiagram-4Y6WPE33-helpQlwb.js +84 -0
  102. package/dist/client/assets/rough.esm-CSKSodPl.js +1 -0
  103. package/dist/client/assets/sankeyDiagram-5OEKKPKP-C9sNuuvA.js +40 -0
  104. package/dist/client/assets/sequenceDiagram-3UESZ5HK-BP0bhCwQ.js +162 -0
  105. package/dist/client/assets/src-CDAPGf2l.js +1 -0
  106. package/dist/client/assets/stateDiagram-AJRCARHV-CsFizpla.js +1 -0
  107. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Bvn8tanl.js +1 -0
  108. package/dist/client/assets/timeline-definition-PNZ67QCA-BnkmHs1F.js +120 -0
  109. package/dist/client/assets/treeView-BLDUP644-DD9HZO5W.js +1 -0
  110. package/dist/client/assets/treemap-LRROVOQU-BBKuQ1lo.js +1 -0
  111. package/dist/client/assets/vennDiagram-CIIHVFJN-BPFTHflF.js +34 -0
  112. package/dist/client/assets/wardley-L42UT6IY-y88gDkwb.js +1 -0
  113. package/dist/client/assets/wardleyDiagram-YWT4CUSO-BlcA7IVh.js +78 -0
  114. package/dist/client/assets/xychartDiagram-2RQKCTM6-DX5vUCRH.js +7 -0
  115. package/dist/client/favicon.png +0 -0
  116. package/dist/client/index.html +14 -0
  117. package/dist/client/logo.png +0 -0
  118. package/dist/client/site-data/blobs/080c0e6/cHVibGljL3NpdGUtZGF0YS9vZy1pbWFnZS5wbmc.png +0 -0
  119. package/dist/client/site-data/blobs/55f23a1/bGFuZGluZy9wdWJsaWMvZGlmaXQvbG9nby5wbmc.png +0 -0
  120. package/dist/client/site-data/blobs/66ff7c6/cHVibGljL2xvZ28ucG5n.png +0 -0
  121. package/dist/client/site-data/blobs/e6977fe/cHVibGljL2xvZ28ucG5n.png +0 -0
  122. package/dist/client/site-data/manifest.json +1 -0
  123. package/dist/client/site-data/og-image.png +0 -0
  124. package/dist/client/site-data/snapshots/55f23a1...080c0e6.json +1 -0
  125. package/dist/client/site-data/snapshots/66ff7c6...e6977fe.json +1 -0
  126. package/dist/client/site-data/snapshots/7d40fd4...a72112f-comments.json +1 -0
  127. package/dist/client/site-data/snapshots/7d40fd4...a72112f.json +1 -0
  128. package/dist/server/file-watcher.d.ts +33 -0
  129. package/dist/server/file-watcher.js +340 -0
  130. package/dist/server/generated-file-check.d.ts +17 -0
  131. package/dist/server/generated-file-check.js +280 -0
  132. package/dist/server/git-diff.d.ts +94 -0
  133. package/dist/server/git-diff.js +909 -0
  134. package/dist/server/grouping/cache.d.ts +3 -0
  135. package/dist/server/grouping/cache.js +22 -0
  136. package/dist/server/grouping/claudeCli.d.ts +10 -0
  137. package/dist/server/grouping/claudeCli.js +95 -0
  138. package/dist/server/grouping/digest.d.ts +15 -0
  139. package/dist/server/grouping/digest.js +38 -0
  140. package/dist/server/grouping/reconcile.d.ts +6 -0
  141. package/dist/server/grouping/reconcile.js +34 -0
  142. package/dist/server/grouping/types.d.ts +9 -0
  143. package/dist/server/grouping/types.js +1 -0
  144. package/dist/server/server.d.ts +37 -0
  145. package/dist/server/server.js +1283 -0
  146. package/dist/shared/grouping.d.ts +4 -0
  147. package/dist/shared/grouping.js +170 -0
  148. package/dist/types/diff.d.ts +263 -0
  149. package/dist/types/diff.js +1 -0
  150. package/dist/types/watch.d.ts +59 -0
  151. package/dist/types/watch.js +8 -0
  152. package/dist/utils/commentFormatting.d.ts +6 -0
  153. package/dist/utils/commentFormatting.js +90 -0
  154. package/dist/utils/commentImports.d.ts +11 -0
  155. package/dist/utils/commentImports.js +386 -0
  156. package/dist/utils/createId.d.ts +1 -0
  157. package/dist/utils/createId.js +5 -0
  158. package/dist/utils/diffMode.d.ts +3 -0
  159. package/dist/utils/diffMode.js +13 -0
  160. package/dist/utils/diffSelection.d.ts +13 -0
  161. package/dist/utils/diffSelection.js +39 -0
  162. package/dist/utils/editorOptions.d.ts +60 -0
  163. package/dist/utils/editorOptions.js +171 -0
  164. package/dist/utils/fileUtils.d.ts +12 -0
  165. package/dist/utils/fileUtils.js +21 -0
  166. package/dist/utils/hubConfig.d.ts +32 -0
  167. package/dist/utils/hubConfig.js +51 -0
  168. package/dist/utils/suggestionUtils.d.ts +19 -0
  169. package/dist/utils/suggestionUtils.js +31 -0
  170. package/dist/utils/turnLog.d.ts +47 -0
  171. package/dist/utils/turnLog.js +133 -0
  172. package/package.json +105 -0
@@ -0,0 +1,909 @@
1
+ import { simpleGit } from 'simple-git';
2
+ import { isAbsolute, resolve, sep } from 'path';
3
+ import { validateDiffArguments, shortHash, createCommitRangeString, findUntrackedFiles, } from '../cli/utils.js';
4
+ import { getMergeBaseTargetRef, normalizeBaseMode } from '../utils/diffSelection.js';
5
+ import { isGeneratedFile } from './generated-file-check.js';
6
+ /**
7
+ * Run `fn` over `items` with at most `limit` in flight at once, preserving input
8
+ * order in the results. Keeps unbounded fan-out (one git spawn per file) from
9
+ * exhausting processes/memory while staying non-blocking.
10
+ */
11
+ async function mapWithConcurrency(items, limit, fn) {
12
+ const results = new Array(items.length);
13
+ let next = 0;
14
+ const worker = async () => {
15
+ while (next < items.length) {
16
+ const index = next++;
17
+ results[index] = await fn(items[index]);
18
+ }
19
+ };
20
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
21
+ return results;
22
+ }
23
+ export class GitDiffParser {
24
+ git;
25
+ repoPath;
26
+ includeUntracked;
27
+ resolvedCommitCache = new Map();
28
+ static RESOLVED_COMMIT_CACHE_TTL_MS = 5_000;
29
+ static GENERATED_HEADER_SCAN_BYTES = 4 * 1024;
30
+ static GITATTRIBUTES_CHECK_CHUNK_SIZE = 200;
31
+ static UNTRACKED_DIFF_MAX_BUFFER = 100 * 1024 * 1024; // 100MB
32
+ // Bound the per-fetch untracked work so a repo full of untracked files (fresh
33
+ // clone, build output) can't spawn thousands of git processes or hog memory.
34
+ static UNTRACKED_DIFF_CONCURRENCY = 8;
35
+ static MAX_UNTRACKED_FILES = 2000;
36
+ // `includeUntracked` is opt-in: the hub opens arbitrary repos at runtime where
37
+ // it cannot show difit's interactive "include untracked?" prompt, so it asks
38
+ // for read-only synthesis instead (see getUntrackedFileDiffs). The CLI leaves
39
+ // it off and keeps owning untracked files via its intent-to-add prompt.
40
+ constructor(repoPath = process.cwd(), options = {}) {
41
+ this.repoPath = repoPath;
42
+ this.git = simpleGit(repoPath);
43
+ this.includeUntracked = options.includeUntracked ?? false;
44
+ }
45
+ normalizeRepositoryRelativePath(filepath) {
46
+ if (filepath.length === 0) {
47
+ throw new Error('Invalid file path');
48
+ }
49
+ const normalizedFilepath = filepath.replace(/\\/g, '/');
50
+ const hasParentTraversal = normalizedFilepath.split('/').some((segment) => segment === '..');
51
+ if (isAbsolute(filepath) || normalizedFilepath.startsWith('/') || hasParentTraversal) {
52
+ throw new Error('File path outside repository');
53
+ }
54
+ const repositoryPath = resolve(this.repoPath);
55
+ const resolvedPath = resolve(repositoryPath, normalizedFilepath);
56
+ if (resolvedPath !== repositoryPath && !resolvedPath.startsWith(`${repositoryPath}${sep}`)) {
57
+ throw new Error('File path outside repository');
58
+ }
59
+ return normalizedFilepath;
60
+ }
61
+ async resolveBaseCommitish(selection) {
62
+ if (normalizeBaseMode(selection.baseMode) !== 'merge-base') {
63
+ return selection.baseCommitish;
64
+ }
65
+ const targetRef = getMergeBaseTargetRef(selection.targetCommitish);
66
+ const mergeBase = await this.git.raw(['merge-base', targetRef, selection.baseCommitish]);
67
+ return mergeBase.trim();
68
+ }
69
+ async parseDiff(selection, ignoreWhitespace = false, contextLines) {
70
+ const { targetCommitish, baseCommitish } = selection;
71
+ const requestedBaseMode = normalizeBaseMode(selection.baseMode) === 'merge-base' ? 'merge-base' : undefined;
72
+ try {
73
+ // diff-easy: a turn that produced no changes has identical before/after
74
+ // snapshots (git dedups equal trees), so base === target. Treat that as an
75
+ // empty diff instead of erroring ("Cannot compare X with itself").
76
+ if (baseCommitish === targetCommitish) {
77
+ const hash = shortHash(baseCommitish);
78
+ return {
79
+ commit: 'No changes in this turn',
80
+ files: [],
81
+ isEmpty: true,
82
+ baseCommitish: hash,
83
+ targetCommitish: hash,
84
+ requestedBaseCommitish: baseCommitish,
85
+ requestedTargetCommitish: targetCommitish,
86
+ requestedBaseMode,
87
+ };
88
+ }
89
+ // Validate arguments
90
+ const validation = validateDiffArguments(targetCommitish, baseCommitish);
91
+ if (!validation.valid) {
92
+ throw new Error(validation.error);
93
+ }
94
+ const effectiveBaseCommitish = await this.resolveBaseCommitish(selection);
95
+ let resolvedCommit;
96
+ let diffArgs;
97
+ let resolvedBaseCommitish = effectiveBaseCommitish;
98
+ let resolvedTargetCommitish = targetCommitish;
99
+ let attributesRef = targetCommitish;
100
+ // Handle target special chars (base is always a regular commit)
101
+ if (targetCommitish === 'working') {
102
+ // Show unstaged changes (working vs staged)
103
+ resolvedCommit = 'Working Directory (unstaged changes)';
104
+ diffArgs = [];
105
+ }
106
+ else if (targetCommitish === 'staged') {
107
+ // Show staged changes against base commit
108
+ const baseHash = await this.git.revparse([effectiveBaseCommitish]);
109
+ resolvedCommit = `${shortHash(baseHash)} vs Staging Area (staged changes)`;
110
+ resolvedBaseCommitish = shortHash(baseHash);
111
+ diffArgs = ['--cached', effectiveBaseCommitish];
112
+ }
113
+ else if (targetCommitish === '.') {
114
+ // Show all uncommitted changes against base commit
115
+ const baseHash = await this.git.revparse([effectiveBaseCommitish]);
116
+ resolvedCommit = `${shortHash(baseHash)} vs Working Directory (all uncommitted changes)`;
117
+ resolvedBaseCommitish = shortHash(baseHash);
118
+ diffArgs = [effectiveBaseCommitish];
119
+ }
120
+ else {
121
+ // Both are regular commits: standard commit-to-commit comparison
122
+ const targetHash = await this.git.revparse([targetCommitish]);
123
+ const baseHash = await this.git.revparse([effectiveBaseCommitish]);
124
+ resolvedCommit = createCommitRangeString(shortHash(baseHash), shortHash(targetHash));
125
+ resolvedBaseCommitish = shortHash(baseHash);
126
+ resolvedTargetCommitish = shortHash(targetHash);
127
+ attributesRef = targetHash;
128
+ diffArgs = [baseHash, targetHash];
129
+ }
130
+ if (ignoreWhitespace) {
131
+ diffArgs.push('-w');
132
+ }
133
+ if (contextLines !== undefined) {
134
+ diffArgs.push(`-U${contextLines}`);
135
+ }
136
+ // Ignore external diff-tools to unify output.
137
+ // https://github.com/yoshiko-pg/difit/issues/19
138
+ diffArgs.push('--no-ext-diff', '--color=never');
139
+ // Single git invocation for better startup latency on large repositories.
140
+ let diffRaw = await this.git.diff(diffArgs);
141
+ // `git diff` never reports untracked files, so brand-new files are invisible
142
+ // in the working ('working') and all-uncommitted ('.') views. When enabled
143
+ // (hub mode), synthesize new-file diffs for them — read-only, no index
144
+ // mutation. The staged ('--cached') view correctly excludes them.
145
+ if (this.includeUntracked && (targetCommitish === 'working' || targetCommitish === '.')) {
146
+ const untrackedDiff = await this.getUntrackedFileDiffs(ignoreWhitespace, contextLines);
147
+ if (untrackedDiff) {
148
+ diffRaw =
149
+ diffRaw && !diffRaw.endsWith('\n')
150
+ ? `${diffRaw}\n${untrackedDiff}`
151
+ : `${diffRaw}${untrackedDiff}`;
152
+ }
153
+ }
154
+ const files = await this.markGitattributesGeneratedFiles(this.parseUnifiedDiff(diffRaw), attributesRef);
155
+ return {
156
+ commit: resolvedCommit,
157
+ files,
158
+ isEmpty: files.length === 0,
159
+ baseCommitish: resolvedBaseCommitish,
160
+ targetCommitish: resolvedTargetCommitish,
161
+ requestedBaseCommitish: baseCommitish,
162
+ requestedTargetCommitish: targetCommitish,
163
+ requestedBaseMode,
164
+ };
165
+ }
166
+ catch (error) {
167
+ throw new Error(`Failed to parse diff for ${targetCommitish} vs ${baseCommitish}: ${error instanceof Error ? error.message : 'Unknown error'}`);
168
+ }
169
+ }
170
+ /**
171
+ * Build synthetic unified diffs for untracked (new, not-yet-added) files.
172
+ *
173
+ * difit's own untracked support works by mutating the index (`git add
174
+ * --intent-to-add`) so the normal `git diff` picks the files up. The hub can't
175
+ * prompt for that and must not mutate repos it merely views, so instead we
176
+ * reuse difit's read-only enumerator (`findUntrackedFiles`) and produce a
177
+ * new-file diff per file with `git diff --no-index -- /dev/null <file>`, which
178
+ * never touches the index.
179
+ */
180
+ async getUntrackedFileDiffs(ignoreWhitespace, contextLines) {
181
+ let files = await findUntrackedFiles(this.git);
182
+ if (files.length === 0) {
183
+ return '';
184
+ }
185
+ // Guard against repos with a huge number of untracked files (fresh clone,
186
+ // build artifacts): cap the work and tell the operator what was dropped
187
+ // rather than silently spawning thousands of git processes.
188
+ if (files.length > GitDiffParser.MAX_UNTRACKED_FILES) {
189
+ console.warn(`difit: ${files.length - GitDiffParser.MAX_UNTRACKED_FILES} untracked file(s) beyond ${GitDiffParser.MAX_UNTRACKED_FILES} are not shown in the diff`);
190
+ files = files.slice(0, GitDiffParser.MAX_UNTRACKED_FILES);
191
+ }
192
+ const { execFile } = await import('child_process');
193
+ const synthesizeOne = (file) => {
194
+ const args = ['diff', '--no-index', '--no-ext-diff', '--color=never'];
195
+ if (ignoreWhitespace) {
196
+ args.push('-w');
197
+ }
198
+ if (contextLines !== undefined) {
199
+ args.push(`-U${contextLines}`);
200
+ }
201
+ args.push('--', '/dev/null', file);
202
+ // Async (non-blocking) so a hub fetch over many untracked files never
203
+ // stalls the event loop — other requests, SSE pings and watcher callbacks
204
+ // keep flowing while git runs.
205
+ return new Promise((resolve) => {
206
+ execFile('git', args, {
207
+ cwd: this.repoPath,
208
+ encoding: 'utf8',
209
+ maxBuffer: GitDiffParser.UNTRACKED_DIFF_MAX_BUFFER,
210
+ }, (error, stdout) => {
211
+ // `git diff --no-index` exits 1 when the files differ (always here,
212
+ // since we diff against /dev/null) and still writes the diff to
213
+ // stdout. Treat exit 1 as success; skip anything else (e.g. the file
214
+ // vanished between listing and diffing) rather than failing the fetch.
215
+ const code = error?.code;
216
+ if (error && code !== 1) {
217
+ resolve('');
218
+ return;
219
+ }
220
+ resolve((stdout ?? '').toString());
221
+ });
222
+ });
223
+ };
224
+ const outputs = await mapWithConcurrency(files, GitDiffParser.UNTRACKED_DIFF_CONCURRENCY, synthesizeOne);
225
+ return outputs
226
+ .filter((output) => output.trim())
227
+ .map((output) => (output.endsWith('\n') ? output : `${output}\n`))
228
+ .join('');
229
+ }
230
+ parseUnifiedDiff(diffText) {
231
+ const files = [];
232
+ const fileBlocks = diffText.split(/^diff --git /m).slice(1);
233
+ for (const fileBlock of fileBlocks) {
234
+ const block = `diff --git ${fileBlock}`;
235
+ const file = this.parseFileBlock(block);
236
+ if (file) {
237
+ files.push(file);
238
+ }
239
+ }
240
+ return files;
241
+ }
242
+ getGitattributesSourceArgs(ref) {
243
+ if (ref === 'working' || ref === '.') {
244
+ return [];
245
+ }
246
+ if (ref === 'staged') {
247
+ return ['--cached'];
248
+ }
249
+ return ['--source', ref];
250
+ }
251
+ parseGitattributesGeneratedOutput(output) {
252
+ const generatedPaths = new Set();
253
+ const fields = output.split('\0');
254
+ for (let i = 0; i + 2 < fields.length; i += 3) {
255
+ const [path, attribute, value] = fields.slice(i, i + 3);
256
+ if (attribute === 'linguist-generated' && value === 'true') {
257
+ generatedPaths.add(path);
258
+ }
259
+ }
260
+ return generatedPaths;
261
+ }
262
+ async getGitattributesGeneratedPaths(filepaths, ref) {
263
+ if (filepaths.length === 0) {
264
+ return new Set();
265
+ }
266
+ const generatedPaths = new Set();
267
+ try {
268
+ for (let i = 0; i < filepaths.length; i += GitDiffParser.GITATTRIBUTES_CHECK_CHUNK_SIZE) {
269
+ const chunk = filepaths.slice(i, i + GitDiffParser.GITATTRIBUTES_CHECK_CHUNK_SIZE);
270
+ const output = await this.git.raw([
271
+ 'check-attr',
272
+ '-z',
273
+ ...this.getGitattributesSourceArgs(ref),
274
+ 'linguist-generated',
275
+ '--',
276
+ ...chunk,
277
+ ]);
278
+ if (typeof output !== 'string') {
279
+ continue;
280
+ }
281
+ for (const path of this.parseGitattributesGeneratedOutput(output)) {
282
+ generatedPaths.add(path);
283
+ }
284
+ }
285
+ return generatedPaths;
286
+ }
287
+ catch {
288
+ return new Set();
289
+ }
290
+ }
291
+ async markGitattributesGeneratedFiles(files, ref) {
292
+ const candidates = files.filter((file) => !file.isGenerated).map((file) => file.path);
293
+ const generatedPaths = await this.getGitattributesGeneratedPaths(candidates, ref);
294
+ if (generatedPaths.size === 0) {
295
+ return files;
296
+ }
297
+ return files.map((file) => generatedPaths.has(file.path) ? { ...file, isGenerated: true } : file);
298
+ }
299
+ decodeGitPath(rawPath) {
300
+ if (typeof rawPath !== 'string') {
301
+ return undefined;
302
+ }
303
+ const trimmed = rawPath.startsWith('"') && rawPath.endsWith('"') ? rawPath.slice(1, -1) : rawPath;
304
+ const gitPrefixes = ['a/', 'b/', 'c/', 'i/', 'w/'];
305
+ let withoutPrefix = trimmed;
306
+ for (const prefix of gitPrefixes) {
307
+ if (withoutPrefix.startsWith(prefix)) {
308
+ withoutPrefix = withoutPrefix.slice(prefix.length);
309
+ break;
310
+ }
311
+ }
312
+ const tabIndex = withoutPrefix.indexOf('\t');
313
+ if (tabIndex !== -1) {
314
+ withoutPrefix = withoutPrefix.slice(0, tabIndex);
315
+ }
316
+ if (withoutPrefix === '/dev/null') {
317
+ return undefined;
318
+ }
319
+ const bytes = [];
320
+ for (let i = 0; i < withoutPrefix.length; i++) {
321
+ const char = withoutPrefix[i];
322
+ if (char === '\\' && i + 1 < withoutPrefix.length) {
323
+ const next = withoutPrefix[i + 1];
324
+ if (/[0-7]/.test(next)) {
325
+ let octal = next;
326
+ let read = 1;
327
+ while (read < 3 && i + 1 + read < withoutPrefix.length) {
328
+ const candidate = withoutPrefix[i + 1 + read];
329
+ if (!/[0-7]/.test(candidate)) {
330
+ break;
331
+ }
332
+ octal += candidate;
333
+ read++;
334
+ }
335
+ bytes.push(parseInt(octal, 8));
336
+ i += read; // Skip consumed digits
337
+ continue;
338
+ }
339
+ switch (next) {
340
+ case 't':
341
+ bytes.push(0x09);
342
+ break;
343
+ case 'n':
344
+ bytes.push(0x0a);
345
+ break;
346
+ case 'r':
347
+ bytes.push(0x0d);
348
+ break;
349
+ case 'b':
350
+ bytes.push(0x08);
351
+ break;
352
+ case 'f':
353
+ bytes.push(0x0c);
354
+ break;
355
+ case 'v':
356
+ bytes.push(0x0b);
357
+ break;
358
+ case 'a':
359
+ bytes.push(0x07);
360
+ break;
361
+ case '\\':
362
+ bytes.push(0x5c);
363
+ break;
364
+ case '"':
365
+ bytes.push(0x22);
366
+ break;
367
+ case ' ':
368
+ bytes.push(0x20);
369
+ break;
370
+ default:
371
+ bytes.push(...Buffer.from(next, 'utf8'));
372
+ break;
373
+ }
374
+ i++; // Skip the escaped character
375
+ continue;
376
+ }
377
+ bytes.push(...Buffer.from(char, 'utf8'));
378
+ }
379
+ return Buffer.from(bytes).toString('utf8');
380
+ }
381
+ extractPathFromLine(line, prefix) {
382
+ if (!line?.startsWith(prefix)) {
383
+ return undefined;
384
+ }
385
+ return this.decodeGitPath(line.slice(prefix.length));
386
+ }
387
+ parseDiffHeaderPaths(headerLine) {
388
+ if (!headerLine.startsWith('diff --git ')) {
389
+ return null;
390
+ }
391
+ const raw = headerLine.slice('diff --git '.length);
392
+ const segments = [];
393
+ let current = '';
394
+ let inQuotes = false;
395
+ for (let i = 0; i < raw.length; i++) {
396
+ const char = raw[i];
397
+ const prevChar = i > 0 ? raw[i - 1] : null;
398
+ if (char === '"' && prevChar !== '\\') {
399
+ inQuotes = !inQuotes;
400
+ current += char;
401
+ continue;
402
+ }
403
+ if (char === ' ' && !inQuotes && prevChar !== '\\') {
404
+ if (current) {
405
+ segments.push(current);
406
+ current = '';
407
+ }
408
+ continue;
409
+ }
410
+ current += char;
411
+ }
412
+ if (current) {
413
+ segments.push(current);
414
+ }
415
+ if (segments.length !== 2) {
416
+ return null;
417
+ }
418
+ const [rawOldPath, rawNewPath] = segments;
419
+ return {
420
+ oldPath: this.decodeGitPath(rawOldPath),
421
+ newPath: this.decodeGitPath(rawNewPath),
422
+ };
423
+ }
424
+ parseFileBlock(block) {
425
+ const lines = block.split('\n');
426
+ const headerLine = lines[0];
427
+ const headerPaths = this.parseDiffHeaderPaths(headerLine);
428
+ const minusLine = lines.find((line) => line.startsWith('--- '));
429
+ const plusLine = lines.find((line) => line.startsWith('+++ '));
430
+ const renameFromLine = lines.find((line) => line.startsWith('rename from '));
431
+ const renameToLine = lines.find((line) => line.startsWith('rename to '));
432
+ const plusPath = this.extractPathFromLine(plusLine, '+++ ');
433
+ const minusPath = this.extractPathFromLine(minusLine, '--- ');
434
+ const renameFromPath = this.extractPathFromLine(renameFromLine, 'rename from ');
435
+ const renameToPath = this.extractPathFromLine(renameToLine, 'rename to ');
436
+ const newPath = renameToPath ?? plusPath ?? headerPaths?.newPath;
437
+ const oldPath = renameFromPath ?? minusPath ?? headerPaths?.oldPath ?? newPath;
438
+ if (!newPath) {
439
+ return null;
440
+ }
441
+ const path = newPath;
442
+ let status = 'modified';
443
+ // Check for new file mode (added files)
444
+ const newFileMode = lines.find((line) => line.startsWith('new file mode'));
445
+ const deletedFileMode = lines.find((line) => line.startsWith('deleted file mode'));
446
+ // Check for /dev/null which indicates added or deleted files
447
+ if (newFileMode || minusLine?.includes('/dev/null')) {
448
+ status = 'added';
449
+ }
450
+ else if (deletedFileMode || plusLine?.includes('/dev/null')) {
451
+ status = 'deleted';
452
+ }
453
+ else if (oldPath !== newPath) {
454
+ status = 'renamed';
455
+ }
456
+ // Common properties for all files
457
+ const baseFile = {
458
+ path,
459
+ oldPath: status === 'renamed' && oldPath !== newPath ? oldPath : undefined,
460
+ status,
461
+ };
462
+ // Parse chunks
463
+ const chunks = this.parseChunks(lines);
464
+ const { additions, deletions } = this.countLinesFromChunks(chunks);
465
+ return {
466
+ ...baseFile,
467
+ additions,
468
+ deletions,
469
+ chunks,
470
+ isGenerated: isGeneratedFile(path).isGenerated,
471
+ };
472
+ }
473
+ countLinesFromChunks(chunks) {
474
+ let additions = 0;
475
+ let deletions = 0;
476
+ for (const chunk of chunks) {
477
+ for (const line of chunk.lines) {
478
+ if (line.type === 'add')
479
+ additions++;
480
+ else if (line.type === 'delete')
481
+ deletions++;
482
+ }
483
+ }
484
+ return { additions, deletions };
485
+ }
486
+ parseChunks(lines) {
487
+ const chunks = [];
488
+ let currentChunk = null;
489
+ let oldLineNum = 0;
490
+ let newLineNum = 0;
491
+ for (const line of lines) {
492
+ if (line.startsWith('@@')) {
493
+ if (currentChunk) {
494
+ chunks.push(currentChunk);
495
+ }
496
+ const match = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)/);
497
+ if (match) {
498
+ const oldStart = parseInt(match[1]);
499
+ const oldLines = parseInt(match[2] || '1');
500
+ const newStart = parseInt(match[3]);
501
+ const newLines = parseInt(match[4] || '1');
502
+ oldLineNum = oldStart;
503
+ newLineNum = newStart;
504
+ currentChunk = {
505
+ header: line,
506
+ oldStart,
507
+ oldLines,
508
+ newStart,
509
+ newLines,
510
+ lines: [],
511
+ };
512
+ }
513
+ }
514
+ else if (currentChunk &&
515
+ (line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))) {
516
+ const type = line.startsWith('+') ? 'add' : line.startsWith('-') ? 'delete' : 'normal';
517
+ const diffLine = {
518
+ type,
519
+ content: line.slice(1),
520
+ oldLineNumber: type !== 'add' ? oldLineNum : undefined,
521
+ newLineNumber: type !== 'delete' ? newLineNum : undefined,
522
+ };
523
+ currentChunk.lines.push(diffLine);
524
+ if (type !== 'add')
525
+ oldLineNum++;
526
+ if (type !== 'delete')
527
+ newLineNum++;
528
+ }
529
+ }
530
+ if (currentChunk) {
531
+ chunks.push(currentChunk);
532
+ }
533
+ return chunks;
534
+ }
535
+ async validateCommit(commitish) {
536
+ try {
537
+ if (commitish === '.' || commitish === 'working' || commitish === 'staged') {
538
+ // For working directory or staging area, just check if we're in a git repo
539
+ await this.git.status();
540
+ return true;
541
+ }
542
+ await this.git.show([commitish, '--name-only']);
543
+ return true;
544
+ }
545
+ catch {
546
+ return false;
547
+ }
548
+ }
549
+ parseStdinDiff(diffContent) {
550
+ const files = this.parseUnifiedDiff(diffContent);
551
+ return {
552
+ commit: 'stdin diff',
553
+ files,
554
+ isEmpty: files.length === 0,
555
+ };
556
+ }
557
+ async getBlobContent(filepath, ref) {
558
+ try {
559
+ // For working directory, read directly from filesystem
560
+ if (ref === 'working' || ref === '.') {
561
+ const fs = await import('fs');
562
+ if (filepath.length === 0) {
563
+ throw new Error('Invalid file path');
564
+ }
565
+ const repositoryPath = fs.realpathSync(resolve(this.repoPath));
566
+ const repositoryRoot = `${repositoryPath}${sep}`;
567
+ const absolutePath = fs.realpathSync(resolve(repositoryRoot, filepath.replace(/\\/g, '/')));
568
+ if (!absolutePath.startsWith(repositoryRoot)) {
569
+ throw new Error('File path outside repository');
570
+ }
571
+ return fs.readFileSync(absolutePath);
572
+ }
573
+ const normalizedFilepath = this.normalizeRepositoryRelativePath(filepath);
574
+ // For git refs, we need to use child_process to execute git cat-file
575
+ // to properly handle binary data
576
+ const { execFileSync } = await import('child_process');
577
+ // Handle staged files
578
+ if (ref === 'staged') {
579
+ // For staged files, use git show :filepath
580
+ // Using execFileSync to prevent command injection
581
+ const buffer = execFileSync('git', ['show', `:${normalizedFilepath}`], {
582
+ cwd: this.repoPath,
583
+ maxBuffer: 10 * 1024 * 1024, // 10MB limit
584
+ });
585
+ return buffer;
586
+ }
587
+ // First, get the blob hash for the file at the given ref
588
+ // Using execFileSync to prevent command injection
589
+ const blobHash = execFileSync('git', ['rev-parse', `${ref}:${normalizedFilepath}`], {
590
+ cwd: this.repoPath,
591
+ encoding: 'utf8',
592
+ maxBuffer: 10 * 1024 * 1024,
593
+ }).trim();
594
+ // Then use git cat-file to get the raw binary content
595
+ // Increase maxBuffer to handle large files (default is 1024*1024 = 1MB)
596
+ const buffer = execFileSync('git', ['cat-file', 'blob', blobHash], {
597
+ cwd: this.repoPath,
598
+ maxBuffer: 10 * 1024 * 1024, // 10MB limit
599
+ });
600
+ return buffer;
601
+ }
602
+ catch (error) {
603
+ // Check if it's a buffer size error
604
+ if (error instanceof Error &&
605
+ (error.message.includes('ENOBUFS') || error.message.includes('maxBuffer'))) {
606
+ throw new Error(`Image file ${filepath} is too large to display (over 10MB limit)`);
607
+ }
608
+ if (error instanceof Error &&
609
+ (error.message === 'Invalid file path' || error.message === 'File path outside repository')) {
610
+ throw error;
611
+ }
612
+ throw new Error(`Failed to get blob content for ${filepath} at ${ref}: ${error instanceof Error ? error.message : 'Unknown error'}`);
613
+ }
614
+ }
615
+ async getLineCount(filepath, ref) {
616
+ const buffer = await this.getBlobContent(filepath, ref);
617
+ let count = 0;
618
+ for (let i = 0; i < buffer.length; i++) {
619
+ if (buffer[i] === 0x0a)
620
+ count++; // newline byte
621
+ }
622
+ // If file doesn't end with newline, the last line still counts
623
+ if (buffer.length > 0 && buffer[buffer.length - 1] !== 0x0a) {
624
+ count++;
625
+ }
626
+ return count;
627
+ }
628
+ extractHeaderLines(buffer, maxLines = 20) {
629
+ const headerSlice = buffer.subarray(0, GitDiffParser.GENERATED_HEADER_SCAN_BYTES);
630
+ return headerSlice.toString('utf8').split('\n').slice(0, maxLines);
631
+ }
632
+ async getGeneratedStatus(filepath, ref) {
633
+ const pathResult = isGeneratedFile(filepath);
634
+ if (pathResult.isGenerated) {
635
+ return { isGenerated: true, source: 'path' };
636
+ }
637
+ const gitattributesResult = await this.getGitattributesGeneratedPaths([filepath], ref);
638
+ if (gitattributesResult.has(filepath)) {
639
+ return { isGenerated: true, source: 'path' };
640
+ }
641
+ try {
642
+ const buffer = await this.getBlobContent(filepath, ref);
643
+ const lines = this.extractHeaderLines(buffer);
644
+ const result = isGeneratedFile(filepath, () => lines);
645
+ return { isGenerated: result.isGenerated, source: 'content' };
646
+ }
647
+ catch {
648
+ return { isGenerated: false, source: 'path' };
649
+ }
650
+ }
651
+ async resolveCommitish(commitish) {
652
+ const now = Date.now();
653
+ const cached = this.resolvedCommitCache.get(commitish);
654
+ if (cached && cached.expiresAt > now) {
655
+ return cached.value;
656
+ }
657
+ const hash = await this.git.revparse([commitish]);
658
+ const value = hash.substring(0, 7);
659
+ this.resolvedCommitCache.set(commitish, {
660
+ value,
661
+ expiresAt: now + GitDiffParser.RESOLVED_COMMIT_CACHE_TTL_MS,
662
+ });
663
+ return value;
664
+ }
665
+ clearResolvedCommitCache() {
666
+ this.resolvedCommitCache.clear();
667
+ }
668
+ async getDefaultBranch() {
669
+ try {
670
+ // Try to get the default branch from origin/HEAD
671
+ const result = await this.git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']);
672
+ // Result will be like "refs/remotes/origin/main\n"
673
+ const match = result.trim().match(/refs\/remotes\/origin\/(.+)/);
674
+ if (match) {
675
+ return match[1];
676
+ }
677
+ }
678
+ catch {
679
+ // If origin/HEAD is not set, fall back to common default branches
680
+ const commonDefaults = ['main', 'master'];
681
+ const branchResult = await this.git.branchLocal();
682
+ const branchNames = Object.keys(branchResult.branches);
683
+ for (const defaultName of commonDefaults) {
684
+ if (branchNames.includes(defaultName)) {
685
+ return defaultName;
686
+ }
687
+ }
688
+ }
689
+ return null;
690
+ }
691
+ async getOriginDefaultBranch() {
692
+ try {
693
+ const result = await this.git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']);
694
+ const match = result.trim().match(/refs\/remotes\/origin\/(.+)/);
695
+ if (match) {
696
+ return `origin/${match[1]}`;
697
+ }
698
+ }
699
+ catch {
700
+ const commonDefaults = ['main', 'master'];
701
+ for (const defaultName of commonDefaults) {
702
+ try {
703
+ await this.git.raw([
704
+ 'show-ref',
705
+ '--verify',
706
+ '--quiet',
707
+ `refs/remotes/origin/${defaultName}`,
708
+ ]);
709
+ return `origin/${defaultName}`;
710
+ }
711
+ catch {
712
+ // Ignore missing refs and continue checking common defaults.
713
+ }
714
+ }
715
+ }
716
+ return null;
717
+ }
718
+ async getCurrentBranch() {
719
+ try {
720
+ const name = (await this.git.raw(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
721
+ return name && name !== 'HEAD' ? name : null;
722
+ }
723
+ catch {
724
+ return null;
725
+ }
726
+ }
727
+ async getPgitParent() {
728
+ const branch = await this.getCurrentBranch();
729
+ if (!branch)
730
+ return null;
731
+ let value;
732
+ try {
733
+ value = (await this.git.raw(['config', `branch.${branch}.pgit-parent`])).trim();
734
+ }
735
+ catch {
736
+ // git config exits non-zero when the key is absent
737
+ return null;
738
+ }
739
+ if (!value)
740
+ return null;
741
+ // Only use the recorded parent if it actually resolves locally; a stale or
742
+ // missing ref would make the current-branch merge-base diff fail, so fall
743
+ // back to null (→ client lands on Uncommitted + a "pick a base" banner).
744
+ try {
745
+ await this.git.raw(['rev-parse', '--verify', '--quiet', `${value}^{commit}`]);
746
+ return value;
747
+ }
748
+ catch {
749
+ return null;
750
+ }
751
+ }
752
+ /**
753
+ * Best-effort parent for a stacked/task branch when no pgit parent is recorded:
754
+ * the local branch whose tip is the *nearest* ancestor of HEAD (smallest
755
+ * `<branch>..HEAD` commit distance, excluding the current branch). For a branch
756
+ * cut from another local branch (e.g. `task_X_child` off `task_X`) this recovers
757
+ * the real parent; git stores no such link otherwise. Heuristic — the caller
758
+ * marks it inferred and keeps it overridable.
759
+ */
760
+ async getNearestAncestorBranch(currentBranch) {
761
+ let candidates;
762
+ try {
763
+ const out = await this.git.raw(['branch', '--merged', 'HEAD', '--format=%(refname:short)']);
764
+ candidates = out
765
+ .split('\n')
766
+ .map((s) => s.trim())
767
+ .filter((b) => b && b !== currentBranch && !b.startsWith('('));
768
+ }
769
+ catch {
770
+ return null;
771
+ }
772
+ const measured = await Promise.all(candidates.map(async (branch) => {
773
+ try {
774
+ const dist = Number.parseInt((await this.git.raw(['rev-list', '--count', `${branch}..HEAD`])).trim(), 10);
775
+ return Number.isFinite(dist) && dist > 0 ? { branch, dist } : null;
776
+ }
777
+ catch {
778
+ return null;
779
+ }
780
+ }));
781
+ let best = null;
782
+ for (const m of measured) {
783
+ if (!m)
784
+ continue;
785
+ if (!best || m.dist < best.dist || (m.dist === best.dist && m.branch < best.branch))
786
+ best = m;
787
+ }
788
+ return best?.branch ?? null;
789
+ }
790
+ async getCommitsInRange(base, target) {
791
+ const normalizeRef = (ref) => ref === '.' || ref === 'staged' || ref === 'working' ? 'HEAD' : ref;
792
+ const normTarget = normalizeRef(target);
793
+ const normBase = base ? normalizeRef(base) : undefined;
794
+ const result = normBase
795
+ ? await this.git.log([`${normBase}..${normTarget}`, '--max-count=200'])
796
+ : await this.git.log([normTarget, '--max-count=50']);
797
+ return result.all.map((commit) => ({
798
+ hash: commit.hash,
799
+ shortHash: commit.hash.substring(0, 7),
800
+ message: commit.message,
801
+ body: commit.body || undefined,
802
+ author: commit.author_name || undefined,
803
+ date: commit.date || undefined,
804
+ }));
805
+ }
806
+ async getRevisionOptions(currentBase, currentTarget) {
807
+ const [branchResult, logResult, defaultBranch, originDefaultBranch, currentBranch, pgitParent] = await Promise.all([
808
+ this.git.branchLocal(),
809
+ this.git.log({ maxCount: 20 }),
810
+ this.getDefaultBranch(),
811
+ this.getOriginDefaultBranch(),
812
+ this.getCurrentBranch(),
813
+ this.getPgitParent(),
814
+ ]);
815
+ const branches = Object.entries(branchResult.branches).map(([name, data]) => ({
816
+ name,
817
+ current: data.current,
818
+ }));
819
+ // Sort branches: default branch first, then current branch, then alphabetically
820
+ branches.sort((a, b) => {
821
+ if (defaultBranch) {
822
+ if (a.name === defaultBranch)
823
+ return -1;
824
+ if (b.name === defaultBranch)
825
+ return 1;
826
+ }
827
+ if (a.current && !b.current)
828
+ return -1;
829
+ if (!a.current && b.current)
830
+ return 1;
831
+ return a.name.localeCompare(b.name);
832
+ });
833
+ const commits = logResult.all.map((commit) => ({
834
+ hash: commit.hash,
835
+ shortHash: commit.hash.substring(0, 7),
836
+ message: commit.message,
837
+ body: commit.body || undefined,
838
+ author: commit.author_name || undefined,
839
+ date: commit.date || undefined,
840
+ }));
841
+ // Resolve HEAD and HEAD^ to actual commit hashes if they're being used
842
+ let resolvedBase;
843
+ let resolvedTarget;
844
+ if (currentBase && !['working', 'staged', '.'].includes(currentBase)) {
845
+ try {
846
+ resolvedBase = await this.resolveCommitish(currentBase);
847
+ }
848
+ catch {
849
+ // If resolution fails, leave undefined
850
+ }
851
+ }
852
+ if (currentTarget && !['working', 'staged', '.'].includes(currentTarget)) {
853
+ try {
854
+ resolvedTarget = await this.resolveCommitish(currentTarget);
855
+ }
856
+ catch {
857
+ // If resolution fails, leave undefined
858
+ }
859
+ }
860
+ // Effective base for the "Current branch" view. Prefer the explicit pgit
861
+ // parent; otherwise fall back to the origin default branch, but only when the
862
+ // current branch actually diverges from it (a merge-base exists and isn't
863
+ // HEAD itself) — so a branch sitting on/at the default branch doesn't produce
864
+ // an empty "branch diff". When neither yields a base, the client prompts.
865
+ let branchBase;
866
+ let branchBaseSource;
867
+ if (pgitParent) {
868
+ branchBase = pgitParent;
869
+ branchBaseSource = 'pgit';
870
+ }
871
+ else {
872
+ // Prefer the nearest local ancestor branch (recovers a stacked/task
873
+ // branch's real parent); fall back to the origin default branch only when
874
+ // no local parent exists.
875
+ const nearest = await this.getNearestAncestorBranch(currentBranch);
876
+ if (nearest) {
877
+ branchBase = nearest;
878
+ branchBaseSource = 'branch';
879
+ }
880
+ else if (originDefaultBranch) {
881
+ try {
882
+ const [mergeBase, head] = await Promise.all([
883
+ this.git.raw(['merge-base', originDefaultBranch, 'HEAD']).then((s) => s.trim()),
884
+ this.git.raw(['rev-parse', 'HEAD']).then((s) => s.trim()),
885
+ ]);
886
+ if (mergeBase && mergeBase !== head) {
887
+ branchBase = originDefaultBranch;
888
+ branchBaseSource = 'origin-default';
889
+ }
890
+ }
891
+ catch {
892
+ // No merge-base (unrelated histories) or the default ref is missing —
893
+ // leave branchBase undefined so the client falls back to a prompt.
894
+ }
895
+ }
896
+ }
897
+ return {
898
+ branches,
899
+ commits,
900
+ originDefaultBranch: originDefaultBranch ?? undefined,
901
+ resolvedBase,
902
+ resolvedTarget,
903
+ currentBranch: currentBranch ?? undefined,
904
+ pgitParent: pgitParent ?? undefined,
905
+ branchBase,
906
+ branchBaseSource,
907
+ };
908
+ }
909
+ }