Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .superpowers/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<parent>/<repo>-<branch>` (outside the repo, so
naturally untracked — the old in-repo `.casper/worktrees/` layout and its
Expand Down
11 changes: 10 additions & 1 deletion .superpowers/themes/app-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
`<parent>/<repo>-<branch>` (outside the repo, so naturally untracked — no
in-repo `.casper/worktrees/` and no `.git/info/exclude` entry; a `-2`/`-3`…
Expand Down
19 changes: 19 additions & 0 deletions Sources/CasperCore/WorktreeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions Sources/CasperGit/Repository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<common>/worktrees/<name>/`
/// while this stays `<common>/`. 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?
Expand Down
205 changes: 194 additions & 11 deletions Sources/CasperUI/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
28 changes: 27 additions & 1 deletion Sources/CasperUI/WorkspaceFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading