diff --git a/docs-ai/062-workspace-child-diff/000-plan.md b/docs-ai/062-workspace-child-diff/000-plan.md new file mode 100644 index 00000000..68ab6825 --- /dev/null +++ b/docs-ai/062-workspace-child-diff/000-plan.md @@ -0,0 +1,87 @@ +# 062 — Workspace Child Per-Repository Diff: Plan + +| | | +| --- | --- | +| **Status** | Planned | +| **Anchor date** | 2026-08-19 | +| **Primary PRs** | (fill in as they merge) | +| **Related** | [042-project-workspaces](../042-project-workspaces/000-plan.md), `docs/` diff & workspace pages, issue #616 | + +## Background + +Workspace child repositories show live per-repository status — branch, `+N/-M` line +changes, and PR info — but none of the diff entry points work for them (issue #616). +The child rows pass `onDiffTap: nil` (`supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift`), +and the whole diff pipeline is keyed on `Worktree.ID`: the sidebar badge delegate +(`RepositoriesFeature.Delegate.showDiff(Worktree.ID)`), the ⌘⇧Y menu command and the +Command Palette items are all gated on `selectedWorktreeID`, which is `nil` while a +workspace child is selected (selection stays `.repository(workspaceID)` + +`selectedWorkspaceChildID`). This was a deliberate v1 scope cut — children are metadata +entries, not tracked worktrees (see entry 042) — not a regression, but it blocks the +core review flow for multi-repo tasks. + +## Goals + +- Clicking a workspace child's `+N/-M` badge opens the diff for that child repository. +- With a child selected, ⌘⇧Y / "Show Diff" and ⌘⇧U / "Show Outgoing Changes" (menu and + Command Palette) target that child, matching single-repo behavior. +- All configured diff tools behave consistently for children: built-in window, FileMerge, + Kaleidoscope, custom command, and Hunk. +- Hunk runs in the workspace's own terminal (a new tab with the child directory as cwd), + so no terminal state exists outside the workspace's lifecycle. +- Built-in diff supports Outgoing Changes for children, using the child's already-fetched + PR info (`workspaceChildInfoByID`) with the existing base-resolution ladder. + +### Non-goals + +- Promoting workspace children to first-class `Worktree`s (conflicts with the single-host + terminal design from entry 042; not needed for diff). +- Path-validity pre-checks: when a child path is gone or not a git repo, git commands fail + naturally and surface the existing "Unable to open diff" error alert. + +## Design / Approach + +Introduce a resolved diff request type and a stable reference for routing: + +- `DiffTargetID`: `case worktree(Worktree.ID)` | `case workspaceChild(String)` (child id = + working-directory path, same key as `workspaceChildInfoByID`). +- `DiffTarget`: `id`, `workingDirectory` (git dir to diff), `branchName` (window title + + `{branch}` template; fallback live branch → metadata branch → repository name), + `repositoryRootURL` (`{repoPath}` template + `repositorySettings` key), `terminalHost: + Worktree` (Hunk tab host — the synthesized workspace worktree for children), and + `terminalWorkingDirectory: URL?` (Hunk cwd override). + +Wiring: + +- `RepositoriesFeature+StateQueries`: `diffTarget(for: DiffTargetID) -> DiffTarget?` and + `selectedDiffTargetID` (selected worktree, else selected workspace child). +- `RepositoriesFeature.Delegate.showDiff` / `.showOutgoingChanges` carry `DiffTargetID`. +- `WorkspaceChildRowsView` gains an `onDiffTap` per row, wired in `RepositorySectionView`. +- `SidebarCommands` and the Command Palette item builder gate the two view items on + `selectedDiffTargetID` instead of `selectedWorktreeID` (navigation/action items keep the + worktree gate). +- `ExternalDiffToolClient.open` takes a `DiffTarget`; the Hunk case sends + `.createTabWithInput(target.terminalHost, workingDirectory: target.terminalWorkingDirectory, …)` + (parameter already exists on `TerminalClient.Command`). +- `ExternalDiffSnapshotClient` takes the working-directory `URL` (its only input today). +- `OutgoingChangesClient` resolves per `DiffTarget`; its `pullRequestInfo` lookup is keyed + by `DiffTargetID` and wired in `supacodeApp` to `worktreeInfoByID` / + `workspaceChildInfoByID` respectively. Child `repositorySettings` are keyed by the child + root, so a child that is also registered standalone honors its configured base ref. +- `WorktreeRow` renders the `+N/-M` badge as non-interactive (no "Show Diff" help) when + `onDiffTap == nil`, removing the current dead-button affordance. + +## Alternatives & decisions + +- **Synthesize a fake child `Worktree`** instead of `DiffTarget`: less churn, but the two + ID-keyed lookups (PR cache, terminal state) would silently miss or orphan; rejected. +- **Badge-only support** (no ⌘⇧Y/palette): smaller, but leaves inconsistent entry points + that would need a follow-up anyway; rejected with onevcat. +- **Skip Hunk for children**: avoids terminal questions, but silently bypasses the user's + configured tool; rejected — `createTabWithInput`'s `workingDirectory` parameter makes the + consistent behavior cheap. +- **Path pre-checks / disabled states for broken children**: rejected in favor of natural + degradation (no line changes → no badge; explicit invocation → existing error alert). + +## Amendments + diff --git a/docs-ai/README.md b/docs-ai/README.md index e4e43dd5..c13acc79 100644 --- a/docs-ai/README.md +++ b/docs-ai/README.md @@ -113,3 +113,4 @@ agent-facing manual for that). | 059 | [agent-transcript-snapshots](059-agent-transcript-snapshots/000-plan.md) | 2026-08-11 | Immediate Codex/Claude agent snapshots with trustworthy transcript results and actionable blocker text | | 060 | [prowl-cli-targeting-and-contract-governance](060-prowl-cli-targeting-and-contract-governance/000-plan.md) | 2026-08-16 | Unified target grammar, CLI contract rebaseline, and durable documentation governance | | 061 | [native-toolbar-controls](061-native-toolbar-controls/000-plan.md) | 2026-08-17 | Native macOS toolbar grouping, Liquid Glass ownership, and review standards | +| 062 | [workspace-child-diff](062-workspace-child-diff/000-plan.md) | 2026-08-19 | Per-repository diff for workspace children via unified DiffTarget routing | diff --git a/docs/components/diff-view.md b/docs/components/diff-view.md index 0b770c51..4bdbd664 100644 --- a/docs/components/diff-view.md +++ b/docs/components/diff-view.md @@ -16,6 +16,11 @@ fast way to review before committing or merging. **Open:** click a worktree's diff badge, press `⌘⇧Y` (`show_diff`), use Command Palette → "Show Diff", or right-click a worktree row → "Show Diff". +The same entry points work for **workspace child repositories**: the child +row's diff badge, its context menu, and — with a child selected — `⌘⇧Y`, +`⌘⌥⇧Y`, and the Command Palette all target that child's repository. The Hunk +tool opens its tab in the workspace's terminal, rooted at the child folder. + ## Outgoing Changes **Outgoing Changes** is the second mode of the same window: the committed @@ -105,6 +110,8 @@ every changed file. Tracked additions and deletions remain exact. ## Availability Diff is a **git-only** feature — it's unavailable for plain (non-git) folders. +A workspace root is a plain folder, so it has no diff of its own; diff is +available per child repository inside it. ## Gotchas for agents diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 697a3de3..4412318e 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -14,7 +14,8 @@ When you open a workspace in Prowl: - The `prowl` CLI reports the runnable target's `worktree.kind` as `workspace`. - Git worktree, branch, diff, and PR controls remain per-repository features; a workspace is intentionally a multi-repo working directory rather than a single - git repository. + git repository. Diff opens per child repository from its sidebar row (see + below). ## Folder layout @@ -110,7 +111,14 @@ current branch, uncommitted line counts, and pull request badge when available, including immediately after a newly created workspace is opened. Click a child row to select it and focus its terminal tab rooted at that repository folder inside the workspace, creating that tab the first time it is selected. -Right-click a child row for **Copy Path** / **Reveal in Finder**. +Right-click a child row for **Copy Path** / **Reveal in Finder** / +**Show Diff** / **Show Outgoing Changes**. + +Diff works per child repository: click the child's `+N/-M` badge to open the +diff for that repository, or, with a child selected, use `⌘⇧Y` (Show Diff), +`⌘⌥⇧Y` (Show Outgoing Changes), or the matching Command Palette items. All +configured diff tools apply; the Hunk tool opens a workspace terminal tab +rooted at the child folder. See [diff-view](diff-view.md). ## Removing a workspace diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 66836fb9..dde7c359 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -280,8 +280,15 @@ struct SupacodeApp: App { storeBox: SupacodeAppStoreBox ) -> OutgoingChangesClient { .live( - pullRequestInfo: { worktreeID in - storeBox.store?.withState { $0.repositories.worktreeInfo(for: worktreeID)?.pullRequest } ?? nil + pullRequestInfo: { targetID in + storeBox.store?.withState { state in + switch targetID { + case .worktree(let worktreeID): + state.repositories.worktreeInfo(for: worktreeID)?.pullRequest + case .workspaceChild(let childID): + state.repositories.workspaceChildInfoByID[childID]?.pullRequest + } + } ?? nil } ) } diff --git a/supacode/Clients/ExternalDiff/ExternalDiffSnapshotClient.swift b/supacode/Clients/ExternalDiff/ExternalDiffSnapshotClient.swift index c07f4efa..109b9f13 100644 --- a/supacode/Clients/ExternalDiff/ExternalDiffSnapshotClient.swift +++ b/supacode/Clients/ExternalDiff/ExternalDiffSnapshotClient.swift @@ -7,12 +7,12 @@ nonisolated struct ExternalDiffSnapshotPair: Equatable, Sendable { } nonisolated struct ExternalDiffSnapshotClient: Sendable { - var makeSnapshotPair: @Sendable (Worktree) async throws -> ExternalDiffSnapshotPair + var makeSnapshotPair: @Sendable (_ workingDirectory: URL) async throws -> ExternalDiffSnapshotPair } extension ExternalDiffSnapshotClient: DependencyKey { - static let liveValue = ExternalDiffSnapshotClient { worktree in - try await ExternalDiffSnapshotBuilder().makeSnapshotPair(for: worktree) + static let liveValue = ExternalDiffSnapshotClient { workingDirectory in + try await ExternalDiffSnapshotBuilder().makeSnapshotPair(at: workingDirectory) } static let testValue = ExternalDiffSnapshotClient { _ in @@ -31,10 +31,10 @@ extension DependencyValues { } private nonisolated struct ExternalDiffSnapshotBuilder { - func makeSnapshotPair(for worktree: Worktree) async throws -> ExternalDiffSnapshotPair { + func makeSnapshotPair(at workingDirectory: URL) async throws -> ExternalDiffSnapshotPair { let gitClient = GitClient() - async let trackedOutput = gitClient.diffNameStatus(at: worktree.workingDirectory) - async let untrackedPaths = gitClient.untrackedFilePaths(at: worktree.workingDirectory) + async let trackedOutput = gitClient.diffNameStatus(at: workingDirectory) + async let untrackedPaths = gitClient.untrackedFilePaths(at: workingDirectory) let trackedFiles = DiffChangedFile.parseNameStatus(await trackedOutput) let untrackedFiles = await untrackedPaths.map { DiffChangedFile(status: .added, oldPath: nil, newPath: $0) @@ -51,7 +51,7 @@ private nonisolated struct ExternalDiffSnapshotBuilder { try FileManager.default.createDirectory(at: rightURL, withIntermediateDirectories: true) for file in files { - try copySnapshotFile(file, from: worktree.workingDirectory, leftURL: leftURL, rightURL: rightURL) + try copySnapshotFile(file, from: workingDirectory, leftURL: leftURL, rightURL: rightURL) } return ExternalDiffSnapshotPair(leftURL: leftURL, rightURL: rightURL) diff --git a/supacode/Clients/ExternalDiff/ExternalDiffToolClient.swift b/supacode/Clients/ExternalDiff/ExternalDiffToolClient.swift index d8d3effa..7e0fc7f0 100644 --- a/supacode/Clients/ExternalDiff/ExternalDiffToolClient.swift +++ b/supacode/Clients/ExternalDiff/ExternalDiffToolClient.swift @@ -5,14 +5,14 @@ nonisolated struct ExternalDiffToolClient: Sendable { var open: @MainActor @Sendable ( _ settings: ExternalDiffSettings, - _ worktree: Worktree, + _ target: DiffTarget, _ resolvedKeybindings: ResolvedKeybindingMap, _ onError: @escaping @MainActor @Sendable (OpenActionError) -> Void ) async -> Void } extension ExternalDiffToolClient: DependencyKey { - static let liveValue = ExternalDiffToolClient { settings, worktree, resolvedKeybindings, onError in + static let liveValue = ExternalDiffToolClient { settings, target, resolvedKeybindings, onError in @Dependency(TerminalClient.self) var terminalClient @Dependency(ShellClient.self) var shellClient @Dependency(ExternalDiffSnapshotClient.self) var snapshotClient @@ -22,21 +22,28 @@ extension ExternalDiffToolClient: DependencyKey { case .builtIn: @Shared(.settingsFile) var settingsFile DiffWindowManager.shared.show( - worktreeURL: worktree.workingDirectory, - branchName: worktree.name, - outgoingResolver: outgoingChangesClient.makeResolver(worktree), + worktreeURL: target.workingDirectory, + branchName: target.branchName, + outgoingResolver: outgoingChangesClient.makeResolver(target), resolvedKeybindings: resolvedKeybindings, colorScheme: settingsFile.global.appearanceMode.colorScheme ) case .hunk: + // A cwd override means the diff runs somewhere other than the host's own + // directory (a workspace child); name the tab after that repository. + let commandName = + target.terminalWorkingDirectory == nil + ? "Hunk Diff" + : "Hunk Diff · \(target.workingDirectory.lastPathComponent)" await terminalClient.send( .createTabWithInput( - worktree, + target.terminalHost, input: "hunk diff", + workingDirectory: target.terminalWorkingDirectory, runSetupScriptIfNew: false, autoCloseOnSuccess: false, - customCommandName: "Hunk Diff", + customCommandName: commandName, customCommandIcon: "square.split.2x1" ) ) @@ -44,7 +51,7 @@ extension ExternalDiffToolClient: DependencyKey { case .fileMerge: await runGUICommand( ExternalDiffGUICommandRequest(tool: settings.tool, executableName: "opendiff", arguments: []), - worktree: worktree, + target: target, shellClient: shellClient, snapshotClient: snapshotClient, onError: onError @@ -53,7 +60,7 @@ extension ExternalDiffToolClient: DependencyKey { case .kaleidoscope: await runGUICommand( ExternalDiffGUICommandRequest(tool: settings.tool, executableName: "ksdiff", arguments: ["--diff"]), - worktree: worktree, + target: target, shellClient: shellClient, snapshotClient: snapshotClient, onError: onError @@ -62,7 +69,7 @@ extension ExternalDiffToolClient: DependencyKey { case .custom: await runCustomCommand( settings: settings, - worktree: worktree, + target: target, shellClient: shellClient, snapshotClient: snapshotClient, onError: onError @@ -88,13 +95,13 @@ private struct ExternalDiffGUICommandRequest { private func runGUICommand( _ request: ExternalDiffGUICommandRequest, - worktree: Worktree, + target: DiffTarget, shellClient: ShellClient, snapshotClient: ExternalDiffSnapshotClient, onError: @escaping @MainActor @Sendable (OpenActionError) -> Void ) async { do { - let snapshot = try await snapshotClient.makeSnapshotPair(worktree) + let snapshot = try await snapshotClient.makeSnapshotPair(target.workingDirectory) let executableURL = URL(fileURLWithPath: "/usr/bin/env") _ = try await shellClient.runLogin( executableURL, @@ -102,7 +109,7 @@ private func runGUICommand( snapshot.leftURL.path(percentEncoded: false), snapshot.rightURL.path(percentEncoded: false), ], - worktree.workingDirectory + target.workingDirectory ) } catch { onError(openError(for: request.tool, error: error)) @@ -111,7 +118,7 @@ private func runGUICommand( private func runCustomCommand( settings: ExternalDiffSettings, - worktree: Worktree, + target: DiffTarget, shellClient: ShellClient, snapshotClient: ExternalDiffSnapshotClient, onError: @escaping @MainActor @Sendable (OpenActionError) -> Void @@ -127,11 +134,11 @@ private func runCustomCommand( return } do { - let snapshot = try await snapshotClient.makeSnapshotPair(worktree) + let snapshot = try await snapshotClient.makeSnapshotPair(target.workingDirectory) let context = ExternalDiffCommandContext( - worktreePath: worktree.workingDirectory.path(percentEncoded: false), - repoPath: worktree.repositoryRootURL.path(percentEncoded: false), - branch: worktree.name, + worktreePath: target.workingDirectory.path(percentEncoded: false), + repoPath: target.repositoryRootURL.path(percentEncoded: false), + branch: target.branchName, leftPath: snapshot.leftURL.path(percentEncoded: false), rightPath: snapshot.rightURL.path(percentEncoded: false) ) @@ -139,7 +146,7 @@ private func runCustomCommand( _ = try await shellClient.runLogin( URL(fileURLWithPath: "/bin/zsh"), ["-lc", command], - worktree.workingDirectory + target.workingDirectory ) } catch { onError(openError(for: settings.tool, error: error)) diff --git a/supacode/Clients/ExternalDiff/OutgoingChangesClient.swift b/supacode/Clients/ExternalDiff/OutgoingChangesClient.swift index e07f9961..e79d5189 100644 --- a/supacode/Clients/ExternalDiff/OutgoingChangesClient.swift +++ b/supacode/Clients/ExternalDiff/OutgoingChangesClient.swift @@ -11,45 +11,46 @@ typealias OutgoingComparisonResolver = @Sendable () async throws -> GitOutgoingC nonisolated struct OutgoingChangesClient: Sendable { var open: @MainActor @Sendable ( - _ worktree: Worktree, + _ target: DiffTarget, _ resolvedKeybindings: ResolvedKeybindingMap, _ onError: @escaping @MainActor @Sendable (OpenActionError) -> Void ) async -> Void - var makeResolver: @MainActor @Sendable (_ worktree: Worktree) -> OutgoingComparisonResolver + var makeResolver: @MainActor @Sendable (_ target: DiffTarget) -> OutgoingComparisonResolver } extension OutgoingChangesClient { - /// `pullRequestInfo` reads the currently cached pull request for a worktree; - /// the app wires it to live store state so resolvers observe pull request - /// changes that happen after the diff window was opened. + /// `pullRequestInfo` reads the currently cached pull request for a diff + /// target (a worktree or a workspace child); the app wires it to live store + /// state so resolvers observe pull request changes that happen after the + /// diff window was opened. static func live( - pullRequestInfo: @escaping @MainActor @Sendable (Worktree.ID) -> GithubPullRequest? + pullRequestInfo: @escaping @MainActor @Sendable (DiffTargetID) -> GithubPullRequest? ) -> Self { - let makeResolver: @MainActor @Sendable (Worktree) -> OutgoingComparisonResolver = { worktree in + let makeResolver: @MainActor @Sendable (DiffTarget) -> OutgoingComparisonResolver = { target in { - let pullRequest = await pullRequestInfo(worktree.id) + let pullRequest = await pullRequestInfo(target.id) let pullRequestBase = pullRequest.map { GitPullRequestBase(url: $0.url, baseRefName: $0.baseRefName ?? "") } - @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings + @Shared(.repositorySettings(target.repositoryRootURL)) var repositorySettings let gitClient = GitClient() let base = try await gitClient.outgoingBaseResolution( pullRequest: pullRequestBase, configuredBaseRef: repositorySettings.worktreeBaseRef, - in: worktree.workingDirectory + in: target.workingDirectory ) - return try await gitClient.outgoingChangesComparison(base: base, at: worktree.workingDirectory) + return try await gitClient.outgoingChangesComparison(base: base, at: target.workingDirectory) } } return OutgoingChangesClient( - open: { worktree, resolvedKeybindings, onError in - let resolver = makeResolver(worktree) + open: { target, resolvedKeybindings, onError in + let resolver = makeResolver(target) do { let comparison = try await resolver() @Shared(.settingsFile) var settingsFile DiffWindowManager.shared.show( - worktreeURL: worktree.workingDirectory, - branchName: worktree.name, + worktreeURL: target.workingDirectory, + branchName: target.branchName, comparison: .outgoing(comparison), outgoingResolver: resolver, resolvedKeybindings: resolvedKeybindings, diff --git a/supacode/Commands/SidebarCommands.swift b/supacode/Commands/SidebarCommands.swift index d176b448..742eeb75 100644 --- a/supacode/Commands/SidebarCommands.swift +++ b/supacode/Commands/SidebarCommands.swift @@ -77,13 +77,13 @@ struct SidebarCommands: Commands { } .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.showDiff))) .help(helpText(title: "Show Diff", commandID: AppShortcuts.CommandID.showDiff)) - .disabled(store.repositories.selectedWorktreeID == nil) + .disabled(store.repositories.selectedDiffTargetID == nil) Button("Show Outgoing Changes", systemImage: "arrow.up.right") { store.send(.showSelectedWorktreeOutgoingChanges) } .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.outgoingChanges))) .help(helpText(title: "Show Outgoing Changes", commandID: AppShortcuts.CommandID.outgoingChanges)) - .disabled(store.repositories.selectedWorktreeID == nil) + .disabled(store.repositories.selectedDiffTargetID == nil) } } diff --git a/supacode/Domain/DiffTarget.swift b/supacode/Domain/DiffTarget.swift new file mode 100644 index 00000000..3dc6dbce --- /dev/null +++ b/supacode/Domain/DiffTarget.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Stable reference to something the diff pipeline can act on: a tracked +/// worktree, or a workspace child repository keyed by its working-directory +/// path (the same key as `workspaceChildInfoByID`). +nonisolated enum DiffTargetID: Hashable, Sendable { + case worktree(Worktree.ID) + case workspaceChild(String) +} + +/// A resolved diff request. Separates the Git target (the directory whose +/// changes are diffed) from the terminal host (where the Hunk tool creates +/// its tab): for a workspace child the Git target is the child repository +/// while the terminal host stays the workspace, so no terminal state exists +/// outside the workspace's lifecycle. +nonisolated struct DiffTarget: Equatable, Sendable { + let id: DiffTargetID + /// Directory whose working-tree changes are diffed. + let workingDirectory: URL + /// Display branch; also fills `{branch}` in custom diff command templates. + let branchName: String + /// Fills `{repoPath}` in custom templates and keys `repositorySettings`. + let repositoryRootURL: URL + /// Worktree owning the terminal that the Hunk tool runs in. + let terminalHost: Worktree + /// Hunk cwd when it differs from the host's own directory. + let terminalWorkingDirectory: URL? +} + +extension DiffTarget { + init(worktree: Worktree) { + self.init( + id: .worktree(worktree.id), + workingDirectory: worktree.workingDirectory, + branchName: worktree.name, + repositoryRootURL: worktree.repositoryRootURL, + terminalHost: worktree, + terminalWorkingDirectory: nil + ) + } +} diff --git a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift index ae848d2d..45ec47ac 100644 --- a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift +++ b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift @@ -191,7 +191,7 @@ extension AppFeature { } func openDiffEffect( - worktree: Worktree, + target: DiffTarget, resolvedKeybindings: ResolvedKeybindingMap ) -> Effect { @Shared(.settingsFile) var settingsFile @@ -200,35 +200,35 @@ extension AppFeature { customCommand: settingsFile.global.externalDiffCustomCommand ) return .run { send in - await externalDiffToolClient.open(settings, worktree, resolvedKeybindings) { error in + await externalDiffToolClient.open(settings, target, resolvedKeybindings) { error in send(.openWorktreeFailed(error)) } } } func openSelectedWorktreeDiffEffect(state: State) -> Effect { - guard let worktreeID = state.repositories.selectedWorktreeID, - let worktree = state.repositories.worktree(for: worktreeID) + guard let targetID = state.repositories.selectedDiffTargetID, + let target = state.repositories.diffTarget(for: targetID) else { return .none } - return openDiffEffect(worktree: worktree, resolvedKeybindings: state.resolvedKeybindings) + return openDiffEffect(target: target, resolvedKeybindings: state.resolvedKeybindings) } func openSelectedWorktreeOutgoingChangesEffect(state: State) -> Effect { - guard let worktreeID = state.repositories.selectedWorktreeID else { + guard let targetID = state.repositories.selectedDiffTargetID else { return .none } - return openOutgoingChangesEffect(worktreeID: worktreeID, state: state) + return openOutgoingChangesEffect(targetID: targetID, state: state) } - func openOutgoingChangesEffect(worktreeID: Worktree.ID, state: State) -> Effect { - guard let worktree = state.repositories.worktree(for: worktreeID) else { + func openOutgoingChangesEffect(targetID: DiffTargetID, state: State) -> Effect { + guard let target = state.repositories.diffTarget(for: targetID) else { return .none } let resolvedKeybindings = state.resolvedKeybindings return .run { send in - await outgoingChangesClient.open(worktree, resolvedKeybindings) { error in + await outgoingChangesClient.open(target, resolvedKeybindings) { error in send(.openWorktreeFailed(error)) } } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 84a8d3f8..037394f1 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -363,14 +363,14 @@ struct AppFeature { let selection = SettingsSection.repository(repositoryID) return openSettingsEffect(selecting: selection) - case .repositories(.delegate(.showDiff(let worktreeID))): - guard let worktree = state.repositories.worktree(for: worktreeID) else { + case .repositories(.delegate(.showDiff(let targetID))): + guard let target = state.repositories.diffTarget(for: targetID) else { return .none } - return openDiffEffect(worktree: worktree, resolvedKeybindings: state.resolvedKeybindings) + return openDiffEffect(target: target, resolvedKeybindings: state.resolvedKeybindings) - case .repositories(.delegate(.showOutgoingChanges(let worktreeID))): - return openOutgoingChangesEffect(worktreeID: worktreeID, state: state) + case .repositories(.delegate(.showOutgoingChanges(let targetID))): + return openOutgoingChangesEffect(targetID: targetID, state: state) case .settings(.setSelection(let selection)): let resolvedSelection = selection ?? .general diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 2c082225..d95e42c0 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -240,8 +240,12 @@ struct CommandPaletteFeature { items.append(contentsOf: canvasCommandItems()) } let worktreeActionTargetID = actionTargetWorktreeID ?? repositories.selectedWorktreeID - if repositories.selectedWorktreeID != nil { + // Diff view items follow the broader diff target (worktree or workspace + // child); the navigation/action items below stay worktree-scoped. + if repositories.selectedDiffTargetID != nil { items.append(contentsOf: selectedWorktreeViewCommandItems()) + } + if repositories.selectedWorktreeID != nil { items.append(contentsOf: worktreeNavigationCommandItems()) items.append( contentsOf: worktreeActionCommandItems( diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift index 84f69ce4..8996f082 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift @@ -228,6 +228,44 @@ extension RepositoriesFeature.State { } } + /// Resolves a diff entry-point reference to a concrete request. Worktrees + /// diff their own directory and host Hunk themselves; workspace children + /// diff the child repository while hosting Hunk in the workspace's terminal + /// with the child directory as cwd. Child branch falls back live branch → + /// metadata branch → repository name. + func diffTarget(for id: DiffTargetID) -> DiffTarget? { + switch id { + case .worktree(let worktreeID): + return worktree(for: worktreeID).map(DiffTarget.init(worktree:)) + case .workspaceChild(let childID): + guard let child = allResolvedWorkspaceChildren().first(where: { $0.id == childID }), + let workspaceRepository = repositories[id: child.workspaceID] + else { + return nil + } + return DiffTarget( + id: id, + workingDirectory: child.workingDirectory, + branchName: workspaceChildBranchByID[child.id] ?? child.metadataBranch ?? child.repositoryName, + repositoryRootURL: child.workingDirectory, + terminalHost: Self.plainFolderWorktree(for: workspaceRepository), + terminalWorkingDirectory: child.workingDirectory + ) + } + } + + /// The diff target that ⌘⇧Y, the View menu, and the Command Palette act on: + /// the selected worktree, else the selected workspace child. + var selectedDiffTargetID: DiffTargetID? { + if let selectedWorktreeID { + return .worktree(selectedWorktreeID) + } + if let selectedWorkspaceChildID, selectedRepository?.isWorkspace == true { + return .workspaceChild(selectedWorkspaceChildID) + } + return nil + } + struct ArchivedWorktreeGroup: Equatable { var repository: Repository var worktrees: [Worktree] diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index f91dd063..2f05bb51 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -541,8 +541,8 @@ struct RepositoriesFeature { case selectedWorktreeChanged(Worktree?) case repositoriesChanged(IdentifiedArrayOf) case openRepositorySettings(Repository.ID) - case showDiff(Worktree.ID) - case showOutgoingChanges(Worktree.ID) + case showDiff(DiffTargetID) + case showOutgoingChanges(DiffTargetID) case worktreeCreated(Worktree) } diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index 931e110a..550d9e07 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -247,6 +247,12 @@ struct RepositorySectionView: View { ? state.selectedWorkspaceChildID : nil, onSelect: { childID in store.send(.openWorkspaceChild(childID)) + }, + onShowDiff: { childID in + store.send(.delegate(.showDiff(.workspaceChild(childID)))) + }, + onShowOutgoingChanges: { childID in + store.send(.delegate(.showOutgoingChanges(.workspaceChild(childID)))) } ) } else { diff --git a/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift index 0ab1aacb..374abd28 100644 --- a/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift @@ -9,6 +9,8 @@ struct WorkspaceChildRowsView: View { let rows: [WorkspaceChildRowModel] let selectedID: String? let onSelect: (String) -> Void + let onShowDiff: (String) -> Void + let onShowOutgoingChanges: (String) -> Void var body: some View { ForEach(rows) { row in @@ -33,7 +35,7 @@ struct WorkspaceChildRowsView: View { pinAction: nil, isSelected: isSelected, archiveAction: nil, - onDiffTap: nil, + onDiffTap: { onShowDiff(row.id) }, onStopRunScript: nil, ) .padding(.leading, 14) @@ -61,6 +63,15 @@ struct WorkspaceChildRowsView: View { Button("Reveal in Finder") { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: row.workingDirectory.path) } + Divider() + Button("Show Diff") { + onShowDiff(row.id) + } + .help("Show uncommitted changes in \(row.repositoryName)") + Button("Show Outgoing Changes") { + onShowOutgoingChanges(row.id) + } + .help("Show committed changes relative to this repository's base") } .id(row.id) } diff --git a/supacode/Features/Repositories/Views/WorktreeRow.swift b/supacode/Features/Repositories/Views/WorktreeRow.swift index 95dbcec4..a5fd5a97 100644 --- a/supacode/Features/Repositories/Views/WorktreeRow.swift +++ b/supacode/Features/Repositories/Views/WorktreeRow.swift @@ -173,27 +173,35 @@ struct WorktreeRow: View { RunScriptIndicator(onStop: onStopRunScript) } if let lineChangePresentation { - Button { - onDiffTap?() - } label: { + if let onDiffTap { + Button { + onDiffTap() + } label: { + WorktreeRowChangeCountView( + presentation: lineChangePresentation, + isSelected: isSelected, + ) + } + .buttonStyle(.plain) + .help( + [ + AppShortcuts.helpText( + title: "Show Diff", + commandID: AppShortcuts.CommandID.showDiff, + in: resolvedKeybindings + ), + lineChangePresentation.incompleteCountDescription, + ] + .compactMap { $0 } + .joined(separator: "\n") + ) + } else { WorktreeRowChangeCountView( presentation: lineChangePresentation, isSelected: isSelected, ) + .help(lineChangePresentation.incompleteCountDescription ?? "") } - .buttonStyle(.plain) - .help( - [ - AppShortcuts.helpText( - title: "Show Diff", - commandID: AppShortcuts.CommandID.showDiff, - in: resolvedKeybindings - ), - lineChangePresentation.incompleteCountDescription, - ] - .compactMap { $0 } - .joined(separator: "\n") - ) } } WorktreeRowInfoView( diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index 2b2f8cbf..3a6cb3cf 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -241,7 +241,7 @@ struct WorktreeRowsView: View { private func diffTapHandler(for worktreeID: Worktree.ID) -> (() -> Void)? { { - store.send(.delegate(.showDiff(worktreeID))) + store.send(.delegate(.showDiff(.worktree(worktreeID)))) } } @@ -512,11 +512,11 @@ struct WorktreeRowsView: View { } Divider() Button("Show Diff") { - store.send(.delegate(.showDiff(worktree.id))) + store.send(.delegate(.showDiff(.worktree(worktree.id)))) } .help("Show uncommitted changes for this worktree") Button("Show Outgoing Changes") { - store.send(.delegate(.showOutgoingChanges(worktree.id))) + store.send(.delegate(.showOutgoingChanges(.worktree(worktree.id)))) } .help("Show committed changes relative to this worktree's base") Divider() diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index c564792d..2e20691c 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -903,13 +903,13 @@ struct AppFeatureCommandPaletteTests { let settingsFileURL = URL( fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json" ) - let launched = LockIsolated<[(ExternalDiffSettings, Worktree)]>([]) + let launched = LockIsolated<[(ExternalDiffSettings, DiffTarget)]>([]) let store = withDependencies { $0.settingsFileStorage = storage.storage $0.settingsFileURL = settingsFileURL $0.terminalClient.send = { _ in } - $0.externalDiffToolClient.open = { settings, worktree, _, _ in - launched.withValue { $0.append((settings, worktree)) } + $0.externalDiffToolClient.open = { settings, target, _, _ in + launched.withValue { $0.append((settings, target)) } } } operation: { @Shared(.settingsFile) var settingsFile @@ -935,7 +935,7 @@ struct AppFeatureCommandPaletteTests { ) ] ) - #expect(launched.value.map(\.1) == [worktree]) + #expect(launched.value.map(\.1) == [DiffTarget(worktree: worktree)]) } @Test(.dependencies) func showSelectedWorktreeDiffUsesConfiguredExternalDiffTool() async { @@ -955,13 +955,13 @@ struct AppFeatureCommandPaletteTests { let settingsFileURL = URL( fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json" ) - let launched = LockIsolated<[(ExternalDiffSettings, Worktree)]>([]) + let launched = LockIsolated<[(ExternalDiffSettings, DiffTarget)]>([]) let store = withDependencies { $0.settingsFileStorage = storage.storage $0.settingsFileURL = settingsFileURL $0.terminalClient.send = { _ in } - $0.externalDiffToolClient.open = { settings, worktree, _, _ in - launched.withValue { $0.append((settings, worktree)) } + $0.externalDiffToolClient.open = { settings, target, _, _ in + launched.withValue { $0.append((settings, target)) } } } operation: { @Shared(.settingsFile) var settingsFile @@ -987,7 +987,93 @@ struct AppFeatureCommandPaletteTests { ) ] ) - #expect(launched.value.map(\.1) == [worktree]) + #expect(launched.value.map(\.1) == [DiffTarget(worktree: worktree)]) + } + + @Test(.dependencies) func showDiffForWorkspaceChildTargetsChildRepository() async { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath + ) + let workspace = Repository( + id: "/tmp/ws-child-diff", + rootURL: URL(fileURLWithPath: "/tmp/ws-child-diff"), + name: "Workspace", + kind: .plain, + worktrees: [], + workspace: ProjectWorkspace(title: "Workspace", repositories: [entry]) + ) + let childID = entry.resolvedURL(relativeTo: workspace.rootURL).path(percentEncoded: false) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [workspace] + repositoriesState.selection = .repository(workspace.id) + repositoriesState.selectedWorkspaceChildID = childID + repositoriesState.workspaceChildBranchByID[childID] = "feature/child" + + let launched = LockIsolated<[DiffTarget]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.externalDiffToolClient.open = { _, target, _, _ in + launched.withValue { $0.append(target) } + } + } + store.exhaustivity = .off + + // Badge/context-menu route and selection-following shortcut route must + // resolve to the same child target. + await store.send(.repositories(.delegate(.showDiff(.workspaceChild(childID))))) + await store.send(.showSelectedWorktreeDiff) + await store.finish() + + let childURL = URL(fileURLWithPath: childID) + #expect(launched.value.count == 2) + for target in launched.value { + #expect(target.id == .workspaceChild(childID)) + #expect(target.workingDirectory == childURL) + #expect(target.branchName == "feature/child") + #expect(target.repositoryRootURL == childURL) + #expect(target.terminalHost.id == workspace.id) + #expect(target.terminalWorkingDirectory == childURL) + } + } + + @Test func commandPaletteOffersDiffItemsForSelectedWorkspaceChild() { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath + ) + let workspace = Repository( + id: "/tmp/ws-palette-diff", + rootURL: URL(fileURLWithPath: "/tmp/ws-palette-diff"), + name: "Workspace", + kind: .plain, + worktrees: [], + workspace: ProjectWorkspace(title: "Workspace", repositories: [entry]) + ) + let childID = entry.resolvedURL(relativeTo: workspace.rootURL).path(percentEncoded: false) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [workspace] + repositoriesState.selection = .repository(workspace.id) + + // Workspace selected without a child: no diff target, no diff items. + let itemsWithoutChild = CommandPaletteFeature.commandPaletteItems(from: repositoriesState) + #expect(!itemsWithoutChild.contains { $0.kind == .showDiff }) + #expect(!itemsWithoutChild.contains { $0.kind == .outgoingChanges }) + + repositoriesState.selectedWorkspaceChildID = childID + let items = CommandPaletteFeature.commandPaletteItems(from: repositoriesState) + #expect(items.contains { $0.kind == .showDiff }) + #expect(items.contains { $0.kind == .outgoingChanges }) } @Test(.dependencies) func outgoingChangesAlwaysUsesBuiltInClient() async { @@ -1025,7 +1111,7 @@ struct AppFeatureCommandPaletteTests { #expect( CommandPaletteFeature.commandPaletteItems(from: repositoriesState).contains { $0.kind == .outgoingChanges } ) - let outgoingRequests = LockIsolated<[Worktree]>([]) + let outgoingRequests = LockIsolated<[DiffTarget]>([]) let externalRequests = LockIsolated(0) let store = TestStore( initialState: AppFeature.State( @@ -1035,8 +1121,8 @@ struct AppFeatureCommandPaletteTests { ) { AppFeature() } withDependencies: { - $0.outgoingChangesClient.open = { worktree, _, _ in - outgoingRequests.withValue { $0.append(worktree) } + $0.outgoingChangesClient.open = { target, _, _ in + outgoingRequests.withValue { $0.append(target) } } $0.externalDiffToolClient.open = { _, _, _, _ in externalRequests.withValue { $0 += 1 } @@ -1048,7 +1134,7 @@ struct AppFeatureCommandPaletteTests { await store.finish() let requests = outgoingRequests.value - #expect(requests == [worktree, worktree]) + #expect(requests == [DiffTarget(worktree: worktree), DiffTarget(worktree: worktree)]) #expect(externalRequests.value == 0) } @@ -1062,7 +1148,7 @@ struct AppFeatureCommandPaletteTests { var repositoriesState = RepositoriesFeature.State() repositoriesState.repositories = [repository] repositoriesState.selection = .worktree(worktree.id) - let outgoingRequests = LockIsolated<[Worktree]>([]) + let outgoingRequests = LockIsolated<[DiffTarget]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -1071,15 +1157,15 @@ struct AppFeatureCommandPaletteTests { ) { AppFeature() } withDependencies: { - $0.outgoingChangesClient.open = { worktree, _, _ in - outgoingRequests.withValue { $0.append(worktree) } + $0.outgoingChangesClient.open = { target, _, _ in + outgoingRequests.withValue { $0.append(target) } } } await store.send(.showSelectedWorktreeOutgoingChanges) await store.finish() - #expect(outgoingRequests.value == [worktree]) + #expect(outgoingRequests.value == [DiffTarget(worktree: worktree)]) #expect(store.state.alert == nil) } @@ -1133,7 +1219,7 @@ struct AppFeatureCommandPaletteTests { var repositoriesState = RepositoriesFeature.State() repositoriesState.repositories = [repository] repositoriesState.selection = .worktree(selected.id) - let outgoingRequests = LockIsolated<[Worktree]>([]) + let outgoingRequests = LockIsolated<[DiffTarget]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -1142,16 +1228,16 @@ struct AppFeatureCommandPaletteTests { ) { AppFeature() } withDependencies: { - $0.outgoingChangesClient.open = { worktree, _, _ in - outgoingRequests.withValue { $0.append(worktree) } + $0.outgoingChangesClient.open = { target, _, _ in + outgoingRequests.withValue { $0.append(target) } } } store.exhaustivity = .off - await store.send(.repositories(.delegate(.showOutgoingChanges(targeted.id)))) + await store.send(.repositories(.delegate(.showOutgoingChanges(.worktree(targeted.id))))) await store.finish() - #expect(outgoingRequests.value == [targeted]) + #expect(outgoingRequests.value == [DiffTarget(worktree: targeted)]) } @Test(.dependencies) func closePullRequestDispatchesAction() async { diff --git a/supacodeTests/ExternalDiffToolTests.swift b/supacodeTests/ExternalDiffToolTests.swift index e8b11bda..4ff9f76c 100644 --- a/supacodeTests/ExternalDiffToolTests.swift +++ b/supacodeTests/ExternalDiffToolTests.swift @@ -72,7 +72,7 @@ struct ExternalDiffToolTests { } operation: { await ExternalDiffToolClient.liveValue.open( ExternalDiffSettings(toolID: ExternalDiffTool.hunk.settingsID, customCommand: ""), - worktree, + DiffTarget(worktree: worktree), .appDefaults ) { _ in } } @@ -91,6 +91,52 @@ struct ExternalDiffToolTests { ) } + @Test func hunkForWorkspaceChildRunsInWorkspaceTerminalWithChildCwd() async { + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let workspaceWorktree = Worktree( + id: "/tmp/workspace", + name: "Workspace", + detail: "/tmp/workspace", + workingDirectory: URL(fileURLWithPath: "/tmp/workspace"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/workspace") + ) + let childURL = URL(fileURLWithPath: "/tmp/workspace/app") + let target = DiffTarget( + id: .workspaceChild(childURL.path(percentEncoded: false)), + workingDirectory: childURL, + branchName: "feature", + repositoryRootURL: childURL, + terminalHost: workspaceWorktree, + terminalWorkingDirectory: childURL + ) + + await withDependencies { + $0.terminalClient.send = { command in + sentCommands.withValue { $0.append(command) } + } + } operation: { + await ExternalDiffToolClient.liveValue.open( + ExternalDiffSettings(toolID: ExternalDiffTool.hunk.settingsID, customCommand: ""), + target, + .appDefaults + ) { _ in } + } + + #expect( + sentCommands.value == [ + .createTabWithInput( + workspaceWorktree, + input: "hunk diff", + workingDirectory: childURL, + runSetupScriptIfNew: false, + autoCloseOnSuccess: false, + customCommandName: "Hunk Diff · app", + customCommandIcon: "square.split.2x1" + ) + ] + ) + } + @Test func customCommandRunsRenderedShellCommandInWorktree() async throws { let runs = LockIsolated<[ShellRun]>([]) let worktree = Worktree( @@ -119,7 +165,7 @@ struct ExternalDiffToolTests { toolID: ExternalDiffTool.custom.settingsID, customCommand: "my-diff {leftPath} {rightPath} --repo {repoPath}" ), - worktree, + DiffTarget(worktree: worktree), .appDefaults ) { _ in } } @@ -144,15 +190,7 @@ struct ExternalDiffToolTests { try "two\n".write(to: repoURL.appending(path: "tracked.txt"), atomically: true, encoding: .utf8) try "new\n".write(to: repoURL.appending(path: "untracked.txt"), atomically: true, encoding: .utf8) - let worktree = Worktree( - id: repoURL.path(percentEncoded: false), - name: "main", - detail: "main", - workingDirectory: repoURL, - repositoryRootURL: repoURL - ) - - let snapshot = try await ExternalDiffSnapshotClient.liveValue.makeSnapshotPair(worktree) + let snapshot = try await ExternalDiffSnapshotClient.liveValue.makeSnapshotPair(repoURL) #expect(try String(contentsOf: snapshot.leftURL.appending(path: "tracked.txt"), encoding: .utf8) == "one\n") #expect(try String(contentsOf: snapshot.rightURL.appending(path: "tracked.txt"), encoding: .utf8) == "two\n") diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 86be46b2..1b69ad7c 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -7705,6 +7705,100 @@ struct RepositoriesFeatureTests { #expect(rows.first?.info == nil) } + @Test func diffTargetForWorkspaceChildDiffsChildRepoAndHostsHunkInWorkspace() { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath, + branchName: "metadata-branch" + ) + let repository = makeWorkspaceRepository(id: "/tmp/ws-diff", children: [entry]) + var state = makeState(repositories: [repository]) + let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) + state.workspaceChildBranchByID[childID] = "live-branch" + + let target = state.diffTarget(for: .workspaceChild(childID)) + + let childURL = URL(fileURLWithPath: childID) + #expect(target?.id == .workspaceChild(childID)) + #expect(target?.workingDirectory == childURL) + // Live branch wins over the metadata branch. + #expect(target?.branchName == "live-branch") + #expect(target?.repositoryRootURL == childURL) + // Hunk stays hosted in the workspace terminal, running in the child dir. + #expect(target?.terminalHost.id == repository.id) + #expect(target?.terminalHost.workingDirectory == repository.rootURL) + #expect(target?.terminalWorkingDirectory == childURL) + } + + @Test func diffTargetForWorkspaceChildFallsBackBranchToMetadataThenRepositoryName() { + let withMetadataBranch = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath, + branchName: "metadata-branch" + ) + let withoutAnyBranch = ProjectWorkspace.RepositoryEntry( + id: "api", + name: "Api", + path: "api", + sourceKind: .existingPath + ) + let repository = makeWorkspaceRepository( + id: "/tmp/ws-diff-fallback", + children: [withMetadataBranch, withoutAnyBranch] + ) + let state = makeState(repositories: [repository]) + let metadataChildID = withMetadataBranch.resolvedURL(relativeTo: repository.rootURL) + .path(percentEncoded: false) + let namelessChildID = withoutAnyBranch.resolvedURL(relativeTo: repository.rootURL) + .path(percentEncoded: false) + + #expect(state.diffTarget(for: .workspaceChild(metadataChildID))?.branchName == "metadata-branch") + #expect(state.diffTarget(for: .workspaceChild(namelessChildID))?.branchName == "Api") + } + + @Test func diffTargetResolvesWorktreesAndRejectsUnknownChildren() { + let worktree = makeWorktree(id: "/tmp/repo-dt/wt", name: "feature") + let repository = makeRepository(id: "/tmp/repo-dt", worktrees: [worktree]) + let state = makeState(repositories: [repository]) + + #expect(state.diffTarget(for: .worktree(worktree.id)) == DiffTarget(worktree: worktree)) + #expect(state.diffTarget(for: .worktree("/tmp/missing")) == nil) + #expect(state.diffTarget(for: .workspaceChild("/tmp/missing")) == nil) + } + + @Test func selectedDiffTargetIDFollowsWorktreeThenWorkspaceChild() { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath + ) + let workspace = makeWorkspaceRepository(id: "/tmp/ws-selected", children: [entry]) + let worktree = makeWorktree(id: "/tmp/repo-sel/wt", name: "alpha") + let repository = makeRepository(id: "/tmp/repo-sel", worktrees: [worktree]) + var state = makeState(repositories: [repository, workspace]) + let childID = entry.resolvedURL(relativeTo: workspace.rootURL).path(percentEncoded: false) + + state.selection = .worktree(worktree.id) + #expect(state.selectedDiffTargetID == .worktree(worktree.id)) + + state.selection = .repository(workspace.id) + state.selectedWorkspaceChildID = childID + #expect(state.selectedDiffTargetID == .workspaceChild(childID)) + + // A stale child id must not leak once a non-workspace repository is selected. + state.selection = .repository(repository.id) + #expect(state.selectedDiffTargetID == nil) + + state.selection = .repository(workspace.id) + state.selectedWorkspaceChildID = nil + #expect(state.selectedDiffTargetID == nil) + } + @Test func openWorkspaceChildFocusesOrCreatesBoundTerminalTabInChildDirectory() async { let entry = ProjectWorkspace.RepositoryEntry( id: "app", -- 2.54.0 From 010022c6953eed32127a2e71c672721d6110bb68 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 19 Aug 2026 21:58:56 +0900 Subject: [PATCH 2/5] Record docs-ai action log for workspace child diff Claude-Session: https://claude.ai/code/session_01WoqhZai4i4izqbUZLtbjde --- docs-ai/062-workspace-child-diff/000-plan.md | 4 +- .../062-workspace-child-diff/001-action.md | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 docs-ai/062-workspace-child-diff/001-action.md diff --git a/docs-ai/062-workspace-child-diff/000-plan.md b/docs-ai/062-workspace-child-diff/000-plan.md index 68ab6825..74886544 100644 --- a/docs-ai/062-workspace-child-diff/000-plan.md +++ b/docs-ai/062-workspace-child-diff/000-plan.md @@ -2,9 +2,9 @@ | | | | --- | --- | -| **Status** | Planned | +| **Status** | Implemented | | **Anchor date** | 2026-08-19 | -| **Primary PRs** | (fill in as they merge) | +| **Primary PRs** | #704 | | **Related** | [042-project-workspaces](../042-project-workspaces/000-plan.md), `docs/` diff & workspace pages, issue #616 | ## Background diff --git a/docs-ai/062-workspace-child-diff/001-action.md b/docs-ai/062-workspace-child-diff/001-action.md new file mode 100644 index 00000000..1a6605ed --- /dev/null +++ b/docs-ai/062-workspace-child-diff/001-action.md @@ -0,0 +1,43 @@ +# 062 — Workspace Child Per-Repository Diff: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-08-19 | Plan aligned with onevcat (⌘⇧Y follows selected child; Outgoing Changes included; Hunk runs in workspace terminal with child cwd; natural degradation, no pre-checks) | tracker relay-tracker#154, issue #616 | +| 2026-08-19 | Implemented `DiffTarget` routing end to end, with tests and `docs/` updates | #704 | + +## Outcome & current state (as of 2026-08-19) + +- `supacode/Domain/DiffTarget.swift` — `DiffTargetID` (`worktree` / `workspaceChild`, + child keyed by working-directory path) and `DiffTarget` (git `workingDirectory`, + `branchName`, `repositoryRootURL`, `terminalHost`, `terminalWorkingDirectory`), plus + `DiffTarget(worktree:)`. +- `supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift` — + `diffTarget(for:)` (child branch falls back live → metadata → repository name; terminal + host is the synthesized workspace worktree via `plainFolderWorktree(for:)`) and + `selectedDiffTargetID` (selected worktree, else selected workspace child). +- `RepositoriesFeature.Delegate.showDiff` / `.showOutgoingChanges` carry `DiffTargetID`; + `WorkspaceChildRowsView` + `RepositorySectionView` wire the child badge and new + **Show Diff** / **Show Outgoing Changes** context-menu items. +- `AppFeature` / `AppFeature+CommandPalette` resolve targets through `diffTarget(for:)`; + `SidebarCommands` and the palette item builder gate the two diff view items on + `selectedDiffTargetID` (navigation/action items keep the worktree gate). +- `ExternalDiffToolClient` and `OutgoingChangesClient` take `DiffTarget`; + `ExternalDiffSnapshotClient` takes the working-directory `URL`. Hunk sends + `.createTabWithInput(terminalHost, workingDirectory: terminalWorkingDirectory, …)` and + names the tab `Hunk Diff · ` when a cwd override is present. The + `pullRequestInfo` lookup in `supacodeApp.swift` dispatches on `DiffTargetID` + (`worktreeInfoByID` / `workspaceChildInfoByID`). +- `WorktreeRow` renders the `+N/-M` badge as plain text (no button, no "Show Diff" help) + when `onDiffTap` is nil. +- Docs: `docs/components/workspaces.md` and `docs/components/diff-view.md` describe the + child entry points and the workspace-root availability rule. + +## Deviations from plan + +None known. + +## Open questions + +None. -- 2.54.0 From 6f629ab95f0934c543b3129571a0dcb33583753a Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 20 Aug 2026 02:14:50 +0900 Subject: [PATCH 3/5] Scope child diff targets by workspace and root them at the source repo Review findings on the DiffTarget routing: - DiffTargetID.workspaceChild carried only a path, but workspace metadata does not enforce path uniqueness: two open workspaces referencing the same child path resolved to the first workspace's metadata, sending Hunk to the wrong terminal. The case now carries the workspace ID and diffTarget(for:) resolves within that workspace only. - A child's repositoryRootURL was its workspace-local checkout, so linked and worktree children missed the source repository's configured worktreeBaseRef and received the checkout path as {repoPath}. It now uses the metadata-recorded local source root (ProjectWorkspaceRepositoryEntry.localSourceURL), falling back to the checkout for remote clones and metadata without a source location. Chosen over runtime git-root resolution to keep the state query synchronous with no extra cached state. - The outgoing pull-request cache dispatch moved from an app-level closure into a testable RepositoriesFeature.State.pullRequest(for:) query, and child Outgoing Changes routing gained reducer coverage. Claude-Session: https://claude.ai/code/session_01WoqhZai4i4izqbUZLtbjde --- .../062-workspace-child-diff/001-action.md | 22 ++-- supacode/App/supacodeApp.swift | 9 +- supacode/Domain/DiffTarget.swift | 8 +- supacode/Domain/ProjectWorkspace.swift | 7 ++ .../RepositoriesFeature+StateQueries.swift | 36 ++++-- ...epositoriesFeature+WorkspaceChildren.swift | 6 + .../Views/RepositorySectionView.swift | 5 +- .../AppFeatureCommandPaletteTests.swift | 59 ++++++++- supacodeTests/ExternalDiffToolTests.swift | 2 +- supacodeTests/RepositoriesFeatureTests.swift | 114 +++++++++++++++++- 10 files changed, 229 insertions(+), 39 deletions(-) diff --git a/docs-ai/062-workspace-child-diff/001-action.md b/docs-ai/062-workspace-child-diff/001-action.md index 1a6605ed..f38932e0 100644 --- a/docs-ai/062-workspace-child-diff/001-action.md +++ b/docs-ai/062-workspace-child-diff/001-action.md @@ -6,17 +6,25 @@ | --- | --- | --- | | 2026-08-19 | Plan aligned with onevcat (⌘⇧Y follows selected child; Outgoing Changes included; Hunk runs in workspace terminal with child cwd; natural degradation, no pre-checks) | tracker relay-tracker#154, issue #616 | | 2026-08-19 | Implemented `DiffTarget` routing end to end, with tests and `docs/` updates | #704 | +| 2026-08-20 | Review round (pi agent): scoped child targets by workspace, child `repositoryRootURL` follows recorded source root, added child outgoing coverage | #704 | ## Outcome & current state (as of 2026-08-19) -- `supacode/Domain/DiffTarget.swift` — `DiffTargetID` (`worktree` / `workspaceChild`, - child keyed by working-directory path) and `DiffTarget` (git `workingDirectory`, - `branchName`, `repositoryRootURL`, `terminalHost`, `terminalWorkingDirectory`), plus - `DiffTarget(worktree:)`. +- `supacode/Domain/DiffTarget.swift` — `DiffTargetID` (`worktree` / + `workspaceChild(workspaceID:path:)` — children are scoped by workspace because metadata + does not enforce path uniqueness across workspaces) and `DiffTarget` (git + `workingDirectory`, `branchName`, `repositoryRootURL`, `terminalHost`, + `terminalWorkingDirectory`), plus `DiffTarget(worktree:)`. - `supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift` — - `diffTarget(for:)` (child branch falls back live → metadata → repository name; terminal - host is the synthesized workspace worktree via `plainFolderWorktree(for:)`) and - `selectedDiffTargetID` (selected worktree, else selected workspace child). + `diffTarget(for:)` (resolves within the named workspace; child branch falls back live → + metadata → repository name; terminal host is the synthesized workspace worktree via + `plainFolderWorktree(for:)`), `selectedDiffTargetID` (selected worktree, else selected + workspace child), and `pullRequest(for:)` (per-target PR cache dispatch, wired from + `supacodeApp`). A child's `repositoryRootURL` is the metadata-recorded local source root + (`ProjectWorkspaceRepositoryEntry.localSourceURL`) so linked/worktree checkouts pick up + the source repository's `repositorySettings` and `{repoPath}`; remote clones and + metadata without a source location fall back to the checkout directory. Chosen over + runtime `git` root resolution to keep the query synchronous with no extra cached state. - `RepositoriesFeature.Delegate.showDiff` / `.showOutgoingChanges` carry `DiffTargetID`; `WorkspaceChildRowsView` + `RepositorySectionView` wire the child badge and new **Show Diff** / **Show Outgoing Changes** context-menu items. diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index dde7c359..e29511b2 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -281,14 +281,7 @@ struct SupacodeApp: App { ) -> OutgoingChangesClient { .live( pullRequestInfo: { targetID in - storeBox.store?.withState { state in - switch targetID { - case .worktree(let worktreeID): - state.repositories.worktreeInfo(for: worktreeID)?.pullRequest - case .workspaceChild(let childID): - state.repositories.workspaceChildInfoByID[childID]?.pullRequest - } - } ?? nil + storeBox.store?.withState { $0.repositories.pullRequest(for: targetID) } ?? nil } ) } diff --git a/supacode/Domain/DiffTarget.swift b/supacode/Domain/DiffTarget.swift index 3dc6dbce..bdaa24ff 100644 --- a/supacode/Domain/DiffTarget.swift +++ b/supacode/Domain/DiffTarget.swift @@ -1,11 +1,13 @@ import Foundation /// Stable reference to something the diff pipeline can act on: a tracked -/// worktree, or a workspace child repository keyed by its working-directory -/// path (the same key as `workspaceChildInfoByID`). +/// worktree, or a workspace child repository. Children are scoped by their +/// workspace because metadata does not enforce path uniqueness — two open +/// workspaces may reference the same child path. `path` is the child's +/// working-directory path (the same key as `workspaceChildInfoByID`). nonisolated enum DiffTargetID: Hashable, Sendable { case worktree(Worktree.ID) - case workspaceChild(String) + case workspaceChild(workspaceID: Repository.ID, path: String) } /// A resolved diff request. Separates the Git target (the directory whose diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift index 881e337b..2abfbdea 100644 --- a/supacode/Domain/ProjectWorkspace.swift +++ b/supacode/Domain/ProjectWorkspace.swift @@ -362,6 +362,13 @@ nonisolated struct ProjectWorkspaceRepositoryEntry: Codable, Equatable, Hashable } 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 { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift index 8996f082..f6c27c85 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift @@ -204,7 +204,8 @@ extension RepositoriesFeature.State { workspaceID: repository.id, repositoryName: entry.name, metadataBranch: entry.branchName, - workingDirectory: url + workingDirectory: url, + repositoryRootURL: entry.localSourceURL ?? url ) } } @@ -230,16 +231,16 @@ extension RepositoriesFeature.State { /// Resolves a diff entry-point reference to a concrete request. Worktrees /// diff their own directory and host Hunk themselves; workspace children - /// diff the child repository while hosting Hunk in the workspace's terminal - /// with the child directory as cwd. Child branch falls back live branch → - /// metadata branch → repository name. + /// diff the child repository while hosting Hunk in their own workspace's + /// terminal with the child directory as cwd. Child branch falls back live + /// branch → metadata branch → repository name. func diffTarget(for id: DiffTargetID) -> DiffTarget? { switch id { case .worktree(let worktreeID): return worktree(for: worktreeID).map(DiffTarget.init(worktree:)) - case .workspaceChild(let childID): - guard let child = allResolvedWorkspaceChildren().first(where: { $0.id == childID }), - let workspaceRepository = repositories[id: child.workspaceID] + case .workspaceChild(let workspaceID, let path): + guard let workspaceRepository = repositories[id: workspaceID], + let child = resolvedWorkspaceChildren(in: workspaceRepository).first(where: { $0.id == path }) else { return nil } @@ -247,7 +248,7 @@ extension RepositoriesFeature.State { id: id, workingDirectory: child.workingDirectory, branchName: workspaceChildBranchByID[child.id] ?? child.metadataBranch ?? child.repositoryName, - repositoryRootURL: child.workingDirectory, + repositoryRootURL: child.repositoryRootURL, terminalHost: Self.plainFolderWorktree(for: workspaceRepository), terminalWorkingDirectory: child.workingDirectory ) @@ -260,12 +261,27 @@ extension RepositoriesFeature.State { if let selectedWorktreeID { return .worktree(selectedWorktreeID) } - if let selectedWorkspaceChildID, selectedRepository?.isWorkspace == true { - return .workspaceChild(selectedWorkspaceChildID) + if let selectedWorkspaceChildID, + let repository = selectedRepository, repository.isWorkspace + { + return .workspaceChild(workspaceID: repository.id, path: selectedWorkspaceChildID) } return nil } + /// The cached pull request for a diff target. Worktrees read the worktree + /// info cache; workspace children read the per-child cache, which is keyed + /// by child path. The app wires the outgoing-changes resolver through this + /// so an open diff window observes pull request changes. + func pullRequest(for targetID: DiffTargetID) -> GithubPullRequest? { + switch targetID { + case .worktree(let worktreeID): + return worktreeInfo(for: worktreeID)?.pullRequest + case .workspaceChild(_, let path): + return workspaceChildInfoByID[path]?.pullRequest + } + } + struct ArchivedWorktreeGroup: Equatable { var repository: Repository var worktrees: [Worktree] diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift index e6556eea..7a77b417 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift @@ -9,6 +9,12 @@ struct ResolvedWorkspaceChild: Equatable, Sendable, Identifiable { let repositoryName: String let metadataBranch: String? let workingDirectory: URL + /// The child's canonical repository root: the recorded local source for + /// linked and worktree checkouts, else the working directory itself (remote + /// clones, metadata without a source location). Keys `repositorySettings` + /// and fills `{repoPath}`, matching how `Worktree.repositoryRootURL` points + /// at the registered repository rather than the checkout. + let repositoryRootURL: URL } extension RepositoriesFeature { diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index 550d9e07..8482cbc9 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -249,10 +249,11 @@ struct RepositorySectionView: View { store.send(.openWorkspaceChild(childID)) }, onShowDiff: { childID in - store.send(.delegate(.showDiff(.workspaceChild(childID)))) + store.send(.delegate(.showDiff(.workspaceChild(workspaceID: repository.id, path: childID)))) }, onShowOutgoingChanges: { childID in - store.send(.delegate(.showOutgoingChanges(.workspaceChild(childID)))) + store.send( + .delegate(.showOutgoingChanges(.workspaceChild(workspaceID: repository.id, path: childID)))) } ) } else { diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 2e20691c..4473039e 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -1029,14 +1029,16 @@ struct AppFeatureCommandPaletteTests { // Badge/context-menu route and selection-following shortcut route must // resolve to the same child target. - await store.send(.repositories(.delegate(.showDiff(.workspaceChild(childID))))) + await store.send( + .repositories(.delegate(.showDiff(.workspaceChild(workspaceID: workspace.id, path: childID)))) + ) await store.send(.showSelectedWorktreeDiff) await store.finish() let childURL = URL(fileURLWithPath: childID) #expect(launched.value.count == 2) for target in launched.value { - #expect(target.id == .workspaceChild(childID)) + #expect(target.id == .workspaceChild(workspaceID: workspace.id, path: childID)) #expect(target.workingDirectory == childURL) #expect(target.branchName == "feature/child") #expect(target.repositoryRootURL == childURL) @@ -1045,6 +1047,59 @@ struct AppFeatureCommandPaletteTests { } } + @Test(.dependencies) func outgoingChangesForWorkspaceChildTargetsChildRepository() async { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath + ) + let workspace = Repository( + id: "/tmp/ws-child-outgoing", + rootURL: URL(fileURLWithPath: "/tmp/ws-child-outgoing"), + name: "Workspace", + kind: .plain, + worktrees: [], + workspace: ProjectWorkspace(title: "Workspace", repositories: [entry]) + ) + let childID = entry.resolvedURL(relativeTo: workspace.rootURL).path(percentEncoded: false) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [workspace] + repositoriesState.selection = .repository(workspace.id) + repositoriesState.selectedWorkspaceChildID = childID + + let outgoingRequests = LockIsolated<[DiffTarget]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.outgoingChangesClient.open = { target, _, _ in + outgoingRequests.withValue { $0.append(target) } + } + } + store.exhaustivity = .off + + // Row context-menu delegate and selection-following shortcut/palette + // route must both reach the outgoing client with the child target. + await store.send( + .repositories( + .delegate(.showOutgoingChanges(.workspaceChild(workspaceID: workspace.id, path: childID))) + ) + ) + await store.send(.showSelectedWorktreeOutgoingChanges) + await store.finish() + + #expect(outgoingRequests.value.count == 2) + for target in outgoingRequests.value { + #expect(target.id == .workspaceChild(workspaceID: workspace.id, path: childID)) + #expect(target.workingDirectory == URL(fileURLWithPath: childID)) + } + } + @Test func commandPaletteOffersDiffItemsForSelectedWorkspaceChild() { let entry = ProjectWorkspace.RepositoryEntry( id: "app", diff --git a/supacodeTests/ExternalDiffToolTests.swift b/supacodeTests/ExternalDiffToolTests.swift index 4ff9f76c..56684364 100644 --- a/supacodeTests/ExternalDiffToolTests.swift +++ b/supacodeTests/ExternalDiffToolTests.swift @@ -102,7 +102,7 @@ struct ExternalDiffToolTests { ) let childURL = URL(fileURLWithPath: "/tmp/workspace/app") let target = DiffTarget( - id: .workspaceChild(childURL.path(percentEncoded: false)), + id: .workspaceChild(workspaceID: "/tmp/workspace", path: childURL.path(percentEncoded: false)), workingDirectory: childURL, branchName: "feature", repositoryRootURL: childURL, diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 1b69ad7c..4d7c675f 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -7718,10 +7718,10 @@ struct RepositoriesFeatureTests { let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) state.workspaceChildBranchByID[childID] = "live-branch" - let target = state.diffTarget(for: .workspaceChild(childID)) + let target = state.diffTarget(for: .workspaceChild(workspaceID: repository.id, path: childID)) let childURL = URL(fileURLWithPath: childID) - #expect(target?.id == .workspaceChild(childID)) + #expect(target?.id == .workspaceChild(workspaceID: repository.id, path: childID)) #expect(target?.workingDirectory == childURL) // Live branch wins over the metadata branch. #expect(target?.branchName == "live-branch") @@ -7756,8 +7756,16 @@ struct RepositoriesFeatureTests { let namelessChildID = withoutAnyBranch.resolvedURL(relativeTo: repository.rootURL) .path(percentEncoded: false) - #expect(state.diffTarget(for: .workspaceChild(metadataChildID))?.branchName == "metadata-branch") - #expect(state.diffTarget(for: .workspaceChild(namelessChildID))?.branchName == "Api") + #expect( + state.diffTarget( + for: .workspaceChild(workspaceID: repository.id, path: metadataChildID) + )?.branchName == "metadata-branch" + ) + #expect( + state.diffTarget( + for: .workspaceChild(workspaceID: repository.id, path: namelessChildID) + )?.branchName == "Api" + ) } @Test func diffTargetResolvesWorktreesAndRejectsUnknownChildren() { @@ -7767,7 +7775,101 @@ struct RepositoriesFeatureTests { #expect(state.diffTarget(for: .worktree(worktree.id)) == DiffTarget(worktree: worktree)) #expect(state.diffTarget(for: .worktree("/tmp/missing")) == nil) - #expect(state.diffTarget(for: .workspaceChild("/tmp/missing")) == nil) + #expect( + state.diffTarget(for: .workspaceChild(workspaceID: repository.id, path: "/tmp/missing")) == nil + ) + } + + @Test func diffTargetScopesWorkspaceChildToItsOwnWorkspace() { + // Two workspaces referencing the same absolute child path: each target + // must resolve within its own workspace so Hunk lands in the right + // terminal. + let sharedEntry = ProjectWorkspace.RepositoryEntry( + id: "shared", + name: "Shared", + path: "/tmp/shared-child", + sourceKind: .existingPath + ) + let firstWorkspace = makeWorkspaceRepository(id: "/tmp/ws-dup-1", children: [sharedEntry]) + let secondWorkspace = makeWorkspaceRepository(id: "/tmp/ws-dup-2", children: [sharedEntry]) + let state = makeState(repositories: [firstWorkspace, secondWorkspace]) + let childID = sharedEntry.resolvedURL(relativeTo: firstWorkspace.rootURL).path(percentEncoded: false) + + let firstTarget = state.diffTarget( + for: .workspaceChild(workspaceID: firstWorkspace.id, path: childID) + ) + let secondTarget = state.diffTarget( + for: .workspaceChild(workspaceID: secondWorkspace.id, path: childID) + ) + + #expect(firstTarget?.terminalHost.id == firstWorkspace.id) + #expect(secondTarget?.terminalHost.id == secondWorkspace.id) + #expect(firstTarget?.workingDirectory == secondTarget?.workingDirectory) + } + + @Test func diffTargetUsesRecordedSourceRootForSettingsAndTemplates() { + // Linked and worktree checkouts live inside the workspace folder but + // belong to a source repository elsewhere; settings and {repoPath} must + // follow the recorded source. Remote clones have no local source and stay + // rooted at the checkout. + let linkedEntry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath, + sourceLocation: "/tmp/source-repo" + ) + let remoteEntry = ProjectWorkspace.RepositoryEntry( + id: "api", + name: "Api", + path: "api", + sourceKind: .remote, + sourceLocation: "https://example.com/api.git" + ) + let repository = makeWorkspaceRepository( + id: "/tmp/ws-roots", + children: [linkedEntry, remoteEntry] + ) + let state = makeState(repositories: [repository]) + let linkedChildID = linkedEntry.resolvedURL(relativeTo: repository.rootURL) + .path(percentEncoded: false) + let remoteChildID = remoteEntry.resolvedURL(relativeTo: repository.rootURL) + .path(percentEncoded: false) + + let linkedTarget = state.diffTarget( + for: .workspaceChild(workspaceID: repository.id, path: linkedChildID) + ) + let remoteTarget = state.diffTarget( + for: .workspaceChild(workspaceID: repository.id, path: remoteChildID) + ) + + #expect(linkedTarget?.repositoryRootURL == URL(fileURLWithPath: "/tmp/source-repo")) + #expect(linkedTarget?.workingDirectory == URL(fileURLWithPath: linkedChildID)) + #expect(remoteTarget?.repositoryRootURL == URL(fileURLWithPath: remoteChildID)) + } + + @Test func pullRequestForDiffTargetDispatchesToTheMatchingCache() { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath + ) + let workspace = makeWorkspaceRepository(id: "/tmp/ws-pr", children: [entry]) + let worktree = makeWorktree(id: "/tmp/repo-pr/wt", name: "feature") + let repository = makeRepository(id: "/tmp/repo-pr", worktrees: [worktree]) + var state = makeState(repositories: [repository, workspace]) + let childID = entry.resolvedURL(relativeTo: workspace.rootURL).path(percentEncoded: false) + let worktreePR = makePullRequest(state: "OPEN", headRefName: "feature", number: 1) + let childPR = makePullRequest(state: "OPEN", headRefName: "child-branch", number: 2) + state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry(pullRequest: worktreePR) + state.workspaceChildInfoByID[childID] = WorktreeInfoEntry(pullRequest: childPR) + + #expect(state.pullRequest(for: .worktree(worktree.id)) == worktreePR) + #expect( + state.pullRequest(for: .workspaceChild(workspaceID: workspace.id, path: childID)) == childPR + ) + #expect(state.pullRequest(for: .worktree("/tmp/missing")) == nil) } @Test func selectedDiffTargetIDFollowsWorktreeThenWorkspaceChild() { @@ -7788,7 +7890,7 @@ struct RepositoriesFeatureTests { state.selection = .repository(workspace.id) state.selectedWorkspaceChildID = childID - #expect(state.selectedDiffTargetID == .workspaceChild(childID)) + #expect(state.selectedDiffTargetID == .workspaceChild(workspaceID: workspace.id, path: childID)) // A stale child id must not leak once a non-workspace repository is selected. state.selection = .repository(repository.id) -- 2.54.0 From ae08f900796538ea923223c0b636fc94e6adeaf7 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 20 Aug 2026 04:25:55 +0900 Subject: [PATCH 4/5] Resolve child diff roots live and scope selection pruning per workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round on the DiffTarget routing: - A recorded workspace source location is not guaranteed to be a git repository root (creation accepts any existing directory: a repo subdirectory or a nested worktree also materializes fine), so keying repositorySettings and {repoPath} by metadata alone can miss the source repository's configuration. The children refresh pipeline now resolves each child's root via gitClient.repoRoot — the same normalization that keys registered repositories on load — and caches it in workspaceChildRepoRootByID. Root preference: live-resolved → metadata source location → checkout directory. - pruneWorkspaceChildInfo validated selectedWorkspaceChildID against the global child-path set, so with two workspaces sharing a child path, removing the child from the selected workspace left a ghost selection: View/Palette stayed enabled while the target resolved to nil. The selection is now validated against the selected workspace's own children. Claude-Session: https://claude.ai/code/session_01WoqhZai4i4izqbUZLtbjde --- .../062-workspace-child-diff/001-action.md | 19 ++++-- .../RepositoriesFeature+StateQueries.swift | 4 +- ...epositoriesFeature+WorkspaceChildren.swift | 32 +++++++-- .../Reducer/RepositoriesFeature.swift | 12 +++- supacodeTests/RepositoriesFeatureTests.swift | 68 +++++++++++++++++-- 5 files changed, 115 insertions(+), 20 deletions(-) diff --git a/docs-ai/062-workspace-child-diff/001-action.md b/docs-ai/062-workspace-child-diff/001-action.md index f38932e0..7e8c0e8c 100644 --- a/docs-ai/062-workspace-child-diff/001-action.md +++ b/docs-ai/062-workspace-child-diff/001-action.md @@ -6,7 +6,8 @@ | --- | --- | --- | | 2026-08-19 | Plan aligned with onevcat (⌘⇧Y follows selected child; Outgoing Changes included; Hunk runs in workspace terminal with child cwd; natural degradation, no pre-checks) | tracker relay-tracker#154, issue #616 | | 2026-08-19 | Implemented `DiffTarget` routing end to end, with tests and `docs/` updates | #704 | -| 2026-08-20 | Review round (pi agent): scoped child targets by workspace, child `repositoryRootURL` follows recorded source root, added child outgoing coverage | #704 | +| 2026-08-20 | Review round 1 (pi agent): scoped child targets by workspace, child `repositoryRootURL` follows recorded source root, added child outgoing coverage | #704 | +| 2026-08-20 | Review round 2 (pi agent): child roots now live-resolved via `gitClient.repoRoot` (cached per reload) since a recorded source location may be a subdirectory or nested worktree; selection pruning validates against the selected workspace's own children | #704 | ## Outcome & current state (as of 2026-08-19) @@ -20,11 +21,17 @@ metadata → repository name; terminal host is the synthesized workspace worktree via `plainFolderWorktree(for:)`), `selectedDiffTargetID` (selected worktree, else selected workspace child), and `pullRequest(for:)` (per-target PR cache dispatch, wired from - `supacodeApp`). A child's `repositoryRootURL` is the metadata-recorded local source root - (`ProjectWorkspaceRepositoryEntry.localSourceURL`) so linked/worktree checkouts pick up - the source repository's `repositorySettings` and `{repoPath}`; remote clones and - metadata without a source location fall back to the checkout directory. Chosen over - runtime `git` root resolution to keep the query synchronous with no extra cached state. + `supacodeApp`). A child's `repositoryRootURL` preference is: live-resolved root → + metadata source root (`ProjectWorkspaceRepositoryEntry.localSourceURL`) → checkout + directory. The live root comes from `gitClient.repoRoot(childDirectory)` in the + workspace-children refresh pipeline, cached in `workspaceChildRepoRootByID` — the same + normalization that keys registered repositories (`RepositoriesFeature+RepositoryLoading`), + so the child's `repositorySettings` lookup matches its source repository by construction, + even when the recorded source location is a subdirectory or a nested worktree. The + metadata fallback covers the window before the first refresh lands. +- `pruneWorkspaceChildInfo` validates `selectedWorkspaceChildID` against the selected + workspace's own children (not the global path set), so a child removed from the selected + workspace cannot survive as a ghost selection through another workspace sharing the path. - `RepositoriesFeature.Delegate.showDiff` / `.showOutgoingChanges` carry `DiffTargetID`; `WorkspaceChildRowsView` + `RepositorySectionView` wire the child badge and new **Show Diff** / **Show Outgoing Changes** context-menu items. diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift index f6c27c85..13bb0782 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift @@ -244,11 +244,13 @@ extension RepositoriesFeature.State { else { return nil } + // Root preference: live-resolved root (same normalization that keys + // registered repositories) → metadata source root → checkout directory. return DiffTarget( id: id, workingDirectory: child.workingDirectory, branchName: workspaceChildBranchByID[child.id] ?? child.metadataBranch ?? child.repositoryName, - repositoryRootURL: child.repositoryRootURL, + repositoryRootURL: workspaceChildRepoRootByID[child.id] ?? child.repositoryRootURL, terminalHost: Self.plainFolderWorktree(for: workspaceRepository), terminalWorkingDirectory: child.workingDirectory ) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift index 7a77b417..90eee280 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift @@ -43,6 +43,9 @@ extension RepositoriesFeature { group.addTask { async let branchTask = gitClient.branchName(child.workingDirectory) async let changesTask = gitClient.lineChanges(child.workingDirectory) + // Same normalization that keys registered repositories, so the + // child's repository-settings lookup matches its source repo. + async let rootTask = try? gitClient.repoRoot(child.workingDirectory) let changes = await changesTask let branch = await branchTask let pullRequest = await Self.fetchWorkspaceChildPullRequest( @@ -56,7 +59,8 @@ extension RepositoriesFeature { id: child.id, branch: branch, lineChanges: changes, - pullRequest: pullRequest + pullRequest: pullRequest, + repositoryRoot: await rootTask ) } } @@ -110,6 +114,12 @@ func applyWorkspaceChildrenInfo( state.workspaceChildBranchByID.removeValue(forKey: update.id) } + if let repositoryRoot = update.repositoryRoot { + state.workspaceChildRepoRootByID[update.id] = repositoryRoot + } else { + state.workspaceChildRepoRootByID.removeValue(forKey: update.id) + } + var entry = state.workspaceChildInfoByID[update.id] ?? WorktreeInfoEntry() if let changes = update.lineChanges, !changes.isEmpty { entry.addedLines = changes.added @@ -134,9 +144,21 @@ func pruneWorkspaceChildInfo(state: inout RepositoriesFeature.State) { let validIDs = Set(state.allResolvedWorkspaceChildren().map(\.id)) state.workspaceChildInfoByID = state.workspaceChildInfoByID.filter { validIDs.contains($0.key) } state.workspaceChildBranchByID = state.workspaceChildBranchByID.filter { validIDs.contains($0.key) } - if let selectedWorkspaceChildID = state.selectedWorkspaceChildID, - !validIDs.contains(selectedWorkspaceChildID) - { - state.selectedWorkspaceChildID = nil + state.workspaceChildRepoRootByID = state.workspaceChildRepoRootByID.filter { + validIDs.contains($0.key) + } + // The selection is validated against the selected workspace's own children, + // not the global path set: another workspace referencing the same path must + // not keep a removed child selected here. + if let selectedWorkspaceChildID = state.selectedWorkspaceChildID { + let selectionIsValid = + state.selectedRepository.map { repository in + repository.isWorkspace + && state.resolvedWorkspaceChildren(in: repository) + .contains { $0.id == selectedWorkspaceChildID } + } ?? false + if !selectionIsValid { + state.selectedWorkspaceChildID = nil + } } } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 2f05bb51..1ae075a2 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -71,13 +71,15 @@ struct ForceDeleteBranchRequest: Equatable { } // Result of refreshing one workspace child repository's live status: current -// branch, uncommitted diff counts, and (when GitHub integration is available) -// the PR for that branch. +// branch, uncommitted diff counts, (when GitHub integration is available) the +// PR for that branch, and the resolved repository root used to key +// repository-scoped diff settings. nonisolated struct WorkspaceChildInfoUpdate: Equatable, Sendable { let id: String let branch: String? let lineChanges: GitLineChanges? let pullRequest: GithubPullRequest? + let repositoryRoot: URL? } struct RemoveWorkspaceConfirmation: Equatable { @@ -305,6 +307,12 @@ struct RepositoriesFeature { // folder. Refreshed by `refreshWorkspaceChildrenEffect` on each repo reload. var workspaceChildInfoByID: [String: WorktreeInfoEntry] = [:] var workspaceChildBranchByID: [String: String] = [:] + // Canonical repository root per child, resolved through the same + // `gitClient.repoRoot` normalization that keys registered repositories — + // so a child's `repositorySettings` lookup matches its source repository + // even when the recorded source location is a subdirectory or a nested + // worktree. Nil (falling back to metadata) until a refresh lands. + var workspaceChildRepoRootByID: [String: URL] = [:] var selectedWorkspaceChildID: String? var worktreeOrderByRepository: [Repository.ID: [Worktree.ID]] = [:] var isOpenPanelPresented = false diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 4d7c675f..2dc90702 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -710,8 +710,13 @@ struct RepositoriesFeatureTests { } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.repoRoot = { url in - Issue.record( - "workspace should load as plain without git probing: \(url.path(percentEncoded: false))") + let path = url.path(percentEncoded: false) + // The child pipeline resolves the child's own repository root; only + // probing the workspace root itself is forbidden. + guard path == childID else { + Issue.record("workspace should load as plain without git probing: \(path)") + return url + } return url } $0.gitClient.worktrees = { url in @@ -719,7 +724,8 @@ struct RepositoriesFeatureTests { return [] } // The workspace's child repository is refreshed via the child pipeline - // (live branch + diff), distinct from the worktree probing above. + // (live branch + diff + repo root), distinct from the worktree probing + // above. $0.gitClient.branchName = { _ in "main" } $0.gitClient.lineChanges = { _ in nil } $0.gitClient.remoteInfo = { _ in nil } @@ -735,6 +741,7 @@ struct RepositoriesFeatureTests { await store.receive(\.delegate.repositoriesChanged) await store.receive(\.workspaceChildrenInfoLoaded) { $0.workspaceChildBranchByID = [childID: "main"] + $0.workspaceChildRepoRootByID = [childID: URL(fileURLWithPath: childID)] } await store.finish() } @@ -7644,13 +7651,15 @@ struct RepositoriesFeatureTests { id: "/ws/app", branch: "feature", lineChanges: GitLineChanges(added: 7, removed: 2), - pullRequest: pullRequest + pullRequest: pullRequest, + repositoryRoot: URL(fileURLWithPath: "/ws/app-source") ), WorkspaceChildInfoUpdate( id: "/ws/api", branch: " ", lineChanges: GitLineChanges(added: 0, removed: 0), - pullRequest: nil + pullRequest: nil, + repositoryRoot: nil ), ], state: &state @@ -7660,9 +7669,11 @@ struct RepositoriesFeatureTests { #expect(state.workspaceChildInfoByID["/ws/app"]?.addedLines == 7) #expect(state.workspaceChildInfoByID["/ws/app"]?.removedLines == 2) #expect(state.workspaceChildInfoByID["/ws/app"]?.pullRequest == pullRequest) + #expect(state.workspaceChildRepoRootByID["/ws/app"] == URL(fileURLWithPath: "/ws/app-source")) // Blank branch + empty diff + no PR → no entries. #expect(state.workspaceChildBranchByID["/ws/api"] == nil) #expect(state.workspaceChildInfoByID["/ws/api"] == nil) + #expect(state.workspaceChildRepoRootByID["/ws/api"] == nil) } @Test func workspaceChildRowsMergesLiveBranchAndInfo() { @@ -7830,7 +7841,7 @@ struct RepositoriesFeatureTests { id: "/tmp/ws-roots", children: [linkedEntry, remoteEntry] ) - let state = makeState(repositories: [repository]) + var state = makeState(repositories: [repository]) let linkedChildID = linkedEntry.resolvedURL(relativeTo: repository.rootURL) .path(percentEncoded: false) let remoteChildID = remoteEntry.resolvedURL(relativeTo: repository.rootURL) @@ -7846,6 +7857,45 @@ struct RepositoriesFeatureTests { #expect(linkedTarget?.repositoryRootURL == URL(fileURLWithPath: "/tmp/source-repo")) #expect(linkedTarget?.workingDirectory == URL(fileURLWithPath: linkedChildID)) #expect(remoteTarget?.repositoryRootURL == URL(fileURLWithPath: remoteChildID)) + + // A live-resolved root (recorded source was a subdirectory or a nested + // worktree) wins over the metadata source location. + state.workspaceChildRepoRootByID[linkedChildID] = URL(fileURLWithPath: "/tmp/true-root") + let resolvedTarget = state.diffTarget( + for: .workspaceChild(workspaceID: repository.id, path: linkedChildID) + ) + #expect(resolvedTarget?.repositoryRootURL == URL(fileURLWithPath: "/tmp/true-root")) + } + + @Test func pruneClearsSelectedChildRemovedFromItsWorkspaceDespiteDuplicatePath() { + // Two workspaces reference the same absolute child path. Removing the + // child from the selected workspace must clear the selection even though + // the path stays globally valid through the other workspace — otherwise + // View/Palette advertise Diff for a target that resolves to nil. + let sharedEntry = ProjectWorkspace.RepositoryEntry( + id: "shared", + name: "Shared", + path: "/tmp/shared-prune-child", + sourceKind: .existingPath + ) + let selectedWorkspace = makeWorkspaceRepository(id: "/tmp/ws-prune-1", children: [sharedEntry]) + let otherWorkspace = makeWorkspaceRepository(id: "/tmp/ws-prune-2", children: [sharedEntry]) + var state = makeState(repositories: [selectedWorkspace, otherWorkspace]) + let childID = sharedEntry.resolvedURL(relativeTo: selectedWorkspace.rootURL) + .path(percentEncoded: false) + state.selection = .repository(selectedWorkspace.id) + state.selectedWorkspaceChildID = childID + + // Reload with the child still present: selection survives. + pruneWorkspaceChildInfo(state: &state) + #expect(state.selectedWorkspaceChildID == childID) + + // Reload after the child was removed from the selected workspace only. + let emptiedWorkspace = makeWorkspaceRepository(id: "/tmp/ws-prune-1", children: []) + state.repositories = [emptiedWorkspace, otherWorkspace] + pruneWorkspaceChildInfo(state: &state) + #expect(state.selectedWorkspaceChildID == nil) + #expect(state.selectedDiffTargetID == nil) } @Test func pullRequestForDiffTargetDispatchesToTheMatchingCache() { @@ -7962,6 +8012,7 @@ struct RepositoriesFeatureTests { removedLines: 1, pullRequest: nil ) + initialState.workspaceChildRepoRootByID["/tmp/gone/app"] = URL(fileURLWithPath: "/tmp/gone") let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { @@ -7969,6 +8020,7 @@ struct RepositoriesFeatureTests { $0.gitClient.lineChanges = { _ in GitLineChanges(added: 7, removed: 2, skippedUntrackedFileCount: 1) } + $0.gitClient.repoRoot = { _ in URL(fileURLWithPath: "/tmp/resolved-root") } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } } store.exhaustivity = .off @@ -7980,10 +8032,14 @@ struct RepositoriesFeatureTests { await store.finish() #expect(store.state.workspaceChildInfoByID["/tmp/gone/app"] == nil) + #expect(store.state.workspaceChildRepoRootByID["/tmp/gone/app"] == nil) #expect(store.state.workspaceChildBranchByID[childID] == "feature/live") #expect(store.state.workspaceChildInfoByID[childID]?.addedLines == 7) #expect(store.state.workspaceChildInfoByID[childID]?.removedLines == 2) #expect(store.state.workspaceChildInfoByID[childID]?.skippedUntrackedFileCount == 1) + #expect( + store.state.workspaceChildRepoRootByID[childID] == URL(fileURLWithPath: "/tmp/resolved-root") + ) } @Test func openRepositoriesFinishedRefreshesWorkspaceChildren() async { -- 2.54.0 From 77128eccbe8372c0599572938cf0be1ca121f4ee Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 20 Aug 2026 04:38:49 +0900 Subject: [PATCH 5/5] Canonicalize child diff roots at invocation instead of caching them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third review round. The refresh-pipeline root cache from the previous round had a startup window (the canonical root only landed after the whole child batch, including network PR fetches, completed) and two cache-lifecycle hazards (a stale in-flight refresh repopulating pruned entries; a transient resolution failure discarding a known root). All three share the cache as their root cause, so the cache is gone: - The diff effects canonicalize a child target's repository root at the moment the action runs — AppFeature.canonicalizedDiffTarget runs gitClient.repoRoot(childDirectory) inside openDiffEffect and openOutgoingChangesEffect, the same normalization that keys registered repositories on load. Every child diff invocation gets a current root; a failed lookup keeps the metadata fallback. Worktree targets are untouched. - Reloads that leave no workspace children now cancel the in-flight children refresh, and workspaceChildrenInfoLoaded filters updates to current children, so a late batch cannot repopulate just-pruned maps. Claude-Session: https://claude.ai/code/session_01WoqhZai4i4izqbUZLtbjde --- .../062-workspace-child-diff/001-action.md | 23 ++++--- supacode/Domain/DiffTarget.swift | 4 +- .../Reducer/AppFeature+CommandPalette.swift | 19 ++++++ .../Features/App/Reducer/AppFeature.swift | 1 + .../RepositoriesFeature+CoreReducer.swift | 5 +- .../RepositoriesFeature+StateQueries.swift | 7 +- ...epositoriesFeature+WorkspaceChildren.swift | 19 ++---- .../Reducer/RepositoriesFeature.swift | 12 +--- .../AppFeatureCommandPaletteTests.swift | 14 +++- supacodeTests/RepositoriesFeatureTests.swift | 64 ++++++++++--------- 10 files changed, 97 insertions(+), 71 deletions(-) diff --git a/docs-ai/062-workspace-child-diff/001-action.md b/docs-ai/062-workspace-child-diff/001-action.md index 7e8c0e8c..301f5d32 100644 --- a/docs-ai/062-workspace-child-diff/001-action.md +++ b/docs-ai/062-workspace-child-diff/001-action.md @@ -7,7 +7,8 @@ | 2026-08-19 | Plan aligned with onevcat (⌘⇧Y follows selected child; Outgoing Changes included; Hunk runs in workspace terminal with child cwd; natural degradation, no pre-checks) | tracker relay-tracker#154, issue #616 | | 2026-08-19 | Implemented `DiffTarget` routing end to end, with tests and `docs/` updates | #704 | | 2026-08-20 | Review round 1 (pi agent): scoped child targets by workspace, child `repositoryRootURL` follows recorded source root, added child outgoing coverage | #704 | -| 2026-08-20 | Review round 2 (pi agent): child roots now live-resolved via `gitClient.repoRoot` (cached per reload) since a recorded source location may be a subdirectory or nested worktree; selection pruning validates against the selected workspace's own children | #704 | +| 2026-08-20 | Review round 2 (pi agent): child roots live-resolved via `gitClient.repoRoot` (cached per reload) since a recorded source location may be a subdirectory or nested worktree; selection pruning validates against the selected workspace's own children | #704 | +| 2026-08-20 | Review round 3 (pi agent): dropped the root cache — canonicalization moved to the diff effects (`AppFeature.canonicalizedDiffTarget`) at invocation time, eliminating the startup window and cache-staleness class; empty-child reloads cancel the in-flight refresh and stale child updates are filtered | #704 | ## Outcome & current state (as of 2026-08-19) @@ -21,17 +22,21 @@ metadata → repository name; terminal host is the synthesized workspace worktree via `plainFolderWorktree(for:)`), `selectedDiffTargetID` (selected worktree, else selected workspace child), and `pullRequest(for:)` (per-target PR cache dispatch, wired from - `supacodeApp`). A child's `repositoryRootURL` preference is: live-resolved root → - metadata source root (`ProjectWorkspaceRepositoryEntry.localSourceURL`) → checkout - directory. The live root comes from `gitClient.repoRoot(childDirectory)` in the - workspace-children refresh pipeline, cached in `workspaceChildRepoRootByID` — the same - normalization that keys registered repositories (`RepositoriesFeature+RepositoryLoading`), - so the child's `repositorySettings` lookup matches its source repository by construction, - even when the recorded source location is a subdirectory or a nested worktree. The - metadata fallback covers the window before the first refresh lands. + `supacodeApp`). A child's sync-resolved `repositoryRootURL` is the metadata source root + (`ProjectWorkspaceRepositoryEntry.localSourceURL`) falling back to the checkout + directory; because a recorded source location may be a subdirectory or a nested + worktree, the diff effects canonicalize it at invocation time — + `AppFeature.canonicalizedDiffTarget` runs `gitClient.repoRoot(childDirectory)` inside + `openDiffEffect` / `openOutgoingChangesEffect`, the same normalization that keys + registered repositories (`RepositoriesFeature+RepositoryLoading`), so + `repositorySettings` and `{repoPath}` match the source repository by construction with + no cache to go stale and no startup window. A failed lookup keeps the metadata fallback. - `pruneWorkspaceChildInfo` validates `selectedWorkspaceChildID` against the selected workspace's own children (not the global path set), so a child removed from the selected workspace cannot survive as a ghost selection through another workspace sharing the path. +- Reloads that leave no workspace children cancel the in-flight children refresh, and + `workspaceChildrenInfoLoaded` filters updates to current children, so a late batch + cannot repopulate just-pruned maps. - `RepositoriesFeature.Delegate.showDiff` / `.showOutgoingChanges` carry `DiffTargetID`; `WorkspaceChildRowsView` + `RepositorySectionView` wire the child badge and new **Show Diff** / **Show Outgoing Changes** context-menu items. diff --git a/supacode/Domain/DiffTarget.swift b/supacode/Domain/DiffTarget.swift index bdaa24ff..909e1f80 100644 --- a/supacode/Domain/DiffTarget.swift +++ b/supacode/Domain/DiffTarget.swift @@ -22,7 +22,9 @@ nonisolated struct DiffTarget: Equatable, Sendable { /// Display branch; also fills `{branch}` in custom diff command templates. let branchName: String /// Fills `{repoPath}` in custom templates and keys `repositorySettings`. - let repositoryRootURL: URL + /// For workspace children this starts as a metadata approximation and is + /// canonicalized by the diff effects before use. + var repositoryRootURL: URL /// Worktree owning the terminal that the Hunk tool runs in. let terminalHost: Worktree /// Hunk cwd when it differs from the host's own directory. diff --git a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift index 45ec47ac..0248f41b 100644 --- a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift +++ b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift @@ -190,6 +190,23 @@ extension AppFeature { } } + /// A workspace child's sync-resolved root comes from workspace metadata, + /// which may record a subdirectory or a nested worktree. Canonicalize it + /// through the same `repoRoot` normalization that keys registered + /// repositories, at the moment the action runs, so `repositorySettings` and + /// `{repoPath}` match the source repository. Worktree targets already carry + /// their registered root; a failed lookup keeps the metadata fallback. + func canonicalizedDiffTarget(_ target: DiffTarget) async -> DiffTarget { + guard case .workspaceChild = target.id, + let root = try? await gitClient.repoRoot(target.workingDirectory) + else { + return target + } + var target = target + target.repositoryRootURL = root + return target + } + func openDiffEffect( target: DiffTarget, resolvedKeybindings: ResolvedKeybindingMap @@ -200,6 +217,7 @@ extension AppFeature { customCommand: settingsFile.global.externalDiffCustomCommand ) return .run { send in + let target = await canonicalizedDiffTarget(target) await externalDiffToolClient.open(settings, target, resolvedKeybindings) { error in send(.openWorktreeFailed(error)) } @@ -228,6 +246,7 @@ extension AppFeature { } let resolvedKeybindings = state.resolvedKeybindings return .run { send in + let target = await canonicalizedDiffTarget(target) await outgoingChangesClient.open(target, resolvedKeybindings) { error in send(.openWorktreeFailed(error)) } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 037394f1..65059c30 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -114,6 +114,7 @@ struct AppFeature { @Dependency(CustomShortcutRegistryClient.self) var customShortcutRegistryClient @Dependency(ExternalDiffToolClient.self) var externalDiffToolClient @Dependency(OutgoingChangesClient.self) var outgoingChangesClient + @Dependency(GitClientDependency.self) var gitClient var body: some Reducer { let core = Reduce { state, action in diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index 1919a84e..a985b62a 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -1004,7 +1004,10 @@ extension RepositoriesFeature { return .none case .workspaceChildrenInfoLoaded(let updates): - applyWorkspaceChildrenInfo(updates, state: &state) + // An in-flight refresh can land after a reload changed the child set; + // only merge updates that still belong to a current workspace child. + let validIDs = Set(state.allResolvedWorkspaceChildren().map(\.id)) + applyWorkspaceChildrenInfo(updates.filter { validIDs.contains($0.id) }, state: &state) return .none case .alert(.dismiss): diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift index 13bb0782..34f4fef1 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift @@ -244,13 +244,14 @@ extension RepositoriesFeature.State { else { return nil } - // Root preference: live-resolved root (same normalization that keys - // registered repositories) → metadata source root → checkout directory. + // The metadata-derived root is a synchronous approximation; the diff + // effects canonicalize it through `gitClient.repoRoot` when the action + // runs (`AppFeature.canonicalizedDiffTarget`). return DiffTarget( id: id, workingDirectory: child.workingDirectory, branchName: workspaceChildBranchByID[child.id] ?? child.metadataBranch ?? child.repositoryName, - repositoryRootURL: workspaceChildRepoRootByID[child.id] ?? child.repositoryRootURL, + repositoryRootURL: child.repositoryRootURL, terminalHost: Self.plainFolderWorktree(for: workspaceRepository), terminalWorkingDirectory: child.workingDirectory ) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift index 90eee280..4b59faf1 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift @@ -30,7 +30,9 @@ extension RepositoriesFeature { func refreshWorkspaceChildrenEffect(state: State) -> Effect { let children = state.allResolvedWorkspaceChildren() guard !children.isEmpty else { - return .none + // A reload that removed the last child must also stop an in-flight + // refresh, or its late result would repopulate the just-pruned maps. + return .cancel(id: CancelID.workspaceChildrenRefresh) } let gitClient = self.gitClient let githubCLI = self.githubCLI @@ -43,9 +45,6 @@ extension RepositoriesFeature { group.addTask { async let branchTask = gitClient.branchName(child.workingDirectory) async let changesTask = gitClient.lineChanges(child.workingDirectory) - // Same normalization that keys registered repositories, so the - // child's repository-settings lookup matches its source repo. - async let rootTask = try? gitClient.repoRoot(child.workingDirectory) let changes = await changesTask let branch = await branchTask let pullRequest = await Self.fetchWorkspaceChildPullRequest( @@ -59,8 +58,7 @@ extension RepositoriesFeature { id: child.id, branch: branch, lineChanges: changes, - pullRequest: pullRequest, - repositoryRoot: await rootTask + pullRequest: pullRequest ) } } @@ -114,12 +112,6 @@ func applyWorkspaceChildrenInfo( state.workspaceChildBranchByID.removeValue(forKey: update.id) } - if let repositoryRoot = update.repositoryRoot { - state.workspaceChildRepoRootByID[update.id] = repositoryRoot - } else { - state.workspaceChildRepoRootByID.removeValue(forKey: update.id) - } - var entry = state.workspaceChildInfoByID[update.id] ?? WorktreeInfoEntry() if let changes = update.lineChanges, !changes.isEmpty { entry.addedLines = changes.added @@ -144,9 +136,6 @@ func pruneWorkspaceChildInfo(state: inout RepositoriesFeature.State) { let validIDs = Set(state.allResolvedWorkspaceChildren().map(\.id)) state.workspaceChildInfoByID = state.workspaceChildInfoByID.filter { validIDs.contains($0.key) } state.workspaceChildBranchByID = state.workspaceChildBranchByID.filter { validIDs.contains($0.key) } - state.workspaceChildRepoRootByID = state.workspaceChildRepoRootByID.filter { - validIDs.contains($0.key) - } // The selection is validated against the selected workspace's own children, // not the global path set: another workspace referencing the same path must // not keep a removed child selected here. diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 1ae075a2..2f05bb51 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -71,15 +71,13 @@ struct ForceDeleteBranchRequest: Equatable { } // Result of refreshing one workspace child repository's live status: current -// branch, uncommitted diff counts, (when GitHub integration is available) the -// PR for that branch, and the resolved repository root used to key -// repository-scoped diff settings. +// branch, uncommitted diff counts, and (when GitHub integration is available) +// the PR for that branch. nonisolated struct WorkspaceChildInfoUpdate: Equatable, Sendable { let id: String let branch: String? let lineChanges: GitLineChanges? let pullRequest: GithubPullRequest? - let repositoryRoot: URL? } struct RemoveWorkspaceConfirmation: Equatable { @@ -307,12 +305,6 @@ struct RepositoriesFeature { // folder. Refreshed by `refreshWorkspaceChildrenEffect` on each repo reload. var workspaceChildInfoByID: [String: WorktreeInfoEntry] = [:] var workspaceChildBranchByID: [String: String] = [:] - // Canonical repository root per child, resolved through the same - // `gitClient.repoRoot` normalization that keys registered repositories — - // so a child's `repositorySettings` lookup matches its source repository - // even when the recorded source location is a subdirectory or a nested - // worktree. Nil (falling back to metadata) until a refresh lands. - var workspaceChildRepoRootByID: [String: URL] = [:] var selectedWorkspaceChildID: String? var worktreeOrderByRepository: [Repository.ID: [Worktree.ID]] = [:] var isOpenPanelPresented = false diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 4473039e..873eede0 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -1024,6 +1024,9 @@ struct AppFeatureCommandPaletteTests { $0.externalDiffToolClient.open = { _, target, _, _ in launched.withValue { $0.append(target) } } + // The effect canonicalizes the child's repository root at invocation + // time; the resolved root must reach the diff client. + $0.gitClient.repoRoot = { _ in URL(fileURLWithPath: "/tmp/child-source-root") } } store.exhaustivity = .off @@ -1041,7 +1044,7 @@ struct AppFeatureCommandPaletteTests { #expect(target.id == .workspaceChild(workspaceID: workspace.id, path: childID)) #expect(target.workingDirectory == childURL) #expect(target.branchName == "feature/child") - #expect(target.repositoryRootURL == childURL) + #expect(target.repositoryRootURL == URL(fileURLWithPath: "/tmp/child-source-root")) #expect(target.terminalHost.id == workspace.id) #expect(target.terminalWorkingDirectory == childURL) } @@ -1080,6 +1083,11 @@ struct AppFeatureCommandPaletteTests { $0.outgoingChangesClient.open = { target, _, _ in outgoingRequests.withValue { $0.append(target) } } + // A failed root canonicalization keeps the metadata fallback instead of + // blocking the action. + $0.gitClient.repoRoot = { _ in + throw NSError(domain: "test", code: 1) + } } store.exhaustivity = .off @@ -1093,10 +1101,12 @@ struct AppFeatureCommandPaletteTests { await store.send(.showSelectedWorktreeOutgoingChanges) await store.finish() + let childURL = URL(fileURLWithPath: childID) #expect(outgoingRequests.value.count == 2) for target in outgoingRequests.value { #expect(target.id == .workspaceChild(workspaceID: workspace.id, path: childID)) - #expect(target.workingDirectory == URL(fileURLWithPath: childID)) + #expect(target.workingDirectory == childURL) + #expect(target.repositoryRootURL == childURL) } } diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 2dc90702..70f78bfa 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -710,13 +710,8 @@ struct RepositoriesFeatureTests { } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.repoRoot = { url in - let path = url.path(percentEncoded: false) - // The child pipeline resolves the child's own repository root; only - // probing the workspace root itself is forbidden. - guard path == childID else { - Issue.record("workspace should load as plain without git probing: \(path)") - return url - } + Issue.record( + "workspace should load as plain without git probing: \(url.path(percentEncoded: false))") return url } $0.gitClient.worktrees = { url in @@ -724,8 +719,7 @@ struct RepositoriesFeatureTests { return [] } // The workspace's child repository is refreshed via the child pipeline - // (live branch + diff + repo root), distinct from the worktree probing - // above. + // (live branch + diff), distinct from the worktree probing above. $0.gitClient.branchName = { _ in "main" } $0.gitClient.lineChanges = { _ in nil } $0.gitClient.remoteInfo = { _ in nil } @@ -741,7 +735,6 @@ struct RepositoriesFeatureTests { await store.receive(\.delegate.repositoriesChanged) await store.receive(\.workspaceChildrenInfoLoaded) { $0.workspaceChildBranchByID = [childID: "main"] - $0.workspaceChildRepoRootByID = [childID: URL(fileURLWithPath: childID)] } await store.finish() } @@ -7651,15 +7644,13 @@ struct RepositoriesFeatureTests { id: "/ws/app", branch: "feature", lineChanges: GitLineChanges(added: 7, removed: 2), - pullRequest: pullRequest, - repositoryRoot: URL(fileURLWithPath: "/ws/app-source") + pullRequest: pullRequest ), WorkspaceChildInfoUpdate( id: "/ws/api", branch: " ", lineChanges: GitLineChanges(added: 0, removed: 0), - pullRequest: nil, - repositoryRoot: nil + pullRequest: nil ), ], state: &state @@ -7669,11 +7660,9 @@ struct RepositoriesFeatureTests { #expect(state.workspaceChildInfoByID["/ws/app"]?.addedLines == 7) #expect(state.workspaceChildInfoByID["/ws/app"]?.removedLines == 2) #expect(state.workspaceChildInfoByID["/ws/app"]?.pullRequest == pullRequest) - #expect(state.workspaceChildRepoRootByID["/ws/app"] == URL(fileURLWithPath: "/ws/app-source")) // Blank branch + empty diff + no PR → no entries. #expect(state.workspaceChildBranchByID["/ws/api"] == nil) #expect(state.workspaceChildInfoByID["/ws/api"] == nil) - #expect(state.workspaceChildRepoRootByID["/ws/api"] == nil) } @Test func workspaceChildRowsMergesLiveBranchAndInfo() { @@ -7841,7 +7830,7 @@ struct RepositoriesFeatureTests { id: "/tmp/ws-roots", children: [linkedEntry, remoteEntry] ) - var state = makeState(repositories: [repository]) + let state = makeState(repositories: [repository]) let linkedChildID = linkedEntry.resolvedURL(relativeTo: repository.rootURL) .path(percentEncoded: false) let remoteChildID = remoteEntry.resolvedURL(relativeTo: repository.rootURL) @@ -7857,14 +7846,6 @@ struct RepositoriesFeatureTests { #expect(linkedTarget?.repositoryRootURL == URL(fileURLWithPath: "/tmp/source-repo")) #expect(linkedTarget?.workingDirectory == URL(fileURLWithPath: linkedChildID)) #expect(remoteTarget?.repositoryRootURL == URL(fileURLWithPath: remoteChildID)) - - // A live-resolved root (recorded source was a subdirectory or a nested - // worktree) wins over the metadata source location. - state.workspaceChildRepoRootByID[linkedChildID] = URL(fileURLWithPath: "/tmp/true-root") - let resolvedTarget = state.diffTarget( - for: .workspaceChild(workspaceID: repository.id, path: linkedChildID) - ) - #expect(resolvedTarget?.repositoryRootURL == URL(fileURLWithPath: "/tmp/true-root")) } @Test func pruneClearsSelectedChildRemovedFromItsWorkspaceDespiteDuplicatePath() { @@ -8012,7 +7993,6 @@ struct RepositoriesFeatureTests { removedLines: 1, pullRequest: nil ) - initialState.workspaceChildRepoRootByID["/tmp/gone/app"] = URL(fileURLWithPath: "/tmp/gone") let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { @@ -8020,7 +8000,6 @@ struct RepositoriesFeatureTests { $0.gitClient.lineChanges = { _ in GitLineChanges(added: 7, removed: 2, skippedUntrackedFileCount: 1) } - $0.gitClient.repoRoot = { _ in URL(fileURLWithPath: "/tmp/resolved-root") } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } } store.exhaustivity = .off @@ -8032,14 +8011,39 @@ struct RepositoriesFeatureTests { await store.finish() #expect(store.state.workspaceChildInfoByID["/tmp/gone/app"] == nil) - #expect(store.state.workspaceChildRepoRootByID["/tmp/gone/app"] == nil) #expect(store.state.workspaceChildBranchByID[childID] == "feature/live") #expect(store.state.workspaceChildInfoByID[childID]?.addedLines == 7) #expect(store.state.workspaceChildInfoByID[childID]?.removedLines == 2) #expect(store.state.workspaceChildInfoByID[childID]?.skippedUntrackedFileCount == 1) - #expect( - store.state.workspaceChildRepoRootByID[childID] == URL(fileURLWithPath: "/tmp/resolved-root") + } + + @Test func workspaceChildrenInfoLoadedIgnoresUpdatesForRemovedChildren() async { + // An in-flight refresh can land after a reload removed its child; the + // stale update must not repopulate the just-pruned maps. + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath ) + let repository = makeWorkspaceRepository(id: "/tmp/ws-stale", children: [entry]) + let store = TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } + + await store.send( + .workspaceChildrenInfoLoaded([ + WorkspaceChildInfoUpdate( + id: "/tmp/ws-removed/app", + branch: "stale", + lineChanges: GitLineChanges(added: 1, removed: 1), + pullRequest: nil + ) + ]) + ) + + #expect(store.state.workspaceChildBranchByID.isEmpty) + #expect(store.state.workspaceChildInfoByID.isEmpty) } @Test func openRepositoriesFinishedRefreshesWorkspaceChildren() async {