Initial commit

This commit is contained in:
2021-11-15 16:16:15 +01:00
commit f6a51c70cc
12 changed files with 1504 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

624
Cargo.lock generated Normal file
View File

@@ -0,0 +1,624 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "aho-corasick"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f"
dependencies = [
"memchr",
]
[[package]]
name = "atty"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
"hermit-abi",
"libc",
"winapi",
]
[[package]]
name = "autocfg"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "cc"
version = "1.0.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22a9137b95ea06864e018375b72adfb7db6e6f68cfc8df5a04d00288050485ee"
dependencies = [
"jobserver",
]
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "clap"
version = "3.0.0-beta.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "feff3878564edb93745d58cf63e17b63f24142506e7a20c87a5521ed7bfb1d63"
dependencies = [
"atty",
"bitflags",
"clap_derive",
"indexmap",
"lazy_static",
"os_str_bytes",
"strsim",
"termcolor",
"textwrap",
"unicase",
]
[[package]]
name = "clap_derive"
version = "3.0.0-beta.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b15c6b4f786ffb6192ffe65a36855bc1fc2444bcd0945ae16748dcd6ed7d0d3"
dependencies = [
"heck",
"proc-macro-error",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "console"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28b32d32ca44b70c3e4acd7db1babf555fa026e385fb95f18028f88848b3c31"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"regex",
"terminal_size",
"unicode-width",
"winapi",
]
[[package]]
name = "dirs-next"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
dependencies = [
"cfg-if",
"dirs-sys-next",
]
[[package]]
name = "dirs-sys-next"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
dependencies = [
"libc",
"redox_users",
"winapi",
]
[[package]]
name = "encode_unicode"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
[[package]]
name = "form_urlencoded"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191"
dependencies = [
"matches",
"percent-encoding",
]
[[package]]
name = "getrandom"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "git-repo-manager"
version = "0.1.0"
dependencies = [
"clap",
"console",
"git2",
"regex",
"serde",
"shellexpand",
"toml",
]
[[package]]
name = "git2"
version = "0.13.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8057932925d3a9d9e4434ea016570d37420ddb1ceed45a174d577f24ed6700"
dependencies = [
"bitflags",
"libc",
"libgit2-sys",
"log",
"openssl-probe",
"openssl-sys",
"url",
]
[[package]]
name = "hashbrown"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e"
[[package]]
name = "heck"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "hermit-abi"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
dependencies = [
"libc",
]
[[package]]
name = "idna"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8"
dependencies = [
"matches",
"unicode-bidi",
"unicode-normalization",
]
[[package]]
name = "indexmap"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc633605454125dec4b66843673f01c7df2b89479b32e0ed634e43a91cff62a5"
dependencies = [
"autocfg",
"hashbrown",
]
[[package]]
name = "jobserver"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af25a77299a7f711a01975c35a6a424eb6862092cc2d6c72c4ed6cbc56dfc1fa"
dependencies = [
"libc",
]
[[package]]
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "libc"
version = "0.2.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbe5e23404da5b4f555ef85ebed98fb4083e55a00c317800bc2a50ede9f3d219"
[[package]]
name = "libgit2-sys"
version = "0.12.24+1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddbd6021eef06fb289a8f54b3c2acfdd85ff2a585dfbb24b8576325373d2152c"
dependencies = [
"cc",
"libc",
"libssh2-sys",
"libz-sys",
"openssl-sys",
"pkg-config",
]
[[package]]
name = "libssh2-sys"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b094a36eb4b8b8c8a7b4b8ae43b2944502be3e59cd87687595cf6b0a71b3f4ca"
dependencies = [
"cc",
"libc",
"libz-sys",
"openssl-sys",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libz-sys"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de5435b8549c16d423ed0c03dbaafe57cf6c3344744f1242520d59c9d8ecec66"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "log"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710"
dependencies = [
"cfg-if",
]
[[package]]
name = "matches"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f"
[[package]]
name = "memchr"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a"
[[package]]
name = "once_cell"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56"
[[package]]
name = "openssl-probe"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a"
[[package]]
name = "openssl-sys"
version = "0.9.70"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6517987b3f8226b5da3661dad65ff7f300cc59fb5ea8333ca191fc65fde3edf"
dependencies = [
"autocfg",
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "os_str_bytes"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addaa943333a514159c80c97ff4a93306530d965d27e139188283cd13e06a799"
dependencies = [
"memchr",
]
[[package]]
name = "percent-encoding"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e"
[[package]]
name = "pkg-config"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12295df4f294471248581bc09bef3c38a5e46f1e36d6a37353621a0c6c357e1f"
[[package]]
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2",
"quote",
"syn",
"version_check",
]
[[package]]
name = "proc-macro-error-attr"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2",
"quote",
"version_check",
]
[[package]]
name = "proc-macro2"
version = "1.0.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba508cc11742c0dc5c1659771673afbab7a0efab23aa17e854cbab0837ed0b43"
dependencies = [
"unicode-xid",
]
[[package]]
name = "quote"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff"
dependencies = [
"bitflags",
]
[[package]]
name = "redox_users"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64"
dependencies = [
"getrandom",
"redox_syscall",
]
[[package]]
name = "regex"
version = "1.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.6.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
[[package]]
name = "serde"
version = "1.0.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f12d06de37cf59146fbdecab66aa99f9fe4f78722e3607577a5375d66bd0c913"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7bc1a1ab1961464eae040d96713baa5a724a8152c1222492465b54322ec508b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "shellexpand"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83bdb7831b2d85ddf4a7b148aa19d0587eddbe8671a436b7bd1182eaad0f2829"
dependencies = [
"dirs-next",
]
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "syn"
version = "1.0.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2afee18b8beb5a596ecb4a2dce128c719b4ba399d34126b9e4396e3f9860966"
dependencies = [
"proc-macro2",
"quote",
"unicode-xid",
]
[[package]]
name = "termcolor"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4"
dependencies = [
"winapi-util",
]
[[package]]
name = "terminal_size"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "textwrap"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0066c8d12af8b5acd21e00547c3797fde4e8677254a7ee429176ccebbe93dd80"
dependencies = [
"unicode-width",
]
[[package]]
name = "tinyvec"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c1c1d5a42b6245520c249549ec267180beaffcc0615401ac8e31853d4b6d8d2"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c"
[[package]]
name = "toml"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa"
dependencies = [
"serde",
]
[[package]]
name = "unicase"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6"
dependencies = [
"version_check",
]
[[package]]
name = "unicode-bidi"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f"
[[package]]
name = "unicode-normalization"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-segmentation"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b"
[[package]]
name = "unicode-width"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973"
[[package]]
name = "unicode-xid"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3"
[[package]]
name = "url"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c"
dependencies = [
"form_urlencoded",
"idna",
"matches",
"percent-encoding",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe"
[[package]]
name = "wasi"
version = "0.10.2+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
dependencies = [
"winapi",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"

45
Cargo.toml Normal file
View File

@@ -0,0 +1,45 @@
[package]
name = "git-repo-manager"
version = "0.1.0"
edition = "2021"
authors = [
"Hannes Körber <hannes@hkoerber.de>",
]
description = """
Manage multiple git repositories.
You configure the git repositories in a file, the program does the rest!
"""
license = "GPL-3.0-only"
[lib]
name = "grm"
path = "src/lib.rs"
[[bin]]
name = "grm"
path = "src/main.rs"
[dependencies]
[dependencies.toml]
version = "0.5"
[dependencies.serde]
version = "1.0"
features = ["derive"]
[dependencies.git2]
version = "0.13"
[dependencies.shellexpand]
version = "2.1"
[dependencies.clap]
version = "3.0.0-beta.5"
[dependencies.console]
version = "0.15.0"
[dependencies.regex]
version = "1.5"

8
Justfile Normal file
View File

@@ -0,0 +1,8 @@
lint:
cargo clippy --no-deps
lint-fix:
cargo clippy --no-deps --fix
release:
cargo build --release

103
README.md Normal file
View File

@@ -0,0 +1,103 @@
# GRM — Git Repository Manager
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
```
### 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`.
# Why?
I have a **lot** of repositories on my machines. My own stuff, forks, quick
clones of other's repositories, projects that never went anywhere ... In short,
I lost overview.
To sync these repositories between machines, I've been using Nextcloud. The thing
is, Nextcloud is not too happy about too many small files that change all the time,
like the files inside `.git`. Git also assumes that those files are updated as
atomically as possible. Nextcloud cannot guarantee that, so when I do a `git status`
during a sync, something blows up. And resolving these conflicts is just no fun ...
In the end, I think that git repos just don't belong into something like Nextcloud.
Git is already managing the content & versions, so there is no point in having
another tool do the same. But of course, setting up all those repositories from
scratch on a new machine is too much hassle. What if there was a way to clone all
those repos in a single command?
Also, I once transferred the domain of my personal git server. I updated a few
remotes manually, but I still stumble upon old, stale remotes in projects that
I haven't touched in a while. What if there was a way to update all those remotes
in once place?
This is how GRM came to be. I'm a fan of infrastructure-as-code, and GRM is a bit
like Terraform for your local git repositories. Write a config, run the tool, and
your repos are ready. The only thing that is tracked by git it the list of
repositories itself.
# Future & Ideas
* Operations over all repos (e.g. pull)
* Show status of managed repositories (dirty, compare to remotes, ...)
# Optional Features
* 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
* [`serde`](https://docs.rs/serde/) because we're using Rust, after all
* [`git2`](https://docs.rs/git2/), a safe wrapper around `libgit2`, for all git operations
* [`clap`](https://docs.rs/clap/), [`console`](https://docs.rs/console/) and [`shellexpand`](https://docs.rs/shellexpand) for good UX

24
example.config.toml Normal file
View File

@@ -0,0 +1,24 @@
[[trees]]
root = "~/projects/"
[[trees.repos]]
name = "git-repo-manager"
[[trees.repos.remotes]]
name = "origin"
url = "https://code.hkoerber.de/hannes/git-repo-manager.git"
type = "https"
[[trees.repos.remotes]]
name = "github"
url = "https://github.com/hakoerber/git-repo-manager.git"
type = "https"
[[trees.repos]]
name = "dotfiles"
[[trees.repos.remotes]]
name = "origin"
url = "https://github.com/hakoerber/dotfiles.git"
type = "https"

51
src/cmd.rs Normal file
View File

@@ -0,0 +1,51 @@
use clap::{AppSettings, Parser};
#[derive(Parser)]
#[clap(
name = clap::crate_name!(),
version = clap::crate_version!(),
author = clap::crate_authors!("\n"),
about = clap::crate_description!(),
long_version = clap::crate_version!(),
license = clap::crate_license!(),
setting = AppSettings::DeriveDisplayOrder,
setting = AppSettings::PropagateVersion,
setting = AppSettings::HelpRequired,
)]
pub struct Opts {
#[clap(subcommand)]
pub subcmd: SubCommand,
}
#[derive(Parser)]
pub enum SubCommand {
#[clap(
visible_alias = "run",
about = "Synchronize the repositories to the configured values"
)]
Sync(Sync),
#[clap(about = "Generate a repository configuration from an existing file tree")]
Find(Find),
}
#[derive(Parser)]
#[clap()]
pub struct Sync {
#[clap(
short,
long,
default_value = "./config.toml",
about = "Path to the configuration file"
)]
pub config: String,
}
#[derive(Parser)]
pub struct Find {
#[clap(about = "The path to search through")]
pub path: String,
}
pub fn parse() -> Opts {
Opts::parse()
}

40
src/config.rs Normal file
View File

@@ -0,0 +1,40 @@
use serde::{Deserialize, Serialize};
use super::repo::Repo;
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub trees: Vec<Tree>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Tree {
pub root: Option<String>,
pub repos: Option<Vec<Repo>>,
}
pub fn read_config(path: &str) -> Result<Config, String> {
let content = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
return Err(format!(
"Error reading configuration file \"{}\": {}",
path, e
))
}
};
let config: Config = match toml::from_str(&content) {
Ok(c) => c,
Err(e) => {
return Err(format!(
"Error parsing configuration file \"{}\": {}",
path, e
))
}
};
Ok(config)
}

395
src/lib.rs Normal file
View File

@@ -0,0 +1,395 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process;
mod cmd;
mod config;
mod output;
mod repo;
use config::{Config, Tree};
use output::*;
use repo::{clone_repo, detect_remote_type, init_repo, open_repo, Remote, Repo};
fn path_as_string(path: &Path) -> String {
path.to_path_buf().into_os_string().into_string().unwrap()
}
fn env_home() -> PathBuf {
match std::env::var("HOME") {
Ok(path) => Path::new(&path).to_path_buf(),
Err(e) => {
print_error(&format!("Unable to read HOME: {}", e));
process::exit(1);
}
}
}
fn sync_trees(config: Config) {
for tree in config.trees {
let repos = tree.repos.unwrap_or_default();
let root_path = match &tree.root {
Some(root) => {
fn home_dir() -> Option<PathBuf> {
Some(env_home())
}
let expanded_path = match shellexpand::full_with_context(
&root,
home_dir,
|name| -> Result<Option<String>, &'static str> {
match name {
"HOME" => Ok(Some(path_as_string(home_dir().unwrap().as_path()))),
_ => Ok(None),
}
},
) {
Ok(std::borrow::Cow::Borrowed(path)) => path.to_owned(),
Ok(std::borrow::Cow::Owned(path)) => path,
Err(e) => {
print_error(&format!("Unable to expand root: {}", e));
process::exit(1);
}
};
Path::new(&expanded_path).to_path_buf()
}
None => std::env::current_dir().unwrap(),
};
for repo in &repos {
let name = &repo.name;
let repo_path = root_path.join(&repo.name);
let mut repo_handle = None;
if repo_path.exists() {
repo_handle = Some(open_repo(&repo_path).unwrap_or_else(|error| {
print_repo_error(name, &format!("Opening repository failed: {}", error));
process::exit(1);
}));
} else {
match &repo.remotes {
None => {
print_repo_action(
name,
"Repository does not have remotes configured, initializing new",
);
repo_handle = match init_repo(&repo_path) {
Ok(r) => {
print_repo_success(name, "Repository created");
Some(r)
}
Err(e) => {
print_repo_error(
name,
&format!("Repository failed during init: {}", e),
);
None
}
}
}
Some(r) => {
let first = match r.first() {
Some(e) => e,
None => {
panic!("Repos is an empty array. This is a bug");
}
};
match clone_repo(first, &repo_path) {
Ok(_) => {
print_repo_success(name, "Repository successfully cloned");
}
Err(e) => {
print_repo_error(
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).unwrap_or_else(|_| process::exit(1)));
let current_remotes: Vec<String> = match repo_handle.remotes() {
Ok(r) => r,
Err(e) => {
print_repo_error(
name,
&format!("Repository failed during getting the remotes: {}", e),
);
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(
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(
name,
&format!("Repository failed during setting the remotes: {}", e),
);
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(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));
continue;
}
};
if remote.url != current_url {
if let Err(e) = repo_handle.remote_set_url(&remote.name, &remote.url) {
print_repo_error(name, &format!("Repository failed during setting of the remote URL for remote \"{}\": {}", &remote.name, e));
continue;
};
}
}
}
for current_remote in &current_remotes {
if !remotes.iter().any(|r| &r.name == current_remote) {
print_repo_action(
name,
&format!("Deleting remote \"{}\"", &current_remote,),
);
if let Err(e) = repo_handle.remote_delete(current_remote) {
print_repo_error(
name,
&format!(
"Repository failed during deleting remote \"{}\": {}",
&current_remote, e
),
);
continue;
}
}
}
}
print_repo_success(&repo.name, "OK");
}
let current_repos = find_repos_without_details(&root_path).unwrap();
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));
}
}
}
}
fn find_repos_without_details(path: &Path) -> Option<Vec<PathBuf>> {
let mut repos: Vec<PathBuf> = Vec::new();
let git_dir = path.join(".git");
if git_dir.exists() {
repos.push(path.to_path_buf());
} else {
match fs::read_dir(path) {
Ok(contents) => {
for content in contents {
match content {
Ok(entry) => {
let path = entry.path();
if path.is_symlink() {
continue;
}
if path.is_dir() {
if let Some(mut r) = find_repos_without_details(&path) {
repos.append(&mut r);
};
}
}
Err(e) => {
print_error(&format!("Error accessing directory: {}", e));
continue;
}
};
}
}
Err(e) => {
print_error(&format!("Failed to open \"{}\": {}", &path.display(), &e));
return None;
}
};
}
Some(repos)
}
fn find_repos(root: &Path, at_root: bool) -> Option<Vec<Repo>> {
let mut repos: Vec<Repo> = Vec::new();
for path in find_repos_without_details(root).unwrap() {
let repo = match open_repo(&path) {
Ok(r) => r,
Err(e) => {
print_error(&format!("Error opening repo {}: {}", path.display(), e));
return None;
}
};
let remotes = match repo.remotes() {
Ok(remotes) => {
let mut results: Vec<Remote> = 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);
}
};
}
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);
}
};
}
Some(results)
}
Err(e) => {
print_repo_error(
&path_as_string(&path),
&format!("Error getting remotes: {}", e),
);
process::exit(1);
}
};
repos.push(Repo {
name: match at_root {
true => match &root.parent() {
Some(parent) => path_as_string(path.strip_prefix(parent).unwrap()),
None => {
print_error("Getting name of the search root failed. Do you have a git repository in \"/\"?");
process::exit(1);
},
}
false => path_as_string(path.strip_prefix(&root).unwrap()),
},
remotes,
});
}
Some(repos)
}
fn find_in_tree(path: &Path) -> Option<Tree> {
let repos: Vec<Repo> = match find_repos(path, true) {
Some(vec) => vec,
None => Vec::new(),
};
let mut root = path.to_path_buf();
let home = env_home();
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**.
root = Path::new("~").join(root.strip_prefix(&home).unwrap());
}
Some(Tree {
root: Some(root.into_os_string().into_string().unwrap()),
repos: Some(repos),
})
}
pub fn run() {
let opts = cmd::parse();
match opts.subcmd {
cmd::SubCommand::Sync(sync) => {
let config = match config::read_config(&sync.config) {
Ok(c) => c,
Err(e) => {
print_error(&e);
process::exit(1);
}
};
sync_trees(config);
}
cmd::SubCommand::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 config = Config {
trees: vec![find_in_tree(path).unwrap()],
};
let toml = toml::to_string(&config).unwrap();
print!("{}", toml);
}
}
}

5
src/main.rs Normal file
View File

@@ -0,0 +1,5 @@
use grm::run;
fn main() {
run();
}

58
src/output.rs Normal file
View File

@@ -0,0 +1,58 @@
use console::{Style, Term};
pub fn print_repo_error(repo: &str, message: &str) {
print_error(&format!("{}: {}", repo, message));
}
pub fn print_error(message: &str) {
let stderr = Term::stderr();
let mut style = Style::new().red();
if stderr.is_term() {
style = style.force_styling(true);
}
stderr
.write_line(&format!("[{}] {}", style.apply_to('\u{2718}'), &message))
.unwrap();
}
pub fn print_repo_action(repo: &str, message: &str) {
print_action(&format!("{}: {}", repo, message));
}
pub fn print_action(message: &str) {
let stderr = Term::stderr();
let mut style = Style::new().yellow();
if stderr.is_term() {
style = style.force_styling(true);
}
stderr
.write_line(&format!("[{}] {}", style.apply_to('\u{2699}'), &message))
.unwrap();
}
pub fn print_warning(message: &str) {
let stderr = Term::stderr();
let mut style = Style::new().yellow();
if stderr.is_term() {
style = style.force_styling(true);
}
stderr
.write_line(&format!("[{}] {}", style.apply_to('!'), &message))
.unwrap();
}
pub fn print_repo_success(repo: &str, message: &str) {
print_success(&format!("{}: {}", repo, message));
}
pub fn print_success(message: &str) {
let stderr = Term::stderr();
let mut style = Style::new().green();
if stderr.is_term() {
style = style.force_styling(true);
}
stderr
.write_line(&format!("[{}] {}", style.apply_to('\u{2714}'), &message))
.unwrap();
}

150
src/repo.rs Normal file
View File

@@ -0,0 +1,150 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
use git2::{Cred, RemoteCallbacks, Repository};
use crate::output::*;
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum RemoteType {
Ssh,
Https,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Remote {
pub name: String,
pub url: String,
#[serde(alias = "type")]
pub remote_type: RemoteType,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Repo {
pub name: String,
pub remotes: Option<Vec<Remote>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_ssh_remote() {
assert_eq!(
detect_remote_type("ssh://git@example.com"),
Some(RemoteType::Ssh)
);
assert_eq!(detect_remote_type("git@example.git"), Some(RemoteType::Ssh));
}
#[test]
fn check_https_remote() {
assert_eq!(
detect_remote_type("https://example.com"),
Some(RemoteType::Https)
);
assert_eq!(
detect_remote_type("https://example.com/test.git"),
Some(RemoteType::Https)
);
}
#[test]
fn check_invalid_remotes() {
assert_eq!(detect_remote_type("https//example.com"), None);
assert_eq!(detect_remote_type("https:example.com"), None);
assert_eq!(detect_remote_type("ssh//example.com"), None);
assert_eq!(detect_remote_type("ssh:example.com"), None);
assert_eq!(detect_remote_type("git@example.com"), None);
}
#[test]
#[should_panic]
fn check_unsupported_protocol_http() {
detect_remote_type("http://example.com");
}
#[test]
#[should_panic]
fn check_unsupported_protocol_git() {
detect_remote_type("git://example.com");
}
#[test]
#[should_panic]
fn check_unsupported_protocol_file() {
detect_remote_type("file:///");
}
}
pub fn detect_remote_type(remote_url: &str) -> Option<RemoteType> {
let git_regex = regex::Regex::new(r"^[a-zA-Z]+@.*$").unwrap();
if remote_url.starts_with("ssh://") {
return Some(RemoteType::Ssh);
}
if git_regex.is_match(remote_url) && remote_url.ends_with(".git") {
return Some(RemoteType::Ssh);
}
if remote_url.starts_with("https://") {
return Some(RemoteType::Https);
}
if remote_url.starts_with("http://") {
unimplemented!("Remotes using HTTP protocol are not supported");
}
if remote_url.starts_with("git://") {
unimplemented!("Remotes using git protocol are not supported");
}
if remote_url.starts_with("file://") || remote_url.starts_with('/') {
unimplemented!("Remotes using local protocol are not supported");
}
None
}
pub fn open_repo(path: &Path) -> Result<Repository, Box<dyn std::error::Error>> {
match Repository::open(path) {
Ok(r) => Ok(r),
Err(e) => Err(Box::new(e)),
}
}
pub fn init_repo(path: &Path) -> Result<Repository, Box<dyn std::error::Error>> {
match Repository::init(path) {
Ok(r) => Ok(r),
Err(e) => Err(Box::new(e)),
}
}
pub fn clone_repo(remote: &Remote, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
print_action(&format!(
"Cloning into \"{}\" from \"{}\"",
&path.display(),
&remote.url
));
match remote.remote_type {
RemoteType::Https => match Repository::clone(&remote.url, &path) {
Ok(_) => Ok(()),
Err(e) => Err(Box::new(e)),
},
RemoteType::Ssh => {
let mut callbacks = RemoteCallbacks::new();
callbacks.credentials(|_url, username_from_url, _allowed_types| {
Cred::ssh_key_from_agent(username_from_url.unwrap())
});
let mut fo = git2::FetchOptions::new();
fo.remote_callbacks(callbacks);
let mut builder = git2::build::RepoBuilder::new();
builder.fetch_options(fo);
match builder.clone(&remote.url, path) {
Ok(_) => Ok(()),
Err(e) => Err(Box::new(e)),
}
}
}
}