gh_stack/
git.rs

1use crate::api::search::PullRequestStatus;
2use crate::graph::FlatDep;
3use crate::util::loop_until_confirm;
4use git2::build::CheckoutBuilder;
5use git2::{
6    CherrypickOptions,
7    Repository, Sort,
8    Revwalk, Oid, Commit,
9    Index
10};
11
12use std::error::Error;
13use tokio::process::Command;
14
15fn remote_ref(remote: &str, git_ref: &str) -> String {
16    format!("{}/{}", remote, git_ref)
17}
18
19/// For all open pull requests in the graph, generate a series of commands
20/// (force-pushes) that will rebase the entire stack. The "PREBASE" variable
21/// is a base for the first branch in the stack (essentially a "stop cherry-picking
22/// here" marker), and is required because of our squash-merge workflow.
23/// TODO: Move this directly into Rust.
24pub fn generate_rebase_script(deps: FlatDep) -> String {
25    let deps = deps
26        .iter()
27        .filter(|(dep, _)| *dep.state() == PullRequestStatus::Open)
28        .collect::<Vec<_>>();
29
30    let mut out = String::new();
31
32    out.push_str("#!/usr/bin/env bash\n\n");
33    out.push_str("set -euo pipefail\n");
34    out.push_str("set -o xtrace\n\n");
35
36    out.push_str("# ------ THIS SCRIPT ASSUMES YOUR PR STACK IS A SINGLE CHAIN WITHOUT BRANCHING ----- #\n\n");
37    out.push_str("# It starts at the base of the stack, cherry-picking onto the new base and force-pushing as it goes.\n");
38    out.push_str("# We can't tell where the initial cherry-pick should stop (mainly because of our squash merge workflow),\n");
39    out.push_str(
40        "# so that initial stopping point for the first PR needs to be specified manually.\n\n",
41    );
42
43    out.push_str("export PREBASE=\"<enter a marker to stop the initial cherry-pick at>\"\n");
44
45    for (from, to) in deps {
46        let to = if let Some(pr) = to {
47            pr.head().to_string()
48        } else {
49            String::from("<enter a ref to rebase the stack on; usually `develop`>")
50        };
51
52        out.push_str("\n# -------------- #\n\n");
53
54        out.push_str(&format!("export TO=\"{}\"\n", remote_ref("heap", &to)));
55        out.push_str(&format!(
56            "export FROM=\"{}\"\n\n",
57            remote_ref("heap", from.head())
58        ));
59
60        out.push_str("git checkout \"$TO\"\n");
61        out.push_str("git cherry-pick \"$PREBASE\"..\"$FROM\"\n");
62        out.push_str("export PREBASE=\"$(git rev-parse --verify $FROM)\"\n");
63        out.push_str("git push -f heap HEAD:refs/heads/\"$FROM\"\n");
64    }
65
66    out
67}
68
69fn oid_to_commit(repo: &Repository, oid: Oid) -> Commit {
70    repo.find_commit(oid).unwrap()
71}
72
73fn head_commit(repo: &Repository) -> Commit {
74    repo.find_commit(repo.head().unwrap().target().unwrap()).unwrap()
75}
76
77fn checkout_commit(repo: &Repository, commit: &Commit, options: Option<&mut CheckoutBuilder>) {
78    repo.checkout_tree(&commit.as_object(), options).unwrap();
79    repo.set_head_detached(commit.id()).unwrap();
80}
81
82fn rev_to_commit<'a>(repo: &'a Repository, rev: &str) -> Commit<'a> {
83    let commit = repo.revparse_single(rev).unwrap();
84    let commit = commit.into_commit().unwrap();
85    commit
86}
87
88/// Commit and checkout `index`
89fn create_commit<'a>(repo: &'a Repository, index: &mut Index, message: &str) -> Commit<'a> {
90    let tree = index.write_tree_to(&repo).unwrap();
91    let tree = repo.find_tree(tree).unwrap();
92
93    let signature = repo.signature().unwrap();
94    let commit = repo
95        .commit(
96            None,
97            &signature,
98            &signature,
99            message,
100            &tree,
101            &[&head_commit(repo)]
102        )
103        .unwrap();
104
105    let commit = oid_to_commit(&repo, commit);
106
107    let mut cb = CheckoutBuilder::new();
108    cb.force();
109
110    checkout_commit(&repo, &commit, Some(&mut cb));
111
112    // "Complete" the cherry-pick. There is likely a better way to do
113    // this that I haven't found so far.
114    repo.cleanup_state().unwrap();
115
116    commit
117}
118
119fn cherry_pick_range(repo: &Repository, walk: &mut Revwalk) {
120    for from in walk {
121        let from = oid_to_commit(&repo, from.unwrap());
122
123        if from.parent_count() > 1 {
124            panic!("Exiting: I don't know how to deal with merge commits correctly.");
125        }
126
127        let mut cb = CheckoutBuilder::new();
128        cb.allow_conflicts(true);
129        let mut opts = CherrypickOptions::new();
130        opts.checkout_builder(cb);
131
132        println!("Cherry-picking: {:?}", from);
133        repo.cherrypick(&from, Some(&mut opts)).unwrap();
134
135        let mut index = repo.index().unwrap();
136
137        if index.has_conflicts() {
138            let prompt = "Conflicts! Resolve manually and `git add` each one (don't run any `git cherry-pick` commands, though).";
139            loop_until_confirm(prompt);
140
141            // Reload index from disk
142            index = repo.index().unwrap();
143            index.read(true).unwrap();
144        }
145
146        create_commit(&repo, &mut index, from.message().unwrap());
147    }
148}
149
150pub async fn perform_rebase(
151    deps: FlatDep,
152    repo: &Repository,
153    remote: &str,
154    boundary: Option<&str>
155) -> Result<(), Box<dyn Error>> {
156    let deps = deps
157        .iter()
158        .filter(|(dep, _)| *dep.state() == PullRequestStatus::Open)
159        .collect::<Vec<_>>();
160
161    let (pr, _) = deps[0];
162
163    let base = rev_to_commit(&repo, &remote_ref(remote, pr.base()));
164    let head = rev_to_commit(&repo, pr.head());
165
166    let mut stop_cherry_pick_at = match boundary {
167        Some(rev) => rev_to_commit(&repo, rev).id(),
168        None => repo.merge_base(base.id(), head.id()).unwrap()
169    };
170    let mut update_local_branches_to = vec![];
171
172    println!("Checking out {:?}", base);
173    checkout_commit(&repo, &base, None);
174
175    let mut push_refspecs = vec![];
176
177    for (pr, _) in deps {
178        println!("\nWorking on PR: {:?}", pr.head());
179
180        let from = rev_to_commit(&repo, pr.head());
181
182        let mut walk = repo.revwalk().unwrap();
183        walk.set_sorting(Sort::TOPOLOGICAL).unwrap();
184        walk.set_sorting(Sort::REVERSE).unwrap();
185        walk.push(from.id()).unwrap();
186        walk.hide(stop_cherry_pick_at).unwrap();
187
188        // TODO: Simplify by using rebase instead of cherry-pick
189        // TODO: Skip if remote/<branch> is the same SHA as <branch> (only until the first cherry-pick)
190        cherry_pick_range(&repo, &mut walk);
191
192        // Record the commit (in the new stack) that the local branch should now point to.
193        // Actually perform the switch later on in a batch so we don't leave the repo in
194        // a troubled state if this process is interrupted.
195        update_local_branches_to.push((pr.head(), head_commit(&repo)));
196
197        // Use remote branch as boundary for the next cherry-pick
198        let from = rev_to_commit(&repo, &remote_ref(remote, pr.head()));
199        stop_cherry_pick_at = from.id();
200
201        push_refspecs.push(format!("{}:refs/heads/{}", head_commit(&repo).id(), pr.head()));
202    }
203
204    let repo_dir = repo.workdir().unwrap().to_str().unwrap();
205
206    // `libgit2` doesn't support refspecs containing raw SHAs, so we shell out
207    // to `git push` instead. https://github.com/libgit2/libgit2/issues/1125
208    let mut command = Command::new("git");
209    command.arg("push").arg("-f").arg(remote);
210    command.args(push_refspecs.as_slice());
211    command.current_dir(repo_dir);
212
213    println!("\n{:?}", push_refspecs);
214    loop_until_confirm("Going to push these refspecs ☝️ ");
215
216    command.spawn()?.await?;
217
218    println!("\nUpdating local branches so they point to the new stack.\n");
219    for (branch, target) in update_local_branches_to {
220        println!("  + Branch {} now points to {}", branch, target.id());
221        repo.branch(branch, &target, true).unwrap();
222    }
223
224    Ok(())
225}