From c4b864f38f0e7bd73467b4659d7f9e510d7edb34 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 17 Jun 2020 17:07:32 +0530 Subject: [PATCH] autorebase --- Cargo.lock | 137 +++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 + README.md | 66 ++++++--------------- src/api/search.rs | 7 ++- src/git.rs | 145 +++++++++++++++++++++++++++++++++++++++++++--- src/graph.rs | 1 + src/main.rs | 12 ++++ 7 files changed, 312 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 08d76fa..f4d0e1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 4f645ec..450a2c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" \ No newline at end of file diff --git a/README.md b/README.md index c9c2eb6..5dc2ab4 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file diff --git a/src/api/search.rs b/src/api/search.rs index 45b442b..c673d68 100644 --- a/src/api/search.rs +++ b/src/api/search.rs @@ -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 { diff --git a/src/git.rs b/src/git.rs index 561d3d5..dc52795 100644 --- a/src/git.rs +++ b/src/git.rs @@ -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::::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> { + let deps = deps + .iter() + .filter(|(dep, _)| *dep.state() == PullRequestStatus::Open) + .collect::>(); + + 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/ is the same SHA as + 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(()) +} \ No newline at end of file diff --git a/src/graph.rs b/src/graph.rs index b205c4e..0116f8e 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -25,6 +25,7 @@ pub fn build(prs: &[Rc]) -> Graph, 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, usize>) -> FlatDep { let roots: Vec<_> = graph.externals(Direction::Incoming).collect(); let mut out = Vec::new(); diff --git a/src/main.rs b/src/main.rs index 488651f..6e0744b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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> { 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> { - [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 */ }