diff --git a/docs/components/repositories-and-worktrees.md b/docs/components/repositories-and-worktrees.md index 8046cf07..66649a38 100644 --- a/docs/components/repositories-and-worktrees.md +++ b/docs/components/repositories-and-worktrees.md @@ -41,7 +41,8 @@ root; "not a git repository" → plain folder). Pick one or more directories. Prowl detects git vs plain, de-duplicates, and persists the list. Paths that don't exist or can't be read are reported in an alert after the load. Repositories added after the initial app load are selected -automatically. +automatically. If a repository was added through a symbolic link, Prowl resolves +and stores its actual git root so branches and worktrees continue to load. ## Creating a worktree diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift index dca65857..10683f31 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift @@ -73,18 +73,10 @@ extension RepositoriesFeature { do { let repoRoot = try await gitClient.repoRoot(URL(fileURLWithPath: normalizedPath)) let normalizedRepoRoot = repoRoot.standardizedFileURL.path(percentEncoded: false) - switch entry.kind { - case .plain: - if normalizedRepoRoot == normalizedPath { - return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .git)) - } - return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .plain)) - case .git: - if normalizedRepoRoot == normalizedPath { - return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .git)) - } - return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .plain)) + if Self.pathsReferToSameFileSystemLocation(normalizedPath, normalizedRepoRoot) { + return (index, PersistedRepositoryEntry(path: normalizedRepoRoot, kind: .git)) } + return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .plain)) } catch { if entry.kind == .git, Self.isNotGitRepositoryError(error), @@ -118,6 +110,12 @@ extension RepositoriesFeature { return message.localizedCaseInsensitiveContains("not a git repository") } + nonisolated static func pathsReferToSameFileSystemLocation(_ lhs: String, _ rhs: String) -> Bool { + let lhsURL = URL(fileURLWithPath: lhs).resolvingSymlinksInPath().standardizedFileURL + let rhsURL = URL(fileURLWithPath: rhs).resolvingSymlinksInPath().standardizedFileURL + return lhsURL == rhsURL + } + nonisolated static func openRepositoryFailureMessage(path: String, error: any Error) -> String { let detail: String if case GitClientError.commandFailed(_, let message) = error, diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 92800ab6..e7954311 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -1850,6 +1850,7 @@ struct RepositoriesFeatureTests { #expect(url.path(percentEncoded: false) == root) return [worktree] } + $0.gitClient.repositoryWebURL = { _ in nil } } await store.send(.loadPersistedRepositories) @@ -1868,6 +1869,55 @@ struct RepositoriesFeatureTests { #expect(savedEntries.value == expectedSavedEntries) } + @Test func loadPersistedRepositoriesResolvesSymlinkedGitRepositoryRoot() async throws { + let tempRoot = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + let realRoot = tempRoot.appending(path: "real", directoryHint: .isDirectory) + let symlinkRoot = tempRoot.appending(path: "link", directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: tempRoot) } + try FileManager.default.createDirectory(at: realRoot, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: symlinkRoot, withDestinationURL: realRoot) + + let realPath = realRoot.standardizedFileURL.path(percentEncoded: false) + let symlinkPath = symlinkRoot.standardizedFileURL.path(percentEncoded: false) + let worktree = makeWorktree(id: realPath, name: "main", repoRoot: realPath) + let repository = makeRepository(id: realPath, name: "real", kind: .git, worktrees: [worktree]) + let savedEntries = LockIsolated<[[PersistedRepositoryEntry]]>([]) + + let store = TestStore(initialState: RepositoriesFeature.State()) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryPersistence.loadRepositoryEntries = { + [PersistedRepositoryEntry(path: symlinkPath, kind: .git)] + } + $0.repositoryPersistence.saveRepositoryEntries = { entries in + savedEntries.withValue { $0.append(entries) } + } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.gitClient.repoRoot = { url in + #expect(url.standardizedFileURL.path(percentEncoded: false) == symlinkPath) + return realRoot + } + $0.gitClient.worktrees = { url in + #expect(url.standardizedFileURL.path(percentEncoded: false) == realPath) + return [worktree] + } + $0.gitClient.repositoryWebURL = { _ in nil } + } + + await store.send(.loadPersistedRepositories) + await store.receive(\.repositoriesLoaded) { + $0.repositories = [repository] + $0.repositoryRoots = [realRoot.standardizedFileURL] + $0.isInitialLoadComplete = true + $0.snapshotPersistencePhase = .active + } + await store.receive(\.delegate.repositoriesChanged) + await store.finish() + + #expect(savedEntries.value == [[PersistedRepositoryEntry(path: realPath, kind: .git)]]) + } + @Test func loadPersistedRepositoriesDoesNotUpgradePlainFolderWhenOnlyAncestorIsGitRoot() async { let root = "/tmp/folder" let ancestorRoot = "/tmp" @@ -1983,6 +2033,7 @@ struct RepositoriesFeatureTests { #expect(url.path(percentEncoded: false) == root) return [worktree] } + $0.gitClient.repositoryWebURL = { _ in nil } } await store.send(.loadPersistedRepositories)