Skip to content

Commit 3420fee

Browse files
committed
fix: correctly showing Updated Files in repos without any commits
1 parent f08823b commit 3420fee

3 files changed

Lines changed: 166 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- added drag and drop support for tasks and subtasks
1212
- avoid showing password dialog on start up in some cases
1313
- added getOpenProjects to Extension Context API
14+
- correctly showing Updated Files in repos without any commits
1415

1516
## [0.75.0]
1617

src/main/worktrees/__tests__/worktree-manager.updated-files.test.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ vi.mock('fs/promises', () => ({
3939
}));
4040

4141
vi.mock('istextorbinary', () => ({
42-
isBinary: () => false,
42+
isBinary: vi.fn(() => false),
4343
}));
4444

4545
const fs = await import('fs/promises');
@@ -168,3 +168,89 @@ describe('WorktreeManager - getUpdatedFiles symlink filtering', () => {
168168
expect(paths).not.toContain('resources/linux');
169169
});
170170
});
171+
172+
describe('WorktreeManager - getUpdatedFiles no HEAD (no commits)', () => {
173+
let worktreeManager: WorktreeManager;
174+
const testPath = '/test/worktree';
175+
176+
beforeEach(() => {
177+
vi.clearAllMocks();
178+
worktreeManager = new WorktreeManager();
179+
});
180+
181+
it('should return staged files when there are no commits (non-worktree mode)', async () => {
182+
const diffContent =
183+
'diff --git a/src/main.ts b/src/main.ts\nnew file mode 100644\n--- /dev/null\n+++ b/src/main.ts\n@@ -0,0 +1,3 @@\n+line1\n+line2\n+line3';
184+
185+
useMockSeq(createMockSeq([new Error('HEAD not found'), { stdout: '3\t0\tsrc/main.ts\0', stderr: '' }, { stdout: diffContent, stderr: '' }]));
186+
(lstatSync as Mock).mockReturnValue({ isSymbolicLink: () => false });
187+
(fs.default.access as Mock).mockResolvedValue(undefined);
188+
(fs.default.readFile as Mock).mockResolvedValue(Buffer.from('hello'));
189+
190+
const result = await worktreeManager.getUpdatedFiles(testPath);
191+
192+
expect(result).toHaveLength(1);
193+
expect(result[0].path).toBe('src/main.ts');
194+
expect(result[0].additions).toBe(3);
195+
expect(result[0].deletions).toBe(0);
196+
expect(result[0].diff).toBe(diffContent);
197+
});
198+
199+
it('should filter symlink paths in no-HEAD mode', async () => {
200+
useMockSeq(
201+
createMockSeq([
202+
new Error('HEAD not found'),
203+
{ stdout: '3\t0\tsrc/main.ts\0-\t-\tresources/linux\0', stderr: '' },
204+
{ stdout: 'diff content', stderr: '' },
205+
]),
206+
);
207+
(lstatSync as Mock).mockImplementation((p: string) => ({
208+
isSymbolicLink: () => p.includes('resources/linux'),
209+
}));
210+
(fs.default.access as Mock).mockResolvedValue(undefined);
211+
(fs.default.readFile as Mock).mockResolvedValue(Buffer.from('hello'));
212+
213+
const result = await worktreeManager.getUpdatedFiles(testPath);
214+
215+
expect(result).toHaveLength(1);
216+
expect(result[0].path).toBe('src/main.ts');
217+
});
218+
219+
it('should handle binary files in no-HEAD mode', async () => {
220+
// @ts-expect-error istextorbinary library does not provide TypeScript definitions
221+
const { isBinary } = await import('istextorbinary');
222+
(isBinary as Mock).mockReturnValue(true);
223+
224+
useMockSeq(createMockSeq([new Error('HEAD not found'), { stdout: '-\t-\tassets/image.png\0', stderr: '' }]));
225+
(lstatSync as Mock).mockReturnValue({ isSymbolicLink: () => false });
226+
(fs.default.access as Mock).mockResolvedValue(undefined);
227+
(fs.default.readFile as Mock).mockResolvedValue(Buffer.from([0x89, 0x50, 0x4e, 0x47]));
228+
229+
const result = await worktreeManager.getUpdatedFiles(testPath);
230+
231+
expect(result).toHaveLength(1);
232+
expect(result[0].path).toBe('assets/image.png');
233+
expect(result[0].diff).toBe('');
234+
235+
(isBinary as Mock).mockReturnValue(false);
236+
});
237+
238+
it('should return staged files in worktree mode when no HEAD exists', async () => {
239+
const mainBranch = 'main';
240+
const diffContent = 'diff --git a/src/app.ts b/src/app.ts\nnew file mode 100644\n--- /dev/null\n+++ b/src/app.ts\n@@ -0,0 +1,2 @@\n+hello\n+world';
241+
242+
useMockSeq(
243+
createMockSeq([new Error('log failed'), new Error('HEAD not found'), { stdout: '2\t0\tsrc/app.ts\0', stderr: '' }, { stdout: diffContent, stderr: '' }]),
244+
);
245+
(lstatSync as Mock).mockReturnValue({ isSymbolicLink: () => false });
246+
(fs.default.access as Mock).mockResolvedValue(undefined);
247+
(fs.default.readFile as Mock).mockResolvedValue(Buffer.from('hello'));
248+
249+
const result = await worktreeManager.getUpdatedFiles(testPath, 'worktree', mainBranch);
250+
251+
expect(result).toHaveLength(1);
252+
expect(result[0].path).toBe('src/app.ts');
253+
expect(result[0].additions).toBe(2);
254+
expect(result[0].diff).toBe(diffContent);
255+
});
256+
});

src/main/worktrees/worktree-manager.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1813,7 +1813,7 @@ export class WorktreeManager {
18131813
// Check if HEAD exists (no commits yet in worktree)
18141814
await execWithShellPath('git rev-parse HEAD', { cwd: worktreePath });
18151815
} catch {
1816-
return files;
1816+
return files.concat(await this.getNoHeadUpdatedFiles(worktreePath));
18171817
}
18181818

18191819
try {
@@ -1956,7 +1956,7 @@ export class WorktreeManager {
19561956
try {
19571957
await execWithShellPath('git rev-parse HEAD', { cwd: worktreePath });
19581958
} catch {
1959-
return [];
1959+
return await this.getNoHeadUpdatedFiles(worktreePath);
19601960
}
19611961

19621962
const { stdout } = await execWithShellPath('git diff --numstat -z HEAD', {
@@ -2019,6 +2019,82 @@ export class WorktreeManager {
20192019
return files;
20202020
}
20212021

2022+
/**
2023+
* No-HEAD mode: repo has no commits yet. Uses the well-known empty tree hash
2024+
* as the diff base, which is equivalent to `git diff HEAD` against an empty repo.
2025+
* Shows staged files and unstaged modifications to staged files (same as `git diff HEAD`
2026+
* would show if HEAD pointed to an empty commit). Untracked files are not shown,
2027+
* consistent with the existing HEAD-based behavior.
2028+
*/
2029+
private async getNoHeadUpdatedFiles(worktreePath: string): Promise<UpdatedFile[]> {
2030+
const EMPTY_TREE_HASH = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
2031+
const files: UpdatedFile[] = [];
2032+
2033+
try {
2034+
const { stdout } = await execWithShellPath(`git diff --numstat -z ${EMPTY_TREE_HASH}`, {
2035+
cwd: worktreePath,
2036+
});
2037+
2038+
const entries = stdout.split('\0').filter((entry) => entry.trim() !== '');
2039+
2040+
for (const entry of entries) {
2041+
const parts = entry.split('\t');
2042+
if (parts.length < 3) {
2043+
continue;
2044+
}
2045+
2046+
const additions = parts[0] === '-' ? 0 : parseInt(parts[0], 10);
2047+
const deletions = parts[1] === '-' ? 0 : parseInt(parts[1], 10);
2048+
const filePath = parts.slice(2).join('\t');
2049+
if (!filePath) {
2050+
continue;
2051+
}
2052+
2053+
if (this.isSymlinkPath(worktreePath, filePath)) {
2054+
continue;
2055+
}
2056+
2057+
const absoluteFilePath = join(worktreePath, filePath);
2058+
2059+
let diff = '';
2060+
try {
2061+
const fileExists = await fs
2062+
.access(absoluteFilePath)
2063+
.then(() => true)
2064+
.catch(() => false);
2065+
2066+
if (fileExists) {
2067+
const fileContentBuffer = await fs.readFile(absoluteFilePath);
2068+
if (isBinary(filePath, fileContentBuffer)) {
2069+
files.push({ path: filePath, additions, deletions, diff });
2070+
continue;
2071+
}
2072+
}
2073+
2074+
const escapedPath = filePath.replace(/"/g, '\\"');
2075+
const { stdout: diffOutput } = await execWithShellPath(`git diff --unified=3 ${EMPTY_TREE_HASH} -- "${escapedPath}"`, {
2076+
cwd: worktreePath,
2077+
maxBuffer: 10 * 1024 * 1024,
2078+
});
2079+
diff = diffOutput;
2080+
} catch (diffError) {
2081+
logger.warn(`Failed to get diff for file ${filePath}:`, {
2082+
error: diffError instanceof Error ? diffError.message : String(diffError),
2083+
});
2084+
diff = '';
2085+
}
2086+
2087+
files.push({ path: filePath, additions, deletions, diff });
2088+
}
2089+
} catch (error) {
2090+
logger.warn('Failed to get updated files for repo with no commits:', {
2091+
error: error instanceof Error ? error.message : String(error),
2092+
});
2093+
}
2094+
2095+
return files;
2096+
}
2097+
20222098
/**
20232099
* Parse the output of `git diff-tree -p -r` into a map of filePath -> diff text.
20242100
* Splits on "diff --git" boundaries to isolate per-file patches.

0 commit comments

Comments
 (0)