├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── coverage.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md └── src ├── cli.rs ├── dotfile_schema.json ├── git ├── operations.rs └── remote.rs ├── jtd-wrapper.sh ├── lib.rs ├── log.rs ├── main.rs ├── structs ├── config.rs ├── dotfile.rs ├── manifest.rs ├── metadata.rs └── mod.rs ├── subcommands ├── install.rs ├── interactive.rs └── sync.rs └── utils.rs /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | 3 | on: [push] 4 | jobs: 5 | test: 6 | name: coverage 7 | runs-on: ubuntu-latest 8 | container: 9 | image: xd009642/tarpaulin:develop-nightly 10 | options: --security-opt seccomp=unconfined 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v2 14 | 15 | - name: Generate code coverage 16 | run: | 17 | cargo +nightly tarpaulin --verbose --all-features --workspace --timeout 120 --out Xml 18 | 19 | - name: Upload to codecov.io 20 | uses: codecov/codecov-action@v2 21 | with: 22 | fail_ci_if_error: true 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | create: 5 | tags: 6 | - v[0-9]+.[0-9]+.[0-9]+ 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v2 19 | 20 | - name: Ensure Cargo.toml version matches git tag 21 | run: | 22 | toml_version=v$(sed -n 's/^version = "\(.*\)"/\1/p' < Cargo.toml) 23 | actual_version=$(git describe --tags --abbrev=0) 24 | if [[ "${toml_version}" != "${actual_version}" ]]; then 25 | echo "Refusing to publish - toml version doesn't match tag version" 26 | exit 1 27 | else 28 | echo "Git tag matches Cargo.toml, you may proceed :)" 29 | fi 30 | 31 | - name: Build 32 | run: cargo build --release 33 | 34 | - name: Publish to Crates.io 35 | run: | 36 | cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} --allow-dirty 37 | 38 | - name: Bundle release artifacts 39 | run: | 40 | mkdir release 41 | mv src/jtd-wrapper.sh release/jtd.sh 42 | mv target/release/jtd release/jtd 43 | 44 | - name: Publish to GitHub 45 | uses: softprops/action-gh-release@v1 46 | with: 47 | fail_on_unmatched_files: true 48 | files: release/* 49 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build-and-test: 14 | name: Test 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v2 21 | - name: Format 22 | run: cargo fmt -- --check 23 | - name: Check 24 | run: cargo check 25 | - name: Test 26 | run: cargo test --verbose 27 | - name: Clippy 28 | run: cargo clippy 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "0.7.18" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "atty" 16 | version = "0.2.14" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 19 | dependencies = [ 20 | "hermit-abi", 21 | "libc", 22 | "winapi", 23 | ] 24 | 25 | [[package]] 26 | name = "autocfg" 27 | version = "1.1.0" 28 | source = "registry+https://github.com/rust-lang/crates.io-index" 29 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 30 | 31 | [[package]] 32 | name = "bitflags" 33 | version = "1.3.2" 34 | source = "registry+https://github.com/rust-lang/crates.io-index" 35 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 36 | 37 | [[package]] 38 | name = "block-buffer" 39 | version = "0.7.3" 40 | source = "registry+https://github.com/rust-lang/crates.io-index" 41 | checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b" 42 | dependencies = [ 43 | "block-padding", 44 | "byte-tools", 45 | "byteorder", 46 | "generic-array 0.12.4", 47 | ] 48 | 49 | [[package]] 50 | name = "block-buffer" 51 | version = "0.10.2" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324" 54 | dependencies = [ 55 | "generic-array 0.14.5", 56 | ] 57 | 58 | [[package]] 59 | name = "block-padding" 60 | version = "0.1.5" 61 | source = "registry+https://github.com/rust-lang/crates.io-index" 62 | checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5" 63 | dependencies = [ 64 | "byte-tools", 65 | ] 66 | 67 | [[package]] 68 | name = "byte-tools" 69 | version = "0.3.1" 70 | source = "registry+https://github.com/rust-lang/crates.io-index" 71 | checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" 72 | 73 | [[package]] 74 | name = "byteorder" 75 | version = "1.4.3" 76 | source = "registry+https://github.com/rust-lang/crates.io-index" 77 | checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" 78 | 79 | [[package]] 80 | name = "cc" 81 | version = "1.0.73" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" 84 | dependencies = [ 85 | "jobserver", 86 | ] 87 | 88 | [[package]] 89 | name = "cfg-if" 90 | version = "1.0.0" 91 | source = "registry+https://github.com/rust-lang/crates.io-index" 92 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 93 | 94 | [[package]] 95 | name = "clap" 96 | version = "3.1.8" 97 | source = "registry+https://github.com/rust-lang/crates.io-index" 98 | checksum = "71c47df61d9e16dc010b55dba1952a57d8c215dbb533fd13cdd13369aac73b1c" 99 | dependencies = [ 100 | "atty", 101 | "bitflags", 102 | "clap_derive", 103 | "indexmap", 104 | "lazy_static", 105 | "os_str_bytes", 106 | "strsim", 107 | "termcolor", 108 | "textwrap", 109 | ] 110 | 111 | [[package]] 112 | name = "clap_derive" 113 | version = "3.1.7" 114 | source = "registry+https://github.com/rust-lang/crates.io-index" 115 | checksum = "a3aab4734e083b809aaf5794e14e756d1c798d2c69c7f7de7a09a2f5214993c1" 116 | dependencies = [ 117 | "heck 0.4.0", 118 | "proc-macro-error", 119 | "proc-macro2", 120 | "quote", 121 | "syn", 122 | ] 123 | 124 | [[package]] 125 | name = "console" 126 | version = "0.14.1" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "3993e6445baa160675931ec041a5e03ca84b9c6e32a056150d3aa2bdda0a1f45" 129 | dependencies = [ 130 | "encode_unicode", 131 | "lazy_static", 132 | "libc", 133 | "regex", 134 | "terminal_size", 135 | "unicode-width", 136 | "winapi", 137 | ] 138 | 139 | [[package]] 140 | name = "console" 141 | version = "0.15.0" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "a28b32d32ca44b70c3e4acd7db1babf555fa026e385fb95f18028f88848b3c31" 144 | dependencies = [ 145 | "encode_unicode", 146 | "libc", 147 | "once_cell", 148 | "regex", 149 | "terminal_size", 150 | "unicode-width", 151 | "winapi", 152 | ] 153 | 154 | [[package]] 155 | name = "cpufeatures" 156 | version = "0.2.2" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b" 159 | dependencies = [ 160 | "libc", 161 | ] 162 | 163 | [[package]] 164 | name = "crypto-common" 165 | version = "0.1.3" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "57952ca27b5e3606ff4dd79b0020231aaf9d6aa76dc05fd30137538c50bd3ce8" 168 | dependencies = [ 169 | "generic-array 0.14.5", 170 | "typenum", 171 | ] 172 | 173 | [[package]] 174 | name = "dialoguer" 175 | version = "0.8.0" 176 | source = "registry+https://github.com/rust-lang/crates.io-index" 177 | checksum = "c9dd058f8b65922819fabb4a41e7d1964e56344042c26efbccd465202c23fa0c" 178 | dependencies = [ 179 | "console 0.14.1", 180 | "lazy_static", 181 | "tempfile", 182 | "zeroize", 183 | ] 184 | 185 | [[package]] 186 | name = "dialoguer" 187 | version = "0.10.0" 188 | source = "registry+https://github.com/rust-lang/crates.io-index" 189 | checksum = "349d6b4fabcd9e97e1df1ae15395ac7e49fb144946a0d453959dc2696273b9da" 190 | dependencies = [ 191 | "console 0.15.0", 192 | "tempfile", 193 | "zeroize", 194 | ] 195 | 196 | [[package]] 197 | name = "digest" 198 | version = "0.8.1" 199 | source = "registry+https://github.com/rust-lang/crates.io-index" 200 | checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" 201 | dependencies = [ 202 | "generic-array 0.12.4", 203 | ] 204 | 205 | [[package]] 206 | name = "digest" 207 | version = "0.10.3" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506" 210 | dependencies = [ 211 | "block-buffer 0.10.2", 212 | "crypto-common", 213 | ] 214 | 215 | [[package]] 216 | name = "dirs" 217 | version = "4.0.0" 218 | source = "registry+https://github.com/rust-lang/crates.io-index" 219 | checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" 220 | dependencies = [ 221 | "dirs-sys", 222 | ] 223 | 224 | [[package]] 225 | name = "dirs-next" 226 | version = "2.0.0" 227 | source = "registry+https://github.com/rust-lang/crates.io-index" 228 | checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" 229 | dependencies = [ 230 | "cfg-if", 231 | "dirs-sys-next", 232 | ] 233 | 234 | [[package]] 235 | name = "dirs-sys" 236 | version = "0.3.7" 237 | source = "registry+https://github.com/rust-lang/crates.io-index" 238 | checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" 239 | dependencies = [ 240 | "libc", 241 | "redox_users", 242 | "winapi", 243 | ] 244 | 245 | [[package]] 246 | name = "dirs-sys-next" 247 | version = "0.1.2" 248 | source = "registry+https://github.com/rust-lang/crates.io-index" 249 | checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" 250 | dependencies = [ 251 | "libc", 252 | "redox_users", 253 | "winapi", 254 | ] 255 | 256 | [[package]] 257 | name = "encode_unicode" 258 | version = "0.3.6" 259 | source = "registry+https://github.com/rust-lang/crates.io-index" 260 | checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" 261 | 262 | [[package]] 263 | name = "fake-simd" 264 | version = "0.1.2" 265 | source = "registry+https://github.com/rust-lang/crates.io-index" 266 | checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" 267 | 268 | [[package]] 269 | name = "fastrand" 270 | version = "1.7.0" 271 | source = "registry+https://github.com/rust-lang/crates.io-index" 272 | checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf" 273 | dependencies = [ 274 | "instant", 275 | ] 276 | 277 | [[package]] 278 | name = "form_urlencoded" 279 | version = "1.0.1" 280 | source = "registry+https://github.com/rust-lang/crates.io-index" 281 | checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" 282 | dependencies = [ 283 | "matches", 284 | "percent-encoding", 285 | ] 286 | 287 | [[package]] 288 | name = "generic-array" 289 | version = "0.12.4" 290 | source = "registry+https://github.com/rust-lang/crates.io-index" 291 | checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" 292 | dependencies = [ 293 | "typenum", 294 | ] 295 | 296 | [[package]] 297 | name = "generic-array" 298 | version = "0.14.5" 299 | source = "registry+https://github.com/rust-lang/crates.io-index" 300 | checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803" 301 | dependencies = [ 302 | "typenum", 303 | "version_check", 304 | ] 305 | 306 | [[package]] 307 | name = "getrandom" 308 | version = "0.2.6" 309 | source = "registry+https://github.com/rust-lang/crates.io-index" 310 | checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad" 311 | dependencies = [ 312 | "cfg-if", 313 | "libc", 314 | "wasi", 315 | ] 316 | 317 | [[package]] 318 | name = "git2" 319 | version = "0.14.2" 320 | source = "registry+https://github.com/rust-lang/crates.io-index" 321 | checksum = "3826a6e0e2215d7a41c2bfc7c9244123969273f3476b939a226aac0ab56e9e3c" 322 | dependencies = [ 323 | "bitflags", 324 | "libc", 325 | "libgit2-sys", 326 | "log", 327 | "openssl-probe", 328 | "openssl-sys", 329 | "url", 330 | ] 331 | 332 | [[package]] 333 | name = "git2_credentials" 334 | version = "0.8.0" 335 | source = "registry+https://github.com/rust-lang/crates.io-index" 336 | checksum = "70247456d7e45a5df577b2064dd11c61a3a71eb25751e58c8da211c602cdcd7a" 337 | dependencies = [ 338 | "dialoguer 0.10.0", 339 | "dirs", 340 | "git2", 341 | "pest", 342 | "pest_derive", 343 | "regex", 344 | ] 345 | 346 | [[package]] 347 | name = "hashbrown" 348 | version = "0.11.2" 349 | source = "registry+https://github.com/rust-lang/crates.io-index" 350 | checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" 351 | 352 | [[package]] 353 | name = "heck" 354 | version = "0.3.3" 355 | source = "registry+https://github.com/rust-lang/crates.io-index" 356 | checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" 357 | dependencies = [ 358 | "unicode-segmentation", 359 | ] 360 | 361 | [[package]] 362 | name = "heck" 363 | version = "0.4.0" 364 | source = "registry+https://github.com/rust-lang/crates.io-index" 365 | checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" 366 | 367 | [[package]] 368 | name = "hermit-abi" 369 | version = "0.1.19" 370 | source = "registry+https://github.com/rust-lang/crates.io-index" 371 | checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" 372 | dependencies = [ 373 | "libc", 374 | ] 375 | 376 | [[package]] 377 | name = "hex" 378 | version = "0.4.3" 379 | source = "registry+https://github.com/rust-lang/crates.io-index" 380 | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 381 | 382 | [[package]] 383 | name = "idna" 384 | version = "0.2.3" 385 | source = "registry+https://github.com/rust-lang/crates.io-index" 386 | checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" 387 | dependencies = [ 388 | "matches", 389 | "unicode-bidi", 390 | "unicode-normalization", 391 | ] 392 | 393 | [[package]] 394 | name = "indexmap" 395 | version = "1.8.1" 396 | source = "registry+https://github.com/rust-lang/crates.io-index" 397 | checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee" 398 | dependencies = [ 399 | "autocfg", 400 | "hashbrown", 401 | ] 402 | 403 | [[package]] 404 | name = "instant" 405 | version = "0.1.12" 406 | source = "registry+https://github.com/rust-lang/crates.io-index" 407 | checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" 408 | dependencies = [ 409 | "cfg-if", 410 | ] 411 | 412 | [[package]] 413 | name = "jobserver" 414 | version = "0.1.24" 415 | source = "registry+https://github.com/rust-lang/crates.io-index" 416 | checksum = "af25a77299a7f711a01975c35a6a424eb6862092cc2d6c72c4ed6cbc56dfc1fa" 417 | dependencies = [ 418 | "libc", 419 | ] 420 | 421 | [[package]] 422 | name = "jointhedots" 423 | version = "0.0.19" 424 | dependencies = [ 425 | "clap", 426 | "console 0.15.0", 427 | "dialoguer 0.8.0", 428 | "git2", 429 | "git2_credentials", 430 | "hex", 431 | "lazy_static", 432 | "regex", 433 | "serde", 434 | "serde_yaml", 435 | "sha-1 0.10.0", 436 | "shellexpand", 437 | "strum", 438 | "strum_macros", 439 | "tempfile", 440 | ] 441 | 442 | [[package]] 443 | name = "lazy_static" 444 | version = "1.4.0" 445 | source = "registry+https://github.com/rust-lang/crates.io-index" 446 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 447 | 448 | [[package]] 449 | name = "libc" 450 | version = "0.2.121" 451 | source = "registry+https://github.com/rust-lang/crates.io-index" 452 | checksum = "efaa7b300f3b5fe8eb6bf21ce3895e1751d9665086af2d64b42f19701015ff4f" 453 | 454 | [[package]] 455 | name = "libgit2-sys" 456 | version = "0.13.2+1.4.2" 457 | source = "registry+https://github.com/rust-lang/crates.io-index" 458 | checksum = "3a42de9a51a5c12e00fc0e4ca6bc2ea43582fc6418488e8f615e905d886f258b" 459 | dependencies = [ 460 | "cc", 461 | "libc", 462 | "libssh2-sys", 463 | "libz-sys", 464 | "openssl-sys", 465 | "pkg-config", 466 | ] 467 | 468 | [[package]] 469 | name = "libssh2-sys" 470 | version = "0.2.23" 471 | source = "registry+https://github.com/rust-lang/crates.io-index" 472 | checksum = "b094a36eb4b8b8c8a7b4b8ae43b2944502be3e59cd87687595cf6b0a71b3f4ca" 473 | dependencies = [ 474 | "cc", 475 | "libc", 476 | "libz-sys", 477 | "openssl-sys", 478 | "pkg-config", 479 | "vcpkg", 480 | ] 481 | 482 | [[package]] 483 | name = "libz-sys" 484 | version = "1.1.5" 485 | source = "registry+https://github.com/rust-lang/crates.io-index" 486 | checksum = "6f35facd4a5673cb5a48822be2be1d4236c1c99cb4113cab7061ac720d5bf859" 487 | dependencies = [ 488 | "cc", 489 | "libc", 490 | "pkg-config", 491 | "vcpkg", 492 | ] 493 | 494 | [[package]] 495 | name = "linked-hash-map" 496 | version = "0.5.4" 497 | source = "registry+https://github.com/rust-lang/crates.io-index" 498 | checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" 499 | 500 | [[package]] 501 | name = "log" 502 | version = "0.4.16" 503 | source = "registry+https://github.com/rust-lang/crates.io-index" 504 | checksum = "6389c490849ff5bc16be905ae24bc913a9c8892e19b2341dbc175e14c341c2b8" 505 | dependencies = [ 506 | "cfg-if", 507 | ] 508 | 509 | [[package]] 510 | name = "maplit" 511 | version = "1.0.2" 512 | source = "registry+https://github.com/rust-lang/crates.io-index" 513 | checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" 514 | 515 | [[package]] 516 | name = "matches" 517 | version = "0.1.9" 518 | source = "registry+https://github.com/rust-lang/crates.io-index" 519 | checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" 520 | 521 | [[package]] 522 | name = "memchr" 523 | version = "2.4.1" 524 | source = "registry+https://github.com/rust-lang/crates.io-index" 525 | checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" 526 | 527 | [[package]] 528 | name = "once_cell" 529 | version = "1.10.0" 530 | source = "registry+https://github.com/rust-lang/crates.io-index" 531 | checksum = "87f3e037eac156d1775da914196f0f37741a274155e34a0b7e427c35d2a2ecb9" 532 | 533 | [[package]] 534 | name = "opaque-debug" 535 | version = "0.2.3" 536 | source = "registry+https://github.com/rust-lang/crates.io-index" 537 | checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c" 538 | 539 | [[package]] 540 | name = "openssl-probe" 541 | version = "0.1.5" 542 | source = "registry+https://github.com/rust-lang/crates.io-index" 543 | checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" 544 | 545 | [[package]] 546 | name = "openssl-sys" 547 | version = "0.9.72" 548 | source = "registry+https://github.com/rust-lang/crates.io-index" 549 | checksum = "7e46109c383602735fa0a2e48dd2b7c892b048e1bf69e5c3b1d804b7d9c203cb" 550 | dependencies = [ 551 | "autocfg", 552 | "cc", 553 | "libc", 554 | "pkg-config", 555 | "vcpkg", 556 | ] 557 | 558 | [[package]] 559 | name = "os_str_bytes" 560 | version = "6.0.0" 561 | source = "registry+https://github.com/rust-lang/crates.io-index" 562 | checksum = "8e22443d1643a904602595ba1cd8f7d896afe56d26712531c5ff73a15b2fbf64" 563 | dependencies = [ 564 | "memchr", 565 | ] 566 | 567 | [[package]] 568 | name = "percent-encoding" 569 | version = "2.1.0" 570 | source = "registry+https://github.com/rust-lang/crates.io-index" 571 | checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" 572 | 573 | [[package]] 574 | name = "pest" 575 | version = "2.1.3" 576 | source = "registry+https://github.com/rust-lang/crates.io-index" 577 | checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53" 578 | dependencies = [ 579 | "ucd-trie", 580 | ] 581 | 582 | [[package]] 583 | name = "pest_derive" 584 | version = "2.1.0" 585 | source = "registry+https://github.com/rust-lang/crates.io-index" 586 | checksum = "833d1ae558dc601e9a60366421196a8d94bc0ac980476d0b67e1d0988d72b2d0" 587 | dependencies = [ 588 | "pest", 589 | "pest_generator", 590 | ] 591 | 592 | [[package]] 593 | name = "pest_generator" 594 | version = "2.1.3" 595 | source = "registry+https://github.com/rust-lang/crates.io-index" 596 | checksum = "99b8db626e31e5b81787b9783425769681b347011cc59471e33ea46d2ea0cf55" 597 | dependencies = [ 598 | "pest", 599 | "pest_meta", 600 | "proc-macro2", 601 | "quote", 602 | "syn", 603 | ] 604 | 605 | [[package]] 606 | name = "pest_meta" 607 | version = "2.1.3" 608 | source = "registry+https://github.com/rust-lang/crates.io-index" 609 | checksum = "54be6e404f5317079812fc8f9f5279de376d8856929e21c184ecf6bbd692a11d" 610 | dependencies = [ 611 | "maplit", 612 | "pest", 613 | "sha-1 0.8.2", 614 | ] 615 | 616 | [[package]] 617 | name = "pkg-config" 618 | version = "0.3.25" 619 | source = "registry+https://github.com/rust-lang/crates.io-index" 620 | checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" 621 | 622 | [[package]] 623 | name = "proc-macro-error" 624 | version = "1.0.4" 625 | source = "registry+https://github.com/rust-lang/crates.io-index" 626 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 627 | dependencies = [ 628 | "proc-macro-error-attr", 629 | "proc-macro2", 630 | "quote", 631 | "syn", 632 | "version_check", 633 | ] 634 | 635 | [[package]] 636 | name = "proc-macro-error-attr" 637 | version = "1.0.4" 638 | source = "registry+https://github.com/rust-lang/crates.io-index" 639 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 640 | dependencies = [ 641 | "proc-macro2", 642 | "quote", 643 | "version_check", 644 | ] 645 | 646 | [[package]] 647 | name = "proc-macro2" 648 | version = "1.0.36" 649 | source = "registry+https://github.com/rust-lang/crates.io-index" 650 | checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029" 651 | dependencies = [ 652 | "unicode-xid", 653 | ] 654 | 655 | [[package]] 656 | name = "quote" 657 | version = "1.0.17" 658 | source = "registry+https://github.com/rust-lang/crates.io-index" 659 | checksum = "632d02bff7f874a36f33ea8bb416cd484b90cc66c1194b1a1110d067a7013f58" 660 | dependencies = [ 661 | "proc-macro2", 662 | ] 663 | 664 | [[package]] 665 | name = "redox_syscall" 666 | version = "0.2.13" 667 | source = "registry+https://github.com/rust-lang/crates.io-index" 668 | checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" 669 | dependencies = [ 670 | "bitflags", 671 | ] 672 | 673 | [[package]] 674 | name = "redox_users" 675 | version = "0.4.3" 676 | source = "registry+https://github.com/rust-lang/crates.io-index" 677 | checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" 678 | dependencies = [ 679 | "getrandom", 680 | "redox_syscall", 681 | "thiserror", 682 | ] 683 | 684 | [[package]] 685 | name = "regex" 686 | version = "1.5.5" 687 | source = "registry+https://github.com/rust-lang/crates.io-index" 688 | checksum = "1a11647b6b25ff05a515cb92c365cec08801e83423a235b51e231e1808747286" 689 | dependencies = [ 690 | "aho-corasick", 691 | "memchr", 692 | "regex-syntax", 693 | ] 694 | 695 | [[package]] 696 | name = "regex-syntax" 697 | version = "0.6.25" 698 | source = "registry+https://github.com/rust-lang/crates.io-index" 699 | checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" 700 | 701 | [[package]] 702 | name = "remove_dir_all" 703 | version = "0.5.3" 704 | source = "registry+https://github.com/rust-lang/crates.io-index" 705 | checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" 706 | dependencies = [ 707 | "winapi", 708 | ] 709 | 710 | [[package]] 711 | name = "rustversion" 712 | version = "1.0.6" 713 | source = "registry+https://github.com/rust-lang/crates.io-index" 714 | checksum = "f2cc38e8fa666e2de3c4aba7edeb5ffc5246c1c2ed0e3d17e560aeeba736b23f" 715 | 716 | [[package]] 717 | name = "ryu" 718 | version = "1.0.9" 719 | source = "registry+https://github.com/rust-lang/crates.io-index" 720 | checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" 721 | 722 | [[package]] 723 | name = "serde" 724 | version = "1.0.136" 725 | source = "registry+https://github.com/rust-lang/crates.io-index" 726 | checksum = "ce31e24b01e1e524df96f1c2fdd054405f8d7376249a5110886fb4b658484789" 727 | dependencies = [ 728 | "serde_derive", 729 | ] 730 | 731 | [[package]] 732 | name = "serde_derive" 733 | version = "1.0.136" 734 | source = "registry+https://github.com/rust-lang/crates.io-index" 735 | checksum = "08597e7152fcd306f41838ed3e37be9eaeed2b61c42e2117266a554fab4662f9" 736 | dependencies = [ 737 | "proc-macro2", 738 | "quote", 739 | "syn", 740 | ] 741 | 742 | [[package]] 743 | name = "serde_yaml" 744 | version = "0.8.23" 745 | source = "registry+https://github.com/rust-lang/crates.io-index" 746 | checksum = "a4a521f2940385c165a24ee286aa8599633d162077a54bdcae2a6fd5a7bfa7a0" 747 | dependencies = [ 748 | "indexmap", 749 | "ryu", 750 | "serde", 751 | "yaml-rust", 752 | ] 753 | 754 | [[package]] 755 | name = "sha-1" 756 | version = "0.8.2" 757 | source = "registry+https://github.com/rust-lang/crates.io-index" 758 | checksum = "f7d94d0bede923b3cea61f3f1ff57ff8cdfd77b400fb8f9998949e0cf04163df" 759 | dependencies = [ 760 | "block-buffer 0.7.3", 761 | "digest 0.8.1", 762 | "fake-simd", 763 | "opaque-debug", 764 | ] 765 | 766 | [[package]] 767 | name = "sha-1" 768 | version = "0.10.0" 769 | source = "registry+https://github.com/rust-lang/crates.io-index" 770 | checksum = "028f48d513f9678cda28f6e4064755b3fbb2af6acd672f2c209b62323f7aea0f" 771 | dependencies = [ 772 | "cfg-if", 773 | "cpufeatures", 774 | "digest 0.10.3", 775 | ] 776 | 777 | [[package]] 778 | name = "shellexpand" 779 | version = "2.1.0" 780 | source = "registry+https://github.com/rust-lang/crates.io-index" 781 | checksum = "83bdb7831b2d85ddf4a7b148aa19d0587eddbe8671a436b7bd1182eaad0f2829" 782 | dependencies = [ 783 | "dirs-next", 784 | ] 785 | 786 | [[package]] 787 | name = "strsim" 788 | version = "0.10.0" 789 | source = "registry+https://github.com/rust-lang/crates.io-index" 790 | checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 791 | 792 | [[package]] 793 | name = "strum" 794 | version = "0.23.0" 795 | source = "registry+https://github.com/rust-lang/crates.io-index" 796 | checksum = "cae14b91c7d11c9a851d3fbc80a963198998c2a64eec840477fa92d8ce9b70bb" 797 | 798 | [[package]] 799 | name = "strum_macros" 800 | version = "0.23.1" 801 | source = "registry+https://github.com/rust-lang/crates.io-index" 802 | checksum = "5bb0dc7ee9c15cea6199cde9a127fa16a4c5819af85395457ad72d68edc85a38" 803 | dependencies = [ 804 | "heck 0.3.3", 805 | "proc-macro2", 806 | "quote", 807 | "rustversion", 808 | "syn", 809 | ] 810 | 811 | [[package]] 812 | name = "syn" 813 | version = "1.0.90" 814 | source = "registry+https://github.com/rust-lang/crates.io-index" 815 | checksum = "704df27628939572cd88d33f171cd6f896f4eaca85252c6e0a72d8d8287ee86f" 816 | dependencies = [ 817 | "proc-macro2", 818 | "quote", 819 | "unicode-xid", 820 | ] 821 | 822 | [[package]] 823 | name = "tempfile" 824 | version = "3.3.0" 825 | source = "registry+https://github.com/rust-lang/crates.io-index" 826 | checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" 827 | dependencies = [ 828 | "cfg-if", 829 | "fastrand", 830 | "libc", 831 | "redox_syscall", 832 | "remove_dir_all", 833 | "winapi", 834 | ] 835 | 836 | [[package]] 837 | name = "termcolor" 838 | version = "1.1.3" 839 | source = "registry+https://github.com/rust-lang/crates.io-index" 840 | checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" 841 | dependencies = [ 842 | "winapi-util", 843 | ] 844 | 845 | [[package]] 846 | name = "terminal_size" 847 | version = "0.1.17" 848 | source = "registry+https://github.com/rust-lang/crates.io-index" 849 | checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" 850 | dependencies = [ 851 | "libc", 852 | "winapi", 853 | ] 854 | 855 | [[package]] 856 | name = "textwrap" 857 | version = "0.15.0" 858 | source = "registry+https://github.com/rust-lang/crates.io-index" 859 | checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" 860 | 861 | [[package]] 862 | name = "thiserror" 863 | version = "1.0.30" 864 | source = "registry+https://github.com/rust-lang/crates.io-index" 865 | checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417" 866 | dependencies = [ 867 | "thiserror-impl", 868 | ] 869 | 870 | [[package]] 871 | name = "thiserror-impl" 872 | version = "1.0.30" 873 | source = "registry+https://github.com/rust-lang/crates.io-index" 874 | checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b" 875 | dependencies = [ 876 | "proc-macro2", 877 | "quote", 878 | "syn", 879 | ] 880 | 881 | [[package]] 882 | name = "tinyvec" 883 | version = "1.5.1" 884 | source = "registry+https://github.com/rust-lang/crates.io-index" 885 | checksum = "2c1c1d5a42b6245520c249549ec267180beaffcc0615401ac8e31853d4b6d8d2" 886 | dependencies = [ 887 | "tinyvec_macros", 888 | ] 889 | 890 | [[package]] 891 | name = "tinyvec_macros" 892 | version = "0.1.0" 893 | source = "registry+https://github.com/rust-lang/crates.io-index" 894 | checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" 895 | 896 | [[package]] 897 | name = "typenum" 898 | version = "1.15.0" 899 | source = "registry+https://github.com/rust-lang/crates.io-index" 900 | checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" 901 | 902 | [[package]] 903 | name = "ucd-trie" 904 | version = "0.1.3" 905 | source = "registry+https://github.com/rust-lang/crates.io-index" 906 | checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" 907 | 908 | [[package]] 909 | name = "unicode-bidi" 910 | version = "0.3.7" 911 | source = "registry+https://github.com/rust-lang/crates.io-index" 912 | checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f" 913 | 914 | [[package]] 915 | name = "unicode-normalization" 916 | version = "0.1.19" 917 | source = "registry+https://github.com/rust-lang/crates.io-index" 918 | checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" 919 | dependencies = [ 920 | "tinyvec", 921 | ] 922 | 923 | [[package]] 924 | name = "unicode-segmentation" 925 | version = "1.9.0" 926 | source = "registry+https://github.com/rust-lang/crates.io-index" 927 | checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" 928 | 929 | [[package]] 930 | name = "unicode-width" 931 | version = "0.1.9" 932 | source = "registry+https://github.com/rust-lang/crates.io-index" 933 | checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" 934 | 935 | [[package]] 936 | name = "unicode-xid" 937 | version = "0.2.2" 938 | source = "registry+https://github.com/rust-lang/crates.io-index" 939 | checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" 940 | 941 | [[package]] 942 | name = "url" 943 | version = "2.2.2" 944 | source = "registry+https://github.com/rust-lang/crates.io-index" 945 | checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" 946 | dependencies = [ 947 | "form_urlencoded", 948 | "idna", 949 | "matches", 950 | "percent-encoding", 951 | ] 952 | 953 | [[package]] 954 | name = "vcpkg" 955 | version = "0.2.15" 956 | source = "registry+https://github.com/rust-lang/crates.io-index" 957 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 958 | 959 | [[package]] 960 | name = "version_check" 961 | version = "0.9.4" 962 | source = "registry+https://github.com/rust-lang/crates.io-index" 963 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 964 | 965 | [[package]] 966 | name = "wasi" 967 | version = "0.10.2+wasi-snapshot-preview1" 968 | source = "registry+https://github.com/rust-lang/crates.io-index" 969 | checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" 970 | 971 | [[package]] 972 | name = "winapi" 973 | version = "0.3.9" 974 | source = "registry+https://github.com/rust-lang/crates.io-index" 975 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 976 | dependencies = [ 977 | "winapi-i686-pc-windows-gnu", 978 | "winapi-x86_64-pc-windows-gnu", 979 | ] 980 | 981 | [[package]] 982 | name = "winapi-i686-pc-windows-gnu" 983 | version = "0.4.0" 984 | source = "registry+https://github.com/rust-lang/crates.io-index" 985 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 986 | 987 | [[package]] 988 | name = "winapi-util" 989 | version = "0.1.5" 990 | source = "registry+https://github.com/rust-lang/crates.io-index" 991 | checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" 992 | dependencies = [ 993 | "winapi", 994 | ] 995 | 996 | [[package]] 997 | name = "winapi-x86_64-pc-windows-gnu" 998 | version = "0.4.0" 999 | source = "registry+https://github.com/rust-lang/crates.io-index" 1000 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1001 | 1002 | [[package]] 1003 | name = "yaml-rust" 1004 | version = "0.4.5" 1005 | source = "registry+https://github.com/rust-lang/crates.io-index" 1006 | checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" 1007 | dependencies = [ 1008 | "linked-hash-map", 1009 | ] 1010 | 1011 | [[package]] 1012 | name = "zeroize" 1013 | version = "1.5.4" 1014 | source = "registry+https://github.com/rust-lang/crates.io-index" 1015 | checksum = "7eb5728b8afd3f280a869ce1d4c554ffaed35f45c231fc41bfbd0381bef50317" 1016 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "jointhedots" 3 | version = "0.1.0" 4 | edition = "2018" 5 | license = "MIT" 6 | authors = ["Daniel O'Brien "] 7 | description = "A simple git-based dotfile manager written entirely in Rust!" 8 | repository = "https://github.com/dob9601/jointhedots" 9 | 10 | [[bin]] 11 | name = "jtd" 12 | path = "src/main.rs" 13 | 14 | [dependencies] 15 | clap = { version = "3.1.8", features = ["derive"] } 16 | console = "0.15.0" 17 | dialoguer = "0.8.0" 18 | git2 = "0.14.2" 19 | git2_credentials = "0.8.0" 20 | hex = "0.4.3" 21 | lazy_static = "1.4.0" 22 | regex = "1.5.4" 23 | serde = { version = "1.0", features = ["derive"] } 24 | serde_yaml = "0.8" 25 | sha-1 = "0.10.0" 26 | shellexpand = "2.1.0" 27 | strum = "0.23.0" 28 | strum_macros = "0.23" 29 | tempfile = "3" 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Daniel O'Brien 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jointhedots 2 | 3 | [![Release](https://github.com/dob9601/jointhedots/actions/workflows/release.yml/badge.svg)](https://github.com/dob9601/jointhedots/actions/workflows/release.yml) 4 | [![Test](https://github.com/dob9601/jointhedots/actions/workflows/test.yml/badge.svg)](https://github.com/dob9601/jointhedots/actions/workflows/test.yml) 5 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://makeapullrequest.com) 6 | [![codecov](https://codecov.io/gh/dob9601/jointhedots/branch/master/graph/badge.svg?token=KZM28SFIEL)](https://codecov.io/gh/dob9601/jointhedots) 7 | 8 | ![Demo](https://user-images.githubusercontent.com/24723950/160283737-6cced48d-3ea1-4b49-8e17-bbb6fd10c9e7.gif) 9 | ``` 10 | jointhedots 11 | A simple git-based dotfile manager written entirely in Rust! 12 | 13 | USAGE: 14 | jtd 15 | 16 | OPTIONS: 17 | -h, --help Print help information 18 | 19 | SUBCOMMANDS: 20 | help Print this message or the help of the given subcommand(s) 21 | install Install a specified JTD repository 22 | interactive Interactively install dotfiles 23 | sync Sync the currently installed JTD repository with the provided remote repo. 24 | ``` 25 | 26 | ## Contents 27 | - [About](#about) 28 | - [Roadmap](#roadmap) 29 | - [Installation](#installation) 30 | - [Configuration](#configuration) 31 | - [Example Manifest](#example-manifest) 32 | - [FAQ](#faq) 33 | 34 | ## About 35 | ![Git log example](https://user-images.githubusercontent.com/24723950/160243228-5dce7b66-1c1b-4a7b-96a2-a2bf10feb0d1.png) 36 | 37 | jointhedots works by reading a "jtd.yaml" manifest file located within your dotfile repository. The manifest contains a mapping of file to installed location (amongst other things), allowing for JTD to automatically install configurations. `pre_install` and `post_install` commands can also be specified, allowing for additional control over installation. 38 | 39 | jtd also allows for pushing your dotfiles back to the remote repo and resolves merges via git. It's also possible to avoid all prompts for input. This, combined with the fact that jtd is deterministic, makes it very suitable for for use in scripts. 40 | 41 | These install steps are designed so that they will run once on your first install, store a hash of the steps run and then only run if the hash differs (i.e. you have modified your config with new install steps). 42 | 43 | *WARNING:* Be very careful about installing dotfiles via untrusted manifests. The pre\_install and post\_install blocks allow for (potentially malicious) code execution**. JTD will prompt you to confirm you trust a manifest if it contains install steps. 44 | 45 | ## Roadmap 46 | | Feature | Implemented | Notes | 47 | | :--- | :---: | :--- | 48 | | Sync local changes to dotfiles with remote repo | ✔ | | 49 | | Interactive mode | ✔ | | 50 | | Selectively install only some dotfiles | ✔ | | 51 | | JSON Schema for manifest files | ✔ | | 52 | | Host latest version somewhere that can be curled | ✔ | `jtd.danielobr.ie` | 53 | | Selectively sync only some dotfile changes | ✔ | | 54 | | Use `git2` as opposed to `Command::new("git")` | ✔ | | 55 | | Ability to specify which manifest to use in (multiple manifest support) | ✔ | | 56 | | Support for non-GitHub/GitLab repos | | | 57 | | Ability to manually specify commit message for JTD sync | ✔ | | 58 | | More detailed default commit messages for JTD sync (list the changed files) | ✔ | | 59 | | Abort syncing if no changes are present in files | ✔ | | 60 | | Don't allow `jtd install` if dotfiles are behind remote main (prompt user to sync) | ✔ | | 61 | 62 | ## Installation 63 | 64 | ### Manual 65 | Grab the latest version [here](https://github.com/dob9601/jointhedots/releases/latest/download/jtd) (for x86-64, more targets on the way!) 66 | ### Cargo 67 | Install via cargo: 68 | ```sh 69 | cargo install jointhedots 70 | ``` 71 | ### Curl (one-time use) 72 | Use the following 1 liner to 1-off run JTD to install your dotfiles 73 | ```sh 74 | curl -sL jtd.danielobr.ie | sh 75 | ``` 76 | 77 | ## Configuration 78 | 79 | JTDs default behaviour can be overridden using the `.config` key. Currently supported configuration: 80 | | Configuration key | Usage | Default | 81 | | :--- | :--- | :---: | 82 | | `commit_prefix` | String to prefix commits with | 🔁  | 83 | | `squash_commits` | Whether to squash commits when syncing multiple dotfiles | `true` | 84 | 85 | 86 | ## Example Manifest 87 | 88 | An example manifest file is shown below: 89 | ```yaml 90 | nvim: 91 | pre_install: 92 | - mkdir -p ~/Applications 93 | - curl -sL -o /tmp/nvim.tar.gz https://github.com/neovim/neovim/releases/latest/download/nvim-linux64.tar.gz 94 | - tar -xvf /tmp/nvim.tar.gz -C ~/Applications 95 | - rm /tmp/nvim.tar.gz 96 | - ln -rfs ~/Applications/nvim-linux64/bin/nvim ~/.local/bin/vim 97 | file: init.vim 98 | target: ~/.config/nvim/init.vim 99 | 100 | kitty: 101 | file: kitty.conf 102 | target: ~/.config/kitty/kitty.conf 103 | 104 | kitty-theme: 105 | file: theme.conf 106 | target: ~/.config/kitty/theme.conf 107 | 108 | fish: 109 | file: config.fish 110 | target: ~/.config/fish/config.fish 111 | post_install: 112 | - git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf 113 | - ~/.fzf/install --all 114 | ``` 115 | The manifest file should be located in the root of the repository and called "jtd.yaml". 116 | 117 | A JSON Schema for the manifest is available [here](https://github.com/dob9601/jointhedots/blob/master/src/dotfile_schema.json). This can be used in conjunction with certain plugins to provide language server support for jtd manifests. 118 | 119 | ## FAQ 120 | 121 | *Q: The different platforms I use require differing installation steps, can I target multiple platforms?* 122 | 123 | **A: Yes! You can write a different manifest for each platform and specify the manifest to use with the `--manifest` flag** 124 | 125 | *Q: Can jointhedots handle secrets* 126 | 127 | **A: Yes, you could store your secrets as encrypted files in the repository along with a `post_install` step to decrypt them, I'd advise against doing this in a public dotfile repository though.** 128 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | 3 | use crate::git::remote::{ConnectionMethod, RepoHostName}; 4 | 5 | #[derive(Parser, Debug)] 6 | #[clap(name = "jointhedots", bin_name = "jtd", about, version)] 7 | pub enum JoinTheDots { 8 | Install(InstallSubcommandArgs), 9 | Sync(SyncSubcommandArgs), 10 | Interactive(InteractiveSubcommandArgs), 11 | } 12 | 13 | #[derive(clap::Args, Debug)] 14 | #[clap(about = "Install a specified JTD repository", version)] 15 | pub struct InstallSubcommandArgs { 16 | #[clap(help = "The location of the repository in the form USERNAME/REPONAME")] 17 | pub repository: String, 18 | 19 | #[clap( 20 | arg_enum, 21 | long = "method", 22 | short = 'm', 23 | help = "The method to use for cloning/pushing the repository", 24 | default_value = "https" 25 | )] 26 | pub method: ConnectionMethod, 27 | 28 | #[clap( 29 | long = "manifest", 30 | short = 'n', 31 | help = "The manifest to use in the repository", 32 | default_value = "jtd.yaml" 33 | )] 34 | pub manifest: String, 35 | 36 | #[clap( 37 | help = "The dotfiles to install. If unspecified, install all of them", 38 | conflicts_with = "all" 39 | )] 40 | pub target_dotfiles: Vec, 41 | 42 | #[clap( 43 | arg_enum, 44 | default_value = "GitHub", 45 | help = "Whether to source the repo from GitHub or GitLab", 46 | long = "source", 47 | short = 's', 48 | ignore_case = true 49 | )] 50 | pub source: RepoHostName, 51 | 52 | #[clap( 53 | help = "Whether to overwrite unsynchronised configs without prompt", 54 | long = "force", 55 | short = 'f' 56 | )] 57 | pub force: bool, 58 | 59 | #[clap( 60 | help = "Whether to run any pre_install/post_install commands without prompting", 61 | long = "trust", 62 | short = 't' 63 | )] 64 | pub trust: bool, 65 | 66 | #[clap( 67 | help = "Whether to install all dotfiles in the config", 68 | long = "all", 69 | short = 'a' 70 | )] 71 | pub all: bool, 72 | } 73 | 74 | #[derive(clap::Args, Debug)] 75 | #[clap( 76 | about = "Sync the currently installed JTD repository with the provided remote repo.", 77 | version 78 | )] 79 | pub struct SyncSubcommandArgs { 80 | #[clap(help = "The location of the repository in the form USERNAME/REPONAME")] 81 | pub repository: String, 82 | 83 | #[clap( 84 | help = "The dotfiles to sync. If unspecified, sync all of them", 85 | conflicts_with = "all" 86 | )] 87 | pub target_dotfiles: Vec, 88 | 89 | #[clap( 90 | help = "Whether to install all dotfiles in the config", 91 | long = "all", 92 | short = 'a' 93 | )] 94 | pub all: bool, 95 | 96 | #[clap( 97 | arg_enum, 98 | long = "method", 99 | short = 'm', 100 | help = "The method to use for cloning/pushing the repository", 101 | default_value = "ssh" 102 | )] 103 | pub method: ConnectionMethod, 104 | 105 | #[clap( 106 | long = "manifest", 107 | short = 'n', 108 | help = "The manifest to use in the repository", 109 | default_value = "jtd.yaml" 110 | )] 111 | pub manifest: String, 112 | 113 | #[clap( 114 | arg_enum, 115 | default_value = "GitHub", 116 | help = "Whether to source the repo from GitHub or GitLab", 117 | long = "source" 118 | )] 119 | pub source: RepoHostName, 120 | 121 | #[clap( 122 | help = "The message to use for the commit", 123 | long = "commit-msg", 124 | short = 'c' 125 | )] 126 | pub commit_msg: Option, 127 | 128 | #[clap( 129 | help = "Whether to use naive sync. If not present, git-based sync will be used unless metadata \ 130 | is unavailable in which case you will be prompted as to whether you wish to fallback to naive sync.", 131 | long = "naive" 132 | )] 133 | pub naive: bool, 134 | } 135 | 136 | #[derive(clap::Args, Debug)] 137 | #[clap(about = "Interactively install dotfiles", version)] 138 | pub struct InteractiveSubcommandArgs {} 139 | -------------------------------------------------------------------------------- /src/dotfile_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "JTDManifest", 3 | "description": "A collection of Dotfiles", 4 | "type": "object", 5 | "properties": { 6 | ".config": { 7 | "properties": { 8 | "squash_commits": { 9 | "type": "boolean" 10 | }, 11 | "commit_prefix": { 12 | "type": "string" 13 | } 14 | }, 15 | "additionalProperties": false 16 | } 17 | }, 18 | "additionalProperties": { 19 | "type": "object", 20 | "properties": { 21 | "file": { 22 | "type": "string" 23 | }, 24 | "target": { 25 | "type": "string" 26 | }, 27 | "pre_install": { 28 | "type": "array", 29 | "items": { 30 | "type": "string" 31 | } 32 | }, 33 | "post_install": { 34 | "type": "array", 35 | "items": { 36 | "type": "string" 37 | } 38 | } 39 | }, 40 | "required": [ 41 | "file", 42 | "target" 43 | ], 44 | "additionalProperties": false 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/git/operations.rs: -------------------------------------------------------------------------------- 1 | use std::io::{stdin, stdout, Write}; 2 | use std::{error::Error, path::Path, sync::RwLock}; 3 | 4 | use console::style; 5 | use dialoguer::{Input, Password}; 6 | use git2::build::CheckoutBuilder; 7 | use git2::{ 8 | AnnotatedCommit, Commit, Direction, PushOptions, RemoteCallbacks, Repository, Signature, 9 | }; 10 | use git2::{Error as Git2Error, IndexAddOption, MergeOptions}; 11 | use git2_credentials::{CredentialHandler, CredentialUI}; 12 | 13 | use crate::utils::get_theme; 14 | use lazy_static::lazy_static; 15 | 16 | pub fn get_head(repo: &Repository) -> Result> { 17 | let commit = repo 18 | .head()? 19 | .resolve()? 20 | .peel(git2::ObjectType::Commit)? 21 | .into_commit() 22 | .unwrap(); 23 | Ok(commit) 24 | } 25 | 26 | pub fn get_head_hash(repo: &Repository) -> Result> { 27 | Ok(get_head(repo)?.id().to_string()) 28 | } 29 | 30 | pub fn checkout_ref(repo: &Repository, reference: &str) -> Result<(), Box> { 31 | let (object, reference) = repo 32 | .revparse_ext(reference) 33 | .map_err(|err| format!("Ref not found: {}", err))?; 34 | 35 | repo.checkout_tree(&object, None)?; 36 | 37 | if let Some(gref) = reference { 38 | repo.set_head(gref.name().unwrap()) 39 | } else { 40 | repo.set_head_detached(object.id()) 41 | } 42 | .map_err(|err| format!("Failed to set HEAD: {}", err).into()) 43 | } 44 | 45 | pub fn get_commit<'a>(repo: &'a Repository, commit_hash: &str) -> Result, Git2Error> { 46 | let (object, _) = repo.revparse_ext(commit_hash)?; 47 | object.peel_to_commit() 48 | } 49 | 50 | lazy_static! { 51 | static ref CREDENTIAL_CACHE: RwLock<(Option, Option)> = 52 | RwLock::new((None, None)); 53 | } 54 | 55 | pub struct CredentialUIDialoguer; 56 | 57 | impl CredentialUI for CredentialUIDialoguer { 58 | fn ask_user_password(&self, username: &str) -> Result<(String, String), Box> { 59 | let theme = get_theme(); 60 | 61 | let mut credential_cache = CREDENTIAL_CACHE.write()?; 62 | 63 | let user = match &credential_cache.0 { 64 | Some(username) => username.to_owned(), 65 | None => { 66 | let user = Input::with_theme(&theme) 67 | .default(username.to_owned()) 68 | .with_prompt("Username") 69 | .interact()?; 70 | credential_cache.0 = Some(user.to_owned()); 71 | user 72 | } 73 | }; 74 | 75 | let password = match &credential_cache.1 { 76 | Some(password) => password.to_owned(), 77 | None => { 78 | let pass = Password::with_theme(&theme) 79 | .with_prompt("Password (hidden)") 80 | .allow_empty_password(true) 81 | .interact()?; 82 | credential_cache.1 = Some(pass.to_owned()); 83 | pass 84 | } 85 | }; 86 | 87 | Ok((user, password)) 88 | } 89 | 90 | fn ask_ssh_passphrase(&self, passphrase_prompt: &str) -> Result> { 91 | let mut credential_cache = CREDENTIAL_CACHE.write()?; 92 | 93 | let passphrase = match &credential_cache.1 { 94 | Some(passphrase) => passphrase.to_owned(), 95 | None => { 96 | let pass = Password::with_theme(&get_theme()) 97 | .with_prompt(format!( 98 | "{} (leave blank for no password): ", 99 | passphrase_prompt 100 | )) 101 | .allow_empty_password(true) 102 | .interact()?; 103 | credential_cache.1 = Some(pass.to_owned()); 104 | pass 105 | } 106 | }; 107 | 108 | Ok(passphrase) 109 | } 110 | } 111 | 112 | pub fn generate_callbacks() -> Result, Box> { 113 | let mut cb = git2::RemoteCallbacks::new(); 114 | let git_config = git2::Config::open_default() 115 | .map_err(|err| format!("Could not open default git config: {}", err))?; 116 | let mut ch = CredentialHandler::new_with_ui(git_config, Box::new(CredentialUIDialoguer {})); 117 | cb.credentials(move |url, username, allowed| ch.try_next_credential(url, username, allowed)); 118 | 119 | Ok(cb) 120 | } 121 | 122 | pub fn clone_repo(url: &str, target_dir: &Path) -> Result> { 123 | // Clone the project. 124 | let cb = generate_callbacks()?; 125 | 126 | // clone a repository 127 | let mut fo = git2::FetchOptions::new(); 128 | fo.remote_callbacks(cb) 129 | .download_tags(git2::AutotagOption::All) 130 | .update_fetchhead(true); 131 | let repo = git2::build::RepoBuilder::new() 132 | .fetch_options(fo) 133 | .clone(url, target_dir) 134 | .map_err(|err| format!("Could not clone repo: {}", &err))?; 135 | 136 | success!("Successfully cloned repository!"); 137 | 138 | Ok(repo) 139 | } 140 | 141 | pub fn generate_signature() -> Result, Git2Error> { 142 | Signature::now("Jointhedots Sync", "jtd@danielobr.ie") 143 | } 144 | 145 | pub fn add_all(repo: &Repository, file_paths: Option>) -> Result<(), Box> { 146 | let mut index = repo.index()?; 147 | if let Some(file_paths) = file_paths { 148 | index.add_all(file_paths.iter(), IndexAddOption::DEFAULT, None)?; 149 | } else { 150 | index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)?; 151 | } 152 | index.write()?; 153 | Ok(()) 154 | } 155 | 156 | /// Add and commit the specified files to the repository index. 157 | /// 158 | /// # Arguments 159 | /// 160 | /// * `repo` - The repository object 161 | /// * `file_paths` - Optionally the paths of the files to commit. If `None`, all changes are 162 | /// committed. 163 | /// * `message` - The commit message to use 164 | /// * `parents` - Optionally the parent commits for the new commit. If None, `HEAD` is used 165 | /// * `update_head` - Optionally whether to update the commit the `HEAD` reference points at. 166 | /// 167 | /// # Returns 168 | /// 169 | /// The new commit in the repository 170 | pub fn add_and_commit<'a>( 171 | repo: &'a Repository, 172 | file_paths: Option>, 173 | message: &str, 174 | maybe_parents: Option>, 175 | update_ref: Option<&str>, 176 | ) -> Result, Box> { 177 | add_all(&repo, file_paths)?; 178 | 179 | let mut index = repo.index()?; 180 | let oid = index.write_tree()?; 181 | let tree = repo.find_tree(oid)?; 182 | let signature = generate_signature()?; 183 | 184 | let head; 185 | let parents = match maybe_parents { 186 | Some(parent_vec) => parent_vec, 187 | None => { 188 | head = get_head(repo)?; 189 | vec![&head] 190 | } 191 | }; 192 | let oid = repo.commit(update_ref, &signature, &signature, message, &tree, &parents)?; 193 | 194 | repo.find_commit(oid) 195 | .map_err(|err| format!("Failed to commit to repo: {}", err.to_string()).into()) 196 | } 197 | 198 | pub fn normal_merge<'a>( 199 | repo: &'a Repository, 200 | main_tip: &AnnotatedCommit, 201 | feature_tip: &AnnotatedCommit, 202 | ) -> Result, Box> { 203 | let mut options = MergeOptions::new(); 204 | options 205 | .standard_style(true) 206 | .minimal(true) 207 | .fail_on_conflict(false); 208 | repo.merge(&[feature_tip], Some(&mut options), None)?; 209 | 210 | let mut idx = repo.index()?; 211 | idx.read(false)?; 212 | if idx.has_conflicts() { 213 | let repo_dir = repo.path().to_string_lossy().replace(".git/", ""); 214 | repo.checkout_index( 215 | Some(&mut idx), 216 | Some( 217 | CheckoutBuilder::default() 218 | .allow_conflicts(true) 219 | .conflict_style_merge(true), 220 | ), 221 | )?; 222 | error!( 223 | "Merge conficts detected. Resolve them manually with the following steps:\n\n \ 224 | 1. Open the temporary repository (located in {}),\n \ 225 | 2. Resolve any merge conflicts as you would with any other repository\n \ 226 | 3. Adding the changed files but NOT committing them\n \ 227 | 4. Returning to this terminal and pressing the \"Enter\" key\n", 228 | repo_dir 229 | ); 230 | loop { 231 | print!( 232 | "{}", 233 | style("Press ENTER when conflicts are resolved") 234 | .blue() 235 | .italic() 236 | ); 237 | let _ = stdout().flush(); 238 | 239 | let mut _newline = String::new(); 240 | stdin().read_line(&mut _newline).unwrap_or(0); 241 | 242 | idx.read(false)?; 243 | 244 | if !idx.has_conflicts() { 245 | break; 246 | } else { 247 | error!("Conflicts not resolved"); 248 | } 249 | } 250 | } 251 | 252 | let tree = repo.find_tree(repo.index()?.write_tree()?)?; 253 | let signature = generate_signature()?; 254 | repo.commit( 255 | Some("HEAD"), 256 | &signature, 257 | &signature, 258 | "Merge", 259 | &tree, 260 | &[ 261 | &repo.find_commit(main_tip.id())?, 262 | &repo.find_commit(feature_tip.id())?, 263 | ], 264 | )?; 265 | repo.cleanup_state()?; 266 | Ok(get_head(&repo)?) 267 | } 268 | 269 | pub fn get_repo_dir(repo: &Repository) -> &Path { 270 | // Safe to unwrap here, repo.path() points to .git folder. Path will always 271 | // have a component before .git 272 | repo.path().parent().unwrap() 273 | } 274 | 275 | pub fn push(repo: &Repository) -> Result<(), Box> { 276 | let mut remote = repo.find_remote("origin")?; 277 | 278 | remote.connect_auth(Direction::Push, Some(generate_callbacks()?), None)?; 279 | let mut options = PushOptions::new(); 280 | options.remote_callbacks(generate_callbacks()?); 281 | remote 282 | .push(&["refs/heads/master:refs/heads/master"], Some(&mut options)) 283 | .map_err(|err| format!("Could not push to remote repo: {}", err).into()) 284 | } 285 | 286 | #[cfg(test)] 287 | mod tests { 288 | use std::fs::File; 289 | 290 | use tempfile::tempdir; 291 | 292 | use super::*; 293 | 294 | #[test] 295 | fn test_get_head() { 296 | let repo_dir = tempdir().expect("Could not create temporary repo dir"); 297 | let repo = Repository::init(&repo_dir).expect("Could not initialise repository"); 298 | 299 | let commit = add_and_commit(&repo, None, "", Some(vec![]), Some("HEAD")).unwrap(); 300 | 301 | assert_eq!(commit.id(), get_head(&repo).unwrap().id()); 302 | } 303 | 304 | #[test] 305 | fn test_get_head_hash() { 306 | let repo_dir = tempdir().unwrap(); 307 | let repo = Repository::init(&repo_dir).unwrap(); 308 | 309 | let commit = add_and_commit(&repo, None, "", Some(vec![]), Some("HEAD")).unwrap(); 310 | 311 | assert_eq!(commit.id().to_string(), get_head_hash(&repo).unwrap()); 312 | } 313 | 314 | #[test] 315 | fn test_checkout_ref() { 316 | let repo_dir = tempdir().expect("Could not create temporary repo dir"); 317 | let repo = Repository::init(&repo_dir).expect("Could not initialise repository"); 318 | 319 | let first_commit = add_and_commit(&repo, None, "", Some(vec![]), Some("HEAD")).unwrap(); 320 | let second_commit = 321 | add_and_commit(&repo, None, "", Some(vec![&first_commit]), Some("HEAD")).unwrap(); 322 | 323 | assert_eq!( 324 | repo.head().unwrap().peel_to_commit().unwrap().id(), 325 | second_commit.id() 326 | ); 327 | 328 | checkout_ref(&repo, &first_commit.id().to_string()) 329 | .expect("Failed to checkout first commit"); 330 | 331 | assert_eq!(get_head_hash(&repo).unwrap(), first_commit.id().to_string()); 332 | } 333 | 334 | #[test] 335 | fn test_get_commit() { 336 | let repo_dir = tempdir().unwrap(); 337 | let repo = Repository::init(&repo_dir).unwrap(); 338 | 339 | let commit = add_and_commit(&repo, None, "", Some(vec![]), Some("HEAD")).unwrap(); 340 | let hash = commit.id().to_string(); 341 | 342 | assert_eq!( 343 | get_commit(&repo, &hash).unwrap().id().to_string(), 344 | commit.id().to_string() 345 | ); 346 | } 347 | 348 | #[test] 349 | fn test_ask_user_password_with_cache() { 350 | { 351 | let mut credential_cache = CREDENTIAL_CACHE 352 | .write() 353 | .expect("Could not get write handle on credential cache"); 354 | credential_cache.0 = Some("username".to_string()); 355 | credential_cache.1 = Some("password".to_string()); 356 | } 357 | 358 | let credential_ui = CredentialUIDialoguer; 359 | 360 | let credentials = credential_ui 361 | .ask_user_password("") 362 | .expect("Could not get user password"); 363 | assert_eq!( 364 | ("username".to_string(), "password".to_string()), 365 | credentials 366 | ); 367 | } 368 | 369 | #[test] 370 | fn test_ask_ssh_passphrase_with_cache() { 371 | { 372 | let mut credential_cache = CREDENTIAL_CACHE 373 | .write() 374 | .expect("Could not get write handle on credential cache"); 375 | credential_cache.1 = Some("password".to_string()); 376 | } 377 | 378 | let credential_ui = CredentialUIDialoguer; 379 | 380 | let credentials = credential_ui 381 | .ask_ssh_passphrase("") 382 | .expect("Could not get user password"); 383 | assert_eq!("password".to_string(), credentials); 384 | } 385 | 386 | #[test] 387 | fn test_generate_callbacks() { 388 | let _callbacks = generate_callbacks().expect("Failed to generate callbacks"); 389 | // FIXME: Find some way to assert the return type of callbacks 390 | } 391 | 392 | #[test] 393 | fn test_clone_repo() { 394 | let repo_dir = tempdir().expect("Failed to create tempdir"); 395 | 396 | let _repo = clone_repo("https://github.com/dob9601/dotfiles.git", repo_dir.path()) 397 | .expect("Failed to clone repo"); 398 | 399 | assert!(Path::exists( 400 | &repo_dir.path().to_owned().join(Path::new("jtd.yaml")) 401 | )); 402 | } 403 | 404 | #[test] 405 | fn test_add_and_commit() { 406 | let repo_dir = tempdir().expect("Could not create temporary repo dir"); 407 | let repo = Repository::init(&repo_dir).expect("Could not initialise repository"); 408 | 409 | let mut filepath = repo_dir.path().to_owned(); 410 | filepath.push(Path::new("file.rs")); 411 | File::create(filepath.to_owned()).expect("Could not create file in repo"); 412 | 413 | add_and_commit( 414 | &repo, 415 | Some(vec![&filepath]), 416 | "commit message", 417 | Some(vec![]), 418 | Some("HEAD"), 419 | ) 420 | .expect("Failed to commit to repository"); 421 | assert_eq!( 422 | "commit message", 423 | get_head(&repo) 424 | .unwrap() 425 | .message() 426 | .expect("No commit message found") 427 | ); 428 | } 429 | 430 | #[test] 431 | fn test_normal_merge() { 432 | let repo_dir = tempdir().expect("Could not create temporary repo dir"); 433 | let repo = Repository::init(&repo_dir).expect("Could not initialise repository"); 434 | 435 | let first_commit = add_and_commit( 436 | &repo, 437 | Some(vec![]), 438 | "1st commit", 439 | Some(vec![]), 440 | Some("HEAD"), 441 | ) 442 | .expect("Failed to create 1st commit"); 443 | 444 | let _second_commit = add_and_commit( 445 | &repo, 446 | Some(vec![]), 447 | "2nd commit", 448 | Some(vec![&first_commit]), 449 | Some("HEAD"), 450 | ) 451 | .expect("Failed to create 2nd commit"); 452 | 453 | let head_ref = &repo.head().unwrap(); 454 | let head_ref_name = head_ref.name().unwrap(); 455 | let annotated_main_head = repo.reference_to_annotated_commit(&head_ref).unwrap(); 456 | 457 | let _branch = repo 458 | .branch("branch", &first_commit, true) 459 | .expect("Failed to create branch"); 460 | checkout_ref(&repo, "branch").expect("Failed to checkout new branch"); 461 | 462 | let annotated_branch_head = repo 463 | .reference_to_annotated_commit(&repo.head().unwrap()) 464 | .unwrap(); 465 | 466 | checkout_ref(&repo, head_ref_name).expect("Failed to checkout new branch"); 467 | 468 | normal_merge(&repo, &annotated_main_head, &annotated_branch_head) 469 | .expect("Failed to merge branch"); 470 | 471 | // FIXME: Some assertion on the repo state after this 472 | } 473 | 474 | #[test] 475 | fn test_generate_signature() { 476 | let signature = generate_signature().unwrap(); 477 | 478 | assert_eq!(signature.email().unwrap(), "jtd@danielobr.ie"); 479 | assert_eq!(signature.name().unwrap(), "Jointhedots Sync"); 480 | } 481 | } 482 | -------------------------------------------------------------------------------- /src/git/remote.rs: -------------------------------------------------------------------------------- 1 | use std::{error::Error, str::FromStr}; 2 | 3 | use clap::ArgEnum; 4 | use strum_macros::{Display, EnumIter}; 5 | 6 | #[derive(ArgEnum, Clone, EnumIter, Display, Debug, PartialEq)] 7 | pub enum ConnectionMethod { 8 | SSH, 9 | HTTPS, 10 | } 11 | 12 | impl FromStr for ConnectionMethod { 13 | type Err = Box; 14 | 15 | fn from_str(s: &str) -> Result { 16 | match s.to_lowercase().as_str() { 17 | "ssh" => Ok(ConnectionMethod::SSH), 18 | "https" => Ok(ConnectionMethod::HTTPS), 19 | v => Err(format!("Failed to convert: '{}' is not a known variant.", v).into()), 20 | } 21 | } 22 | } 23 | 24 | #[derive(ArgEnum, Clone, EnumIter, Display, Debug, PartialEq)] 25 | #[clap(rename_all = "PascalCase")] 26 | pub enum RepoHostName { 27 | GitHub, 28 | GitLab, 29 | } 30 | 31 | impl FromStr for RepoHostName { 32 | type Err = Box; 33 | 34 | fn from_str(s: &str) -> Result { 35 | match s.to_lowercase().as_str() { 36 | "github" => Ok(RepoHostName::GitHub), 37 | "gitlab" => Ok(RepoHostName::GitLab), 38 | v => Err(format!("Failed to convert: '{}' is not a known variant.", v).into()), 39 | } 40 | } 41 | } 42 | 43 | pub struct RepoHost { 44 | ssh_prefix: &'static str, 45 | https_prefix: &'static str, 46 | } 47 | 48 | const GITLAB: RepoHost = RepoHost { 49 | ssh_prefix: "git@gitlab.com:", 50 | https_prefix: "https://gitlab.com/", 51 | }; 52 | 53 | const GITHUB: RepoHost = RepoHost { 54 | ssh_prefix: "git@github.com:", 55 | https_prefix: "https://github.com/", 56 | }; 57 | 58 | pub fn get_host_git_url( 59 | repository: &str, 60 | host: &RepoHostName, 61 | method: &ConnectionMethod, 62 | ) -> Result> { 63 | let repo_host = match *host { 64 | RepoHostName::GitHub => GITHUB, 65 | RepoHostName::GitLab => GITLAB, 66 | }; 67 | 68 | match method { 69 | ConnectionMethod::SSH => Ok(format!("{}{}{}", repo_host.ssh_prefix, repository, ".git")), 70 | ConnectionMethod::HTTPS => Ok(format!( 71 | "{}{}{}", 72 | repo_host.https_prefix, repository, ".git" 73 | )), 74 | } 75 | } 76 | 77 | #[cfg(test)] 78 | mod tests { 79 | use super::*; 80 | 81 | #[test] 82 | fn test_get_host_git_url_ssh_github() { 83 | let repo = "dob9601/dotfiles"; 84 | let host = RepoHostName::GitHub; 85 | let method = ConnectionMethod::SSH; 86 | 87 | let host_url = get_host_git_url(repo, &host, &method).expect("Failed to get host url"); 88 | assert_eq!( 89 | host_url, 90 | String::from("git@github.com:dob9601/dotfiles.git") 91 | ) 92 | } 93 | 94 | #[test] 95 | fn test_get_host_git_url_https_gitlab() { 96 | let repo = "dob9601/dotfiles"; 97 | let host = RepoHostName::GitLab; 98 | let method = ConnectionMethod::HTTPS; 99 | 100 | let host_url = get_host_git_url(repo, &host, &method).expect("Failed to get host url"); 101 | assert_eq!( 102 | host_url, 103 | String::from("https://gitlab.com/dob9601/dotfiles.git") 104 | ) 105 | } 106 | 107 | #[test] 108 | fn test_repo_host_name_from_str_github() { 109 | let hostname = "github"; 110 | assert_eq!( 111 | ::from_str(hostname) 112 | .expect("Could not convert from str"), 113 | RepoHostName::GitHub 114 | ) 115 | } 116 | 117 | #[test] 118 | fn test_repo_host_name_from_str_gitlab() { 119 | let hostname = "gitlab"; 120 | assert_eq!( 121 | ::from_str(hostname) 122 | .expect("Could not convert from str"), 123 | RepoHostName::GitLab 124 | ) 125 | } 126 | 127 | #[test] 128 | fn test_repo_host_from_str_invalid() { 129 | let hostname = "foobar"; 130 | ::from_str(hostname) 131 | .expect_err("Invalid variant produced success"); 132 | } 133 | 134 | #[test] 135 | fn test_connection_method_from_str_ssh() { 136 | let method = "ssh"; 137 | assert_eq!( 138 | ::from_str(method) 139 | .expect("Could not convert from str"), 140 | ConnectionMethod::SSH 141 | ) 142 | } 143 | 144 | #[test] 145 | fn test_connection_method_from_str_https() { 146 | let method = "https"; 147 | assert_eq!( 148 | ::from_str(method) 149 | .expect("Could not convert from str"), 150 | ConnectionMethod::HTTPS 151 | ) 152 | } 153 | 154 | #[test] 155 | fn test_connection_method_from_str_invalid() { 156 | let method = "ftp"; 157 | ::from_str(method) 158 | .expect_err("Invalid variant produced success"); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/jtd-wrapper.sh: -------------------------------------------------------------------------------- 1 | curl -sL https://github.com/dob9601/jointhedots/releases/latest/download/jtd --output /tmp/jtdbin 2 | chmod +x /tmp/jtdbin 3 | /tmp/jtdbin interactive 4 | 5 | rm /tmp/jtdbin 6 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | pub mod log; 3 | 4 | pub mod cli; 5 | pub mod structs; 6 | pub mod utils; 7 | 8 | pub(crate) const MANIFEST_PATH: &str = "~/.local/share/jointhedots/manifest.yaml"; 9 | 10 | pub(crate) mod git { 11 | pub mod operations; 12 | pub mod remote; 13 | } 14 | 15 | pub mod subcommands { 16 | mod install; 17 | mod interactive; 18 | mod sync; 19 | 20 | pub use install::install_subcommand_handler; 21 | pub use interactive::interactive_subcommand_handler; 22 | pub use sync::sync_subcommand_handler; 23 | } 24 | -------------------------------------------------------------------------------- /src/log.rs: -------------------------------------------------------------------------------- 1 | #[allow(unused_imports)] 2 | use console::style; 3 | 4 | macro_rules! success { 5 | ($fmt:expr) => { 6 | println!("{}", style(format!("✔ {}", $fmt)).green()); 7 | }; 8 | ($fmt:expr $(, $($arg:tt)*)?) => { 9 | println!("{}", style(format!(concat!("✔ ", $fmt), $($($arg)*)?)).green()); 10 | }; 11 | } 12 | 13 | macro_rules! info { 14 | ($fmt:expr) => { 15 | println!("{}", style(format!("🛈 {}", $fmt)).blue()); 16 | }; 17 | ($fmt:expr $(, $($arg:tt)*)?) => { 18 | println!("{}", style(format!(concat!("🛈 ", $fmt), $($($arg)*)?)).blue()); 19 | }; 20 | } 21 | 22 | macro_rules! warn { 23 | ($fmt:expr) => { 24 | println!("{}", style(format!("⚠ {}", $fmt)).yellow()); 25 | }; 26 | ($fmt:expr $(, $($arg:tt)*)?) => { 27 | println!("{}", style(format!(concat!("⚠ ", $fmt), $($($arg)*)?)).yellow()); 28 | }; 29 | } 30 | 31 | macro_rules! error { 32 | ($fmt:expr) => { 33 | println!("{}", style(format!("⚠ {}", $fmt)).red()); 34 | }; 35 | ($fmt:expr $(, $($arg:tt)*)?) => { 36 | println!("{}", style(format!(concat!("⚠ ", $fmt), $($($arg)*)?)).red()); 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use console::style; 3 | use jointhedots::{cli::JoinTheDots, subcommands}; 4 | use std::process::exit; 5 | 6 | fn main() { 7 | let result = match JoinTheDots::parse() { 8 | JoinTheDots::Sync(args) => subcommands::sync_subcommand_handler(args), 9 | JoinTheDots::Install(args) => subcommands::install_subcommand_handler(args), 10 | JoinTheDots::Interactive(_) => subcommands::interactive_subcommand_handler(), 11 | }; 12 | if let Err(error) = result { 13 | println!( 14 | "{} {}", 15 | style("Error:").red().dim(), 16 | error.to_string().replace("\n", "\n ") 17 | ); 18 | exit(1); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/structs/config.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | const SINGLE_DOTFILE_COMMIT_FORMAT: &str = "Sync {} dotfile"; 4 | const MULTIPLE_DOTFILES_COMMIT_FORMAT: &str = "Sync dotfiles for {}"; 5 | 6 | #[derive(Clone, Debug, Deserialize)] 7 | #[serde(default)] 8 | pub struct Config { 9 | pub commit_prefix: String, 10 | pub squash_commits: bool, 11 | } 12 | 13 | impl Default for Config { 14 | fn default() -> Self { 15 | Config { 16 | commit_prefix: "🔁 ".to_string(), 17 | squash_commits: true, 18 | } 19 | } 20 | } 21 | 22 | impl Config { 23 | pub fn generate_commit_message(&self, dotfile_names: Vec<&str>) -> String { 24 | let mut commit_message = self.commit_prefix.to_string(); 25 | 26 | if dotfile_names.len() == 1 { 27 | commit_message.push_str(&SINGLE_DOTFILE_COMMIT_FORMAT.replace("{}", &dotfile_names[0])); 28 | } else { 29 | commit_message.push_str( 30 | &MULTIPLE_DOTFILES_COMMIT_FORMAT 31 | .replace("{}", &dotfile_names.join(", ")) 32 | .chars() 33 | .rev() 34 | .collect::() 35 | .replacen(",", "dna ", 1) 36 | .chars() 37 | .rev() 38 | .collect::(), 39 | ); 40 | } 41 | 42 | commit_message 43 | } 44 | } 45 | 46 | #[cfg(test)] 47 | mod tests { 48 | use super::*; 49 | 50 | #[test] 51 | fn test_generate_commit_message_single_dotfile() { 52 | let config = Config::default(); 53 | 54 | let commit_message = config.generate_commit_message(vec!["neovim"]); 55 | 56 | assert_eq!("🔁 Sync neovim dotfile", commit_message.as_str()); 57 | } 58 | 59 | #[test] 60 | fn test_generate_commit_message_multiple_dotfiles() { 61 | let config = Config::default(); 62 | 63 | let commit_message = config.generate_commit_message(vec!["neovim", "kitty"]); 64 | 65 | assert_eq!( 66 | "🔁 Sync dotfiles for neovim and kitty", 67 | commit_message.as_str() 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/structs/dotfile.rs: -------------------------------------------------------------------------------- 1 | use crate::git::operations::{ 2 | add_and_commit, checkout_ref, get_commit, get_head_hash, get_repo_dir, normal_merge, 3 | }; 4 | use crate::utils::run_command_vec; 5 | use crate::MANIFEST_PATH; 6 | use console::style; 7 | use git2::Repository; 8 | use sha1::{Digest, Sha1}; 9 | use std::fs; 10 | use std::path::{Path, PathBuf}; 11 | 12 | use serde::Deserialize; 13 | use std::error::Error; 14 | 15 | use crate::utils::hash_command_vec; 16 | 17 | use super::{Config, DotfileMetadata}; 18 | 19 | #[derive(Deserialize, Debug, Clone, PartialEq)] 20 | pub struct Dotfile { 21 | pub file: String, 22 | pub target: PathBuf, 23 | pub pre_install: Option>, 24 | pub post_install: Option>, 25 | } 26 | 27 | impl Dotfile { 28 | fn hash_pre_install(&self) -> String { 29 | if let Some(pre_install) = &self.pre_install { 30 | hash_command_vec(pre_install) 31 | } else { 32 | "".to_string() 33 | } 34 | } 35 | 36 | fn hash_post_install(&self) -> String { 37 | if let Some(post_install) = &self.post_install { 38 | hash_command_vec(post_install) 39 | } else { 40 | "".to_string() 41 | } 42 | } 43 | 44 | /// Return whether this dotfile has run stages, i.e. pre_install or post_install is not `None` 45 | /// and the hash of the pre/post install stages are different to the one in the metadata 46 | pub fn has_unexecuted_run_stages(&self, maybe_metadata: &Option<&DotfileMetadata>) -> bool { 47 | if let Some(metadata) = maybe_metadata { 48 | // If metadata is available, don't return true if the steps have already 49 | // been executed 50 | (self.pre_install.is_some() && metadata.pre_install_hash != self.hash_pre_install()) 51 | || (self.post_install.is_some() 52 | && metadata.post_install_hash != self.hash_post_install()) 53 | } else { 54 | // Otherwise just depend on the presence of the steps 55 | self.pre_install.is_some() || self.post_install.is_some() 56 | } 57 | } 58 | 59 | fn run_pre_install( 60 | &self, 61 | metadata: &Option, 62 | ) -> Result> { 63 | let mut hash = String::new(); 64 | 65 | if let Some(pre_install) = &self.pre_install { 66 | let mut skip_pre_install = false; 67 | 68 | if let Some(metadata) = metadata { 69 | if self.hash_pre_install() == metadata.pre_install_hash { 70 | info!("{}", style("Skipping pre install steps as they have been run in a previous install").blue()); 71 | skip_pre_install = true; 72 | } 73 | } 74 | 75 | if !skip_pre_install { 76 | success!("Running pre-install steps"); 77 | run_command_vec(pre_install)?; 78 | hash = self.hash_pre_install(); 79 | } 80 | } 81 | Ok(hash) 82 | } 83 | 84 | fn run_post_install( 85 | &self, 86 | metadata: &Option, 87 | ) -> Result> { 88 | let mut hash = String::new(); 89 | 90 | if let Some(post_install) = &self.post_install { 91 | let mut skip_post_install = false; 92 | 93 | if let Some(metadata) = metadata { 94 | if self.hash_post_install() == metadata.post_install_hash { 95 | success!( 96 | "Skipping post install steps as they have been run in a previous install" 97 | ); 98 | skip_post_install = true; 99 | } 100 | } 101 | 102 | if !skip_post_install { 103 | success!("Running post-install steps"); 104 | run_command_vec(post_install)?; 105 | hash = self.hash_post_install(); 106 | } 107 | } 108 | Ok(hash) 109 | } 110 | 111 | fn install_dotfile(&self, repo_dir: &Path) -> Result<(), Box> { 112 | let mut origin_path = repo_dir.to_path_buf(); 113 | origin_path.push(&self.file); 114 | 115 | let unexpanded_target_path = &self.target.to_string_lossy(); 116 | 117 | let target_path_str = shellexpand::tilde(unexpanded_target_path); 118 | 119 | let target_path = Path::new(target_path_str.as_ref()); 120 | 121 | if let Some(parent) = target_path.parent() { 122 | fs::create_dir_all(parent) 123 | .map_err(|_| "Unable to create parent directories".to_string())?; 124 | } 125 | fs::copy(origin_path, target_path).expect("Failed to copy target file"); 126 | 127 | success!( 128 | "Installed config file {} to location {}", 129 | &self.file, 130 | target_path.to_str().expect("Invalid unicode in path") 131 | ); 132 | 133 | Ok(()) 134 | } 135 | 136 | /// Return whether this dotfile has changed since it was last synchronised 137 | /// 138 | /// This is performed by loading the current dotfile on the system, loading the dotfile as of 139 | /// the specified commit and comparing them byte by byte. 140 | /// 141 | /// # Arguments 142 | /// 143 | /// * `repo` - The repository object 144 | /// * `metadata` - The metadata associated to this dotfile 145 | /// 146 | /// # Returns 147 | /// 148 | /// A boolean signifying whether the dotfile on the local system differs to how it looked when 149 | /// last synced 150 | pub fn has_changed( 151 | &self, 152 | repo: &Repository, 153 | metadata: &DotfileMetadata, 154 | ) -> Result> { 155 | let head_ref = repo.head()?; 156 | let head_ref_name = head_ref.name().unwrap(); 157 | 158 | let unexpanded_target_path = &self.target.to_string_lossy(); 159 | let local_dotfile_path = shellexpand::tilde(unexpanded_target_path).to_string(); 160 | let dotfile_contents = fs::read_to_string(local_dotfile_path)?; 161 | let local_dotfile_hash = Sha1::digest(dotfile_contents.as_bytes()); 162 | 163 | checkout_ref(&repo, &metadata.commit_hash)?; 164 | 165 | let repo_dir = get_repo_dir(&repo); 166 | let repo_dotfile_path = &repo_dir.join(&self.file); 167 | let dotfile_contents = fs::read_to_string(repo_dotfile_path)?; 168 | let repo_dotfile_hash = Sha1::digest(dotfile_contents.as_bytes()); 169 | 170 | if local_dotfile_hash != repo_dotfile_hash { 171 | checkout_ref(&repo, &head_ref_name)?; 172 | return Ok(true); 173 | } else { 174 | checkout_ref(&repo, &head_ref_name)?; 175 | Ok(false) 176 | } 177 | } 178 | 179 | /// Install the dotfile to the specified location. 180 | /// 181 | /// Refuse to do so if a local dotfile exists that has changes since the last sync, unless 182 | /// `force` is true. 183 | /// 184 | /// # Arguments 185 | /// 186 | /// * `repo` - The repository object 187 | /// * `maybe_metadata` - Optionally, this dotfiles metadata. If not passed, a naive install will 188 | /// be performed, meaning: 189 | /// * No idempotency checks will be performed for pre/post steps 190 | /// * No check can be made as to whether the dotfile has changed since last sync so it will 191 | /// be overwritten no matter what 192 | /// * `skip_install_steps` - Whether to skip pre/post install steps 193 | /// * `force` - Whether to force the install, even if the local dotfile has changed since the 194 | /// last sync 195 | pub fn install( 196 | &self, 197 | repo: &Repository, 198 | maybe_metadata: Option, 199 | skip_install_steps: bool, 200 | force: bool, 201 | ) -> Result> { 202 | let commit_hash = get_head_hash(&repo)?; 203 | if !force { 204 | if let Some(ref metadata) = maybe_metadata { 205 | if self.has_changed(&repo, &metadata)? { 206 | return Err("Refusing to install dotfile. Changes have been made since last sync. \ 207 | either run \"jtd sync\" for this dotfile or call install again with the \ 208 | \"--force\" flag".into()); 209 | } 210 | } 211 | } 212 | 213 | let pre_install_hash = if !skip_install_steps { 214 | self.run_pre_install(&maybe_metadata)? 215 | } else { 216 | String::new() 217 | }; 218 | 219 | let repo_dir = get_repo_dir(&repo); 220 | self.install_dotfile(repo_dir)?; 221 | 222 | let post_install_hash = if !skip_install_steps { 223 | self.run_post_install(&maybe_metadata)? 224 | } else { 225 | String::new() 226 | }; 227 | 228 | let new_metadata = DotfileMetadata::new(&commit_hash, pre_install_hash, post_install_hash); 229 | 230 | Ok(new_metadata) 231 | } 232 | 233 | pub fn sync( 234 | &self, 235 | repo: &Repository, 236 | dotfile_name: &str, 237 | config: &Config, 238 | metadata: Option<&DotfileMetadata>, 239 | ) -> Result> { 240 | let mut target_path_buf = get_repo_dir(&repo).to_owned(); 241 | target_path_buf.push(&self.file); 242 | let target_path = target_path_buf.as_path(); 243 | 244 | let origin_path_unexpanded = &self.target.to_string_lossy(); 245 | let origin_path_str = shellexpand::tilde(origin_path_unexpanded); 246 | let origin_path = Path::new(origin_path_str.as_ref()); 247 | 248 | if let Some(metadata) = metadata { 249 | let mut new_metadata = metadata.clone(); 250 | 251 | if self.has_changed(&repo, &metadata)? { 252 | let parent_commit = get_commit(repo, &metadata.commit_hash).map_err( 253 | |_| format!("Could not find last sync'd commit for {}, manifest is corrupt. Try fresh-installing \ 254 | this dotfile or manually correcting the commit hash in {}", dotfile_name, MANIFEST_PATH))?; 255 | 256 | let head_ref = repo.head()?; 257 | let head_ref_name = head_ref.name().unwrap(); 258 | let merge_target_commit = repo.reference_to_annotated_commit(&head_ref)?; 259 | 260 | checkout_ref(&repo, &parent_commit.id().to_string())?; 261 | fs::copy(origin_path, target_path)?; 262 | 263 | let new_branch_name = format!("merge-{}-dotfile", dotfile_name); 264 | let _new_branch = repo.branch(&new_branch_name, &parent_commit, true)?; 265 | checkout_ref(&repo, &new_branch_name)?; 266 | 267 | let _new_commit = add_and_commit( 268 | repo, 269 | Some(vec![Path::new(&self.file)]), 270 | &config.generate_commit_message(vec![dotfile_name]), 271 | Some(vec![&parent_commit]), 272 | Some("HEAD"), 273 | )?; 274 | 275 | let new_commit = repo.reference_to_annotated_commit(&repo.head()?)?; 276 | checkout_ref(&repo, &head_ref_name)?; 277 | 278 | let merge_commit = normal_merge(repo, &merge_target_commit, &new_commit) 279 | .map_err(|err| format!("Could not merge commits: {}", err))?; 280 | 281 | new_metadata.commit_hash = merge_commit.id().to_string(); 282 | } else { 283 | info!("Skipping syncing {} as no changes made", dotfile_name); 284 | } 285 | Ok(new_metadata) 286 | } else { 287 | fs::copy(origin_path, target_path)?; 288 | let new_commit = add_and_commit( 289 | repo, 290 | Some(vec![Path::new(&self.file)]), 291 | &config.generate_commit_message(vec![dotfile_name]), 292 | None, 293 | Some("HEAD"), 294 | )?; 295 | Ok(DotfileMetadata::new( 296 | &new_commit.id().to_string(), 297 | self.hash_pre_install(), 298 | self.hash_post_install(), 299 | )) 300 | } 301 | } 302 | } 303 | 304 | #[cfg(test)] 305 | mod tests { 306 | use std::{fs::File, io::Write, path::PathBuf}; 307 | use tempfile::tempdir; 308 | 309 | use crate::git::operations::get_head; 310 | 311 | use super::*; 312 | 313 | #[test] 314 | fn test_hash_empty_pre_install() { 315 | let dotfile = Dotfile { 316 | file: "".to_string(), 317 | target: PathBuf::new(), 318 | pre_install: None, 319 | post_install: None, 320 | }; 321 | 322 | assert_eq!("", dotfile.hash_pre_install()); 323 | } 324 | 325 | #[test] 326 | fn test_hash_pre_install() { 327 | let dotfile = Dotfile { 328 | file: "".to_string(), 329 | target: PathBuf::new(), 330 | pre_install: Some(vec![ 331 | "echo".to_string(), 332 | "ls".to_string(), 333 | "cat".to_string(), 334 | ]), 335 | post_install: None, 336 | }; 337 | 338 | assert_eq!( 339 | "1ef98a8d0946d6512ca5da8242eb7a52a506de54", 340 | dotfile.hash_pre_install() 341 | ); 342 | } 343 | 344 | #[test] 345 | fn test_hash_empty_post_install() { 346 | let dotfile = Dotfile { 347 | file: "".to_string(), 348 | target: PathBuf::new(), 349 | pre_install: None, 350 | post_install: None, 351 | }; 352 | 353 | assert_eq!("", dotfile.hash_post_install()); 354 | } 355 | 356 | #[test] 357 | fn test_hash_post_install() { 358 | let dotfile = Dotfile { 359 | file: "".to_string(), 360 | target: PathBuf::new(), 361 | pre_install: None, 362 | post_install: Some(vec![ 363 | "echo".to_string(), 364 | "ls".to_string(), 365 | "cat".to_string(), 366 | ]), 367 | }; 368 | 369 | assert_eq!( 370 | "1ef98a8d0946d6512ca5da8242eb7a52a506de54", 371 | dotfile.hash_post_install() 372 | ); 373 | } 374 | 375 | #[test] 376 | fn test_has_unexecuted_run_stages_no_metadata() { 377 | let dotfile = Dotfile { 378 | file: "".to_string(), 379 | target: PathBuf::new(), 380 | pre_install: None, 381 | post_install: None, 382 | }; 383 | 384 | assert_eq!(false, dotfile.has_unexecuted_run_stages(&None)); 385 | } 386 | 387 | #[test] 388 | fn test_has_unexecuted_run_stages_with_metadata_no_install_steps() { 389 | let dotfile = Dotfile { 390 | file: "".to_string(), 391 | target: PathBuf::new(), 392 | pre_install: None, 393 | post_install: None, 394 | }; 395 | 396 | let metadata = DotfileMetadata { 397 | commit_hash: "".to_string(), 398 | pre_install_hash: "".to_string(), 399 | post_install_hash: "".to_string(), 400 | }; 401 | 402 | assert_eq!(false, dotfile.has_unexecuted_run_stages(&Some(&metadata))); 403 | } 404 | 405 | #[test] 406 | fn test_has_unexecuted_run_stages_with_metadata_with_install_steps_true() { 407 | let dotfile = Dotfile { 408 | file: "".to_string(), 409 | target: PathBuf::new(), 410 | pre_install: Some(vec![ 411 | "echo".to_string(), 412 | "ls".to_string(), 413 | "cat".to_string(), 414 | ]), 415 | post_install: Some(vec![ 416 | "echo".to_string(), 417 | "ls".to_string(), 418 | "cat".to_string(), 419 | ]), 420 | }; 421 | 422 | let metadata = DotfileMetadata { 423 | commit_hash: "".to_string(), 424 | pre_install_hash: "".to_string(), 425 | post_install_hash: "".to_string(), 426 | }; 427 | 428 | assert_eq!(true, dotfile.has_unexecuted_run_stages(&Some(&metadata))); 429 | } 430 | 431 | #[test] 432 | fn test_has_unexecuted_run_stages_with_metadata_with_install_steps_false() { 433 | let dotfile = Dotfile { 434 | file: "".to_string(), 435 | target: PathBuf::new(), 436 | pre_install: Some(vec![ 437 | "echo".to_string(), 438 | "ls".to_string(), 439 | "cat".to_string(), 440 | ]), 441 | post_install: Some(vec![ 442 | "echo".to_string(), 443 | "ls".to_string(), 444 | "cat".to_string(), 445 | ]), 446 | }; 447 | 448 | let metadata = DotfileMetadata { 449 | commit_hash: "".to_string(), 450 | pre_install_hash: "1ef98a8d0946d6512ca5da8242eb7a52a506de54".to_string(), 451 | post_install_hash: "1ef98a8d0946d6512ca5da8242eb7a52a506de54".to_string(), 452 | }; 453 | 454 | assert_eq!(false, dotfile.has_unexecuted_run_stages(&Some(&metadata))); 455 | } 456 | 457 | #[test] 458 | fn test_has_changed_false() { 459 | let repo_dir = tempdir().expect("Could not create temporary repo dir"); 460 | let dotfile_dir = tempdir().expect("Could not create temporary dotfile dir"); 461 | let repo = Repository::init(&repo_dir).expect("Could not initialise repository"); 462 | 463 | // Create file in repo 464 | let repo_filepath = repo_dir.path().to_owned().join("dotfile"); 465 | File::create(repo_filepath.to_owned()).expect("Could not create file in repo"); 466 | 467 | // Create dotfile "on the local system" 468 | let local_filepath = dotfile_dir.path().to_owned().join("dotfile"); 469 | File::create(local_filepath.to_owned()).expect("Could not create file in tempdir"); 470 | 471 | let commit = add_and_commit( 472 | &repo, 473 | Some(vec![&repo_filepath]), 474 | "commit message", 475 | Some(vec![]), 476 | Some("HEAD"), 477 | ) 478 | .expect("Failed to commit to repository"); 479 | 480 | let dotfile = Dotfile { 481 | file: "dotfile".to_string(), 482 | target: dotfile_dir.path().join("dotfile"), 483 | pre_install: None, 484 | post_install: None, 485 | }; 486 | 487 | let metadata = DotfileMetadata { 488 | commit_hash: commit.id().to_string(), 489 | pre_install_hash: "".to_string(), 490 | post_install_hash: "".to_string(), 491 | }; 492 | 493 | assert!(!dotfile.has_changed(&repo, &metadata).unwrap()); 494 | } 495 | 496 | #[test] 497 | fn test_has_changed_true() { 498 | let repo_dir = tempdir().expect("Could not create temporary repo dir"); 499 | let dotfile_dir = tempdir().expect("Could not create temporary dotfile dir"); 500 | let repo = Repository::init(&repo_dir).expect("Could not initialise repository"); 501 | 502 | // Create file in repo 503 | let filepath = repo_dir.path().to_owned().join("dotfile"); 504 | File::create(filepath.to_owned()).expect("Could not create file in repo"); 505 | 506 | // Create dotfile "on the local system" with different contents 507 | let filepath = dotfile_dir.path().to_owned().join("dotfile"); 508 | let mut dotfile_file = 509 | File::create(filepath.to_owned()).expect("Could not create file in tempdir"); 510 | dotfile_file 511 | .write_all("This file has changes".as_bytes()) 512 | .unwrap(); 513 | 514 | let commit = add_and_commit( 515 | &repo, 516 | Some(vec![&filepath]), 517 | "commit message", 518 | Some(vec![]), 519 | Some("HEAD"), 520 | ) 521 | .expect("Failed to commit to repository"); 522 | 523 | let dotfile = Dotfile { 524 | file: "dotfile".to_string(), 525 | target: dotfile_dir.path().join("dotfile"), 526 | pre_install: None, 527 | post_install: None, 528 | }; 529 | 530 | let metadata = DotfileMetadata { 531 | commit_hash: commit.id().to_string(), 532 | pre_install_hash: "".to_string(), 533 | post_install_hash: "".to_string(), 534 | }; 535 | 536 | assert!(dotfile.has_changed(&repo, &metadata).unwrap()); 537 | } 538 | 539 | #[test] 540 | fn test_install_no_metadata() { 541 | let repo_dir = tempdir().expect("Could not create temporary repo dir"); 542 | let repo = Repository::init(&repo_dir).expect("Could not initialise repository"); 543 | 544 | let dotfile_dir = tempdir().expect("Could not create temporary dotfile dir"); 545 | let target_path = dotfile_dir.path().join("dotfile"); 546 | 547 | // Create file in repo 548 | let filepath = repo_dir.path().to_owned().join("dotfile"); 549 | File::create(filepath.to_owned()).expect("Could not create file in repo"); 550 | 551 | let _commit = add_and_commit( 552 | &repo, 553 | Some(vec![&filepath]), 554 | "commit message", 555 | Some(vec![]), 556 | Some("HEAD"), 557 | ) 558 | .expect("Failed to commit to repository"); 559 | 560 | let dotfile = Dotfile { 561 | file: "dotfile".to_string(), 562 | target: target_path.clone(), 563 | pre_install: None, 564 | post_install: None, 565 | }; 566 | 567 | dotfile 568 | .install(&repo, None, true, true) 569 | .expect("Failed to install dotfile"); 570 | 571 | assert!(Path::exists(&target_path)); 572 | } 573 | 574 | #[test] 575 | fn test_install_commands() { 576 | let repo_dir = tempdir().expect("Could not create temporary repo dir"); 577 | let repo = Repository::init(&repo_dir).expect("Could not initialise repository"); 578 | 579 | let dotfile_dir = tempdir().expect("Could not create temporary dotfile dir"); 580 | let target_path = dotfile_dir.path().join("dotfile"); 581 | 582 | let target_touch_pre_install = dotfile_dir.path().join("pre_install"); 583 | let target_touch_post_install = dotfile_dir.path().join("post_install"); 584 | 585 | // Create file in repo 586 | let filepath = repo_dir.path().to_owned().join("dotfile"); 587 | File::create(filepath.to_owned()).expect("Could not create file in repo"); 588 | 589 | let _commit = add_and_commit( 590 | &repo, 591 | Some(vec![&filepath]), 592 | "commit message", 593 | Some(vec![]), 594 | Some("HEAD"), 595 | ) 596 | .expect("Failed to commit to repository"); 597 | 598 | let dotfile = Dotfile { 599 | file: "dotfile".to_string(), 600 | target: target_path.clone(), 601 | pre_install: Some(vec![format!( 602 | "touch {}", 603 | target_touch_pre_install.to_string_lossy() 604 | )]), 605 | post_install: Some(vec![format!( 606 | "touch {}", 607 | target_touch_post_install.to_string_lossy() 608 | )]), 609 | }; 610 | 611 | dotfile 612 | .install(&repo, None, false, true) 613 | .expect("Failed to install dotfile"); 614 | 615 | assert!(Path::exists(&target_path)); 616 | assert!(Path::exists(&target_touch_pre_install)); 617 | assert!(Path::exists(&target_touch_post_install)); 618 | } 619 | 620 | #[test] 621 | fn test_abort_install_if_local_changes() { 622 | let repo_dir = tempdir().expect("Could not create temporary repo dir"); 623 | let repo = Repository::init(&repo_dir).expect("Could not initialise repository"); 624 | 625 | let dotfile_dir = tempdir().expect("Could not create temporary dotfile dir"); 626 | let target_path = dotfile_dir.path().join("dotfile"); 627 | 628 | // Create file in repo 629 | let filepath = repo_dir.path().to_owned().join("dotfile"); 630 | File::create(filepath.to_owned()).expect("Could not create file in repo"); 631 | 632 | // Create dotfile "on the local system" 633 | let local_filepath = dotfile_dir.path().to_owned().join("dotfile"); 634 | let mut file = 635 | File::create(local_filepath.to_owned()).expect("Could not create file in tempdir"); 636 | file.write_all(b"These are local changes on the system") 637 | .expect("Failed to write to dotfile"); 638 | 639 | let _commit = add_and_commit( 640 | &repo, 641 | Some(vec![&filepath]), 642 | "commit message", 643 | Some(vec![]), 644 | Some("HEAD"), 645 | ) 646 | .expect("Failed to commit to repository"); 647 | 648 | let dotfile = Dotfile { 649 | file: "dotfile".to_string(), 650 | target: target_path.clone(), 651 | pre_install: None, 652 | post_install: None, 653 | }; 654 | 655 | let metadata = DotfileMetadata { 656 | commit_hash: _commit.id().to_string(), 657 | pre_install_hash: "".to_string(), 658 | post_install_hash: "".to_string(), 659 | }; 660 | 661 | assert!(dotfile.install(&repo, Some(metadata), true, false).is_err()); 662 | } 663 | 664 | #[test] 665 | fn test_sync_naive() { 666 | let repo_dir = tempdir().expect("Could not create temporary repo dir"); 667 | let repo = Repository::init(&repo_dir).expect("Could not initialise repository"); 668 | 669 | let dotfile_dir = tempdir().expect("Could not create temporary dotfile dir"); 670 | let target_path = dotfile_dir.path().join("dotfile"); 671 | 672 | // Create file in repo 673 | let filepath = repo_dir.path().to_owned().join("dotfile"); 674 | File::create(filepath.to_owned()).expect("Could not create file in repo"); 675 | let _commit = add_and_commit( 676 | &repo, 677 | Some(vec![&filepath]), 678 | "commit message", 679 | Some(vec![]), 680 | Some("HEAD"), 681 | ) 682 | .expect("Failed to commit to repository"); 683 | 684 | // Create dotfile "on the local system" 685 | let local_filepath = dotfile_dir.path().to_owned().join("dotfile"); 686 | let mut file = 687 | File::create(local_filepath.to_owned()).expect("Could not create file in tempdir"); 688 | file.write_all(b"These are local changes on the system") 689 | .expect("Failed to write to dotfile"); 690 | 691 | let dotfile = Dotfile { 692 | file: "dotfile".to_string(), 693 | target: target_path.clone(), 694 | pre_install: None, 695 | post_install: None, 696 | }; 697 | 698 | let config = Config::default(); 699 | 700 | dotfile 701 | .sync(&repo, "dotfile", &config, None) 702 | .expect("Failed to sync dotfile"); 703 | assert_eq!( 704 | fs::read_to_string(filepath).unwrap(), 705 | "These are local changes on the system" 706 | ); 707 | } 708 | 709 | #[test] 710 | fn test_sync_with_metadata() { 711 | let repo_dir = tempdir().expect("Could not create temporary repo dir"); 712 | let repo = Repository::init(&repo_dir).expect("Could not initialise repository"); 713 | 714 | let dotfile_dir = tempdir().expect("Could not create temporary dotfile dir"); 715 | let target_path = dotfile_dir.path().join("dotfile"); 716 | 717 | // Create file in repo 718 | let filepath = repo_dir.path().to_owned().join("dotfile"); 719 | File::create(filepath.to_owned()).expect("Could not create file in repo"); 720 | let _commit = add_and_commit( 721 | &repo, 722 | Some(vec![&filepath]), 723 | "commit message", 724 | Some(vec![]), 725 | Some("HEAD"), 726 | ) 727 | .expect("Failed to commit to repository"); 728 | 729 | // Create dotfile "on the local system" 730 | let local_filepath = dotfile_dir.path().to_owned().join("dotfile"); 731 | let mut file = 732 | File::create(local_filepath.to_owned()).expect("Could not create file in tempdir"); 733 | file.write_all(b"These are local changes on the system") 734 | .expect("Failed to write to dotfile"); 735 | 736 | let dotfile = Dotfile { 737 | file: "dotfile".to_string(), 738 | target: target_path.clone(), 739 | pre_install: None, 740 | post_install: None, 741 | }; 742 | 743 | let metadata = DotfileMetadata { 744 | commit_hash: _commit.id().to_string(), 745 | pre_install_hash: "".to_string(), 746 | post_install_hash: "".to_string(), 747 | }; 748 | 749 | let config = Config::default(); 750 | 751 | dotfile 752 | .sync(&repo, "dotfile", &config, Some(&metadata)) 753 | .expect("Failed to sync dotfile"); 754 | assert_eq!( 755 | fs::read_to_string(filepath).unwrap(), 756 | "These are local changes on the system" 757 | ); 758 | } 759 | 760 | #[test] 761 | fn test_sync_with_metadata_skip_if_no_changes() { 762 | let repo_dir = tempdir().expect("Could not create temporary repo dir"); 763 | let repo = Repository::init(&repo_dir).expect("Could not initialise repository"); 764 | 765 | let dotfile_dir = tempdir().expect("Could not create temporary dotfile dir"); 766 | let target_path = dotfile_dir.path().join("dotfile"); 767 | 768 | // Create file in repo 769 | let filepath = repo_dir.path().to_owned().join("dotfile"); 770 | File::create(filepath.to_owned()).expect("Could not create file in repo"); 771 | let _commit = add_and_commit( 772 | &repo, 773 | Some(vec![&filepath]), 774 | "commit message", 775 | Some(vec![]), 776 | Some("HEAD"), 777 | ) 778 | .expect("Failed to commit to repository"); 779 | 780 | // Create dotfile "on the local system" 781 | let local_filepath = dotfile_dir.path().to_owned().join("dotfile"); 782 | File::create(local_filepath.to_owned()).expect("Could not create file in tempdir"); 783 | 784 | let dotfile = Dotfile { 785 | file: "dotfile".to_string(), 786 | target: target_path.clone(), 787 | pre_install: None, 788 | post_install: None, 789 | }; 790 | 791 | let metadata = DotfileMetadata { 792 | commit_hash: _commit.id().to_string(), 793 | pre_install_hash: "".to_string(), 794 | post_install_hash: "".to_string(), 795 | }; 796 | 797 | let config = Config::default(); 798 | 799 | dotfile 800 | .sync(&repo, "dotfile", &config, Some(&metadata)) 801 | .expect("Failed to sync dotfile"); 802 | 803 | // Check that the head commit of the repo is still the initial commit - i.e. no changes 804 | // have been committed 805 | assert_eq!(_commit.id(), get_head(&repo).unwrap().id()); 806 | } 807 | } 808 | -------------------------------------------------------------------------------- /src/structs/manifest.rs: -------------------------------------------------------------------------------- 1 | use console::style; 2 | use dialoguer::{Confirm, MultiSelect}; 3 | use git2::{Oid, Repository}; 4 | use serde::Deserialize; 5 | use std::{ 6 | collections::HashMap, 7 | error::Error, 8 | fs::File, 9 | path::{Path, PathBuf}, 10 | }; 11 | 12 | use crate::{ 13 | git::operations::{add_and_commit, get_repo_dir, push}, 14 | utils::get_theme, 15 | }; 16 | 17 | use super::{AggregatedDotfileMetadata, Config, Dotfile}; 18 | 19 | /// Represents an aggregation of [Dotfile]s, as found in the `jtd.yaml` file. This is done via a 20 | /// mapping of `dotfile_name` to [Dotfile] 21 | #[derive(Deserialize, Debug, Clone)] 22 | pub struct Manifest { 23 | #[serde(default, rename = ".config")] 24 | config: Config, 25 | 26 | #[serde(flatten)] 27 | data: HashMap, 28 | } 29 | 30 | impl Manifest { 31 | pub fn get(path: &Path) -> Result> { 32 | let config: Manifest = serde_yaml::from_reader(File::open(path).map_err(|_| { 33 | format!( 34 | "Could not find manifest {} in repository.", 35 | path.file_name() 36 | .map(|v| v.to_string_lossy()) 37 | .unwrap_or_else(|| "N/A".into()) 38 | ) 39 | })?) 40 | .map_err(|err| format!("Could not parse manifest: {}", err))?; 41 | Ok(config) 42 | } 43 | 44 | pub fn install( 45 | &self, 46 | repo: &Repository, 47 | install_all: bool, 48 | target_dotfiles: Vec, 49 | force_install: bool, 50 | trust: bool, 51 | ) -> Result<(), Box> { 52 | let theme = get_theme(); 53 | 54 | let mut skip_install_commands = false; 55 | 56 | let dotfiles = self.get_target_dotfiles(target_dotfiles, install_all); 57 | let mut aggregated_metadata = AggregatedDotfileMetadata::get_or_create()?; 58 | 59 | if !trust 60 | && self.has_unexecuted_run_stages( 61 | Some(dotfiles.iter().map(|(v, _)| v.as_str()).collect()), 62 | &aggregated_metadata, 63 | ) 64 | { 65 | warn!( 66 | "Some of the dotfiles being installed contain pre_install and/or post_install \ 67 | steps. If you do not trust this manifest, you can skip running them." 68 | ); 69 | skip_install_commands = Confirm::with_theme(&theme) 70 | .with_prompt("Skip running pre/post install?") 71 | .default(false) 72 | .wait_for_newline(true) 73 | .interact() 74 | .unwrap(); 75 | } 76 | 77 | let repo_dir = get_repo_dir(&repo); 78 | 79 | for (dotfile_name, dotfile) in dotfiles { 80 | let mut origin_path_buf = PathBuf::from(&repo_dir); 81 | origin_path_buf.push(&dotfile.file); 82 | 83 | if dotfile.target.exists() && !force_install { 84 | let force = Confirm::with_theme(&theme) 85 | .with_prompt(format!( 86 | "Dotfile \"{}\" already exists on disk. Overwrite?", 87 | dotfile_name 88 | )) 89 | .default(false) 90 | .interact() 91 | .unwrap(); 92 | if !force { 93 | continue; 94 | } 95 | } 96 | 97 | println!("Commencing install for {}", dotfile_name); 98 | 99 | let maybe_metadata = aggregated_metadata 100 | .data 101 | .get(dotfile_name) 102 | .map(|d| (*d).clone()); 103 | 104 | let metadata = 105 | dotfile.install(&repo, maybe_metadata, skip_install_commands, force_install)?; 106 | 107 | aggregated_metadata 108 | .data 109 | .insert(dotfile_name.to_string(), metadata); 110 | } 111 | 112 | aggregated_metadata.save()?; 113 | Ok(()) 114 | } 115 | 116 | fn get_target_dotfiles( 117 | &self, 118 | target_dotfiles: Vec, 119 | all: bool, 120 | ) -> Vec<(&String, &Dotfile)> { 121 | let theme = get_theme(); 122 | 123 | if all { 124 | self.data.iter().collect() 125 | } else if !target_dotfiles.is_empty() { 126 | self.data 127 | .iter() 128 | .filter(|(dotfile_name, _)| target_dotfiles.contains(dotfile_name)) 129 | .collect() 130 | } else { 131 | let dotfile_names = &self 132 | .clone() 133 | .into_iter() 134 | .map(|pair| pair.0) 135 | .collect::>(); 136 | let selected = MultiSelect::with_theme(&theme) 137 | .with_prompt("Select the dotfiles you wish to install. Use \"SPACE\" to select and \"ENTER\" to proceed.") 138 | .items(dotfile_names) 139 | .interact() 140 | .unwrap(); 141 | 142 | self.data 143 | .iter() 144 | .enumerate() 145 | .filter(|(index, (_, _))| selected.contains(index)) 146 | .map(|(_, (name, dotfile))| (name, dotfile)) 147 | .collect() 148 | } 149 | } 150 | 151 | /// Return whether this Manifest contains dotfiles containing unexecuted, potentially dangerous 152 | /// run stages. Optionally can take a vector of [Dotfile]s for testing a subset of the manifest. 153 | pub fn has_unexecuted_run_stages( 154 | &self, 155 | dotfile_names: Option>, 156 | metadata: &AggregatedDotfileMetadata, 157 | ) -> bool { 158 | let dotfile_names = 159 | dotfile_names.unwrap_or_else(|| self.data.keys().map(|k| k.as_str()).collect()); 160 | 161 | self.data 162 | .iter() 163 | .filter(|(dotfile_name, _)| dotfile_names.contains(&dotfile_name.as_str())) 164 | .any(|(dotfile_name, dotfile)| { 165 | dotfile.has_unexecuted_run_stages(&metadata.data.get(dotfile_name)) 166 | }) 167 | } 168 | 169 | pub fn sync( 170 | &self, 171 | repo: &Repository, 172 | sync_all: bool, 173 | target_dotfiles: Vec, 174 | commit_msg: Option<&str>, 175 | aggregated_metadata: Option, 176 | use_naive_sync: bool, 177 | ) -> Result<(), Box> { 178 | let theme = get_theme(); 179 | 180 | let dotfiles = self.get_target_dotfiles(target_dotfiles, sync_all); 181 | let mut commit_hashes = vec![]; 182 | 183 | if aggregated_metadata.is_none() && !use_naive_sync { 184 | println!( 185 | "{}", 186 | style( 187 | "Could not find any metadata on the currently installed dotfiles. Proceed with naive sync and overwrite remote files?" 188 | ) 189 | .yellow() 190 | ); 191 | if !Confirm::with_theme(&theme) 192 | .with_prompt("Use naive sync?") 193 | .default(false) 194 | .wait_for_newline(true) 195 | .interact() 196 | .unwrap() 197 | { 198 | return Err("Aborting due to lack of dotfile metadata".into()); 199 | } 200 | } 201 | 202 | let mut aggregated_metadata = aggregated_metadata.unwrap_or_default(); 203 | 204 | for (dotfile_name, dotfile) in dotfiles.iter() { 205 | println!("Syncing {}", dotfile_name); 206 | let new_metadata = dotfile.sync( 207 | repo, 208 | dotfile_name, 209 | &self.config, 210 | aggregated_metadata.data.get(dotfile_name.as_str()), 211 | )?; 212 | 213 | commit_hashes.push(new_metadata.commit_hash.to_owned()); 214 | aggregated_metadata 215 | .data 216 | .insert((*dotfile_name).to_string(), new_metadata); 217 | } 218 | 219 | if self.config.squash_commits { 220 | // Commits[0] isn't necessarily the oldest commit, iterate through and get minimum by 221 | // time 222 | let first_commit = commit_hashes 223 | .iter() 224 | .filter_map(|hash| { 225 | let maybe_commit = repo.find_commit(Oid::from_str(hash).ok()?); 226 | maybe_commit.ok() 227 | }) 228 | .min_by_key(|commit| commit.time()); 229 | if let Some(first_commit) = first_commit { 230 | let target_commit = first_commit.parent(0)?; 231 | repo.reset(target_commit.as_object(), git2::ResetType::Soft, None)?; 232 | 233 | let commit_msg = if let Some(message) = commit_msg { 234 | message.to_string() 235 | } else { 236 | self.config.generate_commit_message( 237 | dotfiles 238 | .iter() 239 | .map(|(name, _)| name.as_str()) 240 | .collect::>(), 241 | ) 242 | }; 243 | // FIXME: Don't commit if commit_hashes is empty 244 | let commit_hash = add_and_commit(repo, None, &commit_msg, None, Some("HEAD"))? 245 | .id() 246 | .to_string(); 247 | for (dotfile_name, metadata) in aggregated_metadata.data.iter_mut() { 248 | if dotfiles 249 | .iter() 250 | .map(|(name, _dotfile)| name) 251 | .any(|s| s == &dotfile_name) 252 | || sync_all 253 | { 254 | metadata.commit_hash = commit_hash.to_owned(); 255 | } 256 | } 257 | } 258 | } else { 259 | info!("Not squashing commits"); 260 | } 261 | 262 | push(repo)?; 263 | 264 | success!("Successfully synced changes!"); 265 | 266 | aggregated_metadata.save()?; 267 | Ok(()) 268 | } 269 | } 270 | 271 | impl IntoIterator for Manifest { 272 | type Item = (String, Dotfile); 273 | 274 | type IntoIter = std::collections::hash_map::IntoIter; 275 | 276 | fn into_iter(self) -> Self::IntoIter { 277 | self.data.into_iter() 278 | } 279 | } 280 | 281 | #[cfg(test)] 282 | mod tests { 283 | use std::{ 284 | fs::{read_to_string, File}, 285 | io::Write, 286 | path::{Path, PathBuf}, 287 | }; 288 | 289 | use super::*; 290 | use tempfile::tempdir; 291 | 292 | const SAMPLE_MANIFEST: &str = r" 293 | kitty: 294 | file: dotfile 295 | target: ~/some/path/here 296 | "; 297 | 298 | #[test] 299 | fn test_manifest_get() { 300 | let tempdir = tempdir().unwrap(); 301 | 302 | let path = tempdir.path().join(Path::new("manifest.yaml")); 303 | let mut manifest_file = File::create(path.to_owned()).unwrap(); 304 | manifest_file.write(SAMPLE_MANIFEST.as_bytes()).unwrap(); 305 | 306 | let manifest = Manifest::get(&path).unwrap(); 307 | 308 | let kitty_dotfile = Dotfile { 309 | file: "dotfile".to_string(), 310 | target: PathBuf::from("~/some/path/here"), 311 | pre_install: None, 312 | post_install: None, 313 | }; 314 | 315 | assert_eq!(manifest.data["kitty"], kitty_dotfile); 316 | } 317 | 318 | #[test] 319 | fn test_manifest_install() { 320 | let repo_dir = tempdir().expect("Could not create temporary repo dir"); 321 | let repo = Repository::init(&repo_dir).expect("Could not initialise repository"); 322 | 323 | let dotfile_dir = tempdir().expect("Could not create temporary dotfile dir"); 324 | let target_path = dotfile_dir.path().join("dotfile"); 325 | 326 | // Create file in repo 327 | let filepath = repo_dir.path().to_owned().join("dotfile"); 328 | File::create(filepath.to_owned()).expect("Could not create file in repo"); 329 | let _commit = add_and_commit( 330 | &repo, 331 | Some(vec![&filepath]), 332 | "commit message", 333 | Some(vec![]), 334 | Some("HEAD"), 335 | ) 336 | .expect("Failed to commit to repository"); 337 | 338 | let manifest: Manifest = serde_yaml::from_str( 339 | &SAMPLE_MANIFEST.replace("~/some/path/here", &target_path.to_string_lossy()), 340 | ) 341 | .unwrap(); 342 | 343 | manifest 344 | .install(&repo, true, vec![], true, false) 345 | .expect("Failed to install manifest"); 346 | assert!(Path::exists(&target_path)); 347 | } 348 | 349 | #[test] 350 | fn test_manifest_sync() { 351 | let repo_dir = tempdir().expect("Could not create temporary repo dir"); 352 | let repo = Repository::init(&repo_dir).expect("Could not initialise repository"); 353 | 354 | let dotfile_dir = tempdir().expect("Could not create temporary dotfile dir"); 355 | let target_path = dotfile_dir.path().join("dotfile"); 356 | 357 | // Create file in repo 358 | let repo_dotfile_path = repo_dir.path().join("dotfile"); 359 | File::create(repo_dotfile_path.to_owned()).expect("Could not create file in repo"); 360 | let _commit = add_and_commit( 361 | &repo, 362 | Some(vec![&repo_dotfile_path]), 363 | "commit message", 364 | Some(vec![]), 365 | Some("HEAD"), 366 | ) 367 | .expect("Failed to commit to repository"); 368 | 369 | // Create dotfile "on the local system" 370 | let mut file = 371 | File::create(target_path.to_owned()).expect("Could not create file in tempdir"); 372 | file.write_all(b"These are local changes on the system") 373 | .expect("Failed to write to dotfile"); 374 | 375 | let manifest: Manifest = serde_yaml::from_str( 376 | &SAMPLE_MANIFEST.replace("~/some/path/here", &target_path.to_string_lossy()), 377 | ) 378 | .unwrap(); 379 | 380 | let err = manifest 381 | .sync(&repo, true, vec![], None, None, true) 382 | .unwrap_err(); 383 | 384 | // FIXME: This is a very dodgy test, maybe setup a mock repo for pushing to? 385 | assert_eq!( 386 | err.to_string(), 387 | "remote 'origin' does not exist; class=Config (7); code=NotFound (-3)" 388 | ); 389 | 390 | assert_eq!( 391 | read_to_string(&repo_dotfile_path).unwrap(), 392 | "These are local changes on the system" 393 | ); 394 | } 395 | } 396 | -------------------------------------------------------------------------------- /src/structs/metadata.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::fs; 3 | use std::io::Write; 4 | use std::path::Path; 5 | use std::{error::Error, fs::File}; 6 | 7 | use serde::{Deserialize, Serialize}; 8 | 9 | use crate::MANIFEST_PATH; 10 | 11 | /// Struct representing a `manifest.yaml` file, typically found in ~/.local/share/jointhedots. 12 | /// Represents an aggregation of the metadata of all of the dotfiles in a Manifest via a mapping of 13 | /// `dotfile_name` to [DotfileMetadata] 14 | #[derive(Serialize, Deserialize, Debug, Default)] 15 | pub struct AggregatedDotfileMetadata { 16 | #[serde(flatten)] 17 | pub data: HashMap, 18 | } 19 | 20 | impl AggregatedDotfileMetadata { 21 | pub fn new() -> Self { 22 | AggregatedDotfileMetadata::default() 23 | } 24 | 25 | /// Get the current AggregatedDotfileMetadata for this machine, or return None if it doesn't exist. 26 | /// 27 | /// # Examples 28 | /// 29 | /// ``` 30 | /// use jointhedots::structs::AggregatedDotfileMetadata; 31 | /// 32 | /// let manifest = AggregatedDotfileMetadata::get().unwrap(); 33 | /// ``` 34 | pub fn get() -> Result, Box> { 35 | let path = shellexpand::tilde(MANIFEST_PATH); 36 | let reader = File::open(path.as_ref()).ok(); 37 | 38 | if let Some(file) = reader { 39 | let config: AggregatedDotfileMetadata = 40 | serde_yaml::from_reader(file).map_err(|_| { 41 | format!( 42 | "Could not parse manifest. Check {} for issues", 43 | MANIFEST_PATH 44 | ) 45 | })?; 46 | Ok(Some(config)) 47 | } else { 48 | Ok(None) 49 | } 50 | } 51 | 52 | /// Get the current AggregatedDotfileMetadata for this machine, or create one if it doesn't exist. 53 | /// 54 | /// # Examples 55 | /// 56 | /// ``` 57 | /// use jointhedots::structs::AggregatedDotfileMetadata; 58 | /// 59 | /// let manifest = AggregatedDotfileMetadata::get_or_create().unwrap(); 60 | /// ``` 61 | pub fn get_or_create() -> Result> { 62 | Ok(AggregatedDotfileMetadata::get()?.unwrap_or_else(AggregatedDotfileMetadata::new)) 63 | } 64 | 65 | pub fn save(&self) -> Result<(), Box> { 66 | let data_path = shellexpand::tilde(MANIFEST_PATH); 67 | fs::create_dir_all( 68 | Path::new(data_path.as_ref()) 69 | .parent() 70 | .ok_or("Could not access manifest directory")?, 71 | )?; 72 | 73 | let mut output_manifest_file = File::create(data_path.to_string())?; 74 | output_manifest_file.write_all("# jointhedots installation manifest. Automatically generated, DO NOT EDIT (unless you know what you're doing)\n".as_bytes())?; 75 | Ok(serde_yaml::to_writer(output_manifest_file, &self)?) 76 | } 77 | } 78 | 79 | /// Represent the metadata of an installed dotfile 80 | #[derive(Serialize, Deserialize, Debug, Clone)] 81 | pub struct DotfileMetadata { 82 | /// The hash of the commit this dotfile was installed from 83 | pub commit_hash: String, 84 | 85 | /// The sha1 hash of the pre-install steps. Used to figure out whether pre-install should be 86 | /// run again on subsequent installations 87 | pub pre_install_hash: String, 88 | 89 | /// The sha1 hash of the post-install steps. Used to figure out whether post-install should be 90 | /// run again on subsequent installations 91 | pub post_install_hash: String, 92 | } 93 | 94 | impl DotfileMetadata { 95 | /// Extract the metadata from a [Dotfile] and the commit hash the dotfile was installed from 96 | pub fn new(commit_hash: &str, pre_install_hash: String, post_install_hash: String) -> Self { 97 | DotfileMetadata { 98 | commit_hash: commit_hash.to_string(), 99 | pre_install_hash, 100 | post_install_hash, 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/structs/mod.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | mod dotfile; 3 | mod manifest; 4 | mod metadata; 5 | 6 | pub use config::Config; 7 | pub use dotfile::Dotfile; 8 | pub use manifest::Manifest; 9 | 10 | pub use metadata::{AggregatedDotfileMetadata, DotfileMetadata}; 11 | -------------------------------------------------------------------------------- /src/subcommands/install.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | use tempfile::tempdir; 4 | 5 | use crate::cli::InstallSubcommandArgs; 6 | use crate::git::operations::clone_repo; 7 | use crate::git::remote::get_host_git_url; 8 | use crate::structs::Manifest; 9 | 10 | pub fn install_subcommand_handler(args: InstallSubcommandArgs) -> Result<(), Box> { 11 | let url = get_host_git_url(&args.repository, &args.source, &args.method)?; 12 | 13 | let target_dir = tempdir()?; 14 | let repo = clone_repo(&url, target_dir.path())?; 15 | 16 | let mut manifest_path = target_dir.path().to_path_buf(); 17 | manifest_path.push(args.manifest); 18 | 19 | let manifest = Manifest::get(&manifest_path)?; 20 | 21 | manifest.install( 22 | &repo, 23 | args.all, 24 | args.target_dotfiles, 25 | args.force, 26 | args.trust, 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/subcommands/interactive.rs: -------------------------------------------------------------------------------- 1 | use console::style; 2 | use dialoguer::{Confirm, Input, Select}; 3 | use regex::Regex; 4 | use std::{error::Error, str::FromStr}; 5 | use strum::IntoEnumIterator; 6 | 7 | use crate::{ 8 | cli::InstallSubcommandArgs, 9 | git::remote::{ConnectionMethod, RepoHostName}, 10 | utils::get_theme, 11 | }; 12 | 13 | use super::install_subcommand_handler; 14 | 15 | pub fn interactive_subcommand_handler() -> Result<(), Box> { 16 | println!("\ 17 | Welcome to JTD! \n\ 18 | This wizard will guide you through installing your preconfigured dotfiles repo. \n\ 19 | If you haven't yet added a manifest to your dotfile repo, view the README for instructions on how to do so \n\n\ 20 | \t{} https://github.com/dob9601/jointhedots \n\ 21 | \t{} https://github.com/dob9601/dotfiles/blob/master/jtd.yaml 22 | ", style("README:").cyan(), style("Example Manifest:").cyan()); 23 | 24 | let theme = get_theme(); 25 | 26 | let repo_regex = Regex::new("[A-Za-z0-9]+/[A-Za-z0-9]+").unwrap(); 27 | let repository = Input::with_theme(&theme) 28 | .with_prompt("Target Repository: ") 29 | .validate_with(|input: &String| { 30 | if repo_regex.is_match(input) { 31 | Ok(()) 32 | } else { 33 | Err("Invalid repository passed, name should follow the format of owner/repo") 34 | } 35 | }) 36 | .interact_text() 37 | .unwrap(); 38 | 39 | let repo_sources = RepoHostName::iter().collect::>(); 40 | let source_index = Select::with_theme(&theme) 41 | .with_prompt("Repository Source: ") 42 | .default(0) 43 | .items(&repo_sources) 44 | .interact() 45 | .unwrap(); 46 | 47 | let methods = ConnectionMethod::iter().collect::>(); 48 | let method_index = Select::with_theme(&theme) 49 | .with_prompt("Method: ") 50 | .default(0) 51 | .items(&methods) 52 | .interact() 53 | .unwrap(); 54 | 55 | let manifest_regex = Regex::new(r"\.yaml$|\.yml$").unwrap(); 56 | 57 | let manifest = Input::with_theme(&theme) 58 | .with_prompt("Manifest: ") 59 | .default(String::from("jtd.yaml")) 60 | .validate_with(|input: &String| { 61 | if manifest_regex.is_match(input) { 62 | Ok(()) 63 | } else { 64 | Err("Manifest must be a yaml file (file extension of yaml/yml)") 65 | } 66 | }) 67 | .interact_text() 68 | .unwrap(); 69 | 70 | let force = Confirm::with_theme(&theme) 71 | .with_prompt("Overwrite existing dotfiles without prompting") 72 | .default(false) 73 | .wait_for_newline(true) 74 | .interact() 75 | .unwrap(); 76 | 77 | let install_args = InstallSubcommandArgs { 78 | repository, 79 | target_dotfiles: vec![], 80 | source: RepoHostName::from_str(repo_sources[source_index].to_string().as_str())?, 81 | force, 82 | manifest, 83 | method: ConnectionMethod::from_str(methods[method_index].to_string().as_str())?, 84 | trust: false, 85 | all: false, 86 | }; 87 | 88 | install_subcommand_handler(install_args)?; 89 | Ok(()) 90 | } 91 | -------------------------------------------------------------------------------- /src/subcommands/sync.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | use tempfile::tempdir; 4 | 5 | use crate::{ 6 | cli::SyncSubcommandArgs, 7 | git::{operations::clone_repo, remote::get_host_git_url}, 8 | structs::{AggregatedDotfileMetadata, Manifest}, 9 | }; 10 | 11 | pub fn sync_subcommand_handler(args: SyncSubcommandArgs) -> Result<(), Box> { 12 | let url = get_host_git_url(&args.repository, &args.source, &args.method)?; 13 | let target_dir = tempdir()?; 14 | 15 | let repo = clone_repo(&url, target_dir.path())?; 16 | 17 | let mut manifest_path = target_dir.path().to_path_buf(); 18 | manifest_path.push(args.manifest); 19 | 20 | let manifest = Manifest::get(&manifest_path)?; 21 | 22 | manifest.sync( 23 | &repo, 24 | args.all, 25 | args.target_dotfiles, 26 | args.commit_msg.as_deref(), 27 | AggregatedDotfileMetadata::get()?, 28 | args.naive, 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | error::Error, 3 | io::{self, Write}, 4 | process::Command, 5 | }; 6 | 7 | use console::style; 8 | use dialoguer::{ 9 | console::Style, 10 | theme::{ColorfulTheme, Theme}, 11 | }; 12 | use sha1::{Digest, Sha1}; 13 | 14 | pub const SPINNER_FRAMES: &[&str] = &[ 15 | "⢀⠀", "⡀⠀", "⠄⠀", "⢂⠀", "⡂⠀", "⠅⠀", "⢃⠀", "⡃⠀", "⠍⠀", "⢋⠀", "⡋⠀", "⠍⠁", "⢋⠁", "⡋⠁", "⠍⠉", "⠋⠉", 16 | "⠋⠉", "⠉⠙", "⠉⠙", "⠉⠩", "⠈⢙", "⠈⡙", "⢈⠩", "⡀⢙", "⠄⡙", "⢂⠩", "⡂⢘", "⠅⡘", "⢃⠨", "⡃⢐", "⠍⡐", "⢋⠠", 17 | "⡋⢀", "⠍⡁", "⢋⠁", "⡋⠁", "⠍⠉", "⠋⠉", "⠋⠉", "⠉⠙", "⠉⠙", "⠉⠩", "⠈⢙", "⠈⡙", "⠈⠩", "⠀⢙", "⠀⡙", "⠀⠩", 18 | "⠀⢘", "⠀⡘", "⠀⠨", "⠀⢐", "⠀⡐", "⠀⠠", "⠀⢀", "⠀⡀", " ", " ", 19 | ]; 20 | pub const SPINNER_RATE: u64 = 48; 21 | 22 | pub fn run_command_vec(command_vec: &[String]) -> Result<(), Box> { 23 | for (stage, command) in command_vec.iter().enumerate() { 24 | println!("{} {}", style(format!("Step #{}:", stage)).cyan(), command); 25 | io::stdout().flush()?; 26 | 27 | let command_vec: Vec = command 28 | .split(' ') 29 | .map(|component| shellexpand::tilde(component).to_string()) 30 | .collect(); 31 | Command::new(command_vec[0].as_str()) 32 | .args(&command_vec[1..]) 33 | .spawn()? 34 | .wait_with_output()?; 35 | } 36 | Ok(()) 37 | } 38 | 39 | #[cfg(not(tarpaulin_include))] 40 | pub(crate) fn get_theme() -> impl Theme { 41 | ColorfulTheme { 42 | values_style: Style::new().yellow().dim(), 43 | ..ColorfulTheme::default() 44 | } 45 | } 46 | 47 | pub(crate) fn hash_command_vec(command_vec: &[String]) -> String { 48 | let mut hasher = Sha1::new(); 49 | let bytes: Vec = command_vec.iter().map(|s| s.bytes()).flatten().collect(); 50 | 51 | hasher.update(bytes); 52 | hex::encode(&hasher.finalize()[..]) 53 | } 54 | 55 | #[cfg(test)] 56 | mod tests { 57 | use std::path::Path; 58 | 59 | use super::*; 60 | 61 | #[test] 62 | fn test_run_command_vec() { 63 | let path = Path::new("/tmp/test-jtd"); 64 | let command_vec = vec![format!("touch {}", path.to_string_lossy())]; 65 | run_command_vec(&command_vec).expect("Could not run command vec"); 66 | assert!(Path::new("/tmp/test-jtd").exists()); 67 | } 68 | 69 | #[test] 70 | fn test_hash_command_vec() { 71 | let command_vec = vec![ 72 | String::from("echo \"Hi!\""), 73 | String::from("echo \"This is a vector of shell commands!\""), 74 | String::from("echo \"Farewell!\""), 75 | ]; 76 | 77 | assert_eq!( 78 | hash_command_vec(&command_vec), 79 | "b51a85b8eeee922159d23463ffc057ab25fbaf9b".to_string() 80 | ); 81 | } 82 | } 83 | --------------------------------------------------------------------------------