diff --git a/Sources/Nirux/Model/WorkspaceState.swift b/Sources/Nirux/Model/WorkspaceState.swift index ebb87f3..da2dc87 100644 --- a/Sources/Nirux/Model/WorkspaceState.swift +++ b/Sources/Nirux/Model/WorkspaceState.swift @@ -76,6 +76,9 @@ final class WorkspaceState { var prInfo: PRInfo? { didSet { if prInfo?.url != oldValue?.url || prInfo?.state != "OPEN" { prFeedback = nil } } } + /// The last pull request found on this branch: the card keeps its + /// title and author while a commit refetches `prInfo`. + private(set) var lastPullRequest: PRInfo? /// The open PR's feedback (docs/pr-feedback-inbox.md), read after each /// PR refresh. Cleared when the PR changes or stops being open. var prFeedback: PRFeedback? @@ -230,6 +233,7 @@ extension WorkspaceState { if !sameRevision || (context?.identity.isDirty == true && Self.isTerminalPullRequest(prInfo)) { prInfo = nil } + if previousContext?.branch != context?.branch { lastPullRequest = nil } diffStats = nil if !titleIsManual, let branch = context?.branch, !branch.isEmpty { title = branch @@ -259,6 +263,7 @@ extension WorkspaceState { prInfo != info else { return false } prInfo = info + if let info { lastPullRequest = info } return true } diff --git a/Sources/Nirux/Util/PRDetect.swift b/Sources/Nirux/Util/PRDetect.swift index fd7786c..47bb303 100644 --- a/Sources/Nirux/Util/PRDetect.swift +++ b/Sources/Nirux/Util/PRDetect.swift @@ -1,4 +1,5 @@ import Foundation +import os enum PRDetect { private struct GitHubCLIContext { @@ -51,11 +52,39 @@ enum PRDetect { context.branch == branch else { return .failure } - return fetch( + let result = fetch( branch: branch, ghPath: ghPath, context: context ) + guard case .success(let context, var info?) = result, + let host = context.upstreamRepository?.host + else { return result } + if let author = info.otherAuthor { + // The gh user unknown: no mark, rather than one on their own branches. + let viewer = viewerLogin(ghPath: ghPath, host: host) + if viewer == nil || viewer == author { info.otherAuthor = nil } + } + return .success(context: context, info: info) + } + + private static let viewerLogins = OSAllocatedUnfairLock(initialState: [String: (login: String?, at: Date)]()) + + /// The gh user on `host`, asked once per launch; nil while gh can't + /// say, asked again ten minutes later rather than at every refresh. + private static func viewerLogin(ghPath: String, host: String) -> String? { + if let known = viewerLogins.withLock({ $0[host] }), + known.login != nil || Date().timeIntervalSince(known.at) < 600 { + return known.login + } + let result = GitHubCLIBoardClient.runGH( + ghPath, arguments: ["api", "user", "--hostname", host, "--jq", ".login"], timeout: 10 + ) + let login = result.flatMap { $0.terminationStatus == 0 ? String(data: $0.standardOutput, encoding: .utf8) : nil }? + .trimmingCharacters(in: .whitespacesAndNewlines) + let known = (login: login?.isEmpty == false ? login : nil, at: Date()) + viewerLogins.withLock { $0[host] = known } + return known.login } static func fetch( @@ -131,7 +160,7 @@ enum PRDetect { "number", "state", "headRefOid", "headRepositoryOwner", "headRepository", "isDraft", "statusCheckRollup", "reviewDecision", "mergeable", "url", "additions", - "deletions", "changedFiles" + "deletions", "changedFiles", "title", "author" ].joined(separator: ",") let result = BoundedProcess.run( executableURL: URL(fileURLWithPath: cliContext.executablePath), @@ -196,10 +225,18 @@ enum PRDetect { url: candidate["url"] as? String ?? "", additions: candidate["additions"] as? Int, deletions: candidate["deletions"] as? Int, - changedFiles: candidate["changedFiles"] as? Int + changedFiles: candidate["changedFiles"] as? Int, + title: candidate["title"] as? String, + otherAuthor: author(of: candidate) ) } + /// A bot is no teammate: no mark. + private static func author(of candidate: [String: Any]) -> String? { + guard let author = candidate["author"] as? [String: Any], author["is_bot"] as? Bool != true else { return nil } + return author["login"] as? String + } + private static func repository(for candidate: [String: Any]) -> GitHubRepository? { guard let owner = candidate["headRepositoryOwner"] as? [String: Any], let login = owner["login"] as? String, @@ -242,7 +279,7 @@ enum PRDetect { return .failure } guard let output = gitOutput( - arguments: noIndexRefresh + ["diff", "--shortstat"], + arguments: noIndexRefresh + ["diff", "--shortstat"] + excludingHandovers, cwd: cwd, gitPath: gitPath ) else { return .failure } @@ -253,7 +290,7 @@ enum PRDetect { private static func diffPaths(cwd: String) -> [String] { // User-initiated: unlike the background shortstat, this may refresh // the index, or it would list touched-but-unchanged files. - guard let output = gitOutput(arguments: ["diff", "--name-only"], cwd: cwd) else { return [] } + guard let output = gitOutput(arguments: ["diff", "--name-only"] + excludingHandovers, cwd: cwd) else { return [] } return output .split(separator: "\n") .map(String.init) @@ -261,6 +298,10 @@ enum PRDetect { .sorted { $0.localizedStandardCompare($1) == .orderedAscending } } + /// A mission's handover is the agent's note, not the work: a repo that + /// tracks one would show it changed in every new worktree. + private static let excludingHandovers = ["--"] + BranchReview.Handover.names.map { ":(top,exclude)\($0)" } + /// `git diff` refreshes and rewrites `.git/index` even under /// GIT_OPTIONAL_LOCKS=0. `--shortstat` still leaves stat-only changes /// out without the refresh (`--name-only` would not). diff --git a/Sources/Nirux/Views/NiruxShellView+SidebarGit.swift b/Sources/Nirux/Views/NiruxShellView+SidebarGit.swift index a73fe5a..bf9fc18 100644 --- a/Sources/Nirux/Views/NiruxShellView+SidebarGit.swift +++ b/Sources/Nirux/Views/NiruxShellView+SidebarGit.swift @@ -60,7 +60,9 @@ extension NiruxShellView { blocker: workspace.blocker, phase: workspace.effectivePhase, lastSummary: workspace.lastSummary, lastActivityAt: workspace.lastActivityAt, reviewBadges: workspace.reviewBadges, - mergedCleanup: mergedCleanupOffer(workspaceIndex: index)) + mergedCleanup: mergedCleanupOffer(workspaceIndex: index), + branchLabel: sidebarBranchLabel(of: workspace, among: visibleIndices), + lastPullRequest: workspace.lastPullRequest) } tickHiddenSpaceAgents(visibleIndices: visibleIndices, foregroundProcesses: foregroundProcesses) updateColumnHeaders(infos: infos, foregroundProcesses: foregroundProcesses, now: now) @@ -88,6 +90,17 @@ extension NiruxShellView { refreshProjectBoards(snapshot: snapshot, now: now, foregroundProcesses: foregroundProcesses) } + /// Line 2's branch, without the prefix it shares with the listed + /// worktrees of its repo (the inactive ones are folded away). + private func sidebarBranchLabel(of workspace: WorkspaceState, among visibleIndices: [Int]) -> String? { + guard let branch = workspace.gitBranch else { return nil } + guard let repository = workspace.gitContext?.upstreamRepository else { return branch } + let siblings = visibleIndices.map { workspaces[$0] } + .filter { !$0.isInactive && $0.gitContext?.upstreamRepository == repository } + .compactMap(\.gitBranch) + return SidebarCardLayout.distinctiveBranch(branch, siblings: siblings) + } + /// Every column's foreground process, shown or not, and what follows /// from it before statuses are read: session bindings of a replaced /// agent, stuck agents (the one in another space is the one nobody diff --git a/Sources/Nirux/Views/SidebarCardLayout.swift b/Sources/Nirux/Views/SidebarCardLayout.swift index a641623..55b3fe6 100644 --- a/Sources/Nirux/Views/SidebarCardLayout.swift +++ b/Sources/Nirux/Views/SidebarCardLayout.swift @@ -208,6 +208,16 @@ struct SidebarCardLayout { var contentMaxX: CGFloat { cardX + cardWidth - Metrics.cardPaddingX } var indentX: CGFloat { contentX + Metrics.cardIndent } + /// The branch without the longest prefix it shares with a sibling's, + /// cut after that prefix's last `/` or `-`: worktrees of one repo tell + /// apart by their ends (`tomiir/payouts-ledger` → `ledger`). + static func distinctiveBranch(_ branch: String, siblings: [String]) -> String { + let shared = siblings.filter { $0 != branch }.map { branch.commonPrefix(with: $0).count }.max() ?? 0 + guard let separator = branch.prefix(shared).lastIndex(where: { $0 == "/" || $0 == "-" }) else { return branch } + let rest = branch[branch.index(after: separator)...] + return rest.isEmpty ? branch : String(rest) + } + /// Between the feedback and the pull request, and the pull request and /// its checks. static func linkGaps(_ links: [SidebarCardLink]) -> CGFloat { diff --git a/Sources/Nirux/Views/SidebarModels.swift b/Sources/Nirux/Views/SidebarModels.swift index 3f13329..4682b9e 100644 --- a/Sources/Nirux/Views/SidebarModels.swift +++ b/Sources/Nirux/Views/SidebarModels.swift @@ -204,6 +204,11 @@ struct PRInfo: Hashable, Sendable { let additions: Int? let deletions: Int? let changedFiles: Int? + var title: String? + /// Who opened it, when that isn't the gh user: the card marks a + /// teammate's branch. `PRDetect` clears it for the user's own, and when + /// it can't tell who the user is. + var otherAuthor: String? var failedCheckUrl: String? { checks.lazy.filter { $0.result == .failure }.compactMap(\.url).first @@ -238,6 +243,20 @@ struct WorkspaceInfo: Hashable { var reviewBadges: ReviewBadges? = nil /// What the card shows after "#N merged"; nil shows nothing. var mergedCleanup: MergedCleanupOffer? + /// Line 2's branch, without what visible worktrees of its repo share + /// (`SidebarCardLayout.distinctiveBranch`); nil prints `gitBranch`. + var branchLabel: String? + /// `WorkspaceState.lastPullRequest`: names the card, marks a teammate's. + var lastPullRequest: PRInfo? + + /// Line 1: the PR's title while the workspace is titled with its + /// branch (a name set by hand differs and stays), without the + /// `type(scope): ` every PR of a repo shares. + var cardTitle: String { + guard title == gitBranch, let pullRequestTitle = lastPullRequest?.title else { return title } + let name = pullRequestTitle.replacing(/^\w+(\([^)]*\))?!?:\s+/, with: "") + return name.isEmpty ? pullRequestTitle : name + } var sidebarAction: (text: String, isBlocker: Bool)? { if let blocker = normalizedContextText(blocker) { diff --git a/Sources/Nirux/Views/SidebarWorkspaceCardRenderer.swift b/Sources/Nirux/Views/SidebarWorkspaceCardRenderer.swift index b136af3..5497a12 100644 --- a/Sources/Nirux/Views/SidebarWorkspaceCardRenderer.swift +++ b/Sources/Nirux/Views/SidebarWorkspaceCardRenderer.swift @@ -114,7 +114,7 @@ final class SidebarWorkspaceCardRenderer { let titleX = layout.contentX + Metrics.stateDotSize + Theme.Space.sm let isQuiet = !workspace.isActive && [.idle, .done].contains(layout.state) let title = textLabel( - workspace.title, font: Theme.Font.title, + workspace.cardTitle, font: Theme.Font.title, color: isQuiet ? Theme.Color.textSecondary : Theme.Color.textPrimary ) title.toolTip = titleToolTip() @@ -141,7 +141,7 @@ final class SidebarWorkspaceCardRenderer { /// The title's tooltip: what the card doesn't print — the phase, the /// purpose, the last summary, another card's next step. private func titleToolTip() -> String { - var lines = [workspace.title, "Phase: \(workspace.phase.displayName)"] + var lines = [workspace.cardTitle, "Phase: \(workspace.phase.displayName)"] if let purpose = workspace.purpose { lines.append("Purpose: \(purpose)") } if let summary = workspace.lastSummary { lines.append("Last: \(summary)") } // The selected card prints the next step, every card its blocker. @@ -213,9 +213,26 @@ final class SidebarWorkspaceCardRenderer { maxX = diff.frame.minX - Theme.Space.sm } if let branchName = workspace.gitBranch { - let branch = textLabel(branchName, font: Theme.Font.mono, color: Theme.Color.textSecondary) + var x = layout.indentX + // A teammate's branch: their initial. Nothing on the user's own. + if let author = workspace.lastPullRequest?.otherAuthor, let initial = author.first { + let badge = SidebarBadgeView( + text: initial.uppercased(), textColor: Theme.Color.textSecondary, + fillColor: Theme.Color.fillPressed, font: Theme.Font.label + ) + badge.cornerRadius = Theme.Radius.chip + badge.frame = NSRect(x: x, y: rowY, width: Metrics.branchRowHeight, height: Metrics.branchRowHeight) + badge.toolTip = "Opened by \(author)" + badge.setAccessibilityRole(.staticText) + badge.setAccessibilityLabel("Opened by \(author)") + append(badge) + x = badge.frame.maxX + Theme.Space.xs + } + let branch = textLabel(workspace.branchLabel ?? branchName, font: Theme.Font.mono, color: Theme.Color.textSecondary) + // A long branch keeps both ends. + branch.lineBreakMode = .byTruncatingMiddle branch.toolTip = branchName - branch.frame = NSRect(x: layout.indentX, y: rowY, width: max(0, maxX - layout.indentX), height: Metrics.branchRowHeight) + branch.frame = NSRect(x: x, y: rowY, width: max(0, maxX - x), height: Metrics.branchRowHeight) append(branch) } } @@ -470,7 +487,7 @@ final class SidebarWorkspaceCardRenderer { )) let titleX = x + 12 + Theme.Space.sm - let title = textLabel(workspace.title, font: Theme.Font.body, color: Theme.Color.textSecondary) + let title = textLabel(workspace.cardTitle, font: Theme.Font.body, color: Theme.Color.textSecondary) title.toolTip = titleToolTip() let height = ceil(title.fittingSize.height) title.frame = NSRect(x: titleX, y: midY - height / 2, width: max(0, badge.frame.minX - Theme.Space.sm - titleX), height: height) diff --git a/Tests/NiruxTests/PRDetectTests.swift b/Tests/NiruxTests/PRDetectTests.swift index 7c0b907..2fde291 100644 --- a/Tests/NiruxTests/PRDetectTests.swift +++ b/Tests/NiruxTests/PRDetectTests.swift @@ -688,3 +688,54 @@ private func fakeGitHubCLIScript(_ configuration: FakeGitHubCLIConfiguration) -> kill "$watchdog_pid" 2>/dev/null || true """# } + +// The card: its title, its teammate mark, its diff. +extension PRDetectTests { + func testPullRequestInfoReadsTitleAndAuthor() { + let info = PRDetect.pullRequestInfo(from: [ + "number": 1174, "state": "OPEN", "title": "Ledger entries for payouts", "author": ["login": "tomiir"] + ]) + XCTAssertEqual(info.title, "Ledger entries for payouts") + XCTAssertEqual(info.otherAuthor, "tomiir") + + let bot = PRDetect.pullRequestInfo(from: ["author": ["login": "app/dependabot", "is_bot": true]]) + XCTAssertNil(bot.otherAuthor, "a bot is no teammate") + } + + func testDiffStatsLeaveHandoversOut() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + try runGit(["init", "-q"], at: directory) + try FileManager.default.createDirectory( + at: directory.appendingPathComponent("sub"), withIntermediateDirectories: true + ) + for name in [".claude-handover.md", ".codex-handover.md", "tracked.txt", "sub/kept.txt"] { + try "clean\n".write(to: directory.appendingPathComponent(name), atomically: true, encoding: .utf8) + } + try runGit(["add", "."], at: directory) + try runGit([ + "-c", "user.name=Nirux Tests", + "-c", "user.email=nirux@example.test", + "commit", "-qm", "initial" + ], at: directory) + + for name in [".claude-handover.md", ".codex-handover.md"] { + try "next steps\nmore\n".write(to: directory.appendingPathComponent(name), atomically: true, encoding: .utf8) + } + // From a subdirectory too: the handovers sit at the top level. + let subdirectory = directory.appendingPathComponent("sub").path + guard case .observed(_, let handoversOnly) = PRDetect.diffStats(cwd: subdirectory) else { + return XCTFail("Expected observed diff stats") + } + XCTAssertNil(handoversOnly) + + try "changed\n".write(to: directory.appendingPathComponent("tracked.txt"), atomically: true, encoding: .utf8) + guard case .observed(_, let stats) = PRDetect.diffStats(cwd: subdirectory) else { + return XCTFail("Expected observed diff stats") + } + XCTAssertEqual(stats, "1 file changed, 1 insertion(+), 1 deletion(-)") + } +} diff --git a/Tests/NiruxTests/SidebarCardStateTests.swift b/Tests/NiruxTests/SidebarCardStateTests.swift index 0f6e2f0..6f63ad6 100644 --- a/Tests/NiruxTests/SidebarCardStateTests.swift +++ b/Tests/NiruxTests/SidebarCardStateTests.swift @@ -37,6 +37,65 @@ final class SidebarCardStateTests: XCTestCase { ) } + // MARK: - Title and branch rows + + func testCardIsNamedByItsPullRequestWhileTitledWithItsBranch() { + var pullRequest = pullRequest() + pullRequest.title = "feat(payouts): ledger entries" + var info = workspace([column()]) + info.lastPullRequest = pullRequest + XCTAssertEqual(info.cardTitle, "checkout", "a name set by hand stays") + + info = WorkspaceInfo( + id: "ws", index: 1, title: "feat/checkout", profileID: WorkspaceProfile.defaultID, isInactive: false, + columnCount: 1, focusedColumn: 0, gitBranch: "feat/checkout", notification: nil, isActive: false, + columns: [column()], prInfo: nil, diffStats: nil, purpose: nil, nextStep: nil, blocker: nil, + phase: .active, lastSummary: nil, lastActivityAt: nil + ) + XCTAssertEqual(info.cardTitle, "feat/checkout") + info.lastPullRequest = pullRequest + XCTAssertEqual(info.cardTitle, "ledger entries", "the type(scope) every PR shares is dropped") + pullRequest.title = "Ledger entries: part 2" + info.lastPullRequest = pullRequest + XCTAssertEqual(info.cardTitle, "Ledger entries: part 2") + } + + func testBranchDropsThePrefixItSharesWithASibling() { + let siblings = ["tomiir/payouts-ledger-entries", "tomiir/payouts-payout-status", "cheun/review-payouts", "cheun/review-fo-account"] + let labels = siblings.map { SidebarCardLayout.distinctiveBranch($0, siblings: siblings) } + XCTAssertEqual(labels, ["ledger-entries", "payout-status", "payouts", "fo-account"]) + // Cut at the last separator of the shared prefix, never mid-word. + XCTAssertEqual(SidebarCardLayout.distinctiveBranch("feat/checkout", siblings: ["feat/check-in"]), "checkout") + XCTAssertEqual(SidebarCardLayout.distinctiveBranch("feat/x", siblings: ["feat/x-2"]), "x") + XCTAssertEqual(SidebarCardLayout.distinctiveBranch("feat/x-2", siblings: ["feat/x"]), "x-2") + } + + func testBranchRowMarksATeammatesBranchAndPrintsItsDistinctiveEnd() { + var teammate = pullRequest() + teammate.otherAuthor = "tomiir" + var info = workspace([column()]) + info.lastPullRequest = teammate + info.branchLabel = "out" + let views = SidebarWorkspaceCardRenderer(workspace: info, sidebarWidth: 260, yOffset: 400).render().views + + XCTAssertTrue(views.contains { ($0 as? SidebarBadgeView)?.accessibilityLabel() == "Opened by tomiir" }) + let branch = views.compactMap { $0 as? NSTextField }.first { $0.stringValue == "out" } + XCTAssertEqual(branch?.toolTip, "feat/checkout") + + var mine = workspace([column()], pullRequest: pullRequest()) + mine.lastPullRequest = pullRequest() + let own = SidebarWorkspaceCardRenderer(workspace: mine, sidebarWidth: 260, yOffset: 400) + XCTAssertFalse(own.render().views.contains { ($0 as? SidebarBadgeView)?.accessibilityLabel()?.hasPrefix("Opened by") == true }) + } + + func testBranchWithoutSiblingStaysWhole() { + XCTAssertEqual(SidebarCardLayout.distinctiveBranch("feat/checkout", siblings: []), "feat/checkout") + XCTAssertEqual(SidebarCardLayout.distinctiveBranch("feat/checkout", siblings: ["feat/checkout"]), "feat/checkout") + XCTAssertEqual(SidebarCardLayout.distinctiveBranch("feat/checkout", siblings: ["fix/login"]), "feat/checkout") + XCTAssertEqual(SidebarCardLayout.distinctiveBranch("main", siblings: ["mainline"]), "main") + XCTAssertEqual(SidebarCardLayout.distinctiveBranch("fix-", siblings: ["fix-login"]), "fix-") + } + private let stopped = SidebarStuckState.stoppedOnError(kind: "rate_limit", detail: nil, failedAt: 1, resume: .offered) // MARK: - State diff --git a/Tests/NiruxTests/WorkspaceContextTests.swift b/Tests/NiruxTests/WorkspaceContextTests.swift index 7d62a98..0b2e30c 100644 --- a/Tests/NiruxTests/WorkspaceContextTests.swift +++ b/Tests/NiruxTests/WorkspaceContextTests.swift @@ -106,6 +106,27 @@ final class WorkspaceContextTests: XCTestCase { XCTAssertEqual(workspace.lastSummary, "Keep the completed work summary") } + func testCardKeepsItsPullRequestAcrossCommitsOfItsBranch() { + let workspace = WorkspaceState(title: "feature/task", cwd: "/tmp") + let pullRequest = PRInfo( + number: 42, state: "OPEN", isDraft: false, ciStatus: nil, checks: [], reviewDecision: nil, + mergeable: nil, url: "https://example.test/pull/42", additions: nil, deletions: nil, changedFiles: nil, + title: "Ledger entries" + ) + let original = GitContext(branch: "feature/task", identity: GitIdentity(repositoryRoot: "/repo", head: "head-a")) + let newCommit = GitContext(branch: "feature/task", identity: GitIdentity(repositoryRoot: "/repo", head: "head-b")) + let otherBranch = GitContext(branch: "feature/other", identity: GitIdentity(repositoryRoot: "/repo", head: "head-b")) + + XCTAssertTrue(workspace.updateGitContext(original)) + XCTAssertTrue(workspace.applyPullRequestInfo(pullRequest, for: original)) + XCTAssertTrue(workspace.updateGitContext(newCommit)) + XCTAssertNil(workspace.prInfo) + XCTAssertEqual(workspace.lastPullRequest, pullRequest) + + XCTAssertTrue(workspace.updateGitContext(otherBranch)) + XCTAssertNil(workspace.lastPullRequest) + } + func testGitContextChangesRejectStalePRMetadata() { let workspace = WorkspaceState(title: "context", cwd: "/tmp") let pullRequest = PRInfo(