diff --git a/README.md b/README.md index f92c01a..059cdac 100644 --- a/README.md +++ b/README.md @@ -3,110 +3,8 @@ GRM helps you manage git repositories in a declarative way. Configure your repositories in a [TOML](https://toml.io/) file, GRM does the rest. -## Quickstart - -See [the example configuration](example.config.toml) to get a feel for the way -you configure your repositories. - -### Install - -```bash -$ cargo install --git https://github.com/hakoerber/git-repo-manager.git --branch master -``` - -### Get the example configuration - -```bash -$ curl --proto '=https' --tlsv1.2 -sSfO https://raw.githubusercontent.com/hakoerber/git-repo-manager/master/example.config.toml -``` - -### Run it! - -```bash -$ grm sync --config example.config.toml -[⚙] Cloning into "/home/me/projects/git-repo-manager" from "https://code.hkoerber.de/hannes/git-repo-manager.git" -[✔] git-repo-manager: Repository successfully cloned -[⚙] git-repo-manager: Setting up new remote "github" to "https://github.com/hakoerber/git-repo-manager.git" -[✔] git-repo-manager: OK -[⚙] Cloning into "/home/me/projects/dotfiles" from "https://github.com/hakoerber/dotfiles.git" -[✔] dotfiles: Repository successfully cloned -[✔] dotfiles: OK -``` - -If you run it again, it will report no changes: - -``` -$ grm sync --config example.config.toml -[✔] git-repo-manager: OK -[✔] dotfiles: OK -``` - -### Generate your own configuration - -Now, if you already have a few repositories, it would be quite laborious to write -a configuration from scratch. Luckily, GRM has a way to generate a configuration -from an existing file tree: - -```bash -$ grm find ~/your/project/root > config.toml -``` - -This will detect all repositories and remotes and write them to `config.toml`. - -### Show the state of your projects - -```bash -$ grm status --config example.config.toml -+------------------+------------+----------------------------------+--------+---------+ -| Repo | Status | Branches | HEAD | Remotes | -+=====================================================================================+ -| git-repo-manager | | branch: master ✔ | master | github | -| | | | | origin | -|------------------+------------+----------------------------------+--------+---------| -| dotfiles | No changes | branch: master ✔ | master | origin | -+------------------+------------+----------------------------------+--------+---------+ -``` - -You can also use `status` without `--config` to check the current directory: - -``` -$ cd ./dotfiles -$ grm status -+----------+------------+----------------------------------+--------+---------+ -| Repo | Status | Branches | HEAD | Remotes | -+=============================================================================+ -| dotfiles | No changes | branch: master ✔ | master | origin | -+----------+------------+----------------------------------+--------+---------+ -``` - -### 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 ` 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 ` to remove it. -GRM will refuse to delete worktrees that contain uncommitted or unpushed changes, -otherwise you might lose work. +**Take a look at the [official documentation](https://hakoerber.github.io/git-repo-manager/) +for installation & quickstart.** # Why? @@ -146,10 +44,6 @@ repositories itself. * Support multiple file formats (YAML, JSON). * Add systemd timer unit to run regular syncs -# Dev Notes - -It requires nightly features due to the usage of [`std::path::Path::is_symlink()`](https://doc.rust-lang.org/std/fs/struct.FileType.html#method.is_symlink). See the [tracking issue](https://github.com/rust-lang/rust/issues/85748). - # Crates * [`toml`](https://docs.rs/toml/) for the configuration file diff --git a/docs/src/worktrees.md b/docs/src/worktrees.md index a99b55a..c808e7b 100644 --- a/docs/src/worktrees.md +++ b/docs/src/worktrees.md @@ -192,6 +192,26 @@ can also use the following: $ grm wt clean ``` +Note that this will not delete the default branch of the repository. It can of +course still be delete with `grm wt delete` if neccessary. + +### Converting an existing repository + +It is possible to convert an existing directory to a worktree setup, using `grm +wt convert`. This command has to be run in the root of the repository you want +to convert: + +``` +grm wt convert +[✔] Conversion successful +``` + +This command will refuse to run if you have any changes in your repository. +Commit them and try again! + +Afterwards, the directory is empty, as there are no worktrees checked out yet. +Now you can use the usual commands to set up worktrees. + ### Manual access GRM isn't doing any magic, it's just git under the hood. If you need to have access diff --git a/src/cmd.rs b/src/cmd.rs index e75bbd2..6cf4a18 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -83,6 +83,8 @@ pub enum WorktreeAction { Delete(WorktreeDeleteArgs), #[clap(about = "Show state of existing worktrees")] Status(WorktreeStatusArgs), + #[clap(about = "Convert a normal repository to a worktree setup")] + Convert(WorktreeConvertArgs), #[clap(about = "Clean all worktrees that do not contain uncommited/unpushed changes")] Clean(WorktreeCleanArgs), } @@ -116,6 +118,9 @@ pub struct WorktreeDeleteArgs { #[derive(Parser)] pub struct WorktreeStatusArgs {} +#[derive(Parser)] +pub struct WorktreeConvertArgs {} + #[derive(Parser)] pub struct WorktreeCleanArgs {} diff --git a/src/lib.rs b/src/lib.rs index a387abb..efa5edc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,13 +13,16 @@ use output::*; use comfy_table::{Cell, Table}; use repo::{ - clone_repo, detect_remote_type, get_repo_status, init_repo, open_repo, Remote, - RemoteTrackingStatus, Repo, RepoErrorKind, + clone_repo, detect_remote_type, get_repo_status, init_repo, open_repo, repo_make_bare, + repo_set_config_push, Remote, RemoteTrackingStatus, Repo, RepoErrorKind, }; const GIT_MAIN_WORKTREE_DIRECTORY: &str = ".git-main-working-tree"; const BRANCH_NAMESPACE_SEPARATOR: &str = "/"; +const GIT_CONFIG_BARE_KEY: &str = "core.bare"; +const GIT_CONFIG_PUSH_DEFAULT: &str = "push.default"; + #[cfg(test)] mod tests { use super::*; @@ -631,7 +634,7 @@ fn show_single_repo_status(path: &Path, is_worktree: bool) { if let Err(error) = repo_handle { if error.kind == RepoErrorKind::NotFound { - print_error(&"Directory is not a git directory".to_string()); + print_error("Directory is not a git directory"); } else { print_error(&format!("Opening repository failed: {}", error)); } @@ -868,28 +871,33 @@ pub fn run() { } }; - 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") + 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)), } - _ => print_error(&format!("Error opening repo: {}", e)), + process::exit(1); } - process::exit(1); } - }; + } - let worktrees = repo - .worktrees() - .unwrap() - .iter() - .map(|e| e.unwrap().to_string()) - .collect::>(); + 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); @@ -1014,6 +1022,7 @@ pub fn run() { cmd::WorktreeAction::Delete(action_args) => { let worktree_dir = dir.join(&action_args.name); + let repo = get_repo(&dir); match remove_worktree( &action_args.name, @@ -1040,6 +1049,8 @@ pub fn run() { } } 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 { @@ -1081,8 +1092,100 @@ pub fn run() { } 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) => { - for worktree in &worktrees { + 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) { @@ -1108,6 +1211,7 @@ pub fn run() { )); } } + for entry in std::fs::read_dir(&dir).unwrap() { let dirname = path_as_string( &entry @@ -1120,6 +1224,9 @@ pub fn run() { 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!", diff --git a/src/repo.rs b/src/repo.rs index c44c88d..75f30e8 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -199,16 +199,43 @@ pub fn open_repo(path: &Path, is_worktree: bool) -> Result Result> { - match is_worktree { - false => match Repository::init(path) { - 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)), - }, + 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")?; } + + 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 clone_repo( @@ -230,10 +257,7 @@ pub fn clone_repo( RemoteType::Https => { let mut builder = git2::build::RepoBuilder::new(); builder.bare(is_worktree); - match builder.clone(&remote.url, &clone_target) { - Ok(_) => Ok(()), - Err(e) => Err(Box::new(e)), - } + builder.clone(&remote.url, &clone_target)?; } RemoteType::Ssh => { let mut callbacks = RemoteCallbacks::new(); @@ -248,12 +272,16 @@ pub fn clone_repo( builder.bare(is_worktree); builder.fetch_options(fo); - match builder.clone(&remote.url, &clone_target) { - Ok(_) => Ok(()), - Err(e) => Err(Box::new(e)), - } + builder.clone(&remote.url, &clone_target)?; } } + + if is_worktree { + let repo = open_repo(&clone_target, false)?; + repo_set_config_push(&repo, "upstream")?; + } + + Ok(()) } pub fn get_repo_status(repo: &git2::Repository, is_worktree: bool) -> RepoStatus {