From ec25f3c4b1d91c3ec946e012a06d913e9b4d4f7e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Apr 2026 11:09:21 +0000 Subject: [PATCH] fix(mobile): GitHub API refresh must reconcile files when commit unchanged refreshViaGitHubApi returned early when remote commit SHA matched stored metadata, skipping blob reconciliation. That breaks recovery when local files are missing or metadata.files is stale while still at the same tip. Remove the early return and add a regression test for missing blobs at unchanged commit. Co-authored-by: Danny Peck --- mobile/__tests__/services/gitService.test.ts | 64 ++++++++++++++++++++ mobile/src/services/gitService.ts | 5 -- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/mobile/__tests__/services/gitService.test.ts b/mobile/__tests__/services/gitService.test.ts index c0a6759..07e3fed 100644 --- a/mobile/__tests__/services/gitService.test.ts +++ b/mobile/__tests__/services/gitService.test.ts @@ -703,6 +703,70 @@ describe('GitService', () => { }) ); }); + + it('should download missing blobs even when metadata commit already matches remote tip', async () => { + const localPath = 'file:///mock/documents/vault/repo'; + const sharedCommitSha = 'remote-tip-sha'; + const metadata = { + version: 1, + transport: 'github-api', + repoUrl: 'https://github.com/test/repo', + owner: 'test', + repo: 'repo', + branch: 'main', + commitSha: sharedCommitSha, + treeSha: 'tree-sha-1', + files: { + 'README.md': { sha: 'blob-readme', mode: '100644', type: 'blob' }, + }, + }; + + (AsyncStorage.getItem as jest.Mock).mockResolvedValueOnce( + JSON.stringify({ ['https://github.com/test/repo']: { username: 'token', token: 'ghp_testtoken123' } }) + ); + + (FileSystem.getInfoAsync as jest.Mock).mockImplementation(async (path: string) => { + if (path === 'file:///mock/documents/vault/repo/.synapse/repo.json') return { exists: true, isDirectory: false, size: 0 }; + if (path === 'file:///mock/documents/vault/repo/README.md') return { exists: true, isDirectory: false, size: 0 }; + if (path === 'file:///mock/documents/vault/repo/NEW.md') return { exists: false, isDirectory: false, size: 0 }; + return { exists: false, isDirectory: false, size: 0 }; + }); + + (FileSystem.readAsStringAsync as jest.Mock).mockImplementation(async (path: string, options?: any) => { + if (path === 'file:///mock/documents/vault/repo/.synapse/repo.json') return JSON.stringify(metadata); + return ''; + }); + + mockFetch + .mockResolvedValueOnce({ ok: true, json: async () => ({ commit: { sha: sharedCommitSha } }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ tree: { sha: 'tree-sha-1' } }) }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + tree: [ + { path: 'README.md', type: 'blob', mode: '100644', sha: 'blob-readme' }, + { path: 'NEW.md', type: 'blob', mode: '100644', sha: 'blob-new' }, + ], + }), + }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ content: 'TkVXIEZJTEUK', encoding: 'base64' }) }); + + await GitService.refreshRemote(localPath); + + expect(FileSystem.writeAsStringAsync).toHaveBeenCalledWith( + 'file:///mock/documents/vault/repo/NEW.md', + 'TkVXIEZJTEUK', + { encoding: 'base64' } + ); + const metaWrite = (FileSystem.writeAsStringAsync as jest.Mock).mock.calls.find( + (call: string[]) => call[0] === 'file:///mock/documents/vault/repo/.synapse/repo.json' + ); + expect(metaWrite).toBeDefined(); + const saved = JSON.parse(metaWrite![1] as string); + expect(saved.files['NEW.md']).toEqual( + expect.objectContaining({ sha: 'blob-new', mode: '100644', type: 'blob' }) + ); + }); }); describe('Error Handling', () => { diff --git a/mobile/src/services/gitService.ts b/mobile/src/services/gitService.ts index 48ef0b3..742bce9 100644 --- a/mobile/src/services/gitService.ts +++ b/mobile/src/services/gitService.ts @@ -877,11 +877,6 @@ export class GitService { const remoteState = await this.fetchGitHubApiRemoteState(metadata); - // Nothing changed since last sync — skip all file work - if (remoteState.commitSha === metadata.commitSha) { - return; - } - const remoteBlobs = remoteState.tree.filter((entry) => entry.type === 'blob'); const nextFiles: RepoMetadataFile['files'] = { ...metadata.files };