diff --git a/README.md b/README.md index b67194a0..81afaf5d 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,8 @@ hosts cannot populate the sidebar. Expand your Mac in the sidebar and select an existing tmux session. Use the host's **+** menu to create a named session. Closing its Ghosthub window or tab only detaches. To end a standalone session, hover its sidebar row and click the -**×**; worktree-backed sessions keep **Kill Session…** in their action menu. +**×**; for a worktree-backed session, Control-click its worktree row and choose +**Kill Session…**. Both paths confirm the exact host and session before terminating it. If a bare session exits on its own, **Reopen** creates the same named session again. @@ -190,7 +191,14 @@ local tracking branch when needed; unmatched input creates a new branch. Selecting the primary checkout or a linked worktree creates or repairs its canonical tmux session when needed, then attaches an ordinary tmux client. -To remove a non-primary worktree, hover its sidebar row and click the **×**. +Use the disclosure chevron beside a worktree to expand its staged, working-tree, +and untracked files without selecting or opening the worktree. Expanded panels +refresh automatically and can be refreshed or collapsed independently. Ghosthub +displays Kwt's semantic status only; it does not show diffs or provide file +actions. + +To remove a non-primary worktree, hover over its row and select the **×**, or +Control-click the row and choose **Remove Worktree…**. After confirmation, Ghosthub terminates that worktree's verified live tmux session if needed and asks kwt to remove the checkout. The Git branch is kept. diff --git a/Sources/App/AccountCommandRunner.swift b/Sources/App/AccountCommandRunner.swift index 5607edf4..3d233076 100644 --- a/Sources/App/AccountCommandRunner.swift +++ b/Sources/App/AccountCommandRunner.swift @@ -87,7 +87,7 @@ struct AccountCommandRunner: Sendable { static let timedOutStatus: Int32 = -124 static let outputExceededStatus: Int32 = -125 static let cancelledStatus: Int32 = -130 - private static let maximumOutputBytes = 1 * 1_024 * 1_024 + static let defaultMaximumOutputBytes = 1 * 1_024 * 1_024 private static let sessionOpenRetryDelays: [useconds_t] = [100_000, 250_000] typealias ProcessRunner = @Sendable ( @@ -101,10 +101,20 @@ struct AccountCommandRunner: Sendable { private let loginShellProvider: @Sendable () -> String init( - processRunner: @escaping ProcessRunner = Self.runProcess, + processRunner: ProcessRunner? = nil, + maximumOutputBytes: Int = Self.defaultMaximumOutputBytes, loginShellProvider: @escaping @Sendable () -> String = Self.loginShell ) { - self.processRunner = processRunner + self.processRunner = processRunner ?? { + executable, arguments, timeout, environmentOverrides in + Self.runProcess( + executable: executable, + arguments: arguments, + timeout: timeout, + environmentOverrides: environmentOverrides, + maximumOutputBytes: maximumOutputBytes + ) + } self.loginShellProvider = loginShellProvider } @@ -161,9 +171,11 @@ struct AccountCommandRunner: Sendable { command: String, timeout: TimeInterval, captureStandardError: Bool = false, - environmentOverrides: [String: String] = [:] + environmentOverrides: [String: String] = [:], + maximumOutputBytes: Int = defaultMaximumOutputBytes ) -> (status: Int32, stdout: String) { let output = AccountCommandRunner( + maximumOutputBytes: maximumOutputBytes, loginShellProvider: { shell } ).runLocalLoginShell( command: command, @@ -184,7 +196,8 @@ struct AccountCommandRunner: Sendable { timeout: TimeInterval, captureStandardError: Bool = false, accountShell: String = loginShell(), - environmentOverrides: [String: String] = [:] + environmentOverrides: [String: String] = [:], + maximumOutputBytes: Int = defaultMaximumOutputBytes ) -> (status: Int32, stdout: String) { let command = ([executable] + arguments) .map(shellQuotedCommandArgument) @@ -194,7 +207,8 @@ struct AccountCommandRunner: Sendable { command: command, timeout: timeout, captureStandardError: captureStandardError, - environmentOverrides: environmentOverrides + environmentOverrides: environmentOverrides, + maximumOutputBytes: maximumOutputBytes ) } @@ -223,7 +237,8 @@ struct AccountCommandRunner: Sendable { executable: String, arguments: [String], timeout: TimeInterval, - environmentOverrides: [String: String] = [:] + environmentOverrides: [String: String] = [:], + maximumOutputBytes: Int = defaultMaximumOutputBytes ) -> AccountCommandOutput { var outputDescriptors = [Int32](repeating: -1, count: 2) guard outputDescriptors.withUnsafeMutableBufferPointer({ descriptors in @@ -321,10 +336,10 @@ struct AccountCommandRunner: Sendable { return AccountCommandOutput(status: 127, stdout: "", stderr: "") } let output = AccountCommandOutputCollector( - limit: maximumOutputBytes + limit: max(0, maximumOutputBytes) ) let errorOutput = AccountCommandOutputCollector( - limit: maximumOutputBytes + limit: max(0, maximumOutputBytes) ) var readBuffer = [UInt8](repeating: 0, count: 64 * 1_024) let deadline = Date().addingTimeInterval(timeout) diff --git a/Sources/App/KwtSSHCommandClient.swift b/Sources/App/KwtSSHCommandClient.swift index ccb79bc4..7223dcb1 100644 --- a/Sources/App/KwtSSHCommandClient.swift +++ b/Sources/App/KwtSSHCommandClient.swift @@ -13,11 +13,13 @@ struct KwtSSHCommandClient: Sendable { private let runner: Runner private let binaryPath: String? private let environment: [String: String] + private let maximumOutputBytes: Int init( runner: Runner? = nil, binaryPath: String? = KwtBinaryLocator.bundledPath(), - environment: [String: String]? = nil + environment: [String: String]? = nil, + maximumOutputBytes: Int = AccountCommandRunner.defaultMaximumOutputBytes ) { let processEnvironment = environment ?? KwtSSHRuntimeEnvironment.resolved() @@ -29,11 +31,13 @@ struct KwtSSHCommandClient: Sendable { executable: executable, arguments: arguments, timeout: timeout, - environmentOverrides: environmentOverrides + environmentOverrides: environmentOverrides, + maximumOutputBytes: maximumOutputBytes ) } self.binaryPath = binaryPath self.environment = processEnvironment + self.maximumOutputBytes = maximumOutputBytes } func run( @@ -45,7 +49,9 @@ struct KwtSSHCommandClient: Sendable { let demoArguments = demoSSHIsolationArguments(environment: environment) if !demoArguments.isEmpty { return await Self.runDetached { - AccountCommandRunner().runRemoteLoginShell( + AccountCommandRunner( + maximumOutputBytes: maximumOutputBytes + ).runRemoteLoginShell( host: host, connectionArguments: demoArguments, command: command, @@ -124,7 +130,7 @@ struct KwtSSHCommandClient: Sendable { : output.stderr return AccountCommandOutput( status: 255, - stdout: "", + stdout: output.stdout, stderr: marker + diagnostic ) } diff --git a/Sources/App/KwtWorktreeClient.swift b/Sources/App/KwtWorktreeClient.swift index f6be8ef4..5300e2c9 100644 --- a/Sources/App/KwtWorktreeClient.swift +++ b/Sources/App/KwtWorktreeClient.swift @@ -18,7 +18,14 @@ enum KwtWorktreeError: Error, Equatable, LocalizedError { case sessionStartedAfterConfirmation(session: String) case commandFailed(host: String, status: Int32) case removalFailed(host: String, status: Int32) - case changeStatusFailed(host: String, status: Int32) + case changeInspectionFailed( + host: String, + status: Int32, + code: String?, + message: String?, + retryable: Bool, + details: [String: KwtProjectErrorDetail] + ) case malformedChangeStatus(host: String) case createdWorktreeMissing(branch: String) case malformedBranches(host: String) @@ -26,56 +33,89 @@ enum KwtWorktreeError: Error, Equatable, LocalizedError { var errorDescription: String? { switch self { case .invalidBranchName: - "Enter a valid git branch name." + return "Enter a valid git branch name." case .projectUnavailable: - "The selected kwt project or host is no longer available." + return "The selected kwt project or host is no longer available." case .creationInProgress: - "Another worktree change is already in progress." + return "Another worktree change is already in progress." case .worktreeUnavailable: - "The selected kwt worktree or host is no longer available." + return "The selected kwt worktree or host is no longer available." case .primaryWorktreeCannotBeRemoved: - "The primary checkout cannot be removed." + return "The primary checkout cannot be removed." case .removalInProgress: - "Another worktree change is already in progress." + return "Another worktree change is already in progress." case .removalIdentityUnavailable: - "The worktree has no stable removal identity. Refresh the" + return "The worktree has no stable removal identity. Refresh the" + " workspace and try again." case .removalTargetChanged: - "The worktree or its tmux session changed after confirmation." + return "The worktree or its tmux session changed after confirmation." + " Review the refreshed workspace and try again." case .removalHostChanged: - "The host destination changed after confirmation. Review the host" + return "The host destination changed after confirmation. Review the host" + " settings and try again." case .removalChangesChanged: - "The worktree gained uncommitted changes after confirmation." + return "The worktree gained uncommitted changes after confirmation." + " Review the updated removal warning and try again." case let .removalPreflightUnavailable(host, message): - "kwt could not verify the worktree on \(host): \(message)" + return "kwt could not verify the worktree on \(host): \(message)" case let .sessionStartedAfterConfirmation(session): - "Tmux session “\(session)” started after confirmation. Review the" + return "Tmux session “\(session)” started after confirmation. Review the" + " updated removal warning and try again." case let .commandFailed(host, status): - "kwt could not create the worktree on \(host) (status \(status))." + return "kwt could not create the worktree on \(host) (status \(status))." case let .removalFailed(host, status): - "kwt could not remove the worktree on \(host) (status \(status))." - case let .changeStatusFailed(host, status): - "kwt could not check the worktree for uncommitted changes on" - + " \(host) (status \(status))." + return "kwt could not remove the worktree on \(host) (status \(status))." + case let .changeInspectionFailed( + host, + status, + _, + message, + retryable, + _ + ): + if requiresInventoryRefresh { + return "Worktree registration changed. Refresh workspace inventory" + + " to inspect the current worktree." + } + let detail = message + ?? "kwt could not inspect worktree changes on \(host)" + + " (status \(status))." + return retryable ? "\(detail) Try again." : detail case let .malformedChangeStatus(host): - "kwt returned an invalid worktree change status on \(host)." + return "kwt returned an invalid worktree change status on \(host)." case let .createdWorktreeMissing(branch): - "kwt completed, but \(branch) was not present in the refreshed inventory." + return "kwt completed, but \(branch) was not present in the refreshed inventory." case let .malformedBranches(host): - "kwt returned an invalid branch list on \(host)." + return "kwt returned an invalid branch list on \(host)." } } } +extension KwtWorktreeError: WorktreeChangesRetryClassifying { + var requiresInventoryRefresh: Bool { + guard case let .changeInspectionFailed(_, _, code, _, _, _) = self + else { return false } + return code == "registration_changed" + } + + var isRetryable: Bool { + if case let .changeInspectionFailed( + _, _, _, _, retryable, _ + ) = self { + // Kwt permits retry after resolving a fresh registration. Polling + // with the same captured identity cannot recover from this error. + return retryable && !requiresInventoryRefresh + } + return false + } +} + /// Executes only kwt's supported worktree lifecycle surfaces. Ghosthub does /// not choose a worktree path or launch its own workspace/session /// implementation. struct KwtWorktreeClient: Sendable { private static let jsonMarker = "GHOSTHUB_KWT_JSON\n" + private static let maximumChangeOutputBytes = 16 * 1_024 * 1_024 typealias LocalRunner = @Sendable ( _ shell: String, _ command: String ) -> (status: Int32, stdout: String) @@ -85,6 +125,8 @@ struct KwtWorktreeClient: Sendable { private let localRunner: LocalRunner private let remoteRunner: RemoteRunner + private let changeLocalRunner: LocalRunner + private let changeRemoteRunner: RemoteRunner private let loginShellProvider: @Sendable () -> String private let localBinaryPath: String? private let remoteBinaryRevision: String? @@ -93,12 +135,14 @@ struct KwtWorktreeClient: Sendable { localRunner: LocalRunner? = nil, remoteRunner: RemoteRunner? = nil, processTimeout: TimeInterval = 60, + changeInspectionTimeout: TimeInterval = 15, localBinaryPath: String? = KwtBinaryLocator.bundledPath(), remoteBinaryRevision: String? = KwtBinaryLocator.bundledRemoteRevision(), loginShellProvider: @escaping @Sendable () -> String = AccountCommandRunner.loginShell ) { + let maximumChangeOutputBytes = Self.maximumChangeOutputBytes self.localRunner = localRunner ?? { shell, command in AccountCommandRunner.runLoginShell( shell: shell, @@ -114,6 +158,25 @@ struct KwtWorktreeClient: Sendable { expectedRouteIdentity: expectedRouteIdentity ) } + changeLocalRunner = localRunner ?? { shell, command in + AccountCommandRunner.runLoginShell( + shell: shell, + command: command, + timeout: changeInspectionTimeout, + maximumOutputBytes: maximumChangeOutputBytes + ) + } + changeRemoteRunner = remoteRunner ?? { + host, command, expectedRouteIdentity in + await KwtSSHCommandClient( + maximumOutputBytes: maximumChangeOutputBytes + ).run( + on: host, + command: command, + timeout: changeInspectionTimeout, + expectedRouteIdentity: expectedRouteIdentity + ) + } self.loginShellProvider = loginShellProvider self.localBinaryPath = localBinaryPath self.remoteBinaryRevision = remoteBinaryRevision @@ -275,9 +338,11 @@ struct KwtWorktreeClient: Sendable { func changes( worktreePath: String, - projectPath: String, + expectedRepository: String, + expectedGeneration: String, + expectedRouteIdentity: String? = nil, on host: CommandHost - ) async throws -> WorktreeChangeSummary { + ) async throws -> WorktreeFileChanges { let binaryPrelude: String let windowsKwtRelativePath: String? let platform: SSHHostInfo.Platform @@ -299,42 +364,79 @@ struct KwtWorktreeClient: Sendable { platform = info.platform } let command = Self.changesCommand( - projectPath: projectPath, + worktreePath: worktreePath, + expectedRepository: expectedRepository, + expectedGeneration: expectedGeneration, platform: platform, binaryPrelude: binaryPrelude, windowsKwtRelativePath: windowsKwtRelativePath ) - let result = try await run(command, on: host) - guard result.status == 0 else { - throw KwtWorktreeError.changeStatusFailed( - host: host.displayName, - status: result.status - ) - } + let result = await runChanges( + command, + on: host, + expectedRouteIdentity: expectedRouteIdentity + ) let normalizedOutput = result.stdout.replacingOccurrences( of: "\r\n", with: "\n" ) - guard let markerRange = normalizedOutput.range( + if result.status == AccountCommandRunner.outputExceededStatus { + throw KwtWorktreeError.changeInspectionFailed( + host: host.displayName, + status: result.status, + code: "response_too_large", + message: "kwt returned too many changed files to display.", + retryable: false, + details: [:] + ) + } + let markerRange = normalizedOutput.range( of: Self.jsonMarker, options: .backwards - ) else { + ) + // SSH setup can fail before the remote shell emits our marker. + // Its error envelope still carries the helper's retry decision. + let payloadStart = markerRange?.upperBound ?? normalizedOutput.startIndex + let data = Data(normalizedOutput[payloadStart...].utf8) + guard result.status == 0 else { + let envelope = try? JSONDecoder().decode( + KwtChangeInspectionErrorEnvelope.self, + from: data + ) + throw KwtWorktreeError.changeInspectionFailed( + host: host.displayName, + status: result.status, + code: envelope?.error.code, + message: envelope?.error.message, + retryable: envelope?.error.retryable + ?? Self.isRetryableChangeInspectionStatus(result.status), + details: envelope?.error.details ?? [:] + ) + } + guard markerRange != nil else { throw KwtWorktreeError.malformedChangeStatus( host: host.displayName ) } - let json = normalizedOutput[markerRange.upperBound...] do { - let records = try JSONDecoder().decode( - [KwtWorktreeChangeRecord].self, - from: Data(json.utf8) + let response = try JSONDecoder().decode( + KwtChangeInspectionResponse.self, + from: data ) - guard let record = records.first(where: { - $0.path == worktreePath - }) else { - return .clean + guard response.worktree.repository == expectedRepository, + Self.worktreePath( + response.worktree.path, + matches: worktreePath, + platform: platform + ), + response.worktree.generation == expectedGeneration, + WorktreeGeneration.isCanonical(response.worktree.generation) + else { + throw KwtWorktreeError.malformedChangeStatus( + host: host.displayName + ) } - return record.gitStatus.summary + return response.value.sortedForPresentation() } catch let error as KwtWorktreeError { throw error } catch { @@ -366,6 +468,39 @@ struct KwtWorktreeClient: Sendable { } } + private func runChanges( + _ command: String, + on host: CommandHost, + expectedRouteIdentity: String? + ) async -> (status: Int32, stdout: String) { + let localRunner = changeLocalRunner + let remoteRunner = changeRemoteRunner + let shell = loginShellProvider() + switch host { + case .local: + return await BlockingTask.run(priority: .userInitiated) { + localRunner(shell, command) + } + case let .ssh(info): + let output = await remoteRunner( + info, + command, + expectedRouteIdentity + ) + return (output.status, output.stdout) + } + } + + private static func worktreePath( + _ actual: String, + matches expected: String, + platform: SSHHostInfo.Platform + ) -> Bool { + WorktreeChangePath.matches( + actual, expected, usesWindowsPaths: platform == .windows + ) + } + static func command( branchName: String, createsBranch: Bool, @@ -459,37 +594,83 @@ struct KwtWorktreeClient: Sendable { } private static func changesCommand( - projectPath: String, + worktreePath: String, + expectedRepository: String, + expectedGeneration: String, platform: SSHHostInfo.Platform, binaryPrelude: String, windowsKwtRelativePath: String? ) -> String { if platform == .windows { return KwtPowerShellCommand.run( - arguments: ["status", "--json", "--no-fetch"], - workingDirectory: projectPath, + arguments: [ + "changes", + worktreePath, + "--expected-repository", + expectedRepository, + "--expected-generation", + expectedGeneration, + "--json", + ], marker: "GHOSTHUB_KWT_JSON", managedRelativePath: windowsKwtRelativePath ) } return binaryPrelude - + "cd -- \(shellQuotedCommandArgument(projectPath)) || exit $?; " + "printf 'GHOSTHUB_KWT_JSON\\n'; " - + "exec \"$ghosthub_kwt_path\" status --json --no-fetch" + + "exec \"$ghosthub_kwt_path\" changes " + + shellQuotedCommandArgument(worktreePath) + + " --expected-repository " + + shellQuotedCommandArgument(expectedRepository) + + " --expected-generation " + + shellQuotedCommandArgument(expectedGeneration) + + " --json" + } + + private static func isRetryableChangeInspectionStatus( + _ status: Int32 + ) -> Bool { + status == 255 || status == AccountCommandRunner.timedOutStatus } } -private struct KwtWorktreeChangeRecord: Decodable { - let path: String - let gitStatus: KwtGitStatusRecord +private struct KwtChangeInspectionResponse: Decodable { + let worktree: KwtChangeInspectionIdentity + let changes: KwtChangeSet + let observedAt: String + + var value: WorktreeFileChanges { + WorktreeFileChanges( + repository: worktree.repository, + path: worktree.path, + generation: worktree.generation, + state: changes.state, + summary: changes.summary.value, + files: changes.files, + observedAt: observedAt + ) + } private enum CodingKeys: String, CodingKey { - case path - case gitStatus = "git_status" + case worktree + case changes + case observedAt = "observed_at" } } -private struct KwtGitStatusRecord: Decodable { +private struct KwtChangeInspectionIdentity: Decodable { + let repository: String + let path: String + let generation: String +} + +private struct KwtChangeSet: Decodable { + let state: WorktreeChangeState + let summary: KwtChangeSummary + let files: [WorktreeFileChange] +} + +private struct KwtChangeSummary: Decodable { let modified: Int let added: Int let deleted: Int @@ -497,7 +678,7 @@ private struct KwtGitStatusRecord: Decodable { let staged: Int let conflicts: Int - var summary: WorktreeChangeSummary { + var value: WorktreeChangeSummary { WorktreeChangeSummary( modified: modified, added: added, @@ -508,3 +689,14 @@ private struct KwtGitStatusRecord: Decodable { ) } } + +private struct KwtChangeInspectionErrorEnvelope: Decodable { + let error: KwtChangeInspectionError +} + +private struct KwtChangeInspectionError: Decodable { + let code: String + let message: String + let retryable: Bool + let details: [String: KwtProjectErrorDetail]? +} diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index 6b229b4b..c2f4d15b 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -74,8 +74,9 @@ final class WorkspaceSceneModel: ObservableObject { String, String, String, String?, CommandHost ) async throws -> Void typealias KwtWorktreeChangeReader = @Sendable ( - String, String, CommandHost + String, String, String, String?, CommandHost ) async throws -> WorktreeChangeSummary + typealias KwtWorktreeChangesReader = WorktreeChangesLoaderAuthority.Reader typealias SSHRouteIdentityResolver = @Sendable ( SSHHostInfo ) async throws -> String @@ -886,6 +887,7 @@ final class WorkspaceSceneModel: ObservableObject { private let kwtWorktreeRemover: KwtWorktreeRemover private let kwtForceWorktreeRemover: KwtWorktreeRemover private let kwtWorktreeChangeReader: KwtWorktreeChangeReader + private let kwtWorktreeChangesReader: KwtWorktreeChangesReader private let sshRouteIdentityResolver: SSHRouteIdentityResolver private let kwtBranchLister: KwtBranchLister private let kwtPullRequestLister: KwtPullRequestLister @@ -1143,10 +1145,22 @@ final class WorkspaceSceneModel: ObservableObject { ) }, kwtWorktreeChangeReader: @escaping KwtWorktreeChangeReader = { - worktreePath, projectPath, host in + worktreePath, repository, generation, routeIdentity, host in try await KwtWorktreeClient().changes( worktreePath: worktreePath, - projectPath: projectPath, + expectedRepository: repository, + expectedGeneration: generation, + expectedRouteIdentity: routeIdentity, + on: host + ).summary + }, + kwtWorktreeChangesReader: @escaping KwtWorktreeChangesReader = { + worktreePath, repository, generation, routeIdentity, host in + try await KwtWorktreeClient().changes( + worktreePath: worktreePath, + expectedRepository: repository, + expectedGeneration: generation, + expectedRouteIdentity: routeIdentity, on: host ) }, @@ -1463,6 +1477,7 @@ final class WorkspaceSceneModel: ObservableObject { self.kwtWorktreeRemover = kwtWorktreeRemover self.kwtForceWorktreeRemover = kwtForceWorktreeRemover self.kwtWorktreeChangeReader = kwtWorktreeChangeReader + self.kwtWorktreeChangesReader = kwtWorktreeChangesReader self.sshRouteIdentityResolver = sshRouteIdentityResolver self.kwtBranchLister = kwtBranchLister self.kwtPullRequestLister = kwtPullRequestLister @@ -2747,6 +2762,50 @@ final class WorkspaceSceneModel: ObservableObject { } } + func loadWorktreeChanges( + _ requested: WorktreeSummary + ) async throws -> WorktreeFileChanges { + guard let worktree = snapshot.worktree(id: requested.id), + !worktree.isStale, + worktree.hostID == requested.hostID, + worktree.projectID == requested.projectID, + WorktreeChangePath.matches( + worktree.path, requested.path, + usesWindowsPaths: snapshot.host(id: worktree.hostID)?.platform == .windows + ), + worktree.generation == requested.generation, + let capturedHost = snapshot.host(id: worktree.hostID), + let capturedTarget = CommandHostResolver.resolve(capturedHost) + else { + throw KwtWorktreeError.worktreeUnavailable + } + do { + try await ensureRemoteKwtForOperation(hostID: worktree.hostID) + guard let currentHost = snapshot.host(id: worktree.hostID), + currentHost.platform == capturedHost.platform, + CommandHostResolver.resolve(currentHost) == capturedTarget + else { + throw KwtWorktreeError.worktreeUnavailable + } + return try await WorktreeChangesLoaderAuthority.load( + requested: requested, + in: snapshot, + resolveRouteIdentity: { [sshRouteIdentityResolver] host in + switch host { + case .local: + nil + case let .ssh(info): + try await sshRouteIdentityResolver(info) + } + }, + read: kwtWorktreeChangesReader + ) + } catch { + recordKwtUnavailability(error, hostID: worktree.hostID) + throw error + } + } + func prepareWorktreeRemoval( _ worktreeID: UUID, refreshSessionIdentity: Bool = false @@ -2764,7 +2823,7 @@ final class WorkspaceSceneModel: ObservableObject { guard snapshot.canRemoveWorktree(worktree) else { throw KwtWorktreeError.worktreeUnavailable } - guard WorktreeGeneration.isCanonical( + guard let generation = WorktreeGeneration.canonical( worktree.generation ) else { throw KwtWorktreeError.removalIdentityUnavailable @@ -2774,7 +2833,11 @@ final class WorkspaceSceneModel: ObservableObject { else { throw KwtWorktreeError.removalIdentityUnavailable } - let changes: WorktreeChangeSummary + let changeInspection: ( + summary: WorktreeChangeSummary, + isComplete: Bool + ) + let routeIdentity: String? do { try await ensureRemoteKwtForOperation(hostID: project.hostID) guard validatedProjectOperationTarget( @@ -2783,9 +2846,17 @@ final class WorkspaceSceneModel: ObservableObject { ) != nil else { throw KwtWorktreeError.removalHostChanged } - changes = try await kwtWorktreeChangeReader( + switch host { + case .local: + routeIdentity = nil + case let .ssh(info): + routeIdentity = try await sshRouteIdentityResolver(info) + } + changeInspection = try await worktreeRemovalChangeInspection( worktree.path, - project.rootPath, + project.scopedKey, + generation, + routeIdentity, host ) } catch { @@ -2829,16 +2900,14 @@ final class WorkspaceSceneModel: ObservableObject { } else { sessionKillRequest = nil } - let routeIdentity: String? - switch host { - case .local: - routeIdentity = nil - case let .ssh(info): - if let sessionRouteIdentity = sessionKillRequest?.routeIdentity { - routeIdentity = sessionRouteIdentity - } else { - routeIdentity = try await sshRouteIdentityResolver(info) - } + if let sessionKillRequest, + sessionKillRequest.routeIdentity != routeIdentity { + throw KwtWorktreeError.removalHostChanged + } + guard validatedProjectOperationTarget(project, capturedHost: host) != nil, + try await removalRouteIdentityMatches(routeIdentity, on: host) + else { + throw KwtWorktreeError.removalHostChanged } return WorktreeRemovalRequest( worktree: worktree, @@ -2846,7 +2915,8 @@ final class WorkspaceSceneModel: ObservableObject { confirmedHost: hostSummary, routeIdentity: routeIdentity, sessionKillRequest: sessionKillRequest, - changes: changes + changes: changeInspection.summary, + changeInspectionComplete: changeInspection.isComplete ) } @@ -2953,13 +3023,19 @@ final class WorkspaceSceneModel: ObservableObject { throw KwtWorktreeError.removalTargetChanged } if !checkoutAlreadyAbsent { - let currentChanges: WorktreeChangeSummary + let currentChangeInspection: ( + summary: WorktreeChangeSummary, + isComplete: Bool + ) do { - currentChanges = try await kwtWorktreeChangeReader( - worktree.path, - project.rootPath, - confirmedHost - ) + currentChangeInspection = try await + worktreeRemovalChangeInspection( + worktree.path, + project.scopedKey, + generation, + request.routeIdentity, + confirmedHost + ) } catch { recordKwtUnavailability(error, hostID: project.hostID) throw error @@ -2967,8 +3043,9 @@ final class WorkspaceSceneModel: ObservableObject { guard removalHostEndpointMatches(request) else { throw KwtWorktreeError.removalHostChanged } - if currentChanges.hasUncommittedChanges, - !request.forceRemoval { + if !currentChangeInspection.isComplete + || currentChangeInspection.summary.hasUncommittedChanges, + !request.forceRemoval { throw KwtWorktreeError.removalChangesChanged } } @@ -3062,29 +3139,36 @@ final class WorkspaceSceneModel: ObservableObject { } else { shouldReadChanges = false } - let changes: WorktreeChangeSummary? + let changeInspection: ( + summary: WorktreeChangeSummary, + isComplete: Bool + )? if shouldReadChanges { do { - changes = try await kwtWorktreeChangeReader( - worktree.path, - project.rootPath, - confirmedHost - ) + changeInspection = try await + worktreeRemovalChangeInspection( + worktree.path, + project.scopedKey, + generation, + request.routeIdentity, + confirmedHost + ) } catch { recordKwtUnavailability( error, hostID: project.hostID ) - changes = nil + changeInspection = nil } } else { - changes = nil + changeInspection = nil } if shouldReadChanges, - let changes, + let changeInspection, removalHostEndpointMatches(request), !terminatedSession || killedRestorationTarget != nil, - changes.hasUncommittedChanges { + !changeInspection.isComplete + || changeInspection.summary.hasUncommittedChanges { throw KwtWorktreeError.removalChangesChanged } throw removalError @@ -3414,6 +3498,42 @@ final class WorkspaceSceneModel: ObservableObject { return try await prepareWorktreeRemoval(worktree.id) } + private func worktreeRemovalChangeInspection( + _ worktreePath: String, + _ repository: String, + _ generation: String, + _ routeIdentity: String?, + _ host: CommandHost + ) async throws -> ( + summary: WorktreeChangeSummary, + isComplete: Bool + ) { + let inspection: (summary: WorktreeChangeSummary, isComplete: Bool) + do { + inspection = try await ( + kwtWorktreeChangeReader( + worktreePath, + repository, + generation, + routeIdentity, + host + ), + true + ) + } catch let error as KwtWorktreeError { + guard case let .changeInspectionFailed( + _, _, code, _, _, _ + ) = error, + code == "response_too_large" + else { throw error } + inspection = (.clean, false) + } + guard try await removalRouteIdentityMatches(routeIdentity, on: host) else { + throw KwtWorktreeError.removalHostChanged + } + return inspection + } + private func currentRemovalTarget( for request: WorktreeRemovalRequest ) -> WorktreeSummary? { @@ -3516,6 +3636,8 @@ final class WorkspaceSceneModel: ObservableObject { ) || updatedRequest.routeIdentity != request.routeIdentity || updatedRequest.sessionKillRequest != request.sessionKillRequest || updatedRequest.changes != request.changes + || updatedRequest.changeInspectionComplete + != request.changeInspectionComplete } private func removalHostEndpointMatches( @@ -5228,7 +5350,7 @@ final class WorkspaceSceneModel: ObservableObject { return true } if let worktreeError = error as? KwtWorktreeError, - case .changeStatusFailed(_, 127) = worktreeError { + case .changeInspectionFailed(_, 127, _, _, _, _) = worktreeError { return true } if let pullRequestError = error as? KwtPullRequestError, diff --git a/Sources/App/WorkspaceWindow.swift b/Sources/App/WorkspaceWindow.swift index 23aee2ba..35a8530e 100644 --- a/Sources/App/WorkspaceWindow.swift +++ b/Sources/App/WorkspaceWindow.swift @@ -1100,6 +1100,9 @@ struct WorkspaceWindow: View { currentWorkspaceSnapshot: { [sceneModel] in sceneModel.snapshot }, + loadWorktreeChanges: { [sceneModel] requested in + try await sceneModel.loadWorktreeChanges(requested) + }, refreshWorkspaceInventory: { [sceneModel] in sceneModel.refreshWorkspaceInventory() }, diff --git a/Sources/App/WorktreeChangesLoaderAuthority.swift b/Sources/App/WorktreeChangesLoaderAuthority.swift new file mode 100644 index 00000000..a44ab1ce --- /dev/null +++ b/Sources/App/WorktreeChangesLoaderAuthority.swift @@ -0,0 +1,118 @@ +import GhosthubTransport +import GhosthubTmux +import GhosthubUI +import GhosthubWorkspace + +enum WorktreeChangesLoaderAuthority { + typealias Reader = @Sendable ( + _ path: String, + _ repository: String, + _ generation: String, + _ expectedRouteIdentity: String?, + _ host: CommandHost + ) async throws -> WorktreeFileChanges + typealias RouteIdentityResolver = @Sendable ( + _ host: CommandHost + ) async throws -> String? + + static func load( + requested: WorktreeSummary, + in snapshot: WorkspaceSnapshot, + coordinator: WorktreeChangesReadCoordinator = .shared, + resolveRouteIdentity: @escaping RouteIdentityResolver = { host in + switch host { + case .local: + nil + case let .ssh(info): + try await BlockingTask.runThrowing( + priority: .userInitiated + ) { + try KwtSSHRouteClient().resolve(info).routeIdentity + } + } + }, + read: @escaping Reader = { + path, repository, generation, routeIdentity, host in + try await KwtWorktreeClient().changes( + worktreePath: path, + expectedRepository: repository, + expectedGeneration: generation, + expectedRouteIdentity: routeIdentity, + on: host + ) + } + ) async throws -> WorktreeFileChanges { + guard let worktree = snapshot.worktree(id: requested.id), + !worktree.isStale, + worktree.hostID == requested.hostID, + worktree.projectID == requested.projectID, + WorktreeChangePath.matches( + worktree.path, requested.path, + usesWindowsPaths: snapshot.host(id: worktree.hostID)?.platform == .windows + ), + worktree.generation == requested.generation, + let generation = WorktreeGeneration.canonical( + worktree.generation + ), + let project = snapshot.project(id: worktree.projectID), + !project.isStale, + project.hostID == worktree.hostID, + !project.scopedKey.isEmpty, + let hostSummary = snapshot.host(id: worktree.hostID), + let host = CommandHostResolver.resolve(hostSummary), + let identity = WorktreeChangesIdentity.resolve( + worktreeID: worktree.id, + in: snapshot + ) + else { + throw KwtWorktreeError.worktreeUnavailable + } + return try await coordinator.load(identity: identity) { + let routeIdentity = try await resolveRouteIdentity(host) + return try await read( + worktree.path, + project.scopedKey, + generation, + routeIdentity, + host + ) + } + } +} + +extension KwtRemoteInstallError: WorktreeChangesRetryClassifying { + var requiresInventoryRefresh: Bool { false } + + var isRetryable: Bool { + let status: Int32 + switch self { + case let .targetProbeFailed(value), + let .prepareFailed(value), + let .uploadFailed(value, _), + let .installFailed(value): + status = value + case .invalidHost, .bundleIncomplete, .unsupportedTarget, + .malformedResponse: + return false + } + return status == 255 + || status == AccountCommandRunner.timedOutStatus + } +} + +extension KwtSSHLeaseError: WorktreeChangesRetryClassifying { + var requiresInventoryRefresh: Bool { false } + + var isRetryable: Bool { + SSHConnectionFailure.retryableTransportFailure(self) != nil + } +} + +extension KwtSSHRouteError: WorktreeChangesRetryClassifying { + var requiresInventoryRefresh: Bool { false } + + var isRetryable: Bool { + guard case let .commandFailed(status) = self else { return false } + return status == AccountCommandRunner.timedOutStatus + } +} diff --git a/Sources/App/WorktreeChangesReadCoordinator.swift b/Sources/App/WorktreeChangesReadCoordinator.swift new file mode 100644 index 00000000..dfbd432c --- /dev/null +++ b/Sources/App/WorktreeChangesReadCoordinator.swift @@ -0,0 +1,201 @@ +import Foundation +import GhosthubUI +import GhosthubWorkspace + +actor WorktreeChangesReadCoordinator { + static let shared = WorktreeChangesReadCoordinator( + globalLimit: 4, + perHostLimit: 2 + ) + + typealias Operation = @Sendable () async throws -> WorktreeFileChanges + + private struct WaiterKey: Hashable, Sendable { + let identity: WorktreeChangesIdentity + let id: UUID + } + + private struct ExecutionTarget: Hashable, Sendable { + let hostID: UUID + let worktreeID: UUID + } + + private struct Entry { + let hostID: UUID + let operation: Operation + var waiters: [UUID: CheckedContinuation] + var replacementWaiters: + [UUID: CheckedContinuation] + var replacementOperation: Operation? + var task: Task? + var isCancelling: Bool + } + + private let globalLimit: Int + private let perHostLimit: Int + private var entries: [WorktreeChangesIdentity: Entry] = [:] + private var queue: [WorktreeChangesIdentity] = [] + private var activeCount = 0 + private var activeByHost: [UUID: Int] = [:] + private var activeExecutionTargets: Set = [] + + init(globalLimit: Int, perHostLimit: Int) { + precondition(globalLimit > 0) + precondition(perHostLimit > 0) + self.globalLimit = globalLimit + self.perHostLimit = perHostLimit + } + + func load( + identity: WorktreeChangesIdentity, + operation: @escaping Operation + ) async throws -> WorktreeFileChanges { + let key = WaiterKey(identity: identity, id: UUID()) + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + enqueue( + key: key, + operation: operation, + continuation: continuation + ) + } + } onCancel: { + Task { await self.cancel(key: key) } + } + } + + private func enqueue( + key: WaiterKey, + operation: @escaping Operation, + continuation: CheckedContinuation + ) { + if var entry = entries[key.identity] { + if entry.isCancelling { + entry.replacementWaiters[key.id] = continuation + entry.replacementOperation = operation + } else { + entry.waiters[key.id] = continuation + } + entries[key.identity] = entry + return + } + entries[key.identity] = Entry( + hostID: key.identity.hostID, + operation: operation, + waiters: [key.id: continuation], + replacementWaiters: [:], + replacementOperation: nil, + task: nil, + isCancelling: false + ) + queue.append(key.identity) + schedule() + } + + private func cancel(key: WaiterKey) { + guard var entry = entries[key.identity] else { + return + } + if let continuation = entry.replacementWaiters.removeValue( + forKey: key.id + ) { + continuation.resume(throwing: CancellationError()) + entries[key.identity] = entry + return + } + guard let continuation = entry.waiters.removeValue(forKey: key.id) + else { return } + continuation.resume(throwing: CancellationError()) + if entry.waiters.isEmpty { + if let task = entry.task { + entry.isCancelling = true + entries[key.identity] = entry + task.cancel() + } else { + entries.removeValue(forKey: key.identity) + queue.removeAll { $0 == key.identity } + } + } else { + entries[key.identity] = entry + } + } + + private func schedule() { + while activeCount < globalLimit, + let queueIndex = queue.firstIndex(where: { identity in + guard let entry = entries[identity] else { return false } + return activeByHost[entry.hostID, default: 0] < perHostLimit + && !activeExecutionTargets.contains( + executionTarget(for: identity) + ) + }) { + let identity = queue.remove(at: queueIndex) + guard var entry = entries[identity], entry.task == nil else { + continue + } + activeCount += 1 + activeByHost[entry.hostID, default: 0] += 1 + activeExecutionTargets.insert(executionTarget(for: identity)) + let operation = entry.operation + entry.task = Task { + let result: Result + do { + result = await .success(try operation()) + } catch { + result = .failure(error) + } + self.finish(identity: identity, result: result) + } + entries[identity] = entry + } + } + + private func finish( + identity: WorktreeChangesIdentity, + result: Result + ) { + guard let entry = entries.removeValue(forKey: identity) else { + return + } + activeCount -= 1 + activeExecutionTargets.remove(executionTarget(for: identity)) + let remainingForHost = activeByHost[entry.hostID, default: 1] - 1 + if remainingForHost == 0 { + activeByHost.removeValue(forKey: entry.hostID) + } else { + activeByHost[entry.hostID] = remainingForHost + } + if entry.isCancelling, + !entry.replacementWaiters.isEmpty, + let replacementOperation = entry.replacementOperation { + entries[identity] = Entry( + hostID: entry.hostID, + operation: replacementOperation, + waiters: entry.replacementWaiters, + replacementWaiters: [:], + replacementOperation: nil, + task: nil, + isCancelling: false + ) + queue.append(identity) + } else { + for continuation in entry.waiters.values { + continuation.resume(with: result) + } + } + schedule() + } + + private func executionTarget( + for identity: WorktreeChangesIdentity + ) -> ExecutionTarget { + ExecutionTarget( + hostID: identity.hostID, + worktreeID: identity.worktreeID + ) + } +} diff --git a/Sources/UI/RootView.swift b/Sources/UI/RootView.swift index 2098bebc..c0d3e96a 100644 --- a/Sources/UI/RootView.swift +++ b/Sources/UI/RootView.swift @@ -50,8 +50,8 @@ public struct RootView: View { @State private var addProjectHost: HostSummary? @State private var workspaceAlert: WorkspaceAlert? @State private var pendingWorktreeRemoval: WorktreeRemovalRequest? - // Retain the first confirmed generation for every runtime ID encountered - // while reconfirming so ID reuse cannot disguise a displaced target. + /// Retain the first confirmed generation for every runtime ID encountered + /// while reconfirming so ID reuse cannot disguise a displaced target. @State private var pendingWorktreeRemovals: [UUID: WorkspacePresentationLifecycle.PendingWorktreeRemovalIdentity] = [:] @State private var sessionRecoveryRequestRouter = @@ -713,6 +713,17 @@ public struct RootView: View { onRequestKillTmuxSession: requestSessionKill, onRequestKillZellijSession: requestZellijSessionKill, onRequestRemoveWorktree: requestWorktreeRemoval, + isWorktreeChangesPollingEligible: + WorktreeChangesPollingEligibility.isEligible( + sidebarVisible: isSidebarVisible, + applicationActive: controlActiveState == .key + && NSApplication.shared.isActive, + permitsBackgroundDemoControl: permitsBackgroundDemoControl + ), + currentSnapshot: { + handlers.currentWorkspaceSnapshot?() ?? snapshot + }, + loadWorktreeChanges: handlers.loadWorktreeChanges, onRequestRemoveProject: requestProjectRemoval, onOpenProjectWorktreesAsTabs: { project, worktrees in handlers.openProjectWorktreesAsTabs?(project, worktrees) diff --git a/Sources/UI/RootViewConfiguration.swift b/Sources/UI/RootViewConfiguration.swift index ec528d39..ddc3efe7 100644 --- a/Sources/UI/RootViewConfiguration.swift +++ b/Sources/UI/RootViewConfiguration.swift @@ -209,9 +209,10 @@ public struct WorktreeRemovalRequest: Equatable, Sendable { public let routeIdentity: String? public let sessionKillRequest: TmuxSessionKillRequest? public let changes: WorktreeChangeSummary + public let changeInspectionComplete: Bool public var forceRemoval: Bool { - changes.hasUncommittedChanges + changes.hasUncommittedChanges || !changeInspectionComplete } public init( @@ -220,7 +221,8 @@ public struct WorktreeRemovalRequest: Equatable, Sendable { confirmedHost: HostSummary, routeIdentity: String? = nil, sessionKillRequest: TmuxSessionKillRequest? = nil, - changes: WorktreeChangeSummary = .clean + changes: WorktreeChangeSummary = .clean, + changeInspectionComplete: Bool = true ) { self.worktree = worktree self.project = project @@ -228,6 +230,7 @@ public struct WorktreeRemovalRequest: Equatable, Sendable { self.routeIdentity = routeIdentity self.sessionKillRequest = sessionKillRequest self.changes = changes + self.changeInspectionComplete = changeInspectionComplete } var worktreeRemovalActionTitle: String { @@ -239,11 +242,18 @@ public struct WorktreeRemovalRequest: Equatable, Sendable { ? "" : " Its live tmux session will be terminated first," + " including every window, pane, and process." - let changesMessage = forceRemoval - ? " This worktree has uncommitted changes. Force removal" - + " permanently discards every staged, unstaged, and untracked" - + " change in it." - : "" + let changesMessage: String + if !changeInspectionComplete { + changesMessage = " Kwt could not enumerate every changed file." + + " Force removal may permanently discard staged, unstaged," + + " and untracked changes in this worktree." + } else if forceRemoval { + changesMessage = " This worktree has uncommitted changes." + + " Force removal permanently discards every staged," + + " unstaged, and untracked change in it." + } else { + changesMessage = "" + } return "This removes the worktree at \(worktree.path)." + sessionMessage + changesMessage @@ -459,6 +469,7 @@ public struct InteractionHandlers { public let createTmuxSession: ((WorkspaceTmuxSessionCreationRequest) -> Void)? public let currentWorkspaceSnapshot: (() -> WorkspaceSnapshot)? + public let loadWorktreeChanges: WorktreeChangesLoader? public let refreshWorkspaceInventory: (() -> Void)? public let reconnectActiveTmuxSessionNow: (() -> Void)? public let reconnectActiveHerdrSessionNow: (() -> Void)? @@ -551,6 +562,7 @@ public struct InteractionHandlers { createTmuxSession: ((WorkspaceTmuxSessionCreationRequest) -> Void)? = nil, currentWorkspaceSnapshot: (() -> WorkspaceSnapshot)? = nil, + loadWorktreeChanges: WorktreeChangesLoader? = nil, refreshWorkspaceInventory: (() -> Void)? = nil, reconnectActiveTmuxSessionNow: (() -> Void)? = nil, reconnectActiveHerdrSessionNow: (() -> Void)? = nil, @@ -622,6 +634,7 @@ public struct InteractionHandlers { self.applyTmuxSessionTheme = applyTmuxSessionTheme self.createTmuxSession = createTmuxSession self.currentWorkspaceSnapshot = currentWorkspaceSnapshot + self.loadWorktreeChanges = loadWorktreeChanges self.refreshWorkspaceInventory = refreshWorkspaceInventory self.reconnectActiveTmuxSessionNow = reconnectActiveTmuxSessionNow self.reconnectActiveHerdrSessionNow = reconnectActiveHerdrSessionNow diff --git a/Sources/UI/WorkspaceSidebarPresentation.swift b/Sources/UI/WorkspaceSidebarPresentation.swift index 515a15b4..70df98ce 100644 --- a/Sources/UI/WorkspaceSidebarPresentation.swift +++ b/Sources/UI/WorkspaceSidebarPresentation.swift @@ -146,10 +146,11 @@ struct WorkspaceWorktreeRemovalActionPresentation: Equatable { init( isRemovable: Bool, isRowHovered: Bool, - isActionHovered: Bool + isActionHovered: Bool, + isFocused: Bool ) { isVisible = isRemovable - && (isRowHovered || isActionHovered) + && (isRowHovered || isActionHovered || isFocused) reservedWidth = isRemovable ? Self.controlWidth : 0 hitTargetWidth = isRemovable ? Self.controlWidth : 0 } @@ -325,6 +326,18 @@ enum WorkspaceSidebarHierarchy { } } +struct WorkspaceWorktreeDisclosurePresentation: Equatable { + let leadingIndent: CGFloat + let contentIndentLevel: Int + + init(rowIndentLevel: Int) { + leadingIndent = WorkspaceSidebarHierarchy.indent( + level: rowIndentLevel + ) + contentIndentLevel = 0 + } +} + enum WorkspaceSidebarInventorySection: Hashable { case tmuxSessions case herdrSessions diff --git a/Sources/UI/WorkspaceSidebarView.swift b/Sources/UI/WorkspaceSidebarView.swift index be8758ed..6adc0a79 100644 --- a/Sources/UI/WorkspaceSidebarView.swift +++ b/Sources/UI/WorkspaceSidebarView.swift @@ -145,6 +145,13 @@ private struct WorkspaceSidebarReorderIndicator: Equatable { let placement: WorkspaceSidebarDropPlacement } +private struct WorktreeChangesTaskID: Hashable { + let identity: WorktreeChangesIdentity + let isEligible: Bool + let manualRefreshRevision: UInt64 + let resumeRevision: UInt64 +} + // MARK: - WorkspaceSidebarView struct WorkspaceSidebarView: View { @@ -185,6 +192,10 @@ struct WorkspaceSidebarView: View { let onRequestKillTmuxSession: (WorkspaceTmuxSessionSelection) -> Void let onRequestKillZellijSession: (WorkspaceZellijSessionSelection) -> Void let onRequestRemoveWorktree: (WorktreeSummary) -> Void + let isWorktreeChangesPollingEligible: Bool + let currentSnapshot: @MainActor () -> WorkspaceSnapshot + let loadWorktreeChanges: WorktreeChangesLoader? + let worktreeChangesSleep: WorktreeChangesSleep let onRequestRemoveProject: (ProjectSummary) -> Void let onOpenProjectWorktreesAsTabs: (ProjectSummary, [WorktreeSummary]) -> Void @@ -210,7 +221,9 @@ struct WorkspaceSidebarView: View { @State private var sessionActionHoverDismissTask: Task? @State private var hoveredWorktreeID: UUID? @State private var hoveredWorktreeActionID: UUID? + @FocusState private var focusedWorktreeActionID: UUID? @State private var worktreeHoverDismissTask: Task? + @StateObject private var worktreeChanges = WorktreeChangesStore() @State private var hoveredProjectID: UUID? @State private var draggedSidebarItem: WorkspaceSidebarDragItem? @State private var reorderIndicator: @@ -277,6 +290,12 @@ struct WorkspaceSidebarView: View { onRequestRemoveWorktree: @escaping ( WorktreeSummary ) -> Void = { _ in }, + isWorktreeChangesPollingEligible: Bool = false, + currentSnapshot: (@MainActor () -> WorkspaceSnapshot)? = nil, + loadWorktreeChanges: WorktreeChangesLoader? = nil, + worktreeChangesSleep: @escaping WorktreeChangesSleep = { + try await Task.sleep(for: $0) + }, onRequestRemoveProject: @escaping ( ProjectSummary ) -> Void = { _ in }, @@ -333,6 +352,11 @@ struct WorkspaceSidebarView: View { self.onRequestKillTmuxSession = onRequestKillTmuxSession self.onRequestKillZellijSession = onRequestKillZellijSession self.onRequestRemoveWorktree = onRequestRemoveWorktree + self.isWorktreeChangesPollingEligible = + isWorktreeChangesPollingEligible + self.currentSnapshot = currentSnapshot ?? { snapshot } + self.loadWorktreeChanges = loadWorktreeChanges + self.worktreeChangesSleep = worktreeChangesSleep self.onRequestRemoveProject = onRequestRemoveProject self.onOpenProjectWorktreesAsTabs = onOpenProjectWorktreesAsTabs self.canOpenProjectWorktreesAsTabs = @@ -1428,16 +1452,24 @@ struct WorkspaceSidebarView: View { activeSelection: activeTmuxSession, activeSelectionIsConnected: activeTmuxSessionIsConnected ) + let isExpanded = worktreeChanges.isExpanded(worktreeID) let isActionHovered = hoveredWorktreeActionID == worktreeID let actionPresentation = WorkspaceWorktreeRemovalActionPresentation( isRemovable: isRemovable, isRowHovered: hoveredWorktreeID == worktreeID, - isActionHovered: isActionHovered + isActionHovered: isActionHovered, + isFocused: focusedWorktreeActionID == worktreeID ) - let content = AnyView( + let disclosurePresentation = + WorkspaceWorktreeDisclosurePresentation( + rowIndentLevel: row.indentLevel + ) + var contentRow = row + contentRow.indentLevel = disclosurePresentation.contentIndentLevel + let worktreeRow = AnyView( sidebarButton( - row, + contentRow, reservedTrailingActionWidth: actionPresentation.reservedWidth ) @@ -1463,17 +1495,19 @@ struct WorkspaceSidebarView: View { .background { if actionPresentation.isVisible { RoundedRectangle(cornerRadius: 5) - .fill( - Color.primary.opacity( - isActionHovered ? 0.14 : 0.05 - ) - ) + .fill(Color.primary.opacity( + isActionHovered ? 0.14 : 0.05 + )) } } .contentShape(Rectangle()) } .buttonStyle(.plain) .foregroundStyle(.secondary) + .focused( + $focusedWorktreeActionID, + equals: worktreeID + ) .onHover { isHovered in if isHovered { worktreeHoverDismissTask?.cancel() @@ -1502,17 +1536,35 @@ struct WorkspaceSidebarView: View { } } .contextMenu { + Button(isExpanded ? "Hide Changes" : "Show Changes") { + worktreeChanges.setExpanded( + !isExpanded, + worktreeID: worktreeID + ) + } if let runningTmuxSession { + Divider() Button("Kill Session…", role: .destructive) { onRequestKillTmuxSession(runningTmuxSession) } } if isRemovable { + if runningTmuxSession == nil { + Divider() + } Button("Remove Worktree…", role: .destructive) { onRequestRemoveWorktree(worktree) } } } + .accessibilityAction( + named: isExpanded ? "Hide Changes" : "Show Changes" + ) { + worktreeChanges.setExpanded( + !isExpanded, + worktreeID: worktreeID + ) + } .accessibilityAction(named: "Remove Worktree") { if isRemovable { onRequestRemoveWorktree(worktree) @@ -1538,7 +1590,104 @@ struct WorkspaceSidebarView: View { ) ) ) - return tmuxPreviewRow(row, content: content) + let content = AnyView( + HStack(spacing: 0) { + worktreeChangesDisclosureButton( + worktree, + isExpanded: isExpanded + ) + worktreeRow + } + .padding(.leading, disclosurePresentation.leadingIndent) + ) + let rowContent = tmuxPreviewRow(row, content: content) + guard isExpanded else { return rowContent } + return AnyView( + VStack(alignment: .leading, spacing: 4) { + rowContent + worktreeChangesPanel(for: worktree) + .padding(.leading, 28) + .padding(.trailing, 6) + } + ) + } + + private func worktreeChangesDisclosureButton( + _ worktree: WorktreeSummary, + isExpanded: Bool + ) -> some View { + Button { + worktreeChanges.setExpanded( + !isExpanded, + worktreeID: worktree.id + ) + } label: { + hierarchyDisclosureIcon(isExpanded: isExpanded) + } + .buttonStyle(.plain) + .workspaceAccessibility( + WorkspaceAccessibilityModel.disclosureDescriptor( + title: "Changes for \(worktree.name)", + isExpanded: isExpanded + ) + ) + .help(isExpanded ? "Hide changes" : "Show changes") + .accessibilityIdentifier( + "worktree-changes-disclosure-\(worktree.id.uuidString)" + ) + } + + private func worktreeChangesPanel( + for worktree: WorktreeSummary + ) -> AnyView { + guard let identity = WorktreeChangesIdentity.resolve( + worktreeID: worktree.id, + in: snapshot + ) else { + var entry = WorktreeChangesEntry() + entry.errorMessage = "Changed files are unavailable for this worktree." + return AnyView(WorktreeChangesView( + entry: entry, + onRefresh: onRefreshInventory + )) + } + guard let loadWorktreeChanges else { + var entry = WorktreeChangesEntry() + entry.errorMessage = "Changed-file loading is unavailable." + return AnyView(WorktreeChangesView( + entry: entry, + onRefresh: nil + )) + } + let entry = worktreeChanges.entry(for: identity) + let taskID = WorktreeChangesTaskID( + identity: identity, + isEligible: isWorktreeChangesPollingEligible, + manualRefreshRevision: entry.manualRefreshRevision, + resumeRevision: entry.resumeRevision + ) + return AnyView( + WorktreeChangesView( + entry: entry, + onRefresh: { + worktreeChanges.requestManualRefresh( + for: identity, + refreshInventory: onRefreshInventory + ) + } + ) + .task(id: taskID) { + await WorktreeChangesPollLoop.run( + identity: identity, + worktree: worktree, + store: worktreeChanges, + currentSnapshot: currentSnapshot, + isEligible: { isWorktreeChangesPollingEligible }, + load: loadWorktreeChanges, + sleep: worktreeChangesSleep + ) + } + ) } private func tmuxPreviewRow( @@ -2090,6 +2239,7 @@ struct WorkspaceSidebarView: View { inventoryWarningsByHost: inventoryWarningsByHost ) else { return } + worktreeChanges.prune(in: snapshot) var worktreeOrder = WorkspaceSidebarOrder( rawValue: worktreeOrderRawValue ) diff --git a/Sources/UI/WorktreeChangesState.swift b/Sources/UI/WorktreeChangesState.swift new file mode 100644 index 00000000..a481a2aa --- /dev/null +++ b/Sources/UI/WorktreeChangesState.swift @@ -0,0 +1,469 @@ +import Combine +import Foundation +import GhosthubWorkspace + +public struct WorktreeChangesIdentity: Hashable, Sendable { + public let worktreeID: UUID + public let hostID: UUID + public let hostRouteKey: String + public let projectID: UUID + public let registrationFingerprint: String + public let repository: String + public let path: String + public let generation: String + public let usesWindowsPaths: Bool + + public static func resolve( + worktreeID: UUID, + in snapshot: WorkspaceSnapshot + ) -> Self? { + guard let worktree = snapshot.worktree(id: worktreeID) else { + return nil + } + return resolve( + worktree: worktree, + host: snapshot.host(id: worktree.hostID), + project: snapshot.project(id: worktree.projectID) + ) + } + + static func resolve( + worktree: WorktreeSummary, + host: HostSummary?, + project: ProjectSummary? + ) -> Self? { + guard !worktree.isStale, + let host, + let project, + project.hostID == host.id, + !project.isStale, + !project.scopedKey.isEmpty, + let generation = canonicalGeneration( + worktree.generation + ), + isAbsolutePath( + worktree.path, + usesWindowsPaths: host.platform == .windows + ), + let hostRouteKey = hostRouteKey(for: host) + else { return nil } + return Self( + worktreeID: worktree.id, + hostID: host.id, + hostRouteKey: hostRouteKey, + projectID: project.id, + registrationFingerprint: project.registrationFingerprint, + repository: project.scopedKey, + path: WorktreeChangePath.key( + worktree.path, usesWindowsPaths: host.platform == .windows + ), + generation: generation, + usesWindowsPaths: host.platform == .windows + ) + } + + public func matches( + result: WorktreeFileChanges, + in snapshot: WorkspaceSnapshot + ) -> Bool { + guard Self.resolve(worktreeID: worktreeID, in: snapshot) == self + else { return false } + return result.repository == repository + && WorktreeChangePath.matches( + result.path, + path, + usesWindowsPaths: usesWindowsPaths + ) + && result.generation == generation + } + + private static func isAbsolutePath( + _ path: String, + usesWindowsPaths: Bool + ) -> Bool { + if usesWindowsPaths { + return path.range( + of: #"^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+(?:[\\/]|$))"#, + options: .regularExpression + ) != nil + } + return path.hasPrefix("/") + } + + private static func canonicalGeneration(_ value: String?) -> String? { + guard let value, + value.range( + of: #"^[0-9a-f]{32}$"#, + options: .regularExpression + ) != nil + else { return nil } + return value + } + + private static func hostRouteKey(for host: HostSummary) -> String? { + let platform = host.platform.rawValue + guard host.kind == .remote else { + return "local:\(platform)" + } + guard let destination = host.sshDestination? + .trimmingCharacters(in: .whitespacesAndNewlines), + !destination.isEmpty + else { return nil } + return "ssh:\(platform):\(destination)" + } +} + +public struct WorktreeChangesEntry: Equatable, Sendable { + public var files: [WorktreeFileChange] = [] + public var hasSuccessfulValue = false + public var isLoading = false + public var errorMessage: String? + public var isStale = false + public var requiresManualRefresh = false + public var requiresInventoryRefresh = false + public var manualRefreshRevision: UInt64 = 0 + public var resumeRevision: UInt64 = 0 + + public init() {} + + fileprivate func hasSameMetadata( + as other: WorktreeChangesEntry + ) -> Bool { + hasSuccessfulValue == other.hasSuccessfulValue + && isLoading == other.isLoading + && errorMessage == other.errorMessage + && isStale == other.isStale + && requiresManualRefresh == other.requiresManualRefresh + && requiresInventoryRefresh == other.requiresInventoryRefresh + && manualRefreshRevision == other.manualRefreshRevision + && resumeRevision == other.resumeRevision + } +} + +enum WorktreeChangesComparison { + static func filesChanged( + previous: [WorktreeFileChange], + current: [WorktreeFileChange] + ) async -> Bool { + await Task.detached(priority: .utility) { + previous != current + }.value + } +} + +public typealias WorktreeChangesLoader = @Sendable ( + WorktreeSummary +) async throws -> WorktreeFileChanges +public typealias WorktreeChangesSleep = @Sendable (Duration) async throws -> Void + +@MainActor +public final class WorktreeChangesStore: ObservableObject { + private static let collapsedSnapshotLimit = 8 + + @Published public private(set) var expandedWorktreeIDs: Set = [] + @Published public private(set) var entries: + [WorktreeChangesIdentity: WorktreeChangesEntry] = [:] + private var activeRequestByWorktreeID: [UUID: UUID] = [:] + private var restartAfterInFlight: Set = [] + private var manualRestartAfterInFlight: Set = [] + private var collapsedWorktreeIDs: [UUID] = [] + + public init() {} + + public func isExpanded(_ worktreeID: UUID) -> Bool { + expandedWorktreeIDs.contains(worktreeID) + } + + public func setExpanded(_ expanded: Bool, worktreeID: UUID) { + if expanded { + expandedWorktreeIDs.insert(worktreeID) + collapsedWorktreeIDs.removeAll { $0 == worktreeID } + } else { + expandedWorktreeIDs.remove(worktreeID) + collapsedWorktreeIDs.removeAll { $0 == worktreeID } + collapsedWorktreeIDs.append(worktreeID) + while collapsedWorktreeIDs.count + > Self.collapsedSnapshotLimit { + let evictedID = collapsedWorktreeIDs.removeFirst() + entries = entries.filter { $0.key.worktreeID != evictedID } + } + } + } + + public func entry(for identity: WorktreeChangesIdentity) -> WorktreeChangesEntry { + entries[identity] ?? WorktreeChangesEntry() + } + + func successfulFiles( + for identity: WorktreeChangesIdentity + ) -> [WorktreeFileChange]? { + guard let entry = entries[identity], entry.hasSuccessfulValue + else { return nil } + return entry.files + } + + public func beginRequest(for identity: WorktreeChangesIdentity) -> UUID? { + var entry = entry(for: identity) + guard activeRequestByWorktreeID[identity.worktreeID] == nil else { + return nil + } + let requestID = UUID() + activeRequestByWorktreeID[identity.worktreeID] = requestID + if !entry.hasSuccessfulValue { + entry.isLoading = true + entry.errorMessage = nil + if entries[identity] != entry { + entries[identity] = entry + } + } + return requestID + } + + public func requestRestartAfterInFlight(for identity: WorktreeChangesIdentity) { + restartAfterInFlight.insert(identity) + } + + public func finishRequest( + _ requestID: UUID, + for identity: WorktreeChangesIdentity, + result: Result, + publishResult: Bool, + filesChanged: Bool + ) { + guard activeRequestByWorktreeID[identity.worktreeID] == requestID + else { return } + activeRequestByWorktreeID.removeValue( + forKey: identity.worktreeID + ) + let waitingIdentities = restartAfterInFlight.filter { + $0.worktreeID == identity.worktreeID + } + if let existingEntry = entries[identity] { + var completedEntry = existingEntry + var publishesFiles = false + completedEntry.isLoading = existingEntry.isLoading + && !waitingIdentities.isEmpty + if publishResult { + switch result { + case let .success(value): + if filesChanged { + completedEntry.files = value.files + publishesFiles = true + } + completedEntry.hasSuccessfulValue = true + completedEntry.errorMessage = nil + completedEntry.isStale = false + completedEntry.requiresManualRefresh = false + completedEntry.requiresInventoryRefresh = false + case let .failure(error): + completedEntry.errorMessage = error.localizedDescription + completedEntry.isStale = completedEntry.hasSuccessfulValue + completedEntry.requiresInventoryRefresh = ( + error as? any WorktreeChangesRetryClassifying + )?.requiresInventoryRefresh ?? false + if completedEntry.requiresInventoryRefresh { + completedEntry.isLoading = false + } + completedEntry.requiresManualRefresh = !( + (error as? any WorktreeChangesRetryClassifying)? + .isRetryable ?? true + ) + } + } + if publishesFiles + || !existingEntry.hasSameMetadata(as: completedEntry) { + entries[identity] = completedEntry + } + } + for waitingIdentity in waitingIdentities { + restartAfterInFlight.remove(waitingIdentity) + var waitingEntry = entry(for: waitingIdentity) + if manualRestartAfterInFlight.remove(waitingIdentity) != nil, + !waitingEntry.requiresInventoryRefresh { + waitingEntry.requiresManualRefresh = false + } + waitingEntry.resumeRevision &+= 1 + entries[waitingIdentity] = waitingEntry + } + } + + public func requestManualRefresh( + for identity: WorktreeChangesIdentity, + refreshInventory: () -> Void + ) { + var entry = entry(for: identity) + guard !entry.requiresInventoryRefresh else { + refreshInventory() + return + } + entry.isLoading = true + entry.requiresManualRefresh = false + guard activeRequestByWorktreeID[identity.worktreeID] == nil else { + entries[identity] = entry + manualRestartAfterInFlight.insert(identity) + requestRestartAfterInFlight(for: identity) + return + } + entry.manualRefreshRevision &+= 1 + entries[identity] = entry + } + + public func prune(in snapshot: @autoclosure () -> WorkspaceSnapshot) { + let trackedWorktreeIDs = expandedWorktreeIDs + .union(entries.keys.map(\.worktreeID)) + .union(activeRequestByWorktreeID.keys) + .union(collapsedWorktreeIDs) + guard !trackedWorktreeIDs.isEmpty else { return } + let snapshot = snapshot() + let hostsByID = snapshot.hostsByID + let projectsByID = snapshot.projectsByID + let identities = Set(snapshot.worktrees.compactMap { worktree -> WorktreeChangesIdentity? in + guard trackedWorktreeIDs.contains(worktree.id) else { return nil } + return WorktreeChangesIdentity.resolve( + worktree: worktree, + host: hostsByID[worktree.hostID], + project: projectsByID[worktree.projectID] + ) + }) + prune(keeping: identities) + } + + public func prune(keeping identities: Set) { + let worktreeIDs = Set(identities.map(\.worktreeID)) + let retainedEntries = entries.filter { + identities.contains($0.key) + } + if retainedEntries.count != entries.count { + entries = retainedEntries + } + let retainedExpandedIDs = expandedWorktreeIDs.intersection( + worktreeIDs + ) + if retainedExpandedIDs != expandedWorktreeIDs { + expandedWorktreeIDs = retainedExpandedIDs + } + activeRequestByWorktreeID = activeRequestByWorktreeID.filter { + worktreeIDs.contains($0.key) + } + restartAfterInFlight.formIntersection(identities) + manualRestartAfterInFlight.formIntersection(identities) + collapsedWorktreeIDs.removeAll { !worktreeIDs.contains($0) } + } +} + +public enum WorktreeChangesPollingPolicy { + public static let interval: Duration = .seconds(5) + + public static func retryDelay( + after failureCount: Int, + identity: WorktreeChangesIdentity + ) -> Duration { + let exponent = min(max(0, failureCount - 1), 3) + let seconds = 5 * (1 << exponent) + let scalarSum = identity.worktreeID.uuidString.unicodeScalars + .reduce(0) { $0 + Int($1.value) } + let jitterMilliseconds = 250 + scalarSum % 750 + return .seconds(seconds) + .milliseconds(jitterMilliseconds) + } +} + +public enum WorktreeChangesPollingEligibility { + public static func isEligible( + sidebarVisible: Bool, + applicationActive: Bool, + permitsBackgroundDemoControl: Bool + ) -> Bool { + sidebarVisible + && (applicationActive || permitsBackgroundDemoControl) + } +} + +public enum WorktreeChangesPollLoop { + @MainActor + public static func run( + identity: WorktreeChangesIdentity, + worktree: WorktreeSummary, + store: WorktreeChangesStore, + currentSnapshot: @escaping @MainActor () -> WorkspaceSnapshot, + isEligible: @escaping @MainActor () -> Bool, + load: @escaping WorktreeChangesLoader, + sleep: @escaping WorktreeChangesSleep + ) async { + var consecutiveFailureCount = 0 + while !Task.isCancelled, + store.isExpanded(identity.worktreeID), + !store.entry(for: identity).requiresManualRefresh, + isEligible() { + guard let requestID = store.beginRequest(for: identity) else { + store.requestRestartAfterInFlight(for: identity) + return + } + let previousFiles = store.successfulFiles(for: identity) + let result: Result + let filesChanged: Bool + do { + let value = try await load(worktree) + if let previousFiles { + filesChanged = await WorktreeChangesComparison.filesChanged( + previous: previousFiles, + current: value.files + ) + } else { + filesChanged = true + } + result = .success(value) + } catch { + result = .failure(error) + filesChanged = false + } + let publishResult: Bool + switch result { + case let .success(value): + publishResult = !Task.isCancelled + && store.isExpanded(identity.worktreeID) + && isEligible() + && identity.matches( + result: value, + in: currentSnapshot() + ) + case .failure: + publishResult = !Task.isCancelled + && store.isExpanded(identity.worktreeID) + && isEligible() + && WorktreeChangesIdentity.resolve( + worktreeID: identity.worktreeID, + in: currentSnapshot() + ) == identity + } + store.finishRequest( + requestID, + for: identity, + result: result, + publishResult: publishResult, + filesChanged: filesChanged + ) + guard !Task.isCancelled, publishResult else { return } + let delay: Duration + switch result { + case .success: + consecutiveFailureCount = 0 + delay = WorktreeChangesPollingPolicy.interval + case let .failure(error): + let isRetryable = ( + error as? any WorktreeChangesRetryClassifying + )?.isRetryable ?? true + guard isRetryable else { return } + consecutiveFailureCount += 1 + delay = WorktreeChangesPollingPolicy.retryDelay( + after: consecutiveFailureCount, + identity: identity + ) + } + do { + try await sleep(delay) + } catch { + return + } + } + } +} diff --git a/Sources/UI/WorktreeChangesView.swift b/Sources/UI/WorktreeChangesView.swift new file mode 100644 index 00000000..b744c9e6 --- /dev/null +++ b/Sources/UI/WorktreeChangesView.swift @@ -0,0 +1,257 @@ +import GhosthubWorkspace +import SwiftUI + +public enum WorktreeFileChangePresentation { + public static func label(for state: WorktreeFileState) -> String { + switch state { + case .modified: "Modified" + case .added: "Added" + case .deleted: "Deleted" + case .renamed: "Renamed" + case .copied: "Copied" + case .conflicted: "Conflict" + case .untracked: "Untracked" + } + } + + public static func symbol(for state: WorktreeFileState) -> String { + switch state { + case .modified: "M" + case .added: "A" + case .deleted: "D" + case .renamed: "R" + case .copied: "C" + case .conflicted: "!" + case .untracked: "?" + } + } + + public static func statusCode( + index: WorktreeFileState?, + worktree: WorktreeFileState? + ) -> String { + if index == .untracked || worktree == .untracked { + return "??" + } + return (index.map(symbol(for:)) ?? " ") + + (worktree.map(symbol(for:)) ?? " ") + } + + public static func accessibilityValue( + for file: WorktreeFileChange + ) -> String { + var values = [file.path] + if let originalPath = file.originalPath { + let action = file.index == .copied || file.worktree == .copied + ? "copied" + : "renamed" + values.append("\(action) from \(originalPath)") + } + if let index = file.index { + values.append("staged \(label(for: index).lowercased())") + } + if let worktree = file.worktree { + values.append( + "working tree \(label(for: worktree).lowercased())" + ) + } + return values.joined(separator: ", ") + } +} + +public enum WorktreeChangesPresentation { + public static let filePageSize = 200 + + public static func showsLoadingChrome( + for entry: WorktreeChangesEntry + ) -> Bool { + entry.isLoading && !entry.hasSuccessfulValue + } + + public static func showsActivityIndicator( + for entry: WorktreeChangesEntry + ) -> Bool { + entry.isLoading + } + + public static func showsRetry( + for entry: WorktreeChangesEntry, + canRefresh: Bool + ) -> Bool { + canRefresh && !entry.hasSuccessfulValue && entry.errorMessage != nil + } + + public static func page( + files: [WorktreeFileChange], + requestedCount: Int + ) -> WorktreeChangesPage { + let visibleCount = min( + files.count, + max(filePageSize, requestedCount) + ) + return WorktreeChangesPage( + files: Array(files.prefix(visibleCount)), + remainingCount: files.count - visibleCount, + nextRequestedCount: min( + files.count, + visibleCount + filePageSize + ) + ) + } + + public static func adjustedRequestedCount( + _ requestedCount: Int, + forFileCount fileCount: Int + ) -> Int { + max(filePageSize, min(requestedCount, fileCount)) + } +} + +public struct WorktreeChangesPage: Equatable, Sendable { + public let files: [WorktreeFileChange] + public let remainingCount: Int + public let nextRequestedCount: Int +} + +public struct WorktreeChangesView: View { + public let entry: WorktreeChangesEntry + public let onRefresh: (() -> Void)? + @State private var requestedFileCount = + WorktreeChangesPresentation.filePageSize + + public init( + entry: WorktreeChangesEntry, + onRefresh: (() -> Void)? + ) { + self.entry = entry + self.onRefresh = onRefresh + } + + public var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text("Changes") + .font(.caption.weight(.semibold)) + if entry.isStale { + Text("Stale") + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityIdentifier("worktree-changes-stale") + } + Spacer(minLength: 4) + if WorktreeChangesPresentation.showsActivityIndicator( + for: entry + ) { + ProgressView() + .controlSize(.small) + .accessibilityLabel( + entry.hasSuccessfulValue + ? "Refreshing changed files" + : "Loading changed files" + ) + .accessibilityIdentifier("worktree-changes-loading") + } else if let onRefresh { + Button(action: onRefresh) { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .help("Refresh changes") + .accessibilityLabel("Refresh changes") + .accessibilityIdentifier("worktree-changes-refresh") + } + } + + if WorktreeChangesPresentation.showsLoadingChrome(for: entry) { + EmptyView() + } else if !entry.hasSuccessfulValue, + let errorMessage = entry.errorMessage { + VStack(alignment: .leading, spacing: 4) { + Text(errorMessage) + .font(.caption2) + .foregroundStyle(.secondary) + if WorktreeChangesPresentation.showsRetry( + for: entry, + canRefresh: onRefresh != nil + ), let onRefresh { + Button("Retry", action: onRefresh) + .buttonStyle(.link) + } + } + .accessibilityIdentifier("worktree-changes-first-error") + } else if entry.hasSuccessfulValue, entry.files.isEmpty { + Text("No changed files") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("worktree-changes-empty") + } else { + let page = WorktreeChangesPresentation.page( + files: entry.files, + requestedCount: requestedFileCount + ) + VStack(alignment: .leading, spacing: 4) { + ForEach(page.files, id: \.path) { file in + WorktreeFileChangeRow(file: file) + } + if page.remainingCount > 0 { + let revealCount = min( + WorktreeChangesPresentation.filePageSize, + page.remainingCount + ) + Button("Show \(revealCount) more") { + requestedFileCount = page.nextRequestedCount + } + .buttonStyle(.link) + .accessibilityIdentifier( + "worktree-changes-show-more" + ) + } + } + .accessibilityIdentifier("worktree-changes-list") + } + } + .padding(8) + .background(.quaternary.opacity(0.55), in: RoundedRectangle( + cornerRadius: 6 + )) + .onChange(of: entry.files.count) { + requestedFileCount = + WorktreeChangesPresentation.adjustedRequestedCount( + requestedFileCount, + forFileCount: entry.files.count + ) + } + } +} + +private struct WorktreeFileChangeRow: View { + let file: WorktreeFileChange + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 5) { + Text(verbatim: WorktreeFileChangePresentation.statusCode( + index: file.index, + worktree: file.worktree + )) + .font(.caption2.monospaced().weight(.semibold)) + .foregroundStyle(.secondary) + .frame(width: 18, alignment: .leading) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text(file.path) + .font(.caption.monospaced()) + .lineLimit(1) + if let originalPath = file.originalPath { + Text("from \(originalPath)") + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: 0) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel( + WorktreeFileChangePresentation.accessibilityValue(for: file) + ) + } +} diff --git a/Sources/Workspace/WorktreeFileChanges.swift b/Sources/Workspace/WorktreeFileChanges.swift new file mode 100644 index 00000000..af59f76e --- /dev/null +++ b/Sources/Workspace/WorktreeFileChanges.swift @@ -0,0 +1,115 @@ +import Foundation + +public enum WorktreeChangePath { + public static func key(_ path: String, usesWindowsPaths: Bool) -> String { + guard usesWindowsPaths else { return path } + return path.replacingOccurrences(of: "/", with: "\\") + .folding(options: .caseInsensitive, locale: Locale(identifier: "en_US_POSIX")) + } + + public static func matches( + _ lhs: String, _ rhs: String, usesWindowsPaths: Bool + ) -> Bool { + key(lhs, usesWindowsPaths: usesWindowsPaths) + == key(rhs, usesWindowsPaths: usesWindowsPaths) + } +} + +public enum WorktreeFileState: String, Codable, CaseIterable, Sendable { + case modified + case added + case deleted + case renamed + case copied + case conflicted + case untracked +} + +public protocol WorktreeChangesRetryClassifying: Error { + var isRetryable: Bool { get } + var requiresInventoryRefresh: Bool { get } +} + +public struct WorktreeFileChange: Codable, Equatable, Sendable { + public let path: String + public let originalPath: String? + public let index: WorktreeFileState? + public let worktree: WorktreeFileState? + + public init( + path: String, + originalPath: String?, + index: WorktreeFileState?, + worktree: WorktreeFileState? + ) { + self.path = path + self.originalPath = originalPath + self.index = index + self.worktree = worktree + } + + private enum CodingKeys: String, CodingKey { + case path + case originalPath = "original_path" + case index + case worktree + } +} + +public enum WorktreeChangeState: String, Codable, CaseIterable, Sendable { + case clean + case modified + case staged + case conflicted +} + +public struct WorktreeFileChanges: Equatable, Sendable { + public let repository: String + public let path: String + public let generation: String + public let state: WorktreeChangeState + public let summary: WorktreeChangeSummary + public let files: [WorktreeFileChange] + public let observedAt: String + + public init( + repository: String, + path: String, + generation: String, + state: WorktreeChangeState, + summary: WorktreeChangeSummary, + files: [WorktreeFileChange], + observedAt: String + ) { + self.repository = repository + self.path = path + self.generation = generation + self.state = state + self.summary = summary + self.files = files + self.observedAt = observedAt + } + + public func sortedForPresentation() -> Self { + Self( + repository: repository, + path: path, + generation: generation, + state: state, + summary: summary, + files: files.sortedForPresentation(), + observedAt: observedAt + ) + } +} + +public extension [WorktreeFileChange] { + func sortedForPresentation() -> Self { + sorted { + if $0.path != $1.path { + return $0.path < $1.path + } + return ($0.originalPath ?? "") < ($1.originalPath ?? "") + } + } +} diff --git a/Tests/App/AccountCommandRunnerTests.swift b/Tests/App/AccountCommandRunnerTests.swift index c11a517a..9907a734 100644 --- a/Tests/App/AccountCommandRunnerTests.swift +++ b/Tests/App/AccountCommandRunnerTests.swift @@ -265,4 +265,25 @@ struct AccountCommandRunnerTests { #expect(sanitized == ["PATH": "/usr/bin"]) } + + @Test("callers can reserve bounded headroom for expanded protocols") + func configurableOutputLimit() { + let rejected = AccountCommandRunner.runProcess( + executable: "/usr/bin/printf", + arguments: ["123456789"], + timeout: 1, + maximumOutputBytes: 8 + ) + let accepted = AccountCommandRunner.runProcess( + executable: "/usr/bin/printf", + arguments: ["123456789"], + timeout: 1, + maximumOutputBytes: 16 + ) + + #expect(rejected.status == AccountCommandRunner.outputExceededStatus) + #expect(rejected.stdout.isEmpty) + #expect(accepted.status == 0) + #expect(accepted.stdout == "123456789") + } } diff --git a/Tests/App/KwtSSHCommandClientTests.swift b/Tests/App/KwtSSHCommandClientTests.swift index 26b0f9a6..71b498e5 100644 --- a/Tests/App/KwtSSHCommandClientTests.swift +++ b/Tests/App/KwtSSHCommandClientTests.swift @@ -105,7 +105,9 @@ struct KwtSSHCommandClientTests { ) #expect(output.status == 255) - #expect(output.stdout.isEmpty) + #expect(output + .stdout == + #"{"error":{"code":"ssh_interaction_required","message":"SSH interaction is required","retryable":false}}"#) #expect(output.stderr.hasPrefix( SSHConnectionArgumentsSnapshot.failureMarker + "ssh_interaction_required\n" diff --git a/Tests/App/KwtSSHLeaseClientTests.swift b/Tests/App/KwtSSHLeaseClientTests.swift index 14719cf7..0d68f6d1 100644 --- a/Tests/App/KwtSSHLeaseClientTests.swift +++ b/Tests/App/KwtSSHLeaseClientTests.swift @@ -66,7 +66,8 @@ struct KwtSSHLeaseClientTests { #!/bin/sh termination_result=\(shellQuotedCommandArgument(terminationResult.path)) handle_term() { - printf '%s\n' terminated > "$termination_result" + printf '%s\n' terminated > "$termination_result.pending" + mv -f "$termination_result.pending" "$termination_result" exit 0 } trap handle_term TERM @@ -74,7 +75,8 @@ struct KwtSSHLeaseClientTests { while [ ! -f \(shellQuotedCommandArgument(cancellationProbe.path)) ]; do sleep 0.01 done - printf '%s\n' survived > \(shellQuotedCommandArgument(terminationResult.path)) + printf '%s\n' survived > "$termination_result.pending" + mv -f "$termination_result.pending" "$termination_result" """ let helper = try fixture.createExecutable(name: "kwt", content: script) let task = Task { @@ -100,11 +102,9 @@ struct KwtSSHLeaseClientTests { while !FileManager.default.fileExists(atPath: terminationResult.path) { try await Task.sleep(for: .milliseconds(10)) } - #expect( - try String(contentsOf: terminationResult, encoding: .utf8) - .trimmingCharacters(in: .whitespacesAndNewlines) - == "terminated" - ) + let termination = try String(contentsOf: terminationResult, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(termination == "terminated") } @Test("rejects a masterless presentation lease with a stable error") diff --git a/Tests/App/KwtWorktreeClientTests.swift b/Tests/App/KwtWorktreeClientTests.swift index e623384f..bc4c645f 100644 --- a/Tests/App/KwtWorktreeClientTests.swift +++ b/Tests/App/KwtWorktreeClientTests.swift @@ -3,10 +3,51 @@ import Foundation import GhosthubTmux import GhosthubWorkspace import Testing +import GhosthubTestSupport @testable import GhosthubApp @Suite("kwt worktree creation") struct KwtWorktreeClientTests { + @Test("only changed-file inspection accepts large helper output", arguments: [0, 2]) + func outputBudgetDependsOnOperation(mebibytes: Int) async throws { + let fixture = try TempDirectoryFixture() + let shell = try fixture.createExecutable( + name: "shell", content: "#!/bin/sh\nexec /bin/sh -c \"$2\"\n" + ) + let helper = try fixture.createExecutable(name: "kwt", content: """ + #!/bin/sh + /bin/dd if=/dev/zero bs=1048576 count=\(mebibytes) 2>/dev/null | /usr/bin/tr '\\000' ' ' + if [ "$1" = changes ]; then + printf '%s\\n' '{"worktree":{"repository":"example.com/project","path":"/worktrees/topic","generation":"0123456789abcdef0123456789abcdef"},"changes":{"state":"clean","summary":{"modified":0,"added":0,"deleted":0,"untracked":0,"staged":0,"conflicts":0},"files":[]},"observed_at":"now"}' + fi + """) + let client = KwtWorktreeClient( + localBinaryPath: helper.path, loginShellProvider: { shell.path } + ) + let create = { + try await client.create( + request: WorktreeCreateRequest( + projectID: UUID(), branchName: "topic", createsBranch: true + ), + projectPath: fixture.url.path, on: .local + ) + } + if mebibytes == 0 { + try await create() + } else { + await #expect(throws: KwtWorktreeError.commandFailed( + host: "localhost", status: AccountCommandRunner.outputExceededStatus + )) { + try await create() + } + } + let changes = try await client.changes( + worktreePath: "/worktrees/topic", expectedRepository: "example.com/project", + expectedGeneration: "0123456789abcdef0123456789abcdef", on: .local + ) + #expect(changes.state == .clean) + } + @Test("local creation delegates path and session creation to kwt") func localCreation() async throws { let recorder = CommandRecorder() @@ -287,9 +328,10 @@ struct KwtWorktreeClientTests { #expect(recorder.command?.contains("--force") == false) } - @Test("worktree changes are read from kwt status JSON") + @Test("worktree changes preserve the exact kwt inspection contract") func worktreeChanges() async throws { let recorder = CommandRecorder() + let generation = "0123456789abcdef0123456789abcdef" let client = KwtWorktreeClient( localRunner: { shell, command in recorder.record(shell: shell, command: command) @@ -298,23 +340,37 @@ struct KwtWorktreeClientTests { """ shell startup noise GHOSTHUB_KWT_JSON - [ - { + { + "worktree": { + "repository": "github.com/acme/ghosthub", "path": "/worktrees/ghost hub/feature", - "branch": "feature/remove", - "status": "modified", - "git_status": { + "generation": "0123456789abcdef0123456789abcdef" + }, + "changes": { + "state": "staged", + "summary": { "modified": 2, "added": 1, "deleted": 3, "untracked": 4, "staged": 5, - "ahead": 6, - "behind": 7, "conflicts": 8 - } - } - ] + }, + "files": [ + { + "path": "notes.txt", + "worktree": "untracked" + }, + { + "path": "Sources/New.swift", + "original_path": "Sources/Old.swift", + "index": "renamed", + "worktree": "modified" + } + ] + }, + "observed_at": "2026-08-25T15:04:05.123456789Z" + } """ ) }, @@ -324,11 +380,16 @@ struct KwtWorktreeClientTests { let changes = try await client.changes( worktreePath: "/worktrees/ghost hub/feature", - projectPath: "/code/ghost hub", + expectedRepository: "github.com/acme/ghosthub", + expectedGeneration: generation, on: .local ) - #expect(changes == WorktreeChangeSummary( + #expect(changes.repository == "github.com/acme/ghosthub") + #expect(changes.path == "/worktrees/ghost hub/feature") + #expect(changes.generation == generation) + #expect(changes.state == .staged) + #expect(changes.summary == WorktreeChangeSummary( modified: 2, added: 1, deleted: 3, @@ -336,11 +397,30 @@ struct KwtWorktreeClientTests { staged: 5, conflicts: 8 )) + #expect(changes.files == [ + WorktreeFileChange( + path: "Sources/New.swift", + originalPath: "Sources/Old.swift", + index: .renamed, + worktree: .modified + ), + WorktreeFileChange( + path: "notes.txt", + originalPath: nil, + index: nil, + worktree: .untracked + ), + ]) + #expect(changes.observedAt == "2026-08-25T15:04:05.123456789Z") #expect( recorder.command?.contains( - "exec \"$ghosthub_kwt_path\" status --json --no-fetch" + "exec \"$ghosthub_kwt_path\" changes " + + "'/worktrees/ghost hub/feature' " + + "--expected-repository 'github.com/acme/ghosthub' " + + "--expected-generation '\(generation)' --json" ) == true ) + #expect(recorder.command?.contains(" status ") == false) } @Test("force removal passes explicit force authority to kwt") @@ -370,19 +450,360 @@ struct KwtWorktreeClientTests { ) == true) } - @Test("an absent worktree has no changes left to discard") - func absentWorktreeChanges() async throws { + @Test("worktree inspection preserves kwt's structured error") + func worktreeChangesStructuredError() async { + let client = KwtWorktreeClient( + localRunner: { _, _ in + ( + 1, + """ + GHOSTHUB_KWT_JSON + {"error":{"code":"registration_changed","message":"worktree registration changed","retryable":true,"details":{"path":"/worktrees/removed"}}} + """ + ) + } + ) + + await #expect { + try await client.changes( + worktreePath: "/worktrees/removed", + expectedRepository: "github.com/acme/project", + expectedGeneration: + "0123456789abcdef0123456789abcdef", + on: .local + ) + } throws: { error in + error as? KwtWorktreeError == .changeInspectionFailed( + host: "localhost", + status: 1, + code: "registration_changed", + message: "worktree registration changed", + retryable: true, + details: ["path": .string("/worktrees/removed")] + ) + } + } + + @Test("remote changes preserve typed SSH failure details and retry policy", arguments: [ + ("ssh_interaction_required", false), + ("ssh_configuration_changed", false), + ("ssh_acquisition_timed_out", true), + ]) + func remoteChangesPreserveTypedSSHFailure(code: String, retryable: Bool) async { + let sshClient = KwtSSHCommandClient( + runner: { _, _, _ in + AccountCommandOutput( + status: 1, + stdout: """ + {"error":{"code":"\(code)","message":"SSH command failed.","retryable":\( + retryable + )}} + """, + stderr: "" + ) + }, + binaryPath: "/bundle/kwt" + ) + let client = KwtWorktreeClient(remoteRunner: { host, command, route in + await sshClient.run( + on: host, command: command, timeout: 5, + expectedRouteIdentity: route + ) + }) + + await #expect { + try await client.changes( + worktreePath: "/srv/widget", + expectedRepository: "example.test/team/widget", + expectedGeneration: String(repeating: "a", count: 32), + expectedRouteIdentity: "sha256:reviewed-route", + on: .ssh(SSHHostInfo(user: nil, hostname: "build.example.test", port: nil)) + ) + } throws: { error in + error as? KwtWorktreeError == .changeInspectionFailed( + host: "build.example.test", status: 255, code: code, + message: "SSH command failed.", retryable: retryable, details: [:] + ) + } + } + + @Test( + "marked malformed transport failures remain retryable", + arguments: [Int32(255), AccountCommandRunner.timedOutStatus] + ) + func worktreeChangesMarkedTransportFailure(status: Int32) async { + let client = KwtWorktreeClient( + localRunner: { _, _ in + (status, "GHOSTHUB_KWT_JSON\ntruncated") + } + ) + + await #expect { + try await client.changes( + worktreePath: "/worktrees/remote", + expectedRepository: "github.com/acme/project", + expectedGeneration: + "0123456789abcdef0123456789abcdef", + on: .local + ) + } throws: { error in + error as? KwtWorktreeError == .changeInspectionFailed( + host: "localhost", + status: status, + code: nil, + message: nil, + retryable: true, + details: [:] + ) + } + } + + @Test("worktree inspection preserves a structured output-limit error") + func worktreeChangesOutputLimitError() async { + let client = KwtWorktreeClient( + localRunner: { _, _ in + (AccountCommandRunner.outputExceededStatus, "") + } + ) + + await #expect { + try await client.changes( + worktreePath: "/worktrees/large", + expectedRepository: "github.com/acme/project", + expectedGeneration: + "0123456789abcdef0123456789abcdef", + on: .local + ) + } throws: { error in + error as? KwtWorktreeError == .changeInspectionFailed( + host: "localhost", + status: AccountCommandRunner.outputExceededStatus, + code: "response_too_large", + message: "kwt returned too many changed files to display.", + retryable: false, + details: [:] + ) + } + } + + @Test("transport failures remain retryable without a kwt envelope") + func worktreeChangesTransportFailure() async { + let client = KwtWorktreeClient( + localRunner: { _, _ in (255, "") } + ) + + await #expect { + try await client.changes( + worktreePath: "/worktrees/remote", + expectedRepository: "github.com/acme/project", + expectedGeneration: + "0123456789abcdef0123456789abcdef", + on: .local + ) + } throws: { error in + error as? KwtWorktreeError == .changeInspectionFailed( + host: "localhost", + status: 255, + code: nil, + message: nil, + retryable: true, + details: [:] + ) + } + } + + @Test( + "unstructured command failures wait for manual retry", + arguments: [ + Int32(1), Int32(2), Int32(64), Int32(126), Int32(127), + AccountCommandRunner.cancelledStatus, + ] + ) + func worktreeChangesUnstructuredFailureIsNotRetryable( + status: Int32 + ) async { + let client = KwtWorktreeClient( + localRunner: { _, _ in (status, "") } + ) + + await #expect { + try await client.changes( + worktreePath: "/worktrees/missing", + expectedRepository: "github.com/acme/project", + expectedGeneration: + "0123456789abcdef0123456789abcdef", + on: .local + ) + } throws: { error in + error as? KwtWorktreeError == .changeInspectionFailed( + host: "localhost", + status: status, + code: nil, + message: nil, + retryable: false, + details: [:] + ) + } + } + + @Test("worktree inspection uses its shorter process deadline") + func worktreeChangesUsesInspectionTimeout() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("ghosthub-kwt-changes-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + let helper = directory.appendingPathComponent("kwt") + try "#!/bin/sh\nexec /bin/sleep 10\n".write( + to: helper, + atomically: true, + encoding: .utf8 + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: helper.path + ) + let client = KwtWorktreeClient( + processTimeout: 5, + changeInspectionTimeout: 0.05, + localBinaryPath: helper.path, + loginShellProvider: { "/bin/sh" } + ) + let started = Date() + + do { + _ = try await client.changes( + worktreePath: directory.path, + expectedRepository: "github.com/acme/project", + expectedGeneration: + "0123456789abcdef0123456789abcdef", + on: .local + ) + Issue.record("expected changed-file inspection to time out") + } catch {} + + #expect(Date().timeIntervalSince(started) < 3) + } + + @Test("canceling inspection cancels its detached command task") + func worktreeChangesCancellation() async { + let probe = DetachedCancellationProbe() + let client = KwtWorktreeClient(localRunner: { _, _ in + probe.run() + }) + let task = Task { + try await client.changes( + worktreePath: "/worktrees/slow", + expectedRepository: "github.com/acme/project", + expectedGeneration: + "0123456789abcdef0123456789abcdef", + on: .local + ) + } + + await probe.waitUntilStarted() + task.cancel() + _ = await task.result + await probe.waitUntilFinished() + + #expect(probe.observedCancellation) + } + + @Test("successful inspection rejects mismatched worktree identity") + func worktreeChangesRejectIdentityMismatch() async { + let expectedRepository = "github.com/acme/ghosthub" + let expectedPath = "/worktrees/ghosthub/feature" + let expectedGeneration = "0123456789abcdef0123456789abcdef" + let mismatches = [ + ("github.com/acme/other", expectedPath, expectedGeneration), + (expectedRepository, "/worktrees/ghosthub/other", expectedGeneration), + (expectedRepository, expectedPath, "fedcba9876543210fedcba9876543210"), + ] + + for (repository, path, generation) in mismatches { + let client = KwtWorktreeClient( + localRunner: { _, _ in + ( + 0, + """ + GHOSTHUB_KWT_JSON + {"worktree":{"repository":"\(repository)","path":"\(path)","generation":"\( + generation + )"},"changes":{"state":"clean","summary":{"modified":0,"added":0,"deleted":0,"untracked":0,"staged":0,"conflicts":0},"files":[]},"observed_at":"2026-08-25T15:04:05Z"} + """ + ) + } + ) + + await #expect { + try await client.changes( + worktreePath: expectedPath, + expectedRepository: expectedRepository, + expectedGeneration: expectedGeneration, + on: .local + ) + } throws: { error in + error as? KwtWorktreeError == .malformedChangeStatus( + host: "localhost" + ) + } + } + } + + @Test("Windows inspection uses the managed helper and identity guards") + func windowsRemoteChanges() async throws { + let recorder = CommandRecorder() + let reviewedRoute = LockedValue(nil) + let revision = String(repeating: "f", count: 40) + let generation = "0123456789abcdef0123456789abcdef" + let path = #"C:\worktrees\ghost hub\feature"# + let repository = "github.com/acme/ghosthub" + let ssh = SSHHostInfo( + user: "ci-user", + hostname: "windows-builder.example", + port: nil, + platform: .windows + ) let client = KwtWorktreeClient( - localRunner: { _, _ in (0, "GHOSTHUB_KWT_JSON\n[]") } + remoteRunner: { host, command, routeIdentity in + recorder.record(host: host, command: command) + reviewedRoute.withLock { $0 = routeIdentity } + return AccountCommandOutput( + status: 0, + stdout: """ + GHOSTHUB_KWT_JSON\r + {"worktree":{"repository":"github.com/acme/ghosthub","path":"C:\\\\worktrees\\\\ghost hub\\\\feature","generation":"0123456789abcdef0123456789abcdef"},"changes":{"state":"clean","summary":{"modified":0,"added":0,"deleted":0,"untracked":0,"staged":0,"conflicts":0},"files":[]},"observed_at":"2026-08-25T15:04:05Z"}\r + """, + stderr: "" + ) + }, + remoteBinaryRevision: revision ) let changes = try await client.changes( - worktreePath: "/worktrees/removed", - projectPath: "/code/project", - on: .local + worktreePath: path, + expectedRepository: repository, + expectedGeneration: generation, + expectedRouteIdentity: "sha256:reviewed-route", + on: .ssh(ssh) ) - #expect(changes == .clean) + #expect(changes.state == .clean) + #expect(recorder.host == ssh) + #expect(reviewedRoute.load() == "sha256:reviewed-route") + #expect(recorder.command?.contains( + [ + "changes", path, + "--expected-repository", repository, + "--expected-generation", generation, + "--json", + ] + .map(powerShellEncodedArgument) + .joined(separator: " ") + ) == true) + #expect(recorder.command?.contains("Set-Location") == false) } @Test("Windows removal uses the managed kwt helper") @@ -475,3 +896,41 @@ private final class CommandRecorder: @unchecked Sendable { } } } + +private final class DetachedCancellationProbe: @unchecked Sendable { + private let lock = NSLock() + private var started = false + private var finished = false + private var canceled = false + + var observedCancellation: Bool { lock.withLock { canceled } } + + func run() -> (status: Int32, stdout: String) { + lock.withLock { started = true } + let deadline = Date().addingTimeInterval(0.5) + while Date() < deadline { + if withUnsafeCurrentTask(body: { $0?.isCancelled == true }) { + lock.withLock { + canceled = true + finished = true + } + return (AccountCommandRunner.cancelledStatus, "") + } + usleep(1_000) + } + lock.withLock { finished = true } + return (0, "") + } + + func waitUntilStarted() async { + while !lock.withLock({ started }) { + await Task.yield() + } + } + + func waitUntilFinished() async { + while !lock.withLock({ finished }) { + await Task.yield() + } + } +} diff --git a/Tests/App/PinnedKwtContractTests.swift b/Tests/App/PinnedKwtContractTests.swift index 71fc340d..6cc0cb6d 100644 --- a/Tests/App/PinnedKwtContractTests.swift +++ b/Tests/App/PinnedKwtContractTests.swift @@ -2,6 +2,7 @@ import CryptoKit import Foundation import GhosthubTestSupport import GhosthubTransport +import GhosthubWorkspace import Testing @testable import GhosthubApp @@ -325,6 +326,114 @@ struct PinnedKwtContractTests { #expect(finalInventory.projects.isEmpty) } + @Test("exact helper inspects one generation-fenced worktree") + func worktreeChanges() async throws { + guard ProcessInfo.processInfo.environment[ + "GHOSTHUB_RUN_PINNED_KWT_CONTRACT_TESTS" + ] == "1" else { return } + let binary = try #require( + ProcessInfo.processInfo.environment[ + "GHOSTHUB_KWT_CONTRACT_BINARY" + ] + ) + let fixture = try TempDirectoryFixture(shortPath: true) + let kwtHome = try fixture.createSubdirectory("kwt-home") + let repository = try fixture.createSubdirectory("changes-widget") + let environment = ["KWT_HOME": kwtHome.path] + let timeout: TimeInterval = 45 + let runLoginShell: @Sendable (String, String) -> ( + status: Int32, + stdout: String + ) = { shell, command in + AccountCommandRunner.runLoginShell( + shell: shell, + command: command, + timeout: timeout, + environmentOverrides: environment + ) + } + defer { + _ = AccountCommandRunner.runProcess( + executable: binary, + arguments: ["daemon", "stop"], + timeout: 10, + environmentOverrides: environment + ) + } + + try initializeRepository(repository) + let registry = KwtProjectRegistryClient( + localRunner: { command in + runLoginShell("/bin/zsh", command) + }, + localBinaryPath: binary + ) + let inventoryClient = KwtInventoryClient( + localRunner: runLoginShell, + localBinaryPath: binary, + loginShellProvider: { "/bin/zsh" } + ) + let changesClient = KwtWorktreeClient( + localRunner: runLoginShell, + localBinaryPath: binary, + loginShellProvider: { "/bin/zsh" } + ) + + _ = try await registry.register( + projectPath: repository.path, + on: .local + ) + let inventory = try await inventoryClient.load(from: .local) + let project = try #require(inventory.projects.first) + let worktree = try #require(project.worktrees.first { $0.isMain }) + let generation = try #require(worktree.generation) + try Data("untracked".utf8).write( + to: repository.appendingPathComponent("notes.txt") + ) + + let changes = try await changesClient.changes( + worktreePath: worktree.path, + expectedRepository: project.project.repository, + expectedGeneration: generation, + on: .local + ) + + #expect(changes.repository == project.project.repository) + #expect(changes.path == worktree.path) + #expect(changes.generation == generation) + #expect(changes.state == .modified) + #expect(changes.summary == WorktreeChangeSummary(untracked: 1)) + #expect(changes.files == [ + WorktreeFileChange( + path: "notes.txt", + originalPath: nil, + index: nil, + worktree: .untracked + ), + ]) + + let staleGeneration = generation.first == "f" + ? String(repeating: "0", count: 32) + : String(repeating: "f", count: 32) + await #expect { + try await changesClient.changes( + worktreePath: worktree.path, + expectedRepository: project.project.repository, + expectedGeneration: staleGeneration, + on: .local + ) + } throws: { error in + guard let inspectionError = error as? KwtWorktreeError, + case let .changeInspectionFailed( + _, status, code, _, retryable, _ + ) = inspectionError else { return false } + return status == 1 + && code == "registration_changed" + && retryable + && !inspectionError.isRetryable + } + } + @Test( "project removal preserves checkout, linked worktree, and live session" ) diff --git a/Tests/App/SceneModelTestSupport.swift b/Tests/App/SceneModelTestSupport.swift index b1131c13..13be1a47 100644 --- a/Tests/App/SceneModelTestSupport.swift +++ b/Tests/App/SceneModelTestSupport.swift @@ -361,9 +361,22 @@ func makeModel( kwtForceWorktreeRemover: @escaping WorkspaceSceneModel.KwtWorktreeRemover = { _, _, _, _, _ in }, kwtWorktreeChangeReader: - @escaping WorkspaceSceneModel.KwtWorktreeChangeReader = { _, _, _ in + @escaping WorkspaceSceneModel.KwtWorktreeChangeReader = { _, _, _, _, _ in .clean }, + kwtWorktreeChangesReader: + @escaping WorkspaceSceneModel.KwtWorktreeChangesReader = { + path, repository, generation, _, _ in + WorktreeFileChanges( + repository: repository, + path: path, + generation: generation, + state: .clean, + summary: .clean, + files: [], + observedAt: "now" + ) + }, sshRouteIdentityResolver: @escaping WorkspaceSceneModel.SSHRouteIdentityResolver = { _ in "sha256:test-route" @@ -584,6 +597,7 @@ func makeModel( kwtWorktreeRemover: kwtWorktreeRemover, kwtForceWorktreeRemover: kwtForceWorktreeRemover, kwtWorktreeChangeReader: kwtWorktreeChangeReader, + kwtWorktreeChangesReader: kwtWorktreeChangesReader, sshRouteIdentityResolver: sshRouteIdentityResolver, worktreeMutationCoordinator: worktreeMutationCoordinator, herdrLifecycleCoordinator: herdrLifecycleCoordinator, @@ -642,9 +656,15 @@ func makeModel( host ) } + let routeIdentity: String? + switch host { + case .local: routeIdentity = nil + case let .ssh(info): + routeIdentity = try await sshRouteIdentityResolver(info) + } return ReviewedTmuxSessionIdentity( identity: identity, - routeIdentity: nil + routeIdentity: routeIdentity ) }, tmuxSessionStyler: tmuxSessionStyler, diff --git a/Tests/App/WorkspaceApplicationShortcutTests.swift b/Tests/App/WorkspaceApplicationShortcutTests.swift index c4fb376b..74828b33 100644 --- a/Tests/App/WorkspaceApplicationShortcutTests.swift +++ b/Tests/App/WorkspaceApplicationShortcutTests.swift @@ -57,17 +57,15 @@ struct WorkspaceApplicationShortcutTests { model.isFocusedWindow = true model.isLogViewerPresented = true - _ = try #require(model.logViewerTerminalView()) - let logSurface = try #require( - model.terminalCoordinator.surfaceEntries().first { - $0.key.target == .logViewer - }?.view - ) let logController = TerminalFindController( isAvailable: true, sessionProvider: { nil } ) - logSurface.terminalFindController = logController + model.terminalCoordinator.onSurfaceCreated = { key, surface in + if key.target == .logViewer { + surface.terminalFindController = logController + } + } let logView = try #require(model.logViewerTerminalView()) let hostingView = NSHostingView(rootView: logView) hostingView.frame = NSRect(x: 0, y: 0, width: 800, height: 600) diff --git a/Tests/App/WorkspaceHerdrPresentationTests.swift b/Tests/App/WorkspaceHerdrPresentationTests.swift index 24165b91..e99326ff 100644 --- a/Tests/App/WorkspaceHerdrPresentationTests.swift +++ b/Tests/App/WorkspaceHerdrPresentationTests.swift @@ -1713,8 +1713,10 @@ struct WorkspaceHerdrPresentationTests { return } await waitUntilMainActor { - store.requestedConfigurations.count == 2 - && model.activeBorrowedHerdrConnectionState == .connected + model.activeBorrowedHerdrConnectionState == .connected + && store.requestedConfigurations.last?.command?.contains( + "/tmp/replacement-herdr-config" + ) == true } #expect(deadAttempts.withLock { $0 } == 1) diff --git a/Tests/App/WorkspaceTmuxPresentationTests.swift b/Tests/App/WorkspaceTmuxPresentationTests.swift index 78b2d4e1..fc430475 100644 --- a/Tests/App/WorkspaceTmuxPresentationTests.swift +++ b/Tests/App/WorkspaceTmuxPresentationTests.swift @@ -2001,9 +2001,13 @@ extension WorkspaceTmuxDiscoveryTests { ), ] let surfaceStore = SceneTmuxSurfaceStoreStub() - let failure = KwtWorktreeError.changeStatusFailed( + let failure = KwtWorktreeError.changeInspectionFailed( host: environment.host.name, - status: 127 + status: 127, + code: nil, + message: nil, + retryable: false, + details: [:] ) let model = try makeModel( database: environment.database, @@ -2013,7 +2017,7 @@ extension WorkspaceTmuxDiscoveryTests { remoteTmuxPathProvider: { _, _ in successfulTmuxResolution("/usr/bin/tmux") }, - kwtWorktreeChangeReader: { _, _, _ in throw failure } + kwtWorktreeChangeReader: { _, _, _, _, _ in throw failure } ) let selection = WorkspaceTmuxSessionSelection( hostID: environment.host.id, diff --git a/Tests/App/WorkspaceWorktreeRemovalPreflightTests.swift b/Tests/App/WorkspaceWorktreeRemovalPreflightTests.swift index d919c1da..228df476 100644 --- a/Tests/App/WorkspaceWorktreeRemovalPreflightTests.swift +++ b/Tests/App/WorkspaceWorktreeRemovalPreflightTests.swift @@ -9,6 +9,53 @@ import Testing @testable import GhosthubApp extension WorkspaceWorktreeRemovalTests { + @MainActor + @Test("removal rejects route drift during each change inspection", arguments: [1, 2]) + func inspectionRejectsRouteDrift(pass: Int) async throws { + let environment = try setupRemoteEnvironment() + var worktree = try #require(environment.snapshot.worktrees.first) + worktree.scopedKey = worktree.path + worktree.generation = stableWorktreeGeneration + worktree.tmuxSessionName = "kwt-feature" + var snapshot = environment.snapshot + snapshot.worktrees = [worktree] + let beforeRemoval = inventory(environment, including: worktree) + let route = LockedValue("sha256:reviewed-route") + let reads = LockedValue(0) + let removals = LockedValue(0) + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: snapshot, + kwtInventoryLoader: { _ in beforeRemoval }, + kwtWorktreeRemover: { _, _, _, _, _ in + removals.withLock { $0 += 1 } + }, + kwtWorktreeChangeReader: { _, _, _, expectedRoute, _ in + #expect(expectedRoute == "sha256:reviewed-route") + reads.withLock { $0 += 1 } + if reads.load() == pass { + route.store("sha256:replacement-route") + } + return .clean + }, + sshRouteIdentityResolver: { _ in route.load() }, + tmuxSessionIdentityReviewer: { _, _, _ in + throw TmuxSessionKillError.sessionNotRunning( + host: "Builder", session: "kwt-feature" + ) + } + ) + await #expect(throws: KwtWorktreeError.removalHostChanged) { + let request = try await model.prepareWorktreeRemoval(worktree.id) + if pass == 2 { + try await model.removeWorktree(request) + } + } + #expect(removals.load() == 0) + await model.shutdown() + } + @MainActor @Test("remote removal carries its reviewed SSH route into execution") func remoteRemovalUsesReviewedRoute() async throws { @@ -21,6 +68,7 @@ extension WorkspaceWorktreeRemovalTests { snapshot.worktrees = [worktree] let reviewedWorktree = worktree let executedRoute = LockedValue(nil) + let inspectedRoutes = LockedValue<[String?]>([]) let model = try makeModel( database: environment.database, localHostID: environment.host.id, @@ -31,6 +79,10 @@ extension WorkspaceWorktreeRemovalTests { kwtWorktreeRemover: { _, _, _, routeIdentity, _ in executedRoute.withLock { $0 = routeIdentity } }, + kwtWorktreeChangeReader: { _, _, _, routeIdentity, _ in + inspectedRoutes.withLock { $0.append(routeIdentity) } + return .clean + }, sshRouteIdentityResolver: { host in #expect(host.hostname == "office-linux") return "sha256:reviewed-route" @@ -55,6 +107,7 @@ extension WorkspaceWorktreeRemovalTests { try await model.removeWorktree(request) #expect(executedRoute.load() == "sha256:reviewed-route") + #expect(inspectedRoutes.load() == ["sha256:reviewed-route", "sha256:reviewed-route"]) await model.shutdown() } diff --git a/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift b/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift index fad24f2e..033e4655 100644 --- a/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift +++ b/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift @@ -1177,7 +1177,7 @@ extension WorkspaceWorktreeRemovalTests { status: 1 ) }, - kwtWorktreeChangeReader: { _, _, _ in + kwtWorktreeChangeReader: { _, _, _, _, _ in reads.withLock { $0 += 1 } return reads.load() < 3 ? .clean diff --git a/Tests/App/WorkspaceWorktreeRemovalTests.swift b/Tests/App/WorkspaceWorktreeRemovalTests.swift index 7a0d3fc4..bc1b3129 100644 --- a/Tests/App/WorkspaceWorktreeRemovalTests.swift +++ b/Tests/App/WorkspaceWorktreeRemovalTests.swift @@ -16,6 +16,7 @@ struct WorkspaceWorktreeRemovalTests { let fixture = try removalFixture() let environment = fixture.environment let reads = LockedValue(0) + let inspectedIdentities = LockedValue<[String]>([]) let loads = LockedValue(0) let normalRemovals = LockedValue(0) let forcedRemovals = LockedValue(0) @@ -36,7 +37,10 @@ struct WorkspaceWorktreeRemovalTests { kwtForceWorktreeRemover: { _, _, _, _, _ in forcedRemovals.withLock { $0 += 1 } }, - kwtWorktreeChangeReader: { _, _, _ in + kwtWorktreeChangeReader: { path, repository, generation, _, _ in + inspectedIdentities.withLock { + $0.append("\(path)|\(repository)|\(generation)") + } reads.withLock { $0 += 1 } return reads.load() == 1 ? .clean @@ -64,6 +68,55 @@ struct WorkspaceWorktreeRemovalTests { #expect(try await model.resolveWorktreeRemoval(updatedRequest) == .removed) #expect(normalRemovals.load() == 0) #expect(forcedRemovals.load() == 1) + #expect(inspectedIdentities.load().allSatisfy { + $0 == fixture.removable.path + + "|" + environment.project.scopedKey + + "|" + stableWorktreeGeneration + }) + await model.shutdown() + } + + @MainActor + @Test("oversized change inspection still permits confirmed force removal") + func oversizedChangeInspectionPermitsForceRemoval() async throws { + let fixture = try removalFixture() + let environment = fixture.environment + let loads = LockedValue(0) + let forcedRemovals = LockedValue(0) + let afterRemoval = inventory(environment) + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: fixture.snapshot, + kwtInventoryLoader: { _ in + loads.withLock { $0 += 1 } + return loads.load() == 1 + ? fixture.beforeRemoval + : afterRemoval + }, + kwtForceWorktreeRemover: { _, _, _, _, _ in + forcedRemovals.withLock { $0 += 1 } + }, + kwtWorktreeChangeReader: { _, _, _, _, _ in + throw KwtWorktreeError.changeInspectionFailed( + host: "local", + status: AccountCommandRunner.outputExceededStatus, + code: "response_too_large", + message: "kwt returned too many changed files to display.", + retryable: false, + details: [:] + ) + } + ) + + let request = try await model.prepareWorktreeRemoval( + fixture.removable.id + ) + + #expect(request.forceRemoval) + #expect(!request.changeInspectionComplete) + #expect(try await model.resolveWorktreeRemoval(request) == .removed) + #expect(forcedRemovals.load() == 1) await model.shutdown() } diff --git a/Tests/App/WorktreeChangesLoaderAuthorityTests.swift b/Tests/App/WorktreeChangesLoaderAuthorityTests.swift new file mode 100644 index 00000000..f8fdbb0e --- /dev/null +++ b/Tests/App/WorktreeChangesLoaderAuthorityTests.swift @@ -0,0 +1,466 @@ +import Combine +import Foundation +import GhosthubPersistence +import GhosthubSettings +import GhosthubTransport +import GhosthubTmux +import GhosthubUI +import GhosthubWorkspace +import Testing +@testable import GhosthubApp + +@Suite("worktree changes loader authority") +struct WorktreeChangesLoaderAuthorityTests { + @MainActor + @Test("malformed successful inspection waits for manual refresh", arguments: [ + "GHOSTHUB_KWT_JSON\nnot-json", "missing marker", + ]) + func malformedInspectionStopsPolling(output: String) async throws { + let fixture = makeFixture() + let reads = LockedValue(0) + let client = KwtWorktreeClient(localRunner: { _, _ in + reads.withLock { $0 += 1 } + return (0, output) + }) + let identity = try #require(WorktreeChangesIdentity.resolve( + worktreeID: fixture.worktree.id, in: fixture.snapshot + )) + let store = WorktreeChangesStore() + store.setExpanded(true, worktreeID: fixture.worktree.id) + for _ in 0 ..< 2 { + await WorktreeChangesPollLoop.run( + identity: identity, worktree: fixture.worktree, store: store, + currentSnapshot: { fixture.snapshot }, isEligible: { true }, + load: { worktree in + try await client.changes( + worktreePath: worktree.path, + expectedRepository: identity.repository, + expectedGeneration: identity.generation, on: .local + ) + }, + sleep: { _ in + Issue.record("Malformed inspection must not schedule a retry") + throw CancellationError() + } + ) + } + #expect(reads.load() == 1) + #expect(store.entry(for: identity).requiresManualRefresh) + } + + @MainActor + @Test( + "registration changes stop stale polling until inventory supplies a new identity", + arguments: [false, true] + ) + func registrationChangeWaitsForInventoryRefresh(manualRefreshDuringRead: Bool) async throws { + let fixture = makeFixture() + var snapshot = fixture.snapshot + let oldGeneration = try #require(fixture.worktree.generation) + let newGeneration = String(repeating: "f", count: 32) + let reads = LockedValue<[String]>([]) + let delays = LockedValue<[Duration]>([]) + let client = KwtWorktreeClient(localRunner: { _, command in + if command.contains(oldGeneration) { + return (1, """ + GHOSTHUB_KWT_JSON + {"error":{"code":"registration_changed","message":"Worktree registration changed.","retryable":true}} + """) + } + return (0, """ + GHOSTHUB_KWT_JSON + {"worktree":{"repository":"\(fixture.project.scopedKey)","path":"\(fixture.worktree + .path)","generation":"\( + newGeneration + )"},"changes":{"state":"clean","summary":{"modified":0,"added":0,"deleted":0,"untracked":0,"staged":0,"conflicts":0},"files":[]},"observed_at":"now"} + """) + }) + let store = WorktreeChangesStore() + store.setExpanded(true, worktreeID: fixture.worktree.id) + let oldIdentity = try #require(WorktreeChangesIdentity.resolve( + worktreeID: fixture.worktree.id, in: snapshot + )) + let poll = { + let worktree = snapshot.worktrees[0] + let identity = try #require(WorktreeChangesIdentity.resolve( + worktreeID: worktree.id, in: snapshot + )) + await WorktreeChangesPollLoop.run( + identity: identity, worktree: worktree, store: store, + currentSnapshot: { snapshot }, isEligible: { true }, + load: { requested in + let generation = try #require(requested.generation) + reads.withLock { $0.append(generation) } + if manualRefreshDuringRead, generation == oldGeneration { + await MainActor.run { + store.requestManualRefresh(for: oldIdentity, refreshInventory: { + Issue.record("The in-flight read has not failed yet") + }) + } + } + return try await client.changes( + worktreePath: requested.path, + expectedRepository: fixture.project.scopedKey, + expectedGeneration: generation, on: .local + ) + }, + sleep: { duration in + delays.withLock { $0.append(duration) } + throw CancellationError() + } + ) + } + + try await poll() + try await poll() + #expect(reads.load() == [oldGeneration]) + #expect(delays.load().isEmpty) + #expect(store.entry(for: oldIdentity).requiresManualRefresh) + #expect(!store.entry(for: oldIdentity).isLoading) + #expect(store.entry(for: oldIdentity).errorMessage? + .contains("Refresh workspace inventory") == true) + + store.requestManualRefresh(for: oldIdentity, refreshInventory: { + snapshot.worktrees[0].generation = newGeneration + }) + let newIdentity = try #require(WorktreeChangesIdentity.resolve( + worktreeID: fixture.worktree.id, in: snapshot + )) + store.prune(keeping: [newIdentity]) + try await poll() + #expect(reads.load() == [oldGeneration, newGeneration]) + #expect(store.entry(for: newIdentity).hasSuccessfulValue) + #expect(!store.entry(for: newIdentity).requiresManualRefresh) + } + + @Test("current inventory supplies the exact guarded target") + func currentTarget() async throws { + let fixture = makeFixture() + let recorded = LockedValue(nil) + let expected = WorktreeFileChanges( + repository: fixture.project.scopedKey, + path: fixture.worktree.path, + generation: fixture.worktree.generation!, + state: .clean, + summary: .clean, + files: [], + observedAt: "now" + ) + + let result = try await WorktreeChangesLoaderAuthority.load( + requested: fixture.worktree, + in: fixture.snapshot, + read: { path, repository, generation, _, host in + recorded.store(ReadArguments( + path: path, + repository: repository, + generation: generation, + host: host + )) + return expected + } + ) + + #expect(result == expected) + #expect(recorded.load()?.path == fixture.worktree.path) + #expect(recorded.load()?.repository == fixture.project.scopedKey) + #expect(recorded.load()?.generation == fixture.worktree.generation) + #expect(recorded.load()?.host == .local) + } + + @Test("changed inventory rejects the request before reading") + func changedTarget() async { + let fixture = makeFixture() + var changed = fixture.snapshot + changed.worktrees[0].path = "/repo/replaced" + let reads = LockedValue(0) + + await #expect(throws: KwtWorktreeError.worktreeUnavailable) { + _ = try await WorktreeChangesLoaderAuthority.load( + requested: fixture.worktree, + in: changed, + read: { _, _, _, _, _ in + reads.withLock { $0 += 1 } + throw KwtWorktreeError.commandFailed( + host: "unexpected", + status: 1 + ) + } + ) + } + #expect(reads.load() == 0) + } + + @Test("remote loads provision kwt before reading changes") + @MainActor + func remoteLoadProvisionsKwt() async throws { + let fixture = makeFixture() + let localHost = HostSummary.fixture() + var remoteHost = fixture.snapshot.hosts[0] + remoteHost.kind = .remote + remoteHost.platform = .linux + remoteHost.sshDestination = "user-a@builder.example.test" + var snapshot = fixture.snapshot + snapshot.hosts = [localHost, remoteHost] + let events = LockedValue<[String]>([]) + let expected = WorktreeFileChanges( + repository: fixture.project.scopedKey, + path: fixture.worktree.path, + generation: fixture.worktree.generation!, + state: .clean, + summary: .clean, + files: [], + observedAt: "now" + ) + let model = try makeModel( + database: try WorkspaceDatabase.inMemory(), + localHostID: localHost.id, + snapshot: snapshot, + kwtRemoteProvisioner: { _ in + events.withLock { $0.append("provision") } + }, + kwtWorktreeChangesReader: { _, _, _, routeIdentity, _ in + events.withLock { $0.append("read") } + #expect(routeIdentity == "sha256:test-route") + return expected + } + ) + + let result = try await model.loadWorktreeChanges(fixture.worktree) + + #expect(result == expected) + #expect(events.load() == ["provision", "read"]) + await model.shutdown() + } + + @MainActor + @Test( + "changed-file loads reject hosts changed during provisioning even after inventory returns", + arguments: [ + ("user-a@builder.example.test:2222", HostPlatform.linux), + ("user-a@builder.example.test", HostPlatform.macOS), + ] + ) + func hostChangedDuringProvisioning(destination: String, platform: HostPlatform) async throws { + let fixture = makeFixture() + let localHost = HostSummary.fixture() + var remoteHost = fixture.snapshot.hosts[0] + remoteHost.kind = .remote + remoteHost.platform = .linux + remoteHost.configKey = "builder" + remoteHost.sshDestination = "user-a@builder.example.test" + let configuredHost = SSHHost( + configKey: remoteHost.configKey, name: remoteHost.name, + platform: remoteHost.platform, + sshDestination: try #require(remoteHost.sshDestination) + ) + let configuredHosts = CurrentValueSubject<[SSHHost], Never>([configuredHost]) + let inventory = WorkspaceTmuxTestSupport.inventory( + project: fixture.project, worktrees: [fixture.worktree] + ) + let snapshot = KwtSnapshotMerger.merge( + inventory, hostID: remoteHost.id, + into: .fixture(hosts: [localHost, remoteHost]) + ) + let worktree = try #require(snapshot.worktrees.first) + let provisioningGate = AsyncGate() + let reads = LockedValue(0) + let model = try makeModel( + database: try WorkspaceDatabase.inMemory(), + localHostID: localHost.id, + snapshot: snapshot, + kwtRemoteProvisioner: { host in + #expect(host == configuredHost) + await provisioningGate.wait() + }, + kwtWorktreeChangesReader: { path, repository, generation, _, _ in + reads.withLock { $0 += 1 } + return WorktreeFileChanges( + repository: repository, path: path, generation: generation, + state: .clean, summary: .clean, files: [], observedAt: "now" + ) + }, + configuredSSHHostsProvider: { configuredHosts.value } + ) + let read = Task { try await model.loadWorktreeChanges(worktree) } + await provisioningGate.waitUntilWaiting() + configuredHosts.send([SSHHost( + configKey: configuredHost.configKey, name: configuredHost.name, + platform: platform, sshDestination: destination + )]) + model.refreshHosts() + #expect(model.snapshot.worktrees.isEmpty) + model.snapshot = KwtSnapshotMerger.merge( + inventory, hostID: remoteHost.id, into: model.snapshot + ) + #expect(model.snapshot.worktree(id: worktree.id)?.generation == worktree.generation) + provisioningGate.open() + + await #expect(throws: KwtWorktreeError.worktreeUnavailable) { + try await read.value + } + #expect(reads.load() == 0) + await model.shutdown() + } + + @Test("permanent provisioning failures stop automatic retries") + @MainActor + func permanentProvisioningFailureIsNotRetryable() async throws { + let fixture = makeFixture() + let localHost = HostSummary.fixture() + var remoteHost = fixture.snapshot.hosts[0] + remoteHost.kind = .remote + remoteHost.platform = .linux + remoteHost.sshDestination = "user-a@builder.example.test" + var snapshot = fixture.snapshot + snapshot.hosts = [localHost, remoteHost] + let model = try makeModel( + database: try WorkspaceDatabase.inMemory(), + localHostID: localHost.id, + snapshot: snapshot, + kwtRemoteProvisioner: { _ in + throw KwtRemoteInstallError.bundleIncomplete + } + ) + + await #expect { + try await model.loadWorktreeChanges(fixture.worktree) + } throws: { error in + (error as? any WorktreeChangesRetryClassifying)?.isRetryable + == false + } + await model.shutdown() + } + + @Test("transient provisioning failures remain retryable") + @MainActor + func transientProvisioningFailureIsRetryable() async throws { + let fixture = makeFixture() + let localHost = HostSummary.fixture() + var remoteHost = fixture.snapshot.hosts[0] + remoteHost.kind = .remote + remoteHost.platform = .linux + remoteHost.sshDestination = "user-a@builder.example.test" + var snapshot = fixture.snapshot + snapshot.hosts = [localHost, remoteHost] + let model = try makeModel( + database: try WorkspaceDatabase.inMemory(), + localHostID: localHost.id, + snapshot: snapshot, + kwtRemoteProvisioner: { _ in + throw KwtSSHLeaseError.acquisitionTimedOut + } + ) + + await #expect { + try await model.loadWorktreeChanges(fixture.worktree) + } throws: { error in + (error as? any WorktreeChangesRetryClassifying)?.isRetryable + == true + } + await model.shutdown() + } + + @Test("route resolution failures stop automatic retries") + @MainActor + func routeResolutionFailureIsNotRetryable() async throws { + let fixture = makeFixture() + let localHost = HostSummary.fixture() + var remoteHost = fixture.snapshot.hosts[0] + remoteHost.kind = .remote + remoteHost.platform = .linux + remoteHost.sshDestination = "user-a@builder.example.test" + var snapshot = fixture.snapshot + snapshot.hosts = [localHost, remoteHost] + let model = try makeModel( + database: try WorkspaceDatabase.inMemory(), + localHostID: localHost.id, + snapshot: snapshot, + sshRouteIdentityResolver: { _ in + throw KwtSSHRouteError.helperUnavailable + } + ) + + await #expect { + try await model.loadWorktreeChanges(fixture.worktree) + } throws: { error in + (error as? any WorktreeChangesRetryClassifying)?.isRetryable + == false + } + await model.shutdown() + } + + @MainActor + @Test( + "Windows refresh accepts equivalent paths and reads the original inventory spelling", + arguments: [ + (#"C:\Worktrees\Topic"#, "c:/worktrees/topic"), + (#"\\server\share\Topic"#, "//SERVER/share/topic"), + ("//server/share", #"\\SERVER\Share"#), + (#"\\wsl.localhost\Ubuntu\repo"#, "//WSL.LOCALHOST/ubuntu/repo"), + ] + ) + func windowsRefreshPreservesReadPath(path: String, requestedPath: String) async throws { + let fixture = makeFixture() + let localHost = HostSummary.fixture() + var snapshot = fixture.snapshot + snapshot.hosts[0].kind = .remote + snapshot.hosts[0].platform = .windows + snapshot.hosts[0].sshDestination = "user-a@builder.example.test" + snapshot.hosts.append(localHost) + snapshot.worktrees[0].path = path + var requested = snapshot.worktrees[0] + requested.path = requestedPath + let paths = LockedValue<[String]>([]) + let model = try makeModel( + database: try WorkspaceDatabase.inMemory(), + localHostID: localHost.id, + snapshot: snapshot, + kwtWorktreeChangesReader: { path, repository, generation, _, _ in + paths.withLock { $0.append(path) } + return WorktreeFileChanges( + repository: repository, path: path, generation: generation, + state: .clean, summary: .clean, files: [], observedAt: "now" + ) + } + ) + let result = try await model.loadWorktreeChanges(requested) + #expect(paths.load() == [path]) + #expect(result.path == path) + await model.shutdown() + } + + private func makeFixture() -> AuthorityFixture { + let host = HostSummary.fixture() + var project = ProjectSummary.fixture(hostID: host.id) + project.scopedKey = "github.com/kenn-io/ghosthub" + var worktree = WorktreeSummary.fixture( + hostID: host.id, + projectID: project.id, + path: "/repo/topic" + ) + worktree.generation = "0123456789abcdef0123456789abcdef" + return AuthorityFixture( + snapshot: WorkspaceSnapshot.fixture( + hosts: [host], + projects: [project], + worktrees: [worktree] + ), + project: project, + worktree: worktree + ) + } +} + +private struct AuthorityFixture { + let snapshot: WorkspaceSnapshot + let project: ProjectSummary + let worktree: WorktreeSummary +} + +private struct ReadArguments { + let path: String + let repository: String + let generation: String + let host: CommandHost +} diff --git a/Tests/App/WorktreeChangesReadCoordinatorTests.swift b/Tests/App/WorktreeChangesReadCoordinatorTests.swift new file mode 100644 index 00000000..32b1460c --- /dev/null +++ b/Tests/App/WorktreeChangesReadCoordinatorTests.swift @@ -0,0 +1,360 @@ +import Foundation +import GhosthubWorkspace +import Testing +@testable import GhosthubApp +@testable import GhosthubUI + +@Suite("worktree changes read coordinator") +struct WorktreeChangesReadCoordinatorTests { + @Test("an already canceled caller never starts an inspection") + func cancellationBeforeRegistration() async { + let coordinator = WorktreeChangesReadCoordinator(globalLimit: 1, perHostLimit: 1) + let identity = changesIdentity(hostID: UUID(), index: 1) + let started = LockedValue(false) + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try await coordinator.load(identity: identity) { + started.withLock { $0 = true } + return WorktreeFileChanges( + repository: identity.repository, path: identity.path, + generation: identity.generation, state: .clean, + summary: .clean, files: [], observedAt: "now" + ) + } + } + switch await task.result { + case .success: + Issue.record("An already canceled caller should receive cancellation") + case let .failure(error): + #expect(error is CancellationError) + } + #expect(!started.load()) + } + + @Test("reads are bounded per host") + func boundedHostConcurrency() async throws { + let coordinator = WorktreeChangesReadCoordinator( + globalLimit: 3, + perHostLimit: 2 + ) + let probe = ConcurrentReadProbe() + let hostID = UUID() + let identities = (0 ..< 5).map { + changesIdentity(hostID: hostID, index: $0) + } + let tasks = identities.map { identity in + Task { + try await coordinator.load(identity: identity) { + try await probe.load(identity) + } + } + } + + await probe.waitUntilStarted(2) + #expect(await probe.maximumActive == 2) + #expect(await probe.startedCount == 2) + await probe.releaseAll() + for task in tasks { + _ = try await task.value + } + + #expect(await probe.maximumActive == 2) + #expect(await probe.startedCount == 5) + } + + @Test("reads are bounded across hosts") + func boundedGlobalConcurrency() async throws { + let coordinator = WorktreeChangesReadCoordinator( + globalLimit: 3, + perHostLimit: 2 + ) + let probe = ConcurrentReadProbe() + let identities = (0 ..< 6).map { + changesIdentity(hostID: UUID(), index: $0) + } + let tasks = identities.map { identity in + Task { + try await coordinator.load(identity: identity) { + try await probe.load(identity) + } + } + } + + await probe.waitUntilStarted(3) + #expect(await probe.maximumActive == 3) + #expect(await probe.startedCount == 3) + await probe.releaseAll() + for task in tasks { + _ = try await task.value + } + + #expect(await probe.maximumActive == 3) + #expect(await probe.startedCount == 6) + } + + @Test("identical reads share one underlying inspection") + func coalescesIdenticalReads() async throws { + let coordinator = WorktreeChangesReadCoordinator( + globalLimit: 4, + perHostLimit: 2 + ) + let probe = ConcurrentReadProbe() + let identity = changesIdentity(hostID: UUID(), index: 1) + let first = Task { + try await coordinator.load(identity: identity) { + try await probe.load(identity) + } + } + let second = Task { + try await coordinator.load(identity: identity) { + try await probe.load(identity) + } + } + + await probe.waitUntilStarted(1) + #expect(await probe.startedCount == 1) + await probe.releaseAll() + _ = try await first.value + _ = try await second.value + + #expect(await probe.startedCount == 1) + } + + @Test("canceling the last waiter cancels its inspection") + func cancellationStopsInspection() async { + let coordinator = WorktreeChangesReadCoordinator( + globalLimit: 1, + perHostLimit: 1 + ) + let probe = CancellableReadProbe() + let identity = changesIdentity(hostID: UUID(), index: 1) + let task = Task { + try await coordinator.load(identity: identity) { + try await probe.load() + } + } + + await probe.waitUntilStarted() + task.cancel() + _ = await task.result + await probe.waitUntilCanceled() + + #expect(await probe.wasCanceled) + } + + @Test("a replacement waits for canceled inspection cleanup") + func replacementWaitsForCancellationDrain() async throws { + let coordinator = WorktreeChangesReadCoordinator( + globalLimit: 1, + perHostLimit: 1 + ) + let probe = CancellationDrainReadProbe() + let identity = changesIdentity(hostID: UUID(), index: 1) + let first = Task { + try await coordinator.load(identity: identity) { + try await probe.load(identity) + } + } + + await probe.waitUntilStarted(1) + first.cancel() + _ = await first.result + let second = Task { + try await coordinator.load(identity: identity) { + try await probe.load(identity) + } + } + try await Task.sleep(for: .milliseconds(25)) + + #expect(await probe.startedCount == 1) + #expect(await probe.maximumActive == 1) + await probe.releaseCancellationDrain() + _ = try await second.value + + #expect(await probe.startedCount == 2) + #expect(await probe.maximumActive == 1) + } + + @Test("a new generation waits for canceled worktree cleanup") + func generationReplacementWaitsForCancellationDrain() async throws { + let coordinator = WorktreeChangesReadCoordinator( + globalLimit: 2, + perHostLimit: 2 + ) + let probe = CancellationDrainReadProbe() + let hostID = UUID() + let worktreeID = UUID() + let firstIdentity = changesIdentity( + hostID: hostID, + worktreeID: worktreeID, + index: 1 + ) + let replacementIdentity = changesIdentity( + hostID: hostID, + worktreeID: worktreeID, + index: 2 + ) + let first = Task { + try await coordinator.load(identity: firstIdentity) { + try await probe.load(firstIdentity) + } + } + + await probe.waitUntilStarted(1) + first.cancel() + _ = await first.result + let replacement = Task { + try await coordinator.load(identity: replacementIdentity) { + try await probe.load(replacementIdentity) + } + } + try await Task.sleep(for: .milliseconds(25)) + + #expect(await probe.startedCount == 1) + #expect(await probe.maximumActive == 1) + await probe.releaseCancellationDrain() + _ = try await replacement.value + + #expect(await probe.startedCount == 2) + #expect(await probe.maximumActive == 1) + } +} + +private func changesIdentity( + hostID: UUID, + worktreeID: UUID = UUID(), + index: Int +) -> WorktreeChangesIdentity { + WorktreeChangesIdentity( + worktreeID: worktreeID, + hostID: hostID, + hostRouteKey: "host", + projectID: UUID(), + registrationFingerprint: "registration-\(index)", + repository: "github.com/acme/project", + path: "/worktrees/\(index)", + generation: String(format: "%032x", index + 1), + usesWindowsPaths: false + ) +} + +private actor ConcurrentReadProbe { + private(set) var startedCount = 0 + private(set) var maximumActive = 0 + private var activeCount = 0 + private var releases: [CheckedContinuation] = [] + private var releasesAreOpen = false + + func load( + _ identity: WorktreeChangesIdentity + ) async throws -> WorktreeFileChanges { + startedCount += 1 + activeCount += 1 + maximumActive = max(maximumActive, activeCount) + if !releasesAreOpen { + await withCheckedContinuation { continuation in + releases.append(continuation) + } + } + activeCount -= 1 + return WorktreeFileChanges( + repository: identity.repository, + path: identity.path, + generation: identity.generation, + state: .clean, + summary: .clean, + files: [], + observedAt: "now" + ) + } + + func waitUntilStarted(_ count: Int) async { + while startedCount < count { + await Task.yield() + } + } + + func releaseAll() { + releasesAreOpen = true + let pending = releases + releases.removeAll() + for release in pending { + release.resume() + } + } +} + +private actor CancellableReadProbe { + private(set) var wasCanceled = false + private var started = false + + func load() async throws -> WorktreeFileChanges { + started = true + do { + try await Task.sleep(for: .seconds(30)) + } catch { + wasCanceled = true + throw error + } + throw CancellationError() + } + + func waitUntilStarted() async { + while !started { + await Task.yield() + } + } + + func waitUntilCanceled() async { + while !wasCanceled { + await Task.yield() + } + } +} + +private actor CancellationDrainReadProbe { + private(set) var startedCount = 0 + private(set) var maximumActive = 0 + private var activeCount = 0 + private var cancellationRelease: CheckedContinuation? + + func load( + _ identity: WorktreeChangesIdentity + ) async throws -> WorktreeFileChanges { + startedCount += 1 + activeCount += 1 + maximumActive = max(maximumActive, activeCount) + if startedCount == 1 { + do { + try await Task.sleep(for: .seconds(30)) + } catch { + await withCheckedContinuation { continuation in + cancellationRelease = continuation + } + activeCount -= 1 + throw error + } + } + activeCount -= 1 + return WorktreeFileChanges( + repository: identity.repository, + path: identity.path, + generation: identity.generation, + state: .clean, + summary: .clean, + files: [], + observedAt: "now" + ) + } + + func waitUntilStarted(_ count: Int) async { + while startedCount < count { + await Task.yield() + } + } + + func releaseCancellationDrain() { + cancellationRelease?.resume() + cancellationRelease = nil + } +} diff --git a/Tests/UI/WorkspaceAlertTests.swift b/Tests/UI/WorkspaceAlertTests.swift index 9751bf1c..6a1847e4 100644 --- a/Tests/UI/WorkspaceAlertTests.swift +++ b/Tests/UI/WorkspaceAlertTests.swift @@ -20,6 +20,22 @@ struct WorkspaceAlertTests { #expect(request.worktreeRemovalMessage.contains("permanently discards")) } + @Test("incomplete change inspection requires a precise force warning") + func incompleteChangeInspectionPresentation() { + let host = HostSummary.fixture() + let project = ProjectSummary.fixture(hostID: host.id) + let request = WorktreeRemovalRequest( + worktree: .fixture(hostID: host.id, projectID: project.id), + project: project, + confirmedHost: host, + changeInspectionComplete: false + ) + + #expect(request.worktreeRemovalActionTitle == "Force Remove Worktree") + #expect(request.worktreeRemovalMessage.contains("could not enumerate")) + #expect(request.worktreeRemovalMessage.contains("may permanently discard")) + } + @Test("workspace actions share one alert identity domain") func workspaceActionsShareAlertIdentityDomain() { let session = WorkspaceAlert.sessionKillFailure( diff --git a/Tests/UI/WorkspaceSidebarModelTests.swift b/Tests/UI/WorkspaceSidebarModelTests.swift index 3dfa892c..ccaeb24a 100644 --- a/Tests/UI/WorkspaceSidebarModelTests.swift +++ b/Tests/UI/WorkspaceSidebarModelTests.swift @@ -307,6 +307,27 @@ struct WorkspaceSidebarModelTests { #expect(nested - child == child - host) } + @Test("worktree disclosure occupies its hierarchy indent") + func worktreeDisclosureUsesChildIndent() { + let rowLevel = 1 + let presentation = WorkspaceWorktreeDisclosurePresentation( + rowIndentLevel: rowLevel + ) + + #expect( + presentation.leadingIndent + == WorkspaceSidebarHierarchy.indent(level: rowLevel) + ) + #expect(presentation.contentIndentLevel == 0) + #expect( + presentation.leadingIndent + + WorkspaceSidebarHierarchy.indent( + level: presentation.contentIndentLevel + ) + == WorkspaceSidebarHierarchy.indent(level: rowLevel) + ) + } + @Test("session actions appear on hover or selection without shifting rows") func sessionActionsStayDiscoverableAndStable() { let idle = WorkspaceSessionActionPresentation( @@ -342,26 +363,36 @@ struct WorkspaceSidebarModelTests { #expect(selected.hitTargetWidth >= 28) } - @Test("worktree removal is subtle without shrinking its hit target") - func worktreeRemovalHoverKeepsStableHitTarget() { + @Test("worktree removal stays subtle without shrinking its hit target") + func worktreeRemovalKeepsStableHitTarget() { let idle = WorkspaceWorktreeRemovalActionPresentation( isRemovable: true, isRowHovered: false, - isActionHovered: false + isActionHovered: false, + isFocused: false ) let hovered = WorkspaceWorktreeRemovalActionPresentation( isRemovable: true, isRowHovered: true, - isActionHovered: false + isActionHovered: false, + isFocused: false + ) + let focused = WorkspaceWorktreeRemovalActionPresentation( + isRemovable: true, + isRowHovered: false, + isActionHovered: false, + isFocused: true ) let primary = WorkspaceWorktreeRemovalActionPresentation( isRemovable: false, isRowHovered: true, - isActionHovered: true + isActionHovered: true, + isFocused: true ) #expect(!idle.isVisible) #expect(hovered.isVisible) + #expect(focused.isVisible) #expect(idle.reservedWidth == hovered.reservedWidth) #expect(hovered.hitTargetWidth >= 28) #expect(!primary.isVisible) diff --git a/Tests/UI/WorktreeChangesStateTests.swift b/Tests/UI/WorktreeChangesStateTests.swift new file mode 100644 index 00000000..6a4affc0 --- /dev/null +++ b/Tests/UI/WorktreeChangesStateTests.swift @@ -0,0 +1,937 @@ +import Combine +import Darwin +import Foundation +import GhosthubWorkspace +import Testing +@testable import GhosthubUI + +@Suite("worktree changes state") +struct WorktreeChangesStateTests { + @Test( + "polling requires a visible active app except in the isolated demo", + arguments: [ + (true, true, false, true), + (true, false, false, false), + (false, true, false, false), + (true, false, true, true), + (false, false, true, false), + ] + ) + func pollingEligibility( + sidebarVisible: Bool, + applicationActive: Bool, + permitsBackgroundDemoControl: Bool, + expected: Bool + ) { + #expect(WorktreeChangesPollingEligibility.isEligible( + sidebarVisible: sidebarVisible, + applicationActive: applicationActive, + permitsBackgroundDemoControl: permitsBackgroundDemoControl + ) == expected) + } + + @MainActor + @Test("successful values survive a later failure and recovery") + func retainedValueTransitions() throws { + let hostID = UUID() + var project = ProjectSummary.fixture(hostID: hostID) + project.scopedKey = "github.com/kenn-io/ghosthub" + var worktree = WorktreeSummary.fixture( + hostID: hostID, + projectID: project.id, + path: "/repo/topic" + ) + worktree.generation = "0123456789abcdef0123456789abcdef" + let snapshot = WorkspaceSnapshot.fixture( + hosts: [.fixture(id: hostID)], + projects: [project], + worktrees: [worktree] + ) + let identity = try #require(WorktreeChangesIdentity.resolve( + worktreeID: worktree.id, + in: snapshot + )) + let store = WorktreeChangesStore() + store.setExpanded(true, worktreeID: worktree.id) + let request = try #require(store.beginRequest(for: identity)) + store.finishRequest( + request, + for: identity, + result: .success(.init( + repository: project.scopedKey, + path: worktree.path, + generation: worktree.generation!, + state: .modified, + summary: .init(modified: 1), + files: [.init( + path: "Sources/App.swift", + originalPath: nil, + index: nil, + worktree: .modified + )], + observedAt: "now" + )), + publishResult: true, + filesChanged: true + ) + let failing = try #require(store.beginRequest(for: identity)) + store.finishRequest( + failing, + for: identity, + result: .failure(TestFailure()), + publishResult: true, + filesChanged: false + ) + + let entry = store.entry(for: identity) + #expect(entry.files.map(\.path) == ["Sources/App.swift"]) + #expect(entry.isStale) + #expect(entry.errorMessage != nil) + } + + @MainActor + @Test("manual refresh during a read queues one follow-up read") + func manualRefreshQueuesAfterInFlightRead() throws { + let fixture = try changesFixture() + let store = WorktreeChangesStore() + let initial = try #require(store.beginRequest( + for: fixture.identity + )) + store.finishRequest( + initial, + for: fixture.identity, + result: .success(fixture.result), + publishResult: true, + filesChanged: true + ) + let request = try #require(store.beginRequest( + for: fixture.identity + )) + + #expect(!store.entry(for: fixture.identity).isLoading) + + store.requestManualRefresh(for: fixture.identity, refreshInventory: { + Issue.record("Ordinary refresh should not reload inventory") + }) + #expect(store.entry(for: fixture.identity).isLoading) + store.finishRequest( + request, + for: fixture.identity, + result: .success(fixture.result), + publishResult: true, + filesChanged: false + ) + + #expect(store.entry(for: fixture.identity).resumeRevision == 1) + #expect(store.entry(for: fixture.identity).isLoading) + + let followUp = try #require(store.beginRequest( + for: fixture.identity + )) + store.finishRequest( + followUp, + for: fixture.identity, + result: .success(fixture.result), + publishResult: true, + filesChanged: false + ) + + #expect(!store.entry(for: fixture.identity).isLoading) + } + + @MainActor + @Test("unchanged background refreshes do not republish visible state") + func unchangedRefreshDoesNotPublish() throws { + let fixture = try changesFixture() + let store = WorktreeChangesStore() + let first = try #require(store.beginRequest(for: fixture.identity)) + store.finishRequest( + first, + for: fixture.identity, + result: .success(fixture.result), + publishResult: true, + filesChanged: true + ) + let counter = ChangeCounter() + let observation = store.objectWillChange.sink { + counter.increment() + } + + let second = try #require(store.beginRequest(for: fixture.identity)) + store.finishRequest( + second, + for: fixture.identity, + result: .success(fixture.result), + publishResult: true, + filesChanged: false + ) + + #expect(counter.value == 0) + withExtendedLifetime(observation) {} + } + + @MainActor + @Test("pruning an unchanged inventory does not republish state") + func unchangedPruneDoesNotPublish() throws { + let fixture = try changesFixture() + let store = WorktreeChangesStore() + store.setExpanded(true, worktreeID: fixture.worktree.id) + let request = try #require(store.beginRequest(for: fixture.identity)) + store.finishRequest( + request, + for: fixture.identity, + result: .success(fixture.result), + publishResult: true, + filesChanged: true + ) + let counter = ChangeCounter() + let observation = store.objectWillChange.sink { + counter.increment() + } + + store.prune(in: fixture.snapshot) + + #expect(counter.value == 0) + withExtendedLifetime(observation) {} + } + + @MainActor + @Test("unused changes state does not request inventory for pruning") + func unusedPruneSkipsInventory() { + let store = WorktreeChangesStore() + store.prune(in: { + Issue.record("Unused changes state should not inspect inventory") + return WorkspaceSnapshot(hosts: [], projects: [], worktrees: []) + }()) + } + + @MainActor + @Test("inventory pruning retains valid cached files and removes obsolete registrations") + func inventoryPrunesTrackedChanges() throws { + let fixture = try changesFixture() + let store = WorktreeChangesStore() + store.setExpanded(true, worktreeID: fixture.worktree.id) + let request = try #require(store.beginRequest(for: fixture.identity)) + store.finishRequest( + request, for: fixture.identity, result: .success(fixture.result), + publishResult: true, filesChanged: true + ) + store.setExpanded(false, worktreeID: fixture.worktree.id) + store.prune(in: fixture.snapshot) + #expect(store.entry(for: fixture.identity).hasSuccessfulValue) + + var snapshot = fixture.snapshot + snapshot.projects[0].registrationFingerprint = "new-registration" + store.setExpanded(true, worktreeID: fixture.worktree.id) + store.prune(in: snapshot) + #expect(store.isExpanded(fixture.worktree.id)) + #expect(!store.entry(for: fixture.identity).hasSuccessfulValue) + + snapshot.worktrees = [] + store.prune(in: snapshot) + #expect(!store.isExpanded(fixture.worktree.id)) + } + + @Test("file comparison detects only exact snapshot changes") + func exactFileComparison() async { + let first = WorktreeFileChange( + path: "Sources/App.swift", + originalPath: nil, + index: nil, + worktree: .modified + ) + let second = WorktreeFileChange( + path: "Sources/Other.swift", + originalPath: nil, + index: .added, + worktree: nil + ) + + let unchanged = await WorktreeChangesComparison.filesChanged( + previous: [first], + current: [first] + ) + let changed = await WorktreeChangesComparison.filesChanged( + previous: [first], + current: [first, second] + ) + + #expect(!unchanged) + #expect(changed) + } + + @MainActor + @Test("collapsed snapshots are retained within a fixed cache budget") + func collapsedSnapshotCacheIsBounded() throws { + let store = WorktreeChangesStore() + var identities: [WorktreeChangesIdentity] = [] + for index in 0 ... 8 { + let identity = WorktreeChangesIdentity( + worktreeID: UUID(), + hostID: UUID(), + hostRouteKey: "host-\(index)", + projectID: UUID(), + registrationFingerprint: "registration-\(index)", + repository: "github.com/acme/project-\(index)", + path: "/repo/\(index)", + generation: String(format: "%032x", index + 1), + usesWindowsPaths: false + ) + identities.append(identity) + store.setExpanded(true, worktreeID: identity.worktreeID) + let request = try #require(store.beginRequest(for: identity)) + store.finishRequest( + request, + for: identity, + result: .success(.init( + repository: identity.repository, + path: identity.path, + generation: identity.generation, + state: .modified, + summary: .init(modified: 1), + files: [.init( + path: "file-\(index)", + originalPath: nil, + index: nil, + worktree: .modified + )], + observedAt: "now" + )), + publishResult: true, + filesChanged: true + ) + store.setExpanded(false, worktreeID: identity.worktreeID) + } + + #expect(!store.entry(for: identities[0]).hasSuccessfulValue) + for identity in identities.dropFirst() { + #expect(store.entry(for: identity).hasSuccessfulValue) + } + } + + @Test("identity validates repository path and generation") + func identityValidation() throws { + let hostID = UUID() + var project = ProjectSummary.fixture(hostID: hostID) + project.scopedKey = "github.com/kenn-io/ghosthub" + var worktree = WorktreeSummary.fixture( + hostID: hostID, + projectID: project.id, + path: "/repo/topic" + ) + worktree.generation = "0123456789abcdef0123456789abcdef" + let snapshot = WorkspaceSnapshot.fixture( + hosts: [.fixture(id: hostID)], + projects: [project], + worktrees: [worktree] + ) + let identity = try #require(WorktreeChangesIdentity.resolve( + worktreeID: worktree.id, + in: snapshot + )) + let result = WorktreeFileChanges( + repository: project.scopedKey, + path: "/repo/topic", + generation: worktree.generation!, + state: .clean, + summary: .clean, + files: [], + observedAt: "now" + ) + + #expect(identity.matches(result: result, in: snapshot)) + } + + @MainActor + @Test("Windows path spelling changes retain polling identity and cached files", arguments: [ + (#"C:\Worktrees\Topic"#, "c:/worktrees/topic"), + (#"C:\Worktrees\Σ"#, "c:/worktrees/ς"), + (#"\\server\share\Topic"#, "//SERVER/share/topic"), + ("//server/share", #"\\SERVER\Share"#), + (#"\\wsl.localhost\Ubuntu\repo"#, "//WSL.LOCALHOST/ubuntu/repo"), + ]) + func windowsPathRefreshRetainsIdentity(path: String, refreshedPath: String) throws { + let fixture = try changesFixture() + var snapshot = fixture.snapshot + snapshot.hosts[0].platform = .windows + snapshot.worktrees[0].path = path + let identity = try #require(WorktreeChangesIdentity.resolve( + worktreeID: fixture.worktree.id, in: snapshot + )) + let store = WorktreeChangesStore() + store.setExpanded(true, worktreeID: fixture.worktree.id) + let request = try #require(store.beginRequest(for: identity)) + let result = WorktreeFileChanges( + repository: identity.repository, + path: path, + generation: identity.generation, + state: .clean, summary: .clean, files: [], observedAt: "now" + ) + store.finishRequest( + request, + for: identity, + result: .success(result), + publishResult: true, + filesChanged: true + ) + snapshot.worktrees[0].path = refreshedPath + let refreshed = try #require(WorktreeChangesIdentity.resolve( + worktreeID: fixture.worktree.id, in: snapshot + )) + #expect(refreshed == identity) + #expect(Set([identity, refreshed]).count == 1) + #expect(identity.matches(result: result, in: snapshot)) + #expect(store.entry(for: refreshed).hasSuccessfulValue) + #expect(result.path == path) + } + + @Test("Windows identity accepts drive paths and slash differences") + func windowsIdentityValidation() throws { + let hostID = UUID() + var host = HostSummary.fixture(id: hostID) + host.platform = .windows + var project = ProjectSummary.fixture(hostID: hostID) + project.scopedKey = "github.com/kenn-io/ghosthub" + var worktree = WorktreeSummary.fixture( + hostID: hostID, + projectID: project.id, + path: #"C:\Users\user-a\ghosthub"# + ) + worktree.generation = "0123456789abcdef0123456789abcdef" + let snapshot = WorkspaceSnapshot.fixture( + hosts: [host], + projects: [project], + worktrees: [worktree] + ) + let identity = try #require(WorktreeChangesIdentity.resolve( + worktreeID: worktree.id, + in: snapshot + )) + let result = WorktreeFileChanges( + repository: project.scopedKey, + path: "c:/users/user-a/ghosthub", + generation: worktree.generation!, + state: .clean, + summary: .clean, + files: [], + observedAt: "now" + ) + + #expect(identity.matches(result: result, in: snapshot)) + } + + @Test("Windows identity rejects relative and incomplete UNC paths", arguments: [ + "repo/topic", #"C:repo\topic"#, #"\repo\topic"#, #"\\server"#, "//server/", + ]) + func windowsIdentityRejectsIncompletePath(path: String) throws { + let fixture = try changesFixture() + var snapshot = fixture.snapshot + snapshot.hosts[0].platform = .windows + snapshot.worktrees[0].path = path + #expect(WorktreeChangesIdentity.resolve( + worktreeID: fixture.worktree.id, in: snapshot + ) == nil) + } + + @Test("identity changes when the resolved host route changes") + func identityIncludesHostRoute() throws { + let hostID = UUID() + var project = ProjectSummary.fixture(hostID: hostID) + project.scopedKey = "github.com/kenn-io/ghosthub" + var worktree = WorktreeSummary.fixture( + hostID: hostID, + projectID: project.id, + path: "/repo/topic" + ) + worktree.generation = "0123456789abcdef0123456789abcdef" + let firstHost = HostSummary.fixture( + id: hostID, + configKey: "builder", + kind: .remote, + platform: .linux, + sshDestination: "dev@old-builder:2222" + ) + let replacementHost = HostSummary.fixture( + id: hostID, + configKey: "builder", + kind: .remote, + platform: .linux, + sshDestination: "dev@new-builder:2222" + ) + let firstSnapshot = WorkspaceSnapshot.fixture( + hosts: [firstHost], + projects: [project], + worktrees: [worktree] + ) + let replacementSnapshot = WorkspaceSnapshot.fixture( + hosts: [replacementHost], + projects: [project], + worktrees: [worktree] + ) + + let first = try #require(WorktreeChangesIdentity.resolve( + worktreeID: worktree.id, + in: firstSnapshot + )) + let replacement = try #require(WorktreeChangesIdentity.resolve( + worktreeID: worktree.id, + in: replacementSnapshot + )) + + #expect(first != replacement) + #expect(first.hostRouteKey != replacement.hostRouteKey) + } + + @MainActor + @Test("polling loads immediately and delays only after completion") + func pollingSequence() async throws { + let fixture = try changesFixture() + let recorder = PollRecorder() + let store = WorktreeChangesStore() + store.setExpanded(true, worktreeID: fixture.worktree.id) + + await WorktreeChangesPollLoop.run( + identity: fixture.identity, + worktree: fixture.worktree, + store: store, + currentSnapshot: { fixture.snapshot }, + isEligible: { true }, + load: { _ in + await recorder.recordLoad() + return fixture.result + }, + sleep: { duration in + await recorder.recordSleep(duration) + throw CancellationError() + } + ) + + #expect(await recorder.loadCount == 1) + #expect(await recorder.sleeps == [WorktreeChangesPollingPolicy.interval]) + #expect(store.entry(for: fixture.identity).hasSuccessfulValue) + } + + @MainActor + @Test("polling executes the complete load away from the main thread") + func pollingLoadsOffMainThread() async throws { + let fixture = try changesFixture() + let probe = LoaderThreadProbe() + let store = WorktreeChangesStore() + store.setExpanded(true, worktreeID: fixture.worktree.id) + + await WorktreeChangesPollLoop.run( + identity: fixture.identity, + worktree: fixture.worktree, + store: store, + currentSnapshot: { fixture.snapshot }, + isEligible: { true }, + load: { _ in + probe.record(pthread_main_np() != 0) + return fixture.result + }, + sleep: { _ in throw CancellationError() } + ) + + #expect(probe.ranOnMainThread == false) + } + + @MainActor + @Test("non-retryable failures stop automatic polling") + func nonRetryableFailureStopsPolling() async throws { + let fixture = try changesFixture() + let recorder = PollRecorder() + let store = WorktreeChangesStore() + store.setExpanded(true, worktreeID: fixture.worktree.id) + + await WorktreeChangesPollLoop.run( + identity: fixture.identity, + worktree: fixture.worktree, + store: store, + currentSnapshot: { fixture.snapshot }, + isEligible: { true }, + load: { _ in throw ClassifiedTestFailure(isRetryable: false) }, + sleep: { duration in + await recorder.recordSleep(duration) + throw CancellationError() + } + ) + + #expect(await recorder.sleeps.isEmpty) + #expect(store.entry(for: fixture.identity).errorMessage != nil) + } + + @MainActor + @Test("non-retryable failures wait for refresh across task restarts") + func nonRetryableFailureWaitsForManualRefresh() async throws { + let fixture = try changesFixture() + let loader = PermanentThenSuccessfulChangesLoader( + result: fixture.result + ) + let store = WorktreeChangesStore() + store.setExpanded(true, worktreeID: fixture.worktree.id) + let poll: () async -> Void = { + await WorktreeChangesPollLoop.run( + identity: fixture.identity, + worktree: fixture.worktree, + store: store, + currentSnapshot: { fixture.snapshot }, + isEligible: { true }, + load: { _ in try await loader.load() }, + sleep: { _ in throw CancellationError() } + ) + } + + await poll() + await poll() + + #expect(await loader.loadCount == 1) + #expect(!store.entry(for: fixture.identity).hasSuccessfulValue) + + store.requestManualRefresh(for: fixture.identity, refreshInventory: { + Issue.record("Ordinary refresh should not reload inventory") + }) + await poll() + + #expect(await loader.loadCount == 2) + #expect(store.entry(for: fixture.identity).hasSuccessfulValue) + } + + @MainActor + @Test("retryable failures back off before normal polling resumes") + func retryableFailureBacksOff() async throws { + let fixture = try changesFixture() + let loader = RecoveringChangesLoader(result: fixture.result) + let recorder = PollRecorder() + let store = WorktreeChangesStore() + store.setExpanded(true, worktreeID: fixture.worktree.id) + + await WorktreeChangesPollLoop.run( + identity: fixture.identity, + worktree: fixture.worktree, + store: store, + currentSnapshot: { fixture.snapshot }, + isEligible: { true }, + load: { _ in try await loader.load() }, + sleep: { duration in + await recorder.recordSleep(duration) + if await recorder.sleeps.count == 3 { + throw CancellationError() + } + } + ) + + let sleeps = await recorder.sleeps + #expect(await loader.loadCount == 3) + #expect(sleeps.count == 3) + #expect(sleeps[0] > WorktreeChangesPollingPolicy.interval) + #expect(sleeps[1] > sleeps[0]) + #expect(sleeps[2] == WorktreeChangesPollingPolicy.interval) + } + + @MainActor + @Test("a response is discarded when current identity changed") + func changedIdentityDiscardsResponse() async throws { + let fixture = try changesFixture() + var changedSnapshot = fixture.snapshot + changedSnapshot.projects[0].scopedKey = "github.com/kenn-io/replacement" + let recorder = PollRecorder() + let store = WorktreeChangesStore() + store.setExpanded(true, worktreeID: fixture.worktree.id) + + await WorktreeChangesPollLoop.run( + identity: fixture.identity, + worktree: fixture.worktree, + store: store, + currentSnapshot: { changedSnapshot }, + isEligible: { true }, + load: { _ in + await recorder.recordLoad() + return fixture.result + }, + sleep: { duration in + await recorder.recordSleep(duration) + } + ) + + #expect(await recorder.loadCount == 1) + #expect(await recorder.sleeps.isEmpty) + #expect(!store.entry(for: fixture.identity).hasSuccessfulValue) + } + + @MainActor + @Test("a response is discarded when project registration changed") + func changedRegistrationDiscardsResponse() async throws { + let fixture = try changesFixture() + var changedSnapshot = fixture.snapshot + changedSnapshot.projects[0].registrationFingerprint = + "replacement-registration" + let recorder = PollRecorder() + let store = WorktreeChangesStore() + store.setExpanded(true, worktreeID: fixture.worktree.id) + + await WorktreeChangesPollLoop.run( + identity: fixture.identity, + worktree: fixture.worktree, + store: store, + currentSnapshot: { changedSnapshot }, + isEligible: { true }, + load: { _ in + await recorder.recordLoad() + return fixture.result + }, + sleep: { duration in + await recorder.recordSleep(duration) + throw CancellationError() + } + ) + + #expect(await recorder.loadCount == 1) + #expect(await recorder.sleeps.isEmpty) + #expect(!store.entry(for: fixture.identity).hasSuccessfulValue) + } + + @MainActor + @Test("generation replacement waits for the in-flight read") + func generationReplacementDoesNotOverlap() async throws { + let first = try changesFixture() + var replacementWorktree = first.worktree + replacementWorktree.generation = + "fedcba9876543210fedcba9876543210" + var replacementSnapshot = first.snapshot + replacementSnapshot.worktrees = [replacementWorktree] + let replacementIdentity = try #require( + WorktreeChangesIdentity.resolve( + worktreeID: replacementWorktree.id, + in: replacementSnapshot + ) + ) + var currentSnapshot = first.snapshot + let loader = ControlledChangesLoader() + let store = WorktreeChangesStore() + store.setExpanded(true, worktreeID: first.worktree.id) + let read: WorktreeChangesLoader = { worktree in + try await loader.load(worktree) + } + let stopAfterDelay: WorktreeChangesSleep = { _ in + throw CancellationError() + } + + let firstPoll = Task { + await WorktreeChangesPollLoop.run( + identity: first.identity, + worktree: first.worktree, + store: store, + currentSnapshot: { currentSnapshot }, + isEligible: { true }, + load: read, + sleep: stopAfterDelay + ) + } + await loader.waitUntilFirstLoadStarts() + currentSnapshot = replacementSnapshot + store.prune(keeping: [replacementIdentity]) + let replacementPoll = Task { + await WorktreeChangesPollLoop.run( + identity: replacementIdentity, + worktree: replacementWorktree, + store: store, + currentSnapshot: { currentSnapshot }, + isEligible: { true }, + load: read, + sleep: stopAfterDelay + ) + } + await replacementPoll.value + + #expect(await loader.loadCount == 1) + #expect(await loader.maximumConcurrentLoads == 1) + await loader.releaseFirstLoad() + await firstPoll.value + #expect( + store.entry(for: replacementIdentity).resumeRevision == 1 + ) + + await WorktreeChangesPollLoop.run( + identity: replacementIdentity, + worktree: replacementWorktree, + store: store, + currentSnapshot: { currentSnapshot }, + isEligible: { true }, + load: read, + sleep: stopAfterDelay + ) + + #expect(await loader.loadCount == 2) + #expect(await loader.maximumConcurrentLoads == 1) + #expect( + store.entry(for: replacementIdentity).hasSuccessfulValue + ) + } + + private func changesFixture() throws -> ChangesFixture { + let hostID = UUID() + var project = ProjectSummary.fixture(hostID: hostID) + project.scopedKey = "github.com/kenn-io/ghosthub" + var worktree = WorktreeSummary.fixture( + hostID: hostID, + projectID: project.id, + path: "/repo/topic" + ) + worktree.generation = "0123456789abcdef0123456789abcdef" + let snapshot = WorkspaceSnapshot.fixture( + hosts: [.fixture(id: hostID)], + projects: [project], + worktrees: [worktree] + ) + let identity = try #require(WorktreeChangesIdentity.resolve( + worktreeID: worktree.id, + in: snapshot + )) + return ChangesFixture( + snapshot: snapshot, + worktree: worktree, + identity: identity, + result: WorktreeFileChanges( + repository: project.scopedKey, + path: worktree.path, + generation: worktree.generation!, + state: .clean, + summary: .clean, + files: [], + observedAt: "now" + ) + ) + } +} + +private struct ChangesFixture: Sendable { + let snapshot: WorkspaceSnapshot + let worktree: WorktreeSummary + let identity: WorktreeChangesIdentity + let result: WorktreeFileChanges +} + +private actor PollRecorder { + private(set) var loadCount = 0 + private(set) var sleeps: [Duration] = [] + + func recordLoad() { + loadCount += 1 + } + + func recordSleep(_ duration: Duration) { + sleeps.append(duration) + } +} + +private actor ControlledChangesLoader { + private(set) var loadCount = 0 + private(set) var maximumConcurrentLoads = 0 + private var concurrentLoads = 0 + private var firstLoadStarted = false + private var firstLoadRelease: CheckedContinuation? + + func load(_ worktree: WorktreeSummary) async throws + -> WorktreeFileChanges { + loadCount += 1 + concurrentLoads += 1 + maximumConcurrentLoads = max( + maximumConcurrentLoads, + concurrentLoads + ) + if loadCount == 1 { + firstLoadStarted = true + await withCheckedContinuation { continuation in + firstLoadRelease = continuation + } + } + concurrentLoads -= 1 + return WorktreeFileChanges( + repository: "github.com/kenn-io/ghosthub", + path: worktree.path, + generation: worktree.generation!, + state: .clean, + summary: .clean, + files: [], + observedAt: "now" + ) + } + + func waitUntilFirstLoadStarts() async { + while !firstLoadStarted { + await Task.yield() + } + } + + func releaseFirstLoad() { + firstLoadRelease?.resume() + firstLoadRelease = nil + } +} + +private actor RecoveringChangesLoader { + private(set) var loadCount = 0 + let result: WorktreeFileChanges + + init(result: WorktreeFileChanges) { + self.result = result + } + + func load() throws -> WorktreeFileChanges { + loadCount += 1 + if loadCount <= 2 { + throw ClassifiedTestFailure(isRetryable: true) + } + return result + } +} + +private actor PermanentThenSuccessfulChangesLoader { + private(set) var loadCount = 0 + let result: WorktreeFileChanges + + init(result: WorktreeFileChanges) { + self.result = result + } + + func load() throws -> WorktreeFileChanges { + loadCount += 1 + if loadCount == 1 { + throw ClassifiedTestFailure(isRetryable: false) + } + return result + } +} + +private final class LoaderThreadProbe: @unchecked Sendable { + private let lock = NSLock() + private var value: Bool? + + var ranOnMainThread: Bool? { lock.withLock { value } } + + func record(_ ranOnMainThread: Bool) { + lock.withLock { value = ranOnMainThread } + } +} + +private final class ChangeCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var value: Int { lock.withLock { count } } + + func increment() { + lock.withLock { count += 1 } + } +} + +private struct ClassifiedTestFailure: + Error, LocalizedError, WorktreeChangesRetryClassifying { + let isRetryable: Bool + var requiresInventoryRefresh: Bool { false } + var errorDescription: String? { "read failed" } +} + +private struct TestFailure: Error, LocalizedError { + var errorDescription: String? { "read failed" } +} diff --git a/Tests/UI/WorktreeChangesViewTests.swift b/Tests/UI/WorktreeChangesViewTests.swift new file mode 100644 index 00000000..4543a588 --- /dev/null +++ b/Tests/UI/WorktreeChangesViewTests.swift @@ -0,0 +1,170 @@ +import GhosthubWorkspace +import Testing +@testable import GhosthubUI + +@Suite("worktree changes presentation") +struct WorktreeChangesViewTests { + @Test( + "semantic states have readable compact labels", + arguments: WorktreeFileState.allCases + ) + func semanticLabels(_ state: WorktreeFileState) { + #expect(!WorktreeFileChangePresentation.label(for: state).isEmpty) + #expect(!WorktreeFileChangePresentation.symbol(for: state).isEmpty) + } + + @Test("accessibility describes staged and working tree states") + func accessibilityValue() { + let file = WorktreeFileChange( + path: "Sources/New.swift", + originalPath: "Sources/Old.swift", + index: .renamed, + worktree: .modified + ) + + #expect(WorktreeFileChangePresentation.accessibilityValue(for: file) + == + "Sources/New.swift, renamed from Sources/Old.swift, staged renamed, working tree modified") + } + + @Test("accessibility distinguishes copied files from renamed files") + func copiedAccessibilityValue() { + let file = WorktreeFileChange( + path: "Sources/Copy.swift", + originalPath: "Sources/Original.swift", + index: .copied, + worktree: nil + ) + + #expect(WorktreeFileChangePresentation.accessibilityValue(for: file) + == + "Sources/Copy.swift, copied from Sources/Original.swift, staged copied") + } + + @Test("retry is offered only when refresh has an effect") + func retryAvailability() { + var failed = WorktreeChangesEntry() + failed.errorMessage = "Unavailable" + + #expect(WorktreeChangesPresentation.showsRetry( + for: failed, + canRefresh: true + )) + #expect(!WorktreeChangesPresentation.showsRetry( + for: failed, + canRefresh: false + )) + } + + @Test("file states use the familiar two-column Git status") + func statusCode() { + #expect( + WorktreeFileChangePresentation.statusCode( + index: nil, + worktree: .untracked + ) == "??" + ) + #expect( + WorktreeFileChangePresentation.statusCode( + index: nil, + worktree: .modified + ) == " M" + ) + #expect( + WorktreeFileChangePresentation.statusCode( + index: .modified, + worktree: nil + ) == "M " + ) + #expect( + WorktreeFileChangePresentation.statusCode( + index: .modified, + worktree: .modified + ) == "MM" + ) + #expect( + WorktreeFileChangePresentation.statusCode( + index: .renamed, + worktree: .deleted + ) == "RD" + ) + } + + @Test("loading chrome appears only before the first successful value") + func loadingChrome() { + var initial = WorktreeChangesEntry() + initial.isLoading = true + + var refreshing = initial + refreshing.hasSuccessfulValue = true + + #expect(WorktreeChangesPresentation.showsLoadingChrome(for: initial)) + #expect(!WorktreeChangesPresentation.showsLoadingChrome( + for: refreshing + )) + } + + @Test("initial and manual loads share one activity indicator") + func activityIndicator() { + var initial = WorktreeChangesEntry() + initial.isLoading = true + + var manual = initial + manual.hasSuccessfulValue = true + + var timer = WorktreeChangesEntry() + timer.hasSuccessfulValue = true + + #expect(WorktreeChangesPresentation.showsActivityIndicator( + for: initial + )) + #expect(WorktreeChangesPresentation.showsActivityIndicator( + for: manual + )) + #expect(!WorktreeChangesPresentation.showsActivityIndicator( + for: timer + )) + } + + @Test("large change sets reveal files in bounded pages") + func boundedFilePages() { + let files = (0 ..< 205).map { index in + WorktreeFileChange( + path: "file-\(index)", + originalPath: nil, + index: nil, + worktree: .modified + ) + } + + let first = WorktreeChangesPresentation.page( + files: files, + requestedCount: 200 + ) + let second = WorktreeChangesPresentation.page( + files: files, + requestedCount: first.nextRequestedCount + ) + + #expect(first.files.count == 200) + #expect(first.remainingCount == 5) + #expect(second.files.count == 205) + #expect(second.remainingCount == 0) + } + + @Test("refreshes preserve the requested page depth") + func requestedPageDepthSurvivesRefresh() { + #expect(WorktreeChangesPresentation.adjustedRequestedCount( + 600, + forFileCount: 800 + ) == 600) + #expect(WorktreeChangesPresentation.adjustedRequestedCount( + 600, + forFileCount: 350 + ) == 350) + #expect(WorktreeChangesPresentation.adjustedRequestedCount( + 200, + forFileCount: 50 + ) == 200) + } +} diff --git a/Tests/Workspace/WorktreeFileChangesTests.swift b/Tests/Workspace/WorktreeFileChangesTests.swift new file mode 100644 index 00000000..c47168f6 --- /dev/null +++ b/Tests/Workspace/WorktreeFileChangesTests.swift @@ -0,0 +1,70 @@ +import Foundation +import GhosthubWorkspace +import Testing + +@Suite("worktree file changes") +struct WorktreeFileChangesTests { + @Test( + "semantic states preserve the kwt wire contract", + arguments: [ + "modified", "added", "deleted", "renamed", "copied", + "conflicted", "untracked", + ] + ) + func semanticStates(rawValue: String) throws { + let value = try JSONDecoder().decode( + WorktreeFileState.self, + from: Data("\"\(rawValue)\"".utf8) + ) + + #expect(value.rawValue == rawValue) + } + + @Test("unknown semantic states fail closed") + func unknownSemanticState() { + #expect(throws: DecodingError.self) { + try JSONDecoder().decode( + WorktreeFileState.self, + from: Data(#""future-state""#.utf8) + ) + } + } + + @Test("file paths and rename origins round-trip without normalization") + func unusualPaths() throws { + let value = WorktreeFileChange( + path: "Sources/space tab\tnewline\n雪.swift", + originalPath: "Sources/old name.swift", + index: .renamed, + worktree: .modified + ) + + let data = try JSONEncoder().encode(value) + #expect(try JSONDecoder().decode( + WorktreeFileChange.self, + from: data + ) == value) + } + + @Test("changed files sort by resulting path then rename origin") + func presentationOrder() { + let files = [ + WorktreeFileChange( + path: "z.swift", + originalPath: "a.swift", + index: .renamed, + worktree: nil + ), + WorktreeFileChange( + path: "a.swift", + originalPath: nil, + index: nil, + worktree: .modified + ), + ] + + #expect(files.sortedForPresentation().map(\.path) == [ + "a.swift", "z.swift", + ]) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index ef825062..ab3e82e4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -290,12 +290,15 @@ present, and reads KWT's machine-readable no-fetch Git status for the exact path. Uncommitted changes turn the action into an explicit force confirmation. Ghosthub reads the status again before terminating the session, so changes that appeared after an ordinary confirmation require a new force -confirmation. It then terminates only that freshly confirmed tmux identity and -delegates an absence-guarded removal to pinned KWT. KWT revalidates the -project, generation, and socket under its lifecycle lock and refuses removal -if the workspace session reappears before deleting the checkout. If ordinary -removal discovers still newer uncommitted changes after Ghosthub terminates the -session, Ghosthub restores the session and requires force confirmation. +confirmation. If the bounded inspection cannot enumerate an unusually large +change set, Ghosthub conservatively requires force confirmation instead of +blocking removal or claiming that the checkout is clean. It then terminates +only that freshly confirmed tmux identity and delegates an absence-guarded +removal to pinned KWT. KWT revalidates the project, generation, and socket +under its lifecycle lock and refuses removal if the workspace session +reappears before deleting the checkout. If ordinary removal discovers still +newer uncommitted changes after Ghosthub terminates the session, Ghosthub +restores the session and requires force confirmation. Ghosthub still has one UI application process and no Ghosthub-owned daemon. For the Windows MVP, tmux inside WSL2 is the long-lived session owner. Closing @@ -568,6 +571,32 @@ identity, worktree metadata, and exact tmux session names. Read failures that kwt marks retryable use cancellation-aware 1-, 4-, and 15-second backoff before Ghosthub publishes a warning. Non-retryable failures remain single-attempt, and each retry repeats only the failed idempotent read. +Each worktree row can also expand an ephemeral, read-only changed-file panel. +The panel invokes the exact pinned kwt helper with the current repository, +absolute path, and durable worktree generation as guards. Kwt remains the +authority for Git status parsing and returns semantic staged and working-tree +states; Ghosthub does not run Git, calculate diffs, or persist the result. +Only mounted panels in a visible sidebar of the active key window poll, with +one non-overlapping request per panel and a five-second delay after each +completed successful read. A shared ephemeral broker coalesces identical +reads and permits at most four inspections across the app and two per host. +When the final caller leaves, cancellation reaches the helper process group; +a replacement waits for that cleanup before starting. Retryable failures use +bounded exponential backoff with identity-stable jitter, while non-retryable +failures wait for explicit Refresh. A later read failure retains the last +successful rows as stale. +Registration-change errors stop polling even when Kwt marks them retryable: +the captured identity is no longer valid. In that state the panel's Refresh +action reloads workspace inventory, and a newly resolved identity restarts +inspection. +Both the requested identity and the response are checked against current +inventory before publication, so a moved worktree, changed registration, or +reconfigured host cannot publish results into an obsolete row. +Kwt's raw Git-status limit remains authoritative; Ghosthub gives the expanded +JSON response separate bounded transport headroom and reports transport +overflow as a non-retryable inspection error. The panel presents files in +pages of 200, suppresses unchanged refresh publications, and retains at most +eight collapsed snapshots per scene. On a macOS or Linux host with no existing kwt registry, the user adds one absolute repository path at a time through **Add Project**. Ghosthub delegates registration to `kwt projects add --json`, then refreshes ordinary kwt @@ -1086,8 +1115,8 @@ Kwt's project and worktree JSON surfaces are authoritative for workspace identity and exact tmux session names. Direct tmux discovery is authoritative for the remaining live sessions on each host and for the eventual result of an explicit named-session creation request. A worktree open does not infer live -session state from kwt inventory: it uses kwt's exact-path start-only command -to converge the session before attachment. +session state from kwt inventory: it uses +kwt's exact-path start-only command to converge the session before attachment. On the Rust Windows path, the revision-pinned helper receives the selected repository identity, registration fingerprint, exact path, generation, and computed session name. KWT revalidates them atomically under its project diff --git a/docs/threat-model.md b/docs/threat-model.md index a8d030dc..a847909a 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -244,6 +244,15 @@ signed release input. Their output must still be validated for compatibility and correctness, but deliberate compromise of those programs or their on-disk state is outside the security model. +Expanded worktree change panels read only semantic file-status records through +Ghosthub's exact pinned kwt helper. The worktree path, repository identity, and +durable generation are supplied as guards and revalidated against current +inventory after the read. Returned file paths and rename origins are displayed +as untrusted text; they are never executed, opened, used as navigation targets, +or passed to a mutation. Ghosthub does not request diff content, fetch from the +network, or store these records. Polling stops when the panel is unmounted, the +sidebar is hidden, the app is inactive, or the owning window loses key status. + Pull-request import is an explicit user mutation delegated to the bundled local kwt or Ghosthub's exact managed kwt revision on the selected remote host. Configuring a remote macOS or Linux host authorizes Ghosthub to install and diff --git a/tools/tests/test_demo_scripts.py b/tools/tests/test_demo_scripts.py index b5711087..6221b8f3 100644 --- a/tools/tests/test_demo_scripts.py +++ b/tools/tests/test_demo_scripts.py @@ -32,6 +32,7 @@ "guide-exe-dev.png", "guide-worktree.png", "guide-worktree-window-counts.png", + "guide-worktree-changes.png", "guide-project-removal.png", "guide-quick-launch.png", "guide-terminal.png", @@ -62,6 +63,65 @@ def run_bash(script: str, *, env: dict[str, str] | None = None) -> subprocess.Co ) +def test_demo_kwt_reports_generation_fenced_worktree_changes(tmp_path: Path) -> None: + scratch = tmp_path / "demo" + repository = scratch / "repos" / "ghosthub" + repository.mkdir(parents=True) + worktree = scratch / "worktrees" / "ghosthub" / "fix-reconnect-backoff" + worktree.mkdir(parents=True) + env = {**os.environ, "GHOSTHUB_DEMO_SCRATCH": str(scratch)} + helper = DEMO / "bin" / "kwt" + + inventory = subprocess.run( + [str(helper), "list"], + cwd=repository, + env=env, + text=True, + capture_output=True, + check=True, + ) + records = json.loads(inventory.stdout) + record = next(item for item in records if item["path"] == str(worktree)) + assert record["tmux_attach_mode"] == "direct" + + result = subprocess.run( + [ + str(helper), + "changes", + str(worktree), + "--expected-repository", + record["repository"], + "--expected-generation", + record["generation"], + "--json", + ], + env=env, + text=True, + capture_output=True, + check=True, + ) + changes = json.loads(result.stdout) + + assert changes["worktree"] == { + "repository": record["repository"], + "path": str(worktree), + "generation": record["generation"], + } + assert changes["changes"]["summary"] == { + "modified": 1, + "added": 1, + "deleted": 0, + "untracked": 1, + "staged": 1, + "conflicts": 0, + } + assert [item["path"] for item in changes["changes"]["files"]] == [ + "README.md", + "Tests/ReconnectBackoffTests.swift", + "reconnect-notes.md", + ] + + @pytest.mark.skipif(sys.platform != "darwin", reason="demo scripts target macOS stat") def test_scratch_guard_rejects_missing_path_under_unsafe_ancestor(tmp_path: Path) -> None: unsafe = tmp_path / "unsafe" @@ -1359,7 +1419,7 @@ def test_fetched_asset_ref_is_authoritative_and_atomic(tmp_path: Path) -> None: "case \"$1\" in\n" " fetch) exit 0 ;;\n" " cat-file)\n" - " [[ \"$3\" == \"FETCH_HEAD:guide-worktree.png\" ]] && exit 1\n" + " [[ \"$3\" == \"FETCH_HEAD:guide-worktree-changes.png\" ]] && exit 1\n" " exit 0\n" " ;;\n" " show) printf 'fetched-%s' \"${2#*:}\"; exit 0 ;;\n" diff --git a/website/demo/assets/demohost.m b/website/demo/assets/demohost.m index 58b5b6cb..11efb288 100644 --- a/website/demo/assets/demohost.m +++ b/website/demo/assets/demohost.m @@ -9,6 +9,7 @@ #import #import #import +#import #import static NSString *const DemoCaptureNotification = @@ -466,6 +467,106 @@ static CGImageRef DemoCreateOwnedWindowComposite( return composite; } +static BOOL DemoImageHasVisibleColor(CGImageRef image) { + const size_t width = 64; + const size_t height = 64; + const size_t bytesPerRow = width * 4; + unsigned char *pixels = calloc(height, bytesPerRow); + if (pixels == NULL) return NO; + CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); + CGContextRef context = CGBitmapContextCreate( + pixels, width, height, 8, bytesPerRow, colorSpace, + (CGBitmapInfo)kCGImageAlphaPremultipliedLast); + CGColorSpaceRelease(colorSpace); + if (context == NULL) { + free(pixels); + return NO; + } + CGContextDrawImage(context, CGRectMake(0, 0, width, height), image); + CGContextRelease(context); + + BOOL visible = NO; + for (size_t offset = 0; offset < height * bytesPerRow; offset += 4) { + if (pixels[offset] != 0 || pixels[offset + 1] != 0 || + pixels[offset + 2] != 0) { + visible = YES; + break; + } + } + free(pixels); + return visible; +} + +static CGImageRef DemoCreateViewSnapshot(NSWindow *window) { + NSView *frameView = window.contentView.superview; + if (frameView == nil || NSIsEmptyRect(frameView.bounds)) return NULL; + [frameView layoutSubtreeIfNeeded]; + NSBitmapImageRep *bitmap = + [frameView bitmapImageRepForCachingDisplayInRect:frameView.bounds]; + if (bitmap == nil) return NULL; + [frameView cacheDisplayInRect:frameView.bounds toBitmapImageRep:bitmap]; + return bitmap.CGImage == NULL ? NULL : CGImageRetain(bitmap.CGImage); +} + +static void DemoAppendRelatedWindows( + NSWindow *window, NSMutableArray *windows) { + if (window == nil || [windows containsObject:window]) return; + [windows addObject:window]; + for (NSWindow *child in window.childWindows) { + DemoAppendRelatedWindows(child, windows); + } + DemoAppendRelatedWindows(window.attachedSheet, windows); +} + +static CGImageRef DemoCreateViewSnapshotComposite(NSWindow *root) { + CGImageRef rootImage = DemoCreateViewSnapshot(root); + if (rootImage == NULL) return NULL; + + NSRect rootFrame = root.frame; + CGFloat scaleX = (CGFloat)CGImageGetWidth(rootImage) / NSWidth(rootFrame); + CGFloat scaleY = (CGFloat)CGImageGetHeight(rootImage) / NSHeight(rootFrame); + size_t width = CGImageGetWidth(rootImage); + size_t height = CGImageGetHeight(rootImage); + CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); + CGContextRef context = CGBitmapContextCreate( + NULL, width, height, 8, width * 4, colorSpace, + (CGBitmapInfo)kCGImageAlphaPremultipliedLast); + CGColorSpaceRelease(colorSpace); + if (context == NULL) { + CGImageRelease(rootImage); + return NULL; + } + + BOOL complete = YES; + NSMutableArray *windows = [NSMutableArray array]; + DemoAppendRelatedWindows(root, windows); + for (NSWindow *candidate in windows) { + if (!candidate.isVisible) continue; + CGImageRef snapshot = candidate == root + ? CGImageRetain(rootImage) + : DemoCreateViewSnapshot(candidate); + if (snapshot == NULL || !DemoImageHasVisibleColor(snapshot)) { + if (snapshot != NULL) CGImageRelease(snapshot); + complete = NO; + break; + } + NSRect frame = candidate.frame; + CGRect destination = CGRectMake( + (NSMinX(frame) - NSMinX(rootFrame)) * scaleX, + (NSMinY(frame) - NSMinY(rootFrame)) * scaleY, + NSWidth(frame) * scaleX, NSHeight(frame) * scaleY); + CGContextDrawImage(context, destination, snapshot); + CGImageRelease(snapshot); + } + CGImageRelease(rootImage); + + CGImageRef composite = complete + ? CGBitmapContextCreateImage(context) + : NULL; + CGContextRelease(context); + return composite; +} + static BOOL DemoCaptureWindow(NSWindow *window, NSString *path, BOOL exactWindow) { if (window == nil) return NO; @@ -496,6 +597,16 @@ static BOOL DemoCaptureWindow(NSWindow *window, NSString *path, CFRelease(windowIDs); } if (image == NULL) return NO; + if (!DemoImageHasVisibleColor(image)) { + CGImageRelease(image); + image = exactWindow + ? DemoCreateViewSnapshot(window) + : DemoCreateViewSnapshotComposite(window); + } + if (image == NULL || !DemoImageHasVisibleColor(image)) { + if (image != NULL) CGImageRelease(image); + return NO; + } BOOL wrote = NO; NSString *temporary = [path stringByAppendingString:@".tmp"]; diff --git a/website/demo/bin/kwt b/website/demo/bin/kwt index e2d60290..2572cd64 100755 --- a/website/demo/bin/kwt +++ b/website/demo/bin/kwt @@ -31,9 +31,9 @@ worktree() { fi cat <&2 + exit 64 + fi + local path="$2" expected_repository="$4" expected_generation="$6" + local repository generation + case "$path" in + "$scratch/worktrees/ghosthub/fix-reconnect-backoff") + repository="github.com/kenn-io/ghosthub" + generation="8f27ac41d5b9e6038c2d17f4ab60e95c" + ;; + *) + echo "faux kwt: unknown demo worktree: $path" >&2 + exit 64 + ;; + esac + if [[ "$expected_repository" != "$repository" \ + || "$expected_generation" != "$generation" ]]; then + echo "faux kwt: demo worktree identity changed" >&2 + exit 1 + fi + cat <}" >&2 diff --git a/website/demo/shoot.sh b/website/demo/shoot.sh index 89da1cf3..776963a3 100755 --- a/website/demo/shoot.sh +++ b/website/demo/shoot.sh @@ -282,6 +282,31 @@ capture_worktree_window_counts() { sleep 0.5 } +capture_worktree_changes() { + # The changed-file poll intentionally pauses while its window is inactive. + # Reassert the fixed frame here so this focused capture starts with a key + # workspace window even when the invoking terminal regained focus. + demo_input frame + demo_input click "32,489" + sleep 0.5 + demo_input click "32,450" + sleep 0.5 + demo_input click "32,412" + sleep 1 + demo_input click "55,340" + sleep 3 + capture_state guide-worktree-changes.png + # Restore the initial disclosure state for the remaining guide captures. + demo_input click "55,340" + sleep 0.5 + demo_input click "32,412" + sleep 0.5 + demo_input click "32,450" + sleep 0.5 + demo_input click "32,489" + sleep 0.5 +} + capture_window_title() { demo_input rename-window sleep 1 @@ -322,6 +347,12 @@ if [[ "${GHOSTHUB_DEMO_WORKTREE_COUNTS_ONLY:-}" == "1" ]]; then exit 0 fi +if [[ "${GHOSTHUB_DEMO_WORKTREE_CHANGES_ONLY:-}" == "1" ]]; then + echo "==> guide: worktree changes" + capture_worktree_changes + exit 0 +fi + if [[ "${GHOSTHUB_DEMO_PROJECT_REMOVAL_ONLY:-}" == "1" ]]; then echo "==> guide: project removal" capture_project_removal @@ -359,6 +390,9 @@ demo_input escape echo "==> guide: worktree window counts" capture_worktree_window_counts +echo "==> guide: worktree changes" +capture_worktree_changes + echo "==> guide: project removal" capture_project_removal diff --git a/website/demo/stage.sh b/website/demo/stage.sh index 51535db5..c652aafc 100755 --- a/website/demo/stage.sh +++ b/website/demo/stage.sh @@ -155,6 +155,13 @@ make_worktree ghosthub pr-142-fleet-sidebar make_worktree agentsview add-session-filters make_worktree msgvault pr-87-imap-sync +changes_worktree="$scratch/worktrees/ghosthub/fix-reconnect-backoff" +printf '\nDocument the reconnect fallback.\n' >> "$changes_worktree/README.md" +mkdir -p "$changes_worktree/Tests" +printf '// Reconnect backoff coverage\n' > "$changes_worktree/Tests/ReconnectBackoffTests.swift" +"${git_c[@]}" -C "$changes_worktree" add Tests/ReconnectBackoffTests.swift +printf '# Reconnect notes\n' > "$changes_worktree/reconnect-notes.md" + echo "==> staging local tmux sessions (socket dir: $TMUX_TMPDIR)" # Pane shells use an explicit zsh with both HOME and ZDOTDIR isolated so the diff --git a/website/docs/content/projects-worktrees.md b/website/docs/content/projects-worktrees.md index 4d25e275..45ae4e09 100644 --- a/website/docs/content/projects-worktrees.md +++ b/website/docs/content/projects-worktrees.md @@ -99,6 +99,31 @@ of being duplicated under **Tmux Sessions**. Open **Settings → Worktrees** and turn off **Hide kwt-managed sessions from Tmux Sessions** if you want both entries visible. +## Inspect changed files + +Select the disclosure chevron beside a worktree. The panel expands beneath that +worktree without selecting it or attaching its tmux session. More than one +worktree can remain expanded. + +![Ghosthub showing staged, modified, and untracked files beneath a worktree](assets/guide-worktree-changes.png) + +Each file shows separate staged and working-tree states when both apply, and a +renamed file includes its original path. An empty panel says **No changed +files**. Large results show the first 200 files; select **Show more** to reveal +the next page. Expanded panels refresh about five seconds after each successful +read. Temporary failures retry less frequently when they continue, while a +permanent failure waits for **Refresh**. Use **Refresh** for an immediate read +or the disclosure chevron to hide the panel. If a later read fails, Ghosthub +keeps the last successful rows visible and marks them stale. + +If Kwt reports that the worktree registration changed, automatic retries stop. +Select the panel's **Refresh** button to refresh workspace inventory. The panel +resumes inspection when inventory supplies the current worktree identity. + +This view is deliberately read-only. Kwt supplies the semantic file status; +Ghosthub does not calculate or display diffs and does not offer per-file Git +actions. + ## Create a worktree from a branch 1. Select the project. @@ -136,8 +161,9 @@ resulting worktree session. ## Remove a worktree -Hover over a non-primary worktree in the sidebar and choose **×**. After you -confirm the exact worktree and host, Ghosthub: +Hover over a non-primary worktree and select the **×**, or Control-click its row +and choose **Remove Worktree…**. +After you confirm the exact worktree and host, Ghosthub: 1. ends that worktree's verified live tmux session when necessary; and 2. asks kwt to remove the checkout. @@ -148,6 +174,10 @@ covered by the confirmation. If the worktree or session changes while the confirmation is open, Ghosthub stops and presents the current removal details for fresh confirmation instead of continuing automatically. +If an unusually large change set exceeds the bounded inspection, Ghosthub +cannot enumerate every changed file and requires an explicit force +confirmation instead of treating the checkout as clean or blocking removal. + After removal, the owning project remains selected. Ghosthub does not select another worktree or open its tmux session on your behalf. diff --git a/website/docs/content/sessions.md b/website/docs/content/sessions.md index 94d53d17..5699fca0 100644 --- a/website/docs/content/sessions.md +++ b/website/docs/content/sessions.md @@ -183,8 +183,8 @@ open or reopen it. ## End a session deliberately To end a standalone session that Ghosthub knows is running, hover over its -sidebar row and choose the subtle **×** control. For a kwt-backed session, use -**Kill Session…** in its workspace action menu. +sidebar row and choose the subtle **×** control. For a kwt-backed session, +Control-click its worktree row and choose **Kill Session…**. Ghosthub confirms the host and exact tmux session before it sends `kill-session`. Ending a session terminates all of its windows, panes, and diff --git a/website/scripts/sync-assets.sh b/website/scripts/sync-assets.sh index d249d31b..4ae29b07 100755 --- a/website/scripts/sync-assets.sh +++ b/website/scripts/sync-assets.sh @@ -25,6 +25,7 @@ assets=( guide-exe-dev.png guide-worktree.png guide-worktree-window-counts.png + guide-worktree-changes.png guide-project-removal.png guide-quick-launch.png guide-terminal.png