Fix sidebar PR badges for restored workspaces (#1570)

* test: cover sidebar PR explicit branch fallback

* fix: restore sidebar PR badges for workspace branches

* test: preserve sidebar PR badge on first prompt

* fix: keep sidebar PR badges through first prompt
This commit is contained in:
Austin Wang 2026-03-16 21:09:02 -07:00 committed by GitHub
parent eb95cb38ce
commit 7f220dc8e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 525 additions and 48 deletions

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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()}"