Skip to main content

declutter/
target.rs

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/// What to review, and how to say it in the viewer.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Target {
11    pub spec: RangeSpec,
12    pub label: String,
13    /// The pull request, when the target is one.
14    pub pr: Option<PullRequest>,
15}
16
17impl Target {
18    /// Names this review, for scoping notes to it.
19    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
27/// Works out what the command-line arguments ask to review:
28///
29/// - nothing: on a branch other than the main one, everything that branch changes —
30///   its commits since it split off from the main branch (or `base`), plus uncommitted
31///   and untracked work; on the main branch, just the uncommitted work;
32/// - a GitHub or Azure DevOps PR URL, or `pr <url | number>`: that pull request;
33/// - a branch: that branch against the default branch (`base` overrides it), from the
34///   point where it branched off — a branch only on `origin` is fetched first;
35/// - `a..b` / `a...b`: as in `git diff`;
36/// - any other revision: that revision against the working tree.
37pub 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                // The main branch itself: there is nothing to compare it with but your work.
97                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
116/// The current branch's commits since it split off from `base`, plus what is not
117/// committed yet (staged only, with `staged`).
118fn 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
161/// The branch `name` refers to — local first, then on `origin` — fetching it from
162/// `origin` when it is nowhere else. `None` when `name` is a revision but not a branch.
163fn 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
196/// The branch reviews are compared with: what `origin/HEAD` points at, else the first
197/// of `origin/main`, `origin/master`, `main`, `master` that exists.
198pub 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
213/// The branch HEAD is on, if any.
214pub 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}