Skip to content

Commit a5b0077

Browse files
committed
Adds support for including untracked files in git diff status and changed files count queries for working tree comparisons
Closes #5158
1 parent 2c5e420 commit a5b0077

6 files changed

Lines changed: 428 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
1212

1313
### Fixed
1414

15+
- Fixes an issue where untracked files were missing from the _Compare Working Tree with…_ file list unless manually staged with `git add -N` first ([#5158](https://github.com/gitkraken/vscode-gitlens/issues/5158))
1516
- Fixes an issue where newlines in commit messages were not rendered in hovers and tooltips, collapsing multi-line commit bodies onto a single line — regression in v17.12.0 ([#5157](https://github.com/gitkraken/vscode-gitlens/issues/5157))
1617
- Fixes an issue where the status bar blame does not appear after updating to v17.12.0+ — regression in v17.12.0 ([#5160](https://github.com/gitkraken/vscode-gitlens/issues/5160))
1718
- Fixes an issue where commit tooltips and hovers can render with corrupted tokens (literal `__` around the author, missing mailto links, missing italic wrappers on dates, stale token suffixes) when an async formatter call is interleaved by a concurrent formatter call — async template resolution now uses a fresh formatter instance so in-flight token resolution can't be poisoned by a reset from another call

packages/git-cli/src/providers/__tests__/integration/diff.test.ts

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)