diff --git a/src/renderer/src/store/slices/repos-refresh-identity.test.ts b/src/renderer/src/store/slices/repos-refresh-identity.test.ts index ecbb8c893bd..c7aae42101b 100644 --- a/src/renderer/src/store/slices/repos-refresh-identity.test.ts +++ b/src/renderer/src/store/slices/repos-refresh-identity.test.ts @@ -52,10 +52,15 @@ function clone(value: T): T { return structuredClone(value) } -function mockRepos(...rows: readonly Repo[]): void { +function mockRepos(...rows: readonly (Repo | Omit)[]): void { reposList.mockImplementation(async () => rows.map(clone)) } +function omitAddedAt(row: Repo): Omit { + const { addedAt: _addedAt, ...rest } = row + return rest +} + beforeEach(() => { reposList.mockReset() projectsList.mockReset() @@ -236,6 +241,62 @@ describe('repo catalog refresh identity', () => { expect(store.getState().projects).toHaveLength(2) expect(store.getState().projectHostSetups).toHaveLength(2) }) + + it('keeps catalog identity when repo.addedAt is 0', async () => { + mockRepos({ ...repo, addedAt: 0 }) + const store = createTestStore() + await store.getState().fetchRepos() + const projects = store.getState().projects + const setups = store.getState().projectHostSetups + expect(projects).toHaveLength(1) + expect(setups).toHaveLength(1) + expect(projects[0]?.createdAt).toBe(0) + expect(setups[0]?.createdAt).toBe(0) + + await store.getState().fetchRepos() + + expect(store.getState().projects).toBe(projects) + expect(store.getState().projects[0]).toBe(projects[0]) + expect(store.getState().projectHostSetups).toBe(setups) + expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + }) + + it('keeps catalog identity when repo.addedAt is omitted', async () => { + mockRepos(omitAddedAt(repo)) + const store = createTestStore() + await store.getState().fetchRepos() + const projects = store.getState().projects + const setups = store.getState().projectHostSetups + expect(projects).toHaveLength(1) + expect(setups).toHaveLength(1) + expect(projects[0]?.createdAt).toBe(0) + expect(setups[0]?.createdAt).toBe(0) + + await store.getState().fetchRepos() + + expect(store.getState().projects).toBe(projects) + expect(store.getState().projects[0]).toBe(projects[0]) + expect(store.getState().projectHostSetups).toBe(setups) + expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + }) + + it('lets a nested hookSettings change through when repo.addedAt is 0', async () => { + mockRepos({ ...repo, addedAt: 0 }) + const store = createTestStore() + await store.getState().fetchRepos() + const setups = store.getState().projectHostSetups + + mockRepos({ + ...repo, + addedAt: 0, + hookSettings: { mode: 'override', scripts: { setup: '', archive: '' } } + }) + await store.getState().fetchRepos() + + expect(store.getState().projectHostSetups).not.toBe(setups) + expect(store.getState().projectHostSetups[0]).not.toBe(setups[0]) + expect(store.getState().projectHostSetups[0]?.hookSettings?.mode).toBe('override') + }) }) describe('repo filter identity across catalog refreshes', () => { diff --git a/src/shared/project-host-setup-projection.test.ts b/src/shared/project-host-setup-projection.test.ts index 606eebdc73f..c85d27f3e2a 100644 --- a/src/shared/project-host-setup-projection.test.ts +++ b/src/shared/project-host-setup-projection.test.ts @@ -19,6 +19,44 @@ function repo(overrides: Partial & Pick { + it('keeps timestamps stable when addedAt is 0 across different now values', () => { + const target = repo({ id: 'repo-1', path: '/Users/alice/orca', displayName: 'orca', addedAt: 0 }) + const first = projectHostSetupProjectionFromRepos([target], 1_000) + const second = projectHostSetupProjectionFromRepos([target], 9_999) + + expect(first.projects[0]?.createdAt).toBe(0) + expect(first.projects[0]?.updatedAt).toBe(0) + expect(first.setups[0]?.createdAt).toBe(0) + expect(second.projects[0]?.createdAt).toBe(first.projects[0]?.createdAt) + expect(second.projects[0]?.updatedAt).toBe(first.projects[0]?.updatedAt) + expect(second.setups[0]?.createdAt).toBe(first.setups[0]?.createdAt) + expect(second.setups[0]?.updatedAt).toBe(first.setups[0]?.updatedAt) + }) + + it('does not wipe a persisted createdAt when a sibling repo has addedAt 0', () => { + const projection = projectHostSetupProjectionFromRepos([ + repo({ + id: 'local-repo', + path: '/Users/alice/orca', + displayName: 'Orca', + addedAt: 100, + upstream: { owner: 'StablyAI', repo: 'Orca' } + }), + repo({ + id: 'remote-repo', + path: '/home/alice/orca', + displayName: 'orca', + addedAt: 0, + connectionId: 'gpu-vm', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ]) + + expect(projection.projects).toHaveLength(1) + expect(projection.projects[0]?.createdAt).toBe(100) + expect(projection.projects[0]?.updatedAt).toBe(100) + }) + it('projects a legacy local repo into one project and one ready local setup', () => { const projection = projectHostSetupProjectionFromRepos( [repo({ id: 'repo-1', path: '/Users/alice/orca', displayName: 'orca' })], diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index febf7a16018..84d6ce6a000 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -205,9 +205,16 @@ function parseGitHubRemoteUrl(remoteUrl: string | undefined): ProjectProviderIde } } -function createProjectFromRepo(repo: Repo, now: number): Project { +// Why: `addedAt || now` restamps Date.now() when addedAt is 0 / absent / NaN, so every +// projection looks dirty and reconcileCatalogRows never reuses the project or setup. +function catalogTimestampFromAddedAt(addedAt: number): number { + return Number.isFinite(addedAt) ? addedAt : 0 +} + +function createProjectFromRepo(repo: Repo): Project { const identity = getProjectProviderIdentity(repo) const gitRemoteIdentity = getProjectGitRemoteIdentity(repo) + const addedAt = catalogTimestampFromAddedAt(repo.addedAt) return { id: getProjectId(repo), displayName: repo.displayName, @@ -217,8 +224,8 @@ function createProjectFromRepo(repo: Repo, now: number): Project { ...(identity ? { providerIdentity: identity } : {}), ...(gitRemoteIdentity ? { gitRemoteIdentity } : {}), sourceRepoIds: [repo.id], - createdAt: repo.addedAt || now, - updatedAt: repo.addedAt || now + createdAt: addedAt, + updatedAt: addedAt } } @@ -226,17 +233,20 @@ function mergeProjectRepo(project: Project, repo: Repo): Project { const sourceRepoIds = project.sourceRepoIds.includes(repo.id) ? project.sourceRepoIds : [...project.sourceRepoIds, repo.id] + // Why: 0 / absent / NaN means "unknown", not epoch — min() would wipe a persisted createdAt. + const addedAt = + Number.isFinite(repo.addedAt) && repo.addedAt !== 0 ? repo.addedAt : undefined return { ...project, sourceRepoIds, - createdAt: Math.min(project.createdAt, repo.addedAt || project.createdAt), - updatedAt: Math.max(project.updatedAt, repo.addedAt || project.updatedAt) + createdAt: Math.min(project.createdAt, addedAt ?? project.createdAt), + updatedAt: Math.max(project.updatedAt, addedAt ?? project.updatedAt) } } -function createSetupFromRepo(repo: Repo, projectId: string, now: number): ProjectHostSetup { +function createSetupFromRepo(repo: Repo, projectId: string): ProjectHostSetup { const hostId = getRepoExecutionHostId(repo) - const createdAt = repo.addedAt || now + const createdAt = catalogTimestampFromAddedAt(repo.addedAt) const setupMethod = repo.projectHostSetupMethod ?? 'legacy-repo' return { id: repo.id, @@ -261,7 +271,7 @@ function createSetupFromRepo(repo: Repo, projectId: string, now: number): Projec export function projectHostSetupProjectionFromRepos( repos: readonly Repo[], - now = Date.now() + _now?: number ): ProjectHostSetupProjection { const projectById = new Map() const setups: ProjectHostSetup[] = [] @@ -271,8 +281,8 @@ export function projectHostSetupProjectionFromRepos( const existing = projectById.get(projectId) const project = existing ? mergeProjectRepo(existing.project, repo) - : createProjectFromRepo(repo, now) - const setup = createSetupFromRepo(repo, projectId, now) + : createProjectFromRepo(repo) + const setup = createSetupFromRepo(repo, projectId) projectById.set(projectId, { project })