From 27586b5ff05b6bbd65bf24a3ae322bdc1faf0377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Thu, 23 Dec 2021 18:33:14 +0100 Subject: [PATCH] Add functionality for persistent branches --- docs/src/worktrees.md | 34 +++++ e2e_tests/test_repos_find.py | 1 + .../test_worktree_config_presistent_branch.py | 143 ++++++++++++++++++ src/grm/main.rs | 15 +- src/repo.rs | 119 ++++++++++++--- src/table.rs | 15 +- 6 files changed, 297 insertions(+), 30 deletions(-) create mode 100644 e2e_tests/test_worktree_config_presistent_branch.py diff --git a/docs/src/worktrees.md b/docs/src/worktrees.md index c808e7b..8feb16f 100644 --- a/docs/src/worktrees.md +++ b/docs/src/worktrees.md @@ -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 diff --git a/e2e_tests/test_repos_find.py b/e2e_tests/test_repos_find.py index 82113ba..2939e95 100644 --- a/e2e_tests/test_repos_find.py +++ b/e2e_tests/test_repos_find.py @@ -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( diff --git a/e2e_tests/test_worktree_config_presistent_branch.py b/e2e_tests/test_worktree_config_presistent_branch.py new file mode 100644 index 0000000..c8f3f6a --- /dev/null +++ b/e2e_tests/test_worktree_config_presistent_branch.py @@ -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) diff --git a/src/grm/main.rs b/src/grm/main.rs index 4aeaa28..b6dad4d 100644 --- a/src/grm/main.rs +++ b/src/grm/main.rs @@ -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); } diff --git a/src/repo.rs b/src/repo.rs index 945aab9..23eeb4a 100644 --- a/src/repo.rs +++ b/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>, +} + +pub fn read_worktree_root_config(worktree_root: &Path) -> Result, 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, ) -> 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; } diff --git a/src/table.rs b/src/table.rs index 8091725..f48ce5d 100644 --- a/src/table.rs +++ b/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::() - .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::() - .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;