Add functionality for persistent branches

This commit is contained in:
2021-12-23 18:33:14 +01:00
parent 70eac10eaa
commit 27586b5ff0
6 changed files with 297 additions and 30 deletions

View File

@@ -71,6 +71,10 @@ Now, when you run a `grm sync`, you'll notice that the directory of the reposito
is empty! Well, not totally, there is a hidden directory called `.git-main-working-tree`.
This is where the repository actually "lives" (it's a bare checkout).
Note that there are few specific things you can configure for a certain
workspace. This is all done in an optional `grm.toml` file right in the root
of the worktree. More on that later.
### Creating a new worktree
To actually work, you'll first have to create a new worktree checkout. All
@@ -195,6 +199,36 @@ $ grm wt clean
Note that this will not delete the default branch of the repository. It can of
course still be delete with `grm wt delete` if neccessary.
### Persistent branches
You most likely have a few branches that are "special", that you don't want to
clean up and that are the usual target for feature branches to merge into. GRM
calls them "persistent branches" and treats them a bit differently:
* Their worktrees will never be deleted by `grm wt clean`
* If the branches in other worktrees are merged into them, they will be cleaned
up, even though they may not be in line with their upstream. Same goes for
`grm wt delete`, which will not require a `--force` flag. Note that of
course, actual changes in the worktree will still block an automatic cleanup!
* As soon as you enable persistent branches, non-persistent branches will only
ever cleaned up when merged into a persistent branch.
To elaborate: This is mostly relevant for a feature-branch workflow. Whenever a
feature branch is merged, it can usually be thrown away. As merging is usually
done on some remote code management platform (GitHub, GitLab, ...), this means
that you usually keep a branch around until it is merged into one of the "main"
branches (`master`, `main`, `develop`, ...)
Enable persistent branches by setting the following in the `grm.toml` in the
worktree root:
```toml
persistent_branches = [
"master",
"develop",
]
```
### Converting an existing repository
It is possible to convert an existing directory to a worktree setup, using `grm

View File

@@ -159,6 +159,7 @@ def test_repos_find_in_root():
assert set(origin.keys()) == {"name", "type", "url"}
assert someremote["type"] == "file"
def test_repos_find_with_invalid_repo():
with tempfile.TemporaryDirectory() as tmpdir:
shell(

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env python3
import os.path
from helpers import *
def test_worktree_never_clean_persistent_branches():
with TempGitRepositoryWorktree() as base_dir:
with open(os.path.join(base_dir, "grm.toml"), "w") as f:
f.write(
"""
persistent_branches = [
"mybranch",
]
"""
)
cmd = grm(["wt", "add", "mybranch", "--track", "origin/master"], cwd=base_dir)
assert cmd.returncode == 0
before = checksum_directory(f"{base_dir}/mybranch")
cmd = grm(["wt", "clean"], cwd=base_dir)
assert cmd.returncode == 0
assert "mybranch" in os.listdir(base_dir)
repo = git.Repo(os.path.join(base_dir, "mybranch"))
assert str(repo.active_branch) == "mybranch"
after = checksum_directory(f"{base_dir}/mybranch")
assert before == after
def test_worktree_clean_branch_merged_into_persistent():
with TempGitRepositoryWorktree() as base_dir:
with open(os.path.join(base_dir, "grm.toml"), "w") as f:
f.write(
"""
persistent_branches = [
"master",
]
"""
)
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
shell(
f"""
cd {base_dir}/test
touch change1
git add change1
git commit -m "commit1"
"""
)
cmd = grm(["wt", "add", "master"], cwd=base_dir)
assert cmd.returncode == 0
shell(
f"""
cd {base_dir}/master
git merge --no-ff test
"""
)
cmd = grm(["wt", "clean"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" not in os.listdir(base_dir)
def test_worktree_no_clean_unmerged_branch():
with TempGitRepositoryWorktree() as base_dir:
with open(os.path.join(base_dir, "grm.toml"), "w") as f:
f.write(
"""
persistent_branches = [
"master",
]
"""
)
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
shell(
f"""
cd {base_dir}/test
touch change1
git add change1
git commit -m "commit1"
git push origin test
"""
)
cmd = grm(["wt", "add", "master"], cwd=base_dir)
assert cmd.returncode == 0
cmd = grm(["wt", "clean"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" in os.listdir(base_dir)
def test_worktree_delete_branch_merged_into_persistent():
with TempGitRepositoryWorktree() as base_dir:
with open(os.path.join(base_dir, "grm.toml"), "w") as f:
f.write(
"""
persistent_branches = [
"master",
]
"""
)
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
shell(
f"""
cd {base_dir}/test
touch change1
git add change1
git commit -m "commit1"
"""
)
cmd = grm(["wt", "add", "master"], cwd=base_dir)
assert cmd.returncode == 0
shell(
f"""
cd {base_dir}/master
git merge --no-ff test
"""
)
cmd = grm(["wt", "delete", "test"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" not in os.listdir(base_dir)

View File

@@ -3,6 +3,7 @@ use std::process;
mod cmd;
use grm::repo;
use grm::config;
use grm::output::*;
@@ -171,12 +172,21 @@ fn main() {
}
cmd::WorktreeAction::Delete(action_args) => {
let worktree_dir = cwd.join(&action_args.name);
let worktree_config = match repo::read_worktree_root_config(&cwd) {
Ok(config) => config,
Err(error) => {
print_error(&format!("Error getting worktree configuration: {}", error));
process::exit(1);
}
};
let repo = grm::Repo::open(&cwd, true).unwrap_or_else(|error| {
print_error(&format!("Error opening repository: {}", error));
process::exit(1);
});
match repo.remove_worktree(&action_args.name, &worktree_dir, action_args.force)
match repo.remove_worktree(&action_args.name, &worktree_dir, action_args.force, &worktree_config)
{
Ok(_) => print_success(&format!("Worktree {} deleted", &action_args.name)),
Err(error) => {
@@ -191,6 +201,9 @@ fn main() {
changes
));
}
grm::WorktreeRemoveFailureReason::NotMerged(message) => {
print_warning(&message);
}
}
process::exit(1);
}

View File

@@ -5,6 +5,8 @@ use git2::{Cred, RemoteCallbacks, Repository};
use crate::output::*;
const WORKTREE_CONFIG_FILE_NAME: &str = "grm.toml";
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum RemoteType {
@@ -16,6 +18,7 @@ pub enum RemoteType {
pub enum WorktreeRemoveFailureReason {
Changes(String),
Error(String),
NotMerged(String),
}
pub enum GitPushDefaultSetting {
@@ -39,6 +42,37 @@ impl RepoError {
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorktreeRootConfig {
pub persistent_branches: Option<Vec<String>>,
}
pub fn read_worktree_root_config(worktree_root: &Path) -> Result<Option<WorktreeRootConfig>, String> {
let path = worktree_root.join(WORKTREE_CONFIG_FILE_NAME);
let content = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
match e.kind() {
std::io::ErrorKind::NotFound => return Ok(None),
_ => return Err(format!("Error reading configuration file \"{}\": {}", path.display(), e)),
}
}
};
let config: WorktreeRootConfig = match toml::from_str(&content) {
Ok(c) => c,
Err(e) => {
return Err(format!(
"Error parsing configuration file \"{}\": {}",
path.display(), e
))
}
};
Ok(Some(config))
}
impl std::error::Error for RepoError {}
impl std::fmt::Display for RepoError {
@@ -237,10 +271,9 @@ impl Repo {
// name exists
let branch = self
.find_local_branch(
&head
head
.shorthand()
.expect("Branch name is not valid utf-8")
.to_string(),
.expect("Branch name is not valid utf-8"),
)
.unwrap();
Ok(branch)
@@ -642,6 +675,7 @@ impl Repo {
name: &str,
worktree_dir: &Path,
force: bool,
worktree_config: &Option<WorktreeRootConfig>,
) -> Result<(), WorktreeRemoveFailureReason> {
if !worktree_dir.exists() {
return Err(WorktreeRemoveFailureReason::Error(format!(
@@ -684,25 +718,55 @@ impl Repo {
)));
}
match branch.upstream() {
Ok(remote_branch) => {
let (ahead, behind) = worktree_repo
.graph_ahead_behind(&branch, &remote_branch)
.unwrap();
let mut is_merged_into_persistent_branch = false;
let mut has_persistent_branches = false;
if let Some(config) = worktree_config {
if let Some(branches) = &config.persistent_branches {
has_persistent_branches = true;
for persistent_branch in branches {
let persistent_branch = worktree_repo
.find_local_branch(persistent_branch)
.map_err(WorktreeRemoveFailureReason::Error)?;
if (ahead, behind) != (0, 0) {
let (ahead, _behind) = worktree_repo
.graph_ahead_behind(&branch, &persistent_branch)
.unwrap();
if ahead == 0 {
is_merged_into_persistent_branch = true;
}
}
}
}
if has_persistent_branches && !is_merged_into_persistent_branch {
return Err(WorktreeRemoveFailureReason::NotMerged(format!(
"Branch {} is not merged into any persistent branches",
name
)));
}
if !has_persistent_branches {
match branch.upstream() {
Ok(remote_branch) => {
let (ahead, behind) = worktree_repo
.graph_ahead_behind(&branch, &remote_branch)
.unwrap();
if (ahead, behind) != (0, 0) {
return Err(WorktreeRemoveFailureReason::Changes(format!(
"Branch {} is not in line with remote branch",
name
)));
}
}
Err(_) => {
return Err(WorktreeRemoveFailureReason::Changes(format!(
"Branch {} is not in line with remote branch",
"No remote tracking branch for branch {} found",
name
)));
}
}
Err(_) => {
return Err(WorktreeRemoveFailureReason::Changes(format!(
"No remote tracking branch for branch {} found",
name
)));
}
}
}
@@ -737,13 +801,22 @@ impl Repo {
.name()
.map_err(|error| format!("Failed getting default branch name: {}", error))?;
let config = read_worktree_root_config(directory)?;
for worktree in worktrees
.iter()
.filter(|worktree| *worktree != &default_branch_name)
.filter(|worktree| match &config {
None => true,
Some(config) => match &config.persistent_branches {
None => true,
Some(branches) => !branches.contains(worktree),
},
})
{
let repo_dir = &directory.join(&worktree);
if repo_dir.exists() {
match self.remove_worktree(worktree, repo_dir, false) {
match self.remove_worktree(worktree, repo_dir, false, &config) {
Ok(_) => print_success(&format!("Worktree {} deleted", &worktree)),
Err(error) => match error {
WorktreeRemoveFailureReason::Changes(changes) => {
@@ -753,6 +826,10 @@ impl Repo {
));
continue;
}
WorktreeRemoveFailureReason::NotMerged(message) => {
warnings.push(message);
continue;
}
WorktreeRemoveFailureReason::Error(error) => {
return Err(error);
}
@@ -773,14 +850,13 @@ impl Repo {
let mut unmanaged_worktrees = Vec::new();
for entry in std::fs::read_dir(&directory).map_err(|error| error.to_string())? {
let dirname = crate::path_as_string(
&entry
entry
.map_err(|error| error.to_string())?
.path()
.strip_prefix(&directory)
// that unwrap() is safe as each entry is
// guaranteed to be a subentry of &directory
.unwrap()
.to_path_buf(),
.unwrap(),
);
let default_branch = self
@@ -794,6 +870,9 @@ impl Repo {
if dirname == crate::GIT_MAIN_WORKTREE_DIRECTORY {
continue;
}
if dirname == WORKTREE_CONFIG_FILE_NAME {
continue;
}
if dirname == default_branch_name {
continue;
}

View File

@@ -46,7 +46,7 @@ fn add_repo_status(
}
None => String::from("\u{2714}"),
},
&repo_status
repo_status
.branches
.iter()
.map(|(branch_name, remote_branch)| {
@@ -73,8 +73,7 @@ fn add_repo_status(
)
})
.collect::<String>()
.trim()
.to_string(),
.trim(),
&match is_worktree {
true => String::from(""),
false => match repo_status.head {
@@ -82,13 +81,12 @@ fn add_repo_status(
None => String::from("Empty"),
},
},
&repo_status
repo_status
.remotes
.iter()
.map(|r| format!("{}\n", r))
.collect::<String>()
.trim()
.to_string(),
.trim(),
]);
Ok(())
@@ -127,14 +125,13 @@ pub fn get_worktree_status_table(
}
for entry in std::fs::read_dir(&directory).map_err(|error| error.to_string())? {
let dirname = crate::path_as_string(
&entry
entry
.map_err(|error| error.to_string())?
.path()
.strip_prefix(&directory)
// this unwrap is safe, as we can be sure that each subentry of
// &directory also has the prefix &dir
.unwrap()
.to_path_buf(),
.unwrap(),
);
if dirname == crate::GIT_MAIN_WORKTREE_DIRECTORY {
continue;