native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
35 kB · 956 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957import ComposableArchitectureimport Foundationimport IdentifiedCollectionsimport ProwlCLISharedimport Testing
@testable import supacode
@MainActorstruct ProjectWorkspaceTests { @Test func baseRefOptionsKeepsDetectedKindForAutomaticSlashBranch() { let options = ProjectWorkspaceCreationRepository.baseRefOptions( automaticBaseRef: "feature/login", options: [ GitBranchRefOption(ref: "feature/login", kind: .local), GitBranchRefOption(ref: "origin/main", kind: .remoteTracking), ] )
#expect( options == [ GitBranchRefOption(ref: "feature/login", kind: .local), GitBranchRefOption(ref: "origin/main", kind: .remoteTracking), ]) }
@Test func preferredBaseRefMatchesTrimmedAutomaticBaseRef() { let preferred = ProjectWorkspaceCreationRepository.preferredBaseRef( automaticBaseRef: " develop \n", options: [ GitBranchRefOption(ref: "develop", kind: .local), GitBranchRefOption(ref: "main", kind: .local), ] )
#expect(preferred == "develop") }
@Test func remoteNamingStripsOnlyTrailingGitSuffix() { #expect( GitRemoteNaming.repositoryName(fromRemoteURL: "git@github.com:onevcat/x.github.io.git") == "x.github.io") #expect( GitRemoteNaming.repositoryName(fromRemoteURL: "https://github.com/onevcat/app.git") == "app") #expect( GitRemoteNaming.repositoryName(fromRemoteURL: "https://github.com/onevcat/app") == "app") }
@Test func loadsWorkspaceMetadataWithDefaultsAndSnakeCaseSources() throws { let rootURL = try makeTemporaryWorkspaceRoot() defer { try? FileManager.default.removeItem(at: rootURL) }
try writeWorkspaceJSON( """ { "title": "Multi Repo Task", "repositories": [ { "role": "backend", "path": "api", "source_kind": "bare_repository", "source_location": "/Users/mikoto/Repos/api.git", "branch_name": "feature/workspace" }, { "id": "web", "name": "Web", "path": "/tmp/web", "source_kind": "remote", "source_location": "git@github.com:onevcat/web.git" }, { "name": "Shared", "path": "shared" } ] } """, to: rootURL )
let workspace = try #require(ProjectWorkspace.load(from: rootURL)) let rootPath = rootURL.standardizedFileURL.path(percentEncoded: false)
#expect(workspace.id == rootPath) #expect(workspace.schemaVersion == ProjectWorkspace.currentSchemaVersion) #expect(workspace.title == "Multi Repo Task") #expect(workspace.description == "") #expect(workspace.taskLinks == []) try #require(workspace.repositories.count == 3)
let api = workspace.repositories[0] #expect(api.id == "api") #expect(api.name == "api") #expect(api.role == "backend") #expect(api.sourceKind == .bareRepository) #expect(api.sourceLocation == "/Users/mikoto/Repos/api.git") #expect(api.branchName == "feature/workspace") #expect( api.resolvedURL(relativeTo: rootURL).path(percentEncoded: false) == rootURL.appending(path: "api").standardizedFileURL.path(percentEncoded: false) )
let web = workspace.repositories[1] #expect(web.id == "web") #expect(web.name == "Web") #expect(web.sourceKind == .remote) #expect( web.resolvedURL(relativeTo: rootURL).path(percentEncoded: false) == URL(fileURLWithPath: "/tmp/web").standardizedFileURL.path(percentEncoded: false) )
let shared = workspace.repositories[2] #expect(shared.id == "shared") #expect(shared.name == "Shared") #expect(shared.sourceKind == .existingPath) }
@Test func loadReturnsNilForMalformedWorkspaceMetadata() throws { let rootURL = try makeTemporaryWorkspaceRoot() defer { try? FileManager.default.removeItem(at: rootURL) } try writeWorkspaceJSON("{ not json", to: rootURL)
#expect(ProjectWorkspace.load(from: rootURL) == nil) }
@Test func normalizesEmptyWorkspaceAndRepositoryFields() throws { let rootURL = URL(fileURLWithPath: "/tmp/prowl-workspace")
let workspace = ProjectWorkspace( id: " ", title: " ", description: " Touch app and API together ", taskLinks: [" https://github.com/onevcat/Prowl/issues/1 ", " "], repositories: [ ProjectWorkspace.RepositoryEntry( id: " ", name: " ", role: " ", path: " app ", sourceKind: .localRepository, sourceLocation: " ", branchName: " feature/workspace ", baseRef: " " ) ] ) .normalized(relativeTo: rootURL)
#expect(workspace.id == "/tmp/prowl-workspace") #expect(workspace.title == "prowl-workspace") #expect(workspace.description == "Touch app and API together") #expect(workspace.taskLinks == ["https://github.com/onevcat/Prowl/issues/1"])
let entry = try #require(workspace.repositories.first) #expect(entry.id == "app") #expect(entry.name == "app") #expect(entry.role == nil) #expect(entry.sourceKind == .localRepository) #expect(entry.sourceLocation == nil) #expect(entry.branchName == "feature/workspace") #expect(entry.baseRef == nil) }
@Test func repositoryEntryNormalizerKeepsWorkspacePathPlain() throws { let rootURL = try makeTemporaryWorkspaceRoot() defer { try? FileManager.default.removeItem(at: rootURL) } try writeWorkspaceJSON("{}", to: rootURL)
let rootPath = rootURL.standardizedFileURL.path(percentEncoded: false) let normalized = RepositoryEntryNormalizer.normalize([ PersistedRepositoryEntry(path: rootPath, kind: .git) ])
#expect(normalized == [PersistedRepositoryEntry(path: rootPath, kind: .plain)]) }
@Test func createWorkspaceWritesMetadataAndRepositoryLinks() async throws { let rootURL = FileManager.default.temporaryDirectory .appending(path: "prowl-created-workspace-\(UUID().uuidString)") .standardizedFileURL let appURL = try makeTemporaryWorkspaceRoot() let apiURL = try makeTemporaryWorkspaceRoot() defer { try? FileManager.default.removeItem(at: rootURL) try? FileManager.default.removeItem(at: appURL) try? FileManager.default.removeItem(at: apiURL) } let createdAt = Date(timeIntervalSince1970: 1_234_567) let workspace = try await ProjectWorkspace.create( ProjectWorkspaceCreationRequest( draft: ProjectWorkspaceCreationDraft( title: "Checkout Flow", rootURL: rootURL, repositories: [ ProjectWorkspaceRepositoryPlan( id: "app", name: "App Repo", path: nil, sourceKind: .existingPath, sourceLocation: appURL.path(percentEncoded: false), checkout: .link ), ProjectWorkspaceRepositoryPlan( id: "api", name: "App Repo", path: nil, sourceKind: .existingPath, sourceLocation: apiURL.path(percentEncoded: false), checkout: .link ), ] ), createdAt: createdAt ), gitRunner: ProjectWorkspaceGitRunner { command in throw ProjectWorkspaceCreationError.gitCommandFailed( command: command.displayCommand, message: "unexpected") } )
#expect(workspace.title == "Checkout Flow") #expect(workspace.schemaVersion == ProjectWorkspace.currentSchemaVersion) #expect(workspace.createdAt == createdAt) let loaded = try #require(ProjectWorkspace.load(from: rootURL)) #expect(loaded.schemaVersion == ProjectWorkspace.currentSchemaVersion) #expect(loaded.repositories.map(\.path) == ["App-Repo", "App-Repo-2"]) #expect(loaded.repositories.map(\.sourceKind) == [.existingPath, .existingPath]) let appPath = normalizedTestPath(appURL) let apiPath = normalizedTestPath(apiURL) #expect( loaded.repositories.map(\.sourceLocation) == [ appPath, apiPath, ]) #expect(loaded.repositories.map(\.branchName) == [nil, nil])
let appLinkPath = rootURL.appending(path: "App-Repo").path(percentEncoded: false) let apiLinkPath = rootURL.appending(path: "App-Repo-2").path(percentEncoded: false) #expect( URL(fileURLWithPath: appLinkPath).resolvingSymlinksInPath().path(percentEncoded: false) == appPath) #expect( URL(fileURLWithPath: apiLinkPath).resolvingSymlinksInPath().path(percentEncoded: false) == apiPath) }
@Test func createWorkspaceMaterializesRemoteCloneAndBareWorktree() async throws { let rootURL = FileManager.default.temporaryDirectory .appending(path: "prowl-materialized-workspace-\(UUID().uuidString)") .standardizedFileURL let bareURL = try makeTemporaryWorkspaceRoot() defer { try? FileManager.default.removeItem(at: rootURL) try? FileManager.default.removeItem(at: bareURL) } let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) let workspace = try await ProjectWorkspace.create( ProjectWorkspaceCreationRequest( draft: ProjectWorkspaceCreationDraft( title: "Materialized", rootURL: rootURL, repositories: [ ProjectWorkspaceRepositoryPlan( id: "app", name: "App", path: "app", sourceKind: .remote, sourceLocation: "git@github.com:onevcat/app.git", checkout: .createBranch(branchName: "codex/app", baseRef: "origin/main") ), ProjectWorkspaceRepositoryPlan( id: "api", name: "API", path: "api", sourceKind: .bareRepository, sourceLocation: bareURL.path(percentEncoded: false), checkout: .createBranch(branchName: "codex/api", baseRef: "main") ), ] ), createdAt: Date(timeIntervalSince1970: 2_345_678) ), gitRunner: ProjectWorkspaceGitRunner { command in commands.withValue { $0.append(command) } } )
#expect(workspace.repositories.map(\.path) == ["app", "api"]) #expect(workspace.repositories.map(\.sourceKind) == [.remote, .bareRepository]) let rootPath = rootURL.path(percentEncoded: false) let barePath = normalizedTestPath(bareURL) #expect( commands.value.map(\.arguments) == [ ["clone", "--end-of-options", "git@github.com:onevcat/app.git", "\(rootPath)/app"], [ "-C", "\(rootPath)/app", "checkout", "-B", "codex/app", "--end-of-options", "origin/main", ], [ "-C", barePath, "worktree", "add", "-b", "codex/api", "\(rootPath)/api", "--end-of-options", "main", ], ])
let loaded = try #require(ProjectWorkspace.load(from: rootURL)) #expect( loaded.repositories.map(\.sourceLocation) == ["git@github.com:onevcat/app.git", barePath]) #expect(loaded.repositories.map(\.branchName) == ["codex/app", "codex/api"]) #expect(loaded.repositories.map(\.baseRef) == ["origin/main", "main"]) }
@Test func createWorkspaceUsesExistingRefsWithoutCreatingBranches() async throws { let rootURL = FileManager.default.temporaryDirectory .appending(path: "prowl-existing-ref-workspace-\(UUID().uuidString)") .standardizedFileURL let bareURL = try makeTemporaryWorkspaceRoot() defer { try? FileManager.default.removeItem(at: rootURL) try? FileManager.default.removeItem(at: bareURL) } let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) _ = try await ProjectWorkspace.create( ProjectWorkspaceCreationRequest( draft: ProjectWorkspaceCreationDraft( title: "Existing Refs", rootURL: rootURL, repositories: [ ProjectWorkspaceRepositoryPlan( id: "app", name: "App", path: "app", sourceKind: .remote, sourceLocation: "git@github.com:onevcat/app.git", checkout: .useExistingRef("origin/feature/login") ), ProjectWorkspaceRepositoryPlan( id: "api", name: "API", path: "api", sourceKind: .bareRepository, sourceLocation: bareURL.path(percentEncoded: false), checkout: .useExistingRef("main") ), ] ), createdAt: Date(timeIntervalSince1970: 3_456_789) ), gitRunner: ProjectWorkspaceGitRunner { command in commands.withValue { $0.append(command) } } )
let rootPath = rootURL.path(percentEncoded: false) let barePath = normalizedTestPath(bareURL) #expect( commands.value.map(\.arguments) == [ ["clone", "--end-of-options", "git@github.com:onevcat/app.git", "\(rootPath)/app"], ["-C", "\(rootPath)/app", "checkout", "--end-of-options", "feature/login"], ["-C", barePath, "worktree", "add", "\(rootPath)/api", "--end-of-options", "main"], ])
let loaded = try #require(ProjectWorkspace.load(from: rootURL)) #expect(loaded.repositories.map(\.branchName) == [nil, nil]) #expect(loaded.repositories.map(\.baseRef) == ["origin/feature/login", "main"]) }
@Test func createWorkspaceTracksRemoteRefsAsLocalBranches() async throws { let rootURL = FileManager.default.temporaryDirectory .appending(path: "prowl-tracked-ref-workspace-\(UUID().uuidString)") .standardizedFileURL let bareURL = try makeTemporaryWorkspaceRoot() defer { try? FileManager.default.removeItem(at: rootURL) try? FileManager.default.removeItem(at: bareURL) } let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) let workspace = try await ProjectWorkspace.create( ProjectWorkspaceCreationRequest( draft: ProjectWorkspaceCreationDraft( title: "Tracked Refs", rootURL: rootURL, repositories: [ ProjectWorkspaceRepositoryPlan( id: "app", name: "App", path: "app", sourceKind: .remote, sourceLocation: "git@github.com:onevcat/app.git", checkout: .trackRemoteRef( remoteRef: "origin/chore/disable-spine", branchName: "chore/disable-spine") ), ProjectWorkspaceRepositoryPlan( id: "maker", name: "maker.git", path: nil, sourceKind: .bareRepository, sourceLocation: bareURL.path(percentEncoded: false), checkout: .trackRemoteRef( remoteRef: "origin/chore/disable-spine", branchName: "chore/disable-spine") ), ] ), createdAt: Date(timeIntervalSince1970: 8_901_234) ), gitRunner: ProjectWorkspaceGitRunner { command in commands.withValue { $0.append(command) } } )
let rootPath = rootURL.path(percentEncoded: false) let barePath = normalizedTestPath(bareURL) #expect( commands.value.map(\.arguments) == [ ["clone", "--end-of-options", "git@github.com:onevcat/app.git", "\(rootPath)/app"], ["-C", "\(rootPath)/app", "checkout", "--end-of-options", "chore/disable-spine"], [ "-C", barePath, "worktree", "add", "--track", "-B", "chore/disable-spine", "\(rootPath)/maker", "--end-of-options", "origin/chore/disable-spine", ], ]) #expect(workspace.repositories.map(\.path) == ["app", "maker"]) #expect( workspace.repositories.map(\.branchName) == ["chore/disable-spine", "chore/disable-spine"]) #expect( workspace.repositories.map(\.baseRef) == ["origin/chore/disable-spine", "origin/chore/disable-spine"] ) }
@Test func planMapsRemoteTrackingRefToTrackingBranch() { let repository = ProjectWorkspaceCreationRepository( id: "maker", name: "maker.git", sourceKind: .bareRepository, sourceLocation: "/tmp/maker.git", checkoutMode: .useExistingRef, baseRef: "origin/chore/x", baseRefOptions: [GitBranchRefOption(ref: "origin/chore/x", kind: .remoteTracking)] )
#expect( WorkspaceEditorFeature.plan(for: repository).map(\.checkout) == .success(.trackRemoteRef(remoteRef: "origin/chore/x", branchName: "chore/x")) ) }
@Test func planKeepsExistingLocalBranchWhenNotResetting() { let repository = ProjectWorkspaceCreationRepository( id: "maker", name: "maker.git", sourceKind: .bareRepository, sourceLocation: "/tmp/maker.git", checkoutMode: .useExistingRef, baseRef: "origin/chore/x", baseRefOptions: [ GitBranchRefOption(ref: "origin/chore/x", kind: .remoteTracking), GitBranchRefOption(ref: "chore/x", kind: .local), ] )
// A same-named local branch exists and the user keeps it (default): check // out the local branch directly instead of resetting it to the remote. #expect(repository.resettableLocalBranchName == "chore/x") #expect( WorkspaceEditorFeature.plan(for: repository).map(\.checkout) == .success(.useExistingRef("chore/x")) ) }
@Test func localBranchNameForRemoteRefKeepsSlashedBranchName() { #expect( ProjectWorkspaceCreationRepository.localBranchName(forRemoteRef: "origin/chore/x") == "chore/x") #expect( ProjectWorkspaceCreationRepository.localBranchName(forRemoteRef: "upstream/main") == "main") #expect(ProjectWorkspaceCreationRepository.localBranchName(forRemoteRef: "main") == nil) }
@Test func planResetsLocalBranchToRemoteWhenChosen() { var repository = ProjectWorkspaceCreationRepository( id: "maker", name: "maker.git", sourceKind: .bareRepository, sourceLocation: "/tmp/maker.git", checkoutMode: .useExistingRef, baseRef: "origin/chore/x", baseRefOptions: [ GitBranchRefOption(ref: "origin/chore/x", kind: .remoteTracking), GitBranchRefOption(ref: "chore/x", kind: .local), ] ) repository.resetLocalBranchToRemote = true
#expect( WorkspaceEditorFeature.plan(for: repository).map(\.checkout) == .success(.trackRemoteRef(remoteRef: "origin/chore/x", branchName: "chore/x")) ) }
@Test func defaultRepositoryNameStripsGitSuffix() { #expect( WorkspaceEditorFeature.defaultRepositoryName( for: URL(fileURLWithPath: "/tmp/maker.git")) == "maker" ) #expect( WorkspaceEditorFeature.defaultRepositoryName(for: URL(fileURLWithPath: "/tmp/maker")) == "maker" ) }
@Test func createWorkspaceMaterializesLocalRepositoriesAsWorktrees() async throws { let rootURL = FileManager.default.temporaryDirectory .appending(path: "prowl-local-worktree-workspace-\(UUID().uuidString)") .standardizedFileURL let appURL = try makeTemporaryWorkspaceRoot() let apiURL = try makeTemporaryWorkspaceRoot() defer { try? FileManager.default.removeItem(at: rootURL) try? FileManager.default.removeItem(at: appURL) try? FileManager.default.removeItem(at: apiURL) } let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) let workspace = try await ProjectWorkspace.create( ProjectWorkspaceCreationRequest( draft: ProjectWorkspaceCreationDraft( title: "Local Worktrees", rootURL: rootURL, repositories: [ ProjectWorkspaceRepositoryPlan( id: "app", name: "App", path: "app", sourceKind: .localRepository, sourceLocation: appURL.path(percentEncoded: false), checkout: .createBranch(branchName: "codex/app", baseRef: "main") ), ProjectWorkspaceRepositoryPlan( id: "api", name: "API", path: "api", sourceKind: .existingPath, sourceLocation: apiURL.path(percentEncoded: false), checkout: .useExistingRef("origin/main") ), ] ), createdAt: Date(timeIntervalSince1970: 4_567_890) ), gitRunner: ProjectWorkspaceGitRunner { command in commands.withValue { $0.append(command) } } )
let rootPath = rootURL.path(percentEncoded: false) let appPath = normalizedTestPath(appURL) let apiPath = normalizedTestPath(apiURL) #expect( commands.value.map(\.arguments) == [ [ "-C", appPath, "worktree", "add", "-b", "codex/app", "\(rootPath)/app", "--end-of-options", "main", ], ["-C", apiPath, "worktree", "add", "\(rootPath)/api", "--end-of-options", "origin/main"], ]) #expect(workspace.repositories.map(\.branchName) == ["codex/app", nil]) #expect(workspace.repositories.map(\.baseRef) == ["main", "origin/main"]) }
@Test func createWorkspaceRollsBackCloneWhenCheckoutFails() async throws { let rootURL = try makeTemporaryWorkspaceRoot() let bareURL = try makeTemporaryWorkspaceRoot() defer { try? FileManager.default.removeItem(at: rootURL) try? FileManager.default.removeItem(at: bareURL) } let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) let cloneDestination = rootURL.appending(path: "app", directoryHint: .isDirectory) await #expect(throws: ProjectWorkspaceCreationError.self) { try await ProjectWorkspace.create( ProjectWorkspaceCreationRequest( draft: ProjectWorkspaceCreationDraft( title: "Half Done", rootURL: rootURL, repositories: [ ProjectWorkspaceRepositoryPlan( id: "api", name: "API", path: "api", sourceKind: .bareRepository, sourceLocation: bareURL.path(percentEncoded: false), checkout: .createBranch(branchName: "codex/api", baseRef: "main") ), ProjectWorkspaceRepositoryPlan( id: "app", name: "App", path: "app", sourceKind: .remote, sourceLocation: "git@github.com:onevcat/app.git", checkout: .createBranch(branchName: "codex/app", baseRef: "origin/main") ), ] ), createdAt: Date(timeIntervalSince1970: 5_678_901) ), gitRunner: ProjectWorkspaceGitRunner { command in commands.withValue { $0.append(command) } if command.arguments.first == "clone" { try FileManager.default.createDirectory( at: cloneDestination, withIntermediateDirectories: true) } if command.arguments.contains("checkout") { throw ProjectWorkspaceCreationError.gitCommandFailed( command: command.displayCommand, message: "boom" ) } } ) }
let rootPath = rootURL.path(percentEncoded: false) let barePath = normalizedTestPath(bareURL) #expect( commands.value.map(\.arguments).contains( ["-C", barePath, "worktree", "remove", "--force", "\(rootPath)/api"] ) ) #expect(!FileManager.default.fileExists(atPath: cloneDestination.path(percentEncoded: false))) #expect( !FileManager.default.fileExists( atPath: ProjectWorkspace.metadataURL(for: rootURL).path(percentEncoded: false) ) ) #expect(FileManager.default.fileExists(atPath: rootPath)) }
@Test func createWorkspaceRejectsCreateBranchWithoutName() async throws { let rootURL = FileManager.default.temporaryDirectory .appending(path: "prowl-invalid-workspace-\(UUID().uuidString)") let appURL = try makeTemporaryWorkspaceRoot() defer { try? FileManager.default.removeItem(at: rootURL) try? FileManager.default.removeItem(at: appURL) } await #expect(throws: ProjectWorkspaceCreationError.missingBranchName("App")) { try await ProjectWorkspace.create( ProjectWorkspaceCreationRequest( draft: ProjectWorkspaceCreationDraft( title: "Invalid", rootURL: rootURL, repositories: [ ProjectWorkspaceRepositoryPlan( id: "app", name: "App", path: "app", sourceKind: .localRepository, sourceLocation: appURL.path(percentEncoded: false), checkout: .createBranch(branchName: " ", baseRef: nil) ), ProjectWorkspaceRepositoryPlan( id: "api", name: "API", path: "api", sourceKind: .existingPath, sourceLocation: appURL.path(percentEncoded: false), checkout: .link ), ] ), createdAt: Date(timeIntervalSince1970: 6_789_012) ), gitRunner: ProjectWorkspaceGitRunner { _ in } ) } #expect(!FileManager.default.fileExists(atPath: rootURL.path(percentEncoded: false))) }
@Test func createWorkspaceRejectsLinkForBareRepository() async throws { let rootURL = FileManager.default.temporaryDirectory .appending(path: "prowl-invalid-link-workspace-\(UUID().uuidString)") let bareURL = try makeTemporaryWorkspaceRoot() defer { try? FileManager.default.removeItem(at: rootURL) try? FileManager.default.removeItem(at: bareURL) } await #expect(throws: ProjectWorkspaceCreationError.linkCheckoutUnsupported("API")) { try await ProjectWorkspace.create( ProjectWorkspaceCreationRequest( draft: ProjectWorkspaceCreationDraft( title: "Invalid Link", rootURL: rootURL, repositories: [ ProjectWorkspaceRepositoryPlan( id: "api", name: "API", path: "api", sourceKind: .bareRepository, sourceLocation: bareURL.path(percentEncoded: false), checkout: .link ), ProjectWorkspaceRepositoryPlan( id: "app", name: "App", path: "app", sourceKind: .existingPath, sourceLocation: bareURL.path(percentEncoded: false), checkout: .link ), ] ), createdAt: Date(timeIntervalSince1970: 7_890_123) ), gitRunner: ProjectWorkspaceGitRunner { _ in } ) } }
@Test func planRequiresBranchNameForCreateBranchOnLocalSources() { let repository = ProjectWorkspaceCreationRepository( id: "app", name: "App", rootURL: URL(fileURLWithPath: "/tmp/app"), checkoutMode: .createBranch )
#expect( WorkspaceEditorFeature.plan(for: repository) == .failure(.missingBranchName("App")) ) }
@Test func planMapsLinkAndExistingRefModes() throws { let linked = ProjectWorkspaceCreationRepository( id: "app", name: "App", rootURL: URL(fileURLWithPath: "/tmp/app") ) #expect(WorkspaceEditorFeature.plan(for: linked).map(\.checkout) == .success(.link))
var existing = ProjectWorkspaceCreationRepository( id: "api", name: "API", sourceKind: .bareRepository, sourceLocation: "/tmp/api.git", checkoutMode: .useExistingRef ) #expect( WorkspaceEditorFeature.plan(for: existing) == .failure(.missingExistingRef("API")) ) existing.baseRef = "main" #expect( WorkspaceEditorFeature.plan(for: existing).map(\.checkout) == .success(.useExistingRef("main")) ) }
@Test func cleanupUnregistersWorktreesAndRemovesFolder() async throws { let rootURL = try makeTemporaryWorkspaceRoot() let linkedSourceURL = try makeTemporaryWorkspaceRoot() let bareURL = try makeTemporaryWorkspaceRoot() defer { try? FileManager.default.removeItem(at: rootURL) try? FileManager.default.removeItem(at: linkedSourceURL) try? FileManager.default.removeItem(at: bareURL) } try FileManager.default.createSymbolicLink( at: rootURL.appending(path: "app"), withDestinationURL: linkedSourceURL ) try FileManager.default.createDirectory( at: rootURL.appending(path: "api"), withIntermediateDirectories: true )
let workspace = ProjectWorkspace( repositories: [ ProjectWorkspace.RepositoryEntry( id: "app", name: "App", path: "app", sourceKind: .existingPath, sourceLocation: linkedSourceURL.path(percentEncoded: false) ), ProjectWorkspace.RepositoryEntry( id: "api", name: "API", path: "api", sourceKind: .bareRepository, sourceLocation: bareURL.path(percentEncoded: false), branchName: "chore/x" ), ] ) let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) let apiPath = rootURL.appending(path: "api").standardizedFileURL.path(percentEncoded: false) let failures = await ProjectWorkspace.removeWorktrees( workspace, rootURL: rootURL, gitRunner: ProjectWorkspaceGitRunner { command in commands.withValue { $0.append(command) } } )
#expect(failures.isEmpty) #expect( commands.value.map(\.arguments) == [ ["-C", bareURL.path(percentEncoded: false), "worktree", "remove", "--force", apiPath] ]) // removeWorktrees must leave the folder and the linked source intact. #expect(FileManager.default.fileExists(atPath: rootURL.path(percentEncoded: false))) ProjectWorkspace.removeWorkspaceFolder(at: rootURL) #expect(!FileManager.default.fileExists(atPath: rootURL.path(percentEncoded: false))) #expect(FileManager.default.fileExists(atPath: linkedSourceURL.path(percentEncoded: false))) }
@Test func removeWorktreesDoesNotDeleteBranches() async throws { let rootURL = try makeTemporaryWorkspaceRoot() let bareURL = try makeTemporaryWorkspaceRoot() defer { try? FileManager.default.removeItem(at: rootURL) try? FileManager.default.removeItem(at: bareURL) } try FileManager.default.createDirectory( at: rootURL.appending(path: "api"), withIntermediateDirectories: true )
let workspace = ProjectWorkspace( repositories: [ ProjectWorkspace.RepositoryEntry( id: "api", name: "API", path: "api", sourceKind: .bareRepository, sourceLocation: bareURL.path(percentEncoded: false), branchName: "chore/x" ) ] ) let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) let apiPath = rootURL.appending(path: "api").standardizedFileURL.path(percentEncoded: false) let failures = await ProjectWorkspace.removeWorktrees( workspace, rootURL: rootURL, gitRunner: ProjectWorkspaceGitRunner { command in commands.withValue { $0.append(command) } } )
let barePath = bareURL.path(percentEncoded: false) #expect(failures.isEmpty) // Branch deletion is routed through GitClient's guarded entry point, never // the workspace git runner — so no `branch -D` is issued here. #expect( commands.value.map(\.arguments) == [ ["-C", barePath, "worktree", "remove", "--force", apiPath] ]) }
@Test func removeWorktreesReportsFailedRemovals() async throws { let rootURL = try makeTemporaryWorkspaceRoot() let bareURL = try makeTemporaryWorkspaceRoot() defer { try? FileManager.default.removeItem(at: rootURL) try? FileManager.default.removeItem(at: bareURL) } try FileManager.default.createDirectory( at: rootURL.appending(path: "api"), withIntermediateDirectories: true )
let workspace = ProjectWorkspace( repositories: [ ProjectWorkspace.RepositoryEntry( id: "api", name: "API", path: "api", sourceKind: .bareRepository, sourceLocation: bareURL.path(percentEncoded: false), branchName: "chore/x" ) ] ) let failures = await ProjectWorkspace.removeWorktrees( workspace, rootURL: rootURL, gitRunner: ProjectWorkspaceGitRunner { _ in throw ProjectWorkspaceCreationError.gitCommandFailed( command: "git worktree remove", message: "broken") } )
#expect(failures == ["API"]) // A failed worktree removal must not delete the folder. #expect(FileManager.default.fileExists(atPath: rootURL.path(percentEncoded: false))) }
@Test func listRuntimeContextsReportWorkspaceKind() { let rootURL = URL(fileURLWithPath: "/tmp/workspace") let repository = Repository( id: rootURL.path(percentEncoded: false), rootURL: rootURL, name: "Workspace", kind: .plain, worktrees: [], workspace: ProjectWorkspace(title: "Workspace") ) var state = RepositoriesFeature.State() state.repositories = [repository] state.repositoryRoots = [rootURL]
let contexts = ListRuntimeSnapshotBuilder.orderedWorktreeContexts(from: state)
#expect(contexts.map(\.kind) == [.workspace]) #expect(contexts.first?.id == repository.id) }
@Test func repositoryWithWorkspaceIsAlwaysPlain() { let rootURL = URL(fileURLWithPath: "/tmp/workspace") let repository = Repository( id: rootURL.path(percentEncoded: false), rootURL: rootURL, name: "Workspace", kind: .git, worktrees: [], workspace: ProjectWorkspace(title: "Workspace") )
#expect(repository.kind == .plain) #expect(repository.isWorkspace) }
private func makeTemporaryWorkspaceRoot() throws -> URL { let rootURL = FileManager.default.temporaryDirectory .appending(path: "prowl-workspace-\(UUID().uuidString)") try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) return rootURL }
private func writeWorkspaceJSON(_ json: String, to rootURL: URL) throws { let metadataDirectoryURL = rootURL.appending(path: ProjectWorkspace.metadataDirectoryName) try FileManager.default.createDirectory( at: metadataDirectoryURL, withIntermediateDirectories: true) try Data(json.utf8).write(to: ProjectWorkspace.metadataURL(for: rootURL)) }
private func normalizedTestPath(_ url: URL) -> String { var path = PathPolicy.normalizeURL(url).path(percentEncoded: false) while path.count > 1, path.hasSuffix("/") { path.removeLast() } return path }}