Add rebase option for worktrees

This commit is contained in:
2021-12-29 19:02:42 +01:00
parent 7a2fa7ae3f
commit ef8a57c60e
5 changed files with 445 additions and 1 deletions

View File

@@ -312,6 +312,40 @@ grm wt pull --rebase
This will rebase your changes onto the upstream branch. This is mainly helpful This will rebase your changes onto the upstream branch. This is mainly helpful
for persistent branches that change on the remote side. for persistent branches that change on the remote side.
There is a similar rebase feature that rebases onto the **default** branch instead:
```
grm wt rebase
[✔] master: Done
[✔] my-cool-branch: Done
```
This is super helpful for feature branches. If you want to incorporate changes
made on the remote branches, use `grm wt rebase` and all your branches will
be up to date. If you want to also update to remote tracking branches in one go,
use the `--pull` flag, and `--rebase` if you want to rebase instead of aborting
on non-fast-forwards:
```
grm wt rebase --pull --rebase
[✔] master: Done
[✔] my-cool-branch: Done
```
"So, what's the difference between `pull --rebase` and `rebase --pull`? Why the
hell is there a `--rebase` flag in the `rebase` command?"
Yes, it's kind of weird. Remember that `pull` only ever updates each worktree
to their remote branch, if possible. `rebase` rabases onto the **default** branch
instead. The switches to `rebase` are just convenience, so you do not have to
run two commands.
* `rebase --pull` is the same as `pull` && `rebase`
* `rebase --pull --rebase` is the same as `pull --rebase` && `rebase`
I understand that the UX is not the most intuitive. If you can think of an
improvement, please let me know (e.g. via an GitHub issue)!
### 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

@@ -0,0 +1,250 @@
#!/usr/bin/env python3
from helpers import *
import pytest
import git
@pytest.mark.parametrize("pull", [True, False])
@pytest.mark.parametrize("rebase", [True, False])
@pytest.mark.parametrize("ffable", [True, False])
def test_worktree_rebase(pull, rebase, ffable):
with TempGitRepositoryWorktree() as (base_dir, _root_commit):
with open(os.path.join(base_dir, "grm.toml"), "w") as f:
f.write('persistent_branches = ["mybasebranch"]')
repo = git.Repo(f"{base_dir}/.git-main-working-tree")
grm(
["wt", "add", "mybasebranch", "--track", "origin/mybasebranch"],
cwd=base_dir,
)
shell(
f"""
cd {base_dir}/mybasebranch
echo change > mychange-root
git add mychange-root
git commit -m "commit-root"
echo change > mychange-base-local
git add mychange-base-local
git commit -m "commit-in-base-local"
git push origin mybasebranch
"""
)
grm(
["wt", "add", "myfeatbranch", "--track", "origin/myfeatbranch"],
cwd=base_dir,
)
shell(
f"""
cd {base_dir}/myfeatbranch
git reset --hard mybasebranch^ # root
echo change > mychange-feat-local
git add mychange-feat-local
git commit -m "commit-in-feat-local"
git push origin HEAD:myfeatbranch
"""
)
grm(["wt", "add", "tmp"], cwd=base_dir)
shell(
f"""
cd {base_dir}/tmp
git reset --hard mybasebranch
echo change > mychange-base-remote
git add mychange-base-remote
git commit -m "commit-in-base-remote"
git push origin HEAD:mybasebranch
git reset --hard myfeatbranch
echo change > mychange-feat-remote
git add mychange-feat-remote
git commit -m "commit-in-feat-remote"
git push origin HEAD:myfeatbranch
"""
)
if not ffable:
shell(
f"""
cd {base_dir}/mybasebranch
echo change > mychange-base-no-ff
git add mychange-base-no-ff
git commit -m "commit-in-base-local-no-ff"
cd {base_dir}/myfeatbranch
echo change > mychange-feat-no-ff
git add mychange-feat-no-ff
git commit -m "commit-in-feat-local-no-ff"
"""
)
grm(["wt", "delete", "--force", "tmp"], cwd=base_dir)
repo = git.Repo(f"{base_dir}/.git-main-working-tree")
if ffable:
assert repo.commit("mybasebranch~1").message.strip() == "commit-root"
assert (
repo.refs.mybasebranch.commit.message.strip() == "commit-in-base-local"
)
assert (
repo.remote("origin").refs.mybasebranch.commit.message.strip()
== "commit-in-base-remote"
)
assert (
repo.refs.myfeatbranch.commit.message.strip() == "commit-in-feat-local"
)
assert (
repo.remote("origin").refs.myfeatbranch.commit.message.strip()
== "commit-in-feat-remote"
)
else:
assert (
repo.commit("mybasebranch").message.strip()
== "commit-in-base-local-no-ff"
)
assert (
repo.commit("mybasebranch~1").message.strip() == "commit-in-base-local"
)
assert repo.commit("mybasebranch~2").message.strip() == "commit-root"
assert (
repo.commit("myfeatbranch").message.strip()
== "commit-in-feat-local-no-ff"
)
assert (
repo.commit("myfeatbranch~1").message.strip() == "commit-in-feat-local"
)
assert repo.commit("myfeatbranch~2").message.strip() == "commit-root"
assert (
repo.remote("origin").refs.mybasebranch.commit.message.strip()
== "commit-in-base-remote"
)
assert (
repo.remote("origin").refs.myfeatbranch.commit.message.strip()
== "commit-in-feat-remote"
)
args = ["wt", "rebase"]
if pull:
args += ["--pull"]
if rebase:
args += ["--rebase"]
cmd = grm(args, cwd=base_dir)
print(args)
if rebase and not pull:
assert cmd.returncode != 0
assert len(cmd.stderr) != 0
else:
assert cmd.returncode == 0
repo = git.Repo(f"{base_dir}/myfeatbranch")
if pull:
if rebase:
if ffable:
assert (
repo.commit("HEAD").message.strip()
== "commit-in-feat-remote"
)
assert (
repo.commit("HEAD~1").message.strip()
== "commit-in-feat-local"
)
assert (
repo.commit("HEAD~2").message.strip()
== "commit-in-base-remote"
)
assert (
repo.commit("HEAD~3").message.strip()
== "commit-in-base-local"
)
assert repo.commit("HEAD~4").message.strip() == "commit-root"
else:
assert (
repo.commit("HEAD").message.strip()
== "commit-in-feat-local-no-ff"
)
assert (
repo.commit("HEAD~1").message.strip()
== "commit-in-feat-remote"
)
assert (
repo.commit("HEAD~2").message.strip()
== "commit-in-feat-local"
)
assert (
repo.commit("HEAD~3").message.strip()
== "commit-in-base-local-no-ff"
)
assert (
repo.commit("HEAD~4").message.strip()
== "commit-in-base-remote"
)
assert (
repo.commit("HEAD~5").message.strip()
== "commit-in-base-local"
)
assert repo.commit("HEAD~6").message.strip() == "commit-root"
else:
if ffable:
assert (
repo.commit("HEAD").message.strip()
== "commit-in-feat-remote"
)
assert (
repo.commit("HEAD~1").message.strip()
== "commit-in-feat-local"
)
assert (
repo.commit("HEAD~2").message.strip()
== "commit-in-base-remote"
)
assert (
repo.commit("HEAD~3").message.strip()
== "commit-in-base-local"
)
assert repo.commit("HEAD~4").message.strip() == "commit-root"
else:
assert (
repo.commit("HEAD").message.strip()
== "commit-in-feat-local-no-ff"
)
assert (
repo.commit("HEAD~1").message.strip()
== "commit-in-feat-local"
)
assert (
repo.commit("HEAD~2").message.strip()
== "commit-in-base-local-no-ff"
)
assert (
repo.commit("HEAD~3").message.strip()
== "commit-in-base-local"
)
assert repo.commit("HEAD~4").message.strip() == "commit-root"
else:
if ffable:
assert repo.commit("HEAD").message.strip() == "commit-in-feat-local"
assert (
repo.commit("HEAD~1").message.strip() == "commit-in-base-local"
)
assert repo.commit("HEAD~2").message.strip() == "commit-root"
else:
assert (
repo.commit("HEAD").message.strip()
== "commit-in-feat-local-no-ff"
)
assert (
repo.commit("HEAD~1").message.strip() == "commit-in-feat-local"
)
assert (
repo.commit("HEAD~2").message.strip()
== "commit-in-base-local-no-ff"
)
assert (
repo.commit("HEAD~3").message.strip() == "commit-in-base-local"
)
assert repo.commit("HEAD~4").message.strip() == "commit-root"

View File

@@ -91,6 +91,8 @@ pub enum WorktreeAction {
Fetch(WorktreeFetchArgs), Fetch(WorktreeFetchArgs),
#[clap(about = "Fetch refs from remotes and update local branches")] #[clap(about = "Fetch refs from remotes and update local branches")]
Pull(WorktreePullArgs), Pull(WorktreePullArgs),
#[clap(about = "Rebase worktree onto default branch")]
Rebase(WorktreeRebaseArgs),
} }
#[derive(Parser)] #[derive(Parser)]
@@ -137,6 +139,14 @@ pub struct WorktreePullArgs {
pub rebase: bool, pub rebase: bool,
} }
#[derive(Parser)]
pub struct WorktreeRebaseArgs {
#[clap(long = "--pull", about = "Perform a pull before rebasing")]
pub pull: bool,
#[clap(long = "--rebase", about = "Perform a rebase when doing a pull")]
pub rebase: bool,
}
pub fn parse() -> Opts { pub fn parse() -> Opts {
Opts::parse() Opts::parse()
} }

View File

@@ -362,6 +362,76 @@ fn main() {
} }
} }
} }
cmd::WorktreeAction::Rebase(args) => {
if args.rebase && !args.pull {
print_error("There is no point in using --rebase without --pull");
process::exit(1);
}
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);
});
if args.pull {
repo.fetchall().unwrap_or_else(|error| {
print_error(&format!("Error fetching remotes: {}", error));
process::exit(1);
});
}
let config =
grm::repo::read_worktree_root_config(&cwd).unwrap_or_else(|error| {
print_error(&format!(
"Failed to read worktree configuration: {}",
error
));
process::exit(1);
});
let worktrees = repo.get_worktrees().unwrap_or_else(|error| {
print_error(&format!("Error getting worktrees: {}", error));
process::exit(1);
});
for worktree in &worktrees {
if args.pull {
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));
}
}
}
for worktree in &worktrees {
if let Some(warning) =
worktree
.rebase_onto_default(&config)
.unwrap_or_else(|error| {
print_error(&format!(
"Error rebasing worktree branch: {}",
error
));
process::exit(1);
})
{
print_warning(&format!("{}: {}", worktree.name(), warning));
} else {
print_success(&format!("{}: Done", worktree.name()));
}
}
}
} }
} }
} }

View File

@@ -219,8 +219,16 @@ impl Worktree {
.map_err(convert_libgit2_error)?; .map_err(convert_libgit2_error)?;
let committer = rebased_commit.committer(); let committer = rebased_commit.committer();
if rebase.commit(None, &committer, None).is_err() { // This is effectively adding all files to the index explicitly.
// Normal files are already staged, but changed submodules are not.
let mut index = repo.0.index().map_err(convert_libgit2_error)?;
index
.add_all(["."].iter(), git2::IndexAddOption::CHECK_PATHSPEC, None)
.map_err(convert_libgit2_error)?;
if let Err(error) = rebase.commit(None, &committer, None) {
rebase.abort().map_err(convert_libgit2_error)?; rebase.abort().map_err(convert_libgit2_error)?;
return Err(convert_libgit2_error(error));
} }
} }
@@ -251,6 +259,78 @@ impl Worktree {
}; };
Ok(None) Ok(None)
} }
pub fn rebase_onto_default(
&self,
config: &Option<WorktreeRootConfig>,
) -> Result<Option<String>, String> {
let repo = Repo::open(Path::new(&self.name), false)
.map_err(|error| format!("Error opening worktree: {}", error))?;
let guess_default_branch = || {
repo.default_branch()
.map_err(|_| "Could not determine default branch")?
.name()
.map_err(|error| format!("Failed getting default branch name: {}", error))
};
let default_branch_name = match &config {
None => guess_default_branch()?,
Some(config) => match &config.persistent_branches {
None => guess_default_branch()?,
Some(persistent_branches) => {
if persistent_branches.is_empty() {
guess_default_branch()?
} else {
persistent_branches[0].clone()
}
}
},
};
let base_branch = repo.find_local_branch(&default_branch_name)?;
let base_annotated_commit = repo
.0
.find_annotated_commit(base_branch.commit()?.id().0)
.map_err(convert_libgit2_error)?;
let mut rebase = repo
.0
.rebase(
None, // use HEAD
Some(&base_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();
// This is effectively adding all files to the index explicitly.
// Normal files are already staged, but changed submodules are not.
let mut index = repo.0.index().map_err(convert_libgit2_error)?;
index
.add_all(["."].iter(), git2::IndexAddOption::CHECK_PATHSPEC, None)
.map_err(convert_libgit2_error)?;
if let Err(error) = rebase.commit(None, &committer, None) {
rebase.abort().map_err(convert_libgit2_error)?;
return Err(convert_libgit2_error(error));
}
}
rebase.finish(None).map_err(convert_libgit2_error)?;
Ok(None)
}
} }
impl RepoStatus { impl RepoStatus {