diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 61f99ea5..9ca5e5ff 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -93,11 +93,15 @@ struct GitClient { let data = Data(trimmed.utf8) let entries = try JSONDecoder().decode([GitWtWorktreeEntry].self, from: data) .filter { !$0.isBare } - let worktreeEntries = entries.enumerated().map { index, entry in + var seenWorktreeIDs = Set() + let worktreeEntries: [WorktreeSortEntry] = entries.enumerated().compactMap { index, entry -> WorktreeSortEntry? in let worktreeURL = URL(fileURLWithPath: entry.path).standardizedFileURL let name = entry.branch.isEmpty ? worktreeURL.lastPathComponent : entry.branch let detail = Self.relativePath(from: repositoryRootURL, to: worktreeURL) let id = worktreeURL.path(percentEncoded: false) + guard seenWorktreeIDs.insert(id).inserted else { + return nil + } let resourceValues = try? worktreeURL.resourceValues(forKeys: [ .creationDateKey, .contentModificationDateKey, ]) diff --git a/supacode/Clients/Repositories/RepositoryPersistenceClient.swift b/supacode/Clients/Repositories/RepositoryPersistenceClient.swift index 410e347c..6bf37c0c 100644 --- a/supacode/Clients/Repositories/RepositoryPersistenceClient.swift +++ b/supacode/Clients/Repositories/RepositoryPersistenceClient.swift @@ -310,7 +310,7 @@ extension RepositorySnapshotCachePayload { rootURL: rootURL, name: repositoryName.isEmpty ? Repository.name(for: rootURL) : repositoryName, kind: kind, - worktrees: IdentifiedArray(uniqueElements: restoredWorktrees) + worktrees: IdentifiedArray(restoredWorktrees, uniquingIDsWith: { current, _ in current }) ) } } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 3e81549c..74c268c5 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -1439,7 +1439,7 @@ struct RepositoriesFeature { rootURL: rootURL, name: Repository.name(for: rootURL), kind: .git, - worktrees: IdentifiedArray(uniqueElements: worktrees) + worktrees: IdentifiedArray(worktrees, uniquingIDsWith: { current, _ in current }) ), errorMessage: nil ) diff --git a/supacodeTests/GitClientWorktreeDiscoveryTests.swift b/supacodeTests/GitClientWorktreeDiscoveryTests.swift index db905ee2..a7202de5 100644 --- a/supacodeTests/GitClientWorktreeDiscoveryTests.swift +++ b/supacodeTests/GitClientWorktreeDiscoveryTests.swift @@ -140,6 +140,31 @@ struct GitClientWorktreeDiscoveryTests { #expect(recorder.loginInvocations().isEmpty) } + @Test func worktreesDeduplicateStandardizedPaths() async throws { + let output = """ + [ + {"branch":"main","path":"/tmp/repo","head":"abc","is_bare":false}, + {"branch":"feature","path":"/tmp/repo/.worktrees/feature","head":"def","is_bare":false}, + {"branch":"feature","path":"/tmp/repo/.worktrees/./feature","head":"def","is_bare":false} + ] + """ + let shell = ShellClient( + run: { _, _, _ in + ShellOutput(stdout: output, stderr: "", exitCode: 0) + }, + runLoginImpl: { _, _, _, _ in + Issue.record("worktrees should not use runLogin when direct execution succeeds") + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + ) + let client = GitClient(shell: shell) + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + + let worktrees = try await client.worktrees(for: repoRoot) + + #expect(worktrees.map(\.id) == ["/tmp/repo", "/tmp/repo/.worktrees/feature"]) + } + @Test func repoRootFallsBackToLoginShellWhenDirectExecutionCannotResolveGit() async throws { let recorder = GitWorktreeDiscoveryRecorder() let shell = ShellClient( diff --git a/supacodeTests/RepositoryPersistenceClientTests.swift b/supacodeTests/RepositoryPersistenceClientTests.swift index 030fd0e1..713740ef 100644 --- a/supacodeTests/RepositoryPersistenceClientTests.swift +++ b/supacodeTests/RepositoryPersistenceClientTests.swift @@ -167,6 +167,44 @@ struct RepositoryPersistenceClientTests { #expect(restored == [repository]) } + @Test func repositorySnapshotPayloadDeduplicatesRestoredWorktreeIDs() throws { + let payloadData = Data( + """ + { + "version": 2, + "repositories": [ + { + "rootPath": "/tmp/repo", + "name": "repo", + "kind": "git", + "worktrees": [ + { + "name": "feature", + "detail": ".worktrees/feature", + "workingDirectoryPath": "/tmp/repo/.worktrees/feature", + "createdAt": null + }, + { + "name": "duplicate", + "detail": ".worktrees/feature", + "workingDirectoryPath": "/tmp/repo/.worktrees/./feature", + "createdAt": null + } + ] + } + ] + } + """.utf8 + ) + let payload = try JSONDecoder().decode(RepositorySnapshotCachePayload.self, from: payloadData) + + let restored = payload.restoreRepositories { path in + path == "/tmp/repo" || path == "/tmp/repo/.worktrees/feature" + } + + #expect(restored?.first?.worktrees.map(\.name) == ["feature"]) + } + @Test func repositorySnapshotPayloadRejectsMissingWorktreePath() { let repoRoot = "/tmp/repo" let worktree = Worktree(