autorebase

This commit is contained in:
Timothy Andrew 2020-06-17 17:07:32 +05:30
parent b5462a9751
commit c4b864f38f
No known key found for this signature in database
GPG Key ID: ABD64509E977B249
7 changed files with 312 additions and 58 deletions

137
Cargo.lock generated
View File

@ -50,6 +50,9 @@ name = "cc"
version = "1.0.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bbb73db36c1246e9034e307d0fba23f9a2e251faa47ade70c1bd252220c8311"
dependencies = [
"jobserver",
]
[[package]]
name = "cfg-if"
@ -57,6 +60,23 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
[[package]]
name = "console"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c0994e656bba7b922d8dd1245db90672ffb701e684e45be58f20719d69abc5a"
dependencies = [
"encode_unicode",
"lazy_static",
"libc",
"regex",
"terminal_size",
"termios",
"unicode-width",
"winapi 0.3.8",
"winapi-util",
]
[[package]]
name = "core-foundation"
version = "0.7.0"
@ -73,12 +93,29 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac"
[[package]]
name = "dialoguer"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4aa86af7b19b40ef9cbef761ed411a49f0afa06b7b6dcd3dfe2f96a3c546138"
dependencies = [
"console",
"lazy_static",
"tempfile",
]
[[package]]
name = "dtoa"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4358a9e11b9a09cf52383b451b49a169e8d797b68aa02301ff586d70d9661ea3"
[[package]]
name = "encode_unicode"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
[[package]]
name = "encoding_rs"
version = "0.8.23"
@ -241,7 +278,9 @@ dependencies = [
name = "gh-stack"
version = "0.1.0"
dependencies = [
"dialoguer",
"futures",
"git2",
"petgraph",
"regex",
"reqwest",
@ -249,6 +288,21 @@ dependencies = [
"tokio",
]
[[package]]
name = "git2"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11e4b2082980e751c4bf4273e9cbb4a02c655729c8ee8a79f66cad03c8f4d31e"
dependencies = [
"bitflags",
"libc",
"libgit2-sys",
"log",
"openssl-probe",
"openssl-sys",
"url",
]
[[package]]
name = "h2"
version = "0.2.5"
@ -376,6 +430,15 @@ version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8b7a7c0c47db5545ed3fef7468ee7bb5b74691498139e4b3f6a20685dc6dd8e"
[[package]]
name = "jobserver"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c71313ebb9439f74b00d9d2dcec36440beaf57a6aa0623068441dd7cd81a7f2"
dependencies = [
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.40"
@ -407,6 +470,46 @@ version = "0.2.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9457b06509d27052635f90d6466700c65095fdf75409b3fbdd903e988b886f49"
[[package]]
name = "libgit2-sys"
version = "0.12.7+1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcd07968649bcb7b9351ecfde53ca4d27673cccfdf57c84255ec18710f3153e0"
dependencies = [
"cc",
"libc",
"libssh2-sys",
"libz-sys",
"openssl-sys",
"pkg-config",
]
[[package]]
name = "libssh2-sys"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45f516b9b19ea6c940b9f36d36734062a153a2b4cc9ef31d82c54bb9780f525"
dependencies = [
"cc",
"libc",
"libz-sys",
"openssl-sys",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libz-sys"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eb5e43362e38e2bca2fd5f5134c4d4564a23a5c28e9b95411652021a8675ebe"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "log"
version = "0.4.8"
@ -926,6 +1029,25 @@ dependencies = [
"winapi 0.3.8",
]
[[package]]
name = "terminal_size"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8038f95fc7a6f351163f4b964af631bd26c9e828f7db085f2a84aca56f70d13b"
dependencies = [
"libc",
"winapi 0.3.8",
]
[[package]]
name = "termios"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f0fcee7b24a25675de40d5bb4de6e41b0df07bc9856295e7e2b3a3600c400c2"
dependencies = [
"libc",
]
[[package]]
name = "thread_local"
version = "1.0.1"
@ -1043,6 +1165,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "unicode-width"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caaa9d531767d1ff2150b9332433f32a24622147e5ebb1f26409d5da67afd479"
[[package]]
name = "unicode-xid"
version = "0.2.0"
@ -1194,6 +1322,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
dependencies = [
"winapi 0.3.8",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"

View File

@ -19,3 +19,5 @@ serde = { version = "1.0", features = ["derive"] }
futures = "0.3.5"
petgraph = "0.5"
regex = "1"
git2 = "0.13"
dialoguer = "0.6.2"

View File

@ -2,8 +2,6 @@
- [Usage](#usage)
- [Strategy](#strategy)
- [Feature Changes](#feature-changes)
- [Feature Complete & Merged](#feature-complete--merged)
- [Disclaimer](#disclaimer)
---
@ -14,17 +12,16 @@ I use this tool to help managed stacked pull requests on Github, which are notor
- https://stackoverflow.com/questions/26619478/are-dependent-pull-requests-in-github-possible
- https://gist.github.com/Jlevyd15/66743bab4982838932dda4f13f2bd02a
This tool assumes that all PRs in a single "stack" all have a unique identifier in their title (I typically use a Jira ticket number for this). It then looks for all PRs containing this containing this identifier and builds a dependency graph in memory. This can technically support a "branched stack" instead of a single chain, but I haven't really tried the latter style. Note that the `gh-stack rebase` command will definitely _not_ work with the branched style.
This tool assumes that:
- All PRs in a single "stack" all have a unique identifier in their title (I typically use a Jira ticket number for this). It then looks for all PRs containing this containing this identifier and builds a dependency graph in memory. This can technically support a "branched stack" instead of a single chain, but I haven't really tried the latter style.
- All remote branches that these PRs represent have local branches named identically.
With this graph built up, the tool can:
- Add a markdown table to the PR description (idempotently) of each PR in the stack describing _all_ PRs in the stack.
- Log a simple list of all PRs in the stack (+ dependencies) to stdout.
- Emit a bash script that can update all PRs in the stack.
- This generally happens in the event of:
- The PR at the base of the stack is merged, leaving all the remaining PRs in a conflicted state.
- One of the PRs (not the top of the stack) has a commit added to it, leaving all dependent PRs in a conflicted state.
- The script requires two placeholders to be manually specified before execution.
- Automatically update the stack + push after making local changes (this handles conflicts as well).
## Usage
@ -42,52 +39,27 @@ $ gh-stack github 'stack-identifier' filename.txt
# Print a description of the stack to stdout.
$ gh-stack log 'stack-identifier'
# Automatically update the entire stack, both locally and remotely.
# WARNING: This operation modifies local branches and force-pushes.
$ gh-stack autorebase 'stack-identifier' /path/to/repo
# Emit a bash script that can update a stack in the case of conflicts.
# WARNING: This script could potentially cause destructive behavior.
$ gh-stack rebase 'stack-identifier'
```
## Strategy
Here's a quick summary of the strategy that the bash script described above uses to keep the stack up-to-date.
This is a quick summary of the strategy the `autorebase` subcommand uses:
Let's use this stack as an example:
![](img/initial.png)
### Feature Changes
In the first case, let's assume that "feature part 1" had some changes added to it in the form of a commit; this leaves parts 2 & 3 in a conflicted state:
![](img/feature-1.png)
The script requires that you pass in a `PREBASE` ref (which is essentially the boundary for the feature part you're operating on - in this case the _parent of the_ last/oldest commit in feature-part-2).
The script starts cherry-picking commits at this ref for the first iteration. An initial `TO` ref is also required, which is the point upon which you want to rebase the rest of the stack. In this case, that ref is `remote/feature-part-1`).
The script executes a single step, we now have this intermediate state:
![](img/feature-2.png)
The script completes execution, and we now have this final state with the entire stack updated/recreated:
![](img/feature-3.png)
### Feature Complete & Merged
In the second case, let's assume that "feature part 1" is done and has been merged to `develop`:
![](/img/complete-1.png)
This immediately leaves feature parts 2 & 3 in a conflicted state. The script can fix this situation as well.
As before, pass a `PREBASE` (in this case _the parent of the_ oldest commit in feature part 2) and an initial `TO` ref to rebase on (in this case `remote/develop`).
Once the script executes a single step, we're left with:
![](/img/complete-2.png)
And once the script is done:
![](img/complete-3.png)
1. Find the `merge_base` between the local branch of the first PR in the stack and the branch it merges into (usually `develop`). This forms the boundary for the initial cherry-pick.
2. Check out the commit/ref that the first PR in the stack merges into (usually `develop`). We're going to cherry-pick the entire stack onto this commit.
3. Cherry-pick all commits from the first PR (stopping at the cherry-pick boundary calculated in 1.) onto `HEAD`.
4. Move the _local_ branch for the first PR onto `HEAD`.
5. The _remote_ branch for the first PR becomes the next cherry-pick boundary.
6. Repeat steps 3-5 until all PRs have been cherry-picked over.
7. Push all refs at once by passing multiple refspecs to a single invocation of `git push -f`.
## Disclaimer
@ -95,4 +67,4 @@ Use at your own risk (and make sure your git repository is backed up), especiall
- This tool works for my specific use case, but has _not_ been extensively tested.
- I've been writing Rust for all of 3 weeks at this point.
- The script that `gh-stack rebase` emits attempts to force-push when executed.
- The `autorebase` command is in an experimental state; there are possibly edge cases I haven't considered.

View File

@ -13,7 +13,8 @@ pub struct SearchItem {
#[derive(Deserialize, Debug, Clone)]
pub struct PullRequestRef {
label: String,
r#ref: String,
#[serde(rename = "ref")]
gitref: String,
sha: String,
}
@ -39,11 +40,11 @@ pub struct PullRequest {
impl PullRequest {
pub fn head(&self) -> &str {
&self.head.label
&self.head.gitref
}
pub fn base(&self) -> &str {
&self.base.label
&self.base.gitref
}
pub fn url(&self) -> &str {

View File

@ -1,11 +1,28 @@
use crate::api::search::PullRequestStatus;
use crate::graph::FlatDep;
use std::error::Error;
use git2::{Cred, ObjectType, Repository, Index, Sort, CherrypickOptions, Remote, Commit, PushOptions, RemoteCallbacks};
use git2::build::CheckoutBuilder;
use tokio::process::Command;
use dialoguer::Input;
use std::env;
fn process_ref(git_ref: &str) -> String {
git_ref.replace("heap:", "")
fn remote_ref(remote: &str, git_ref: &str) -> String {
format!("{}/{}", remote, git_ref)
}
/// For all open pull requests in the graph, generate a series of commands
fn loop_until_confirm(prompt: &str) {
let prompt = format!("{} Type 'yes' to continue", prompt);
loop {
let result = Input::<String>::new().with_prompt(&prompt).interact().unwrap();
match &result[..] {
"yes" => return,
_ => continue
}
}
}
/// or all open pull requests in the graph, generate a series of commands
/// (force-pushes) that will rebase the entire stack. The "PREBASE" variable
/// is a base for the first branch in the stack (essentially a "stop cherry-picking
/// here" marker), and is required because of our squash-merge workflow.
@ -40,14 +57,126 @@ pub fn generate_rebase_script(deps: FlatDep) -> String {
out.push_str("\n# -------------- #\n\n");
out.push_str(&format!("export TO=\"{}\"\n", process_ref(&to)));
out.push_str(&format!("export FROM=\"{}\"\n\n", process_ref(from.head())));
out.push_str(&format!("export TO=\"{}\"\n", remote_ref("heap", &to)));
out.push_str(&format!("export FROM=\"{}\"\n\n", remote_ref("heap", from.head())));
out.push_str("git checkout heap/\"$TO\"\n");
out.push_str("git cherry-pick \"$PREBASE\"..heap/\"$FROM\"\n");
out.push_str("export PREBASE=\"$(git rev-parse --verify heap/$FROM)\"\n");
out.push_str("git checkout \"$TO\"\n");
out.push_str("git cherry-pick \"$PREBASE\"..\"$FROM\"\n");
out.push_str("export PREBASE=\"$(git rev-parse --verify $FROM)\"\n");
out.push_str("git push -f heap HEAD:refs/heads/\"$FROM\"\n");
}
out
}
pub async fn perform_rebase(deps: FlatDep, repo: &Repository, remote: &str) -> Result<(), Box<dyn Error>> {
let deps = deps
.iter()
.filter(|(dep, _)| *dep.state() == PullRequestStatus::Open)
.collect::<Vec<_>>();
let (pr, _) = deps[0];
let base = remote_ref(remote, pr.base());
let base = repo.revparse_single(&base).unwrap();
let base = base.as_commit().unwrap();
let head = pr.head();
let head = repo.revparse_single(&head).unwrap();
let head = head.as_commit().unwrap();
let mut stop_cherry_pick_at = repo.merge_base(base.id(), head.id()).unwrap();
println!("Checking out {:?}", base);
repo.checkout_tree(&base.as_object(), None).unwrap();
repo.set_head_detached(base.id()).unwrap();
let mut push_refspecs = vec![];
for (pr, _) in deps {
println!("Working on PR: {:?}", pr.head());
let from = repo.revparse_single(&pr.head()).unwrap();
let from = from.as_commit().unwrap();
let mut walk = repo.revwalk().unwrap();
walk.set_sorting(Sort::TOPOLOGICAL).unwrap();
walk.set_sorting(Sort::REVERSE).unwrap();
walk.push(from.id()).unwrap();
walk.hide(stop_cherry_pick_at).unwrap();
// TODO: Simplify by using rebase instead of cherry-pick
// TODO: Skip if remote/<branch> is the same SHA as <branch>
for from in walk {
let from = repo.find_commit(from.unwrap()).unwrap();
let to = repo.find_commit(repo.head().unwrap().target().unwrap()).unwrap();
if from.parent_count() > 1 {
panic!("Exiting: I don't know how to deal with merge commits correctly.");
}
let mut cb = CheckoutBuilder::new();
cb.allow_conflicts(true);
let mut opts = CherrypickOptions::new();
opts.checkout_builder(cb);
println!("Cherry-picking: {:?}", from);
repo.cherrypick(&from, Some(&mut opts)).unwrap();
let mut index = repo.index().unwrap();
if index.has_conflicts() {
let prompt = "Conflicts! Resolve manually and `git add` each one (don't run any `git cherry-pick` commands, though).";
loop_until_confirm(prompt);
index = repo.index().unwrap();
index.read(true).unwrap();
}
let tree = index.write_tree_to(&repo).unwrap();
let tree = repo.find_tree(tree).unwrap();
let signature = repo.signature().unwrap();
let commit = repo.commit(None, &signature, &signature, &from.message().unwrap(), &tree, &[&to]).unwrap();
let commit = repo.find_commit(commit).unwrap();
let mut cb = CheckoutBuilder::new();
cb.force();
repo.checkout_tree(&commit.as_object(), Some(&mut cb)).unwrap();
repo.set_head_detached(commit.id()).unwrap();
// "Complete" the cherry-pick. There is likely a better way to do
// this that I haven't found so far.
repo.cleanup_state().unwrap();
}
// Update local branch
let head = repo.head().unwrap().target().unwrap();
let head = repo.find_commit(head).unwrap();
repo.branch(pr.head(), &head, true).unwrap();
// Use remote branch as boundary for next cherry-pick
let from = repo.revparse_single(&remote_ref(remote, pr.head())).unwrap();
let from = from.as_commit().unwrap();
stop_cherry_pick_at = from.id();
push_refspecs.push(format!("refs/heads/{}:refs/heads/{}", pr.head(), pr.head()));
}
let repo_dir = repo.workdir().unwrap().to_str().unwrap();
// `libgit2` doesn't support refspecs containing raw SHAs, so we shell out
// to `git push` instead. https://github.com/libgit2/libgit2/issues/1125
let mut command = Command::new("git");
command.arg("push").arg("-f").arg(remote);
command.args(push_refspecs.as_slice());
command.current_dir(repo_dir);
println!("\n{:?}", push_refspecs);
loop_until_confirm("Going to push these refspecs ☝️ ");
command.spawn()?.await?;
Ok(())
}

View File

@ -25,6 +25,7 @@ pub fn build(prs: &[Rc<PullRequest>]) -> Graph<Rc<PullRequest>, usize> {
}
/// Return a flattened list of graph nodes as tuples; each tuple is `(node, node's parent [if exists])`.
/// TODO: Panic if this isn't a single flat list of dependencies
pub fn log(graph: &Graph<Rc<PullRequest>, usize>) -> FlatDep {
let roots: Vec<_> = graph.externals(Direction::Incoming).collect();
let mut out = Vec::new();

View File

@ -1,4 +1,5 @@
use std::collections::HashMap;
use git2::Repository;
use std::env;
use std::error::Error;
use std::fs;
@ -86,6 +87,15 @@ async fn main() -> Result<(), Box<dyn Error>> {
println!("{}", script);
}
"autorebase" => {
let deps = graph::log(&tree);
let repo = Repository::open(prelude.unwrap()).unwrap();
// TODO: Make this configurable
let remote = repo.find_remote("heap").unwrap();
git::perform_rebase(deps, &repo, remote.name().unwrap()).await?;
println!("All done!");
}
"log" => {
let log = graph::log(&tree);
for (pr, maybe_parent) in log {
@ -109,6 +119,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
- [x] Persist table back to Github
- [x] Accept a prelude via STDIN
- [x] Log a textual representation of the graph
- [x] Automate rebase
- [ ] Build status icons
- [ ] Panic on non-200s
*/
}