diffstalker 0.1.6 → 0.2.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 (79) hide show
  1. package/.github/workflows/release.yml +5 -3
  2. package/CHANGELOG.md +36 -0
  3. package/bun.lock +378 -0
  4. package/dist/App.js +1162 -1
  5. package/dist/config.js +83 -2
  6. package/dist/core/ExplorerStateManager.js +266 -0
  7. package/dist/core/FilePathWatcher.js +133 -0
  8. package/dist/core/GitOperationQueue.js +109 -1
  9. package/dist/core/GitStateManager.js +525 -1
  10. package/dist/git/diff.js +471 -10
  11. package/dist/git/ignoreUtils.js +30 -0
  12. package/dist/git/status.js +237 -5
  13. package/dist/index.js +70 -16
  14. package/dist/ipc/CommandClient.js +165 -0
  15. package/dist/ipc/CommandServer.js +152 -0
  16. package/dist/services/commitService.js +22 -1
  17. package/dist/state/CommitFlowState.js +86 -0
  18. package/dist/state/UIState.js +182 -0
  19. package/dist/themes.js +127 -1
  20. package/dist/types/tabs.js +4 -0
  21. package/dist/ui/Layout.js +252 -0
  22. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  23. package/dist/ui/modals/DiscardConfirm.js +77 -0
  24. package/dist/ui/modals/HotkeysModal.js +209 -0
  25. package/dist/ui/modals/ThemePicker.js +107 -0
  26. package/dist/ui/widgets/CommitPanel.js +58 -0
  27. package/dist/ui/widgets/CompareListView.js +216 -0
  28. package/dist/ui/widgets/DiffView.js +279 -0
  29. package/dist/ui/widgets/ExplorerContent.js +102 -0
  30. package/dist/ui/widgets/ExplorerView.js +95 -0
  31. package/dist/ui/widgets/FileList.js +185 -0
  32. package/dist/ui/widgets/Footer.js +46 -0
  33. package/dist/ui/widgets/Header.js +111 -0
  34. package/dist/ui/widgets/HistoryView.js +69 -0
  35. package/dist/utils/ansiToBlessed.js +125 -0
  36. package/dist/utils/ansiTruncate.js +108 -0
  37. package/dist/utils/baseBranchCache.js +44 -2
  38. package/dist/utils/commitFormat.js +38 -1
  39. package/dist/utils/diffFilters.js +21 -1
  40. package/dist/utils/diffRowCalculations.js +113 -1
  41. package/dist/utils/displayRows.js +351 -2
  42. package/dist/utils/explorerDisplayRows.js +169 -0
  43. package/dist/utils/fileCategories.js +26 -1
  44. package/dist/utils/formatDate.js +39 -1
  45. package/dist/utils/formatPath.js +58 -1
  46. package/dist/utils/languageDetection.js +236 -0
  47. package/dist/utils/layoutCalculations.js +98 -1
  48. package/dist/utils/lineBreaking.js +88 -5
  49. package/dist/utils/mouseCoordinates.js +165 -1
  50. package/dist/utils/pathUtils.js +27 -0
  51. package/dist/utils/rowCalculations.js +246 -4
  52. package/dist/utils/wordDiff.js +50 -0
  53. package/package.json +15 -19
  54. package/dist/components/BaseBranchPicker.js +0 -1
  55. package/dist/components/BottomPane.js +0 -1
  56. package/dist/components/CommitPanel.js +0 -1
  57. package/dist/components/CompareListView.js +0 -1
  58. package/dist/components/ExplorerContentView.js +0 -3
  59. package/dist/components/ExplorerView.js +0 -1
  60. package/dist/components/FileList.js +0 -1
  61. package/dist/components/Footer.js +0 -1
  62. package/dist/components/Header.js +0 -1
  63. package/dist/components/HistoryView.js +0 -1
  64. package/dist/components/HotkeysModal.js +0 -1
  65. package/dist/components/Modal.js +0 -1
  66. package/dist/components/ScrollableList.js +0 -1
  67. package/dist/components/ThemePicker.js +0 -1
  68. package/dist/components/TopPane.js +0 -1
  69. package/dist/components/UnifiedDiffView.js +0 -1
  70. package/dist/hooks/useCommitFlow.js +0 -1
  71. package/dist/hooks/useCompareState.js +0 -1
  72. package/dist/hooks/useExplorerState.js +0 -9
  73. package/dist/hooks/useGit.js +0 -1
  74. package/dist/hooks/useHistoryState.js +0 -1
  75. package/dist/hooks/useKeymap.js +0 -1
  76. package/dist/hooks/useLayout.js +0 -1
  77. package/dist/hooks/useMouse.js +0 -1
  78. package/dist/hooks/useTerminalSize.js +0 -1
  79. package/dist/hooks/useWatcher.js +0 -11
package/dist/git/diff.js CHANGED
@@ -1,10 +1,471 @@
1
- import{execSync as B}from"node:child_process";import{simpleGit as b}from"simple-git";export function parseDiffLine(s){return s.startsWith("diff --git")||s.startsWith("index ")||s.startsWith("---")||s.startsWith("+++")||s.startsWith("new file")||s.startsWith("deleted file")?{type:"header",content:s}:s.startsWith("@@")?{type:"hunk",content:s}:s.startsWith("+")?{type:"addition",content:s}:s.startsWith("-")?{type:"deletion",content:s}:{type:"context",content:s}}export function parseHunkHeader(s){const o=s.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);return o?{oldStart:parseInt(o[1],10),newStart:parseInt(o[2],10)}:null}export function parseDiffWithLineNumbers(s){const o=s.split(`
2
- `),i=[];let a=0,c=0;for(const n of o)if(n.startsWith("diff --git")||n.startsWith("index ")||n.startsWith("---")||n.startsWith("+++")||n.startsWith("new file")||n.startsWith("deleted file")||n.startsWith("Binary files")||n.startsWith("similarity index")||n.startsWith("rename from")||n.startsWith("rename to"))i.push({type:"header",content:n});else if(n.startsWith("@@")){const l=parseHunkHeader(n);l&&(a=l.oldStart,c=l.newStart),i.push({type:"hunk",content:n})}else n.startsWith("+")?i.push({type:"addition",content:n,newLineNum:c++}):n.startsWith("-")?i.push({type:"deletion",content:n,oldLineNum:a++}):i.push({type:"context",content:n,oldLineNum:a++,newLineNum:c++});return i}export async function getDiff(s,o,i=!1){const a=b(s);try{const c=[];i&&c.push("--cached"),o&&c.push("--",o);const n=await a.diff(c),l=parseDiffWithLineNumbers(n);return{raw:n,lines:l}}catch{return{raw:"",lines:[]}}}export async function getDiffForUntracked(s,o){try{const i=B(`cat "${o}"`,{cwd:s,encoding:"utf-8"}),a=[{type:"header",content:`diff --git a/${o} b/${o}`},{type:"header",content:"new file mode 100644"},{type:"header",content:"--- /dev/null"},{type:"header",content:`+++ b/${o}`}],c=i.split(`
3
- `);a.push({type:"hunk",content:`@@ -0,0 +1,${c.length} @@`});let n=1;for(const h of c)a.push({type:"addition",content:"+"+h,newLineNum:n++});return{raw:a.map(h=>h.content).join(`
4
- `),lines:a}}catch{return{raw:"",lines:[]}}}export async function getStagedDiff(s){return getDiff(s,void 0,!0)}export async function getCandidateBaseBranches(s){const o=b(s),i=new Set,a=[];try{const c=await o.raw(["log","--oneline","--decorate=short","--all","-n","200"]),n=/\(([^)]+)\)/g;for(const l of c.split(`
5
- `)){const h=n.exec(l);if(h){const m=h[1].split(",").map(u=>u.trim());for(const u of m){if(u.startsWith("HEAD")||u.startsWith("tag:")||!u.includes("/"))continue;const d=u.replace(/^.*-> /,"");d.includes("/")&&!i.has(d)&&(i.add(d),a.push(d))}}n.lastIndex=0}a.length>0&&a.sort((l,h)=>{const m=l.split("/").slice(1).join("/"),u=h.split("/").slice(1).join("/"),d=m==="main"||m==="master",g=u==="main"||u==="master";if(d&&!g)return-1;if(!d&&g)return 1;if(d&&g){const w=l.startsWith("origin/"),y=h.startsWith("origin/");if(w&&!y)return 1;if(!w&&y)return-1}return 0})}catch{}return[...new Set(a)]}export async function getDefaultBaseBranch(s){return(await getCandidateBaseBranches(s))[0]??null}export async function getDiffBetweenRefs(s,o){const i=b(s),c=(await i.raw(["merge-base",o,"HEAD"])).trim(),n=await i.raw(["diff","--numstat",`${c}...HEAD`]),l=await i.raw(["diff","--name-status",`${c}...HEAD`]),h=await i.raw(["diff",`${c}...HEAD`]),m=n.trim().split(`
6
- `).filter(t=>t),u=new Map;for(const t of m){const e=t.split(" ");if(e.length>=3){const r=e[0]==="-"?0:parseInt(e[0],10),p=e[1]==="-"?0:parseInt(e[1],10),f=e.slice(2).join(" ");u.set(f,{additions:r,deletions:p})}}const d=l.trim().split(`
7
- `).filter(t=>t),g=new Map;for(const t of d){const e=t.split(" ");if(e.length>=2){const r=e[0][0],p=e[e.length-1];let f;switch(r){case"A":f="added";break;case"D":f="deleted";break;case"R":f="renamed";break;default:f="modified"}g.set(p,f)}}const w=[],y=h.split(/(?=^diff --git )/m).filter(t=>t.trim());for(const t of y){const e=t.match(/^diff --git a\/.+ b\/(.+)$/m);if(!e)continue;const r=e[1],p=parseDiffWithLineNumbers(t),f=u.get(r)||{additions:0,deletions:0},D=g.get(r)||"modified";w.push({path:r,status:D,additions:f.additions,deletions:f.deletions,diff:{raw:t,lines:p}})}let x=0,k=0;for(const t of w)x+=t.additions,k+=t.deletions;const I=(await i.status()).files.length,S=(await i.log({from:c,to:"HEAD"})).all.map(t=>({hash:t.hash,shortHash:t.hash.slice(0,7),message:t.message.split(`
8
- `)[0],author:t.author_name,date:new Date(t.date),refs:t.refs||""}));return{baseBranch:o,stats:{filesChanged:w.length,additions:x,deletions:k},files:w,commits:S,uncommittedCount:I}}export async function getCommitDiff(s,o){const i=b(s);try{const a=await i.raw(["show",o,"--format="]),c=parseDiffWithLineNumbers(a);return{raw:a,lines:c}}catch{return{raw:"",lines:[]}}}export async function getCompareDiffWithUncommitted(s,o){const i=b(s),a=await getDiffBetweenRefs(s,o),c=await i.diff(["--cached","--numstat"]),n=await i.diff(["--numstat"]),l=await i.diff(["--cached"]),h=await i.diff([]),m=new Map;for(const t of c.trim().split(`
9
- `).filter(e=>e)){const e=t.split(" ");if(e.length>=3){const r=e[0]==="-"?0:parseInt(e[0],10),p=e[1]==="-"?0:parseInt(e[1],10),f=e.slice(2).join(" ");m.set(f,{additions:r,deletions:p,staged:!0,unstaged:!1})}}for(const t of n.trim().split(`
10
- `).filter(e=>e)){const e=t.split(" ");if(e.length>=3){const r=e[0]==="-"?0:parseInt(e[0],10),p=e[1]==="-"?0:parseInt(e[1],10),f=e.slice(2).join(" "),D=m.get(f);D?(D.additions+=r,D.deletions+=p,D.unstaged=!0):m.set(f,{additions:r,deletions:p,staged:!1,unstaged:!0})}}const u=await i.status(),d=new Map;for(const t of u.files)t.index==="A"||t.working_dir==="?"?d.set(t.path,"added"):t.index==="D"||t.working_dir==="D"?d.set(t.path,"deleted"):t.index==="R"?d.set(t.path,"renamed"):d.set(t.path,"modified");const g=[],y=(l+h).split(/(?=^diff --git )/m).filter(t=>t.trim()),x=new Set;for(const t of y){const e=t.match(/^diff --git a\/.+ b\/(.+)$/m);if(!e)continue;const r=e[1];if(x.has(r))continue;x.add(r);const p=parseDiffWithLineNumbers(t),f=m.get(r)||{additions:0,deletions:0},D=d.get(r)||"modified";g.push({path:r,status:D,additions:f.additions,deletions:f.deletions,diff:{raw:t,lines:p},isUncommitted:!0})}const k=new Set(a.files.map(t=>t.path)),W=[];for(const t of a.files){const e=g.find(r=>r.path===t.path);e?(W.push(t),W.push(e)):W.push(t)}for(const t of g)k.has(t.path)||W.push(t);let I=0,L=0;const S=new Set;for(const t of W)S.has(t.path)||S.add(t.path),I+=t.additions,L+=t.deletions;return{baseBranch:a.baseBranch,stats:{filesChanged:S.size,additions:I,deletions:L},files:W,commits:a.commits,uncommittedCount:a.uncommittedCount}}
1
+ import { execSync } from 'node:child_process';
2
+ import { simpleGit } from 'simple-git';
3
+ export function parseDiffLine(line) {
4
+ if (line.startsWith('diff --git') ||
5
+ line.startsWith('index ') ||
6
+ line.startsWith('---') ||
7
+ line.startsWith('+++') ||
8
+ line.startsWith('new file') ||
9
+ line.startsWith('deleted file')) {
10
+ return { type: 'header', content: line };
11
+ }
12
+ if (line.startsWith('@@')) {
13
+ return { type: 'hunk', content: line };
14
+ }
15
+ if (line.startsWith('+')) {
16
+ return { type: 'addition', content: line };
17
+ }
18
+ if (line.startsWith('-')) {
19
+ return { type: 'deletion', content: line };
20
+ }
21
+ return { type: 'context', content: line };
22
+ }
23
+ /**
24
+ * Parse a hunk header to extract line numbers.
25
+ * Format: @@ -oldStart,oldCount +newStart,newCount @@
26
+ * Example: @@ -1,5 +1,7 @@ or @@ -10 +10,2 @@
27
+ */
28
+ export function parseHunkHeader(line) {
29
+ const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
30
+ if (match) {
31
+ return {
32
+ oldStart: parseInt(match[1], 10),
33
+ newStart: parseInt(match[2], 10),
34
+ };
35
+ }
36
+ return null;
37
+ }
38
+ /**
39
+ * Parse diff output with line numbers.
40
+ * Tracks line numbers through hunks for proper display.
41
+ */
42
+ export function parseDiffWithLineNumbers(raw) {
43
+ const lines = raw.split('\n');
44
+ const result = [];
45
+ let oldLineNum = 0;
46
+ let newLineNum = 0;
47
+ for (const line of lines) {
48
+ if (line.startsWith('diff --git') ||
49
+ line.startsWith('index ') ||
50
+ line.startsWith('---') ||
51
+ line.startsWith('+++') ||
52
+ line.startsWith('new file') ||
53
+ line.startsWith('deleted file') ||
54
+ line.startsWith('Binary files') ||
55
+ line.startsWith('similarity index') ||
56
+ line.startsWith('rename from') ||
57
+ line.startsWith('rename to')) {
58
+ result.push({ type: 'header', content: line });
59
+ }
60
+ else if (line.startsWith('@@')) {
61
+ const hunkInfo = parseHunkHeader(line);
62
+ if (hunkInfo) {
63
+ oldLineNum = hunkInfo.oldStart;
64
+ newLineNum = hunkInfo.newStart;
65
+ }
66
+ result.push({ type: 'hunk', content: line });
67
+ }
68
+ else if (line.startsWith('+')) {
69
+ result.push({
70
+ type: 'addition',
71
+ content: line,
72
+ newLineNum: newLineNum++,
73
+ });
74
+ }
75
+ else if (line.startsWith('-')) {
76
+ result.push({
77
+ type: 'deletion',
78
+ content: line,
79
+ oldLineNum: oldLineNum++,
80
+ });
81
+ }
82
+ else {
83
+ // Context line (starts with space) or empty line
84
+ result.push({
85
+ type: 'context',
86
+ content: line,
87
+ oldLineNum: oldLineNum++,
88
+ newLineNum: newLineNum++,
89
+ });
90
+ }
91
+ }
92
+ return result;
93
+ }
94
+ export async function getDiff(repoPath, file, staged = false) {
95
+ const git = simpleGit(repoPath);
96
+ try {
97
+ const args = [];
98
+ if (staged) {
99
+ args.push('--cached');
100
+ }
101
+ if (file) {
102
+ args.push('--', file);
103
+ }
104
+ const raw = await git.diff(args);
105
+ const lines = parseDiffWithLineNumbers(raw);
106
+ return { raw, lines };
107
+ }
108
+ catch {
109
+ return { raw: '', lines: [] };
110
+ }
111
+ }
112
+ export async function getDiffForUntracked(repoPath, file) {
113
+ try {
114
+ // For untracked files, show the entire file as additions
115
+ const content = execSync(`cat "${file}"`, { cwd: repoPath, encoding: 'utf-8' });
116
+ const lines = [
117
+ { type: 'header', content: `diff --git a/${file} b/${file}` },
118
+ { type: 'header', content: 'new file mode 100644' },
119
+ { type: 'header', content: `--- /dev/null` },
120
+ { type: 'header', content: `+++ b/${file}` },
121
+ ];
122
+ const contentLines = content.split('\n');
123
+ lines.push({ type: 'hunk', content: `@@ -0,0 +1,${contentLines.length} @@` });
124
+ let lineNum = 1;
125
+ for (const line of contentLines) {
126
+ lines.push({ type: 'addition', content: '+' + line, newLineNum: lineNum++ });
127
+ }
128
+ const raw = lines.map((l) => l.content).join('\n');
129
+ return { raw, lines };
130
+ }
131
+ catch {
132
+ return { raw: '', lines: [] };
133
+ }
134
+ }
135
+ export async function getStagedDiff(repoPath) {
136
+ return getDiff(repoPath, undefined, true);
137
+ }
138
+ /**
139
+ * Get candidate base branches for PR comparison.
140
+ * Uses git log to find branches that appear in recent history (likely PR targets).
141
+ */
142
+ export async function getCandidateBaseBranches(repoPath) {
143
+ const git = simpleGit(repoPath);
144
+ const seen = new Set();
145
+ const candidates = [];
146
+ try {
147
+ // Get recent commits with decorations to find branches in our history
148
+ const logOutput = await git.raw(['log', '--oneline', '--decorate=short', '--all', '-n', '200']);
149
+ // Extract remote branch refs from decorations like (origin/main, upstream/feature)
150
+ const refPattern = /\(([^)]+)\)/g;
151
+ for (const line of logOutput.split('\n')) {
152
+ const match = refPattern.exec(line);
153
+ if (match) {
154
+ const refs = match[1].split(',').map((r) => r.trim());
155
+ for (const ref of refs) {
156
+ // Skip HEAD, tags, and local branches - only want remote branches
157
+ if (ref.startsWith('HEAD') || ref.startsWith('tag:') || !ref.includes('/'))
158
+ continue;
159
+ // Clean up "origin/main" from things like "HEAD -> origin/main"
160
+ const cleaned = ref.replace(/^.*-> /, '');
161
+ if (cleaned.includes('/') && !seen.has(cleaned)) {
162
+ seen.add(cleaned);
163
+ candidates.push(cleaned);
164
+ }
165
+ }
166
+ }
167
+ refPattern.lastIndex = 0; // Reset regex state
168
+ }
169
+ // If we found candidates, sort main/master to top, prefer non-origin
170
+ if (candidates.length > 0) {
171
+ candidates.sort((a, b) => {
172
+ const aName = a.split('/').slice(1).join('/');
173
+ const bName = b.split('/').slice(1).join('/');
174
+ const aIsMain = aName === 'main' || aName === 'master';
175
+ const bIsMain = bName === 'main' || bName === 'master';
176
+ // main/master first
177
+ if (aIsMain && !bIsMain)
178
+ return -1;
179
+ if (!aIsMain && bIsMain)
180
+ return 1;
181
+ // Among main/master, prefer non-origin
182
+ if (aIsMain && bIsMain) {
183
+ const aIsOrigin = a.startsWith('origin/');
184
+ const bIsOrigin = b.startsWith('origin/');
185
+ if (aIsOrigin && !bIsOrigin)
186
+ return 1;
187
+ if (!aIsOrigin && bIsOrigin)
188
+ return -1;
189
+ }
190
+ return 0; // Keep discovery order otherwise
191
+ });
192
+ }
193
+ }
194
+ catch {
195
+ // Failed to get branches
196
+ }
197
+ // Return unique candidates (Set deduplication)
198
+ return [...new Set(candidates)];
199
+ }
200
+ /**
201
+ * Get the best default base branch for PR comparison.
202
+ */
203
+ export async function getDefaultBaseBranch(repoPath) {
204
+ const candidates = await getCandidateBaseBranches(repoPath);
205
+ return candidates[0] ?? null;
206
+ }
207
+ /**
208
+ * Get diff between HEAD and a base ref (for PR-like view).
209
+ * Uses three-dot diff (merge-base) to show only changes on current branch.
210
+ */
211
+ export async function getDiffBetweenRefs(repoPath, baseRef) {
212
+ const git = simpleGit(repoPath);
213
+ // Get merge-base for three-dot diff
214
+ const mergeBase = await git.raw(['merge-base', baseRef, 'HEAD']);
215
+ const base = mergeBase.trim();
216
+ // Get per-file stats with --numstat
217
+ const numstat = await git.raw(['diff', '--numstat', `${base}...HEAD`]);
218
+ // Get file statuses with --name-status
219
+ const nameStatus = await git.raw(['diff', '--name-status', `${base}...HEAD`]);
220
+ // Get full diff
221
+ const rawDiff = await git.raw(['diff', `${base}...HEAD`]);
222
+ // Parse numstat: "additions deletions filepath" per line
223
+ const numstatLines = numstat
224
+ .trim()
225
+ .split('\n')
226
+ .filter((l) => l);
227
+ const fileStats = new Map();
228
+ for (const line of numstatLines) {
229
+ const parts = line.split('\t');
230
+ if (parts.length >= 3) {
231
+ const additions = parts[0] === '-' ? 0 : parseInt(parts[0], 10);
232
+ const deletions = parts[1] === '-' ? 0 : parseInt(parts[1], 10);
233
+ const filepath = parts.slice(2).join('\t'); // Handle paths with tabs
234
+ fileStats.set(filepath, { additions, deletions });
235
+ }
236
+ }
237
+ // Parse name-status: "A/M/D/R filepath" per line
238
+ const nameStatusLines = nameStatus
239
+ .trim()
240
+ .split('\n')
241
+ .filter((l) => l);
242
+ const fileStatuses = new Map();
243
+ for (const line of nameStatusLines) {
244
+ const parts = line.split('\t');
245
+ if (parts.length >= 2) {
246
+ const statusChar = parts[0][0];
247
+ const filepath = parts[parts.length - 1]; // Use last part for renamed files
248
+ let status;
249
+ switch (statusChar) {
250
+ case 'A':
251
+ status = 'added';
252
+ break;
253
+ case 'D':
254
+ status = 'deleted';
255
+ break;
256
+ case 'R':
257
+ status = 'renamed';
258
+ break;
259
+ default:
260
+ status = 'modified';
261
+ }
262
+ fileStatuses.set(filepath, status);
263
+ }
264
+ }
265
+ // Split raw diff by file headers
266
+ const fileDiffs = [];
267
+ const diffChunks = rawDiff.split(/(?=^diff --git )/m).filter((chunk) => chunk.trim());
268
+ for (const chunk of diffChunks) {
269
+ // Extract file path from the diff header
270
+ const match = chunk.match(/^diff --git a\/.+ b\/(.+)$/m);
271
+ if (!match)
272
+ continue;
273
+ const filepath = match[1];
274
+ const lines = parseDiffWithLineNumbers(chunk);
275
+ const stats = fileStats.get(filepath) || { additions: 0, deletions: 0 };
276
+ const status = fileStatuses.get(filepath) || 'modified';
277
+ fileDiffs.push({
278
+ path: filepath,
279
+ status,
280
+ additions: stats.additions,
281
+ deletions: stats.deletions,
282
+ diff: { raw: chunk, lines },
283
+ });
284
+ }
285
+ // Calculate total stats
286
+ let totalAdditions = 0;
287
+ let totalDeletions = 0;
288
+ for (const file of fileDiffs) {
289
+ totalAdditions += file.additions;
290
+ totalDeletions += file.deletions;
291
+ }
292
+ // Get uncommitted count from status
293
+ const status = await git.status();
294
+ const uncommittedCount = status.files.length;
295
+ // Get commits between base and HEAD
296
+ const log = await git.log({ from: base, to: 'HEAD' });
297
+ const commits = log.all.map((entry) => ({
298
+ hash: entry.hash,
299
+ shortHash: entry.hash.slice(0, 7),
300
+ message: entry.message.split('\n')[0],
301
+ author: entry.author_name,
302
+ date: new Date(entry.date),
303
+ refs: entry.refs || '',
304
+ }));
305
+ return {
306
+ baseBranch: baseRef,
307
+ stats: {
308
+ filesChanged: fileDiffs.length,
309
+ additions: totalAdditions,
310
+ deletions: totalDeletions,
311
+ },
312
+ files: fileDiffs,
313
+ commits,
314
+ uncommittedCount,
315
+ };
316
+ }
317
+ /**
318
+ * Get diff for a specific commit.
319
+ * Shows the changes introduced by that commit.
320
+ */
321
+ export async function getCommitDiff(repoPath, hash) {
322
+ const git = simpleGit(repoPath);
323
+ try {
324
+ // git show <hash> --format="" gives just the diff without commit metadata
325
+ const raw = await git.raw(['show', hash, '--format=']);
326
+ const lines = parseDiffWithLineNumbers(raw);
327
+ return { raw, lines };
328
+ }
329
+ catch {
330
+ return { raw: '', lines: [] };
331
+ }
332
+ }
333
+ /**
334
+ * Get PR diff that includes uncommitted changes (staged + unstaged).
335
+ * Merges committed diff with working tree changes.
336
+ */
337
+ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
338
+ const git = simpleGit(repoPath);
339
+ // Get the committed PR diff first
340
+ const committedDiff = await getDiffBetweenRefs(repoPath, baseRef);
341
+ // Get uncommitted changes (both staged and unstaged)
342
+ const stagedRaw = await git.diff(['--cached', '--numstat']);
343
+ const unstagedRaw = await git.diff(['--numstat']);
344
+ const stagedDiff = await git.diff(['--cached']);
345
+ const unstagedDiff = await git.diff([]);
346
+ // Parse uncommitted file stats
347
+ const uncommittedFiles = new Map();
348
+ // Parse staged files
349
+ for (const line of stagedRaw
350
+ .trim()
351
+ .split('\n')
352
+ .filter((l) => l)) {
353
+ const parts = line.split('\t');
354
+ if (parts.length >= 3) {
355
+ const additions = parts[0] === '-' ? 0 : parseInt(parts[0], 10);
356
+ const deletions = parts[1] === '-' ? 0 : parseInt(parts[1], 10);
357
+ const filepath = parts.slice(2).join('\t');
358
+ uncommittedFiles.set(filepath, { additions, deletions, staged: true, unstaged: false });
359
+ }
360
+ }
361
+ // Parse unstaged files
362
+ for (const line of unstagedRaw
363
+ .trim()
364
+ .split('\n')
365
+ .filter((l) => l)) {
366
+ const parts = line.split('\t');
367
+ if (parts.length >= 3) {
368
+ const additions = parts[0] === '-' ? 0 : parseInt(parts[0], 10);
369
+ const deletions = parts[1] === '-' ? 0 : parseInt(parts[1], 10);
370
+ const filepath = parts.slice(2).join('\t');
371
+ const existing = uncommittedFiles.get(filepath);
372
+ if (existing) {
373
+ existing.additions += additions;
374
+ existing.deletions += deletions;
375
+ existing.unstaged = true;
376
+ }
377
+ else {
378
+ uncommittedFiles.set(filepath, { additions, deletions, staged: false, unstaged: true });
379
+ }
380
+ }
381
+ }
382
+ // Get status for file status detection
383
+ const status = await git.status();
384
+ const statusMap = new Map();
385
+ for (const file of status.files) {
386
+ if (file.index === 'A' || file.working_dir === '?') {
387
+ statusMap.set(file.path, 'added');
388
+ }
389
+ else if (file.index === 'D' || file.working_dir === 'D') {
390
+ statusMap.set(file.path, 'deleted');
391
+ }
392
+ else if (file.index === 'R') {
393
+ statusMap.set(file.path, 'renamed');
394
+ }
395
+ else {
396
+ statusMap.set(file.path, 'modified');
397
+ }
398
+ }
399
+ // Split uncommitted diffs by file
400
+ const uncommittedFileDiffs = [];
401
+ const combinedDiff = stagedDiff + unstagedDiff;
402
+ const diffChunks = combinedDiff.split(/(?=^diff --git )/m).filter((chunk) => chunk.trim());
403
+ // Track files we've already processed (avoid duplicates if file has both staged and unstaged)
404
+ const processedFiles = new Set();
405
+ for (const chunk of diffChunks) {
406
+ const match = chunk.match(/^diff --git a\/.+ b\/(.+)$/m);
407
+ if (!match)
408
+ continue;
409
+ const filepath = match[1];
410
+ if (processedFiles.has(filepath))
411
+ continue;
412
+ processedFiles.add(filepath);
413
+ const lines = parseDiffWithLineNumbers(chunk);
414
+ const fileStats = uncommittedFiles.get(filepath) || { additions: 0, deletions: 0 };
415
+ const fileStatus = statusMap.get(filepath) || 'modified';
416
+ uncommittedFileDiffs.push({
417
+ path: filepath,
418
+ status: fileStatus,
419
+ additions: fileStats.additions,
420
+ deletions: fileStats.deletions,
421
+ diff: { raw: chunk, lines },
422
+ isUncommitted: true,
423
+ });
424
+ }
425
+ // Merge: keep committed files, add/replace with uncommitted
426
+ const committedFilePaths = new Set(committedDiff.files.map((f) => f.path));
427
+ const mergedFiles = [];
428
+ // Add committed files first
429
+ for (const file of committedDiff.files) {
430
+ const uncommittedFile = uncommittedFileDiffs.find((f) => f.path === file.path);
431
+ if (uncommittedFile) {
432
+ // If file has both committed and uncommitted changes, combine them
433
+ // For simplicity, we'll show committed + uncommitted as separate entries
434
+ // with the uncommitted one marked
435
+ mergedFiles.push(file);
436
+ mergedFiles.push(uncommittedFile);
437
+ }
438
+ else {
439
+ mergedFiles.push(file);
440
+ }
441
+ }
442
+ // Add uncommitted-only files (not in committed diff)
443
+ for (const file of uncommittedFileDiffs) {
444
+ if (!committedFilePaths.has(file.path)) {
445
+ mergedFiles.push(file);
446
+ }
447
+ }
448
+ // Calculate new totals including uncommitted
449
+ let totalAdditions = 0;
450
+ let totalDeletions = 0;
451
+ const seenPaths = new Set();
452
+ for (const file of mergedFiles) {
453
+ // Count unique file paths for stats
454
+ if (!seenPaths.has(file.path)) {
455
+ seenPaths.add(file.path);
456
+ }
457
+ totalAdditions += file.additions;
458
+ totalDeletions += file.deletions;
459
+ }
460
+ return {
461
+ baseBranch: committedDiff.baseBranch,
462
+ stats: {
463
+ filesChanged: seenPaths.size,
464
+ additions: totalAdditions,
465
+ deletions: totalDeletions,
466
+ },
467
+ files: mergedFiles,
468
+ commits: committedDiff.commits,
469
+ uncommittedCount: committedDiff.uncommittedCount,
470
+ };
471
+ }
@@ -0,0 +1,30 @@
1
+ import { simpleGit } from 'simple-git';
2
+ /**
3
+ * Check which files from a list are ignored by git.
4
+ * Uses `git check-ignore` to determine ignored files.
5
+ */
6
+ export async function getIgnoredFiles(repoPath, files) {
7
+ if (files.length === 0)
8
+ return new Set();
9
+ const git = simpleGit(repoPath);
10
+ const ignoredFiles = new Set();
11
+ const batchSize = 100;
12
+ for (let i = 0; i < files.length; i += batchSize) {
13
+ const batch = files.slice(i, i + batchSize);
14
+ try {
15
+ const result = await git.raw(['check-ignore', ...batch]);
16
+ const ignored = result
17
+ .trim()
18
+ .split('\n')
19
+ .filter((f) => f.length > 0);
20
+ for (const f of ignored) {
21
+ ignoredFiles.add(f);
22
+ }
23
+ }
24
+ catch {
25
+ // check-ignore exits with code 1 if no files are ignored, which throws
26
+ // Just continue to next batch
27
+ }
28
+ }
29
+ return ignoredFiles;
30
+ }