diff --git a/Cargo.toml b/Cargo.toml index fe81561..8a93b6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ path = "src/lib.rs" [[bin]] name = "grm" -path = "src/main.rs" +path = "src/grm/main.rs" [dependencies] diff --git a/Justfile b/Justfile index 721426d..1ab9c1d 100644 --- a/Justfile +++ b/Justfile @@ -30,7 +30,7 @@ e2e-venv: test-e2e: e2e-venv release cd ./e2e_tests \ && . ./venv/bin/activate \ - && TMPDIR=/dev/shm python -m pytest . + && TMPDIR=/dev/shm python -m pytest --color=yes . update-dependencies: @cd ./depcheck \ diff --git a/src/config.rs b/src/config.rs index 7a0caec..efcf6e6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,18 +1,48 @@ use serde::{Deserialize, Serialize}; -use super::repo::Repo; +use super::repo::RepoConfig; #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { - pub trees: Vec, + pub trees: Trees, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Trees(Vec); + +impl Trees { + pub fn to_config(self) -> Config { + Config { trees: self } + } + + pub fn from_vec(vec: Vec) -> Self { + Trees(vec) + } + + pub fn as_vec(self) -> Vec { + self.0 + } + + pub fn as_vec_ref(&self) -> &Vec { + self.0.as_ref() + } +} + +impl Config { + pub fn as_toml(&self) -> Result { + match toml::to_string(self) { + Ok(toml) => Ok(toml), + Err(error) => Err(error.to_string()), + } + } } #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Tree { pub root: String, - pub repos: Option>, + pub repos: Option>, } pub fn read_config(path: &str) -> Result { diff --git a/src/cmd.rs b/src/grm/cmd.rs similarity index 100% rename from src/cmd.rs rename to src/grm/cmd.rs diff --git a/src/grm/main.rs b/src/grm/main.rs new file mode 100644 index 0000000..203d82c --- /dev/null +++ b/src/grm/main.rs @@ -0,0 +1,278 @@ +use std::path::Path; +use std::process; + +mod cmd; + +use grm::config; +use grm::output::*; + +fn main() { + let opts = cmd::parse(); + + match opts.subcmd { + cmd::SubCommand::Repos(repos) => match repos.action { + cmd::ReposAction::Sync(sync) => { + let config = match config::read_config(&sync.config) { + Ok(config) => config, + Err(error) => { + print_error(&error); + process::exit(1); + } + }; + match grm::sync_trees(config) { + Ok(success) => { + if !success { + process::exit(1) + } + } + Err(error) => { + print_error(&format!("Error syncing trees: {}", error)); + process::exit(1); + } + } + } + cmd::ReposAction::Status(args) => match &args.config { + Some(config_path) => { + let config = match config::read_config(config_path) { + Ok(config) => config, + Err(error) => { + print_error(&error); + process::exit(1); + } + }; + match grm::table::get_status_table(config) { + Ok((tables, errors)) => { + for table in tables { + println!("{}", table); + } + for error in errors { + print_error(&format!("Error: {}", error)); + } + } + Err(error) => print_error(&format!("Error getting status: {}", error)), + } + } + None => { + let dir = match std::env::current_dir() { + Ok(dir) => dir, + Err(error) => { + print_error(&format!("Could not open current directory: {}", error)); + process::exit(1); + } + }; + + match grm::table::show_single_repo_status(&dir) { + Ok((table, warnings)) => { + println!("{}", table); + for warning in warnings { + print_warning(&warning); + } + } + Err(error) => print_error(&format!("Error getting status: {}", error)), + } + } + }, + cmd::ReposAction::Find(find) => { + let path = Path::new(&find.path); + if !path.exists() { + print_error(&format!("Path \"{}\" does not exist", path.display())); + process::exit(1); + } + if !path.is_dir() { + print_error(&format!("Path \"{}\" is not a directory", path.display())); + process::exit(1); + } + + let path = match path.canonicalize() { + Ok(path) => path, + Err(error) => { + print_error(&format!( + "Failed to canonicalize path \"{}\". This is a bug. Error message: {}", + &path.display(), + error + )); + process::exit(1); + } + }; + + let found_repos = match grm::find_in_tree(&path) { + Ok(repos) => repos, + Err(error) => { + print_error(&error); + process::exit(1); + } + }; + + let trees = grm::config::Trees::from_vec(vec![found_repos]); + if trees.as_vec_ref().iter().all(|t| match &t.repos { + None => false, + Some(r) => r.is_empty(), + }) { + print_warning("No repositories found"); + } else { + let config = trees.to_config(); + + let toml = match config.as_toml() { + Ok(toml) => toml, + Err(error) => { + print_error(&format!("Failed converting config to TOML: {}", &error)); + process::exit(1); + } + }; + + print!("{}", toml); + } + } + }, + cmd::SubCommand::Worktree(args) => { + let cwd = std::env::current_dir().unwrap_or_else(|error| { + print_error(&format!("Could not open current directory: {}", error)); + process::exit(1); + }); + + match args.action { + cmd::WorktreeAction::Add(action_args) => { + let track = match &action_args.track { + Some(branch) => { + let split = branch.split_once('/'); + + if split.is_none() || + split.unwrap().0.len() == 0 + ||split.unwrap().1.len() == 0 { + print_error("Tracking branch needs to match the pattern /"); + process::exit(1); + }; + + // unwrap() here is safe because we checked for + // is_none() explictily before + let (remote_name, remote_branch_name) = split.unwrap(); + + Some((remote_name, remote_branch_name)) + } + None => None, + }; + + match grm::add_worktree( + &cwd, + &action_args.name, + action_args.branch_namespace.as_deref(), + track, + ) { + Ok(_) => print_success(&format!("Worktree {} created", &action_args.name)), + Err(error) => { + print_error(&format!("Error creating worktree: {}", error)); + process::exit(1); + } + } + } + cmd::WorktreeAction::Delete(action_args) => { + let worktree_dir = cwd.join(&action_args.name); + 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) + { + Ok(_) => print_success(&format!("Worktree {} deleted", &action_args.name)), + Err(error) => { + match error { + grm::WorktreeRemoveFailureReason::Error(msg) => { + print_error(&msg); + process::exit(1); + } + grm::WorktreeRemoveFailureReason::Changes(changes) => { + print_warning(&format!( + "Changes in worktree: {}. Refusing to delete", + changes + )); + } + } + process::exit(1); + } + } + } + cmd::WorktreeAction::Status(_args) => { + let repo = grm::Repo::open(&cwd, true).unwrap_or_else(|error| { + print_error(&format!("Error opening repository: {}", error)); + process::exit(1); + }); + + match grm::table::get_worktree_status_table(&repo, &cwd) { + Ok((table, errors)) => { + println!("{}", table); + for error in errors { + print_error(&format!("Error: {}", error)); + } + } + Err(error) => print_error(&format!("Error getting status: {}", error)), + } + } + cmd::WorktreeAction::Convert(_args) => { + // Converting works like this: + // * Check whether there are uncommitted/unpushed changes + // * Move the contents of .git dir to the worktree directory + // * Remove all files + // * Set `core.bare` to `true` + + let repo = grm::Repo::open(&cwd, false).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); + }); + + let status = repo.status(false).unwrap_or_else(|error| { + print_error(&format!("Failed getting repo changes: {}", error)); + process::exit(1); + }); + if status.changes.is_some() { + print_error("Changes found in repository, refusing to convert"); + process::exit(1); + } + + match repo.convert_to_worktree(&cwd) { + Ok(_) => print_success("Conversion done"), + Err(error) => print_error(&format!("Error during conversion: {}", error)), + } + } + cmd::WorktreeAction::Clean(_args) => { + 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); + }); + + match repo.cleanup_worktrees(&cwd) { + Ok(warnings) => { + for warning in warnings { + print_warning(&warning); + } + }, + Err(error) => { + print_error(&format!("Worktree cleanup failed: {}", error)); + process::exit(1); + } + } + + for unmanaged_worktree in + repo.find_unmanaged_worktrees(&cwd).unwrap_or_else(|error| { + print_error(&format!("Failed finding unmanaged worktrees: {}", error)); + process::exit(1); + }) + { + print_warning(&format!( + "Found {}, which is not a valid worktree directory!", + &unmanaged_worktree + )); + } + } + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index dd32fa6..4b78967 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,20 +4,17 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process; -mod cmd; -mod config; -mod output; +pub mod config; +pub mod output; pub mod repo; +pub mod table; use config::{Config, Tree}; use output::*; -use comfy_table::{Cell, Table}; +use repo::{clone_repo, detect_remote_type, Remote, RepoConfig}; -use repo::{ - clone_repo, detect_remote_type, get_repo_status, init_repo, open_repo, repo_make_bare, - repo_set_config_push, Remote, RemoteTrackingStatus, Repo, RepoErrorKind, -}; +pub use repo::{RemoteTrackingStatus, RepoErrorKind, Repo, WorktreeRemoveFailureReason}; const GIT_MAIN_WORKTREE_DIRECTORY: &str = ".git-main-working-tree"; const BRANCH_NAMESPACE_SEPARATOR: &str = "/"; @@ -65,7 +62,7 @@ mod tests { } } -fn path_as_string(path: &Path) -> String { +pub fn path_as_string(path: &Path) -> String { path.to_path_buf().into_os_string().into_string().unwrap() } @@ -105,208 +102,175 @@ fn expand_path(path: &Path) -> PathBuf { Path::new(&expanded_path).to_path_buf() } -fn get_default_branch(repo: &git2::Repository) -> Result { - match repo.find_branch("main", git2::BranchType::Local) { - Ok(branch) => Ok(branch), - Err(_) => match repo.find_branch("master", git2::BranchType::Local) { - Ok(branch) => Ok(branch), - Err(_) => Err(String::from("Could not determine default branch")), - }, +fn sync_repo(root_path: &Path, repo: &RepoConfig) -> Result<(), String> { + 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; + + if repo_path.exists() { + if repo.worktree_setup && !actual_git_directory.exists() { + return Err(String::from( + "Repo already exists, but is not using a worktree setup", + )); + } + repo_handle = match Repo::open(&repo_path, repo.worktree_setup) { + Ok(repo) => Some(repo), + Err(error) => { + if !repo.worktree_setup && Repo::open(&repo_path, true).is_ok() { + return Err(String::from( + "Repo already exists, but is using a worktree setup", + )); + } else { + return Err(format!("Opening repository failed: {}", error)); + } + } + }; + } else if matches!(&repo.remotes, None) || repo.remotes.as_ref().unwrap().is_empty() { + print_repo_action( + &repo.name, + "Repository does not have remotes configured, initializing new", + ); + repo_handle = match Repo::init(&repo_path, repo.worktree_setup) { + Ok(r) => { + print_repo_success(&repo.name, "Repository created"); + Some(r) + } + Err(e) => { + return Err(format!("Repository failed during init: {}", e)); + } + } + } else { + let first = repo.remotes.as_ref().unwrap().first().unwrap(); + + match clone_repo(first, &repo_path, repo.worktree_setup) { + Ok(_) => { + print_repo_success(&repo.name, "Repository successfully cloned"); + } + Err(e) => { + return Err(format!("Repository failed during clone: {}", e)); + } + }; } + if let Some(remotes) = &repo.remotes { + let repo_handle = repo_handle.unwrap_or_else(|| { + Repo::open(&repo_path, repo.worktree_setup).unwrap_or_else(|_| process::exit(1)) + }); + + let current_remotes: Vec = repo_handle + .remotes() + .map_err(|error| format!("Repository failed during getting the remotes: {}", error))?; + + for remote in remotes { + let current_remote = repo_handle.find_remote(&remote.name)?; + + match current_remote { + Some(current_remote) => { + let current_url = current_remote.url(); + + if remote.url != current_url { + print_repo_action( + &repo.name, + &format!("Updating remote {} to \"{}\"", &remote.name, &remote.url), + ); + if let Err(e) = repo_handle.remote_set_url(&remote.name, &remote.url) { + return Err(format!("Repository failed during setting of the remote URL for remote \"{}\": {}", &remote.name, e)); + }; + } + }, + None => { + print_repo_action( + &repo.name, + &format!( + "Setting up new remote \"{}\" to \"{}\"", + &remote.name, &remote.url + ), + ); + if let Err(e) = repo_handle.new_remote(&remote.name, &remote.url) { + return Err(format!( + "Repository failed during setting the remotes: {}", + e + )); + } + } + } + } + + for current_remote in ¤t_remotes { + if !remotes.iter().any(|r| &r.name == current_remote) { + print_repo_action( + &repo.name, + &format!("Deleting remote \"{}\"", ¤t_remote,), + ); + if let Err(e) = repo_handle.remote_delete(current_remote) { + return Err(format!( + "Repository failed during deleting remote \"{}\": {}", + ¤t_remote, e + )); + } + } + } + } + Ok(()) } -fn sync_trees(config: Config) -> bool { +pub fn find_unmanaged_repos( + root_path: &Path, + managed_repos: &[RepoConfig], +) -> Result, String> { + let mut unmanaged_repos = Vec::new(); + + for repo in find_repo_paths(root_path)? { + let name = path_as_string(repo.strip_prefix(&root_path).unwrap()); + if !managed_repos.iter().any(|r| r.name == name) { + unmanaged_repos.push(name); + } + } + Ok(unmanaged_repos) +} + +pub fn sync_trees(config: Config) -> Result { let mut failures = false; - for tree in config.trees { + for tree in config.trees.as_vec() { let repos = tree.repos.unwrap_or_default(); let root_path = expand_path(Path::new(&tree.root)); for repo in &repos { - 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; - - if repo_path.exists() { - if repo.worktree_setup && !actual_git_directory.exists() { - print_repo_error( - &repo.name, - "Repo already exists, but is not using a worktree setup", - ); + match sync_repo(&root_path, repo) { + Ok(_) => print_repo_success(&repo.name, "OK"), + Err(error) => { + print_repo_error(&repo.name, &error); failures = true; - continue; - } - repo_handle = match open_repo(&repo_path, repo.worktree_setup) { - Ok(repo) => Some(repo), - Err(error) => { - if !repo.worktree_setup { - if open_repo(&repo_path, true).is_ok() { - print_repo_error( - &repo.name, - "Repo already exists, but is using a worktree setup", - ); - } - } else { - print_repo_error( - &repo.name, - &format!("Opening repository failed: {}", error), - ); - } - failures = true; - continue; - } - }; - } else if matches!(&repo.remotes, None) || repo.remotes.as_ref().unwrap().is_empty() { - print_repo_action( - &repo.name, - "Repository does not have remotes configured, initializing new", - ); - repo_handle = match init_repo(&repo_path, repo.worktree_setup) { - Ok(r) => { - print_repo_success(&repo.name, "Repository created"); - Some(r) - } - Err(e) => { - print_repo_error( - &repo.name, - &format!("Repository failed during init: {}", e), - ); - None - } - } - } else { - let first = repo.remotes.as_ref().unwrap().first().unwrap(); - - match clone_repo(first, &repo_path, repo.worktree_setup) { - Ok(_) => { - print_repo_success(&repo.name, "Repository successfully cloned"); - } - Err(e) => { - print_repo_error( - &repo.name, - &format!("Repository failed during clone: {}", e), - ); - continue; - } - }; - } - if let Some(remotes) = &repo.remotes { - let repo_handle = repo_handle.unwrap_or_else(|| { - open_repo(&repo_path, repo.worktree_setup).unwrap_or_else(|_| process::exit(1)) - }); - - let current_remotes: Vec = match repo_handle.remotes() { - Ok(r) => r, - Err(e) => { - print_repo_error( - &repo.name, - &format!("Repository failed during getting the remotes: {}", e), - ); - failures = true; - continue; - } - } - .iter() - .flatten() - .map(|r| r.to_owned()) - .collect(); - - for remote in remotes { - if !current_remotes.iter().any(|r| *r == remote.name) { - print_repo_action( - &repo.name, - &format!( - "Setting up new remote \"{}\" to \"{}\"", - &remote.name, &remote.url - ), - ); - if let Err(e) = repo_handle.remote(&remote.name, &remote.url) { - print_repo_error( - &repo.name, - &format!("Repository failed during setting the remotes: {}", e), - ); - failures = true; - continue; - } - } else { - let current_remote = repo_handle.find_remote(&remote.name).unwrap(); - let current_url = match current_remote.url() { - Some(url) => url, - None => { - print_repo_error(&repo.name, &format!("Repository failed during getting of the remote URL for remote \"{}\". This is most likely caused by a non-utf8 remote name", remote.name)); - failures = true; - continue; - } - }; - if remote.url != current_url { - print_repo_action( - &repo.name, - &format!("Updating remote {} to \"{}\"", &remote.name, &remote.url), - ); - if let Err(e) = repo_handle.remote_set_url(&remote.name, &remote.url) { - print_repo_error(&repo.name, &format!("Repository failed during setting of the remote URL for remote \"{}\": {}", &remote.name, e)); - failures = true; - continue; - }; - } - } - } - - for current_remote in ¤t_remotes { - if !remotes.iter().any(|r| &r.name == current_remote) { - print_repo_action( - &repo.name, - &format!("Deleting remote \"{}\"", ¤t_remote,), - ); - if let Err(e) = repo_handle.remote_delete(current_remote) { - print_repo_error( - &repo.name, - &format!( - "Repository failed during deleting remote \"{}\": {}", - ¤t_remote, e - ), - ); - failures = true; - continue; - } - } } } - - print_repo_success(&repo.name, "OK"); } - let current_repos = match find_repos_without_details(&root_path) { - Ok(repos) => repos, - Err(error) => { - print_error(&error.to_string()); - failures = true; - continue; + match find_unmanaged_repos(&root_path, &repos) { + Ok(unmanaged_repos) => { + for name in unmanaged_repos { + print_warning(&format!("Found unmanaged repository: {}", name)); + } } - }; - - for (repo, _) in current_repos { - let name = path_as_string(repo.strip_prefix(&root_path).unwrap()); - if !repos.iter().any(|r| r.name == name) { - print_warning(&format!("Found unmanaged repository: {}", name)); + Err(error) => { + print_error(&format!("Error getting unmanaged repos: {}", error)); + failures = true; } } } - !failures + Ok(!failures) } -fn find_repos_without_details(path: &Path) -> Result, String> { - let mut repos: Vec<(PathBuf, bool)> = Vec::new(); +/// Finds repositories recursively, returning their path +fn find_repo_paths(path: &Path) -> Result, String> { + let mut repos = Vec::new(); let git_dir = path.join(".git"); let git_worktree = path.join(GIT_MAIN_WORKTREE_DIRECTORY); - if git_dir.exists() { - repos.push((path.to_path_buf(), false)); - } else if git_worktree.exists() { - repos.push((path.to_path_buf(), true)); + if git_dir.exists() || git_worktree.exists() { + repos.push(path.to_path_buf()); } else { match fs::read_dir(path) { Ok(contents) => { @@ -318,7 +282,7 @@ fn find_repos_without_details(path: &Path) -> Result, Strin continue; } if path.is_dir() { - match find_repos_without_details(&path) { + match find_repo_paths(&path) { Ok(ref mut r) => repos.append(r), Err(error) => return Err(error), } @@ -355,97 +319,64 @@ fn get_actual_git_directory(path: &Path, is_worktree: bool) -> PathBuf { } } -fn find_repos(root: &Path) -> Option<(Vec, bool)> { - let mut repos: Vec = Vec::new(); +/// Find all git repositories under root, recursively +/// +/// The bool in the return value specifies whether there is a repository +/// in root itself. +fn find_repos(root: &Path) -> Result, bool)>, String> { + let mut repos: Vec = Vec::new(); let mut repo_in_root = false; - for (path, is_worktree) in find_repos_without_details(root).unwrap() { + for path in find_repo_paths(root)? { + let is_worktree = Repo::detect_worktree(&path); if path == root { repo_in_root = true; } - match open_repo(&path, is_worktree) { - Err(e) => { - print_error(&format!( + + match Repo::open(&path, is_worktree) { + Err(error) => { + return Err(format!( "Error opening repo {}{}: {}", path.display(), match is_worktree { true => " as worktree", false => "", }, - e - )); - } + error + )) + }, Ok(repo) => { - let remotes = match repo.remotes() { - Ok(remotes) => { - let mut results: Vec = Vec::new(); - for remote in remotes.iter() { - match remote { - Some(remote_name) => { - match repo.find_remote(remote_name) { - Ok(remote) => { - let name = match remote.name() { - Some(name) => name.to_string(), - None => { - print_repo_error(&path_as_string(&path), &format!("Falied getting name of remote \"{}\". This is most likely caused by a non-utf8 remote name", remote_name)); - process::exit(1); - } - }; - let url = match remote.url() { - Some(url) => url.to_string(), - None => { - print_repo_error(&path_as_string(&path), &format!("Falied getting URL of remote \"{}\". This is most likely caused by a non-utf8 URL", name)); - process::exit(1); - } - }; - let remote_type = match detect_remote_type(&url) { - Some(t) => t, - None => { - print_repo_error( - &path_as_string(&path), - &format!( - "Could not detect remote type of \"{}\"", - &url - ), - ); - process::exit(1); - } - }; - results.push(Remote { - name, - url, - remote_type, - }); - } - Err(e) => { - print_repo_error( - &path_as_string(&path), - &format!( - "Error getting remote {}: {}", - remote_name, e - ), - ); - process::exit(1); - } - }; - } + let remotes = repo.remotes().map_err(|error| { + format!("{}: Error getting remotes: {}", &path_as_string(&path), error) + })?; + + let mut results: Vec = Vec::new(); + for remote_name in remotes.iter() { + match repo.find_remote(remote_name)? { + Some(remote) => { + let name = remote.name(); + let url = remote.url(); + let remote_type = match detect_remote_type(&url) { + Some(t) => t, None => { - print_repo_error(&path_as_string(&path), "Error getting remote. This is most likely caused by a non-utf8 remote name"); - process::exit(1); + return Err(format!("{}: Could not detect remote type of \"{}\"", &path_as_string(&path), &url)); } }; + + results.push(Remote { + name, + url, + remote_type, + }); } - Some(results) - } - Err(e) => { - print_repo_error( - &path_as_string(&path), - &format!("Error getting remotes: {}", e), - ); - process::exit(1); - } - }; - repos.push(Repo { + None => { + return Err(format!("{}: Remote {} not found", &path_as_string(&path), remote_name)); + } + }; + } + let remotes = results; + + repos.push(RepoConfig { name: match path == root { true => match &root.parent() { Some(parent) => path_as_string(path.strip_prefix(parent).unwrap()), @@ -456,17 +387,18 @@ fn find_repos(root: &Path) -> Option<(Vec, bool)> { } false => path_as_string(path.strip_prefix(&root).unwrap()), }, - remotes, + remotes: Some(remotes), worktree_setup: is_worktree, }); } - }; + } + } - Some((repos, repo_in_root)) + Ok(Some((repos, repo_in_root))) } -fn find_in_tree(path: &Path) -> Option { - let (repos, repo_in_root): (Vec, bool) = match find_repos(path) { +pub fn find_in_tree(path: &Path) -> Result { + let (repos, repo_in_root): (Vec, bool) = match find_repos(path)? { Some((vec, repo_in_root)) => (vec, repo_in_root), None => (Vec::new(), false), }; @@ -476,8 +408,9 @@ fn find_in_tree(path: &Path) -> Option { root = match root.parent() { Some(root) => root.to_path_buf(), None => { - print_error("Cannot detect root directory. Are you working in /?"); - process::exit(1); + return Err(String::from( + "Cannot detect root directory. Are you working in /?", + )); } } } @@ -485,801 +418,80 @@ fn find_in_tree(path: &Path) -> Option { if root.starts_with(&home) { // The tilde is not handled differently, it's just a normal path component for `Path`. // Therefore we can treat it like that during **output**. + // + // The `unwrap()` is safe here as we are testing via `starts_with()` + // beforehand root = Path::new("~").join(root.strip_prefix(&home).unwrap()); } - Some(Tree { + Ok(Tree { root: root.into_os_string().into_string().unwrap(), repos: Some(repos), }) } -fn add_table_header(table: &mut Table) { - table - .load_preset(comfy_table::presets::UTF8_FULL) - .apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS) - .set_header(vec![ - Cell::new("Repo"), - Cell::new("Worktree"), - Cell::new("Status"), - Cell::new("Branches"), - Cell::new("HEAD"), - Cell::new("Remotes"), - ]); -} - -fn add_worktree_table_header(table: &mut Table) { - table - .load_preset(comfy_table::presets::UTF8_FULL) - .apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS) - .set_header(vec![ - Cell::new("Worktree"), - Cell::new("Status"), - Cell::new("Branch"), - Cell::new("Remote branch"), - ]); -} - -fn add_repo_status( - table: &mut Table, - repo_name: &str, - repo_handle: &git2::Repository, - is_worktree: bool, -) { - let repo_status = get_repo_status(repo_handle, is_worktree); - - table.add_row(vec![ - repo_name, - match is_worktree { - true => "\u{2714}", - false => "", - }, - &match repo_status.changes { - None => String::from("-"), - Some(changes) => match changes { - Some(changes) => { - let mut out = Vec::new(); - if changes.files_new > 0 { - out.push(format!("New: {}\n", changes.files_new)) - } - if changes.files_modified > 0 { - out.push(format!("Modified: {}\n", changes.files_modified)) - } - if changes.files_deleted > 0 { - out.push(format!("Deleted: {}\n", changes.files_deleted)) - } - out.into_iter().collect::().trim().to_string() - } - None => String::from("\u{2714}"), - }, - }, - &repo_status - .branches - .iter() - .map(|(branch_name, remote_branch)| { - format!( - "branch: {}{}\n", - &branch_name, - &match remote_branch { - None => String::from(" "), - Some((remote_branch_name, remote_tracking_status)) => { - format!( - " <{}>{}", - 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::() - .trim() - .to_string(), - &match is_worktree { - true => String::from(""), - false => match repo_status.head { - Some(head) => head, - None => String::from("Empty"), - }, - }, - &repo_status - .remotes - .iter() - .map(|r| format!("{}\n", r)) - .collect::() - .trim() - .to_string(), - ]); -} - -fn add_worktree_status(table: &mut Table, worktree_name: &str, repo: &git2::Repository) { - let repo_status = get_repo_status(repo, false); - - let head = repo.head().unwrap(); - - if !head.is_branch() { - print_error("No branch checked out in worktree"); - process::exit(1); - } - - let local_branch_name = head.shorthand().unwrap(); - let local_branch = repo - .find_branch(local_branch_name, git2::BranchType::Local) - .unwrap(); - - let upstream_output = match local_branch.upstream() { - Ok(remote_branch) => { - let remote_branch_name = remote_branch.name().unwrap().unwrap().to_string(); - - let (ahead, behind) = repo - .graph_ahead_behind( - local_branch.get().peel_to_commit().unwrap().id(), - remote_branch.get().peel_to_commit().unwrap().id(), - ) - .unwrap(); - - format!( - "{}{}\n", - &remote_branch_name, - &match (ahead, behind) { - (0, 0) => String::from(""), - (d, 0) => format!(" [+{}]", &d), - (0, d) => format!(" [-{}]", &d), - (d1, d2) => format!(" [+{}/-{}]", &d1, &d2), - }, - ) - } - Err(_) => String::from(""), - }; - - table.add_row(vec![ - worktree_name, - &match repo_status.changes { - None => String::from(""), - Some(changes) => match changes { - Some(changes) => { - let mut out = Vec::new(); - if changes.files_new > 0 { - out.push(format!("New: {}\n", changes.files_new)) - } - if changes.files_modified > 0 { - out.push(format!("Modified: {}\n", changes.files_modified)) - } - if changes.files_deleted > 0 { - out.push(format!("Deleted: {}\n", changes.files_deleted)) - } - out.into_iter().collect::().trim().to_string() - } - None => String::from("\u{2714}"), - }, - }, - local_branch_name, - &upstream_output, - ]); -} - -fn show_single_repo_status(path: &Path, is_worktree: bool) { - let mut table = Table::new(); - add_table_header(&mut table); - - let repo_handle = open_repo(path, is_worktree); - - if let Err(error) = repo_handle { - if error.kind == RepoErrorKind::NotFound { - print_error("Directory is not a git directory"); - } else { - print_error(&format!("Opening repository failed: {}", error)); - } - process::exit(1); - }; - - let repo_name = match path.file_name() { - None => { - print_warning("Cannot detect repo name. Are you working in /?"); - String::from("unknown") - } - Some(file_name) => match file_name.to_str() { - None => { - print_warning("Name of current directory is not valid UTF-8"); - String::from("invalid") - } - Some(name) => name.to_string(), - }, - }; - - add_repo_status(&mut table, &repo_name, &repo_handle.unwrap(), is_worktree); - - println!("{}", table); -} - -fn show_status(config: Config) { - for tree in config.trees { - let repos = tree.repos.unwrap_or_default(); - - let root_path = expand_path(Path::new(&tree.root)); - - let mut table = Table::new(); - add_table_header(&mut table); - - for repo in &repos { - let repo_path = root_path.join(&repo.name); - - if !repo_path.exists() { - print_repo_error( - &repo.name, - &"Repository does not exist. Run sync?".to_string(), - ); - continue; - } - - let repo_handle = open_repo(&repo_path, repo.worktree_setup); - - if let Err(error) = repo_handle { - if error.kind == RepoErrorKind::NotFound { - print_repo_error( - &repo.name, - &"No git repository found. Run sync?".to_string(), - ); - } else { - print_repo_error(&repo.name, &format!("Opening repository failed: {}", error)); - } - continue; - }; - - let repo_handle = repo_handle.unwrap(); - - add_repo_status(&mut table, &repo.name, &repo_handle, repo.worktree_setup); - } - println!("{}", table); - } -} - -enum WorktreeRemoveFailureReason { - Changes(String), - Error(String), -} - -fn remove_worktree( +pub fn add_worktree( + directory: &Path, name: &str, - worktree_dir: &Path, - force: bool, - main_repo: &git2::Repository, -) -> Result<(), WorktreeRemoveFailureReason> { - if !worktree_dir.exists() { - return Err(WorktreeRemoveFailureReason::Error(format!( - "{} does not exist", - name - ))); + branch_namespace: Option<&str>, + track: Option<(&str, &str)>, +) -> Result<(), String> { + let repo = Repo::open(directory, true).map_err(|error| match error.kind { + RepoErrorKind::NotFound => { + String::from("Current directory does not contain a worktree setup") + } + _ => format!("Error opening repo: {}", error), + })?; + + if repo.find_worktree(name).is_ok() { + return Err(format!("Worktree {} already exists", &name)); } - let worktree_repo = match open_repo(worktree_dir, false) { - Ok(r) => r, - Err(e) => { - return Err(WorktreeRemoveFailureReason::Error(format!( - "Error opening repo: {}", - e - ))); + + let branch_name = match branch_namespace { + Some(prefix) => format!("{}{}{}", &prefix, BRANCH_NAMESPACE_SEPARATOR, &name), + None => name.to_string(), + }; + + let mut remote_branch_exists = false; + + let checkout_commit = match track { + Some((remote_name, remote_branch_name)) => { + let remote_branch = repo.find_remote_branch(remote_name, remote_branch_name); + match remote_branch { + Ok(branch) => { + remote_branch_exists = true; + branch.to_commit()? + } + Err(_) => { + remote_branch_exists = false; + repo.default_branch()?.to_commit()? + } + } + } + None => repo.default_branch()?.to_commit()?, + }; + + let mut target_branch = match repo.find_local_branch(&branch_name) { + Ok(branchref) => branchref, + Err(_) => repo.create_branch(&branch_name, &checkout_commit)?, + }; + + if let Some((remote_name, remote_branch_name)) = track { + if remote_branch_exists { + target_branch.set_upstream(remote_name, remote_branch_name)?; + } else { + let mut remote = repo + .find_remote(remote_name) + .map_err(|error| format!("Error getting remote {}: {}", remote_name, error))? + .ok_or_else(|| format!("Remote {} not found", remote_name))?; + + remote.push(&target_branch.name()?, remote_branch_name, &repo)?; + + target_branch.set_upstream(remote_name, remote_branch_name)?; } }; - let head = worktree_repo.head().unwrap(); - if !head.is_branch() { - return Err(WorktreeRemoveFailureReason::Error(String::from( - "No branch checked out in worktree", - ))); - } - - let branch_name = head.shorthand().unwrap(); - if branch_name != name - && !branch_name.ends_with(&format!("{}{}", BRANCH_NAMESPACE_SEPARATOR, name)) - { - return Err(WorktreeRemoveFailureReason::Error(format!( - "Branch {} is checked out in worktree, this does not look correct", - &branch_name - ))); - } - - let mut branch = worktree_repo - .find_branch(branch_name, git2::BranchType::Local) - .unwrap(); - - if !force { - let status = get_repo_status(&worktree_repo, false); - if status.changes.unwrap().is_some() { - return Err(WorktreeRemoveFailureReason::Changes(String::from( - "Changes found in worktree", - ))); - } - - match branch.upstream() { - Ok(remote_branch) => { - let (ahead, behind) = worktree_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) { - return Err(WorktreeRemoveFailureReason::Changes(format!( - "Branch {} is not in line with remote branch", - name - ))); - } - } - Err(_) => { - return Err(WorktreeRemoveFailureReason::Changes(format!( - "No remote tracking branch for branch {} found", - name - ))); - } - } - } - - if let Err(e) = std::fs::remove_dir_all(&worktree_dir) { - return Err(WorktreeRemoveFailureReason::Error(format!( - "Error deleting {}: {}", - &worktree_dir.display(), - e - ))); - } - main_repo.find_worktree(name).unwrap().prune(None).unwrap(); - branch.delete().unwrap(); + repo.new_worktree(name, &directory.join(&name), &target_branch)?; Ok(()) } - -pub fn run() { - let opts = cmd::parse(); - - match opts.subcmd { - cmd::SubCommand::Repos(repos) => match repos.action { - cmd::ReposAction::Sync(sync) => { - let config = match config::read_config(&sync.config) { - Ok(c) => c, - Err(e) => { - print_error(&e); - process::exit(1); - } - }; - if !sync_trees(config) { - process::exit(1); - } - } - cmd::ReposAction::Status(args) => match &args.config { - Some(config_path) => { - let config = match config::read_config(config_path) { - Ok(c) => c, - Err(e) => { - print_error(&e); - process::exit(1); - } - }; - 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); - } - }; - - let has_worktree = dir.join(GIT_MAIN_WORKTREE_DIRECTORY).exists(); - show_single_repo_status(&dir, has_worktree); - } - }, - cmd::ReposAction::Find(find) => { - let path = Path::new(&find.path); - if !path.exists() { - print_error(&format!("Path \"{}\" does not exist", path.display())); - process::exit(1); - } - let path = &path.canonicalize().unwrap(); - if !path.is_dir() { - print_error(&format!("Path \"{}\" is not a directory", path.display())); - process::exit(1); - } - - let trees = vec![find_in_tree(path).unwrap()]; - if trees.iter().all(|t| match &t.repos { - None => false, - Some(r) => r.is_empty(), - }) { - print_warning("No repositories found"); - } else { - let config = Config { trees }; - - let toml = toml::to_string(&config).unwrap(); - - 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); - } - }; - - fn get_repo(dir: &Path) -> git2::Repository { - 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); - } - } - } - - fn get_worktrees(repo: &git2::Repository) -> Vec { - repo.worktrees() - .unwrap() - .iter() - .map(|e| e.unwrap().to_string()) - .collect::>() - } - - match args.action { - cmd::WorktreeAction::Add(action_args) => { - let repo = get_repo(&dir); - let worktrees = get_worktrees(&repo); - if worktrees.contains(&action_args.name) { - print_error("Worktree already exists"); - process::exit(1); - } - - let branch_name = match action_args.branch_namespace { - Some(prefix) => format!( - "{}{}{}", - &prefix, BRANCH_NAMESPACE_SEPARATOR, &action_args.name - ), - None => action_args.name.clone(), - }; - - let mut remote_branch_exists = false; - - let checkout_commit = match &action_args.track { - Some(upstream_branch_name) => { - match repo.find_branch(upstream_branch_name, git2::BranchType::Remote) { - Ok(branch) => { - remote_branch_exists = true; - branch.into_reference().peel_to_commit().unwrap() - } - Err(_) => { - remote_branch_exists = false; - get_default_branch(&repo) - .unwrap() - .into_reference() - .peel_to_commit() - .unwrap() - } - } - } - None => get_default_branch(&repo) - .unwrap() - .into_reference() - .peel_to_commit() - .unwrap(), - }; - - let mut target_branch = - match repo.find_branch(&branch_name, git2::BranchType::Local) { - Ok(branchref) => branchref, - Err(_) => repo.branch(&branch_name, &checkout_commit, false).unwrap(), - }; - - if let Some(upstream_branch_name) = action_args.track { - if remote_branch_exists { - target_branch - .set_upstream(Some(&upstream_branch_name)) - .unwrap(); - } else { - let split_at = upstream_branch_name.find('/').unwrap_or(0); - if split_at == 0 || split_at >= upstream_branch_name.len() - 1 { - print_error("Tracking branch needs to match the pattern /"); - process::exit(1); - } - - let (remote_name, remote_branch_name) = - &upstream_branch_name.split_at(split_at); - // strip the remaining slash - let remote_branch_name = &remote_branch_name[1..]; - - let mut remote = match repo.find_remote(remote_name) { - Ok(r) => r, - Err(_) => { - print_error(&format!("Remote {} not found", remote_name)); - process::exit(1); - } - }; - - let mut callbacks = git2::RemoteCallbacks::new(); - callbacks.push_update_reference(|_, status| { - if let Some(message) = status { - return Err(git2::Error::new( - git2::ErrorCode::GenericError, - git2::ErrorClass::None, - message, - )); - } - Ok(()) - }); - callbacks.credentials(|_url, username_from_url, _allowed_types| { - git2::Cred::ssh_key_from_agent(username_from_url.unwrap()) - }); - - let mut push_options = git2::PushOptions::new(); - push_options.remote_callbacks(callbacks); - - let push_refspec = format!( - "+{}:refs/heads/{}", - target_branch.get().name().unwrap(), - remote_branch_name - ); - remote - .push(&[push_refspec], Some(&mut push_options)) - .unwrap_or_else(|error| { - print_error(&format!( - "Pushing to {} ({}) failed: {}", - remote_name, - remote.url().unwrap(), - error - )); - process::exit(1); - }); - - target_branch - .set_upstream(Some(&upstream_branch_name)) - .unwrap(); - } - }; - - let worktree = repo.worktree( - &action_args.name, - &dir.join(&action_args.name), - Some(git2::WorktreeAddOptions::new().reference(Some(target_branch.get()))), - ); - - match worktree { - 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); - let repo = get_repo(&dir); - - match remove_worktree( - &action_args.name, - &worktree_dir, - action_args.force, - &repo, - ) { - Ok(_) => print_success(&format!("Worktree {} deleted", &action_args.name)), - Err(error) => { - match error { - WorktreeRemoveFailureReason::Error(msg) => { - print_error(&msg); - process::exit(1); - } - WorktreeRemoveFailureReason::Changes(changes) => { - print_warning(&format!( - "Changes in worktree: {}. Refusing to delete", - changes - )); - } - } - process::exit(1); - } - } - } - cmd::WorktreeAction::Status(_args) => { - let repo = get_repo(&dir); - let worktrees = get_worktrees(&repo); - let mut table = Table::new(); - add_worktree_table_header(&mut table); - for worktree in &worktrees { - let repo_dir = &dir.join(&worktree); - if repo_dir.exists() { - let repo = match open_repo(repo_dir, false) { - Ok(r) => r, - Err(e) => { - print_error(&format!("Error opening repo: {}", e)); - process::exit(1); - } - }; - add_worktree_status(&mut table, worktree, &repo); - } else { - print_warning(&format!( - "Worktree {} does not have a directory", - &worktree - )); - } - } - for entry in std::fs::read_dir(&dir).unwrap() { - let dirname = path_as_string( - &entry - .unwrap() - .path() - .strip_prefix(&dir) - .unwrap() - .to_path_buf(), - ); - if dirname == GIT_MAIN_WORKTREE_DIRECTORY { - continue; - } - if !&worktrees.contains(&dirname) { - print_warning(&format!( - "Found {}, which is not a valid worktree directory!", - &dirname - )); - } - } - println!("{}", table); - } - cmd::WorktreeAction::Convert(_args) => { - // Converting works like this: - // * Check whether there are uncommitted/unpushed changes - // * Move the contents of .git dir to the worktree directory - // * Remove all files - // * Set `core.bare` to `true` - - let repo = open_repo(&dir, false).unwrap_or_else(|error| { - if error.kind == RepoErrorKind::NotFound { - print_error("Directory does not contain a git repository"); - } else { - print_error(&format!("Opening repository failed: {}", error)); - } - process::exit(1); - }); - - let status = get_repo_status(&repo, false); - if status.changes.unwrap().is_some() { - print_error("Changes found in repository, refusing to convert"); - } - - if let Err(error) = std::fs::rename(".git", GIT_MAIN_WORKTREE_DIRECTORY) { - print_error(&format!("Error moving .git directory: {}", error)); - } - - for entry in match std::fs::read_dir(&dir) { - Ok(iterator) => iterator, - Err(error) => { - print_error(&format!("Opening directory failed: {}", error)); - process::exit(1); - } - } { - match entry { - Ok(entry) => { - let path = entry.path(); - // The path will ALWAYS have a file component - if path.file_name().unwrap() == GIT_MAIN_WORKTREE_DIRECTORY { - continue; - } - if path.is_file() || path.is_symlink() { - if let Err(error) = std::fs::remove_file(&path) { - print_error(&format!("Failed removing {}", error)); - process::exit(1); - } - } else if let Err(error) = std::fs::remove_dir_all(&path) { - print_error(&format!("Failed removing {}", error)); - process::exit(1); - } - } - Err(error) => { - print_error(&format!("Error getting directory entry: {}", error)); - process::exit(1); - } - } - } - - let worktree_repo = open_repo(&dir, true).unwrap_or_else(|error| { - print_error(&format!( - "Opening newly converted repository failed: {}", - error - )); - process::exit(1); - }); - - repo_make_bare(&worktree_repo, true).unwrap_or_else(|error| { - print_error(&format!("Error: {}", error)); - process::exit(1); - }); - - repo_set_config_push(&worktree_repo, "upstream").unwrap_or_else(|error| { - print_error(&format!("Error: {}", error)); - process::exit(1); - }); - - print_success("Conversion done"); - } - cmd::WorktreeAction::Clean(_args) => { - let repo = get_repo(&dir); - let worktrees = get_worktrees(&repo); - - let default_branch = match get_default_branch(&repo) { - Ok(branch) => branch, - Err(error) => { - print_error(&format!("Failed getting default branch: {}", error)); - process::exit(1); - } - }; - - let default_branch_name = default_branch.name().unwrap().unwrap(); - - for worktree in worktrees - .iter() - .filter(|worktree| *worktree != default_branch_name) - { - let repo_dir = &dir.join(&worktree); - if repo_dir.exists() { - match remove_worktree(worktree, repo_dir, false, &repo) { - Ok(_) => print_success(&format!("Worktree {} deleted", &worktree)), - Err(error) => match error { - WorktreeRemoveFailureReason::Changes(changes) => { - print_warning(&format!( - "Changes found in {}: {}, skipping", - &worktree, &changes - )); - continue; - } - WorktreeRemoveFailureReason::Error(e) => { - print_error(&e); - process::exit(1); - } - }, - } - } else { - print_warning(&format!( - "Worktree {} does not have a directory", - &worktree - )); - } - } - - for entry in std::fs::read_dir(&dir).unwrap() { - let dirname = path_as_string( - &entry - .unwrap() - .path() - .strip_prefix(&dir) - .unwrap() - .to_path_buf(), - ); - if dirname == GIT_MAIN_WORKTREE_DIRECTORY { - continue; - } - if dirname == default_branch_name { - continue; - } - if !&worktrees.contains(&dirname) { - print_warning(&format!( - "Found {}, which is not a valid worktree directory!", - &dirname - )); - } - } - } - } - } - } -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 76fe0cb..0000000 --- a/src/main.rs +++ /dev/null @@ -1,5 +0,0 @@ -use grm::run; - -fn main() { - run(); -} diff --git a/src/repo.rs b/src/repo.rs index a917f4a..e127f7e 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -13,6 +13,15 @@ pub enum RemoteType { File, } +pub enum WorktreeRemoveFailureReason { + Changes(String), + Error(String), +} + +pub enum GitPushDefaultSetting { + Upstream +} + #[derive(Debug, PartialEq)] pub enum RepoErrorKind { NotFound, @@ -53,7 +62,7 @@ fn worktree_setup_default() -> bool { #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct Repo { +pub struct RepoConfig { pub name: String, #[serde(default = "worktree_setup_default")] @@ -91,10 +100,7 @@ pub struct RepoStatus { pub head: Option, - // None(_) => Could not get changes (e.g. because it's a worktree setup) - // Some(None) => No changes - // Some(Some(_)) => Changes - pub changes: Option>, + pub changes: Option, pub worktrees: usize, @@ -181,64 +187,737 @@ pub fn detect_remote_type(remote_url: &str) -> Option { None } -pub fn open_repo(path: &Path, is_worktree: bool) -> Result { - 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), - Err(e) => match e.code() { - git2::ErrorCode::NotFound => Err(RepoError::new(RepoErrorKind::NotFound)), - _ => Err(RepoError::new(RepoErrorKind::Unknown( - e.message().to_string(), - ))), - }, - } +pub struct Repo(git2::Repository); +pub struct Branch<'a>(git2::Branch<'a>); + +fn convert_libgit2_error(error: git2::Error) -> String { + error.message().to_string() } -pub fn init_repo(path: &Path, is_worktree: bool) -> Result> { - let repo = match is_worktree { - false => Repository::init(path)?, - true => Repository::init_bare(path.join(super::GIT_MAIN_WORKTREE_DIRECTORY))?, - }; - - if is_worktree { - repo_set_config_push(&repo, "upstream")?; +impl Repo { + pub fn open(path: &Path, is_worktree: bool) -> Result { + let open_func = match is_worktree { + true => Repository::open_bare, + false => Repository::open, + }; + let path = match is_worktree { + true => path.join(crate::GIT_MAIN_WORKTREE_DIRECTORY), + false => path.to_path_buf(), + }; + match open_func(path) { + Ok(r) => Ok(Self(r)), + Err(e) => match e.code() { + git2::ErrorCode::NotFound => Err(RepoError::new(RepoErrorKind::NotFound)), + _ => Err(RepoError::new(RepoErrorKind::Unknown( + convert_libgit2_error(e), + ))), + }, + } } - Ok(repo) -} - -pub fn get_repo_config(repo: &git2::Repository) -> Result { - repo.config() - .map_err(|error| format!("Failed getting repository configuration: {}", error)) -} - -pub fn repo_make_bare(repo: &git2::Repository, value: bool) -> Result<(), String> { - let mut config = get_repo_config(repo)?; - - config - .set_bool(super::GIT_CONFIG_BARE_KEY, value) - .map_err(|error| format!("Could not set {}: {}", super::GIT_CONFIG_BARE_KEY, error)) -} - -pub fn repo_set_config_push(repo: &git2::Repository, value: &str) -> Result<(), String> { - let mut config = get_repo_config(repo)?; - - config - .set_str(super::GIT_CONFIG_PUSH_DEFAULT, value) - .map_err(|error| { - format!( - "Could not set {}: {}", - super::GIT_CONFIG_PUSH_DEFAULT, - error + pub fn graph_ahead_behind( + &self, + local_branch: &Branch, + remote_branch: &Branch, + ) -> Result<(usize, usize), String> { + self.0 + .graph_ahead_behind( + local_branch.commit()?.id().0, + remote_branch.commit()?.id().0, ) + .map_err(convert_libgit2_error) + } + + pub fn head_branch(&self) -> Result { + let head = self.0.head().map_err(convert_libgit2_error)?; + if !head.is_branch() { + return Err(String::from("No branch checked out")); + } + // unwrap() is safe here, as we can be certain that a branch with that + // name exists + let branch = self + .find_local_branch( + &head + .shorthand() + .expect("Branch name is not valid utf-8") + .to_string(), + ) + .unwrap(); + Ok(branch) + } + + pub fn remote_set_url(&self, name: &str, url: &str) -> Result<(), String> { + self.0 + .remote_set_url(name, url) + .map_err(convert_libgit2_error) + } + + pub fn remote_delete(&self, name: &str) -> Result<(), String> { + self.0.remote_delete(name).map_err(convert_libgit2_error) + } + + pub fn is_empty(&self) -> Result { + self.0.is_empty().map_err(convert_libgit2_error) + } + + pub fn is_bare(&self) -> bool { + self.0.is_bare() + } + + pub fn new_worktree( + &self, + name: &str, + directory: &Path, + target_branch: &Branch, + ) -> Result<(), String> { + self.0 + .worktree( + name, + directory, + Some(git2::WorktreeAddOptions::new().reference(Some(target_branch.as_reference()))), + ) + .map_err(convert_libgit2_error)?; + Ok(()) + } + + pub fn remotes(&self) -> Result, String> { + Ok(self + .0 + .remotes() + .map_err(convert_libgit2_error)? + .iter() + .map(|name| name.expect("Remote name is invalid utf-8")) + .map(|name| name.to_owned()) + .collect()) + } + + pub fn new_remote(&self, name: &str, url: &str) -> Result<(), String> { + self.0.remote(name, url).map_err(convert_libgit2_error)?; + Ok(()) + } + + pub fn init(path: &Path, is_worktree: bool) -> Result { + let repo = match is_worktree { + false => Repository::init(path).map_err(convert_libgit2_error)?, + true => Repository::init_bare(path.join(crate::GIT_MAIN_WORKTREE_DIRECTORY)) + .map_err(convert_libgit2_error)?, + }; + + let repo = Repo(repo); + + if is_worktree { + repo.set_config_push(GitPushDefaultSetting::Upstream)?; + } + + Ok(repo) + } + + pub fn config(&self) -> Result { + self.0.config().map_err(convert_libgit2_error) + } + + pub fn find_worktree(&self, name: &str) -> Result<(), String> { + self.0.find_worktree(name).map_err(convert_libgit2_error)?; + Ok(()) + } + + pub fn prune_worktree(&self, name: &str) -> Result<(), String> { + let worktree = self.0.find_worktree(name).map_err(convert_libgit2_error)?; + worktree.prune(None).map_err(convert_libgit2_error)?; + Ok(()) + } + + pub fn find_remote_branch(&self, remote_name: &str, branch_name: &str) -> Result { + Ok(Branch( + self.0 + .find_branch(&format!("{}/{}", remote_name, branch_name), git2::BranchType::Remote) + .map_err(convert_libgit2_error)?, + )) + } + + pub fn find_local_branch(&self, name: &str) -> Result { + Ok(Branch( + self.0 + .find_branch(name, git2::BranchType::Local) + .map_err(convert_libgit2_error)?, + )) + } + + pub fn create_branch(&self, name: &str, target: &Commit) -> Result { + Ok(Branch( + self.0 + .branch(name, &target.0, false) + .map_err(convert_libgit2_error)?, + )) + } + + pub fn make_bare(&self, value: bool) -> Result<(), String> { + let mut config = self.config()?; + + config + .set_bool(crate::GIT_CONFIG_BARE_KEY, value) + .map_err(|error| format!("Could not set {}: {}", crate::GIT_CONFIG_BARE_KEY, error)) + } + + pub fn convert_to_worktree(&self, root_dir: &Path) -> Result<(), String> { + std::fs::rename(".git", crate::GIT_MAIN_WORKTREE_DIRECTORY) + .map_err(|error| format!("Error moving .git directory: {}", error))?; + + for entry in match std::fs::read_dir(&root_dir) { + Ok(iterator) => iterator, + Err(error) => { + return Err(format!("Opening directory failed: {}", error)); + } + } { + match entry { + Ok(entry) => { + let path = entry.path(); + // unwrap is safe here, the path will ALWAYS have a file component + if path.file_name().unwrap() == crate::GIT_MAIN_WORKTREE_DIRECTORY { + continue; + } + if path.is_file() || path.is_symlink() { + if let Err(error) = std::fs::remove_file(&path) { + return Err(format!("Failed removing {}", error)); + } + } else if let Err(error) = std::fs::remove_dir_all(&path) { + return Err(format!("Failed removing {}", error)); + } + } + Err(error) => { + return Err(format!("Error getting directory entry: {}", error)); + } + } + } + + let worktree_repo = Repo::open(root_dir, true) + .map_err(|error| format!("Opening newly converted repository failed: {}", error))?; + + worktree_repo + .make_bare(true) + .map_err(|error| format!("Error: {}", error))?; + + worktree_repo + .set_config_push(GitPushDefaultSetting::Upstream) + .map_err(|error| format!("Error: {}", error))?; + + Ok(()) + } + + pub fn set_config_push(&self, value: GitPushDefaultSetting) -> Result<(), String> { + let mut config = self.config()?; + + config + .set_str(crate::GIT_CONFIG_PUSH_DEFAULT, match value { + GitPushDefaultSetting::Upstream => "upstream", + }) + .map_err(|error| { + format!( + "Could not set {}: {}", + crate::GIT_CONFIG_PUSH_DEFAULT, + error + ) + }) + } + + pub fn status(&self, is_worktree: bool) -> Result { + let operation = match self.0.state() { + git2::RepositoryState::Clean => None, + state => Some(state), + }; + + let empty = self.is_empty()?; + + let remotes = self + .0 + .remotes() + .map_err(convert_libgit2_error)? + .iter() + .map(|repo_name| repo_name.expect("Worktree name is invalid utf-8.")) + .map(|repo_name| repo_name.to_owned()) + .collect::>(); + + let head = match is_worktree { + true => None, + false => match empty { + true => None, + false => Some(self.head_branch()?.name()?), + }, + }; + + let changes = match is_worktree { + true => { + return Err(String::from( + "Cannot get changes as this is a bare worktree repository", + )) + } + false => { + let statuses = self + .0 + .statuses(Some( + git2::StatusOptions::new() + .include_ignored(false) + .include_untracked(true), + )) + .map_err(convert_libgit2_error)?; + + match statuses.is_empty() { + true => None, + false => { + let mut files_new = 0; + let mut files_modified = 0; + let mut files_deleted = 0; + for status in statuses.iter() { + let status_bits = status.status(); + if status_bits.intersects( + git2::Status::INDEX_MODIFIED + | git2::Status::INDEX_RENAMED + | git2::Status::INDEX_TYPECHANGE + | git2::Status::WT_MODIFIED + | git2::Status::WT_RENAMED + | git2::Status::WT_TYPECHANGE, + ) { + files_modified += 1; + } else if status_bits + .intersects(git2::Status::INDEX_NEW | git2::Status::WT_NEW) + { + files_new += 1; + } else if status_bits + .intersects(git2::Status::INDEX_DELETED | git2::Status::WT_DELETED) + { + files_deleted += 1; + } + } + 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!" + ); + } + Some(RepoChanges { + files_new, + files_modified, + files_deleted, + }) + } + } + } + }; + + let worktrees = self.0.worktrees().unwrap().len(); + + let submodules = match is_worktree { + true => None, + false => { + let mut submodules = Vec::new(); + for submodule in self.0.submodules().unwrap() { + let submodule_name = submodule.name().unwrap().to_string(); + + let submodule_status; + let status = self + .0 + .submodule_status(submodule.name().unwrap(), git2::SubmoduleIgnore::None) + .unwrap(); + + if status.intersects( + git2::SubmoduleStatus::WD_INDEX_MODIFIED + | git2::SubmoduleStatus::WD_WD_MODIFIED + | git2::SubmoduleStatus::WD_UNTRACKED, + ) { + submodule_status = SubmoduleStatus::Changed; + } else if status.is_wd_uninitialized() { + submodule_status = SubmoduleStatus::Uninitialized; + } else if status.is_wd_modified() { + submodule_status = SubmoduleStatus::OutOfDate; + } else { + submodule_status = SubmoduleStatus::Clean; + } + + submodules.push((submodule_name, submodule_status)); + } + Some(submodules) + } + }; + + let mut branches = Vec::new(); + for (local_branch, _) in self + .0 + .branches(Some(git2::BranchType::Local)) + .unwrap() + .map(|branch_name| branch_name.unwrap()) + { + let branch_name = local_branch.name().unwrap().unwrap().to_string(); + let remote_branch = match local_branch.upstream() { + Ok(remote_branch) => { + let remote_branch_name = remote_branch.name().unwrap().unwrap().to_string(); + + let (ahead, behind) = self + .0 + .graph_ahead_behind( + local_branch.get().peel_to_commit().unwrap().id(), + remote_branch.get().peel_to_commit().unwrap().id(), + ) + .unwrap(); + + let remote_tracking_status = match (ahead, behind) { + (0, 0) => RemoteTrackingStatus::UpToDate, + (0, d) => RemoteTrackingStatus::Behind(d), + (d, 0) => RemoteTrackingStatus::Ahead(d), + (d1, d2) => RemoteTrackingStatus::Diverged(d1, d2), + }; + Some((remote_branch_name, remote_tracking_status)) + } + // Err => no remote branch + Err(_) => None, + }; + branches.push((branch_name, remote_branch)); + } + + Ok(RepoStatus { + operation, + empty, + remotes, + head, + changes, + worktrees, + submodules, + branches, }) + } + + pub fn default_branch(&self) -> Result { + match self.0.find_branch("main", git2::BranchType::Local) { + Ok(branch) => Ok(Branch(branch)), + Err(_) => match self.0.find_branch("master", git2::BranchType::Local) { + Ok(branch) => Ok(Branch(branch)), + Err(_) => Err(String::from("Could not determine default branch")), + }, + } + } + + // Looks like there is no distinguishing between the error cases + // "no such remote" and "failed to get remote for some reason". + // May be a good idea to handle this explicitly, by returning a + // Result, String> instead, Returning Ok(None) + // on "not found" and Err() on an actual error. + pub fn find_remote(&self, remote_name: &str) -> Result, String> { + let remotes = self.0.remotes().map_err(convert_libgit2_error)?; + + if !remotes.iter().any(|remote| remote.expect("Remote name is invalid utf-8") == remote_name) { + return Ok(None) + } + + Ok(Some(RemoteHandle( + self.0 + .find_remote(remote_name) + .map_err(convert_libgit2_error)?, + ))) + } + + pub fn get_worktrees(&self) -> Result, String> { + Ok(self + .0 + .worktrees() + .map_err(convert_libgit2_error)? + .iter() + .map(|name| name.expect("Worktree name is invalid utf-8")) + .map(|name| name.to_string()) + .collect()) + } + + pub fn remove_worktree( + &self, + name: &str, + worktree_dir: &Path, + force: bool, + ) -> Result<(), WorktreeRemoveFailureReason> { + if !worktree_dir.exists() { + return Err(WorktreeRemoveFailureReason::Error(format!( + "{} does not exist", + name + ))); + } + let worktree_repo = Repo::open(worktree_dir, false).map_err(|error| { + WorktreeRemoveFailureReason::Error(format!("Error opening repo: {}", error)) + })?; + + let local_branch = worktree_repo.head_branch().map_err(|error| { + WorktreeRemoveFailureReason::Error(format!("Failed getting head branch: {}", error)) + })?; + + let branch_name = local_branch.name().map_err(|error| { + WorktreeRemoveFailureReason::Error(format!("Failed getting name of branch: {}", error)) + })?; + + if branch_name != name + && !branch_name.ends_with(&format!("{}{}", crate::BRANCH_NAMESPACE_SEPARATOR, name)) + { + return Err(WorktreeRemoveFailureReason::Error(format!( + "Branch {} is checked out in worktree, this does not look correct", + &branch_name + ))); + } + + let branch = worktree_repo + .find_local_branch(&branch_name) + .map_err(WorktreeRemoveFailureReason::Error)?; + + if !force { + let status = worktree_repo + .status(false) + .map_err(WorktreeRemoveFailureReason::Error)?; + if status.changes.is_some() { + return Err(WorktreeRemoveFailureReason::Changes(String::from( + "Changes found in worktree", + ))); + } + + 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!( + "No remote tracking branch for branch {} found", + name + ))); + } + } + } + + if let Err(e) = std::fs::remove_dir_all(&worktree_dir) { + return Err(WorktreeRemoveFailureReason::Error(format!( + "Error deleting {}: {}", + &worktree_dir.display(), + e + ))); + } + self.prune_worktree(name) + .map_err(WorktreeRemoveFailureReason::Error)?; + branch + .delete() + .map_err(WorktreeRemoveFailureReason::Error)?; + + Ok(()) + } + + pub fn cleanup_worktrees(&self, directory: &Path) -> Result, String> { + let mut warnings = Vec::new(); + + let worktrees = self + .get_worktrees() + .map_err(|error| format!("Getting worktrees failed: {}", error))?; + + let default_branch = self + .default_branch() + .map_err(|error| format!("Failed getting default branch: {}", error))?; + + let default_branch_name = default_branch + .name() + .map_err(|error| format!("Failed getting default branch name: {}", error))?; + + for worktree in worktrees + .iter() + .filter(|worktree| *worktree != &default_branch_name) + { + let repo_dir = &directory.join(&worktree); + if repo_dir.exists() { + match self.remove_worktree(worktree, repo_dir, false) { + Ok(_) => print_success(&format!("Worktree {} deleted", &worktree)), + Err(error) => match error { + WorktreeRemoveFailureReason::Changes(changes) => { + warnings.push(format!( + "Changes found in {}: {}, skipping", + &worktree, &changes + )); + continue; + } + WorktreeRemoveFailureReason::Error(error) => { + return Err(error); + } + }, + } + } else { + warnings.push(format!("Worktree {} does not have a directory", &worktree)); + } + } + Ok(warnings) + } + + pub fn find_unmanaged_worktrees(&self, directory: &Path) -> Result, String> { + let worktrees = self + .get_worktrees() + .map_err(|error| format!("Getting worktrees failed: {}", error))?; + + 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.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(), + ); + + let default_branch = self + .default_branch() + .map_err(|error| format!("Failed getting default branch: {}", error))?; + + let default_branch_name = default_branch + .name() + .map_err(|error| format!("Failed getting default branch name: {}", error))?; + + if dirname == crate::GIT_MAIN_WORKTREE_DIRECTORY { + continue; + } + if dirname == default_branch_name { + continue; + } + if !&worktrees.contains(&dirname) { + unmanaged_worktrees.push(dirname); + } + } + Ok(unmanaged_worktrees) + } + + pub fn detect_worktree(path: &Path) -> bool { + path.join(crate::GIT_MAIN_WORKTREE_DIRECTORY).exists() + } +} + +pub struct RemoteHandle<'a>(git2::Remote<'a>); +pub struct Commit<'a>(git2::Commit<'a>); +pub struct Reference<'a>(git2::Reference<'a>); +pub struct Oid(git2::Oid); + +impl Oid { + pub fn hex_string(&self) -> String { + self.0.to_string() + } +} + +impl Commit<'_> { + pub fn id(&self) -> Oid { + Oid(self.0.id()) + } +} + +impl<'a> Branch<'a> { + pub fn to_commit(self) -> Result, String> { + Ok(Commit( + self.0 + .into_reference() + .peel_to_commit() + .map_err(convert_libgit2_error)?, + )) + } +} + +impl Branch<'_> { + pub fn commit(&self) -> Result { + Ok(Commit( + self.0 + .get() + .peel_to_commit() + .map_err(convert_libgit2_error)?, + )) + } + + pub fn set_upstream(&mut self, remote_name: &str, branch_name: &str) -> Result<(), String> { + self.0 + .set_upstream(Some(&format!("{}/{}", remote_name, branch_name))) + .map_err(convert_libgit2_error)?; + Ok(()) + } + + pub fn name(&self) -> Result { + self.0 + .name() + .map(|name| name.expect("Branch name is invalid utf-8")) + .map_err(convert_libgit2_error) + .map(|name| name.to_string()) + } + + pub fn upstream(&self) -> Result { + Ok(Branch(self.0.upstream().map_err(convert_libgit2_error)?)) + } + + pub fn delete(mut self) -> Result<(), String> { + self.0.delete().map_err(convert_libgit2_error) + } + + // only used internally in this module, exposes libgit2 details + fn as_reference(&self) -> &git2::Reference { + self.0.get() + } +} + +impl RemoteHandle<'_> { + pub fn url(&self) -> String { + self.0 + .url() + .expect("Remote URL is invalid utf-8") + .to_string() + } + + pub fn name(&self) -> String { + self.0 + .name() + .expect("Remote name is invalid utf-8") + .to_string() + } + + pub fn push( + &mut self, + local_branch_name: &str, + remote_branch_name: &str, + _repo: &Repo, + ) -> Result<(), String> { + let mut callbacks = git2::RemoteCallbacks::new(); + callbacks.push_update_reference(|_, status| { + if let Some(message) = status { + return Err(git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::None, + message, + )); + } + Ok(()) + }); + callbacks.credentials(|_url, username_from_url, _allowed_types| { + git2::Cred::ssh_key_from_agent(username_from_url.unwrap()) + }); + + let mut push_options = git2::PushOptions::new(); + push_options.remote_callbacks(callbacks); + + let push_refspec = format!( + "+refs/heads/{}:refs/heads/{}", + local_branch_name, remote_branch_name + ); + self.0 + .push(&[push_refspec], Some(&mut push_options)) + .map_err(|error| { + format!( + "Pushing {} to {} ({}) failed: {}", + local_branch_name, + self.name(), + self.url(), + error + ) + })?; + Ok(()) + } } pub fn clone_repo( @@ -248,7 +927,7 @@ pub fn clone_repo( ) -> Result<(), Box> { let clone_target = match is_worktree { false => path.to_path_buf(), - true => path.join(super::GIT_MAIN_WORKTREE_DIRECTORY), + true => path.join(crate::GIT_MAIN_WORKTREE_DIRECTORY), }; print_action(&format!( @@ -280,163 +959,9 @@ pub fn clone_repo( } if is_worktree { - let repo = open_repo(&clone_target, false)?; - repo_set_config_push(&repo, "upstream")?; + let repo = Repo::open(&clone_target, false)?; + repo.set_config_push(GitPushDefaultSetting::Upstream)?; } Ok(()) } - -pub fn get_repo_status(repo: &git2::Repository, is_worktree: bool) -> RepoStatus { - let operation = match repo.state() { - git2::RepositoryState::Clean => None, - state => Some(state), - }; - - let empty = repo.is_empty().unwrap(); - - let remotes = repo - .remotes() - .unwrap() - .iter() - .map(|repo_name| repo_name.unwrap().to_string()) - .collect::>(); - - let head = match is_worktree { - true => None, - false => match empty { - true => None, - false => Some(repo.head().unwrap().shorthand().unwrap().to_string()), - }, - }; - - let changes = match is_worktree { - true => None, - false => { - let statuses = repo - .statuses(Some( - git2::StatusOptions::new() - .include_ignored(false) - .include_untracked(true), - )) - .unwrap(); - - match statuses.is_empty() { - true => Some(None), - false => { - let mut files_new = 0; - let mut files_modified = 0; - let mut files_deleted = 0; - for status in statuses.iter() { - let status_bits = status.status(); - if status_bits.intersects( - git2::Status::INDEX_MODIFIED - | git2::Status::INDEX_RENAMED - | git2::Status::INDEX_TYPECHANGE - | git2::Status::WT_MODIFIED - | git2::Status::WT_RENAMED - | git2::Status::WT_TYPECHANGE, - ) { - files_modified += 1; - } else if status_bits - .intersects(git2::Status::INDEX_NEW | git2::Status::WT_NEW) - { - files_new += 1; - } else if status_bits - .intersects(git2::Status::INDEX_DELETED | git2::Status::WT_DELETED) - { - files_deleted += 1; - } - } - 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!" - ); - } - Some(Some(RepoChanges { - files_new, - files_modified, - files_deleted, - })) - } - } - } - }; - - let worktrees = repo.worktrees().unwrap().len(); - - let submodules = match is_worktree { - true => None, - false => { - let mut submodules = Vec::new(); - for submodule in repo.submodules().unwrap() { - let submodule_name = submodule.name().unwrap().to_string(); - - let submodule_status; - let status = repo - .submodule_status(submodule.name().unwrap(), git2::SubmoduleIgnore::None) - .unwrap(); - - if status.intersects( - git2::SubmoduleStatus::WD_INDEX_MODIFIED - | git2::SubmoduleStatus::WD_WD_MODIFIED - | git2::SubmoduleStatus::WD_UNTRACKED, - ) { - submodule_status = SubmoduleStatus::Changed; - } else if status.is_wd_uninitialized() { - submodule_status = SubmoduleStatus::Uninitialized; - } else if status.is_wd_modified() { - submodule_status = SubmoduleStatus::OutOfDate; - } else { - submodule_status = SubmoduleStatus::Clean; - } - - submodules.push((submodule_name, submodule_status)); - } - Some(submodules) - } - }; - - let mut branches = Vec::new(); - for (local_branch, _) in repo - .branches(Some(git2::BranchType::Local)) - .unwrap() - .map(|branch_name| branch_name.unwrap()) - { - let branch_name = local_branch.name().unwrap().unwrap().to_string(); - let remote_branch = match local_branch.upstream() { - Ok(remote_branch) => { - let remote_branch_name = remote_branch.name().unwrap().unwrap().to_string(); - - let (ahead, behind) = repo - .graph_ahead_behind( - local_branch.get().peel_to_commit().unwrap().id(), - remote_branch.get().peel_to_commit().unwrap().id(), - ) - .unwrap(); - - let remote_tracking_status = match (ahead, behind) { - (0, 0) => RemoteTrackingStatus::UpToDate, - (0, d) => RemoteTrackingStatus::Behind(d), - (d, 0) => RemoteTrackingStatus::Ahead(d), - (d1, d2) => RemoteTrackingStatus::Diverged(d1, d2), - }; - Some((remote_branch_name, remote_tracking_status)) - } - // Err => no remote branch - Err(_) => None, - }; - branches.push((branch_name, remote_branch)); - } - - RepoStatus { - operation, - empty, - remotes, - head, - changes, - worktrees, - submodules, - branches, - } -} diff --git a/src/table.rs b/src/table.rs new file mode 100644 index 0000000..9b94c5e --- /dev/null +++ b/src/table.rs @@ -0,0 +1,311 @@ +use comfy_table::{Cell, Table}; + +use std::path::Path; + +fn add_table_header(table: &mut Table) { + table + .load_preset(comfy_table::presets::UTF8_FULL) + .apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS) + .set_header(vec![ + Cell::new("Repo"), + Cell::new("Worktree"), + Cell::new("Status"), + Cell::new("Branches"), + Cell::new("HEAD"), + Cell::new("Remotes"), + ]); +} + +fn add_repo_status( + table: &mut Table, + repo_name: &str, + repo_handle: &crate::Repo, + is_worktree: bool, +) -> Result<(), String> { + let repo_status = repo_handle.status(is_worktree)?; + + table.add_row(vec![ + repo_name, + match is_worktree { + true => "\u{2714}", + false => "", + }, + &match repo_status.changes { + Some(changes) => { + let mut out = Vec::new(); + if changes.files_new > 0 { + out.push(format!("New: {}\n", changes.files_new)) + } + if changes.files_modified > 0 { + out.push(format!("Modified: {}\n", changes.files_modified)) + } + if changes.files_deleted > 0 { + out.push(format!("Deleted: {}\n", changes.files_deleted)) + } + out.into_iter().collect::().trim().to_string() + } + None => String::from("\u{2714}"), + }, + &repo_status + .branches + .iter() + .map(|(branch_name, remote_branch)| { + format!( + "branch: {}{}\n", + &branch_name, + &match remote_branch { + None => String::from(" "), + Some((remote_branch_name, remote_tracking_status)) => { + format!( + " <{}>{}", + remote_branch_name, + &match remote_tracking_status { + crate::RemoteTrackingStatus::UpToDate => + String::from(" \u{2714}"), + crate::RemoteTrackingStatus::Ahead(d) => format!(" [+{}]", &d), + crate::RemoteTrackingStatus::Behind(d) => format!(" [-{}]", &d), + crate::RemoteTrackingStatus::Diverged(d1, d2) => + format!(" [+{}/-{}]", &d1, &d2), + } + ) + } + } + ) + }) + .collect::() + .trim() + .to_string(), + &match is_worktree { + true => String::from(""), + false => match repo_status.head { + Some(head) => head, + None => String::from("Empty"), + }, + }, + &repo_status + .remotes + .iter() + .map(|r| format!("{}\n", r)) + .collect::() + .trim() + .to_string(), + ]); + + Ok(()) +} + +// Don't return table, return a type that implements Display(?) +pub fn get_worktree_status_table( + repo: &crate::Repo, + directory: &Path, +) -> Result<(impl std::fmt::Display, Vec), String> { + let worktrees = repo.get_worktrees()?; + let mut table = Table::new(); + + let mut errors = Vec::new(); + + add_worktree_table_header(&mut table); + for worktree in &worktrees { + let worktree_dir = &directory.join(&worktree); + if worktree_dir.exists() { + let repo = match crate::Repo::open(worktree_dir, false) { + Ok(repo) => repo, + Err(error) => { + errors.push(format!( + "Failed opening repo of worktree {}: {}", + &worktree, &error + )); + continue; + } + }; + if let Err(error) = add_worktree_status(&mut table, worktree, &repo) { + errors.push(error); + } + } else { + errors.push(format!("Worktree {} does not have a directory", &worktree)); + } + } + for entry in std::fs::read_dir(&directory).map_err(|error| error.to_string())? { + let dirname = crate::path_as_string( + &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(), + ); + if dirname == crate::GIT_MAIN_WORKTREE_DIRECTORY { + continue; + } + if !&worktrees.contains(&dirname) { + errors.push(format!( + "Found {}, which is not a valid worktree directory!", + &dirname + )); + } + } + Ok((table, errors)) +} + +pub fn get_status_table(config: crate::Config) -> Result<(Vec, Vec), String> { + let mut errors = Vec::new(); + let mut tables = Vec::new(); + for tree in config.trees.as_vec() { + let repos = tree.repos.unwrap_or_default(); + + let root_path = crate::expand_path(Path::new(&tree.root)); + + let mut table = Table::new(); + add_table_header(&mut table); + + for repo in &repos { + let repo_path = root_path.join(&repo.name); + + if !repo_path.exists() { + errors.push(format!( + "{}: Repository does not exist. Run sync?", + &repo.name + )); + continue; + } + + let repo_handle = crate::Repo::open(&repo_path, repo.worktree_setup); + + let repo_handle = match repo_handle { + Ok(repo) => repo, + Err(error) => { + if error.kind == crate::RepoErrorKind::NotFound { + errors.push(format!( + "{}: No git repository found. Run sync?", + &repo.name + )); + } else { + errors.push(format!( + "{}: Opening repository failed: {}", + &repo.name, error + )); + } + continue; + } + }; + + add_repo_status(&mut table, &repo.name, &repo_handle, repo.worktree_setup)?; + } + + tables.push(table); + } + + Ok((tables, errors)) +} + +fn add_worktree_table_header(table: &mut Table) { + table + .load_preset(comfy_table::presets::UTF8_FULL) + .apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS) + .set_header(vec![ + Cell::new("Worktree"), + Cell::new("Status"), + Cell::new("Branch"), + Cell::new("Remote branch"), + ]); +} + +fn add_worktree_status( + table: &mut Table, + worktree_name: &str, + repo: &crate::Repo, +) -> Result<(), String> { + let repo_status = repo.status(false)?; + + let local_branch = repo + .head_branch() + .map_err(|error| format!("Failed getting head branch: {}", error))?; + + let upstream_output = match local_branch.upstream() { + Ok(remote_branch) => { + let remote_branch_name = remote_branch + .name() + .map_err(|error| format!("Failed getting name of remote branch: {}", error))?; + + let (ahead, behind) = repo + .graph_ahead_behind(&local_branch, &remote_branch) + .map_err(|error| format!("Failed computing branch deviation: {}", error))?; + + format!( + "{}{}\n", + &remote_branch_name, + &match (ahead, behind) { + (0, 0) => String::from(""), + (d, 0) => format!(" [+{}]", &d), + (0, d) => format!(" [-{}]", &d), + (d1, d2) => format!(" [+{}/-{}]", &d1, &d2), + }, + ) + } + Err(_) => String::from(""), + }; + + table.add_row(vec![ + worktree_name, + &match repo_status.changes { + Some(changes) => { + let mut out = Vec::new(); + if changes.files_new > 0 { + out.push(format!("New: {}\n", changes.files_new)) + } + if changes.files_modified > 0 { + out.push(format!("Modified: {}\n", changes.files_modified)) + } + if changes.files_deleted > 0 { + out.push(format!("Deleted: {}\n", changes.files_deleted)) + } + out.into_iter().collect::().trim().to_string() + } + None => String::from("\u{2714}"), + }, + &local_branch + .name() + .map_err(|error| format!("Failed getting name of branch: {}", error))?, + &upstream_output, + ]); + + Ok(()) +} + +pub fn show_single_repo_status(path: &Path) -> Result<(impl std::fmt::Display, Vec), String> { + let mut table = Table::new(); + let mut warnings = Vec::new(); + + let is_worktree = crate::Repo::detect_worktree(path); + add_table_header(&mut table); + + let repo_handle = crate::Repo::open(path, is_worktree); + + if let Err(error) = repo_handle { + if error.kind == crate::RepoErrorKind::NotFound { + return Err(String::from("Directory is not a git directory")); + } else { + return Err(format!("Opening repository failed: {}", error)); + } + }; + + let repo_name = match path.file_name() { + None => { + warnings.push(format!("Cannot detect repo name for path {}. Are you working in /?", &path.display())); + String::from("unknown") + } + Some(file_name) => match file_name.to_str() { + None => { + warnings.push(format!("Name of repo directory {} is not valid UTF-8", &path.display())); + String::from("invalid") + } + Some(name) => name.to_string(), + }, + }; + + add_repo_status(&mut table, &repo_name, &repo_handle.unwrap(), is_worktree)?; + + Ok((table, warnings)) +} diff --git a/tests/repo.rs b/tests/repo.rs index 261fd18..5027b4f 100644 --- a/tests/repo.rs +++ b/tests/repo.rs @@ -8,13 +8,13 @@ use helpers::*; fn open_empty_repo() { let tmpdir = init_tmpdir(); assert!(matches!( - open_repo(tmpdir.path(), true), + RepoHandle::open(tmpdir.path(), true), Err(RepoError { kind: RepoErrorKind::NotFound }) )); assert!(matches!( - open_repo(tmpdir.path(), false), + RepoHandle::open(tmpdir.path(), false), Err(RepoError { kind: RepoErrorKind::NotFound }) @@ -25,7 +25,7 @@ fn open_empty_repo() { #[test] fn create_repo() -> Result<(), Box> { let tmpdir = init_tmpdir(); - let repo = init_repo(tmpdir.path(), false)?; + let repo = RepoHandle::init(tmpdir.path(), false)?; assert!(!repo.is_bare()); assert!(repo.is_empty()?); cleanup_tmpdir(tmpdir); @@ -35,7 +35,7 @@ fn create_repo() -> Result<(), Box> { #[test] fn create_repo_with_worktree() -> Result<(), Box> { let tmpdir = init_tmpdir(); - let repo = init_repo(tmpdir.path(), true)?; + let repo = RepoHandle::init(tmpdir.path(), true)?; assert!(repo.is_bare()); assert!(repo.is_empty()?); cleanup_tmpdir(tmpdir);