commit f6a51c70cc4db04049b88e9d2ccde46ad88c75e9 Author: Hannes Körber Date: Mon Nov 15 16:16:15 2021 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..be27528 --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..190289d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "git-repo-manager" +version = "0.1.0" +edition = "2021" +authors = [ + "Hannes Körber ", +] +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" diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..de20a95 --- /dev/null +++ b/Justfile @@ -0,0 +1,8 @@ +lint: + cargo clippy --no-deps + +lint-fix: + cargo clippy --no-deps --fix + +release: + cargo build --release diff --git a/README.md b/README.md new file mode 100644 index 0000000..d15de39 --- /dev/null +++ b/README.md @@ -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 diff --git a/example.config.toml b/example.config.toml new file mode 100644 index 0000000..42fda9d --- /dev/null +++ b/example.config.toml @@ -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" diff --git a/src/cmd.rs b/src/cmd.rs new file mode 100644 index 0000000..5c3c4dc --- /dev/null +++ b/src/cmd.rs @@ -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() +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e6fdc76 --- /dev/null +++ b/src/config.rs @@ -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, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Tree { + pub root: Option, + pub repos: Option>, +} + +pub fn read_config(path: &str) -> Result { + 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) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..7c07e6b --- /dev/null +++ b/src/lib.rs @@ -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 { + Some(env_home()) + } + + let expanded_path = match shellexpand::full_with_context( + &root, + home_dir, + |name| -> Result, &'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 = 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 ¤t_remotes { + if !remotes.iter().any(|r| &r.name == current_remote) { + print_repo_action( + name, + &format!("Deleting remote \"{}\"", ¤t_remote,), + ); + if let Err(e) = repo_handle.remote_delete(current_remote) { + print_repo_error( + name, + &format!( + "Repository failed during deleting remote \"{}\": {}", + ¤t_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> { + let mut repos: Vec = 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> { + let mut repos: Vec = 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 = 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 { + let repos: Vec = 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); + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..76fe0cb --- /dev/null +++ b/src/main.rs @@ -0,0 +1,5 @@ +use grm::run; + +fn main() { + run(); +} diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..b86aca6 --- /dev/null +++ b/src/output.rs @@ -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(); +} diff --git a/src/repo.rs b/src/repo.rs new file mode 100644 index 0000000..7fba147 --- /dev/null +++ b/src/repo.rs @@ -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>, +} + +#[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 { + 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> { + match Repository::open(path) { + Ok(r) => Ok(r), + Err(e) => Err(Box::new(e)), + } +} + +pub fn init_repo(path: &Path) -> Result> { + match Repository::init(path) { + Ok(r) => Ok(r), + Err(e) => Err(Box::new(e)), + } +} + +pub fn clone_repo(remote: &Remote, path: &Path) -> Result<(), Box> { + 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)), + } + } + } +}