diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 338844d0..fc1d4cf1 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -145,6 +145,40 @@ _cmux_pr_output_indicates_no_pull_request() { || "$output" == *"no pull request associated"* ]] } +_cmux_github_repo_slug_for_path() { + local repo_path="$1" + local remote_url="" path_part="" + [[ -n "$repo_path" ]] || return 0 + + remote_url="$(git -C "$repo_path" remote get-url origin 2>/dev/null)" + [[ -n "$remote_url" ]] || return 0 + + case "$remote_url" in + git@github.com:*) + path_part="${remote_url#git@github.com:}" + ;; + ssh://git@github.com/*) + path_part="${remote_url#ssh://git@github.com/}" + ;; + https://github.com/*) + path_part="${remote_url#https://github.com/}" + ;; + http://github.com/*) + path_part="${remote_url#http://github.com/}" + ;; + git://github.com/*) + path_part="${remote_url#git://github.com/}" + ;; + *) + return 0 + ;; + esac + + path_part="${path_part%.git}" + [[ "$path_part" == */* ]] || return 0 + printf '%s\n' "$path_part" +} + _cmux_report_pr_for_path() { local repo_path="$1" [[ -n "$repo_path" ]] || { @@ -159,18 +193,26 @@ _cmux_report_pr_for_path() { [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 - local branch gh_output gh_error="" err_file="" gh_status number state url status_opt="" + local branch repo_slug="" gh_output="" gh_error="" err_file="" gh_status number state url status_opt="" + local explicit_branch_output="" explicit_branch_error="" explicit_branch_status=0 + local implicit_probe_indicates_no_pr=0 explicit_probe_indicates_no_pr=0 + local -a gh_repo_args=() branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)" if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then _cmux_clear_pr_for_panel return 0 fi + repo_slug="$(_cmux_github_repo_slug_for_path "$repo_path")" + if [[ -n "$repo_slug" ]]; then + gh_repo_args=(--repo "$repo_slug") + fi err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" [[ -n "$err_file" ]] || return 1 gh_output="$( builtin cd "$repo_path" 2>/dev/null \ && gh pr view \ + "${gh_repo_args[@]}" \ --json number,state,url \ --jq '[.number, .state, .url] | @tsv' \ 2>"$err_file" @@ -180,18 +222,54 @@ _cmux_report_pr_for_path() { gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true fi - if (( gh_status != 0 )); then - if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then - _cmux_clear_pr_for_panel - return 0 + + if (( gh_status == 0 )) && [[ -n "$gh_output" ]]; then + : + else + if (( gh_status == 0 )) && [[ -z "$gh_output" ]]; then + implicit_probe_indicates_no_pr=1 + elif _cmux_pr_output_indicates_no_pull_request "$gh_error"; then + implicit_probe_indicates_no_pr=1 + fi + + # `gh pr view` without an explicit branch can fail to resolve the + # current worktree branch even when the branch has a PR. Fall back to + # the explicit branch name before concluding there is no PR. + err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" + [[ -n "$err_file" ]] || return 1 + explicit_branch_output="$( + builtin cd "$repo_path" 2>/dev/null \ + && gh pr view "$branch" \ + "${gh_repo_args[@]}" \ + --json number,state,url \ + --jq '[.number, .state, .url] | @tsv' \ + 2>"$err_file" + )" + explicit_branch_status=$? + if [[ -f "$err_file" ]]; then + explicit_branch_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" + /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true + fi + + if (( explicit_branch_status == 0 )) && [[ -n "$explicit_branch_output" ]]; then + gh_output="$explicit_branch_output" + gh_status=0 + else + if (( explicit_branch_status == 0 )) && [[ -z "$explicit_branch_output" ]]; then + explicit_probe_indicates_no_pr=1 + elif _cmux_pr_output_indicates_no_pull_request "$explicit_branch_error"; then + explicit_probe_indicates_no_pr=1 + fi + + if (( implicit_probe_indicates_no_pr )) && (( explicit_probe_indicates_no_pr )); then + _cmux_clear_pr_for_panel + return 0 + fi + + # Preserve the last-known PR badge when gh fails transiently, then retry + # on the next background poll instead of clearing visible state. + return 1 fi - # Preserve the last-known PR badge when gh fails transiently, then retry - # on the next background poll instead of clearing visible state. - return 1 - fi - if [[ -z "$gh_output" ]]; then - _cmux_clear_pr_for_panel - return 0 fi IFS=$'\t' read -r number state url <<< "$gh_output" @@ -376,11 +454,18 @@ _cmux_prompt_command() { if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then local head_signature head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)" - if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then - _CMUX_GIT_HEAD_SIGNATURE="$head_signature" - git_head_changed=1 - # Also invalidate the PR poller so it refreshes with the new branch. - _CMUX_PR_FORCE=1 + if [[ -n "$head_signature" ]]; then + if [[ -z "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + # The first observed HEAD value is just the session baseline. + # Treating it as a branch change clears restore-seeded PR badges + # before the first background probe can confirm the current PR. + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + elif [[ "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + git_head_changed=1 + # Also invalidate the PR poller so it refreshes with the new branch. + _CMUX_PR_FORCE=1 + fi fi fi diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index aeef42d2..92af16d9 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -169,6 +169,40 @@ _cmux_pr_output_indicates_no_pull_request() { || "$output" == *"no pull request associated"* ]] } +_cmux_github_repo_slug_for_path() { + local repo_path="$1" + local remote_url="" path_part="" + [[ -n "$repo_path" ]] || return 0 + + remote_url="$(git -C "$repo_path" remote get-url origin 2>/dev/null)" + [[ -n "$remote_url" ]] || return 0 + + case "$remote_url" in + git@github.com:*) + path_part="${remote_url#git@github.com:}" + ;; + ssh://git@github.com/*) + path_part="${remote_url#ssh://git@github.com/}" + ;; + https://github.com/*) + path_part="${remote_url#https://github.com/}" + ;; + http://github.com/*) + path_part="${remote_url#http://github.com/}" + ;; + git://github.com/*) + path_part="${remote_url#git://github.com/}" + ;; + *) + return 0 + ;; + esac + + path_part="${path_part%.git}" + [[ "$path_part" == */* ]] || return 0 + print -r -- "$path_part" +} + _cmux_report_pr_for_path() { local repo_path="$1" [[ -n "$repo_path" ]] || { @@ -183,18 +217,27 @@ _cmux_report_pr_for_path() { [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 - local branch gh_output gh_error="" err_file="" number state url status_opt="" gh_status + local branch repo_slug="" gh_output="" gh_error="" err_file="" number state url status_opt="" gh_status + local explicit_branch_output="" explicit_branch_error="" explicit_branch_status=0 + local implicit_probe_indicates_no_pr=0 explicit_probe_indicates_no_pr=0 + local -a gh_repo_args + gh_repo_args=() branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)" if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then _cmux_clear_pr_for_panel return 0 fi + repo_slug="$(_cmux_github_repo_slug_for_path "$repo_path")" + if [[ -n "$repo_slug" ]]; then + gh_repo_args=(--repo "$repo_slug") + fi err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" [[ -n "$err_file" ]] || return 1 gh_output="$( builtin cd "$repo_path" 2>/dev/null \ && gh pr view \ + "${gh_repo_args[@]}" \ --json number,state,url \ --jq '[.number, .state, .url] | @tsv' \ 2>"$err_file" @@ -204,18 +247,54 @@ _cmux_report_pr_for_path() { gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true fi - if (( gh_status != 0 )); then - if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then - _cmux_clear_pr_for_panel - return 0 + + if (( gh_status == 0 )) && [[ -n "$gh_output" ]]; then + : + else + if (( gh_status == 0 )) && [[ -z "$gh_output" ]]; then + implicit_probe_indicates_no_pr=1 + elif _cmux_pr_output_indicates_no_pull_request "$gh_error"; then + implicit_probe_indicates_no_pr=1 + fi + + # `gh pr view` without an explicit branch can fail to resolve the + # current worktree branch even when the branch has a PR. Fall back to + # the explicit branch name before concluding there is no PR. + err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" + [[ -n "$err_file" ]] || return 1 + explicit_branch_output="$( + builtin cd "$repo_path" 2>/dev/null \ + && gh pr view "$branch" \ + "${gh_repo_args[@]}" \ + --json number,state,url \ + --jq '[.number, .state, .url] | @tsv' \ + 2>"$err_file" + )" + explicit_branch_status=$? + if [[ -f "$err_file" ]]; then + explicit_branch_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" + /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true + fi + + if (( explicit_branch_status == 0 )) && [[ -n "$explicit_branch_output" ]]; then + gh_output="$explicit_branch_output" + gh_status=0 + else + if (( explicit_branch_status == 0 )) && [[ -z "$explicit_branch_output" ]]; then + explicit_probe_indicates_no_pr=1 + elif _cmux_pr_output_indicates_no_pull_request "$explicit_branch_error"; then + explicit_probe_indicates_no_pr=1 + fi + + if (( implicit_probe_indicates_no_pr )) && (( explicit_probe_indicates_no_pr )); then + _cmux_clear_pr_for_panel + return 0 + fi + + # Keep the last-known PR badge on transient gh failures (auth hiccups, + # API lag after creation, or rate limiting) and retry on the next poll. + return 1 fi - # Keep the last-known PR badge on transient gh failures (auth hiccups, - # API lag after creation, or rate limiting) and retry on the next poll. - return 1 - fi - if [[ -z "$gh_output" ]]; then - _cmux_clear_pr_for_panel - return 0 fi local IFS=$'\t' @@ -453,14 +532,21 @@ _cmux_precmd() { if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then local head_signature head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)" - if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then - _CMUX_GIT_HEAD_SIGNATURE="$head_signature" - git_head_changed=1 - # Treat HEAD file change like a git command — force-replace any - # running probe so the sidebar picks up the new branch immediately. - _CMUX_GIT_FORCE=1 - _CMUX_PR_FORCE=1 - should_git=1 + if [[ -n "$head_signature" ]]; then + if [[ -z "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + # The first observed HEAD value establishes the baseline for this + # shell session. Don't treat it as a branch change or we'll clear + # restore-seeded PR badges before the first background probe runs. + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + elif [[ "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + git_head_changed=1 + # Treat HEAD file change like a git command — force-replace any + # running probe so the sidebar picks up the new branch immediately. + _CMUX_GIT_FORCE=1 + _CMUX_PR_FORCE=1 + should_git=1 + fi fi fi diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 34bed6c2..40c07397 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -627,6 +627,15 @@ class TabManager: ObservableObject { private struct InitialWorkspaceGitMetadataSnapshot: Equatable { let branch: String? let isDirty: Bool + let pullRequest: SidebarPullRequestState? + } + + private struct CommandResult { + let stdout: String? + let stderr: String? + let exitStatus: Int32? + let timedOut: Bool + let executionError: String? } /// The window that owns this TabManager. Set by AppDelegate.registerMainWindow(). @@ -642,6 +651,7 @@ class TabManager: ObservableObject { /// Static so port ranges don't overlap across multiple windows (each window has its own TabManager). private static var nextPortOrdinal: Int = 0 private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0] + private nonisolated static let initialWorkspacePullRequestProbeTimeout: TimeInterval = 5.0 @Published var selectedTabId: UUID? { willSet { #if DEBUG @@ -1166,15 +1176,25 @@ class TabManager: ObservableObject { workspace.clearPanelGitBranch(panelId: panelId) } - if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) { + if let pullRequest = snapshot.pullRequest { + workspace.updatePanelPullRequest( + panelId: panelId, + number: pullRequest.number, + label: pullRequest.label, + url: pullRequest.url, + status: pullRequest.status + ) + } else if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) { workspace.clearPanelPullRequest(panelId: panelId) } #if DEBUG let branchLabel = snapshot.branch ?? "none" + let prLabel = snapshot.pullRequest.map { "#\($0.number):\($0.status.rawValue)" } ?? "none" dlog( "workspace.gitProbe.apply workspace=\(workspaceId.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0)" + "panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0) " + + "pr=\(prLabel)" ) #endif } @@ -1184,36 +1204,233 @@ class TabManager: ObservableObject { ) -> InitialWorkspaceGitMetadataSnapshot { let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"])) guard let branch else { - return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false) + return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false, pullRequest: nil) } let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"]) let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) - return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty) + let pullRequest = initialWorkspacePullRequestSnapshot(directory: directory, branch: branch) + return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty, pullRequest: pullRequest) } private nonisolated static func runGitCommand(directory: String, arguments: [String]) -> String? { + runCommand( + directory: directory, + executable: "git", + arguments: arguments + ) + } + + private nonisolated static func initialWorkspacePullRequestSnapshot( + directory: String, + branch: String + ) -> SidebarPullRequestState? { + let repoSlug = githubRepositorySlug(directory: directory) + let repoArguments = repoSlug.map { ["--repo", $0] } ?? [] + let result = runCommandResult( + directory: directory, + executable: "gh", + arguments: [ + "pr", "view", branch, + ] + repoArguments + [ + "--json", "number,state,url", + "--jq", "[.number, .state, .url] | @tsv", + ], + timeout: initialWorkspacePullRequestProbeTimeout + ) + + guard let result else { return nil } + guard let output = result.stdout, + result.exitStatus == 0, + !output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { +#if DEBUG + let statusText: String + if result.timedOut { + statusText = "timeout" + } else if let exitStatus = result.exitStatus { + statusText = "exit=\(exitStatus)" + } else if let executionError = result.executionError { + statusText = "error=\(executionError)" + } else { + statusText = "unknown" + } + let stderr = debugLogSnippet(result.stderr) ?? "none" + dlog( + "workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug ?? "none") status=\(statusText) stderr=\(stderr)" + ) +#endif + return nil + } + + let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) + let fields = trimmedOutput + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: "\t", maxSplits: 2, omittingEmptySubsequences: false) + guard fields.count == 3, + let number = Int(fields[0]), + let url = URL(string: String(fields[2])) else { +#if DEBUG + dlog( + "workspace.gitProbe.pr.parseFail dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug ?? "none") output=\(debugLogSnippet(trimmedOutput) ?? "none")" + ) +#endif + return nil + } + + let status: SidebarPullRequestStatus + switch fields[1].uppercased() { + case "OPEN": + status = .open + case "MERGED": + status = .merged + case "CLOSED": + status = .closed + default: + return nil + } + +#if DEBUG + dlog( + "workspace.gitProbe.pr.success dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug ?? "none") number=\(number) state=\(status.rawValue)" + ) +#endif + return SidebarPullRequestState(number: number, label: "PR", url: url, status: status) + } + + private nonisolated static func runCommand( + directory: String, + executable: String, + arguments: [String], + timeout: TimeInterval? = nil + ) -> String? { + let result = runCommandResult( + directory: directory, + executable: executable, + arguments: arguments, + timeout: timeout + ) + guard let result, + result.exitStatus == 0, + !result.timedOut else { + return nil + } + return result.stdout + } + + private nonisolated static func runCommandResult( + directory: String, + executable: String, + arguments: [String], + timeout: TimeInterval? = nil + ) -> CommandResult? { let process = Process() let stdout = Pipe() + let stderr = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = ["git", "-C", directory] + arguments + process.arguments = [executable] + arguments + process.currentDirectoryURL = URL(fileURLWithPath: directory) process.standardOutput = stdout - process.standardError = FileHandle.nullDevice + process.standardError = stderr + + let completion = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in + completion.signal() + } do { try process.run() } catch { + return CommandResult( + stdout: nil, + stderr: nil, + exitStatus: nil, + timedOut: false, + executionError: String(describing: error) + ) + } + + if let timeout, + completion.wait(timeout: .now() + timeout) == .timedOut { + process.terminate() + if completion.wait(timeout: .now() + 0.2) == .timedOut { + kill(process.processIdentifier, SIGKILL) + _ = completion.wait(timeout: .now() + 0.2) + } + return CommandResult( + stdout: nil, + stderr: nil, + exitStatus: nil, + timedOut: true, + executionError: nil + ) + } else if timeout == nil { + completion.wait() + } + + let stdoutData = stdout.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderr.fileHandleForReading.readDataToEndOfFile() + return CommandResult( + stdout: String(data: stdoutData, encoding: .utf8), + stderr: String(data: stderrData, encoding: .utf8), + exitStatus: process.terminationStatus, + timedOut: false, + executionError: nil + ) + } + + private nonisolated static func githubRepositorySlug(directory: String) -> String? { + guard let remoteURL = runGitCommand( + directory: directory, + arguments: ["remote", "get-url", "origin"] + ) else { return nil } - // Drain stdout while the subprocess is active so large repos cannot fill the pipe buffer. - let data = stdout.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - guard process.terminationStatus == 0 else { + let trimmed = remoteURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let githubPrefixes = [ + "git@github.com:", + "ssh://git@github.com/", + "https://github.com/", + "http://github.com/", + "git://github.com/", + ] + for prefix in githubPrefixes where trimmed.hasPrefix(prefix) { + let path = String(trimmed.dropFirst(prefix.count)) + return normalizedGitHubRepositorySlug(path) + } + + guard let url = URL(string: trimmed), + let host = url.host?.lowercased(), + host == "github.com" else { return nil } - return String(data: data, encoding: .utf8) + return normalizedGitHubRepositorySlug(url.path) + } + + private nonisolated static func normalizedGitHubRepositorySlug(_ rawPath: String) -> String? { + let trimmedPath = rawPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !trimmedPath.isEmpty else { return nil } + let components = trimmedPath.split(separator: "/").map(String.init) + guard components.count >= 2 else { return nil } + let owner = components[0] + var repo = components[1] + if repo.hasSuffix(".git") { + repo.removeLast(4) + } + guard !owner.isEmpty, !repo.isEmpty else { return nil } + return "\(owner)/\(repo)" + } + + private nonisolated static func debugLogSnippet(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + return String(trimmed.prefix(180)) } private nonisolated static func normalizedBranchName(_ branch: String?) -> String? { @@ -4171,6 +4388,9 @@ extension TabManager { for tab in tabs { unwireClosedBrowserTracking(for: tab) } + for workspaceId in Array(initialWorkspaceGitProbeGenerationByWorkspace.keys) { + clearInitialWorkspaceGitProbe(workspaceId: workspaceId) + } // Clear non-@Published state without touching tabs/selectedTabId yet. lastFocusedPanelByTab.removeAll() @@ -4227,6 +4447,21 @@ extension TabManager { // never see an intermediate state with empty tabs or nil selection. tabs = newTabs selectedTabId = newSelectedId + for workspace in newTabs { + guard let terminalPanel = workspace.focusedTerminalPanel ?? workspace.panels.values + .compactMap({ $0 as? TerminalPanel }) + .first, + let directory = normalizedWorkingDirectory( + workspace.panelDirectories[terminalPanel.id] ?? workspace.currentDirectory + ) else { + continue + } + scheduleInitialWorkspaceGitMetadataRefresh( + workspaceId: workspace.id, + panelId: terminalPanel.id, + directory: directory + ) + } if let selectedTabId { NotificationCenter.default.post( diff --git a/tests/test_issue_1138_sidebar_pr_polling.py b/tests/test_issue_1138_sidebar_pr_polling.py index 973e98dd..9ff9db2e 100644 --- a/tests/test_issue_1138_sidebar_pr_polling.py +++ b/tests/test_issue_1138_sidebar_pr_polling.py @@ -10,6 +10,9 @@ Validates that shell integration: 4) recovers when a gh probe wedges longer than the async timeout 5) keeps polling in bash after prompt-render helper commands run 6) tears down the timed-out gh probe instead of leaking it in the background +7) falls back to explicit branch lookup when implicit gh branch resolution fails +8) does not clear an existing PR badge on the first prompt while establishing + the HEAD baseline """ from __future__ import annotations @@ -77,6 +80,11 @@ def _git_stub() -> str: exit 0 fi + if [ "$1" = "remote" ] && [ "$2" = "get-url" ] && [ "$3" = "origin" ]; then + printf 'https://github.com/manaflow-ai/cmux.git\\n' + exit 0 + fi + if [ "$1" = "status" ] && [ "$2" = "--porcelain" ] && [ "$3" = "-uno" ]; then exit 0 fi @@ -111,6 +119,17 @@ def _gh_stub() -> str: exit 9 fi + requested_branch="" + if [ $# -ge 3 ]; then + case "$3" in + --*) + ;; + *) + requested_branch="$3" + ;; + esac + fi + branch="" if [ -f "$head_file" ]; then head_line="$(cat "$head_file")" @@ -125,6 +144,9 @@ def _gh_stub() -> str: prompt_helper_idle) printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' ;; + initial_prompt_preserves_pr_badge) + printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' + ;; transient_same_context) if [ "$count" -eq 1 ]; then printf 'rate limit exceeded\\n' >&2 @@ -154,6 +176,18 @@ def _gh_stub() -> str: fi printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' ;; + explicit_branch_fallback) + if [ -z "$requested_branch" ]; then + printf 'no pull requests found for branch "%s"\\n' "$branch" >&2 + exit 1 + fi + if [ "$requested_branch" = "$branch" ]; then + printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' + exit 0 + fi + printf 'unexpected branch lookup: %s\\n' "$requested_branch" >&2 + exit 8 + ;; *) printf 'unknown scenario: %s\\n' "$scenario" >&2 exit 2 @@ -198,6 +232,20 @@ def _shell_command(kind: str, scenario: str) -> str: 'sleep 4\n' '_cmux_cleanup\n' ), + "explicit_branch_fallback": ( + 'cd "$CMUX_TEST_REPO"\n' + '_CMUX_PR_POLL_INTERVAL=10\n' + '_cmux_prompt_entry\n' + 'sleep 2\n' + '_cmux_cleanup\n' + ), + "initial_prompt_preserves_pr_badge": ( + 'cd "$CMUX_TEST_REPO"\n' + '_CMUX_PR_POLL_INTERVAL=10\n' + '_cmux_prompt_entry\n' + 'sleep 2\n' + '_cmux_cleanup\n' + ), }[scenario] if kind == "zsh": @@ -344,6 +392,27 @@ def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path, sc return (1, f"{shell}/{scenario}: timed-out gh probe still running as pid {gh_pid}") return (0, f"{shell}/{scenario}: ok") + if scenario == "explicit_branch_fallback": + if _report_line(1138) not in send_lines: + return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines)) + if not any(line.startswith("pr view feature/issue-1138 ") for line in gh_args_lines): + return ( + 1, + f"{shell}/{scenario}: expected explicit branch fallback\n" + "\n".join(gh_args_lines), + ) + return (0, f"{shell}/{scenario}: ok") + + if scenario == "initial_prompt_preserves_pr_badge": + if _report_line(1138) not in send_lines: + return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines)) + if any(line.startswith("clear_pr ") for line in send_lines): + return ( + 1, + f"{shell}/{scenario}: initial prompt should not clear an existing PR badge\n" + + "\n".join(send_lines), + ) + return (0, f"{shell}/{scenario}: ok") + return (1, f"{shell}/{scenario}: unhandled scenario") @@ -358,6 +427,8 @@ def main() -> int: "transient_same_context", "branch_switch_clear", "timeout_recovery", + "explicit_branch_fallback", + "initial_prompt_preserves_pr_badge", ] base = Path("/tmp") / f"cmux_issue_1138_pr_poll_{os.getpid()}"