Add functionality for persistent branches
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
143
e2e_tests/test_worktree_config_presistent_branch.py
Normal file
143
e2e_tests/test_worktree_config_presistent_branch.py
Normal 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)
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
119
src/repo.rs
119
src/repo.rs
@@ -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;
|
||||
}
|
||||
|
||||
15
src/table.rs
15
src/table.rs
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user