native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349import 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 `<remote>/<branch>` 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<String>() 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> ) -> [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<String> = [] 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> ) -> 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 }}