1use std::path::Path;
2
3use anyhow::{Context, Result, bail};
4
5use crate::git::{RangeSpec, Side, git};
6use crate::pr::{PrLookup, PullRequest, parse_pr_url, pr_number, resolve};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Target {
11 pub spec: RangeSpec,
12 pub label: String,
13 pub pr: Option<PullRequest>,
15}
16
17impl Target {
18 pub fn review_key(&self) -> String {
20 match &self.pr {
21 Some(pr) => pr.review_key(),
22 None => self.label.clone(),
23 }
24 }
25}
26
27pub fn resolve_target(
38 dir: &Path,
39 positionals: &[String],
40 staged: bool,
41 base: Option<&str>,
42 lookup: &dyn PrLookup,
43) -> Result<Target> {
44 let pr = |target: &str| -> Result<Target> {
45 if staged {
46 bail!("--staged cannot be combined with a pull request");
47 }
48 let label = match pr_number(target) {
49 Some(number) => format!("PR {number}"),
50 None => format!("PR {target}"),
51 };
52 let (spec, pr) = resolve(dir, target, lookup)?;
53 Ok(Target {
54 spec,
55 label,
56 pr: Some(pr),
57 })
58 };
59 let worktree = if staged { "index" } else { "working tree" };
60
61 match positionals {
62 [command, target] if command == "pr" => pr(target),
63 [command] if command == "pr" => bail!("`pr` needs a pull-request URL or number"),
64 [url] if parse_pr_url(url).is_some() => pr(url),
65 [] => {
66 let branch = current_branch(dir);
67 let base = match base {
68 Some(base) => Some(base.to_string()),
69 None => default_branch(dir).filter(|main| {
70 branch
71 .as_deref()
72 .is_some_and(|branch| short_name(main) != branch)
73 }),
74 };
75 match base {
76 Some(base) => current_branch_against(dir, branch.as_deref(), &base, staged),
77 None => Ok(Target {
78 spec: RangeSpec::parse(None, staged)?,
79 label: format!("HEAD → {worktree}"),
80 pr: None,
81 }),
82 }
83 }
84 [range] if range.contains("..") => Ok(Target {
85 spec: RangeSpec::parse(Some(range), staged)?,
86 label: range.clone(),
87 pr: None,
88 }),
89 [name] => match branch(dir, name)? {
90 Some(branch) => {
91 let base = match base {
92 Some(base) => base.to_string(),
93 None => default_branch(dir)
94 .context("can't tell which branch is the main one; pass it with --base")?,
95 };
96 if short_name(&branch) == short_name(&base) {
98 return Ok(Target {
99 spec: RangeSpec::parse(Some(name), staged)?,
100 label: format!("{name} → {worktree}"),
101 pr: None,
102 });
103 }
104 branch_against(dir, &branch, &base, staged)
105 }
106 None => Ok(Target {
107 spec: RangeSpec::parse(Some(name), staged)?,
108 label: format!("{name} → {worktree}"),
109 pr: None,
110 }),
111 },
112 _ => bail!("give one branch, revision, range or PR"),
113 }
114}
115
116fn current_branch_against(
119 dir: &Path,
120 branch: Option<&str>,
121 base: &str,
122 staged: bool,
123) -> Result<Target> {
124 if !exists(dir, &format!("{base}^{{commit}}")) {
125 bail!("no branch or revision named `{base}` to compare against");
126 }
127 let (side, uncommitted) = if staged {
128 (Side::Index, "staged")
129 } else {
130 (Side::Worktree, "uncommitted")
131 };
132 Ok(Target {
133 spec: RangeSpec {
134 old: base.to_string(),
135 new: side,
136 merge_base: true,
137 },
138 label: format!("{base}...{} + {uncommitted}", branch.unwrap_or("HEAD")),
139 pr: None,
140 })
141}
142
143fn branch_against(dir: &Path, branch: &str, base: &str, staged: bool) -> Result<Target> {
144 if staged {
145 bail!("--staged compares against the index, so it takes a revision, not a branch");
146 }
147 if !exists(dir, &format!("{base}^{{commit}}")) {
148 bail!("no branch or revision named `{base}` to compare against");
149 }
150 Ok(Target {
151 spec: RangeSpec {
152 old: base.to_string(),
153 new: Side::Rev(branch.to_string()),
154 merge_base: true,
155 },
156 label: format!("{base}...{branch}"),
157 pr: None,
158 })
159}
160
161fn branch(dir: &Path, name: &str) -> Result<Option<String>> {
164 let has_ref = |full: &str| git(dir, &["show-ref", "--verify", "--quiet", full]).is_ok();
165 if has_ref(&format!("refs/heads/{name}")) {
166 return Ok(Some(name.to_string()));
167 }
168 if has_ref(&format!("refs/remotes/origin/{name}")) {
169 return Ok(Some(format!("origin/{name}")));
170 }
171 if has_ref(&format!("refs/remotes/{name}")) {
172 return Ok(Some(name.to_string()));
173 }
174 if exists(dir, &format!("{name}^{{commit}}")) {
175 return Ok(None);
176 }
177 let is_branch_name = git(dir, &["check-ref-format", "--branch", name]).is_ok();
178 let fetched = is_branch_name
179 && git(
180 dir,
181 &[
182 "fetch",
183 "--quiet",
184 "--no-tags",
185 "origin",
186 &format!("+refs/heads/{name}:refs/remotes/origin/{name}"),
187 ],
188 )
189 .is_ok();
190 if fetched {
191 return Ok(Some(format!("origin/{name}")));
192 }
193 bail!("no branch or revision named `{name}`, here or on origin")
194}
195
196pub fn default_branch(dir: &Path) -> Option<String> {
199 if let Ok(out) = git(
200 dir,
201 &["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
202 ) && let Ok(full) = String::from_utf8(out)
203 && let Some(short) = full.trim().strip_prefix("refs/remotes/")
204 {
205 return Some(short.to_string());
206 }
207 ["origin/main", "origin/master", "main", "master"]
208 .into_iter()
209 .find(|name| exists(dir, &format!("{name}^{{commit}}")))
210 .map(str::to_string)
211}
212
213pub fn current_branch(dir: &Path) -> Option<String> {
215 let out = git(dir, &["symbolic-ref", "--quiet", "--short", "HEAD"]).ok()?;
216 Some(String::from_utf8(out).ok()?.trim().to_string())
217}
218
219fn exists(dir: &Path, revision: &str) -> bool {
220 git(dir, &["rev-parse", "--verify", "--quiet", revision]).is_ok()
221}
222
223fn short_name(branch: &str) -> &str {
224 branch.strip_prefix("origin/").unwrap_or(branch)
225}