Codex Handbook
tui/src/branch_summary.rs 739 lines
//! Branch and pull-request metadata for TUI status-line items.//!//! This module owns the git and GitHub probes behind the TUI `git-branch`, `pull-request-number`,//! and `branch-changes` status-line items. It deliberately talks only to a//! `WorkspaceCommandExecutor`, not to `tokio::process::Command`, so the same lookup logic works//! when the TUI is connected to either an embedded or remote app-server.//!//! All lookups are best-effort. A failed command, missing `git` or `gh`, unauthenticated GitHub//! CLI, non-git directory, or ambiguous repository state should result in absent optional metadata//! rather than a user-visible error. The status line can then render whichever pieces are available//! without blocking the rest of the UI.#[cfg(test)]use std::collections::VecDeque;use std::path::Path;use serde::Deserialize;use crate::workspace_command::WorkspaceCommand;#[cfg(test)]use crate::workspace_command::WorkspaceCommandError;use crate::workspace_command::WorkspaceCommandExecutor;use crate::workspace_command::WorkspaceCommandOutput;/// Additions and deletions between `HEAD` and a branch comparison base.#[derive(Clone, Debug, Eq, PartialEq)]pub(crate) struct GitBranchDiffStats {    /// Total added lines in committed changes on the current branch.    pub(crate) additions: u64,    /// Total deleted lines in committed changes on the current branch.    pub(crate) deletions: u64,}/// Combined git metadata cached by the status line for one working directory.////// A summary may contain only one of the fields when the other probe fails. Renderers should treat/// missing fields as omitted optional UI rather than as a hard lookup failure.#[derive(Clone, Debug, Default)]pub(crate) struct StatusLineGitSummary {    /// Open pull request associated with the current branch or HEAD commit.    pub(crate) pull_request: Option<StatusLinePullRequest>,    /// Additions and deletions between `HEAD` and the repository default branch merge base.    pub(crate) branch_change_stats: Option<GitBranchDiffStats>,}/// Open GitHub pull request shown by the `pull-request-number` status-line item.////// The URL is kept with the number so clickable renderers can open the same PR represented by the/// compact label. Callers should only construct this for open PRs; closed or merged PRs are filtered/// out by this module.#[derive(Clone, Debug, Eq, PartialEq)]pub(crate) struct StatusLinePullRequest {    /// GitHub pull request number.    pub(crate) number: u64,    /// Browser URL for the pull request.    pub(crate) url: String,}#[derive(Clone, Debug, Eq, PartialEq)]struct DefaultBranch {    /// Git ref used for merge-base comparison.    ///    /// This may be a remote-tracking ref such as `refs/remotes/origin/main`, which avoids    /// comparing against a stale or absent local `main` branch.    merge_ref: String,}#[derive(Deserialize)]struct GhPullRequestView {    number: u64,    url: String,    state: String,}#[derive(Deserialize)]struct GhPullRequestApiItem {    number: u64,    #[serde(rename = "html_url")]    url: String,    state: String,}#[derive(Deserialize)]struct GhRepoView {    #[serde(rename = "nameWithOwner")]    name_with_owner: Option<String>,    parent: Option<GhRepoParent>,}#[derive(Deserialize)]struct GhRepoParent {    #[serde(rename = "nameWithOwner")]    name_with_owner: String,}/// Returns the checked-out branch name for one status-line working directory.////// Detached HEADs, non-git directories, and command failures return `None` so the renderer can/// omit the branch item without surfacing a background lookup error.pub(crate) async fn current_branch_name(    runner: &dyn WorkspaceCommandExecutor,    cwd: &Path,) -> Option<String> {    let output = run_git_command(runner, cwd, &["branch", "--show-current"])        .await        .ok()?;    if !output.success() {        return None;    }    Some(output.stdout.trim().to_string()).filter(|name| !name.is_empty())}/// Resolves PR and branch-change metadata for one status-line working directory.////// The PR and diff-stat probes run concurrently because each is independent and both are optional./// The returned summary is suitable for caching by `cwd`; callers should discard it if the active/// status-line cwd changes before the async lookup completes.pub(crate) async fn status_line_git_summary(    runner: &dyn WorkspaceCommandExecutor,    cwd: &Path,) -> StatusLineGitSummary {    let (pull_request, branch_change_stats) = tokio::join!(        open_pull_request(runner, cwd),        branch_diff_stats_to_default_branch(runner, cwd),    );    StatusLineGitSummary {        pull_request,        branch_change_stats,    }}/// Counts committed line changes between `HEAD` and the repository default branch.////// The comparison base is the merge base with a verified default-branch ref. Uncommitted working/// tree edits are intentionally ignored because the status-line item summarizes the checked-out/// branch, not the current dirty worktree.async fn branch_diff_stats_to_default_branch(    runner: &dyn WorkspaceCommandExecutor,    cwd: &Path,) -> Option<GitBranchDiffStats> {    let git_dir = run_git_command(runner, cwd, &["rev-parse", "--git-dir"])        .await        .ok()?;    if !git_dir.success() {        return None;    }    let default_branch = get_default_branch(runner, cwd).await?;    let merge_base = run_git_command(        runner,        cwd,        &["merge-base", "HEAD", &default_branch.merge_ref],    )    .await    .ok()?;    if !merge_base.success() {        return None;    }    let merge_base = merge_base.stdout.trim();    if merge_base.is_empty() {        return None;    }    let range = format!("{merge_base}..HEAD");    let numstat = run_git_command(runner, cwd, &["diff", "--numstat", &range])        .await        .ok()?;    if !numstat.success() {        return None;    }    let mut additions = 0_u64;    let mut deletions = 0_u64;    for line in numstat.stdout.lines() {        let mut columns = line.split('\t');        additions += columns            .next()            .and_then(|value| value.parse().ok())            .unwrap_or(0);        deletions += columns            .next()            .and_then(|value| value.parse().ok())            .unwrap_or(0);    }    Some(GitBranchDiffStats {        additions,        deletions,    })}/// Returns git remotes in the order used for default-branch discovery.////// `origin` is prioritized because most repositories use it as the canonical upstream. Other/// remotes are still tried so fork or enterprise layouts with a differently named upstream can/// produce branch-change stats when their remote HEAD is configured.async fn get_git_remotes(runner: &dyn WorkspaceCommandExecutor, cwd: &Path) -> Option<Vec<String>> {    let output = run_git_command(runner, cwd, &["remote"]).await.ok()?;    if !output.success() {        return None;    }    let mut remotes: Vec<String> = output.stdout.lines().map(str::to_string).collect();    if let Some(pos) = remotes.iter().position(|remote| remote == "origin") {        let origin = remotes.remove(pos);        remotes.insert(0, origin);    }    Some(remotes)}/// Resolves the default branch ref that should be used for branch-change comparisons.////// The lookup prefers remote-tracking refs over local branches so feature-only clones and stale/// local `main` branches do not inflate the status-line diff. When no remote default is available,/// local `main` or `master` is used as a last resort.async fn get_default_branch(    runner: &dyn WorkspaceCommandExecutor,    cwd: &Path,) -> Option<DefaultBranch> {    let remotes = get_git_remotes(runner, cwd).await.unwrap_or_default();    for remote in remotes {        if let Some(branch) =            get_remote_default_branch_from_symbolic_ref(runner, cwd, &remote).await        {            return Some(branch);        }        if let Some(branch) = get_remote_default_branch_from_remote_show(runner, cwd, &remote).await        {            return Some(branch);        }    }    get_default_branch_local(runner, cwd).await}/// Resolves a remote's symbolic HEAD into a concrete remote-tracking ref.////// The returned ref is verified before use. Without that check, a symbolic `origin/HEAD` left over/// from an old fetch could point at a ref that no longer exists, causing the later merge-base probe/// to fail in a less obvious place.async fn get_remote_default_branch_from_symbolic_ref(    runner: &dyn WorkspaceCommandExecutor,    cwd: &Path,    remote: &str,) -> Option<DefaultBranch> {    let remote_head = format!("refs/remotes/{remote}/HEAD");    let output = run_git_command(runner, cwd, &["symbolic-ref", "--quiet", &remote_head])        .await        .ok()?;    if !output.success() {        return None;    }    let trimmed = output.stdout.trim();    let remote_ref_prefix = format!("refs/remotes/{remote}/");    trimmed.strip_prefix(&remote_ref_prefix)?;    if !git_ref_exists(runner, cwd, trimmed).await {        return None;    }    Some(DefaultBranch {        merge_ref: trimmed.to_string(),    })}/// Parses `git remote show` output to discover a remote's default branch ref.////// This is a fallback for repositories where `refs/remotes/<remote>/HEAD` is not configured but/// `git remote show` can still report the upstream HEAD branch. The concrete remote-tracking ref/// must already exist locally before it is accepted.async fn get_remote_default_branch_from_remote_show(    runner: &dyn WorkspaceCommandExecutor,    cwd: &Path,    remote: &str,) -> Option<DefaultBranch> {    let output = run_git_command(runner, cwd, &["remote", "show", remote])        .await        .ok()?;    if !output.success() {        return None;    }    for line in output.stdout.lines() {        let line = line.trim();        let Some(rest) = line.strip_prefix("HEAD branch:") else {            continue;        };        let name = rest.trim();        let remote_ref = format!("refs/remotes/{remote}/{name}");        if !name.is_empty() && git_ref_exists(runner, cwd, &remote_ref).await {            return Some(DefaultBranch {                merge_ref: remote_ref,            });        }    }    None}/// Falls back to local `main` or `master` when no remote default branch can be found.async fn get_default_branch_local(    runner: &dyn WorkspaceCommandExecutor,    cwd: &Path,) -> Option<DefaultBranch> {    for candidate in ["main", "master"] {        let local_ref = format!("refs/heads/{candidate}");        if git_ref_exists(runner, cwd, &local_ref).await {            return Some(DefaultBranch {                merge_ref: local_ref,            });        }    }    None}/// Checks whether a git ref exists in the status-line working directory.async fn git_ref_exists(    runner: &dyn WorkspaceCommandExecutor,    cwd: &Path,    reference: &str,) -> bool {    run_git_command(        runner,        cwd,        &["rev-parse", "--verify", "--quiet", reference],    )    .await    .is_ok_and(|output| output.success())}/// Resolves the open PR associated with the current checkout.////// Branch-based lookup is attempted first because it is cheap and mirrors `gh pr view`. Commit-based/// lookup is used as a fallback so fork workflows can still find a PR opened against the upstream/// repository even when `gh` infers the fork from the current checkout.async fn open_pull_request(    runner: &dyn WorkspaceCommandExecutor,    cwd: &Path,) -> Option<StatusLinePullRequest> {    if let Some(pull_request) = open_pull_request_for_current_branch(runner, cwd).await {        return Some(pull_request);    }    open_pull_request_for_head_commit(runner, cwd).await}/// Uses GitHub CLI's current-branch PR lookup.async fn open_pull_request_for_current_branch(    runner: &dyn WorkspaceCommandExecutor,    cwd: &Path,) -> Option<StatusLinePullRequest> {    let output = run_gh_command(runner, cwd, &["pr", "view", "--json", "number,url,state"])        .await        .ok()?;    if !output.success() {        return None;    }    pull_request_from_view_output(&output.stdout)}/// Looks up open PRs for `HEAD` across the upstream/fork repository search order.async fn open_pull_request_for_head_commit(    runner: &dyn WorkspaceCommandExecutor,    cwd: &Path,) -> Option<StatusLinePullRequest> {    let head_sha = current_head_sha(runner, cwd).await?;    for repo in gh_repo_search_order(runner, cwd).await? {        let endpoint = format!("repos/{repo}/commits/{head_sha}/pulls");        let output = run_gh_command(            runner,            cwd,            &[                "api",                "-H",                "Accept: application/vnd.github+json",                &endpoint,            ],        )        .await        .ok()?;        if output.success()            && let Some(pull_request) = pull_request_from_api_output(&output.stdout)        {            return Some(pull_request);        }    }    None}/// Returns the current `HEAD` SHA for commit-based PR lookup.async fn current_head_sha(runner: &dyn WorkspaceCommandExecutor, cwd: &Path) -> Option<String> {    let output = run_git_command(runner, cwd, &["rev-parse", "HEAD"])        .await        .ok()?;    if !output.success() {        return None;    }    Some(output.stdout.trim().to_string()).filter(|sha| !sha.is_empty())}/// Returns repositories to query for commit-associated PRs, with parent before fork.async fn gh_repo_search_order(    runner: &dyn WorkspaceCommandExecutor,    cwd: &Path,) -> Option<Vec<String>> {    let output = run_gh_command(        runner,        cwd,        &["repo", "view", "--json", "nameWithOwner,parent"],    )    .await    .ok()?;    if !output.success() {        return None;    }    repo_search_order_from_output(&output.stdout)}/// Parses `gh pr view --json number,url,state` output for an open PR.fn pull_request_from_view_output(stdout: &str) -> Option<StatusLinePullRequest> {    let pull_request = serde_json::from_str::<GhPullRequestView>(stdout).ok()?;    pull_request        .state        .eq_ignore_ascii_case("open")        .then_some(StatusLinePullRequest {            number: pull_request.number,            url: pull_request.url,        })}/// Parses the GitHub REST commit-to-PR response and returns the first open PR.fn pull_request_from_api_output(stdout: &str) -> Option<StatusLinePullRequest> {    serde_json::from_str::<Vec<GhPullRequestApiItem>>(stdout)        .ok()?        .into_iter()        .find(|pull_request| pull_request.state.eq_ignore_ascii_case("open"))        .map(|pull_request| StatusLinePullRequest {            number: pull_request.number,            url: pull_request.url,        })}/// Parses `gh repo view` output into the repository search order for fallback PR lookup.////// Parent-first ordering matches upstream PR workflows: a branch may be checked out from a fork/// while the open PR lives on the parent repository.fn repo_search_order_from_output(stdout: &str) -> Option<Vec<String>> {    let repo = serde_json::from_str::<GhRepoView>(stdout).ok()?;    let mut repos = Vec::new();    if let Some(parent) = repo.parent {        repos.push(parent.name_with_owner);    }    if let Some(name_with_owner) = repo.name_with_owner        && !repos.iter().any(|repo| repo == &name_with_owner)    {        repos.push(name_with_owner);    }    if repos.is_empty() {        return None;    }    Some(repos)}/// Runs a git command through the workspace-command abstraction.async fn run_git_command(    runner: &dyn WorkspaceCommandExecutor,    cwd: &Path,    args: &[&str],) -> Result<WorkspaceCommandOutput, crate::workspace_command::WorkspaceCommandError> {    let mut argv = Vec::with_capacity(args.len() + 1);    argv.push("git".to_string());    argv.extend(args.iter().map(|arg| (*arg).to_string()));    runner        .run(            WorkspaceCommand::new(argv)                .cwd(cwd.to_path_buf())                .env("GIT_OPTIONAL_LOCKS", "0"),        )        .await}/// Runs a GitHub CLI command through the workspace-command abstraction.////// Prompting is disabled because status-line probes are background UI work. A command that needs/// authentication or user input should fail and leave the optional PR item hidden.async fn run_gh_command(    runner: &dyn WorkspaceCommandExecutor,    cwd: &Path,    args: &[&str],) -> Result<WorkspaceCommandOutput, crate::workspace_command::WorkspaceCommandError> {    let mut argv = Vec::with_capacity(args.len() + 1);    argv.push("gh".to_string());    argv.extend(args.iter().map(|arg| (*arg).to_string()));    runner        .run(            WorkspaceCommand::new(argv)                .cwd(cwd.to_path_buf())                .env("GH_PROMPT_DISABLED", "1")                .env("GIT_TERMINAL_PROMPT", "0"),        )        .await}#[cfg(test)]mod tests {    use super::*;    use crate::workspace_command::WorkspaceCommand;    use pretty_assertions::assert_eq;    use std::future::Future;    use std::pin::Pin;    use std::sync::Mutex;    #[tokio::test]    async fn branch_diff_stats_prefers_remote_default_ref_over_stale_local_branch() {        let runner = FakeRunner::new(vec![            response(                &["git", "rev-parse", "--git-dir"],                /*exit_code*/ 0,                ".git\n",            ),            response(&["git", "remote"], /*exit_code*/ 0, "origin\n"),            response(                &["git", "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],                /*exit_code*/ 0,                "refs/remotes/origin/main\n",            ),            response(                &[                    "git",                    "rev-parse",                    "--verify",                    "--quiet",                    "refs/remotes/origin/main",                ],                /*exit_code*/ 0,                "remote-main-sha\n",            ),            response(                &["git", "merge-base", "HEAD", "refs/remotes/origin/main"],                /*exit_code*/ 0,                "base-sha\n",            ),            response(                &["git", "diff", "--numstat", "base-sha..HEAD"],                /*exit_code*/ 0,                "1\t0\tfile\n",            ),        ]);        let stats = branch_diff_stats_to_default_branch(&runner, Path::new("/repo"))            .await            .expect("branch diff stats");        assert_eq!(            stats,            GitBranchDiffStats {                additions: 1,                deletions: 0,            }        );        assert!(runner.saw(&["git", "merge-base", "HEAD", "refs/remotes/origin/main"]));    }    #[tokio::test]    async fn open_pull_request_uses_current_branch_view_first() {        let runner = FakeRunner::new(vec![response(            &["gh", "pr", "view", "--json", "number,url,state"],            /*exit_code*/ 0,            r#"{"number":20252,"url":"https://github.com/openai/codex/pull/20252","state":"OPEN"}"#,        )]);        let pull_request = open_pull_request(&runner, Path::new("/repo"))            .await            .expect("pull request");        assert_eq!(            pull_request,            StatusLinePullRequest {                number: 20_252,                url: "https://github.com/openai/codex/pull/20252".to_string(),            }        );        assert!(!runner.saw(&["git", "rev-parse", "HEAD"]));    }    #[tokio::test]    async fn open_pull_request_falls_back_to_parent_repo_commit_lookup() {        let runner = FakeRunner::new(vec![            response(                &["gh", "pr", "view", "--json", "number,url,state"],                /*exit_code*/ 1,                "",            ),            response(                &["git", "rev-parse", "HEAD"],                /*exit_code*/ 0,                "head-sha\n",            ),            response(                &["gh", "repo", "view", "--json", "nameWithOwner,parent"],                /*exit_code*/ 0,                r#"{"nameWithOwner":"fcoury/codex","parent":{"nameWithOwner":"openai/codex"}}"#,            ),            response(                &[                    "gh",                    "api",                    "-H",                    "Accept: application/vnd.github+json",                    "repos/openai/codex/commits/head-sha/pulls",                ],                /*exit_code*/ 0,                r#"[{"number":20252,"html_url":"https://github.com/openai/codex/pull/20252","state":"open"}]"#,            ),        ]);        let pull_request = open_pull_request(&runner, Path::new("/repo"))            .await            .expect("pull request");        assert_eq!(            pull_request,            StatusLinePullRequest {                number: 20_252,                url: "https://github.com/openai/codex/pull/20252".to_string(),            }        );        assert!(runner.saw(&[            "gh",            "api",            "-H",            "Accept: application/vnd.github+json",            "repos/openai/codex/commits/head-sha/pulls",        ]));    }    #[test]    fn status_line_pr_view_parser_requires_open_pr() {        assert_eq!(            pull_request_from_view_output(                r#"{"number":20252,"url":"https://github.com/openai/codex/pull/20252","state":"OPEN"}"#            ),            Some(StatusLinePullRequest {                number: 20_252,                url: "https://github.com/openai/codex/pull/20252".to_string(),            })        );        assert_eq!(            pull_request_from_view_output(                r#"{"number":20252,"url":"https://github.com/openai/codex/pull/20252","state":"MERGED"}"#            ),            None        );    }    #[test]    fn status_line_pr_fallback_searches_parent_repo_first() {        assert_eq!(            repo_search_order_from_output(                r#"{"nameWithOwner":"fcoury/codex","parent":{"nameWithOwner":"openai/codex"}}"#            ),            Some(vec!["openai/codex".to_string(), "fcoury/codex".to_string()])        );    }    fn response(argv: &[&str], exit_code: i32, stdout: &str) -> FakeResponse {        FakeResponse {            argv: argv.iter().map(|arg| (*arg).to_string()).collect(),            output: WorkspaceCommandOutput {                exit_code,                stdout: stdout.to_string(),                stderr: String::new(),            },        }    }    struct FakeResponse {        argv: Vec<String>,        output: WorkspaceCommandOutput,    }    struct FakeRunner {        responses: Mutex<VecDeque<FakeResponse>>,        seen: Mutex<Vec<Vec<String>>>,    }    impl FakeRunner {        fn new(responses: Vec<FakeResponse>) -> Self {            Self {                responses: Mutex::new(responses.into()),                seen: Mutex::new(Vec::new()),            }        }        fn saw(&self, argv: &[&str]) -> bool {            let argv: Vec<String> = argv.iter().map(|arg| (*arg).to_string()).collect();            self.seen                .lock()                .expect("seen lock")                .iter()                .any(|seen| seen == &argv)        }    }    impl WorkspaceCommandExecutor for FakeRunner {        fn run(            &self,            command: WorkspaceCommand,        ) -> Pin<            Box<                dyn Future<Output = Result<WorkspaceCommandOutput, WorkspaceCommandError>>                    + Send                    + '_,            >,        > {            self.seen                .lock()                .expect("seen lock")                .push(command.argv.clone());            Box::pin(async move {                let mut responses = self.responses.lock().expect("responses lock");                let index = responses                    .iter()                    .position(|response| response.argv == command.argv)                    .unwrap_or_else(|| panic!("missing fake response for {:?}", command.argv));                let response = responses.remove(index).expect("fake response");                Ok(response.output)            })        }    }}