3 Commits

4 changed files with 330 additions and 91 deletions

View File

@@ -79,6 +79,35 @@ $ grm status
+----------+------------+----------------------------------+--------+---------+ +----------+------------+----------------------------------+--------+---------+
``` ```
### Manage worktrees for projects
Optionally, GRM can also set up a repository to support multiple worktrees. See
[the git documentation](https://git-scm.com/docs/git-worktree) for details about
worktrees. Long story short: Worktrees allow you to have multiple independent
checkouts of the same repository in different directories, backed by a single
git repository.
To use this, specify `worktree_setup = true` for a repo in your configuration.
After the sync, you will see that the target directory is empty. Actually, the
repository was bare-cloned into a hidden directory: `.git-main-working-tree`.
Don't touch it! GRM provides a command to manage working trees.
Use `grm worktree add <name>` to create a new checkout of a new branch into
a subdirectory. An example:
```bash
$ grm worktree add mybranch
$ cd ./mybranch
$ git status
On branch mybranch
nothing to commit, working tree clean
```
If you're done with your worktree, use `grm worktree delete <name>` to remove it.
GRM will refuse to delete worktrees that contain uncommitted or unpushed changes,
otherwise you might lose work.
# Why? # Why?
I have a **lot** of repositories on my machines. My own stuff, forks, quick I have a **lot** of repositories on my machines. My own stuff, forks, quick

View File

@@ -28,6 +28,12 @@ pub enum SubCommand {
Find(Find), Find(Find),
#[clap(about = "Show status of configured repositories")] #[clap(about = "Show status of configured repositories")]
Status(OptionalConfig), Status(OptionalConfig),
#[clap(
visible_alias = "wt",
about = "Manage worktrees"
)]
Worktree(Worktree),
} }
#[derive(Parser)] #[derive(Parser)]
@@ -45,11 +51,7 @@ pub struct Sync {
#[derive(Parser)] #[derive(Parser)]
#[clap()] #[clap()]
pub struct OptionalConfig { pub struct OptionalConfig {
#[clap( #[clap(short, long, about = "Path to the configuration file")]
short,
long,
about = "Path to the configuration file"
)]
pub config: Option<String>, pub config: Option<String>,
} }
@@ -59,6 +61,27 @@ pub struct Find {
pub path: String, pub path: String,
} }
#[derive(Parser)]
pub struct Worktree {
#[clap(subcommand, name = "action")]
pub action: WorktreeAction,
}
#[derive(Parser)]
pub enum WorktreeAction {
#[clap(about = "Add a new worktree")]
Add(WorktreeActionArgs),
#[clap(about = "Add an existing worktree")]
Delete(WorktreeActionArgs),
}
#[derive(Parser)]
pub struct WorktreeActionArgs {
#[clap(about = "Name of the worktree")]
pub name: String,
}
pub fn parse() -> Opts { pub fn parse() -> Opts {
Opts::parse() Opts::parse()
} }

View File

@@ -10,13 +10,15 @@ mod repo;
use config::{Config, Tree}; use config::{Config, Tree};
use output::*; use output::*;
use comfy_table::{Table, Cell}; use comfy_table::{Cell, Table};
use repo::{ use repo::{
clone_repo, detect_remote_type, get_repo_status, init_repo, open_repo, Remote, Repo, clone_repo, detect_remote_type, get_repo_status, init_repo, open_repo, Remote,
RepoErrorKind, RemoteTrackingStatus RemoteTrackingStatus, Repo, RepoErrorKind,
}; };
const GIT_MAIN_WORKTREE_DIRECTORY: &str = ".git-main-working-tree";
fn path_as_string(path: &Path) -> String { fn path_as_string(path: &Path) -> String {
path.to_path_buf().into_os_string().into_string().unwrap() path.to_path_buf().into_os_string().into_string().unwrap()
} }
@@ -65,21 +67,36 @@ fn sync_trees(config: Config) {
for repo in &repos { for repo in &repos {
let repo_path = root_path.join(&repo.name); let repo_path = root_path.join(&repo.name);
let actual_git_directory = get_actual_git_directory(&repo_path, repo.worktree_setup);
let mut repo_handle = None; let mut repo_handle = None;
if repo_path.exists() { if repo_path.exists() {
repo_handle = Some(open_repo(&repo_path).unwrap_or_else(|error| { if repo.worktree_setup && !actual_git_directory.exists() {
print_repo_error(&repo.name, &format!("Opening repository failed: {}", error)); print_repo_error(
&repo.name,
&format!("Repo already exists, but is not using a worktree setup"),
);
process::exit(1); process::exit(1);
})); }
repo_handle = Some(open_repo(&repo_path, repo.worktree_setup).unwrap_or_else(
|error| {
print_repo_error(
&repo.name,
&format!("Opening repository failed: {}", error),
);
process::exit(1);
},
));
} else { } else {
if matches!(&repo.remotes, None) || repo.remotes.as_ref().unwrap().len().clone() == 0 { if matches!(&repo.remotes, None)
|| repo.remotes.as_ref().unwrap().len().clone() == 0
{
print_repo_action( print_repo_action(
&repo.name, &repo.name,
"Repository does not have remotes configured, initializing new", "Repository does not have remotes configured, initializing new",
); );
repo_handle = match init_repo(&repo_path) { repo_handle = match init_repo(&repo_path, repo.worktree_setup) {
Ok(r) => { Ok(r) => {
print_repo_success(&repo.name, "Repository created"); print_repo_success(&repo.name, "Repository created");
Some(r) Some(r)
@@ -95,7 +112,7 @@ fn sync_trees(config: Config) {
} else { } else {
let first = repo.remotes.as_ref().unwrap().first().unwrap(); let first = repo.remotes.as_ref().unwrap().first().unwrap();
match clone_repo(first, &repo_path) { match clone_repo(first, &repo_path, repo.worktree_setup) {
Ok(_) => { Ok(_) => {
print_repo_success(&repo.name, "Repository successfully cloned"); print_repo_success(&repo.name, "Repository successfully cloned");
} }
@@ -110,8 +127,9 @@ fn sync_trees(config: Config) {
} }
} }
if let Some(remotes) = &repo.remotes { if let Some(remotes) = &repo.remotes {
let repo_handle = repo_handle let repo_handle = repo_handle.unwrap_or_else(|| {
.unwrap_or_else(|| open_repo(&repo_path).unwrap_or_else(|_| process::exit(1))); open_repo(&repo_path, repo.worktree_setup).unwrap_or_else(|_| process::exit(1))
});
let current_remotes: Vec<String> = match repo_handle.remotes() { let current_remotes: Vec<String> = match repo_handle.remotes() {
Ok(r) => r, Ok(r) => r,
@@ -190,7 +208,7 @@ fn sync_trees(config: Config) {
} }
let current_repos = find_repos_without_details(&root_path).unwrap(); let current_repos = find_repos_without_details(&root_path).unwrap();
for repo in current_repos { for (repo, _) in current_repos {
let name = path_as_string(repo.strip_prefix(&root_path).unwrap()); let name = path_as_string(repo.strip_prefix(&root_path).unwrap());
if !repos.iter().any(|r| r.name == name) { if !repos.iter().any(|r| r.name == name) {
print_warning(&format!("Found unmanaged repository: {}", name)); print_warning(&format!("Found unmanaged repository: {}", name));
@@ -199,12 +217,16 @@ fn sync_trees(config: Config) {
} }
} }
fn find_repos_without_details(path: &Path) -> Option<Vec<PathBuf>> { fn find_repos_without_details(path: &Path) -> Option<Vec<(PathBuf, bool)>> {
let mut repos: Vec<PathBuf> = Vec::new(); let mut repos: Vec<(PathBuf, bool)> = Vec::new();
let git_dir = path.join(".git"); let git_dir = path.join(".git");
let git_worktree = path.join(GIT_MAIN_WORKTREE_DIRECTORY);
if git_dir.exists() { if git_dir.exists() {
repos.push(path.to_path_buf()); repos.push((path.to_path_buf(), false));
} else if git_worktree.exists() {
repos.push((path.to_path_buf(), true));
} else { } else {
match fs::read_dir(path) { match fs::read_dir(path) {
Ok(contents) => { Ok(contents) => {
@@ -238,14 +260,29 @@ fn find_repos_without_details(path: &Path) -> Option<Vec<PathBuf>> {
Some(repos) Some(repos)
} }
fn get_actual_git_directory(path: &Path, is_worktree: bool) -> PathBuf {
match is_worktree {
false => path.to_path_buf(),
true => path.join(GIT_MAIN_WORKTREE_DIRECTORY),
}
}
fn find_repos(root: &Path) -> Option<Vec<Repo>> { fn find_repos(root: &Path) -> Option<Vec<Repo>> {
let mut repos: Vec<Repo> = Vec::new(); let mut repos: Vec<Repo> = Vec::new();
for path in find_repos_without_details(root).unwrap() { for (path, is_worktree) in find_repos_without_details(root).unwrap() {
let repo = match open_repo(&path) { let repo = match open_repo(&path, is_worktree) {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
print_error(&format!("Error opening repo {}: {}", path.display(), e)); print_error(&format!(
"Error opening repo {}{}: {}",
path.display(),
match is_worktree {
true => " as worktree",
false => "",
},
e
));
return None; return None;
} }
}; };
@@ -330,6 +367,7 @@ fn find_repos(root: &Path) -> Option<Vec<Repo>> {
false => path_as_string(path.strip_prefix(&root).unwrap()), false => path_as_string(path.strip_prefix(&root).unwrap()),
}, },
remotes, remotes,
worktree_setup: is_worktree,
}); });
} }
Some(repos) Some(repos)
@@ -386,41 +424,56 @@ fn add_repo_status(table: &mut Table, repo_name: &str, repo_handle: &git2::Repos
out.push(format!("Deleted: {}\n", changes.files_deleted)) out.push(format!("Deleted: {}\n", changes.files_deleted))
} }
out.into_iter().collect::<String>().trim().to_string() out.into_iter().collect::<String>().trim().to_string()
}, }
None => String::from("\u{2714}"), None => String::from("\u{2714}"),
}, },
&repo_status.branches.iter().map(|(branch_name, remote_branch)| { &repo_status
format!("branch: {}{}\n", .branches
&branch_name, .iter()
&match remote_branch { .map(|(branch_name, remote_branch)| {
None => String::from(" <!local>"), format!(
Some((remote_branch_name, remote_tracking_status)) => { "branch: {}{}\n",
format!(" <{}>{}", &branch_name,
remote_branch_name, &match remote_branch {
&match remote_tracking_status { None => String::from(" <!local>"),
RemoteTrackingStatus::UpToDate => String::from(" \u{2714}"), Some((remote_branch_name, remote_tracking_status)) => {
RemoteTrackingStatus::Ahead(d) => format!(" [+{}]", &d), format!(
RemoteTrackingStatus::Behind(d) => format!(" [-{}]", &d), " <{}>{}",
RemoteTrackingStatus::Diverged(d1, d2) => format!(" [-{}/+{}]", &d1,&d2), remote_branch_name,
} &match remote_tracking_status {
) RemoteTrackingStatus::UpToDate => String::from(" \u{2714}"),
RemoteTrackingStatus::Ahead(d) => format!(" [+{}]", &d),
RemoteTrackingStatus::Behind(d) => format!(" [-{}]", &d),
RemoteTrackingStatus::Diverged(d1, d2) =>
format!(" [-{}/+{}]", &d1, &d2),
}
)
}
} }
} )
) })
}).collect::<String>().trim().to_string(), .collect::<String>()
.trim()
.to_string(),
&match repo_status.head { &match repo_status.head {
Some(head) => head, Some(head) => head,
None => String::from("Empty"), None => String::from("Empty"),
}, },
&repo_status.remotes.iter().map(|r| format!("{}\n", r)).collect::<String>().trim().to_string(), &repo_status
.remotes
.iter()
.map(|r| format!("{}\n", r))
.collect::<String>()
.trim()
.to_string(),
]); ]);
} }
fn show_single_repo_status(path: &Path) { fn show_single_repo_status(path: &Path, is_worktree: bool) {
let mut table = Table::new(); let mut table = Table::new();
add_table_header(&mut table); add_table_header(&mut table);
let repo_handle = open_repo(path); let repo_handle = open_repo(path, is_worktree);
if let Err(error) = repo_handle { if let Err(error) = repo_handle {
if error.kind == RepoErrorKind::NotFound { if error.kind == RepoErrorKind::NotFound {
@@ -435,14 +488,14 @@ fn show_single_repo_status(path: &Path) {
None => { None => {
print_warning("Cannot detect repo name. Are you working in /?"); print_warning("Cannot detect repo name. Are you working in /?");
String::from("unknown") String::from("unknown")
}, }
Some(file_name) => match file_name.to_str() { Some(file_name) => match file_name.to_str() {
None => { None => {
print_warning("Name of current directory is not valid UTF-8"); print_warning("Name of current directory is not valid UTF-8");
String::from("invalid") String::from("invalid")
}, }
Some(name) => name.to_string(), Some(name) => name.to_string(),
} },
}; };
add_repo_status(&mut table, &repo_name, &repo_handle.unwrap()); add_repo_status(&mut table, &repo_name, &repo_handle.unwrap());
@@ -463,15 +516,21 @@ fn show_status(config: Config) {
let repo_path = root_path.join(&repo.name); let repo_path = root_path.join(&repo.name);
if !repo_path.exists() { if !repo_path.exists() {
print_repo_error(&repo.name, &"Repository does not exist. Run sync?".to_string()); print_repo_error(
&repo.name,
&"Repository does not exist. Run sync?".to_string(),
);
continue; continue;
} }
let repo_handle = open_repo(&repo_path); let repo_handle = open_repo(&repo_path, repo.worktree_setup);
if let Err(error) = repo_handle { if let Err(error) = repo_handle {
if error.kind == RepoErrorKind::NotFound { if error.kind == RepoErrorKind::NotFound {
print_repo_error(&repo.name, &"No git repository found. Run sync?".to_string()); print_repo_error(
&repo.name,
&"No git repository found. Run sync?".to_string(),
);
} else { } else {
print_repo_error(&repo.name, &format!("Opening repository failed: {}", error)); print_repo_error(&repo.name, &format!("Opening repository failed: {}", error));
} }
@@ -500,31 +559,30 @@ pub fn run() {
}; };
sync_trees(config); sync_trees(config);
} }
cmd::SubCommand::Status(args) => { cmd::SubCommand::Status(args) => match &args.config {
match &args.config { Some(config_path) => {
Some(config_path) => { let config = match config::read_config(config_path) {
let config = match config::read_config(config_path) { Ok(c) => c,
Ok(c) => c, Err(e) => {
Err(e) => { print_error(&e);
print_error(&e); process::exit(1);
process::exit(1); }
} };
}; show_status(config);
show_status(config);
},
None => {
let dir = match std::env::current_dir(){
Ok(d) => d,
Err(e) => {
print_error(&format!("Could not open current directory: {}", e));
process::exit(1);
},
};
show_single_repo_status(&dir);
}
} }
} None => {
let dir = match std::env::current_dir() {
Ok(d) => d,
Err(e) => {
print_error(&format!("Could not open current directory: {}", e));
process::exit(1);
}
};
let has_worktree = dir.join(GIT_MAIN_WORKTREE_DIRECTORY).exists();
show_single_repo_status(&dir, has_worktree);
}
},
cmd::SubCommand::Find(find) => { cmd::SubCommand::Find(find) => {
let path = Path::new(&find.path); let path = Path::new(&find.path);
if !path.exists() { if !path.exists() {
@@ -541,9 +599,98 @@ pub fn run() {
trees: vec![find_in_tree(path).unwrap()], trees: vec![find_in_tree(path).unwrap()],
}; };
println!("{:#?}", config);
let toml = toml::to_string(&config).unwrap(); let toml = toml::to_string(&config).unwrap();
print!("{}", toml); print!("{}", toml);
} },
cmd::SubCommand::Worktree(args) => {
let dir = match std::env::current_dir() {
Ok(d) => d,
Err(e) => {
print_error(&format!("Could not open current directory: {}", e));
process::exit(1);
}
};
match args.action {
cmd::WorktreeAction::Add(action_args) => {
let repo = match open_repo(&dir, true) {
Ok(r) => r,
Err(e) => {
match e.kind {
RepoErrorKind::NotFound => print_error(&"Current directory does not contain a worktree setup"),
_ => print_error(&format!("Error opening repo: {}", e)),
}
process::exit(1);
}
};
let worktrees = repo.worktrees().unwrap().iter().map(|e| e.unwrap()).collect::<String>();
if worktrees.contains(&action_args.name) {
print_error("Worktree directory already exists");
process::exit(1);
}
match repo.worktree(&action_args.name, &dir.join(&action_args.name), None) {
Ok(_) => print_success(&format!("Worktree {} created", &action_args.name)),
Err(e) => {
print_error(&format!("Error creating worktree: {}", e));
process::exit(1);
}
};
},
cmd::WorktreeAction::Delete(action_args) => {
let worktree_dir = dir.join(&action_args.name);
if !worktree_dir.exists() {
print_error(&format!("{} does not exist", &action_args.name));
process::exit(1);
}
let repo = match open_repo(&worktree_dir, false) {
Ok(r) => r,
Err(e) => {
print_error(&format!("Error opening repo: {}", e));
process::exit(1);
},
};
let status = get_repo_status(&repo);
if let Some(_) = status.changes {
println!("Changes found in worktree, refusing to delete!");
process::exit(1);
}
let mut branch = repo.find_branch(&action_args.name, git2::BranchType::Local).unwrap();
match branch.upstream() {
Ok(remote_branch) => {
let (ahead, behind) = repo
.graph_ahead_behind(
branch.get().peel_to_commit().unwrap().id(),
remote_branch.get().peel_to_commit().unwrap().id(),
)
.unwrap();
if (ahead, behind) != (0, 0) {
print_error(&format!("Branch {} is not in line with remote branch, refusing to delete worktree!", &action_args.name));
process::exit(1);
}
},
Err(_) => {
print_error(&format!("No remote tracking branch for branch {} found, refusing to delete worktree!", &action_args.name));
process::exit(1);
},
}
match std::fs::remove_dir_all(&worktree_dir) {
Ok(_) => print_success(&format!("Worktree {} deleted", &action_args.name)),
Err(e) => {
print_error(&format!("Error deleting {}: {}", &worktree_dir.display(), e));
process::exit(1);
},
}
repo.find_worktree(&action_args.name).unwrap().prune(None).unwrap();
branch.delete().unwrap();
},
}
},
} }
} }

View File

@@ -46,10 +46,18 @@ pub struct Remote {
pub remote_type: RemoteType, pub remote_type: RemoteType,
} }
fn worktree_setup_default() -> bool {
false
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Repo { pub struct Repo {
pub name: String, pub name: String,
#[serde(default = "worktree_setup_default")]
pub worktree_setup: bool,
pub remotes: Option<Vec<Remote>>, pub remotes: Option<Vec<Remote>>,
} }
@@ -167,8 +175,16 @@ pub fn detect_remote_type(remote_url: &str) -> Option<RemoteType> {
None None
} }
pub fn open_repo(path: &Path) -> Result<Repository, RepoError> { pub fn open_repo(path: &Path, is_worktree: bool) -> Result<Repository, RepoError> {
match Repository::open(path) { let open_func = match is_worktree {
true => Repository::open_bare,
false => Repository::open,
};
let path = match is_worktree {
true => path.join(super::GIT_MAIN_WORKTREE_DIRECTORY),
false => path.to_path_buf(),
};
match open_func(path) {
Ok(r) => Ok(r), Ok(r) => Ok(r),
Err(e) => match e.code() { Err(e) => match e.code() {
git2::ErrorCode::NotFound => Err(RepoError::new(RepoErrorKind::NotFound)), git2::ErrorCode::NotFound => Err(RepoError::new(RepoErrorKind::NotFound)),
@@ -179,24 +195,43 @@ pub fn open_repo(path: &Path) -> Result<Repository, RepoError> {
} }
} }
pub fn init_repo(path: &Path) -> Result<Repository, Box<dyn std::error::Error>> { pub fn init_repo(path: &Path, is_worktree: bool) -> Result<Repository, Box<dyn std::error::Error>> {
match Repository::init(path) { match is_worktree {
Ok(r) => Ok(r), false => match Repository::init(path) {
Err(e) => Err(Box::new(e)), Ok(r) => Ok(r),
Err(e) => Err(Box::new(e)),
},
true => match Repository::init_bare(path.join(super::GIT_MAIN_WORKTREE_DIRECTORY)) {
Ok(r) => Ok(r),
Err(e) => Err(Box::new(e)),
},
} }
} }
pub fn clone_repo(remote: &Remote, path: &Path) -> Result<(), Box<dyn std::error::Error>> { pub fn clone_repo(
remote: &Remote,
path: &Path,
is_worktree: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let clone_target = match is_worktree {
false => path.to_path_buf(),
true => path.join(super::GIT_MAIN_WORKTREE_DIRECTORY),
};
print_action(&format!( print_action(&format!(
"Cloning into \"{}\" from \"{}\"", "Cloning into \"{}\" from \"{}\"",
&path.display(), &clone_target.display(),
&remote.url &remote.url
)); ));
match remote.remote_type { match remote.remote_type {
RemoteType::Https => match Repository::clone(&remote.url, &path) { RemoteType::Https => {
Ok(_) => Ok(()), let mut builder = git2::build::RepoBuilder::new();
Err(e) => Err(Box::new(e)), builder.bare(is_worktree);
}, match builder.clone(&remote.url, &clone_target) {
Ok(_) => Ok(()),
Err(e) => Err(Box::new(e)),
}
}
RemoteType::Ssh => { RemoteType::Ssh => {
let mut callbacks = RemoteCallbacks::new(); let mut callbacks = RemoteCallbacks::new();
callbacks.credentials(|_url, username_from_url, _allowed_types| { callbacks.credentials(|_url, username_from_url, _allowed_types| {
@@ -207,9 +242,10 @@ pub fn clone_repo(remote: &Remote, path: &Path) -> Result<(), Box<dyn std::error
fo.remote_callbacks(callbacks); fo.remote_callbacks(callbacks);
let mut builder = git2::build::RepoBuilder::new(); let mut builder = git2::build::RepoBuilder::new();
builder.bare(is_worktree);
builder.fetch_options(fo); builder.fetch_options(fo);
match builder.clone(&remote.url, path) { match builder.clone(&remote.url, &clone_target) {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(e) => Err(Box::new(e)), Err(e) => Err(Box::new(e)),
} }
@@ -237,7 +273,9 @@ pub fn get_repo_status(repo: &git2::Repository) -> RepoStatus {
false => Some(repo.head().unwrap().shorthand().unwrap().to_string()), false => Some(repo.head().unwrap().shorthand().unwrap().to_string()),
}; };
let statuses = repo.statuses(Some(git2::StatusOptions::new().include_ignored(false))).unwrap(); let statuses = repo
.statuses(Some(git2::StatusOptions::new().include_ignored(false).include_untracked(true)))
.unwrap();
let changes = match statuses.is_empty() { let changes = match statuses.is_empty() {
true => None, true => None,
@@ -265,7 +303,9 @@ pub fn get_repo_status(repo: &git2::Repository) -> RepoStatus {
} }
} }
if (files_new, files_modified, files_deleted) == (0, 0, 0) { if (files_new, files_modified, files_deleted) == (0, 0, 0) {
panic!("is_empty() returned true, but no file changes were detected. This is a bug!"); panic!(
"is_empty() returned true, but no file changes were detected. This is a bug!"
);
} }
Some(RepoChanges { Some(RepoChanges {
files_new, files_new,