import Foundation nonisolated enum ProjectWorkspaceRepositorySourceKind: String, Codable, Equatable, Hashable, Sendable { case remote case localRepository = "local_repository" case bareRepository = "bare_repository" case existingPath = "existing_path" var supportsLinkCheckout: Bool { switch self { case .existingPath, .localRepository: return true case .remote, .bareRepository: return false } } var defaultCheckoutMode: ProjectWorkspaceRepositoryCheckoutMode { switch self { case .existingPath, .localRepository: return .link case .bareRepository: return .createBranch case .remote: return .useExistingRef } } func localSourceURL(from sourceLocation: String) -> URL? { switch self { case .existingPath, .localRepository, .bareRepository: let trimmed = sourceLocation.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } return URL(fileURLWithPath: trimmed).standardizedFileURL case .remote: return nil } } } nonisolated enum ProjectWorkspaceRepositoryCheckoutMode: String, Codable, Equatable, Hashable, Sendable { case link case createBranch = "create_branch" case useExistingRef = "use_existing_ref" } nonisolated enum ProjectWorkspaceRepositoryCheckout: Equatable, Hashable, Sendable { case link case createBranch(branchName: String, baseRef: String?) case useExistingRef(String) case trackRemoteRef(remoteRef: String, branchName: String) } nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Sendable, Identifiable { var id: String var name: String var role: String? var path: String? var sourceKind: ProjectWorkspaceRepositorySourceKind var sourceLocation: String var checkoutMode: ProjectWorkspaceRepositoryCheckoutMode var branchName: String? var baseRef: String? var baseRefOptions: [GitBranchRefOption] // User choice when a Use-Existing checkout on a remote-tracking ref would // reset an existing same-named local branch: false keeps the local branch // (checks it out directly), true resets it to the remote ref. var resetLocalBranchToRemote: Bool = false init( id: String, name: String, rootURL: URL, checkoutMode: ProjectWorkspaceRepositoryCheckoutMode? = nil, branchName: String? = nil, path: String? = nil, baseRef: String? = nil, baseRefOptions: [GitBranchRefOption] = [] ) { let normalizedURL = rootURL.standardizedFileURL self.id = id self.name = name self.path = path sourceKind = .existingPath sourceLocation = normalizedURL.path(percentEncoded: false) self.checkoutMode = checkoutMode ?? ProjectWorkspaceRepositorySourceKind.existingPath.defaultCheckoutMode self.branchName = branchName self.baseRef = baseRef self.baseRefOptions = Self.normalizedBaseRefOptions(baseRefOptions) } init( id: String, name: String, sourceKind: ProjectWorkspaceRepositorySourceKind, sourceLocation: String, checkoutMode: ProjectWorkspaceRepositoryCheckoutMode? = nil, branchName: String? = nil, baseRef: String? = nil, path: String? = nil, baseRefOptions: [GitBranchRefOption] = [] ) { self.id = id self.name = name self.path = path self.sourceKind = sourceKind self.sourceLocation = sourceLocation self.checkoutMode = checkoutMode ?? sourceKind.defaultCheckoutMode self.branchName = branchName self.baseRef = baseRef self.baseRefOptions = Self.normalizedBaseRefOptions(baseRefOptions) } var localSourceURL: URL? { sourceKind.localSourceURL(from: sourceLocation) } // Non-nil when a Use-Existing checkout on a remote-tracking ref would reset an // already-existing same-named local branch via `git worktree add -B`, so the // user should choose between keeping the local branch and resetting it. // Derived purely from the already-loaded ref options — no extra git lookup. var resettableLocalBranchName: String? { guard checkoutMode == .useExistingRef, let selectedRef = baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, let kind = baseRefOptions.first(where: { $0.ref == selectedRef })?.kind, kind != .local else { return nil } guard let localName = Self.localBranchName(forRemoteRef: selectedRef), baseRefOptions.contains(where: { $0.kind == .local && $0.ref == localName }) else { return nil } return localName } /// Converts refs loaded as `/` into the local branch name Git /// would create or reset for a tracking checkout. Branch names can contain /// slashes, so only the first path component is the remote name. nonisolated static func localBranchName(forRemoteRef ref: String) -> String? { let parts = ref.trimmingCharacters(in: .whitespacesAndNewlines).split( separator: "/", maxSplits: 1) guard parts.count == 2 else { return nil } let branchName = String(parts[1]) return branchName.isEmpty ? nil : branchName } nonisolated static func baseRefOptions( automaticBaseRef: String?, options: [GitBranchRefOption] ) -> [GitBranchRefOption] { var values: [GitBranchRefOption] = [] if let automaticBaseRef { let trimmed = automaticBaseRef.trimmingCharacters(in: .whitespacesAndNewlines) let kind = options.first { $0.ref == trimmed }?.kind ?? .local values.append(GitBranchRefOption(ref: automaticBaseRef, kind: kind)) } values += options return normalizedBaseRefOptions(values) } nonisolated static func preferredBaseRef(automaticBaseRef: String?, options: [GitBranchRefOption]) -> String? { let refs = Set(options.map(\.ref)) if let trimmed = automaticBaseRef?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty, refs.contains(trimmed) { return trimmed } return ["main", "master", "origin/main", "origin/master"].first { refs.contains($0) } ?? options.first?.ref } nonisolated static func normalizedBaseRefOptions(_ values: [GitBranchRefOption]) -> [GitBranchRefOption] { var seen = Set() var result: [GitBranchRefOption] = [] for value in values { let trimmed = value.ref.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { continue } result.append(GitBranchRefOption(ref: trimmed, kind: value.kind)) } return result } } nonisolated struct ProjectWorkspaceRepositoryPlan: Equatable, Sendable, Identifiable { var id: String var name: String var role: String? var path: String? var sourceKind: ProjectWorkspaceRepositorySourceKind var sourceLocation: String var checkout: ProjectWorkspaceRepositoryCheckout init( id: String, name: String, role: String? = nil, path: String?, sourceKind: ProjectWorkspaceRepositorySourceKind, sourceLocation: String, checkout: ProjectWorkspaceRepositoryCheckout ) { self.id = id self.name = name self.role = role self.path = path self.sourceKind = sourceKind self.sourceLocation = sourceLocation self.checkout = checkout } var localSourceURL: URL? { sourceKind.localSourceURL(from: sourceLocation) } } nonisolated struct ProjectWorkspaceCreationDraft: Equatable, Sendable { var title: String var description: String var taskLinks: [String] var rootURL: URL var repositories: [ProjectWorkspaceRepositoryPlan] init( title: String, description: String = "", taskLinks: [String] = [], rootURL: URL, repositories: [ProjectWorkspaceRepositoryPlan] ) { self.title = title self.description = description self.taskLinks = taskLinks self.rootURL = rootURL.standardizedFileURL self.repositories = repositories } } nonisolated struct ProjectWorkspaceCreationRequest: Equatable, Sendable { var draft: ProjectWorkspaceCreationDraft var createdAt: Date } /// One member of an edited workspace, in the order it should appear in the /// saved metadata. Existing entries carry the (possibly renamed / re-roled) /// metadata as-is; added members are materialized on save. nonisolated enum ProjectWorkspaceUpdateMember: Equatable, Sendable { case existing(ProjectWorkspaceRepositoryEntry) case added(ProjectWorkspaceRepositoryPlan) } /// A member dropped from the workspace. `deleteFiles` removes what Prowl /// materialized under the workspace root (symlink, worktree, or clone); /// `deleteBranch` additionally asks the caller to delete the recorded branch /// through the guarded branch-deletion path once the worktree is gone. nonisolated struct ProjectWorkspaceRepositoryRemoval: Equatable, Sendable, Identifiable { var entry: ProjectWorkspaceRepositoryEntry var deleteFiles: Bool var deleteBranch: Bool var id: String { entry.id } init(entry: ProjectWorkspaceRepositoryEntry, deleteFiles: Bool = false, deleteBranch: Bool = false) { self.entry = entry self.deleteFiles = deleteFiles self.deleteBranch = deleteBranch } } nonisolated struct ProjectWorkspaceUpdateRequest: Equatable, Sendable { var rootURL: URL var title: String var description: String var taskLinks: [String] var members: [ProjectWorkspaceUpdateMember] var removals: [ProjectWorkspaceRepositoryRemoval] var updatedAt: Date init( rootURL: URL, title: String, description: String = "", taskLinks: [String] = [], members: [ProjectWorkspaceUpdateMember], removals: [ProjectWorkspaceRepositoryRemoval] = [], updatedAt: Date ) { self.rootURL = rootURL.standardizedFileURL self.title = title self.description = description self.taskLinks = taskLinks self.members = members self.removals = removals self.updatedAt = updatedAt } } /// A removed member whose files stayed on disk. The metadata is already /// saved without the entry, so this is reported rather than thrown. nonisolated struct ProjectWorkspaceCleanupFailure: Equatable, Sendable, Identifiable { var entryID: String var entryName: String var message: String var id: String { entryID } } nonisolated struct ProjectWorkspaceUpdateResult: Equatable, Sendable { var workspace: ProjectWorkspace var cleanupFailures: [ProjectWorkspaceCleanupFailure] /// Removals whose files were cleaned up, so branch deletion can proceed /// safely. A worktree whose unregistration failed is excluded: deleting /// its branch would leave git with a dangling checkout. var completedRemovals: [ProjectWorkspaceRepositoryRemoval] } nonisolated struct ProjectWorkspaceGitCommand: Equatable, Sendable { var arguments: [String] var currentDirectoryURL: URL? var displayCommand: String { (["git"] + arguments).joined(separator: " ") } } nonisolated struct ProjectWorkspaceGitRunner: Sendable { var run: @Sendable (ProjectWorkspaceGitCommand) async throws -> Void } nonisolated enum ProjectWorkspaceCreationError: LocalizedError, Equatable, Sendable { case missingTitle case missingPath case notEnoughRepositories case missingRepositoryName case missingRepositorySource(String) case missingBranchName(String) case missingExistingRef(String) case linkCheckoutUnsupported(String) case destinationIsFile(String) case workspaceAlreadyExists(String) case workspaceMetadataMissing(String) case repositoryDoesNotExist(String) case linkAlreadyExists(String) case gitCommandFailed(command: String, message: String) var errorDescription: String? { switch self { case .missingTitle: return String(localized: "Workspace title required.") case .missingPath: return String(localized: "Workspace folder required.") case .notEnoughRepositories: return String(localized: "Add at least one repository.") case .missingRepositoryName: return String(localized: "Repository name required.") case .missingRepositorySource(let name): return String(localized: "Source required for \(name).") case .missingBranchName(let name): return String(localized: "Branch name required for \(name).") case .missingExistingRef(let name): return String(localized: "Choose an existing branch for \(name).") case .linkCheckoutUnsupported(let name): return String(localized: "Link is not available for \(name).") case .destinationIsFile(let path): return String(localized: "\(path) is a file. Choose a folder path instead.") case .workspaceAlreadyExists(let path): return String(localized: "\(path) already contains a Prowl workspace.") case .workspaceMetadataMissing(let path): return String(localized: "\(path) has no workspace metadata to update.") case .repositoryDoesNotExist(let path): return String(localized: "\(path) does not exist.") case .linkAlreadyExists(let path): return String(localized: "\(path) already exists.") case .gitCommandFailed(let command, let message): if message.isEmpty { return String(localized: "Git command failed: \(command)") } return String(localized: "Git command failed: \(command)\n\(message)") } } } nonisolated struct ProjectWorkspaceRepositoryEntry: Codable, Equatable, Hashable, Sendable, Identifiable { var id: String var name: String var role: String? var path: String var sourceKind: ProjectWorkspaceRepositorySourceKind var sourceLocation: String? var branchName: String? var baseRef: String? enum CodingKeys: String, CodingKey { case id case name case role case path case sourceKind = "source_kind" case sourceLocation = "source_location" case branchName = "branch_name" case baseRef = "base_ref" } init( id: String = "", name: String = "", role: String? = nil, path: String = "", sourceKind: ProjectWorkspaceRepositorySourceKind = .existingPath, sourceLocation: String? = nil, branchName: String? = nil, baseRef: String? = nil ) { self.id = id self.name = name self.role = role self.path = path self.sourceKind = sourceKind self.sourceLocation = sourceLocation self.branchName = branchName self.baseRef = baseRef } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decodeIfPresent(String.self, forKey: .id) ?? "" name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" role = try container.decodeIfPresent(String.self, forKey: .role) path = try container.decodeIfPresent(String.self, forKey: .path) ?? name.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? id.trimmingCharacters(in: .whitespacesAndNewlines) sourceKind = try container.decodeIfPresent(ProjectWorkspaceRepositorySourceKind.self, forKey: .sourceKind) ?? .existingPath sourceLocation = try container.decodeIfPresent(String.self, forKey: .sourceLocation) branchName = try container.decodeIfPresent(String.self, forKey: .branchName) baseRef = try container.decodeIfPresent(String.self, forKey: .baseRef) } func resolvedURL(relativeTo workspaceRootURL: URL) -> URL { let trimmedPath = path.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedPath.hasPrefix("/") { return URL(fileURLWithPath: trimmedPath).standardizedFileURL } return workspaceRootURL.appending(path: trimmedPath).standardizedFileURL } /// The local repository this entry was materialized from, when recorded. /// Nil for remote clones and for metadata written without a source location. var localSourceURL: URL? { guard let sourceLocation else { return nil } return sourceKind.localSourceURL(from: sourceLocation) } } nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { typealias RepositorySourceKind = ProjectWorkspaceRepositorySourceKind typealias RepositoryEntry = ProjectWorkspaceRepositoryEntry nonisolated static let metadataDirectoryName = ".prowl" nonisolated static let metadataFileName = "workspace.json" nonisolated static let currentSchemaVersion = "prowl.workspace.v1" nonisolated private static let log = SupaLogger("workspace") var schemaVersion: String var id: String var title: String var description: String var taskLinks: [String] var repositories: [RepositoryEntry] var createdAt: Date? var updatedAt: Date? enum CodingKeys: String, CodingKey { case schemaVersion = "schema_version" case id case title case description case taskLinks = "task_links" case repositories case createdAt = "created_at" case updatedAt = "updated_at" } init( schemaVersion: String = currentSchemaVersion, id: String = "", title: String = "", description: String = "", taskLinks: [String] = [], repositories: [RepositoryEntry] = [], createdAt: Date? = nil, updatedAt: Date? = nil ) { self.schemaVersion = schemaVersion self.id = id self.title = title self.description = description self.taskLinks = taskLinks self.repositories = repositories self.createdAt = createdAt self.updatedAt = updatedAt } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) schemaVersion = try container.decodeIfPresent(String.self, forKey: .schemaVersion) ?? Self.currentSchemaVersion id = try container.decodeIfPresent(String.self, forKey: .id) ?? "" title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" description = try container.decodeIfPresent(String.self, forKey: .description) ?? "" taskLinks = try container.decodeIfPresent([String].self, forKey: .taskLinks) ?? [] repositories = try container.decodeIfPresent([RepositoryEntry].self, forKey: .repositories) ?? [] createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) } static func metadataURL(for rootURL: URL) -> URL { rootURL .appending(path: metadataDirectoryName) .appending(path: metadataFileName) } static func hasMetadata(at rootURL: URL) -> Bool { FileManager.default.fileExists(atPath: metadataURL(for: rootURL).path(percentEncoded: false)) } static func load(from rootURL: URL) -> ProjectWorkspace? { let metadataURL = metadataURL(for: rootURL) guard let data = try? Data(contentsOf: metadataURL) else { return nil } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 var workspace: ProjectWorkspace do { workspace = try decoder.decode(ProjectWorkspace.self, from: data) } catch { log.warning( "Ignoring malformed workspace metadata at \(metadataURL.path(percentEncoded: false)): \(error)" ) return nil } let normalizedRoot = rootURL.standardizedFileURL.path(percentEncoded: false) if workspace.id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { workspace.id = normalizedRoot } if workspace.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { workspace.title = rootURL.lastPathComponent.isEmpty ? normalizedRoot : rootURL.lastPathComponent } return workspace.normalized(relativeTo: rootURL) } func normalized(relativeTo rootURL: URL) -> ProjectWorkspace { var copy = self copy.schemaVersion = copy.schemaVersion.trimmingCharacters(in: .whitespacesAndNewlines) if copy.schemaVersion.isEmpty { copy.schemaVersion = Self.currentSchemaVersion } let normalizedRoot = rootURL.standardizedFileURL.path(percentEncoded: false) if copy.id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { copy.id = normalizedRoot } copy.title = copy.title.trimmingCharacters(in: .whitespacesAndNewlines) if copy.title.isEmpty { copy.title = rootURL.lastPathComponent.isEmpty ? normalizedRoot : rootURL.lastPathComponent } copy.description = copy.description.trimmingCharacters(in: .whitespacesAndNewlines) copy.taskLinks = copy.taskLinks.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } copy.repositories = copy.repositories.map { entry in var entry = entry entry.id = entry.id.trimmingCharacters(in: .whitespacesAndNewlines) entry.name = entry.name.trimmingCharacters(in: .whitespacesAndNewlines) entry.path = entry.path.trimmingCharacters(in: .whitespacesAndNewlines) if entry.id.isEmpty { entry.id = entry.path.isEmpty ? entry.name : entry.path } if entry.name.isEmpty { let resolvedURL = entry.resolvedURL(relativeTo: rootURL) entry.name = resolvedURL.lastPathComponent.isEmpty ? entry.id : resolvedURL.lastPathComponent } entry.role = entry.role?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty entry.sourceLocation = entry.sourceLocation?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty entry.branchName = entry.branchName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty entry.baseRef = entry.baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty return entry } return copy } static func create( _ request: ProjectWorkspaceCreationRequest, fileManager: FileManager = .default, gitRunner: ProjectWorkspaceGitRunner ) async throws -> ProjectWorkspace { let title = request.draft.title.trimmingCharacters(in: .whitespacesAndNewlines) guard !title.isEmpty else { throw ProjectWorkspaceCreationError.missingTitle } guard !request.draft.repositories.isEmpty else { throw ProjectWorkspaceCreationError.notEnoughRepositories } let rootPath = normalizedPath(request.draft.rootURL, resolvingSymlinks: false) guard !rootPath.isEmpty else { throw ProjectWorkspaceCreationError.missingPath } let rootURL = URL(fileURLWithPath: rootPath, isDirectory: true).standardizedFileURL var ledger = MaterializationLedger() do { var isDirectory = ObjCBool(false) if fileManager.fileExists(atPath: rootPath, isDirectory: &isDirectory) { guard isDirectory.boolValue else { throw ProjectWorkspaceCreationError.destinationIsFile(rootPath) } } else { try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true) ledger.createdRoot = true } let metadataDirectoryURL = rootURL.appending( path: metadataDirectoryName, directoryHint: .isDirectory) let metadataPath = metadataDirectoryURL.path(percentEncoded: false) let metadataURL = metadataURL(for: rootURL) if fileManager.fileExists(atPath: metadataURL.path(percentEncoded: false)) { throw ProjectWorkspaceCreationError.workspaceAlreadyExists(rootPath) } if !fileManager.fileExists(atPath: metadataPath) { try fileManager.createDirectory(at: metadataDirectoryURL, withIntermediateDirectories: true) ledger.createdMetadataDirectory = true } var entries: [RepositoryEntry] = [] for repository in request.draft.repositories { let entry = try await materialize( repository, workspaceRootURL: rootURL, ledger: &ledger, fileManager: fileManager, gitRunner: gitRunner ) entries.append(entry) } let workspace = ProjectWorkspace( id: rootPath, title: title, description: request.draft.description, taskLinks: request.draft.taskLinks, repositories: entries, createdAt: request.createdAt, updatedAt: request.createdAt ) .normalized(relativeTo: rootURL) try encodeMetadata(workspace, preservingUnknownKeysIn: nil) .write(to: metadataURL, options: .atomic) ledger.createdURLs.append(metadataURL) return workspace } catch { await rollback(ledger, rootURL: rootURL, fileManager: fileManager, gitRunner: gitRunner) throw error } } /// Applies an edit to an existing workspace. Steps run in the order that /// keeps what is already on disk safe when the risky step fails: /// /// 1. validate the request; /// 2. materialize added members (rolled back on failure); /// 3. write the metadata atomically, keeping unknown JSON keys; /// 4. clean up removed members best-effort, after the metadata no longer /// references them. Cleanup failures are reported, not thrown. static func update( _ request: ProjectWorkspaceUpdateRequest, fileManager: FileManager = .default, gitRunner: ProjectWorkspaceGitRunner ) async throws -> ProjectWorkspaceUpdateResult { let title = request.title.trimmingCharacters(in: .whitespacesAndNewlines) guard !title.isEmpty else { throw ProjectWorkspaceCreationError.missingTitle } guard !request.members.isEmpty else { throw ProjectWorkspaceCreationError.notEnoughRepositories } let rootPath = normalizedPath(request.rootURL, resolvingSymlinks: false) let rootURL = URL(fileURLWithPath: rootPath, isDirectory: true).standardizedFileURL let metadataURL = metadataURL(for: rootURL) guard let existingData = try? Data(contentsOf: metadataURL), let existing = load(from: rootURL) else { throw ProjectWorkspaceCreationError.workspaceMetadataMissing(rootPath) } var ledger = MaterializationLedger() // Every current path stays occupied, including members being removed: // their folders are still on disk while additions are materialized, so a // re-added repository gets a suffixed folder instead of a collision. ledger.occupiedNames = Set(existing.repositories.map { $0.path.lowercased() }) let workspace: ProjectWorkspace do { var entries: [RepositoryEntry] = [] for member in request.members { switch member { case .existing(let entry): entries.append(entry) case .added(let plan): entries.append( try await materialize( plan, workspaceRootURL: rootURL, ledger: &ledger, fileManager: fileManager, gitRunner: gitRunner ) ) } } var updated = existing updated.title = title updated.description = request.description updated.taskLinks = request.taskLinks updated.repositories = entries updated.updatedAt = request.updatedAt workspace = updated.normalized(relativeTo: rootURL) try encodeMetadata(workspace, preservingUnknownKeysIn: existingData) .write(to: metadataURL, options: .atomic) } catch { await rollback(ledger, rootURL: rootURL, fileManager: fileManager, gitRunner: gitRunner) throw error } var cleanupFailures: [ProjectWorkspaceCleanupFailure] = [] var completedRemovals: [ProjectWorkspaceRepositoryRemoval] = [] for removal in request.removals { guard removal.deleteFiles else { continue } if let message = await removeMemberFiles( removal.entry, rootURL: rootURL, fileManager: fileManager, gitRunner: gitRunner) { cleanupFailures.append( ProjectWorkspaceCleanupFailure( entryID: removal.entry.id, entryName: removal.entry.name, message: message)) } else { completedRemovals.append(removal) } } return ProjectWorkspaceUpdateResult( workspace: workspace, cleanupFailures: cleanupFailures, completedRemovals: completedRemovals ) } /// Removes what Prowl materialized for one member under the workspace root. /// Returns a user-facing message when the folder was left in place. Paths /// outside the workspace root and folders Prowl did not create are never /// deleted. private static func removeMemberFiles( _ entry: RepositoryEntry, rootURL: URL, fileManager: FileManager, gitRunner: ProjectWorkspaceGitRunner ) async -> String? { let rootPath = normalizedPath(rootURL, resolvingSymlinks: false) let entryURL = entry.resolvedURL(relativeTo: rootURL) let entryPath = entryURL.path(percentEncoded: false) guard entryPath.hasPrefix(rootPath + "/") else { return String(localized: "\(entryPath) is outside the workspace folder and was left in place.") } let isSymbolicLink = (try? entryURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true guard isSymbolicLink || fileManager.fileExists(atPath: entryPath) else { return nil } do { if isSymbolicLink { // A symlink removal never touches the linked checkout. try fileManager.removeItem(at: entryURL) return nil } // Without a recorded source Prowl cannot tell whether it created the // folder, so it is never deleted, whatever the source kind claims. guard let sourceLocation = entry.sourceLocation, !sourceLocation.isEmpty else { return String(localized: "\(entryPath) was not created by Prowl and was left in place.") } if entry.sourceKind == .remote { // A clone is wholly owned by the workspace. try fileManager.removeItem(at: entryURL) return nil } try await gitRunner.run( ProjectWorkspaceGitCommand( arguments: ["-C", sourceLocation, "worktree", "remove", "--force", entryPath], currentDirectoryURL: nil ) ) return nil } catch { log.warning("Workspace member cleanup failed for \(entryPath): \(error)") return error.localizedDescription } } private static let knownTopLevelKeys = Set( [ CodingKeys.schemaVersion, .id, .title, .description, .taskLinks, .repositories, .createdAt, .updatedAt, ].map(\.stringValue)) private static let knownEntryKeys = Set( [ RepositoryEntry.CodingKeys.id, .name, .role, .path, .sourceKind, .sourceLocation, .branchName, .baseRef, ].map(\.stringValue)) /// Encodes the metadata. When the existing file is supplied, keys this /// version of Prowl does not model (top-level, and per repository entry /// matched by `id`) are carried over so other tools' fields survive a save; /// known keys always follow the model, so a cleared optional is dropped. static func encodeMetadata( _ workspace: ProjectWorkspace, preservingUnknownKeysIn existingData: Data? ) throws -> Data { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] let encoded = try encoder.encode(workspace) guard let existingData, let existingObject = try? JSONSerialization.jsonObject(with: existingData) as? [String: Any], let newObject = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else { return encoded } var merged = mergedObject(existing: existingObject, new: newObject, knownKeys: knownTopLevelKeys) if let newEntries = newObject[CodingKeys.repositories.stringValue] as? [[String: Any]] { let existingEntries = existingObject[CodingKeys.repositories.stringValue] as? [[String: Any]] ?? [] // Key by the same fallback chain `normalized` uses for a missing id, // so hand-written entries without an `id` still keep their extra keys. var existingEntriesByID: [String: [String: Any]] = [:] for entry in existingEntries { let key = (entry[RepositoryEntry.CodingKeys.id.stringValue] as? String)?.nilIfEmpty ?? (entry[RepositoryEntry.CodingKeys.path.stringValue] as? String)?.nilIfEmpty ?? (entry[RepositoryEntry.CodingKeys.name.stringValue] as? String)?.nilIfEmpty if let key { existingEntriesByID[key] = entry } } merged[CodingKeys.repositories.stringValue] = newEntries.map { entry in guard let key = entry[RepositoryEntry.CodingKeys.id.stringValue] as? String, let existingEntry = existingEntriesByID[key] else { return entry } return mergedObject(existing: existingEntry, new: entry, knownKeys: knownEntryKeys) } } return try JSONSerialization.data( withJSONObject: merged, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] ) } private static func mergedObject( existing: [String: Any], new: [String: Any], knownKeys: Set ) -> [String: Any] { var result = existing.filter { !knownKeys.contains($0.key) } for (key, value) in new { result[key] = value } return result } private struct MaterializationLedger: Sendable { var occupiedNames: Set = [] var createdURLs: [URL] = [] var cleanupCommands: [ProjectWorkspaceGitCommand] = [] var createdRoot = false var createdMetadataDirectory = false } // Rollback runs in a detached task so it survives cooperative cancellation of // the surrounding effect: a cancelled parent task would otherwise SIGTERM the // cleanup git subprocesses before they finish. private static func rollback( _ ledger: MaterializationLedger, rootURL: URL, fileManager: FileManager, gitRunner: ProjectWorkspaceGitRunner ) async { nonisolated(unsafe) let fileManager = fileManager let task = Task.detached { for command in ledger.cleanupCommands.reversed() { do { try await gitRunner.run(command) } catch { log.warning("Workspace rollback command failed: \(command.displayCommand): \(error)") } } let removableURLs = ledger.createdURLs.reversed() + (ledger.createdRoot ? [rootURL] : ledger.createdMetadataDirectory ? [rootURL.appending(path: metadataDirectoryName, directoryHint: .isDirectory)] : []) for url in removableURLs { let path = url.path(percentEncoded: false) guard fileManager.fileExists(atPath: path) || (try? url.checkResourceIsReachable()) == true else { continue } do { try fileManager.removeItem(at: url) } catch { log.warning("Workspace rollback could not remove \(path): \(error)") } } } await task.value } // Best-effort worktree removal for a workspace being deleted. Every failure // is logged and the remaining entries are still processed so a broken // repository cannot block deletion. Returns the display names of entries // whose worktree could not be unregistered, so the caller can decide whether // to still delete the workspace folder — deleting it unconditionally would // leave a dangling worktree registration in the source repository. // // Branch deletion is intentionally NOT performed here: the caller routes it // through GitClient's guarded entry point so protected branches // (main/master/default) can never be force-deleted. static func removeWorktrees( _ workspace: ProjectWorkspace, rootURL: URL, fileManager: FileManager = .default, gitRunner: ProjectWorkspaceGitRunner ) async -> [String] { let rootPath = normalizedPath(rootURL, resolvingSymlinks: false) var failedRemovals: [String] = [] for entry in workspace.repositories { let entryURL = entry.resolvedURL(relativeTo: rootURL) let entryPath = entryURL.path(percentEncoded: false) guard entryPath.hasPrefix(rootPath + "/") else { continue } let isSymbolicLink = (try? entryURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true guard !isSymbolicLink, entry.sourceKind != .remote, let sourceLocation = entry.sourceLocation else { continue } do { try await gitRunner.run( ProjectWorkspaceGitCommand( arguments: ["-C", sourceLocation, "worktree", "remove", "--force", entryPath], currentDirectoryURL: nil ) ) } catch { log.warning("Workspace cleanup could not unregister worktree at \(entryPath): \(error)") failedRemovals.append(entry.name) } } return failedRemovals } // Deletes the workspace folder itself. Separate from `removeWorktrees` so the // caller can gate it on whether every worktree was successfully unregistered. static func removeWorkspaceFolder( at rootURL: URL, fileManager: FileManager = .default ) { let rootPath = normalizedPath(rootURL, resolvingSymlinks: false) do { try fileManager.removeItem(at: rootURL) } catch { log.warning("Workspace cleanup could not remove \(rootPath): \(error)") } } static func defaultWorkspaceFolderName(for title: String) -> String { let sanitized = sanitizedWorkspaceComponent(title) return sanitized.isEmpty ? "workspace" : sanitized } nonisolated static func workspaceRootPath(folderName: String, suffix: Int?) -> String { let component = suffix.map { "\(folderName)-\($0)" } ?? folderName return SupacodePaths.workspacesDirectory .appending(path: component, directoryHint: .isDirectory) .standardizedFileURL .path(percentEncoded: false) } nonisolated static func uniqueWorkspaceRootPath(folderName: String) -> String { var suffix: Int? while true { let candidate = workspaceRootPath(folderName: folderName, suffix: suffix) if !FileManager.default.fileExists(atPath: candidate) { return candidate } suffix = (suffix ?? 1) + 1 } } private static func materialize( _ repository: ProjectWorkspaceRepositoryPlan, workspaceRootURL: URL, ledger: inout MaterializationLedger, fileManager: FileManager, gitRunner: ProjectWorkspaceGitRunner ) async throws -> RepositoryEntry { let name = repositoryDisplayName(repository) guard !name.isEmpty else { throw ProjectWorkspaceCreationError.missingRepositoryName } let sourceLocation = repository.sourceLocation.trimmingCharacters(in: .whitespacesAndNewlines) guard !sourceLocation.isEmpty else { throw ProjectWorkspaceCreationError.missingRepositorySource(name) } let checkout = try validatedCheckout( repository.checkout, sourceKind: repository.sourceKind, name: name) let workspacePath = uniqueRepositoryPath( for: repository, displayName: name, occupiedNames: &ledger.occupiedNames ) let destinationURL = workspaceRootURL.appending( path: workspacePath, directoryHint: .isDirectory) let destinationPath = normalizedPath(destinationURL, resolvingSymlinks: false) guard !fileManager.fileExists(atPath: destinationPath) else { throw ProjectWorkspaceCreationError.linkAlreadyExists(destinationPath) } let normalizedSourceLocation: String switch repository.sourceKind { case .existingPath, .localRepository: let sourcePath = try localRepositoryPath( sourceLocation: sourceLocation, fileManager: fileManager) switch checkout { case .link: let sourceURL = URL(fileURLWithPath: sourcePath, isDirectory: true).standardizedFileURL try fileManager.createSymbolicLink(at: destinationURL, withDestinationURL: sourceURL) ledger.createdURLs.append(destinationURL) case .createBranch, .useExistingRef, .trackRemoteRef: try await gitRunner.run( worktreeAddCommand(checkout, sourcePath: sourcePath, destinationPath: destinationPath) ) ledger.createdURLs.append(destinationURL) ledger.cleanupCommands.append( worktreeRemoveCommand(sourcePath: sourcePath, destinationPath: destinationPath) ) } normalizedSourceLocation = sourcePath case .remote: try await gitRunner.run( ProjectWorkspaceGitCommand( arguments: ["clone", "--end-of-options", sourceLocation, destinationPath], currentDirectoryURL: workspaceRootURL ) ) ledger.createdURLs.append(destinationURL) if let checkoutCommand = remoteCheckoutCommand(checkout, destinationPath: destinationPath) { try await gitRunner.run(checkoutCommand) } normalizedSourceLocation = sourceLocation case .bareRepository: let sourcePath = try localRepositoryPath( sourceLocation: sourceLocation, fileManager: fileManager) try await gitRunner.run( worktreeAddCommand(checkout, sourcePath: sourcePath, destinationPath: destinationPath) ) ledger.createdURLs.append(destinationURL) ledger.cleanupCommands.append( worktreeRemoveCommand(sourcePath: sourcePath, destinationPath: destinationPath) ) normalizedSourceLocation = sourcePath } let entryBranchName: String? let entryBaseRef: String? switch checkout { case .link: entryBranchName = nil entryBaseRef = nil case .createBranch(let branchName, let baseRef): entryBranchName = branchName entryBaseRef = baseRef case .useExistingRef(let ref): entryBranchName = nil entryBaseRef = ref case .trackRemoteRef(let remoteRef, let branchName): entryBranchName = branchName entryBaseRef = remoteRef } return RepositoryEntry( id: repository.id.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? workspacePath, name: name, role: repository.role?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, path: workspacePath, sourceKind: repository.sourceKind, sourceLocation: normalizedSourceLocation, branchName: entryBranchName, baseRef: entryBaseRef ) } private static func validatedCheckout( _ checkout: ProjectWorkspaceRepositoryCheckout, sourceKind: ProjectWorkspaceRepositorySourceKind, name: String ) throws -> ProjectWorkspaceRepositoryCheckout { switch checkout { case .link: guard sourceKind.supportsLinkCheckout else { throw ProjectWorkspaceCreationError.linkCheckoutUnsupported(name) } return .link case .createBranch(let branchName, let baseRef): guard let trimmedBranch = branchName.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty else { throw ProjectWorkspaceCreationError.missingBranchName(name) } return .createBranch( branchName: trimmedBranch, baseRef: baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ) case .useExistingRef(let ref): guard let trimmedRef = ref.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty else { throw ProjectWorkspaceCreationError.missingExistingRef(name) } return .useExistingRef(trimmedRef) case .trackRemoteRef(let remoteRef, let branchName): guard let trimmedRemoteRef = remoteRef.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, let trimmedBranch = branchName.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty else { throw ProjectWorkspaceCreationError.missingExistingRef(name) } return .trackRemoteRef(remoteRef: trimmedRemoteRef, branchName: trimmedBranch) } } private static func worktreeAddCommand( _ checkout: ProjectWorkspaceRepositoryCheckout, sourcePath: String, destinationPath: String ) -> ProjectWorkspaceGitCommand { var arguments = ["-C", sourcePath, "worktree", "add"] switch checkout { case .link: arguments += [destinationPath] case .createBranch(let branchName, let baseRef): arguments += ["-b", branchName, destinationPath] if let baseRef { arguments += ["--end-of-options", baseRef] } case .useExistingRef(let ref): arguments += [destinationPath, "--end-of-options", ref] case .trackRemoteRef(let remoteRef, let branchName): // -B aligns a same-named local branch to the remote ref; git still refuses // when that branch is checked out in another worktree. arguments += ["--track", "-B", branchName, destinationPath, "--end-of-options", remoteRef] } return ProjectWorkspaceGitCommand(arguments: arguments, currentDirectoryURL: nil) } private static func worktreeRemoveCommand( sourcePath: String, destinationPath: String ) -> ProjectWorkspaceGitCommand { ProjectWorkspaceGitCommand( arguments: ["-C", sourcePath, "worktree", "remove", "--force", destinationPath], currentDirectoryURL: nil ) } private static func remoteCheckoutCommand( _ checkout: ProjectWorkspaceRepositoryCheckout, destinationPath: String ) -> ProjectWorkspaceGitCommand? { switch checkout { case .link: return nil case .createBranch(let branchName, let baseRef): var arguments = ["-C", destinationPath, "checkout", "-B", branchName] if let baseRef { arguments += ["--end-of-options", baseRef] } return ProjectWorkspaceGitCommand(arguments: arguments, currentDirectoryURL: nil) case .useExistingRef(let ref): return ProjectWorkspaceGitCommand( arguments: [ "-C", destinationPath, "checkout", "--end-of-options", remoteCloneExistingCheckoutRef(ref), ], currentDirectoryURL: nil ) case .trackRemoteRef(_, let branchName): return ProjectWorkspaceGitCommand( arguments: ["-C", destinationPath, "checkout", "--end-of-options", branchName], currentDirectoryURL: nil ) } } private static func withoutGitSuffix(_ name: String) -> String { guard name.count > 4, name.hasSuffix(".git") else { return name } return String(name.dropLast(4)) } private static func remoteCloneExistingCheckoutRef(_ baseRef: String) -> String { let prefix = "origin/" guard baseRef.hasPrefix(prefix), baseRef.count > prefix.count else { return baseRef } return String(baseRef.dropFirst(prefix.count)) } private static func localRepositoryPath( sourceLocation: String, fileManager: FileManager ) throws -> String { let repositoryPath = normalizedPath( URL(fileURLWithPath: sourceLocation), resolvingSymlinks: true) var repositoryIsDirectory = ObjCBool(false) guard fileManager.fileExists(atPath: repositoryPath, isDirectory: &repositoryIsDirectory), repositoryIsDirectory.boolValue else { throw ProjectWorkspaceCreationError.repositoryDoesNotExist(repositoryPath) } return repositoryPath } private static func uniqueRepositoryPath( for repository: ProjectWorkspaceRepositoryPlan, displayName: String, occupiedNames: inout Set ) -> String { var baseName = withoutGitSuffix(sanitizedWorkspaceComponent(repository.path ?? "")) if baseName.isEmpty { baseName = withoutGitSuffix(sanitizedWorkspaceComponent(displayName)) } if baseName.isEmpty { baseName = "repository" } var candidate = baseName var suffix = 2 while occupiedNames.contains(candidate.lowercased()) { candidate = "\(baseName)-\(suffix)" suffix += 1 } occupiedNames.insert(candidate.lowercased()) return candidate } private static func repositoryDisplayName(_ repository: ProjectWorkspaceRepositoryPlan) -> String { let trimmedName = repository.name.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedName.isEmpty { return trimmedName } switch repository.sourceKind { case .existingPath, .localRepository, .bareRepository: if let localSourceURL = repository.localSourceURL { return Repository.name(for: localSourceURL) } case .remote: let name = GitRemoteNaming.repositoryName(fromRemoteURL: repository.sourceLocation) if !name.isEmpty { return name } } return "" } private static func sanitizedWorkspaceComponent(_ value: String) -> String { var result = "" for scalar in value.trimmingCharacters(in: .whitespacesAndNewlines).unicodeScalars { if CharacterSet.whitespacesAndNewlines.contains(scalar) || CharacterSet(charactersIn: "/:").contains(scalar) { result.append("-") } else { result.unicodeScalars.append(scalar) } } while result.contains("--") { result = result.replacing("--", with: "-") } result = result.trimmingCharacters(in: CharacterSet(charactersIn: "-")) if result == "." || result == ".." { return "" } return result } private static func normalizedPath(_ url: URL, resolvingSymlinks: Bool) -> String { var path = PathPolicy.normalizeURL(url, resolvingSymlinks: resolvingSymlinks).path( percentEncoded: false) while path.count > 1, path.hasSuffix("/") { path.removeLast() } return path } } extension String { nonisolated fileprivate var nilIfEmpty: String? { isEmpty ? nil : self } }