Add fetch & pull option to worktrees
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
144
e2e_tests/test_worktree_fetch.py
Normal file
144
e2e_tests/test_worktree_fetch.py
Normal 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
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
161
src/repo.rs
161
src/repo.rs
@@ -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/{}",
|
||||||
|
|||||||
Reference in New Issue
Block a user