Add fetch & pull option to worktrees

This commit is contained in:
2021-12-29 11:19:00 +01:00
parent ef381c7421
commit 717b0d3a74
6 changed files with 385 additions and 22 deletions

View File

@@ -279,6 +279,39 @@ Commit them and try again!
Afterwards, the directory is empty, as there are no worktrees checked out yet. Afterwards, the directory is empty, as there are no worktrees checked out yet.
Now you can use the usual commands to set up worktrees. 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 ### Manual access
GRM isn't doing any magic, it's just git under the hood. If you need to have access GRM isn't doing any magic, it's just git under the hood. If you need to have access

View File

@@ -169,6 +169,7 @@ class TempGitRepositoryWorktree:
git commit -m "commit2" git commit -m "commit2"
git remote add origin file://{self.remote_1_dir.name} git remote add origin file://{self.remote_1_dir.name}
git remote add otherremote file://{self.remote_2_dir.name} git remote add otherremote file://{self.remote_2_dir.name}
git push origin HEAD:master
git ls-files | xargs rm -rf git ls-files | xargs rm -rf
mv .git .git-main-working-tree mv .git .git-main-working-tree
git --git-dir .git-main-working-tree config core.bare true git --git-dir .git-main-working-tree config core.bare true

View File

@@ -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

View File

@@ -87,6 +87,10 @@ pub enum WorktreeAction {
Convert(WorktreeConvertArgs), Convert(WorktreeConvertArgs),
#[clap(about = "Clean all worktrees that do not contain uncommited/unpushed changes")] #[clap(about = "Clean all worktrees that do not contain uncommited/unpushed changes")]
Clean(WorktreeCleanArgs), Clean(WorktreeCleanArgs),
#[clap(about = "Fetch refs from remotes")]
Fetch(WorktreeFetchArgs),
#[clap(about = "Fetch refs from remotes and update local branches")]
Pull(WorktreePullArgs),
} }
#[derive(Parser)] #[derive(Parser)]
@@ -121,6 +125,18 @@ pub struct WorktreeConvertArgs {}
#[derive(Parser)] #[derive(Parser)]
pub struct WorktreeCleanArgs {} 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 { pub fn parse() -> Opts {
Opts::parse() Opts::parse()
} }

View File

@@ -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()));
}
}
}
} }
} }
} }

View File

@@ -180,6 +180,77 @@ impl Worktree {
pub fn name(&self) -> &str { pub fn name(&self) -> &str {
&self.name &self.name
} }
pub fn forward_branch(&self, rebase: bool) -> Result<Option<String>, 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)] #[cfg(test)]
@@ -364,6 +435,42 @@ impl Repo {
Ok(()) Ok(())
} }
pub fn fetchall(&self) -> Result<(), String> {
for remote in self.remotes()? {
self.fetch(&remote)?;
}
Ok(())
}
pub fn local_branches(&self) -> Result<Vec<Branch>, 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::<Result<Vec<Branch>, 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<Self, String> { pub fn init(path: &Path, is_worktree: bool) -> Result<Self, String> {
let repo = match is_worktree { let repo = match is_worktree {
false => Repository::init(path).map_err(convert_libgit2_error)?, 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<'_> { impl RemoteHandle<'_> {
pub fn url(&self) -> String { pub fn url(&self) -> String {
self.0 self.0
@@ -1121,29 +1259,8 @@ impl RemoteHandle<'_> {
return Err(String::from("Trying to push to a non-pushable remote")); 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(); let mut push_options = git2::PushOptions::new();
push_options.remote_callbacks(callbacks); push_options.remote_callbacks(get_remote_callbacks());
let push_refspec = format!( let push_refspec = format!(
"+refs/heads/{}:refs/heads/{}", "+refs/heads/{}:refs/heads/{}",