diff --git a/.superpowers/status.md b/.superpowers/status.md index 5be9661..ab3f15c 100644 --- a/.superpowers/status.md +++ b/.superpowers/status.md @@ -121,9 +121,20 @@ debug symbols in `make release`). UI-1 is verified live on a real desktop sessio **UI-2** is done: the `Space` level (`Session → Space → Workspace`; `repoPath` moved up to `Space.folderPath`; `Workspace` gained `kind: primary|linked` and `baseBranch`). Opening a folder builds a Space — Git or not (non-Git folders are -degenerate Spaces: one primary, no worktree creation), promoted to Git when a -`.git` appears — detected live by the workspace filesystem watcher (and once per -Space at launch), and demoted back if the `.git` is removed. A per-Space "+" +degenerate Spaces: one primary, no worktree creation), with **one Space per Git +repository** enforced both ways: a folder that is a linked worktree of a +repository already open as a Space is adopted into that Space as a linked +workspace (same branch, the Space's primary branch as its base, nothing created +on disk and no `setup` hook, since the worktree already exists); and opening a +repository whose worktrees are already open as Spaces of their own **reunifies** +them into the Space it creates — those workspaces move whole (same ids, ports, +layouts and live terminals), each ex-primary becoming a linked workspace named +after its branch, and a workspace that already recorded a base branch keeps it. +Repository identity is libgit2's common `.git` directory, shared by every +working tree of a repository. Re-adding a folder Casper already tracks just +selects it. A Space is promoted to Git when a `.git` appears — detected live by +the workspace filesystem watcher (and once per Space at launch), and demoted +back if the `.git` is removed. A per-Space "+" creates a **linked** workspace as a new branch + `git worktree` at a visible sibling of the repo folder, `/-` (outside the repo, so naturally untracked — the old in-repo `.casper/worktrees/` layout and its diff --git a/.superpowers/themes/app-ui.md b/.superpowers/themes/app-ui.md index 7baace4..9cfac1d 100644 --- a/.superpowers/themes/app-ui.md +++ b/.superpowers/themes/app-ui.md @@ -57,7 +57,16 @@ recursive splits/tabs layout (UI-3) depends on Ghostty layout composition `repoPath` moved up to `Space.folderPath`; `Workspace` gained `kind: primary|linked` and `baseBranch`). Opening a folder builds a Space (Git or not — non-Git folders are degenerate Spaces with one primary workspace and - no worktree creation); a per-Space "+" creates a **linked** workspace as a new + no worktree creation), with **one Space per Git repository** — identity being + the common `.git` directory every working tree of a repository shares. A folder + that is a **linked worktree of a repository already open as a Space** is adopted + into that Space as a linked workspace instead of becoming a Space of its own + (nothing is created on disk, so no `setup` hook runs); conversely, opening a + **repository whose worktrees are already open as Spaces** reunifies them into + the Space it creates, moving those workspaces whole (ids, ports, layouts and + live terminals unchanged) with each ex-primary becoming a linked workspace named + after its branch. Re-adding a folder Casper already tracks only selects it; a + per-Space "+" creates a **linked** workspace as a new branch + `git worktree` at a visible sibling of the repo folder, `/-` (outside the repo, so naturally untracked — no in-repo `.casper/worktrees/` and no `.git/info/exclude` entry; a `-2`/`-3`… diff --git a/Sources/CasperCore/WorktreeManager.swift b/Sources/CasperCore/WorktreeManager.swift index 4db1dc0..878fed1 100644 --- a/Sources/CasperCore/WorktreeManager.swift +++ b/Sources/CasperCore/WorktreeManager.swift @@ -125,6 +125,25 @@ public enum WorktreeManager { } } + /// The name git registered for the worktree checked out at `worktreePath`, or + /// nil when the repository lists no worktree there (or cannot be read). + /// + /// Casper's own worktrees are registered under their branch name, so the two are + /// interchangeable for them — but a worktree created outside Casper and later + /// adopted into a Space can carry any name, and pruning its admin entry needs the + /// registered one, not the branch. + public static func registeredName(repoPath: String, worktreePath: String) -> String? { + let target = canonicalPath(worktreePath) + return (try? list(repoPath: repoPath))? + .first(where: { canonicalPath($0.path) == target })?.name + } + + /// `path` with symlinks resolved, so paths reported by libgit2 and paths held by + /// the model compare equal whichever spelling each came from. + private static func canonicalPath(_ path: String) -> String { + URL(fileURLWithPath: path).resolvingSymlinksInPath().path + } + /// Remove the worktree named `name` (working tree at `worktreePath`) from the /// repository at `repoPath`, guaranteeing the working-tree directory is gone /// from disk. diff --git a/Sources/CasperGit/Repository.swift b/Sources/CasperGit/Repository.swift index 1e1032d..ac5d341 100644 --- a/Sources/CasperGit/Repository.swift +++ b/Sources/CasperGit/Repository.swift @@ -42,6 +42,22 @@ public final class Repository { return String(cString: cString) } + /// Absolute path to the repository's **common** directory (trailing slash, per + /// libgit2): the `.git` directory shared by the main working tree and every + /// linked worktree. Equal to `gitDirPath` when the handle is the main working + /// tree; for a linked worktree `gitDirPath` is `/worktrees//` + /// while this stays `/`. It therefore identifies the repository + /// itself, whichever of its working trees was opened. + public var commonDirPath: String { + String(cString: git_repository_commondir(pointer)) + } + + /// True when this handle was opened on a linked worktree (`git worktree add`) + /// rather than on the repository's main working tree. + public var isLinkedWorktree: Bool { + git_repository_is_worktree(pointer) == 1 + } + /// Short name of the branch HEAD currently points to. public func headBranchName() throws -> String { var head: OpaquePointer? diff --git a/Sources/CasperUI/AppModel.swift b/Sources/CasperUI/AppModel.swift index 4d4ae3d..c5daca3 100644 --- a/Sources/CasperUI/AppModel.swift +++ b/Sources/CasperUI/AppModel.swift @@ -582,13 +582,29 @@ final class AppModel { body(&spaces[index.space].workspaces[index.workspace]) } + /// Adopt `folderURL` into the session, keeping one Space per Git repository: + /// + /// - a folder that is a linked worktree of a repository already open joins that + /// repository's Space as a linked workspace — a worktree is part of its repo, + /// not a project of its own; + /// - a folder that is a repository whose worktrees are already open as Spaces of + /// their own becomes their Space, reunifying them into it as linked workspaces; + /// - any other folder becomes a Space, as before. + /// + /// A folder that is already tracked (as a Space or as one of its workspaces) is + /// not added twice: it is just selected. func addSpace(folderURL: URL, probe: (URL) -> WorkspaceFactory.GitInfo?) { let folderPath = folderURL.path - let candidate = URL(fileURLWithPath: folderPath).resolvingSymlinksInPath().path - if spaces.contains(where: { - URL(fileURLWithPath: $0.folderPath).resolvingSymlinksInPath().path == candidate - }) { - CasperLog.app.error("folder already open as a Space: \(folderPath, privacy: .public)") + let candidate = Self.canonicalPath(folderPath) + if let known = trackedWorkspaceID(atCanonicalPath: candidate) { + CasperLog.app.error("folder already open: \(folderPath, privacy: .public)") + selectWorkspace(known) + return + } + let info = probe(folderURL) + if let info, info.isLinkedWorktree, + let spaceID = spaceSharingRepository(with: info, probe: probe) { + adoptWorktree(at: folderURL, info: info, into: spaceID) return } let portBase: Int @@ -598,14 +614,171 @@ final class AppModel { CasperLog.app.failure("cannot add space: no free port block", error) return } - let space = WorkspaceFactory.makeSpace( - folderURL: folderURL, probe: probe, portBase: portBase) - spaces.append(space) - spaces = Self.sortedByName(spaces) + var space = WorkspaceFactory.makeSpace( + folderURL: folderURL, info: info, portBase: portBase) + // The mirror image of adoption: this folder is the main working tree, so any + // Space rooted at one of its worktrees is really a part of the Space being + // created and is folded into it. + let absorbed = info.map { worktreeSpaces(sharingRepositoryWith: $0, probe: probe) } ?? [] + reunify(absorbed, into: &space) + // One write, so the absorbed workspaces are never momentarily absent from the + // model: they keep running throughout, they only change Space. + let absorbedIDs = Set(absorbed.map(\.id)) + var updated = spaces.filter { !absorbedIDs.contains($0.id) } + updated.append(space) + spaces = Self.sortedByName(updated) selectWorkspace(space.workspaces.first?.id) persist() } + /// `path` with symlinks resolved, so two spellings of the same folder compare + /// equal (on macOS `/tmp/x` and `/private/tmp/x` are the same directory). + private static func canonicalPath(_ path: String) -> String { + URL(fileURLWithPath: path).resolvingSymlinksInPath().path + } + + /// The id of the workspace Casper already tracks at `canonical`: either a Space + /// rooted there (answering with its primary workspace) or any workspace whose + /// worktree is that folder. + private func trackedWorkspaceID(atCanonicalPath canonical: String) -> UUID? { + for space in spaces { + if Self.canonicalPath(space.folderPath) == canonical { + return space.orderedWorkspaces.first?.id + } + if let match = space.workspaces.first(where: { + Self.canonicalPath($0.worktreePath) == canonical + }) { + return match.id + } + } + return nil + } + + /// An open Space that turns out to be backed by the same Git repository as a + /// folder being added, and which of the repository's working trees it roots at. + private struct RepositoryMatch { + let space: Space + let isLinkedWorktree: Bool + } + + /// Every open Space backed by the same Git repository as `info` — same common + /// `.git` directory, which all of a repository's working trees share. + private func spacesSharingRepository( + with info: WorkspaceFactory.GitInfo, probe: (URL) -> WorkspaceFactory.GitInfo? + ) -> [RepositoryMatch] { + guard let commonDir = info.commonDirPath else { return [] } + return spaces.compactMap { space in + guard space.isGitRepo, + let spaceInfo = probe(URL(fileURLWithPath: space.folderPath)), + spaceInfo.commonDirPath == commonDir else { return nil } + return RepositoryMatch(space: space, isLinkedWorktree: spaceInfo.isLinkedWorktree) + } + } + + /// The Space a worktree described by `info` should join, or nil when its + /// repository isn't open. When both a repository's main working tree and one of + /// its worktrees are open as Spaces, the main working tree wins: its folder is + /// what worktree operations (create, prune, merge) run against. + private func spaceSharingRepository( + with info: WorkspaceFactory.GitInfo, probe: (URL) -> WorkspaceFactory.GitInfo? + ) -> UUID? { + let matches = spacesSharingRepository(with: info, probe: probe) + return (matches.first(where: { !$0.isLinkedWorktree }) ?? matches.first)?.space.id + } + + /// The open Spaces rooted at a worktree of `info`'s repository: what opening that + /// repository's main working tree reunifies into a single Space. Spaces rooted at + /// the main working tree itself are excluded — a Space always roots at the folder + /// its primary workspace is, and `addSpace` has already ruled out a duplicate. + private func worktreeSpaces( + sharingRepositoryWith info: WorkspaceFactory.GitInfo, + probe: (URL) -> WorkspaceFactory.GitInfo? + ) -> [Space] { + spacesSharingRepository(with: info, probe: probe) + .filter(\.isLinkedWorktree) + .map(\.space) + } + + /// Move every workspace of `absorbed` into `space` as a linked workspace (see + /// `linkedWorkspaces`), tearing down whatever cannot be carried over so a dropped + /// workspace leaves behind neither a reserved port nor a cached view. The + /// absorbed Spaces themselves are dropped by the caller, in the same write that + /// installs `space`. + private func reunify(_ absorbed: [Space], into space: inout Space) { + guard !absorbed.isEmpty, let primary = space.workspaces.first else { return } + space.workspaces.append(contentsOf: Self.linkedWorkspaces( + absorbing: absorbed, baseBranch: primary.branch, + excluding: Self.canonicalPath(primary.worktreePath))) + let moved = Set(space.workspaces.map(\.id)) + for workspace in absorbed.flatMap(\.workspaces) where !moved.contains(workspace.id) { + portAllocator.release(workspace.portBase) + discardSurfaceViews( + LayoutTree.surfaceIDs(workspace.layout) + [workspace.inspector.browser.id]) + pruneTransientState(for: workspace) + } + } + + /// The workspaces of `absorbed`, reshaped as linked workspaces of the Space that + /// absorbs them. They move whole — same ids, ports, layouts and live terminals, + /// so nothing is torn down or respawned — and only the fields that make a + /// workspace linked are normalized: an absorbed Space's own worktree stops being + /// a primary and takes its branch as its name (a Space is named after its + /// repository, which would merely duplicate the new primary's name), and any + /// workspace with no base branch of its own inherits `baseBranch`. A workspace + /// that already records a base keeps it: that is the branch it forked from and + /// still merges back into. + /// + /// A workspace rooted at `primaryPath` is dropped rather than moved: a second + /// workspace on the absorbing Space's own working tree would be a linked + /// workspace whose deletion removes the repository itself. + private static func linkedWorkspaces( + absorbing absorbed: [Space], baseBranch: String, excluding primaryPath: String + ) -> [Workspace] { + absorbed.flatMap(\.orderedWorkspaces) + .filter { canonicalPath($0.worktreePath) != primaryPath } + .map { workspace in + var workspace = workspace + if workspace.kind == .primary { + workspace.kind = .linked + if !workspace.branch.isEmpty { workspace.name = workspace.branch } + } + if workspace.baseBranch?.isEmpty ?? true { workspace.baseBranch = baseBranch } + return workspace + } + } + + /// Add an existing worktree to `spaceID` as a linked workspace. Unlike + /// `createLinkedWorkspace` nothing is created on disk — the branch and the + /// worktree already exist, Casper merely starts tracking them — so the repo's + /// `setup` hook does not run: it fires at creation only. + @discardableResult + private func adoptWorktree( + at folderURL: URL, info: WorkspaceFactory.GitInfo, into spaceID: UUID + ) -> Workspace? { + guard let si = spaces.firstIndex(where: { $0.id == spaceID }) else { return nil } + // Read before the selection moves below, exactly as `createLinkedWorkspace` does. + let inheritedEditor = selectedWorkspaceID.flatMap { workspace(id: $0) }?.lastUsedEditor + let portBase: Int + do { portBase = try portAllocator.allocate() } catch { + CasperLog.app.failure("cannot adopt worktree: no free port block", error) + return nil + } + // Same shape as a Casper-created linked workspace: named after its branch, + // with the Space's primary branch as the base it merges back into. A worktree + // with no branch name of its own falls back to its folder name. + let branch = info.branch + let baseBranch = spaces[si].workspaces.first(where: { $0.kind == .primary })?.branch ?? "" + var ws = WorkspaceFactory.makeLinkedWorkspace( + name: branch.isEmpty ? folderURL.lastPathComponent : branch, + worktreePath: info.canonicalPath, branch: branch, + baseBranch: baseBranch, portBase: portBase) + ws.lastUsedEditor = inheritedEditor + spaces[si].workspaces.append(ws) + selectWorkspace(ws.id) + persist() + return ws + } + /// The workspace selection should fall back to after a removal: the first /// remaining workspace of `space` in display order if it still has one, /// otherwise the first workspace of the first remaining Space overall. @@ -1633,7 +1806,11 @@ final class AppModel { let remote = (try? repo.remoteURL(named: "origin")) ?? nil return WorkspaceFactory.GitInfo( canonicalPath: URL(fileURLWithPath: workdir).standardizedFileURL.path, - branch: branch, remoteURL: remote) + branch: branch, remoteURL: remote, + // Canonicalized (not just standardized) because it is compared across + // folders reached by different spellings — see `spaceSharingRepository`. + commonDirPath: canonicalPath(repo.commonDirPath), + isLinkedWorktree: repo.isLinkedWorktree) } /// Path variant of `gitProbe` for re-probing an already-open Space. @@ -2308,7 +2485,13 @@ final class AppModel { // is load-bearing: a checked-out branch cannot be deleted, so the worktree // must go first. try await Self.offloadGit { - try WorktreeManager.remove(repoPath: repoPath, name: branch, worktreePath: worktreePath) + // Casper's own worktrees are registered under their branch name, but an + // adopted one (created outside Casper) can carry any name, so the admin + // entry is resolved by path — falling back to the branch when the repo + // lists nothing there, which is what the removal below expects anyway. + let entry = WorktreeManager.registeredName( + repoPath: repoPath, worktreePath: worktreePath) ?? branch + try WorktreeManager.remove(repoPath: repoPath, name: entry, worktreePath: worktreePath) try WorktreeManager.deleteBranch(repoPath: repoPath, name: branch) } } catch { diff --git a/Sources/CasperUI/WorkspaceFactory.swift b/Sources/CasperUI/WorkspaceFactory.swift index bcd55c4..a9c37fe 100644 --- a/Sources/CasperUI/WorkspaceFactory.swift +++ b/Sources/CasperUI/WorkspaceFactory.swift @@ -9,13 +9,39 @@ enum WorkspaceFactory { let canonicalPath: String let branch: String let remoteURL: String? + /// The repository's common `.git` directory: the identity shared by its main + /// working tree and every linked worktree, so two folders belong to the same + /// repository exactly when these match. Nil when the prober doesn't report it. + let commonDirPath: String? + /// True when the probed folder is a linked worktree rather than the + /// repository's main working tree. + let isLinkedWorktree: Bool + + init( + canonicalPath: String, branch: String, remoteURL: String?, + commonDirPath: String? = nil, isLinkedWorktree: Bool = false + ) { + self.canonicalPath = canonicalPath + self.branch = branch + self.remoteURL = remoteURL + self.commonDirPath = commonDirPath + self.isLinkedWorktree = isLinkedWorktree + } } static func makeSpace( folderURL: URL, probe: (URL) -> GitInfo?, portBase: Int + ) -> Space { + makeSpace(folderURL: folderURL, info: probe(folderURL), portBase: portBase) + } + + /// Variant taking an already-probed `info`, for callers that inspect the probe + /// result before deciding what to build (see `AppModel.addSpace`, which routes a + /// worktree of an open repository into that repository's Space instead). + static func makeSpace( + folderURL: URL, info: GitInfo?, portBase: Int ) -> Space { let folderPath = folderURL.path - let info = probe(folderURL) let canonical = info?.canonicalPath ?? folderPath let name = SpaceName.derive( remoteURL: info?.remoteURL, folderName: folderURL.lastPathComponent) diff --git a/Tests/CasperCoreTests/WorktreeManagerTests.swift b/Tests/CasperCoreTests/WorktreeManagerTests.swift index bbc24fa..0fcace7 100644 --- a/Tests/CasperCoreTests/WorktreeManagerTests.swift +++ b/Tests/CasperCoreTests/WorktreeManagerTests.swift @@ -130,6 +130,19 @@ final class WorktreeManagerTests: XCTestCase { XCTAssertEqual(listed.map(\.name), ["feature"]) } + func testRegisteredNameResolvesTheAdminEntryByPath() throws { + let wtPath = root.appendingPathComponent("feature").path + _ = try WorktreeManager.create( + repoPath: repoDir.path, name: "feature", worktreePath: wtPath, base: nil) + + XCTAssertEqual( + WorktreeManager.registeredName(repoPath: repoDir.path, worktreePath: wtPath), + "feature") + // A path git knows nothing about — the caller falls back to the branch name. + XCTAssertNil(WorktreeManager.registeredName( + repoPath: repoDir.path, worktreePath: root.appendingPathComponent("ghost").path)) + } + func testRemoveDeletesWorktree() throws { let wtPath = root.appendingPathComponent("feature").path _ = try WorktreeManager.create( diff --git a/Tests/CasperGitTests/WorktreeTests.swift b/Tests/CasperGitTests/WorktreeTests.swift index 3d6f422..2a7324a 100644 --- a/Tests/CasperGitTests/WorktreeTests.swift +++ b/Tests/CasperGitTests/WorktreeTests.swift @@ -85,6 +85,25 @@ final class WorktreeTests: XCTestCase { XCTAssertFalse(try repo.branchExists("blocked")) } + /// The common directory is the repository's identity: a linked worktree reports + /// the SAME one as its main working tree, even though their git dirs differ. + func testWorktreeSharesCommonDirWithMainWorkingTree() throws { + let repo = try GitFixture.repository(at: repoDir.path) + let wtPath = root.appendingPathComponent("feature").path + _ = try repo.addWorktree(name: "feature", atPath: wtPath, basedOn: nil) + + let worktreeRepo = try Repository.open(atPath: wtPath) + + XCTAssertEqual( + URL(fileURLWithPath: worktreeRepo.commonDirPath).standardizedFileURL.path, + URL(fileURLWithPath: repo.commonDirPath).standardizedFileURL.path) + XCTAssertNotEqual( + URL(fileURLWithPath: worktreeRepo.gitDirPath).standardizedFileURL.path, + URL(fileURLWithPath: repo.gitDirPath).standardizedFileURL.path) + XCTAssertTrue(worktreeRepo.isLinkedWorktree) + XCTAssertFalse(repo.isLinkedWorktree) + } + func testPruneRemovesWorktree() throws { let repo = try GitFixture.repository(at: repoDir.path) let wtPath = root.appendingPathComponent("feature").path diff --git a/Tests/CasperUITests/AppModelTests.swift b/Tests/CasperUITests/AppModelTests.swift index 8cc8683..3daaada 100644 --- a/Tests/CasperUITests/AppModelTests.swift +++ b/Tests/CasperUITests/AppModelTests.swift @@ -550,6 +550,271 @@ final class AppModelTests: XCTestCase { XCTAssertEqual(model.spaces.count, 1) } + // MARK: - Adding a folder that is a worktree of an open repository + + /// A worktree of `repo` at a sibling path, registered under `name` on a branch + /// of the same name. Returns its folder URL. + private func makeWorktree(of repo: URL, named name: String) throws -> URL { + let worktree = repo.deletingLastPathComponent() + .appendingPathComponent(repo.lastPathComponent + "-" + name) + addTeardownBlock { try? FileManager.default.removeItem(at: worktree) } + let handle = try Repository.open(atPath: repo.path) + _ = try handle.addWorktree(name: name, atPath: worktree.path, basedOn: nil) + return worktree + } + + func testAddFolderAdoptsWorktreeIntoItsRepositorySpace() throws { + let repo = try makeTempGitRepo() + let worktree = try makeWorktree(of: repo, named: "adopted") + let (store, _) = makeStore() + let model = AppModel(sessionStore: store) + model.addSpace(folderURL: repo, probe: AppModel.gitProbe) + let primary = model.spaces[0].workspaces[0] + + model.addSpace(folderURL: worktree, probe: AppModel.gitProbe) + + XCTAssertEqual(model.spaces.count, 1) // no Space of its own + XCTAssertEqual(model.spaces[0].workspaces.count, 2) + let adopted = model.spaces[0].workspaces[1] + XCTAssertEqual(adopted.kind, .linked) + XCTAssertEqual(adopted.branch, "adopted") + XCTAssertEqual(adopted.name, "adopted") + XCTAssertEqual(adopted.baseBranch, primary.branch) + XCTAssertNotEqual(adopted.portBase, primary.portBase) + XCTAssertEqual( + URL(fileURLWithPath: adopted.worktreePath).resolvingSymlinksInPath().path, + worktree.resolvingSymlinksInPath().path) + XCTAssertEqual(model.selectedWorkspaceID, adopted.id) + // The disk write is backgrounded; flush so the synchronous load is deterministic. + model.flushPendingSave() + XCTAssertEqual(try store.load().spaces.flatMap(\.workspaces).count, 2) + } + + func testAddFolderAdoptsWorktreeIntoCollapsedSpaceAndExpandsIt() throws { + let repo = try makeTempGitRepo() + let worktree = try makeWorktree(of: repo, named: "adopted") + let (store, _) = makeStore() + let model = AppModel(sessionStore: store) + model.addSpace(folderURL: repo, probe: AppModel.gitProbe) + model.toggleSpaceCollapsed(id: model.spaces[0].id) + XCTAssertTrue(model.spaces[0].isCollapsed) + + model.addSpace(folderURL: worktree, probe: AppModel.gitProbe) + + // Selecting the adopted workspace must reveal it. + XCTAssertFalse(model.spaces[0].isCollapsed) + XCTAssertEqual(model.spaces[0].workspaces.count, 2) + } + + func testAddFolderKeepsWorktreeOfUnopenedRepositoryAsItsOwnSpace() throws { + let repo = try makeTempGitRepo() + let worktree = try makeWorktree(of: repo, named: "solo") + let (store, _) = makeStore() + let model = AppModel(sessionStore: store) + + model.addSpace(folderURL: worktree, probe: AppModel.gitProbe) + + XCTAssertEqual(model.spaces.count, 1) + XCTAssertEqual(model.spaces[0].workspaces.count, 1) + XCTAssertEqual(model.spaces[0].workspaces[0].kind, .primary) + } + + func testAddFolderIgnoresRepositoryOpenedInADifferentSession() throws { + let repoA = try makeTempGitRepo() + let repoB = try makeTempGitRepo() + let worktree = try makeWorktree(of: repoB, named: "feature") + let (store, _) = makeStore() + let model = AppModel(sessionStore: store) + model.addSpace(folderURL: repoA, probe: AppModel.gitProbe) + + model.addSpace(folderURL: worktree, probe: AppModel.gitProbe) + + // Another repo's Space must not absorb it. + XCTAssertEqual(model.spaces.count, 2) + XCTAssertTrue(model.spaces.allSatisfy { $0.workspaces.count == 1 }) + } + + func testAddFolderSelectsAnAlreadyAdoptedWorktreeInsteadOfDuplicatingIt() throws { + let repo = try makeTempGitRepo() + let worktree = try makeWorktree(of: repo, named: "adopted") + let (store, _) = makeStore() + let model = AppModel(sessionStore: store) + model.addSpace(folderURL: repo, probe: AppModel.gitProbe) + model.addSpace(folderURL: worktree, probe: AppModel.gitProbe) + let adoptedID = model.spaces[0].workspaces[1].id + model.selectWorkspace(model.spaces[0].workspaces[0].id) + + model.addSpace(folderURL: worktree, probe: AppModel.gitProbe) + + XCTAssertEqual(model.spaces[0].workspaces.count, 2) + XCTAssertEqual(model.selectedWorkspaceID, adoptedID) + } + + func testAddFolderAdoptsWorktreeOfCasperCreatedWorkspaceRepository() throws { + let repo = try makeTempGitRepo() + let (store, _) = makeStore() + let model = AppModel(sessionStore: store) + model.addSpace(folderURL: repo, probe: AppModel.gitProbe) + // A worktree created by Casper, then dropped from the sidebar without + // deleting it on disk: re-adding its folder must bring it back into the + // same Space rather than spawning a second Space for the same repo. + XCTAssertTrue(model.addLinkedWorkspace(spaceID: model.spaces[0].id, name: "feat")) + let linked = model.spaces[0].workspaces[1] + addTeardownBlock { try? FileManager.default.removeItem(atPath: linked.worktreePath) } + model.removeWorkspace(id: linked.id) + XCTAssertEqual(model.spaces[0].workspaces.count, 1) + + model.addSpace(folderURL: URL(fileURLWithPath: linked.worktreePath), probe: AppModel.gitProbe) + + XCTAssertEqual(model.spaces.count, 1) + XCTAssertEqual(model.spaces[0].workspaces.count, 2) + XCTAssertEqual(model.spaces[0].workspaces[1].branch, "feat") + } + + // MARK: - Adding the repository of worktrees already open as Spaces + + func testAddFolderReunifiesAWorktreeSpaceIntoTheRepositorySpace() throws { + let repo = try makeTempGitRepo() + let worktree = try makeWorktree(of: repo, named: "solo") + let (store, _) = makeStore() + let model = AppModel(sessionStore: store) + // The worktree is opened first, so it lands as a Space of its own. + model.addSpace(folderURL: worktree, probe: AppModel.gitProbe) + let stranded = model.spaces[0].workspaces[0] + let strandedSurfaces = LayoutTree.surfaceIDs(stranded.layout) + + model.addSpace(folderURL: repo, probe: AppModel.gitProbe) + + XCTAssertEqual(model.spaces.count, 1) + let space = model.spaces[0] + // The reunified Space roots at the repository, not at the worktree. + XCTAssertEqual( + URL(fileURLWithPath: space.folderPath).resolvingSymlinksInPath().path, + repo.resolvingSymlinksInPath().path) + XCTAssertEqual(space.workspaces.count, 2) + let primary = space.orderedWorkspaces[0] + XCTAssertEqual(primary.kind, .primary) + XCTAssertEqual( + URL(fileURLWithPath: primary.worktreePath).resolvingSymlinksInPath().path, + repo.resolvingSymlinksInPath().path) + let reunified = try XCTUnwrap(space.workspaces.first(where: { $0.id == stranded.id })) + XCTAssertEqual(reunified.kind, .linked) + XCTAssertEqual(reunified.branch, "solo") + // Renamed from the Space name (the repository) to its branch. + XCTAssertEqual(reunified.name, "solo") + XCTAssertEqual(reunified.baseBranch, primary.branch) + // Moved whole: same identity, same port block, same live surfaces. + XCTAssertEqual(reunified.portBase, stranded.portBase) + XCTAssertEqual(LayoutTree.surfaceIDs(reunified.layout), strandedSurfaces) + XCTAssertEqual(model.selectedWorkspaceID, primary.id) + // The disk write is backgrounded; flush so the synchronous load is deterministic. + model.flushPendingSave() + let saved = try store.load() + XCTAssertEqual(saved.spaces.count, 1) + XCTAssertEqual(saved.spaces[0].workspaces.count, 2) + } + + /// A Git Space rooted at `path` with a single primary workspace, for sessions + /// built by hand (a state `addSpace` itself no longer produces). + private func gitSpace(name: String, path: String, branch: String, portBase: Int) -> Space { + let workspace = Workspace( + name: name, worktreePath: path, branch: branch, portBase: portBase, + layout: .leaf(Surface(kind: .terminal(cwd: path))), kind: .primary) + return Space(name: name, folderPath: path, isGitRepo: true, workspaces: [workspace]) + } + + func testAddFolderReunifiesEveryWorktreeSpaceOfTheSameRepository() throws { + let repo = try makeTempGitRepo() + let one = try makeWorktree(of: repo, named: "one") + let two = try makeWorktree(of: repo, named: "two") + let unrelated = try makeTempGitRepo() + // A session from before worktrees were grouped: each worktree of the same + // repository sits in a Space of its own, next to an unrelated repository. + let session = Session(spaces: [ + gitSpace(name: "one", path: one.path, branch: "one", portBase: 41000), + gitSpace(name: "two", path: two.path, branch: "two", portBase: 41010), + gitSpace(name: "other", path: unrelated.path, branch: "main", portBase: 41020), + ]) + let (store, _) = makeStore() + let model = AppModel(sessionStore: store, session: session) + + model.addSpace(folderURL: repo, probe: AppModel.gitProbe) + + // Both worktree Spaces collapse into the repository's; the unrelated + // repository is left alone. + XCTAssertEqual(model.spaces.count, 2) + let space = try XCTUnwrap(model.spaces.first(where: { + URL(fileURLWithPath: $0.folderPath).resolvingSymlinksInPath().path + == repo.resolvingSymlinksInPath().path + })) + XCTAssertEqual(space.workspaces.count, 3) + XCTAssertEqual(space.workspaces.filter { $0.kind == .primary }.count, 1) + XCTAssertEqual( + Set(space.workspaces.filter { $0.kind == .linked }.map(\.branch)), ["one", "two"]) + XCTAssertEqual( + model.spaces.first(where: { $0.id != space.id })?.workspaces.count, 1) + } + + func testAddFolderGroupsWorktreesTogetherEvenBeforeTheirRepositoryIsOpen() throws { + let repo = try makeTempGitRepo() + let one = try makeWorktree(of: repo, named: "one") + let two = try makeWorktree(of: repo, named: "two") + let (store, _) = makeStore() + let model = AppModel(sessionStore: store) + + model.addSpace(folderURL: one, probe: AppModel.gitProbe) + model.addSpace(folderURL: two, probe: AppModel.gitProbe) + + // Same repository, so still a single Space — rooted at the first worktree, + // since only the repository's main working tree can take that place and it + // is not open. + XCTAssertEqual(model.spaces.count, 1) + XCTAssertEqual(model.spaces[0].workspaces.count, 2) + XCTAssertEqual( + URL(fileURLWithPath: model.spaces[0].folderPath).resolvingSymlinksInPath().path, + one.resolvingSymlinksInPath().path) + XCTAssertEqual(model.spaces[0].workspaces[1].branch, "two") + } + + func testReunifiedWorkspacesKeepTheirOwnRecordedBaseBranch() throws { + let repo = try makeTempGitRepo() + let host = try makeWorktree(of: repo, named: "host") + let child = try makeWorktree(of: repo, named: "child") + // A Space rooted at the `host` worktree that already carries a linked + // workspace of its own, forked from `host` rather than from the repository's + // default branch. + let hostWorkspace = Workspace( + name: "host-space", worktreePath: host.path, branch: "host", portBase: 41000, + layout: .leaf(Surface(kind: .terminal(cwd: host.path))), kind: .primary) + let childWorkspace = Workspace( + name: "child", worktreePath: child.path, branch: "child", portBase: 41010, + layout: .leaf(Surface(kind: .terminal(cwd: child.path))), kind: .linked, + baseBranch: "host") + let session = Session(spaces: [ + Space(name: "host-space", folderPath: host.path, isGitRepo: true, + workspaces: [hostWorkspace, childWorkspace]), + ]) + let (store, _) = makeStore() + let model = AppModel(sessionStore: store, session: session) + + model.addSpace(folderURL: repo, probe: AppModel.gitProbe) + + XCTAssertEqual(model.spaces.count, 1) + let space = model.spaces[0] + XCTAssertEqual(space.workspaces.count, 3) + let primary = space.orderedWorkspaces[0] + // The ex-primary inherits the repository's branch as its base… + let reunifiedHost = try XCTUnwrap(space.workspaces.first(where: { $0.id == hostWorkspace.id })) + XCTAssertEqual(reunifiedHost.kind, .linked) + XCTAssertEqual(reunifiedHost.name, "host") + XCTAssertEqual(reunifiedHost.baseBranch, primary.branch) + // …while a workspace that already recorded a base keeps it: that is still + // the branch it forked from and merges back into. + let reunifiedChild = try XCTUnwrap(space.workspaces.first(where: { $0.id == childWorkspace.id })) + XCTAssertEqual(reunifiedChild.kind, .linked) + XCTAssertEqual(reunifiedChild.baseBranch, "host") + } + // MARK: - Promotion on worktree change (degenerate space gaining .git) func testSelectingDegenerateSpaceThatGainedGitPromotesIt() {