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:
parent
eb95cb38ce
commit
7f220dc8e4
4 changed files with 525 additions and 48 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue