From 717b0d3a74d22ffa3a5ef7622e9b45526cc140b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Wed, 29 Dec 2021 11:19:00 +0100 Subject: [PATCH] Add fetch & pull option to worktrees --- docs/src/worktrees.md | 33 +++++++ e2e_tests/helpers.py | 1 + e2e_tests/test_worktree_fetch.py | 144 +++++++++++++++++++++++++++ src/grm/cmd.rs | 16 +++ src/grm/main.rs | 52 ++++++++++ src/repo.rs | 161 ++++++++++++++++++++++++++----- 6 files changed, 385 insertions(+), 22 deletions(-) create mode 100644 e2e_tests/test_worktree_fetch.py diff --git a/docs/src/worktrees.md b/docs/src/worktrees.md index baa1f4b..5c2daff 100644 --- a/docs/src/worktrees.md +++ b/docs/src/worktrees.md @@ -279,6 +279,39 @@ Commit them and try again! Afterwards, the directory is empty, as there are no worktrees checked out yet. Now you can use the usual commands to set up worktrees. +### Working with remotes + +To fetch all remote references from all remotes in a worktree setup, you can +use the following command: + +``` +grm wt fetch +[✔] Fetched from all remotes +``` + +This is equivalent to running `git fetch --all` in any of the worktrees. + +Often, you may want to pull all remote changes into your worktrees. For this, +use the `git pull` equivalent: + +``` +grm wt pull +[✔] master: Done +[✔] my-cool-branch: Done +``` + +This will refuse when there are local changes, or if the branch cannot be fast +forwarded. If you want to rebase your local branches, use the `--rebase` switch: + +``` +grm wt pull --rebase +[✔] master: Done +[✔] my-cool-branch: Done +``` + +This will rebase your changes onto the upstream branch. This is mainly helpful +for persistent branches that change on the remote side. + ### Manual access GRM isn't doing any magic, it's just git under the hood. If you need to have access diff --git a/e2e_tests/helpers.py b/e2e_tests/helpers.py index 7ed8137..c4caa42 100644 --- a/e2e_tests/helpers.py +++ b/e2e_tests/helpers.py @@ -169,6 +169,7 @@ class TempGitRepositoryWorktree: git commit -m "commit2" git remote add origin file://{self.remote_1_dir.name} git remote add otherremote file://{self.remote_2_dir.name} + git push origin HEAD:master git ls-files | xargs rm -rf mv .git .git-main-working-tree git --git-dir .git-main-working-tree config core.bare true diff --git a/e2e_tests/test_worktree_fetch.py b/e2e_tests/test_worktree_fetch.py new file mode 100644 index 0000000..1c165a9 --- /dev/null +++ b/e2e_tests/test_worktree_fetch.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 + +from helpers import * + +import pytest +import git + + +def test_worktree_fetch(): + with TempGitRepositoryWorktree() as (base_dir, root_commit): + with TempGitFileRemote() as (remote_path, _remote_sha): + shell( + f""" + cd {base_dir} + git --git-dir .git-main-working-tree remote add upstream file://{remote_path} + git --git-dir .git-main-working-tree push --force upstream master:master + """ + ) + + cmd = grm(["wt", "fetch"], cwd=base_dir) + assert cmd.returncode == 0 + + repo = git.Repo(f"{base_dir}/.git-main-working-tree") + assert repo.commit("master").hexsha == repo.commit("origin/master").hexsha + assert repo.commit("master").hexsha == repo.commit("upstream/master").hexsha + + with EmptyDir() as tmp: + shell( + f""" + cd {tmp} + git clone {remote_path} tmp + cd tmp + echo change > mychange-remote + git add mychange-remote + git commit -m "change-remote" + git push origin HEAD:master + """ + ) + remote_commit = git.Repo(f"{tmp}/tmp").commit("master").hexsha + + assert repo.commit("master").hexsha == repo.commit("origin/master").hexsha + assert repo.commit("master").hexsha == repo.commit("upstream/master").hexsha + + cmd = grm(["wt", "fetch"], cwd=base_dir) + assert cmd.returncode == 0 + + assert repo.commit("master").hexsha == repo.commit("origin/master").hexsha + assert repo.commit("master").hexsha == root_commit + assert repo.commit("upstream/master").hexsha == remote_commit + + +@pytest.mark.parametrize("rebase", [True, False]) +@pytest.mark.parametrize("ffable", [True, False]) +def test_worktree_pull(rebase, ffable): + with TempGitRepositoryWorktree() as (base_dir, root_commit): + with TempGitFileRemote() as (remote_path, _remote_sha): + shell( + f""" + cd {base_dir} + git --git-dir .git-main-working-tree remote add upstream file://{remote_path} + git --git-dir .git-main-working-tree push --force upstream master:master + """ + ) + + repo = git.Repo(f"{base_dir}/.git-main-working-tree") + assert repo.commit("origin/master").hexsha == repo.commit("master").hexsha + assert repo.commit("upstream/master").hexsha == repo.commit("master").hexsha + + with EmptyDir() as tmp: + shell( + f""" + cd {tmp} + git clone {remote_path} tmp + cd tmp + git checkout origin/master + echo change > mychange-remote + git add mychange-remote + git commit -m "change-remote" + git push origin HEAD:master + """ + ) + remote_commit = git.Repo(f"{tmp}/tmp").commit("HEAD").hexsha + + grm(["wt", "add", "master", "--track", "upstream/master"], cwd=base_dir) + + repo = git.Repo(f"{base_dir}/master") + if not ffable: + shell( + f""" + cd {base_dir}/master + echo change > mychange + git add mychange + git commit -m "local-commit-in-master" + """ + ) + + args = ["wt", "pull"] + if rebase: + args += ["--rebase"] + cmd = grm(args, cwd=base_dir) + assert cmd.returncode == 0 + + assert repo.commit("upstream/master").hexsha == remote_commit + assert repo.commit("origin/master").hexsha == root_commit + assert ( + repo.commit("master").hexsha != repo.commit("origin/master").hexsha + ) + + if not rebase: + if ffable: + assert ( + repo.commit("master").hexsha + != repo.commit("origin/master").hexsha + ) + assert ( + repo.commit("master").hexsha + == repo.commit("upstream/master").hexsha + ) + assert repo.commit("upstream/master").hexsha == remote_commit + else: + assert "cannot be fast forwarded" in cmd.stderr + assert ( + repo.commit("master").hexsha + != repo.commit("origin/master").hexsha + ) + assert repo.commit("master").hexsha != remote_commit + assert repo.commit("upstream/master").hexsha == remote_commit + else: + if ffable: + assert ( + repo.commit("master").hexsha + != repo.commit("origin/master").hexsha + ) + assert ( + repo.commit("master").hexsha + == repo.commit("upstream/master").hexsha + ) + assert repo.commit("upstream/master").hexsha == remote_commit + else: + assert ( + repo.commit("master").message.strip() + == "local-commit-in-master" + ) + assert repo.commit("master~1").hexsha == remote_commit diff --git a/src/grm/cmd.rs b/src/grm/cmd.rs index 67b00b4..5d247c7 100644 --- a/src/grm/cmd.rs +++ b/src/grm/cmd.rs @@ -87,6 +87,10 @@ pub enum WorktreeAction { Convert(WorktreeConvertArgs), #[clap(about = "Clean all worktrees that do not contain uncommited/unpushed changes")] Clean(WorktreeCleanArgs), + #[clap(about = "Fetch refs from remotes")] + Fetch(WorktreeFetchArgs), + #[clap(about = "Fetch refs from remotes and update local branches")] + Pull(WorktreePullArgs), } #[derive(Parser)] @@ -121,6 +125,18 @@ pub struct WorktreeConvertArgs {} #[derive(Parser)] pub struct WorktreeCleanArgs {} +#[derive(Parser)] +pub struct WorktreeFetchArgs {} + +#[derive(Parser)] +pub struct WorktreePullArgs { + #[clap( + long = "--rebase", + about = "Perform a rebase instead of a fast-forward" + )] + pub rebase: bool, +} + pub fn parse() -> Opts { Opts::parse() } diff --git a/src/grm/main.rs b/src/grm/main.rs index dea98a3..20d33a6 100644 --- a/src/grm/main.rs +++ b/src/grm/main.rs @@ -310,6 +310,58 @@ fn main() { )); } } + cmd::WorktreeAction::Fetch(_args) => { + let repo = grm::Repo::open(&cwd, true).unwrap_or_else(|error| { + if error.kind == grm::RepoErrorKind::NotFound { + print_error("Directory does not contain a git repository"); + } else { + print_error(&format!("Opening repository failed: {}", error)); + } + process::exit(1); + }); + + repo.fetchall().unwrap_or_else(|error| { + print_error(&format!("Error fetching remotes: {}", error)); + process::exit(1); + }); + print_success("Fetched from all remotes"); + } + cmd::WorktreeAction::Pull(args) => { + let repo = grm::Repo::open(&cwd, true).unwrap_or_else(|error| { + if error.kind == grm::RepoErrorKind::NotFound { + print_error("Directory does not contain a git repository"); + } else { + print_error(&format!("Opening repository failed: {}", error)); + } + process::exit(1); + }); + + repo.fetchall().unwrap_or_else(|error| { + print_error(&format!("Error fetching remotes: {}", error)); + process::exit(1); + }); + + for worktree in repo.get_worktrees().unwrap_or_else(|error| { + print_error(&format!("Error getting worktrees: {}", error)); + process::exit(1); + }) { + if let Some(warning) = + worktree + .forward_branch(args.rebase) + .unwrap_or_else(|error| { + print_error(&format!( + "Error updating worktree branch: {}", + error + )); + process::exit(1); + }) + { + print_warning(&format!("{}: {}", worktree.name(), warning)); + } else { + print_success(&format!("{}: Done", worktree.name())); + } + } + } } } } diff --git a/src/repo.rs b/src/repo.rs index d66f8e7..e6cd49c 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -180,6 +180,77 @@ impl Worktree { pub fn name(&self) -> &str { &self.name } + + pub fn forward_branch(&self, rebase: bool) -> Result, String> { + let repo = Repo::open(Path::new(&self.name), false) + .map_err(|error| format!("Error opening worktree: {}", error))?; + + if let Ok(remote_branch) = repo.find_local_branch(&self.name)?.upstream() { + let status = repo.status(false)?; + + if !status.clean() { + return Ok(Some(String::from("Worktree contains changes"))); + } + + let remote_annotated_commit = repo + .0 + .find_annotated_commit(remote_branch.commit()?.id().0) + .map_err(convert_libgit2_error)?; + + if rebase { + let mut rebase = repo + .0 + .rebase( + None, // use HEAD + Some(&remote_annotated_commit), + None, // figure out the base yourself, libgit2! + Some(&mut git2::RebaseOptions::new()), + ) + .map_err(convert_libgit2_error)?; + + while let Some(operation) = rebase.next() { + let operation = operation.map_err(convert_libgit2_error)?; + + // This is required to preserve the commiter of the rebased + // commits, which is the expected behaviour. + let rebased_commit = repo + .0 + .find_commit(operation.id()) + .map_err(convert_libgit2_error)?; + let committer = rebased_commit.committer(); + + if rebase.commit(None, &committer, None).is_err() { + rebase.abort().map_err(convert_libgit2_error)?; + } + } + + rebase.finish(None).map_err(convert_libgit2_error)?; + } else { + let (analysis, _preference) = repo + .0 + .merge_analysis(&[&remote_annotated_commit]) + .map_err(convert_libgit2_error)?; + + if analysis.is_up_to_date() { + return Ok(None); + } + if !analysis.is_fast_forward() { + return Ok(Some(String::from("Worktree cannot be fast forwarded"))); + } + + repo.0 + .reset( + remote_branch.commit()?.0.as_object(), + git2::ResetType::Hard, + Some(git2::build::CheckoutBuilder::new().safe()), + ) + .map_err(convert_libgit2_error)?; + } + } else { + return Ok(Some(String::from("No remote branch to rebase onto"))); + }; + Ok(None) + } } #[cfg(test)] @@ -364,6 +435,42 @@ impl Repo { Ok(()) } + pub fn fetchall(&self) -> Result<(), String> { + for remote in self.remotes()? { + self.fetch(&remote)?; + } + Ok(()) + } + + pub fn local_branches(&self) -> Result, String> { + self.0 + .branches(Some(git2::BranchType::Local)) + .map_err(convert_libgit2_error)? + .map(|branch| Ok(Branch(branch.map_err(convert_libgit2_error)?.0))) + .collect::, String>>() + } + + pub fn fetch(&self, remote_name: &str) -> Result<(), String> { + let mut remote = self + .0 + .find_remote(remote_name) + .map_err(convert_libgit2_error)?; + + let mut fetch_options = git2::FetchOptions::new(); + fetch_options.remote_callbacks(get_remote_callbacks()); + + for refspec in &remote.fetch_refspecs().map_err(convert_libgit2_error)? { + remote + .fetch( + &[refspec.ok_or("Remote name is invalid utf-8")?], + Some(&mut fetch_options), + None, + ) + .map_err(convert_libgit2_error)?; + } + Ok(()) + } + pub fn init(path: &Path, is_worktree: bool) -> Result { let repo = match is_worktree { false => Repository::init(path).map_err(convert_libgit2_error)?, @@ -1090,6 +1197,37 @@ impl Branch<'_> { } } +fn get_remote_callbacks() -> git2::RemoteCallbacks<'static> { + let mut callbacks = git2::RemoteCallbacks::new(); + callbacks.push_update_reference(|_, status| { + if let Some(message) = status { + return Err(git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::None, + message, + )); + } + Ok(()) + }); + callbacks.credentials(|_url, username_from_url, _allowed_types| { + let username = match username_from_url { + Some(username) => username, + None => panic!("Could not get username. This is a bug"), + }; + git2::Cred::ssh_key_from_agent(username) + }); + + callbacks.credentials(|_url, username_from_url, _allowed_types| { + let username = match username_from_url { + Some(username) => username, + None => panic!("Could not get username. This is a bug"), + }; + git2::Cred::ssh_key(username, None, &crate::env_home().join(".ssh/id_rsa"), None) + }); + + callbacks +} + impl RemoteHandle<'_> { pub fn url(&self) -> String { self.0 @@ -1121,29 +1259,8 @@ impl RemoteHandle<'_> { return Err(String::from("Trying to push to a non-pushable remote")); } - let mut callbacks = git2::RemoteCallbacks::new(); - callbacks.push_update_reference(|_, status| { - if let Some(message) = status { - return Err(git2::Error::new( - git2::ErrorCode::GenericError, - git2::ErrorClass::None, - message, - )); - } - Ok(()) - }); - callbacks.credentials(|_url, username_from_url, _allowed_types| { - let username = match username_from_url { - Some(username) => username, - None => panic!("Could not get username. This is a bug"), - }; - git2::Cred::ssh_key_from_agent(username).or_else(|_| { - git2::Cred::ssh_key(username, None, &crate::env_home().join(".ssh/id_rsa"), None) - }) - }); - let mut push_options = git2::PushOptions::new(); - push_options.remote_callbacks(callbacks); + push_options.remote_callbacks(get_remote_callbacks()); let push_refspec = format!( "+refs/heads/{}:refs/heads/{}",