Skip to main content

git_workflow/commands/
open.rs

1//! `gw open` command - Open the PR for the current branch in the browser
2//!
3//! Resolves the PR for the current branch via `gh` and opens its URL in the
4//! browser. The command used to open the URL is configurable so that
5//! environment-specific behavior (e.g. selecting a particular Chrome profile)
6//! lives in the user's dotfiles, not in this CLI.
7//!
8//! # Browser command resolution
9//!
10//! The URL is opened by invoking a single program with the URL as its only
11//! argument (`<program> <url>`). The program is resolved in this order:
12//!
13//! 1. `$GW_OPEN_URL_CMD` - gw-specific override
14//! 2. `$OPEN_URL_CMD`     - generic override (shared with other tooling)
15//! 3. OS default          - `open` on macOS, `xdg-open` elsewhere
16//!
17//! Set `GW_OPEN_URL_CMD`/`OPEN_URL_CMD` to a script that takes the URL as `$1`.
18
19use std::process::Command;
20
21use crate::error::{GwError, Result};
22use crate::git;
23use crate::github;
24use crate::output;
25use crate::state::RepoType;
26
27/// Execute the `open` command
28pub fn run(verbose: bool) -> Result<()> {
29    // Ensure we're in a git repo
30    if !git::is_git_repo() {
31        return Err(GwError::NotAGitRepository);
32    }
33
34    let repo_type = RepoType::detect()?;
35    let home_branch = repo_type.home_branch();
36    let current = git::current_branch()?;
37
38    println!();
39
40    // On the home branch there is no feature PR to open
41    if current == home_branch {
42        output::warn(&format!("On home branch '{}'. No PR to open.", home_branch));
43        output::hints(&["gw new feature/your-feature  # Start a branch first"]);
44        return Ok(());
45    }
46
47    // Need gh to look up the PR for this branch
48    if !github::is_gh_available() {
49        return Err(GwError::Other(
50            "GitHub CLI (gh) is not available. Install it from https://cli.github.com/".into(),
51        ));
52    }
53
54    let pr = match github::get_pr_for_branch(&current)? {
55        Some(pr) => pr,
56        None => {
57            output::warn(&format!("No PR found for branch '{}'.", current));
58            output::hints(&["gh pr create -a \"@me\" -t \"...\"  # Create a PR first"]);
59            return Ok(());
60        }
61    };
62
63    output::info(&format!("PR: #{} {}", pr.number, pr.title));
64    open_url(&pr.url, verbose)?;
65    output::success(&format!("Opened {}", pr.url));
66
67    Ok(())
68}
69
70/// Open a URL in the browser using the resolved open command.
71fn open_url(url: &str, verbose: bool) -> Result<()> {
72    let gw_cmd = std::env::var("GW_OPEN_URL_CMD").ok();
73    let open_cmd = std::env::var("OPEN_URL_CMD").ok();
74    let program = resolve_open_program(
75        gw_cmd.as_deref(),
76        open_cmd.as_deref(),
77        default_open_program(),
78    );
79
80    if verbose {
81        output::action(&format!("{} {}", program, url));
82    }
83
84    let status = Command::new(&program)
85        .arg(url)
86        .status()
87        .map_err(|e| GwError::Other(format!("Failed to run open command '{program}': {e}")))?;
88
89    if !status.success() {
90        return Err(GwError::Other(format!(
91            "Open command '{program}' exited with a non-zero status"
92        )));
93    }
94
95    Ok(())
96}
97
98/// The OS default program for opening a URL.
99fn default_open_program() -> &'static str {
100    if cfg!(target_os = "macos") {
101        "open"
102    } else {
103        "xdg-open"
104    }
105}
106
107/// Resolve which program should be used to open a URL.
108///
109/// Precedence: `GW_OPEN_URL_CMD` > `OPEN_URL_CMD` > OS default. Empty values
110/// are ignored so that an exported-but-blank variable falls through.
111fn resolve_open_program(gw_cmd: Option<&str>, open_cmd: Option<&str>, os_default: &str) -> String {
112    gw_cmd
113        .filter(|s| !s.is_empty())
114        .or_else(|| open_cmd.filter(|s| !s.is_empty()))
115        .unwrap_or(os_default)
116        .to_string()
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn gw_cmd_takes_precedence() {
125        let program = resolve_open_program(Some("gw-open"), Some("open-url"), "open");
126        assert_eq!(program, "gw-open");
127    }
128
129    #[test]
130    fn falls_back_to_open_url_cmd() {
131        let program = resolve_open_program(None, Some("open-url"), "open");
132        assert_eq!(program, "open-url");
133    }
134
135    #[test]
136    fn falls_back_to_os_default() {
137        let program = resolve_open_program(None, None, "xdg-open");
138        assert_eq!(program, "xdg-open");
139    }
140
141    #[test]
142    fn empty_values_are_ignored() {
143        // Exported-but-blank GW var should fall through to OPEN_URL_CMD
144        assert_eq!(
145            resolve_open_program(Some(""), Some("open-url"), "open"),
146            "open-url"
147        );
148        // Both blank → OS default
149        assert_eq!(resolve_open_program(Some(""), Some(""), "open"), "open");
150    }
151}