git_brws/
argv.rs

1use crate::async_runtime;
2use crate::config::{Config, EnvConfig};
3use crate::error::{Error, ErrorKind, Result};
4use crate::git::Git;
5use crate::github_api::Client;
6use getopts::Options;
7use std::env;
8use std::ffi::OsStr;
9use std::fs;
10use std::path::PathBuf;
11
12fn handle_scp_like_syntax(mut url: String) -> String {
13    // ref: https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols
14
15    if url.contains("://") {
16        // When url is a URL like https://server/project.git
17        return url;
18    }
19
20    // When the target is an scp-like syntax: [user@]server:project.git
21    // Handle ':' in the syntax. Note that GitHub user name may start with number (#24)
22    if let Some(i) = url.find(':') {
23        let after_colon = i + 1;
24        if let Some(i) = url[after_colon..].find(':') {
25            // When port number exists
26            //  git@service.com:123:user/repo.git -> git@service.com:123/user/repo.git
27            let i = after_colon + i;
28            url.replace_range(i..i + 1, "/");
29        } else {
30            // When a port number is omitted, default SSH port is 22
31            //  git@service.com:user/repo.git -> git@service.com:22/user/repo.git
32            url.insert_str(after_colon, "22/"); // Insert port number after colon
33        }
34    }
35
36    // Examples:
37    //  git@service.com:22/user/repo.git -> ssh://git@service.com:22/user/repo.git
38    url.insert_str(0, "ssh://");
39    url
40}
41
42#[cfg_attr(feature = "cargo-clippy", allow(clippy::large_enum_variant))]
43#[derive(Debug)]
44pub enum Parsed {
45    Help(String),
46    Version(&'static str),
47    OpenPage(Config),
48}
49
50fn is_scp_like_syntax_with_user(s: &str) -> bool {
51    // SCP-like syntax like user@project:project
52    // user@ cannot be omitted in SCP-like syntax because `s` can be a search query
53    //
54    // Note that checking it starts with "git@" does not work because SSH protocol for
55    // visualstudio.com is "mycompany@vs-ssh.visualstudio.com:v3/project.git"
56    if s.contains(char::is_whitespace) {
57        return false; // Containing whitespace means it's a search query
58    }
59    for (i, c) in s.char_indices() {
60        match c {
61            '@' if i == 0 => return false,          // Seems query like "@rhysd"
62            '@' => return s[i + 1..].contains(':'), // SCP-like syntax must also contain ':'
63            ':' => return false,                    // '@' must be put before ':'
64            _ => continue,
65        }
66    }
67    false
68}
69
70fn normalize_repo_format(mut slug: String, env: &EnvConfig) -> Result<String> {
71    if slug.is_empty() {
72        return Error::err(ErrorKind::BrokenRepoFormat { input: slug });
73    }
74
75    // - URL like https://server/project
76    // - SCP-like syntax user@project:project
77    //
78    // Note: user@ cannot be omitted in SCP-like syntax because `slug` can be search word
79    if slug.starts_with("https://")
80        || slug.starts_with("ssh://")
81        || slug.starts_with("http://")
82        || slug.starts_with("file://")
83        || is_scp_like_syntax_with_user(&slug)
84    {
85        if !slug.ends_with(".git") {
86            slug.push_str(".git");
87        }
88        return Ok(slug);
89    }
90
91    match slug.chars().filter(|c| *c == '/').count() {
92        1 => Ok(format!("https://github.com/{}.git", slug)),
93        2 => Ok(format!("https://{}.git", slug)),
94        0 => {
95            let client = Client::build("api.github.com", &env.github_token, &env.https_proxy)?;
96            async_runtime::blocking(client.most_popular_repo_by_name(&slug))
97                .map(|repo| repo.clone_url)
98        }
99        _ => Error::err(ErrorKind::BrokenRepoFormat { input: slug }),
100    }
101}
102
103fn get_cwd(specified: Option<String>) -> Result<PathBuf> {
104    if let Some(dir) = specified {
105        let p = fs::canonicalize(&dir)?;
106        if !p.exists() {
107            return Error::err(ErrorKind::SpecifiedDirNotExist { dir });
108        }
109        Ok(p)
110    } else {
111        Ok(env::current_dir()?.canonicalize()?)
112    }
113}
114
115const USAGE: &str = "\
116Usage: git brws [Options] {Args}
117
118  Open a repository, file, commit, diff or pull request, issue or project's
119  website in your web browser from command line.
120  GitHub, Bitbucket, GitLab, GitHub Enterprise, Azure DevOps are supported as
121  hosting service.
122  git-brws looks some environment variables for configuration. Please see
123  https://github.com/rhysd/git-brws#readme for more details.
124
125Examples:
126  - Current repository:
127
128    $ git brws
129
130  - GitHub repository:
131
132    $ git brws -r rhysd/git-brws
133
134  - Most popular GitHub repository by name:
135
136    $ git brws -r git-brws
137
138  - File:
139
140    $ git brws some/file.txt
141
142  - Commit:
143
144    $ git brws HEAD~3
145
146  - Tag:
147
148    $ git brws 0.10.0
149
150  - Diff between commits:
151
152    $ git brws HEAD~3..HEAD
153
154  - Diff between topic and topic's merge base commit:
155
156    $ git brws master...topic
157
158  - Line 123 of file:
159
160    $ git brws some/file.txt#L123
161
162  - Range from line 123 to line 126 of file:
163
164    $ git brws some/file.txt#L123-L126
165
166  - Pull request page (for GitHub and GitHub Enterprise):
167
168    $ git brws --pr
169
170  - Website of repository at current directory
171
172    $ git brws --website
173
174  - Website of other repository
175
176    $ git brws --website --repo react
177
178  - Issue page:
179
180    $ git brws '#8'";
181
182impl Parsed {
183    pub fn parse_iter<I>(argv: I) -> Result<Parsed>
184    where
185        I: IntoIterator,
186        I::Item: AsRef<OsStr>,
187    {
188        let mut opts = Options::new();
189
190        opts.optopt("r", "repo", "Shorthand format (repo, user/repo, host/user/repo) or Git URL you want to see. When only repo name is specified, most popular repository will be searched from GitHub", "REPO");
191        opts.optopt("b", "branch", "Branch name to browse", "BRANCH");
192        opts.optopt(
193            "d",
194            "dir",
195            "Directory path to the repository. Default value is current working directory",
196            "PATH",
197        );
198        opts.optopt("R", "remote", "Remote name (e.g. origin). Default value is a remote the current branch is tracking. If current branch tracks no branch, it falls back to 'origin'", "REMOTE");
199        opts.optflag(
200            "u",
201            "url",
202            "Output URL to stdout instead of opening in browser",
203        );
204        opts.optflag(
205            "p",
206            "pr",
207            "Open pull request page instead of repository page. If not existing, open 'Create Pull Request' page",
208        );
209        opts.optflag(
210            "w",
211            "website",
212            "Open website page instead of repository page (homepage URL for GitHub, GitLab pages, Bitbucket Cloud)",
213        );
214        opts.optflag(
215            "B",
216            "blame",
217            "Open blame page instead of repository page. File path to blame must be passed also.",
218        );
219        opts.optflag(
220            "c",
221            "current-branch",
222            "Open the current branch instead of default branch",
223        );
224        opts.optflag("h", "help", "Print this help");
225        opts.optflag("v", "version", "Show version");
226
227        let matches = opts.parse(argv.into_iter().skip(1))?;
228
229        if matches.opt_present("h") {
230            return Ok(Parsed::Help(opts.usage(USAGE)));
231        }
232
233        if matches.opt_present("v") {
234            return Ok(Parsed::Version(
235                option_env!("CARGO_PKG_VERSION").unwrap_or("unknown"),
236            ));
237        }
238
239        let env = EnvConfig::from_iter(env::vars())?.with_global_env();
240        let cwd = get_cwd(matches.opt_str("d"))?;
241        let git = Git::new(&cwd, &env.git_command);
242        let branch = if let Some(b) = matches.opt_str("b") {
243            if b.is_empty() {
244                return Error::err(ErrorKind::BranchNameEmpty);
245            }
246            Some(b)
247        } else if matches.opt_present("c") {
248            Some(git.current_branch()?)
249        } else {
250            None
251        };
252        let (repo_url, remote) = match (matches.opt_str("r"), matches.opt_str("R")) {
253            (Some(repo), remote) => {
254                if !matches.free.is_empty() {
255                    return Error::err(ErrorKind::ArgsNotAllowed {
256                        flag: "--repo {repo}",
257                        args: matches.free,
258                    });
259                }
260                (normalize_repo_format(repo, &env)?, remote)
261            }
262            (None, remote) => {
263                let (url, remote) = if let Some(remote) = remote {
264                    (git.remote_url(&remote)?, remote)
265                } else {
266                    git.tracking_remote_url(&branch)?
267                };
268                (url, Some(remote))
269            }
270        };
271
272        let repo_url = handle_scp_like_syntax(repo_url);
273
274        Ok(Parsed::OpenPage(Config {
275            repo_url,
276            branch,
277            cwd,
278            stdout: matches.opt_present("u"),
279            pull_request: matches.opt_present("p"),
280            website: matches.opt_present("w"),
281            blame: matches.opt_present("B"),
282            args: matches.free,
283            remote,
284            env,
285        }))
286    }
287}