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}