grit/command/
update.rs

1use std::{collections::BTreeSet, env, ffi};
2
3use crate::{
4    error::{Error, Result},
5    git::{self, HEAD, git},
6    trunk,
7};
8
9/// Policy for which branches to delete.
10struct Args {
11    /// Specifies removal of local branches merged to local trunk.  Note that
12    /// this does not include GitHub "squash merges," which do not actually
13    /// merge the original branch.
14    merged: bool,
15
16    /// Specifies removal of local branches whose upstreams are gone.  Upstream
17    /// branches are often deleted after being merged to trunk, even if they
18    /// were "squash merged" on GitHub, so this is a useful way to detect such
19    /// "merges."
20    gone: bool,
21}
22
23impl Args {
24    /// Returns the name of this program for use in error logs, and the branch
25    /// removal policy.
26    fn new(args: impl IntoIterator<Item = ffi::OsString>) -> Result<Self> {
27        if let Some(arg) = args.into_iter().next() {
28            return Err(Error::Arg(arg));
29        }
30
31        // TODO: Set branch removal policy from CLI.
32        let args = Args {
33            merged: true,
34            gone: true,
35        };
36
37        Ok(args)
38    }
39}
40
41async fn is_working_copy_clean() -> Result<bool> {
42    Ok(git(["status", "--porcelain"]).await?.is_empty())
43}
44
45/// Splits the specified Git output into lines, excludes any line beginning with
46/// "*", and trims leading whitespace from each line.  Note that the output is
47/// not necessarily a list of simple branch names; e.g., if the output is from
48/// `git branch --verbose`.
49fn trim_branches(stdout: &str) -> impl Iterator<Item = &str> {
50    stdout
51        .lines()
52        .filter(|line| !line.starts_with("* "))
53        .map(str::trim_ascii_start)
54}
55
56/// Pulls the main branch of the repo from which it's called, then deletes any
57/// local branches that are not ahead of trunk, and finally checks back out the
58/// original branch. The trunk branch is the first existing branch returned by
59/// [`trunk::names()`].
60///
61/// # Errors
62///
63/// Returns an error if no trunk can be identified, or any `git` command fails.
64///
65/// # TODO
66///
67/// * [ ] Delete remote branches behind trunk.
68/// * [ ] Don't mess up "checkout -" by checking out main.  I tried _not_
69///   checking out main, but after fetch --prune, the user still sees a list
70///   of obsolete branches the next time they pull --prune; so now this program
71///   checks out main just so it can run pull --prune per se.
72pub async fn update(args: env::ArgsOs) -> Result<()> {
73    let rm = Args::new(args)?; // Branch removal policy.
74
75    if !is_working_copy_clean().await? {
76        return Err(Error::Unclean);
77    }
78
79    let orig = git(["rev-parse", "--abbrev-ref", HEAD]).await?;
80    let orig = orig.as_str().trim();
81    let trunk = trunk::local().await?;
82    if trunk != orig {
83        git(["checkout", &trunk]).await?;
84    }
85
86    if git::upstream(&trunk).await.is_some() {
87        match git(["pull", "--prune"]).await.as_deref() {
88            Ok("Already up to date.\n") => (),
89            Ok(out) => print!("{out}"),
90            Err(err) => eprintln!("warning: can't pull {trunk}: {err}"),
91        }
92    }
93
94    let mut dead_branches = BTreeSet::<String>::new();
95    if rm.merged {
96        dead_branches.extend(trim_branches(&git(["branch", "--merged"]).await?).map(str::to_owned));
97    }
98
99    if rm.gone {
100        dead_branches.extend(
101            trim_branches(&git(["branch", "--list", "--verbose"]).await?)
102                .filter(|line| line.contains("[gone]"))
103                .filter_map(|line| line.split_ascii_whitespace().next())
104                .map(str::to_owned),
105        );
106    }
107
108    // Don't delete potential trunks, even if they're behind the actual trunk.  When
109    // you have an integration branch (dev or staging or whatever) that's ahead
110    // of main, you want to be able to use that branch as trunk, without
111    // deleting main simply because it's behind staging.
112    for trunk in trunk::names() {
113        dead_branches.remove(&trunk);
114    }
115
116    if dead_branches.contains(orig) {
117        // Let the user know we're not leaving HEAD on the original branch.
118        println!("co {trunk}");
119    } else if trunk != orig {
120        git(["checkout", orig]).await?;
121    }
122
123    if !dead_branches.is_empty() {
124        for zombie in &dead_branches {
125            println!("rm {zombie}");
126        }
127        git(["branch", "-D"]
128            .into_iter()
129            .chain(dead_branches.iter().map(String::as_str)))
130        .await?;
131    }
132
133    Ok(())
134}