From 08e1c950d8e79331ef1509ce50be82756302bc67 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 11:25:13 +0900 Subject: [PATCH] Fix deletion of externally-created worktrees under symlinked roots Worktrees created outside Prowl under symlinked roots like /tmp could not be deleted: the row briefly vanished then reappeared after the next refresh. `worktrees(for:)` stores paths via `standardizedFileURL`, which resolves the /private symlink (e.g. /private/tmp/foo -> /tmp/foo), while the removal guard compared against the raw paths reported by `git worktree list --porcelain` (/private/tmp/foo). The mismatch failed the guard, so `removeWorktree` silently returned without relocating or pruning anything, and the optimistic UI removal was undone by the next worktree refresh. Canonicalize both sides through the same `standardizedFileURL` transform so externally-created worktrees match and actually get removed. --- supacode/Clients/Git/GitClient.swift | 15 +++++- .../GitClientRemoveWorktreeTests.swift | 49 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 5bfc132f..af8c90ac 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -526,7 +526,7 @@ struct GitClient { nonisolated func removeWorktree(_ worktree: Worktree, deleteBranch: Bool) async throws -> URL { let rootPath = worktree.repositoryRootURL.path(percentEncoded: false) let worktreeURL = worktree.workingDirectory.standardizedFileURL - let worktreePath = worktreeURL.path(percentEncoded: false) + let worktreePath = Self.canonicalWorktreePath(worktreeURL.path(percentEncoded: false)) let registeredWorktreePaths = try await registeredWorktreePaths(rootPath: rootPath) guard registeredWorktreePaths.contains(worktreePath) else { return worktree.workingDirectory @@ -572,7 +572,11 @@ struct GitClient { operation: .worktreeList, arguments: ["-C", rootPath, "worktree", "list", "--porcelain"] ) - return Self.parseGitWorktreePorcelainPaths(output) + // `git worktree list --porcelain` reports the raw on-disk path (e.g. `/private/tmp/foo`), + // while `worktrees(for:)` stores `standardizedFileURL` paths (which resolve `/private` + // symlinks to `/tmp`). Canonicalize both sides identically so the removal guard matches + // externally-created worktrees living under symlinked roots like /tmp or /var. + return Set(Self.parseGitWorktreePorcelainPaths(output).map(Self.canonicalWorktreePath)) } nonisolated func deleteLocalBranch( @@ -874,6 +878,13 @@ struct GitClient { return FileManager.default.fileExists(atPath: gitMetadataURL.path(percentEncoded: false)) } + /// Normalizes a worktree path to the same canonical form `worktrees(for:)` stores, so paths + /// reported by git (which keep `/private` symlink prefixes) compare equal to the standardized + /// URLs Prowl tracks internally. + nonisolated static func canonicalWorktreePath(_ path: String) -> String { + URL(fileURLWithPath: path).standardizedFileURL.path(percentEncoded: false) + } + nonisolated static func parseGitWorktreePorcelainPaths(_ output: String) -> Set { Set( output diff --git a/supacodeTests/GitClientRemoveWorktreeTests.swift b/supacodeTests/GitClientRemoveWorktreeTests.swift index 94b78365..6c3bb581 100644 --- a/supacodeTests/GitClientRemoveWorktreeTests.swift +++ b/supacodeTests/GitClientRemoveWorktreeTests.swift @@ -131,6 +131,55 @@ struct GitClientRemoveWorktreeTests { #expect(!calls.contains { $0.suffix(3) == ["branch", "-d", "feat"] }) } + @Test func removeWorktreeMatchesWhenGitReportsPrivateSymlinkPath() async throws { + // Regression: externally-created worktrees under /tmp are reported by git as `/private/tmp/...`, + // but Prowl tracks them as standardized `/tmp/...` URLs. The removal guard must treat them as + // the same worktree, otherwise removal silently no-ops and the row reappears after a refresh. + let fileManager = FileManager.default + let name = "prowl-private-symlink-wt-\(UUID().uuidString)" + let standardizedURL = URL(fileURLWithPath: "/tmp/\(name)", isDirectory: true) + try fileManager.createDirectory(at: standardizedURL, withIntermediateDirectories: true) + try Data("gitdir: /tmp/repo/.git/worktrees/\(name)\n".utf8) + .write(to: standardizedURL.appending(path: ".git")) + defer { + try? fileManager.removeItem(at: standardizedURL) + } + + let store = ShellCallStore() + let shell = ShellClient( + run: { _, arguments, _ in + await store.record(arguments) + if arguments.contains("--porcelain") { + // Git reports the raw, non-standardized path with the /private prefix. + return ShellOutput( + stdout: "worktree /private/tmp/\(name)\nHEAD abc\ndetached\n", + stderr: "", + exitCode: 0 + ) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + ) + let client = GitClient(shell: shell) + let worktree = Worktree( + id: standardizedURL.path(percentEncoded: false), + name: name, + detail: "../\(name)", + workingDirectory: standardizedURL, + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + + _ = try await client.removeWorktree(worktree, deleteBranch: false) + + // The guard matched, so removal proceeded: the directory was relocated off its original path + // and git was asked to prune the now-missing worktree. + let stillExists = fileManager.fileExists(atPath: standardizedURL.path(percentEncoded: false)) + #expect(!stillExists) + let calls = await store.calls + #expect(calls.contains { $0.contains("prune") }) + } + @Test func forceDeleteLocalBranchUsesForceFlag() async throws { let store = ShellCallStore() let shell = ShellClient( -- 2.51.2