Merge branch 'develop'

This commit is contained in:
2021-11-26 17:21:48 +01:00
5 changed files with 198 additions and 144 deletions

110
README.md
View File

@@ -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 <origin/master> ✔ | master | github |
| | | | | origin |
|------------------+------------+----------------------------------+--------+---------|
| dotfiles | No changes | branch: master <origin/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 <origin/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 <name>` to create a new checkout of a new branch into
a subdirectory. An example:
```bash
$ grm worktree add mybranch
$ cd ./mybranch
$ git status
On branch mybranch
nothing to commit, working tree clean
```
If you're done with your worktree, use `grm worktree delete <name>` to remove it.
GRM will refuse to delete worktrees that contain uncommitted or unpushed changes,
otherwise you might lose work.
**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

View File

@@ -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

View File

@@ -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 {}

View File

@@ -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::<Vec<String>>();
fn get_worktrees(repo: &git2::Repository) -> Vec<String> {
repo.worktrees()
.unwrap()
.iter()
.map(|e| e.unwrap().to_string())
.collect::<Vec<String>>()
}
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!",

View File

@@ -199,16 +199,43 @@ pub fn open_repo(path: &Path, is_worktree: bool) -> Result<Repository, RepoError
}
pub fn init_repo(path: &Path, is_worktree: bool) -> Result<Repository, Box<dyn std::error::Error>> {
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<git2::Config, String> {
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 {