@@ -108,3 +108,310 @@ suite('DiffSubProvider.getParsedDiff', () => {
108108 }
109109 } ) ;
110110} ) ;
111+
112+ suite ( 'DiffSubProvider.includeUntracked' , ( ) => {
113+ test ( 'getDiffStatus(HEAD, includeUntracked) includes tracked + staged + untracked files' , async ( ) => {
114+ const r = createTestRepo ( ) ;
115+ try {
116+ addCommit ( r . path , 'tracked.txt' , 'original\n' , 'Add tracked.txt' ) ;
117+
118+ // Modify the tracked file (working tree change)
119+ writeFileSync ( join ( r . path , 'tracked.txt' ) , 'modified\n' ) ;
120+ // Add a staged new file
121+ writeFileSync ( join ( r . path , 'staged.txt' ) , 'staged content\n' ) ;
122+ execFileSync ( 'git' , [ 'add' , 'staged.txt' ] , { cwd : r . path , stdio : 'pipe' } ) ;
123+ // Add an untracked file
124+ writeFileSync ( join ( r . path , 'untracked.txt' ) , 'untracked content\n' ) ;
125+
126+ const filesWithout = await r . provider . diff . getDiffStatus ( r . path , 'HEAD' ) ;
127+ assert . ok ( filesWithout , 'Without includeUntracked, should still return tracked + staged' ) ;
128+ assert . ok ( filesWithout . find ( f => f . path === 'tracked.txt' ) ) ;
129+ assert . ok ( filesWithout . find ( f => f . path === 'staged.txt' ) ) ;
130+ assert . strictEqual (
131+ filesWithout . find ( f => f . path === 'untracked.txt' ) ,
132+ undefined ,
133+ 'Without includeUntracked, untracked files should be absent' ,
134+ ) ;
135+
136+ const filesWith = await r . provider . diff . getDiffStatus ( r . path , 'HEAD' , undefined , {
137+ includeUntracked : true ,
138+ } ) ;
139+ assert . ok ( filesWith , 'Files should not be undefined' ) ;
140+ assert . ok (
141+ filesWith . find ( f => f . path === 'tracked.txt' ) ,
142+ 'Should include modified tracked file' ,
143+ ) ;
144+ assert . ok (
145+ filesWith . find ( f => f . path === 'staged.txt' ) ,
146+ 'Should include staged file' ,
147+ ) ;
148+ assert . ok (
149+ filesWith . find ( f => f . path === 'untracked.txt' ) ,
150+ 'Should include untracked file' ,
151+ ) ;
152+ } finally {
153+ r . cleanup ( ) ;
154+ }
155+ } ) ;
156+
157+ test ( 'getDiffStatus(HEAD, includeUntracked) returns untracked only in otherwise-clean repo' , async ( ) => {
158+ const r = createTestRepo ( ) ;
159+ try {
160+ addCommit ( r . path , 'a.txt' , 'a\n' , 'Add a.txt' ) ;
161+ writeFileSync ( join ( r . path , 'new.txt' ) , 'new\n' ) ;
162+
163+ const files = await r . provider . diff . getDiffStatus ( r . path , 'HEAD' , undefined , { includeUntracked : true } ) ;
164+ assert . ok ( files , 'Expected at least the untracked file' ) ;
165+ assert . strictEqual ( files . length , 1 ) ;
166+ assert . strictEqual ( files [ 0 ] . path , 'new.txt' ) ;
167+ } finally {
168+ r . cleanup ( ) ;
169+ }
170+ } ) ;
171+
172+ test ( 'getDiffStatus with two refs ignores includeUntracked' , async ( ) => {
173+ const r = createTestRepo ( ) ;
174+ try {
175+ addCommit ( r . path , 'a.txt' , 'a\n' , 'Add a.txt' ) ;
176+ addCommit ( r . path , 'b.txt' , 'b\n' , 'Add b.txt' ) ;
177+ writeFileSync ( join ( r . path , 'untracked.txt' ) , 'ignored\n' ) ;
178+
179+ // Two-ref form (ref2 != null) is not "working tree vs ref" — untracked should be skipped
180+ const files = await r . provider . diff . getDiffStatus ( r . path , 'HEAD' , 'HEAD~1' , {
181+ includeUntracked : true ,
182+ } ) ;
183+ assert . ok ( files , 'Files should not be undefined' ) ;
184+ assert . strictEqual (
185+ files . find ( f => f . path === 'untracked.txt' ) ,
186+ undefined ,
187+ 'Untracked files should not be merged into a two-ref diff' ,
188+ ) ;
189+ } finally {
190+ r . cleanup ( ) ;
191+ }
192+ } ) ;
193+
194+ test ( 'getChangedFilesCount(HEAD, includeUntracked) adds untracked count' , async ( ) => {
195+ const r = createTestRepo ( ) ;
196+ try {
197+ addCommit ( r . path , 'tracked.txt' , 'original\n' , 'Add tracked.txt' ) ;
198+
199+ writeFileSync ( join ( r . path , 'tracked.txt' ) , 'modified\n' ) ;
200+ writeFileSync ( join ( r . path , 'untracked.txt' ) , 'new\n' ) ;
201+
202+ const without = await r . provider . diff . getChangedFilesCount ( r . path , 'HEAD' ) ;
203+ assert . ok ( without , 'Without includeUntracked, should still report tracked changes' ) ;
204+ assert . strictEqual ( without . files , 1 ) ;
205+
206+ const withUntracked = await r . provider . diff . getChangedFilesCount ( r . path , 'HEAD' , undefined , {
207+ includeUntracked : true ,
208+ } ) ;
209+ assert . ok ( withUntracked , 'Stat should not be undefined' ) ;
210+ assert . strictEqual ( withUntracked . files , 2 , 'Expected tracked + untracked count' ) ;
211+ } finally {
212+ r . cleanup ( ) ;
213+ }
214+ } ) ;
215+
216+ test ( 'getChangedFilesCount("", <non-HEAD ref>) returns working-tree vs ref, not ref^..ref' , async ( ) => {
217+ const r = createTestRepo ( ) ;
218+ try {
219+ // main (HEAD): one commit with a.txt only.
220+ // feature: adds b.txt in a second commit.
221+ // Working tree stays on main (clean) — so b.txt does NOT exist on disk but DOES on feature.
222+ addCommit ( r . path , 'a.txt' , 'v1\n' , 'Add a.txt' ) ;
223+ const mainRef = execFileSync ( 'git' , [ 'rev-parse' , 'HEAD' ] , { cwd : r . path } ) . toString ( ) . trim ( ) ;
224+ execFileSync ( 'git' , [ 'checkout' , '-b' , 'feature' ] , { cwd : r . path , stdio : 'pipe' } ) ;
225+ addCommit ( r . path , 'b.txt' , 'new line\n' , 'Add b.txt on feature' ) ;
226+ execFileSync ( 'git' , [ 'checkout' , mainRef ] , { cwd : r . path , stdio : 'pipe' } ) ;
227+
228+ // Working tree vs feature: b.txt exists on feature but not in working tree
229+ // → diff reports b.txt as deleted (1 file, 1 deletion, 0 additions).
230+ const workingTreeVsFeature = await r . provider . diff . getChangedFilesCount ( r . path , '' , 'feature' ) ;
231+ assert . ok ( workingTreeVsFeature , 'Expected a shortstat for working tree vs feature' ) ;
232+ assert . strictEqual ( workingTreeVsFeature . files , 1 ) ;
233+ assert . strictEqual ( workingTreeVsFeature . additions , 0 ) ;
234+ assert . strictEqual ( workingTreeVsFeature . deletions , 1 ) ;
235+
236+ // feature^..feature: b.txt was added (1 file, 1 addition, 0 deletions) — opposite direction.
237+ const featureParentToFeature = await r . provider . diff . getChangedFilesCount ( r . path , 'feature' , undefined ) ;
238+ assert . ok ( featureParentToFeature ) ;
239+ assert . strictEqual ( featureParentToFeature . files , 1 ) ;
240+ assert . strictEqual ( featureParentToFeature . additions , 1 ) ;
241+ assert . strictEqual ( featureParentToFeature . deletions , 0 ) ;
242+
243+ // Hard guardrail: the two shapes MUST differ.
244+ assert . notDeepStrictEqual (
245+ workingTreeVsFeature ,
246+ featureParentToFeature ,
247+ 'working-tree-vs-ref and ref^..ref must produce different stats' ,
248+ ) ;
249+ } finally {
250+ r . cleanup ( ) ;
251+ }
252+ } ) ;
253+
254+ test ( 'getChangedFilesCount("", <non-HEAD ref>, includeUntracked) adds untracked count' , async ( ) => {
255+ const r = createTestRepo ( ) ;
256+ try {
257+ addCommit ( r . path , 'a.txt' , 'v1\n' , 'Add a.txt' ) ;
258+ const mainRef = execFileSync ( 'git' , [ 'rev-parse' , 'HEAD' ] , { cwd : r . path } ) . toString ( ) . trim ( ) ;
259+ execFileSync ( 'git' , [ 'checkout' , '-b' , 'feature' ] , { cwd : r . path , stdio : 'pipe' } ) ;
260+ addCommit ( r . path , 'a.txt' , 'v2\n' , 'Modify a.txt on feature' ) ;
261+ execFileSync ( 'git' , [ 'checkout' , mainRef ] , { cwd : r . path , stdio : 'pipe' } ) ;
262+
263+ writeFileSync ( join ( r . path , 'untracked.txt' ) , 'new\n' ) ;
264+
265+ const without = await r . provider . diff . getChangedFilesCount ( r . path , '' , 'feature' ) ;
266+ assert . ok ( without ) ;
267+ assert . strictEqual ( without . files , 1 , 'Without includeUntracked, only the tracked diff file is counted' ) ;
268+
269+ const withUntracked = await r . provider . diff . getChangedFilesCount ( r . path , '' , 'feature' , {
270+ includeUntracked : true ,
271+ } ) ;
272+ assert . ok ( withUntracked ) ;
273+ assert . strictEqual (
274+ withUntracked . files ,
275+ 2 ,
276+ 'With includeUntracked on a non-HEAD working-tree comparison, untracked files must contribute to the count' ,
277+ ) ;
278+ } finally {
279+ r . cleanup ( ) ;
280+ }
281+ } ) ;
282+
283+ test ( 'getDiffStatus("", <non-HEAD ref>, includeUntracked) merges untracked' , async ( ) => {
284+ const r = createTestRepo ( ) ;
285+ try {
286+ addCommit ( r . path , 'a.txt' , 'v1\n' , 'Add a.txt' ) ;
287+ const mainRef = execFileSync ( 'git' , [ 'rev-parse' , 'HEAD' ] , { cwd : r . path } ) . toString ( ) . trim ( ) ;
288+ execFileSync ( 'git' , [ 'checkout' , '-b' , 'feature' ] , { cwd : r . path , stdio : 'pipe' } ) ;
289+ addCommit ( r . path , 'a.txt' , 'v2\n' , 'Modify a.txt on feature' ) ;
290+ execFileSync ( 'git' , [ 'checkout' , mainRef ] , { cwd : r . path , stdio : 'pipe' } ) ;
291+
292+ writeFileSync ( join ( r . path , 'untracked.txt' ) , 'new\n' ) ;
293+
294+ const files = await r . provider . diff . getDiffStatus ( r . path , 'feature' , undefined , {
295+ includeUntracked : true ,
296+ } ) ;
297+ assert . ok ( files ) ;
298+ assert . ok (
299+ files . find ( f => f . path === 'a.txt' ) ,
300+ 'Should include the tracked diff file' ,
301+ ) ;
302+ assert . ok (
303+ files . find ( f => f . path === 'untracked.txt' ) ,
304+ 'Should include the untracked file when comparing working tree vs feature' ,
305+ ) ;
306+ } finally {
307+ r . cleanup ( ) ;
308+ }
309+ } ) ;
310+
311+ test ( 'getDiffStatus with options.path ignores non-matching untracked files' , async ( ) => {
312+ const r = createTestRepo ( ) ;
313+ try {
314+ addCommit ( r . path , 'a.txt' , 'v1\n' , 'Add a.txt' ) ;
315+ writeFileSync ( join ( r . path , 'a.txt' ) , 'v2\n' ) ;
316+ writeFileSync ( join ( r . path , 'other-untracked.txt' ) , 'new\n' ) ;
317+
318+ const files = await r . provider . diff . getDiffStatus ( r . path , 'HEAD' , undefined , {
319+ includeUntracked : true ,
320+ path : 'a.txt' ,
321+ } ) ;
322+ assert . ok ( files ) ;
323+ assert . strictEqual (
324+ files . find ( f => f . path === 'other-untracked.txt' ) ,
325+ undefined ,
326+ 'Untracked files outside the path filter must not be merged' ,
327+ ) ;
328+ } finally {
329+ r . cleanup ( ) ;
330+ }
331+ } ) ;
332+
333+ test ( 'getDiffStatus with filters that exclude additions omits untracked' , async ( ) => {
334+ const r = createTestRepo ( ) ;
335+ try {
336+ addCommit ( r . path , 'a.txt' , 'v1\n' , 'Add a.txt' ) ;
337+ writeFileSync ( join ( r . path , 'a.txt' ) , 'v2\n' ) ;
338+ writeFileSync ( join ( r . path , 'untracked.txt' ) , 'new\n' ) ;
339+
340+ const files = await r . provider . diff . getDiffStatus ( r . path , 'HEAD' , undefined , {
341+ includeUntracked : true ,
342+ filters : [ 'M' ] ,
343+ } ) ;
344+ assert . ok ( files ) ;
345+ assert . strictEqual (
346+ files . find ( f => f . path === 'untracked.txt' ) ,
347+ undefined ,
348+ 'Untracked files are "added" — a filter restricted to M must omit them' ,
349+ ) ;
350+
351+ // But filters including 'A' should still merge untracked
352+ const withA = await r . provider . diff . getDiffStatus ( r . path , 'HEAD' , undefined , {
353+ includeUntracked : true ,
354+ filters : [ 'M' , 'A' ] ,
355+ } ) ;
356+ assert . ok ( withA ) ;
357+ assert . ok (
358+ withA . find ( f => f . path === 'untracked.txt' ) ,
359+ 'Filters containing A should still include untracked files' ,
360+ ) ;
361+ } finally {
362+ r . cleanup ( ) ;
363+ }
364+ } ) ;
365+
366+ test ( 'getChangedFilesCount("", <non-HEAD ref>, includeUntracked) adds untracked count' , async ( ) => {
367+ const r = createTestRepo ( ) ;
368+ try {
369+ addCommit ( r . path , 'a.txt' , 'v1\n' , 'Add a.txt' ) ;
370+ const mainRef = execFileSync ( 'git' , [ 'rev-parse' , 'HEAD' ] , { cwd : r . path } ) . toString ( ) . trim ( ) ;
371+ execFileSync ( 'git' , [ 'checkout' , '-b' , 'feature' ] , { cwd : r . path , stdio : 'pipe' } ) ;
372+ addCommit ( r . path , 'b.txt' , 'new line\n' , 'Add b.txt on feature' ) ;
373+ execFileSync ( 'git' , [ 'checkout' , mainRef ] , { cwd : r . path , stdio : 'pipe' } ) ;
374+
375+ // Working tree on main is clean; feature has b.txt. Add an untracked file on disk.
376+ writeFileSync ( join ( r . path , 'untracked.txt' ) , 'new\n' ) ;
377+
378+ const without = await r . provider . diff . getChangedFilesCount ( r . path , '' , 'feature' ) ;
379+ assert . ok ( without , 'Without includeUntracked, stats should still reflect working-tree vs feature' ) ;
380+ assert . strictEqual ( without . files , 1 , 'Expected b.txt only (tracked diff)' ) ;
381+
382+ const withUntracked = await r . provider . diff . getChangedFilesCount ( r . path , '' , 'feature' , {
383+ includeUntracked : true ,
384+ } ) ;
385+ assert . ok ( withUntracked , 'Stat should not be undefined' ) ;
386+ assert . strictEqual (
387+ withUntracked . files ,
388+ 2 ,
389+ 'Expected b.txt (tracked) + untracked.txt when includeUntracked is set for non-HEAD ref' ,
390+ ) ;
391+ } finally {
392+ r . cleanup ( ) ;
393+ }
394+ } ) ;
395+
396+ test ( 'getChangedFilesCount with uris ignores includeUntracked' , async ( ) => {
397+ const r = createTestRepo ( ) ;
398+ try {
399+ addCommit ( r . path , 'a.txt' , 'v1\n' , 'Add a.txt' ) ;
400+ writeFileSync ( join ( r . path , 'a.txt' ) , 'v2\n' ) ;
401+ writeFileSync ( join ( r . path , 'untracked.txt' ) , 'new\n' ) ;
402+
403+ const stat = await r . provider . diff . getChangedFilesCount ( r . path , 'HEAD' , undefined , {
404+ includeUntracked : true ,
405+ uris : [ 'a.txt' ] ,
406+ } ) ;
407+ assert . ok ( stat ) ;
408+ assert . strictEqual (
409+ stat . files ,
410+ 1 ,
411+ 'When a pathspec filter is active, untracked files must not be merged into the count' ,
412+ ) ;
413+ } finally {
414+ r . cleanup ( ) ;
415+ }
416+ } ) ;
417+ } ) ;
0 commit comments