├── .github └── workflows │ ├── ci.yaml │ └── release.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── scripts ├── integration_tests.sh └── local_install.sh └── src ├── cli ├── add.rs ├── mod.rs └── state.rs ├── main.rs └── profiles ├── config.rs ├── hooks.rs ├── mapping.rs ├── mod.rs └── profile.rs /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: test 8 | env: 9 | # Emit backtraces on panics. 10 | RUST_BACKTRACE: 1 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | build: 15 | # Test on stable and beta rust compilers. 16 | - stable 17 | - beta 18 | include: 19 | - build: macos 20 | os: macos-latest 21 | rust: stable 22 | - build: stable 23 | os: ubuntu-latest 24 | rust: stable 25 | - build: beta 26 | os: ubuntu-latest 27 | rust: beta 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v3 31 | 32 | - name: Install Rust 33 | uses: dtolnay/rust-toolchain@master 34 | with: 35 | toolchain: ${{ matrix.rust }} 36 | 37 | - name: Build dfm 38 | run: cargo build --verbose ${{ env.TARGET_FLAGS }} 39 | 40 | - name: Run cargo tests 41 | run: cargo test 42 | 43 | - name: Run integration tests 44 | run: ./scripts/integration_tests.sh -b ./target/debug/dfm 45 | 46 | clippy: 47 | name: clippy 48 | runs-on: ubuntu-latest 49 | steps: 50 | - name: Checkout repository 51 | uses: actions/checkout@v3 52 | - name: Install Rust 53 | uses: dtolnay/rust-toolchain@master 54 | with: 55 | toolchain: stable 56 | components: clippy 57 | - name: Lint 58 | run: cargo clippy --no-deps --all-targets 59 | 60 | rustfmt: 61 | name: rustfmt 62 | runs-on: ubuntu-latest 63 | steps: 64 | - name: Checkout repository 65 | uses: actions/checkout@v3 66 | - name: Install Rust 67 | uses: dtolnay/rust-toolchain@master 68 | with: 69 | toolchain: stable 70 | components: rustfmt 71 | - name: Check formatting 72 | run: cargo fmt --all --check 73 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "[0-9]+.[0-9]+.[0-9]+" 6 | 7 | jobs: 8 | create-release: 9 | name: create-release 10 | runs-on: ubuntu-latest 11 | env: 12 | DFM_VERSION: "" 13 | outputs: 14 | upload_url: ${{ steps.release.outputs.upload_url }} 15 | dfm_version: ${{ env.DFM_VERSION }} 16 | steps: 17 | - name: Get the release version from the tag 18 | shell: bash 19 | if: env.DFM_VERSION == '' 20 | run: | 21 | # See: https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027 22 | echo "DFM_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 23 | echo "version is: ${{ env.DFM_VERSION }}" 24 | - name: Create GitHub release 25 | id: release 26 | uses: actions/create-release@v1 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | with: 30 | tag_name: ${{ env.DFM_VERSION }} 31 | release_name: ${{ env.DFM_VERSION }} 32 | 33 | build-release: 34 | name: build-release 35 | needs: ['create-release'] 36 | runs-on: ${{ matrix.os }} 37 | env: 38 | CARGO: cargo 39 | TARGET_FLAGS: "--target ${{ matrix.target }}" 40 | TARGET_DIR: ./target/${{ matrix.target }} 41 | strategy: 42 | matrix: 43 | build: [linux, linux-arm, macos] 44 | include: 45 | - build: linux 46 | os: ubuntu-latest 47 | rust: stable 48 | target: x86_64-unknown-linux-gnu 49 | - build: linux-arm 50 | os: ubuntu-latest 51 | rust: stable 52 | target: arm-unknown-linux-gnueabihf 53 | - build: macos 54 | os: macos-latest 55 | rust: stable 56 | target: x86_64-apple-darwin 57 | 58 | steps: 59 | - name: Checkout repository 60 | uses: actions/checkout@v3 61 | 62 | - name: Install Rust 63 | uses: dtolnay/rust-toolchain@master 64 | with: 65 | toolchain: ${{ matrix.rust }} 66 | target: ${{ matrix.target }} 67 | 68 | - name: Use Cross 69 | shell: bash 70 | run: | 71 | cargo install cross 72 | echo "CARGO=cross" >> $GITHUB_ENV 73 | 74 | - name: Show command used for Cargo 75 | run: | 76 | echo "cargo command is: ${{ env.CARGO }}" 77 | echo "target flag is: ${{ env.TARGET_FLAGS }}" 78 | echo "target dir is: ${{ env.TARGET_DIR }}" 79 | 80 | - name: Build release binary 81 | run: ${{ env.CARGO }} build --verbose --release ${{ env.TARGET_FLAGS }} 82 | 83 | - name: Strip release binary (linux and macos) 84 | if: matrix.build == 'linux' || matrix.build == 'macos' 85 | run: strip "target/${{ matrix.target }}/release/dfm" 86 | 87 | - name: Strip release binary (arm) 88 | if: matrix.build == 'linux-arm' 89 | run: | 90 | docker run --rm -v \ 91 | "$PWD/target:/target:Z" \ 92 | rustembedded/cross:arm-unknown-linux-gnueabihf \ 93 | arm-linux-gnueabihf-strip \ 94 | /target/arm-unknown-linux-gnueabihf/release/dfm 95 | 96 | - name: Build archive 97 | shell: bash 98 | run: | 99 | staging="dfm-${{ needs.create-release.outputs.dfm_version }}-${{ matrix.target }}" 100 | mkdir -p "$staging/complete" 101 | 102 | cp {README.md,LICENSE} "$staging/" 103 | cp "target/${{ matrix.target }}/release/dfm" "$staging/" 104 | 105 | tar czvf "$staging.tar.gz" "$staging" 106 | echo "ASSET=$staging.tar.gz" >> $GITHUB_ENV 107 | 108 | - name: Upload release archive 109 | uses: actions/upload-release-asset@v1.0.2 110 | env: 111 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 112 | with: 113 | upload_url: ${{ needs.create-release.outputs.upload_url }} 114 | asset_path: ${{ env.ASSET }} 115 | asset_name: ${{ env.ASSET }} 116 | asset_content_type: application/octet-stream 117 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | build/* 3 | /target 4 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "anstream" 16 | version = "0.6.18" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 19 | dependencies = [ 20 | "anstyle", 21 | "anstyle-parse", 22 | "anstyle-query", 23 | "anstyle-wincon", 24 | "colorchoice", 25 | "is_terminal_polyfill", 26 | "utf8parse", 27 | ] 28 | 29 | [[package]] 30 | name = "anstyle" 31 | version = "1.0.10" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 34 | 35 | [[package]] 36 | name = "anstyle-parse" 37 | version = "0.2.6" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 40 | dependencies = [ 41 | "utf8parse", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle-query" 46 | version = "1.1.2" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 49 | dependencies = [ 50 | "windows-sys 0.59.0", 51 | ] 52 | 53 | [[package]] 54 | name = "anstyle-wincon" 55 | version = "3.0.7" 56 | source = "registry+https://github.com/rust-lang/crates.io-index" 57 | checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" 58 | dependencies = [ 59 | "anstyle", 60 | "once_cell", 61 | "windows-sys 0.59.0", 62 | ] 63 | 64 | [[package]] 65 | name = "bitflags" 66 | version = "2.8.0" 67 | source = "registry+https://github.com/rust-lang/crates.io-index" 68 | checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" 69 | 70 | [[package]] 71 | name = "cfg-if" 72 | version = "1.0.0" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 75 | 76 | [[package]] 77 | name = "cfg_aliases" 78 | version = "0.1.1" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" 81 | 82 | [[package]] 83 | name = "clap" 84 | version = "4.5.27" 85 | source = "registry+https://github.com/rust-lang/crates.io-index" 86 | checksum = "769b0145982b4b48713e01ec42d61614425f27b7058bda7180a3a41f30104796" 87 | dependencies = [ 88 | "clap_builder", 89 | "clap_derive", 90 | ] 91 | 92 | [[package]] 93 | name = "clap_builder" 94 | version = "4.5.27" 95 | source = "registry+https://github.com/rust-lang/crates.io-index" 96 | checksum = "1b26884eb4b57140e4d2d93652abfa49498b938b3c9179f9fc487b0acc3edad7" 97 | dependencies = [ 98 | "anstream", 99 | "anstyle", 100 | "clap_lex", 101 | "strsim", 102 | ] 103 | 104 | [[package]] 105 | name = "clap_complete" 106 | version = "4.5.42" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "33a7e468e750fa4b6be660e8b5651ad47372e8fb114030b594c2d75d48c5ffd0" 109 | dependencies = [ 110 | "clap", 111 | ] 112 | 113 | [[package]] 114 | name = "clap_derive" 115 | version = "4.5.24" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "54b755194d6389280185988721fffba69495eed5ee9feeee9a599b53db80318c" 118 | dependencies = [ 119 | "heck", 120 | "proc-macro2", 121 | "quote", 122 | "syn", 123 | ] 124 | 125 | [[package]] 126 | name = "clap_lex" 127 | version = "0.7.4" 128 | source = "registry+https://github.com/rust-lang/crates.io-index" 129 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 130 | 131 | [[package]] 132 | name = "clipboard-win" 133 | version = "5.4.0" 134 | source = "registry+https://github.com/rust-lang/crates.io-index" 135 | checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" 136 | dependencies = [ 137 | "error-code", 138 | ] 139 | 140 | [[package]] 141 | name = "colorchoice" 142 | version = "1.0.3" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 145 | 146 | [[package]] 147 | name = "dfm" 148 | version = "10.3.5" 149 | dependencies = [ 150 | "clap", 151 | "clap_complete", 152 | "env_logger", 153 | "lazy_static", 154 | "log", 155 | "regex", 156 | "rustyline", 157 | "serde", 158 | "serde_json", 159 | "serde_regex", 160 | "serde_yaml", 161 | "shellexpand", 162 | "shlex", 163 | "text_io", 164 | "walkdir", 165 | ] 166 | 167 | [[package]] 168 | name = "dirs" 169 | version = "5.0.1" 170 | source = "registry+https://github.com/rust-lang/crates.io-index" 171 | checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" 172 | dependencies = [ 173 | "dirs-sys", 174 | ] 175 | 176 | [[package]] 177 | name = "dirs-sys" 178 | version = "0.4.1" 179 | source = "registry+https://github.com/rust-lang/crates.io-index" 180 | checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" 181 | dependencies = [ 182 | "libc", 183 | "option-ext", 184 | "redox_users", 185 | "windows-sys 0.48.0", 186 | ] 187 | 188 | [[package]] 189 | name = "endian-type" 190 | version = "0.1.2" 191 | source = "registry+https://github.com/rust-lang/crates.io-index" 192 | checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" 193 | 194 | [[package]] 195 | name = "env_filter" 196 | version = "0.1.3" 197 | source = "registry+https://github.com/rust-lang/crates.io-index" 198 | checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" 199 | dependencies = [ 200 | "log", 201 | "regex", 202 | ] 203 | 204 | [[package]] 205 | name = "env_logger" 206 | version = "0.11.6" 207 | source = "registry+https://github.com/rust-lang/crates.io-index" 208 | checksum = "dcaee3d8e3cfc3fd92428d477bc97fc29ec8716d180c0d74c643bb26166660e0" 209 | dependencies = [ 210 | "anstream", 211 | "anstyle", 212 | "env_filter", 213 | "humantime", 214 | "log", 215 | ] 216 | 217 | [[package]] 218 | name = "equivalent" 219 | version = "1.0.1" 220 | source = "registry+https://github.com/rust-lang/crates.io-index" 221 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 222 | 223 | [[package]] 224 | name = "errno" 225 | version = "0.3.10" 226 | source = "registry+https://github.com/rust-lang/crates.io-index" 227 | checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" 228 | dependencies = [ 229 | "libc", 230 | "windows-sys 0.59.0", 231 | ] 232 | 233 | [[package]] 234 | name = "error-code" 235 | version = "3.3.1" 236 | source = "registry+https://github.com/rust-lang/crates.io-index" 237 | checksum = "a5d9305ccc6942a704f4335694ecd3de2ea531b114ac2d51f5f843750787a92f" 238 | 239 | [[package]] 240 | name = "fd-lock" 241 | version = "4.0.2" 242 | source = "registry+https://github.com/rust-lang/crates.io-index" 243 | checksum = "7e5768da2206272c81ef0b5e951a41862938a6070da63bcea197899942d3b947" 244 | dependencies = [ 245 | "cfg-if", 246 | "rustix", 247 | "windows-sys 0.52.0", 248 | ] 249 | 250 | [[package]] 251 | name = "getrandom" 252 | version = "0.2.15" 253 | source = "registry+https://github.com/rust-lang/crates.io-index" 254 | checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 255 | dependencies = [ 256 | "cfg-if", 257 | "libc", 258 | "wasi", 259 | ] 260 | 261 | [[package]] 262 | name = "hashbrown" 263 | version = "0.15.2" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 266 | 267 | [[package]] 268 | name = "heck" 269 | version = "0.5.0" 270 | source = "registry+https://github.com/rust-lang/crates.io-index" 271 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 272 | 273 | [[package]] 274 | name = "home" 275 | version = "0.5.11" 276 | source = "registry+https://github.com/rust-lang/crates.io-index" 277 | checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" 278 | dependencies = [ 279 | "windows-sys 0.59.0", 280 | ] 281 | 282 | [[package]] 283 | name = "humantime" 284 | version = "2.1.0" 285 | source = "registry+https://github.com/rust-lang/crates.io-index" 286 | checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" 287 | 288 | [[package]] 289 | name = "indexmap" 290 | version = "2.7.1" 291 | source = "registry+https://github.com/rust-lang/crates.io-index" 292 | checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" 293 | dependencies = [ 294 | "equivalent", 295 | "hashbrown", 296 | ] 297 | 298 | [[package]] 299 | name = "is_terminal_polyfill" 300 | version = "1.70.1" 301 | source = "registry+https://github.com/rust-lang/crates.io-index" 302 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 303 | 304 | [[package]] 305 | name = "itoa" 306 | version = "1.0.14" 307 | source = "registry+https://github.com/rust-lang/crates.io-index" 308 | checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" 309 | 310 | [[package]] 311 | name = "lazy_static" 312 | version = "1.5.0" 313 | source = "registry+https://github.com/rust-lang/crates.io-index" 314 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 315 | 316 | [[package]] 317 | name = "libc" 318 | version = "0.2.169" 319 | source = "registry+https://github.com/rust-lang/crates.io-index" 320 | checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" 321 | 322 | [[package]] 323 | name = "libredox" 324 | version = "0.1.3" 325 | source = "registry+https://github.com/rust-lang/crates.io-index" 326 | checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 327 | dependencies = [ 328 | "bitflags", 329 | "libc", 330 | ] 331 | 332 | [[package]] 333 | name = "linux-raw-sys" 334 | version = "0.4.15" 335 | source = "registry+https://github.com/rust-lang/crates.io-index" 336 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 337 | 338 | [[package]] 339 | name = "log" 340 | version = "0.4.25" 341 | source = "registry+https://github.com/rust-lang/crates.io-index" 342 | checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" 343 | 344 | [[package]] 345 | name = "memchr" 346 | version = "2.7.4" 347 | source = "registry+https://github.com/rust-lang/crates.io-index" 348 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 349 | 350 | [[package]] 351 | name = "nibble_vec" 352 | version = "0.1.0" 353 | source = "registry+https://github.com/rust-lang/crates.io-index" 354 | checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" 355 | dependencies = [ 356 | "smallvec", 357 | ] 358 | 359 | [[package]] 360 | name = "nix" 361 | version = "0.28.0" 362 | source = "registry+https://github.com/rust-lang/crates.io-index" 363 | checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" 364 | dependencies = [ 365 | "bitflags", 366 | "cfg-if", 367 | "cfg_aliases", 368 | "libc", 369 | ] 370 | 371 | [[package]] 372 | name = "once_cell" 373 | version = "1.20.2" 374 | source = "registry+https://github.com/rust-lang/crates.io-index" 375 | checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 376 | 377 | [[package]] 378 | name = "option-ext" 379 | version = "0.2.0" 380 | source = "registry+https://github.com/rust-lang/crates.io-index" 381 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 382 | 383 | [[package]] 384 | name = "proc-macro2" 385 | version = "1.0.93" 386 | source = "registry+https://github.com/rust-lang/crates.io-index" 387 | checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" 388 | dependencies = [ 389 | "unicode-ident", 390 | ] 391 | 392 | [[package]] 393 | name = "quote" 394 | version = "1.0.38" 395 | source = "registry+https://github.com/rust-lang/crates.io-index" 396 | checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" 397 | dependencies = [ 398 | "proc-macro2", 399 | ] 400 | 401 | [[package]] 402 | name = "radix_trie" 403 | version = "0.2.1" 404 | source = "registry+https://github.com/rust-lang/crates.io-index" 405 | checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" 406 | dependencies = [ 407 | "endian-type", 408 | "nibble_vec", 409 | ] 410 | 411 | [[package]] 412 | name = "redox_users" 413 | version = "0.4.6" 414 | source = "registry+https://github.com/rust-lang/crates.io-index" 415 | checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" 416 | dependencies = [ 417 | "getrandom", 418 | "libredox", 419 | "thiserror", 420 | ] 421 | 422 | [[package]] 423 | name = "regex" 424 | version = "1.11.1" 425 | source = "registry+https://github.com/rust-lang/crates.io-index" 426 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 427 | dependencies = [ 428 | "aho-corasick", 429 | "memchr", 430 | "regex-automata", 431 | "regex-syntax", 432 | ] 433 | 434 | [[package]] 435 | name = "regex-automata" 436 | version = "0.4.9" 437 | source = "registry+https://github.com/rust-lang/crates.io-index" 438 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 439 | dependencies = [ 440 | "aho-corasick", 441 | "memchr", 442 | "regex-syntax", 443 | ] 444 | 445 | [[package]] 446 | name = "regex-syntax" 447 | version = "0.8.5" 448 | source = "registry+https://github.com/rust-lang/crates.io-index" 449 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 450 | 451 | [[package]] 452 | name = "rustix" 453 | version = "0.38.44" 454 | source = "registry+https://github.com/rust-lang/crates.io-index" 455 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 456 | dependencies = [ 457 | "bitflags", 458 | "errno", 459 | "libc", 460 | "linux-raw-sys", 461 | "windows-sys 0.59.0", 462 | ] 463 | 464 | [[package]] 465 | name = "rustyline" 466 | version = "14.0.0" 467 | source = "registry+https://github.com/rust-lang/crates.io-index" 468 | checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" 469 | dependencies = [ 470 | "bitflags", 471 | "cfg-if", 472 | "clipboard-win", 473 | "fd-lock", 474 | "home", 475 | "libc", 476 | "log", 477 | "memchr", 478 | "nix", 479 | "radix_trie", 480 | "unicode-segmentation", 481 | "unicode-width", 482 | "utf8parse", 483 | "windows-sys 0.52.0", 484 | ] 485 | 486 | [[package]] 487 | name = "ryu" 488 | version = "1.0.18" 489 | source = "registry+https://github.com/rust-lang/crates.io-index" 490 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 491 | 492 | [[package]] 493 | name = "same-file" 494 | version = "1.0.6" 495 | source = "registry+https://github.com/rust-lang/crates.io-index" 496 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 497 | dependencies = [ 498 | "winapi-util", 499 | ] 500 | 501 | [[package]] 502 | name = "serde" 503 | version = "1.0.217" 504 | source = "registry+https://github.com/rust-lang/crates.io-index" 505 | checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" 506 | dependencies = [ 507 | "serde_derive", 508 | ] 509 | 510 | [[package]] 511 | name = "serde_derive" 512 | version = "1.0.217" 513 | source = "registry+https://github.com/rust-lang/crates.io-index" 514 | checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" 515 | dependencies = [ 516 | "proc-macro2", 517 | "quote", 518 | "syn", 519 | ] 520 | 521 | [[package]] 522 | name = "serde_json" 523 | version = "1.0.137" 524 | source = "registry+https://github.com/rust-lang/crates.io-index" 525 | checksum = "930cfb6e6abf99298aaad7d29abbef7a9999a9a8806a40088f55f0dcec03146b" 526 | dependencies = [ 527 | "itoa", 528 | "memchr", 529 | "ryu", 530 | "serde", 531 | ] 532 | 533 | [[package]] 534 | name = "serde_regex" 535 | version = "1.1.0" 536 | source = "registry+https://github.com/rust-lang/crates.io-index" 537 | checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" 538 | dependencies = [ 539 | "regex", 540 | "serde", 541 | ] 542 | 543 | [[package]] 544 | name = "serde_yaml" 545 | version = "0.9.34+deprecated" 546 | source = "registry+https://github.com/rust-lang/crates.io-index" 547 | checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" 548 | dependencies = [ 549 | "indexmap", 550 | "itoa", 551 | "ryu", 552 | "serde", 553 | "unsafe-libyaml", 554 | ] 555 | 556 | [[package]] 557 | name = "shellexpand" 558 | version = "3.1.0" 559 | source = "registry+https://github.com/rust-lang/crates.io-index" 560 | checksum = "da03fa3b94cc19e3ebfc88c4229c49d8f08cdbd1228870a45f0ffdf84988e14b" 561 | dependencies = [ 562 | "dirs", 563 | ] 564 | 565 | [[package]] 566 | name = "shlex" 567 | version = "1.3.0" 568 | source = "registry+https://github.com/rust-lang/crates.io-index" 569 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 570 | 571 | [[package]] 572 | name = "smallvec" 573 | version = "1.13.2" 574 | source = "registry+https://github.com/rust-lang/crates.io-index" 575 | checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" 576 | 577 | [[package]] 578 | name = "strsim" 579 | version = "0.11.1" 580 | source = "registry+https://github.com/rust-lang/crates.io-index" 581 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 582 | 583 | [[package]] 584 | name = "syn" 585 | version = "2.0.96" 586 | source = "registry+https://github.com/rust-lang/crates.io-index" 587 | checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80" 588 | dependencies = [ 589 | "proc-macro2", 590 | "quote", 591 | "unicode-ident", 592 | ] 593 | 594 | [[package]] 595 | name = "text_io" 596 | version = "0.1.12" 597 | source = "registry+https://github.com/rust-lang/crates.io-index" 598 | checksum = "d5f0c8eb2ad70c12a6a69508f499b3051c924f4b1cfeae85bfad96e6bc5bba46" 599 | 600 | [[package]] 601 | name = "thiserror" 602 | version = "1.0.69" 603 | source = "registry+https://github.com/rust-lang/crates.io-index" 604 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 605 | dependencies = [ 606 | "thiserror-impl", 607 | ] 608 | 609 | [[package]] 610 | name = "thiserror-impl" 611 | version = "1.0.69" 612 | source = "registry+https://github.com/rust-lang/crates.io-index" 613 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 614 | dependencies = [ 615 | "proc-macro2", 616 | "quote", 617 | "syn", 618 | ] 619 | 620 | [[package]] 621 | name = "unicode-ident" 622 | version = "1.0.15" 623 | source = "registry+https://github.com/rust-lang/crates.io-index" 624 | checksum = "11cd88e12b17c6494200a9c1b683a04fcac9573ed74cd1b62aeb2727c5592243" 625 | 626 | [[package]] 627 | name = "unicode-segmentation" 628 | version = "1.12.0" 629 | source = "registry+https://github.com/rust-lang/crates.io-index" 630 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 631 | 632 | [[package]] 633 | name = "unicode-width" 634 | version = "0.1.14" 635 | source = "registry+https://github.com/rust-lang/crates.io-index" 636 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 637 | 638 | [[package]] 639 | name = "unsafe-libyaml" 640 | version = "0.2.11" 641 | source = "registry+https://github.com/rust-lang/crates.io-index" 642 | checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" 643 | 644 | [[package]] 645 | name = "utf8parse" 646 | version = "0.2.2" 647 | source = "registry+https://github.com/rust-lang/crates.io-index" 648 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 649 | 650 | [[package]] 651 | name = "walkdir" 652 | version = "2.5.0" 653 | source = "registry+https://github.com/rust-lang/crates.io-index" 654 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 655 | dependencies = [ 656 | "same-file", 657 | "winapi-util", 658 | ] 659 | 660 | [[package]] 661 | name = "wasi" 662 | version = "0.11.0+wasi-snapshot-preview1" 663 | source = "registry+https://github.com/rust-lang/crates.io-index" 664 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 665 | 666 | [[package]] 667 | name = "winapi-util" 668 | version = "0.1.9" 669 | source = "registry+https://github.com/rust-lang/crates.io-index" 670 | checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 671 | dependencies = [ 672 | "windows-sys 0.59.0", 673 | ] 674 | 675 | [[package]] 676 | name = "windows-sys" 677 | version = "0.48.0" 678 | source = "registry+https://github.com/rust-lang/crates.io-index" 679 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 680 | dependencies = [ 681 | "windows-targets 0.48.5", 682 | ] 683 | 684 | [[package]] 685 | name = "windows-sys" 686 | version = "0.52.0" 687 | source = "registry+https://github.com/rust-lang/crates.io-index" 688 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 689 | dependencies = [ 690 | "windows-targets 0.52.6", 691 | ] 692 | 693 | [[package]] 694 | name = "windows-sys" 695 | version = "0.59.0" 696 | source = "registry+https://github.com/rust-lang/crates.io-index" 697 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 698 | dependencies = [ 699 | "windows-targets 0.52.6", 700 | ] 701 | 702 | [[package]] 703 | name = "windows-targets" 704 | version = "0.48.5" 705 | source = "registry+https://github.com/rust-lang/crates.io-index" 706 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 707 | dependencies = [ 708 | "windows_aarch64_gnullvm 0.48.5", 709 | "windows_aarch64_msvc 0.48.5", 710 | "windows_i686_gnu 0.48.5", 711 | "windows_i686_msvc 0.48.5", 712 | "windows_x86_64_gnu 0.48.5", 713 | "windows_x86_64_gnullvm 0.48.5", 714 | "windows_x86_64_msvc 0.48.5", 715 | ] 716 | 717 | [[package]] 718 | name = "windows-targets" 719 | version = "0.52.6" 720 | source = "registry+https://github.com/rust-lang/crates.io-index" 721 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 722 | dependencies = [ 723 | "windows_aarch64_gnullvm 0.52.6", 724 | "windows_aarch64_msvc 0.52.6", 725 | "windows_i686_gnu 0.52.6", 726 | "windows_i686_gnullvm", 727 | "windows_i686_msvc 0.52.6", 728 | "windows_x86_64_gnu 0.52.6", 729 | "windows_x86_64_gnullvm 0.52.6", 730 | "windows_x86_64_msvc 0.52.6", 731 | ] 732 | 733 | [[package]] 734 | name = "windows_aarch64_gnullvm" 735 | version = "0.48.5" 736 | source = "registry+https://github.com/rust-lang/crates.io-index" 737 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 738 | 739 | [[package]] 740 | name = "windows_aarch64_gnullvm" 741 | version = "0.52.6" 742 | source = "registry+https://github.com/rust-lang/crates.io-index" 743 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 744 | 745 | [[package]] 746 | name = "windows_aarch64_msvc" 747 | version = "0.48.5" 748 | source = "registry+https://github.com/rust-lang/crates.io-index" 749 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 750 | 751 | [[package]] 752 | name = "windows_aarch64_msvc" 753 | version = "0.52.6" 754 | source = "registry+https://github.com/rust-lang/crates.io-index" 755 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 756 | 757 | [[package]] 758 | name = "windows_i686_gnu" 759 | version = "0.48.5" 760 | source = "registry+https://github.com/rust-lang/crates.io-index" 761 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 762 | 763 | [[package]] 764 | name = "windows_i686_gnu" 765 | version = "0.52.6" 766 | source = "registry+https://github.com/rust-lang/crates.io-index" 767 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 768 | 769 | [[package]] 770 | name = "windows_i686_gnullvm" 771 | version = "0.52.6" 772 | source = "registry+https://github.com/rust-lang/crates.io-index" 773 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 774 | 775 | [[package]] 776 | name = "windows_i686_msvc" 777 | version = "0.48.5" 778 | source = "registry+https://github.com/rust-lang/crates.io-index" 779 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 780 | 781 | [[package]] 782 | name = "windows_i686_msvc" 783 | version = "0.52.6" 784 | source = "registry+https://github.com/rust-lang/crates.io-index" 785 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 786 | 787 | [[package]] 788 | name = "windows_x86_64_gnu" 789 | version = "0.48.5" 790 | source = "registry+https://github.com/rust-lang/crates.io-index" 791 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 792 | 793 | [[package]] 794 | name = "windows_x86_64_gnu" 795 | version = "0.52.6" 796 | source = "registry+https://github.com/rust-lang/crates.io-index" 797 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 798 | 799 | [[package]] 800 | name = "windows_x86_64_gnullvm" 801 | version = "0.48.5" 802 | source = "registry+https://github.com/rust-lang/crates.io-index" 803 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 804 | 805 | [[package]] 806 | name = "windows_x86_64_gnullvm" 807 | version = "0.52.6" 808 | source = "registry+https://github.com/rust-lang/crates.io-index" 809 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 810 | 811 | [[package]] 812 | name = "windows_x86_64_msvc" 813 | version = "0.48.5" 814 | source = "registry+https://github.com/rust-lang/crates.io-index" 815 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 816 | 817 | [[package]] 818 | name = "windows_x86_64_msvc" 819 | version = "0.52.6" 820 | source = "registry+https://github.com/rust-lang/crates.io-index" 821 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 822 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dfm" 3 | description = "A dotfile manager for lazy people and pair programmers." 4 | license = "Apache-2.0" 5 | version = "10.3.5" 6 | homepage = "https://github.com/chasinglogic/dfm" 7 | documentation = "https://github.com/chasinglogic/dfm" 8 | repository = "https://github.com/chasinglogic/dfm" 9 | edition = "2021" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [dependencies] 14 | clap = { version = "4.5.20", features = ["derive", "cargo"] } 15 | clap_complete = "4.5.33" 16 | env_logger = "0.11.5" 17 | lazy_static = "1.5.0" 18 | log = "0.4.22" 19 | regex = "1.11.0" 20 | rustyline = "14.0.0" 21 | serde = { version = "1.0.210", features = ["derive"] } 22 | serde_json = "1.0.129" 23 | serde_regex = "1.1.0" 24 | serde_yaml = "0.9.34" 25 | shellexpand = "3.1.0" 26 | shlex = "1.3.0" 27 | text_io = "0.1.12" 28 | walkdir = "2.5.0" 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dfm 2 | 3 | A dotfile manager for lazy people and pair programmers. 4 | 5 | ## Table of Contents 6 | 7 | - [Features](#features) 8 | - [Multiple dotfile profiles](#multiple-dotfile-profiles) 9 | - [Profile modules](#profile-modules) 10 | - [Pre and post command hooks](#pre-and-post-command-hooks) 11 | - [Respects `$XDG_CONFIG_HOME`](#respects-xdg_config_home) 12 | - [Skips relevant files](#skips-relevant-files) 13 | - [Configurable mappings](#custom-mappings) 14 | - [Encrypted Dotfiles](#encrypted-dotfiles) 15 | - [Installation](#installation) 16 | - [Updating](#updating) 17 | - [Usage](#usage) 18 | - [Git quick start](#git-quick-start) 19 | - [Existing dotfiles repository](#quick-start-existing-dotfiles-repository) 20 | - [No existing dotfiles repository](#quick-start-no-existing-dotfiles-repository) 21 | - [Configuration](#configuration) 22 | - [Contributing](#contributing) 23 | - [License](#license) 24 | 25 | ## Features 26 | 27 | dfm supports these features that I was unable to find in other Dotfile 28 | Management solutions. 29 | 30 | ### Multiple dotfile profiles 31 | 32 | dfm's core feature is the idea of profiles. Profiles are simply a 33 | collection of dotfiles that dfm manages and links in the `$HOME` 34 | directory or configuration directories. This means that you can have 35 | multiple profiles and overlap them. 36 | 37 | This feature is hard to describe, so I will illustrate it's usefulness 38 | with two use cases: 39 | 40 | #### The work profile 41 | 42 | I use one laptop for work and personal projects in my dfm profiles I have my 43 | personal profile `chasinglogic` which contains all my dotfiles for Emacs, git, 44 | etc. and a "work" profile which only has a `.gitconfig` that has my work email 45 | in it. So my profile directory looks like this: 46 | 47 | ```text 48 | profiles/ 49 | ├── chasinglogic 50 | │   ├── agignore 51 | │   ├── bash 52 | │   ├── bashrc 53 | │   ├── gitconfig 54 | │   ├── gnupg 55 | │   ├── password-store 56 | │   ├── pypirc 57 | │   ├── spacemacs.d 58 | │   └── tmux.conf 59 | └── work 60 | └── gitconfig 61 | ``` 62 | 63 | Since dfm when linking only overwrites the files which are in the new 64 | profile, I can run `dfm link work` and still have access to my emacs 65 | configuration but my `gitconfig` has been updated to use my work 66 | email. Similarly when I leave work I just `dfm link chasinglogic` to 67 | switch back. 68 | 69 | See [profile modules](#profile-modules) for an even better solution to this 70 | particular use case. 71 | 72 | #### Pair programming 73 | 74 | The original inspiration for this tool was pair programming with my 75 | friend [lionize](https://github.com/lionize). lionize has a dotfiles 76 | repository so I can clone it using the git backend for dfm with `dfm 77 | clone --name lionize https://github.com/lionize/dotfiles`. 78 | 79 | Now our profile directory looks like: 80 | 81 | ```text 82 | profiles/ 83 | ├── chasinglogic 84 | │   ├── .dfm.yml 85 | │   ├── .git 86 | │   ├── .gitignore 87 | │   ├── agignore 88 | │   ├── bash 89 | │   ├── bashrc 90 | │   ├── gitconfig 91 | │   ├── gnupg 92 | │   ├── password-store 93 | │   ├── pypirc 94 | │   ├── spacemacs.d 95 | │   └── tmux.conf 96 | ├── lionize 97 | │   ├── .agignore 98 | │   ├── .git 99 | │   ├── .gitconfig 100 | │   ├── .gitignore_global 101 | │   ├── .gitmessage 102 | │   ├── .scripts 103 | │   ├── .tmux.conf 104 | │   ├── .vim 105 | │   ├── .vimrc -> ./.vim/init.vim 106 | │   └── .zshrc 107 | └── work 108 | ├── .git 109 | └── gitconfig 110 | ``` 111 | 112 | Now when I'm driving I simply `dfm link chasinglogic` and when passing back to 113 | lionize he runs `dfm link lionize` and we don't have to mess with multiple 114 | machines vice versa. 115 | 116 | ### Profile modules 117 | 118 | dfm supports profile modules which can be either additional dotfiles profiles as 119 | accepted by the `dfm clone` command or can be any git repository such as 120 | [Spacemacs](https://github.com/syl20bnr/spacemacs). You can get more info about 121 | how to use them and configure them in [Configuration](#configuration) 122 | 123 | ### Pre and Post command hooks 124 | 125 | dfm supports pre and post command hooks. that allows you to specify before and 126 | after command scripts to run. For example, I use a profile module to keep 127 | certain ssh keys in an encrypted git repository. Whenever I run the `dfm sync` command 128 | I have hooks which fix the permissions of the keys and ssh-add them to my ssh 129 | agent. You can read about how to write your own hooks in 130 | [Configuration](#configuration) 131 | 132 | ### Respects `$XDG_CONFIG_HOME` 133 | 134 | dfm respects dotfiles which exist in the `$XDG_CONFIG_HOME` directory, 135 | meaning if in your repository you have a folder named `config` or 136 | `.config` it'll translate those into the `$XDG_CONFIG_HOME` 137 | directory automatically. Similarly when using `dfm add` if inside your 138 | `$XDG_CONFIG_HOME` or $HOME/.configuration directories it'll add those to 139 | the repository appropriately. 140 | 141 | ### Skips relevant files 142 | 143 | dfm by default will skip multiple relevant files. 144 | 145 | - .git 146 | 147 | dfm will skip the .git directory so your `$HOME` directory isn't 148 | turned into a git repository. 149 | 150 | - .gitignore 151 | 152 | If you would like to store a global `.gitignore` file you can either omit the 153 | leading dot (so just `gitignore`) or name the global one `.ggitignore` and dfm 154 | will translate the name for you. Otherwise it assumes that `.gitignore` is the 155 | gitignore for the profile's repository and so skips it. 156 | 157 | - README 158 | 159 | Want to make a README for your dotfiles? Go ahead! As long as the file name 160 | starts with README dfm will ignore it. So `README.txt` `README.md` and 161 | `README.rst` or whatever other permutations you can dream up all work. 162 | 163 | - LICENSE 164 | 165 | You should put a LICENSE on all code you put on the internet and some dotfiles / 166 | configurations are actual code (See: Emacs). If you put a LICENSE in your 167 | profile dfm will respect you being a good open source citizen and not clutter your 168 | `$HOME` directory. 169 | 170 | - .dfm.yml 171 | 172 | This is a special dfm file used for hooks today and in the future for other ways 173 | to extend dfm. As such dfm doesn't put it in your `$HOME` directory. 174 | 175 | ### Custom mappings 176 | 177 | The above ignores are implemented as a dfm feature called 178 | Mappings. You can write your own mappings to either skip, skip based 179 | on platform or translate files to different locations than dfm would 180 | normally place them. You can read how to configure your own mappings 181 | in [Configuration](#configuration) 182 | 183 | ### Encrypted Dotfiles 184 | 185 | Using hooks and mappings you can integrate GPG with DFM to have an encrypted 186 | dotfiles repository. 187 | 188 | If you add the following `.dfm.yml` to your repository per the 189 | [Configuration](#configuration) documentation: 190 | 191 | ```yaml 192 | --- 193 | mappings: 194 | - match: '.*.gpg' 195 | skip: true 196 | 197 | hooks: 198 | before_sync: 199 | - interpreter: /bin/bash -c 200 | script: | 201 | echo "encrypting files..." 202 | for file in $(find . -not -name '*.gpg' -not -name '.dfm.yml' -not -name '.gitignore' -not -path './.git/*'); do 203 | echo "Encrypting $file to ${file/.gpg/}" 204 | gpg --batch --yes --encrypt ${file/.gpg/} 205 | done 206 | after_sync: 207 | - interpreter: /bin/bash -c 208 | script: | 209 | for file in $(git ls-files | grep -v .dfm.yml | grep -v .gitignore); do 210 | gpg --batch --yes --decrypt -o ${file/.gpg/} $file 211 | done 212 | ``` 213 | 214 | And the following `.gitignore` file: 215 | 216 | ``` 217 | * 218 | !*/ 219 | !.gitignore 220 | !.dfm.yml 221 | !*.gpg 222 | ``` 223 | 224 | Then when running `dfm sync` DFM will run the gpg command to encrypt all your 225 | files, then git will ignore all non-GPG encrypted files (due to the 226 | `.gitignore`), and after syncing DFM will decrypt all the GPG encrypted files. 227 | 228 | This all happens before linking, when you run `dfm link` DFM will ignore all gpg 229 | encrypted files due to the `mapping` configuration. It will then only link the 230 | unencrypted versions into your home directory. 231 | 232 | ## Installation 233 | 234 | ### Install from Release 235 | 236 | dfm is available on Github Releases and should be installed from there. 237 | 238 | The latest release is available 239 | [here](https://github.com/chasinglogic/dfm/releases). 240 | 241 | Download the archive that is appropriate for your platform and extract the 242 | binary into your `$PATH`. A common valid path location is `/usr/local/bin`. 243 | 244 | You can run these commands to automate this install (on most platforms, it does not always work): 245 | 246 | ``` 247 | platform=$(uname -s) 248 | arch=$(uname -m) 249 | # If you're running on an M1 Macbook run this: 250 | # arch="x86_64" 251 | download_url=$(curl -s https://api.github.com/repos/chasinglogic/dfm/releases/latest | grep "browser_download_url.*$arch.*${platform,,}" | cut -d : -f 2,3 | sed 's/"//g' | xargs) 252 | curl -L -o /tmp/dfm.tar.gz "$download_url" 253 | tar -C /tmp -xzvf /tmp/dfm.tar.gz 254 | mv $(find /$(readlink /tmp) -perm +111 -type f -name dfm 2>/dev/null) /usr/local/bin/ 255 | ``` 256 | ### Install from Source 257 | 258 | You will need a [rust](https://rustup.rs) compiler to build dfm from 259 | source. 260 | 261 | Clone the repository and run `./scripts/local_install.sh`: 262 | 263 | ```bash 264 | git clone https://github.com/chasinglogic/dfm 265 | cd dfm 266 | ./scripts/local_install.sh 267 | ``` 268 | 269 | > It's possible that for your system you will need to run the install 270 | > script with sudo. 271 | 272 | ## Usage 273 | 274 | ```text 275 | A dotfile manager written for pair programmers and lazy people. 276 | 277 | Examples on getting started with dfm are available at https://github.com/chasinglogic/dfm 278 | 279 | Usage: dfm 280 | 281 | Commands: 282 | where Prints the location of the current dotfile profile [aliases: w] 283 | status Print the git status of the current dotfile profile [aliases: st] 284 | git Run the given git command on the current profile [aliases: g] 285 | list List available dotfile profiles on this system [aliases: ls] 286 | link Create links for a profile [aliases: l] 287 | init Create a new profile [aliases: i] 288 | remove Remove a profile [aliases: rm] 289 | run-hook Run dfm hooks without using normal commands [aliases: rh] 290 | sync Sync your dotfiles [aliases: s] 291 | clone Use git clone to download an existing profile 292 | clean Clean dead symlinks. Will ignore symlinks unrelated to DFM. 293 | add Add files to the current dotfile profile 294 | gen-completions Generate shell completions and print them to stdout 295 | help Print this message or the help of the given subcommand(s) 296 | 297 | Options: 298 | -h, --help Print help 299 | -V, --version Print version 300 | ``` 301 | 302 | ## Quick start 303 | 304 | ### Quick start (Existing dotfiles repository) 305 | 306 | If you already have a dotfiles repository you can start by cloning it using the clone 307 | command. 308 | 309 | > SSH URLs will work as well. 310 | 311 | ```bash 312 | dfm clone https://github.com/chasinglogic/dotfiles 313 | ``` 314 | 315 | If you're using GitHub you can shortcut the domain: 316 | 317 | ```bash 318 | dfm clone chasinglogic/dotfiles 319 | ``` 320 | 321 | If you want to clone and link the dotfiles in one command: 322 | 323 | ```bash 324 | dfm clone --link chasinglogic/dotfiles 325 | ``` 326 | 327 | You may have to use `--overwrite` as well if you have existing non-symlinked 328 | versions of your dotfiles 329 | 330 | Once you have multiple profiles you can switch between them using `dfm link` 331 | 332 | ```bash 333 | dfm link some-other-profile 334 | ``` 335 | 336 | See the Usage Notes below for some quick info on what to expect from other dfm 337 | commands. 338 | 339 | ### Quick Start (No existing dotfiles repository) 340 | 341 | If you don't have a dotfiles repository the best place to start is with `dfm init` 342 | 343 | ```bash 344 | dfm init my-new-profile 345 | ``` 346 | 347 | Then run `dfm link` to set it as the active profile, this is also how you switch 348 | profiles 349 | 350 | ```bash 351 | dfm link my-new-profile 352 | ``` 353 | 354 | Once that's done you can start adding your dotfiles 355 | 356 | ```bash 357 | dfm add ~/.bashrc 358 | ``` 359 | 360 | Alternatively you can add multiple files at once 361 | 362 | ```bash 363 | dfm add ~/.bashrc ~/.vimrc ~/.vim ~/.emacs.d 364 | ``` 365 | 366 | Then create your dotfiles repository on GitHub. Instructions for how to do that can be 367 | found [here](https://docs.github.com/en/get-started/quickstart/create-a-repo). Once that's done 368 | get the "clone" URL for your new repository and set it as origin for the profile: 369 | 370 | **Note:** When creating the remote repository don't choose any options such as 371 | "initialize this repository with a README" otherwise git'll get cranky when you add 372 | the remote because of a recent git update and how it handles [unrelated 373 | histories](http://stackoverflow.com/questions/37937984/git-refusing-to-merge-unrelated-histories) 374 | if you do don't worry the linked post explains how to get past it. 375 | 376 | ```bash 377 | dfm git remote add origin 378 | ``` 379 | 380 | Then simply run `dfm sync` to sync your dotfiles to the remote 381 | ```bash 382 | dfm sync 383 | ``` 384 | 385 | Now you're done! 386 | 387 | ## Configuration 388 | 389 | dfm supports a `.dfm.yml` file in the root of your repository that 390 | changes dfm's behavior when syncing and linking your profile. This 391 | file will be ignored when doing a `dfm link` so won't end up in 392 | your home directory. The `.dfm.yml` can be used to configure these 393 | features: 394 | 395 | - [Modules](#modules) 396 | - [Mappings](#mappings) 397 | - [Hooks](#hooks) 398 | 399 | ### Modules 400 | 401 | Modules in dfm are sub profiles. They're git repositories that are cloned into a 402 | a special directory: `$XDG_CONFIG_HOME/dfm/modules`. They're shared across 403 | profiles so if two dotfile profiles have the same module they'll share that 404 | module. 405 | 406 | The syntax for defining a minimum module is as follows: 407 | 408 | ```yaml 409 | modules: 410 | - repository: git@github.com:chasinglogic/dotfiles 411 | ``` 412 | 413 | This would clone my dotfiles repository as a module into 414 | `$XDG_CONFIG_HOME/dfm/modules/chasinglogic`. If I wanted to use a unique name or 415 | some other folder name so it wouldn't be shared you can specify an additional 416 | option `name`: 417 | 418 | ```yaml 419 | modules: 420 | - repository: git@github.com:chasinglogic/dotfiles 421 | name: chasinglogic-dotfiles 422 | ``` 423 | 424 | Which would instead clone into 425 | `$XDG_CONFIG_HOME/dfm/modules/chasinglogic-dotfiles`. You can define multiple 426 | modules: 427 | 428 | ```yaml 429 | modules: 430 | - repository: git@github.com:chasinglogic/dotfiles 431 | name: chasinglogic-dotfiles 432 | - repository: git@github.com:lionize/dotfiles 433 | ``` 434 | 435 | Make sure that you specify a name if the resulting clone location as defined by 436 | git would conflict as we see here. Both of these would have been cloned into 437 | dotfiles which would cause the clone to fail for the second module if we didn't 438 | specify name for chasinglogic's dotfiles. 439 | 440 | An additional use for modules is that of a git repository you want to clone but not 441 | link. An example use would be for downloading 442 | [Spacemacs](https://github.com/syl20bnr/spacemacs) or any such community 443 | configuration like oh-my-zsh, etc. 444 | 445 | ```yaml 446 | modules: 447 | - repo: git@github.com:syl20bnr/spacemacs 448 | link: none 449 | pull_only: true 450 | location: ~/.emacs.d 451 | ``` 452 | 453 | Here we specify a few extra keys. There purpose should be self explanatory but 454 | if you're curious [below](#available-keys) is a detailed explanation of all keys 455 | that each module configuration supports. 456 | 457 | Modules work just like any other dfm profile so if a module you're 458 | pulling in has a `.dfm.yml` in it that will be loaded and executed 459 | accordingly. Including pulling down any modules it defines. 460 | 461 | #### Available keys 462 | 463 | - [repo](#repo) 464 | - [name](#name) 465 | - [location](#location) 466 | - [link](#link) 467 | - [pull\_only](#pull\_only) 468 | - [mappings](#mappings) 469 | - [clone\_flags](#clone\_flags) 470 | 471 | ##### repo 472 | 473 | Required, this is the git repository to clone for the module. 474 | 475 | ##### name 476 | 477 | This changes the cloned name. This only has an effect if location isn't 478 | provided. Normally a git repository would be cloned into 479 | `$XDG_CONFIG_HOME/dfm/modules` and the resulting folder would be named whatever 480 | git decides it should be based on the git URL. If this is provided it'll be 481 | cloned into the modules directory with the specified name. This is useful if 482 | multiple profiles use the same module. 483 | 484 | ##### location 485 | 486 | If provided module will be cloned into the specified location. You can use the 487 | `~` bash expansion here to represent `$HOME`. No other expansions are available. 488 | This option is useful for cloning community configurations like oh-my-zsh or 489 | spacemacs. 490 | 491 | ##### link 492 | 493 | Determines when to link the module. Link in this context means that it'll be 494 | treated like a normal dotfile profile, so all files will go through the same 495 | translation rules as a regular profile and be linked accordingly. Available 496 | values are `post`, `pre`, and `none`. `post` is the default and means that the 497 | module will be linked after the parent profile. "pre" means this will be linked 498 | before the parent profile, use this if for instance you want to use most files 499 | from this profile and override a few files with those from the parent file since 500 | dfm will overwrite the links with the last one found. "none" means the module is 501 | not a dotfiles profile and shouldn't be linked at all, an example being 502 | community configuration repositories like oh-my-zsh or spacemacs. 503 | 504 | ##### pull\_only 505 | 506 | If set to `true` won't attempt to push any changes. It's important to 507 | know that dfm always tries to push to origin master, so if you don't 508 | have write access to the repository or don't want it to automatically 509 | push to master then you should set this to true. This is useful for 510 | community configuration repositories. 511 | 512 | ##### mappings 513 | 514 | A list of file mappings as described below in [Mappings](#mappings). Modules do 515 | not inherit parent mappings, they do however inherit the default mappings as 516 | described in [Skips Relevant Files](#skips-relevant-files) 517 | 518 | ##### clone\_flags 519 | 520 | A list of strings that will be added to the `git clone` command when cloning the 521 | module. Useful if the module is using git submodules or otherwise needs 522 | specialised cloning behavior. An example would be: 523 | 524 | ```yaml 525 | - repository: https://github.com/akinomyoga/ble.sh 526 | clone_flags: ["--recursive", "--depth=1", "--shallow-submodules"] 527 | hooks: 528 | after_sync: 529 | - make -C ble.sh install PREFIX=~/.local 530 | ``` 531 | 532 | ### Mappings 533 | 534 | Mappings are a way of defining custom file locations. To understand 535 | mappings one must understand dfm's default link behavior: 536 | 537 | #### Default behavior 538 | 539 | For an example let's say you have a file named `my_config.txt` in your 540 | dotfile repository. dfm will try and translate that to a new location 541 | of `$HOME/.my_config.txt`. It'll then create a symlink at that location 542 | pointing to `my_config.txt` in your dotfile repository. 543 | 544 | #### Using mappings 545 | 546 | With mappings you can replace this behavior and make it so dfm will 547 | link `my_config` wherever you wish. This is useful if you need to 548 | store config files that are actually global. Such as configuration 549 | files that would go into `/etc/` or if you want to sync some files in 550 | your repo but not link them. 551 | 552 | Here is a simple example: 553 | 554 | ```yaml 555 | mappings: 556 | - match: .config/some-dir 557 | link_as_dir: true 558 | - match: my_global_etc_files 559 | target_dir: /etc/ 560 | - match: something_want_to_skip_but_sync 561 | skip: true 562 | - match: something_only_for_macos 563 | target_os: "Darwin" 564 | - match: some_file_for_mac_and_linux_only 565 | target_os: 566 | - "Linux" 567 | - "Darwin" 568 | - match: some_specific_translation_for_mac 569 | dest: ~/.mac_os_dotfile 570 | target_os: 571 | - "Darwin" 572 | ``` 573 | 574 | Here dfm uses the match as a regular expression to match the file 575 | paths in your dotfile repository. When it finds a path which matches 576 | the regular expression it adds an alternative linking behavior. For 577 | anything where `skip` is true it simply skips linking. For anything 578 | with `target_dir` that value will override `$HOME` when linking. For 579 | anything with a `target_os` value the file will only be linked if dfm 580 | is being run on the given os. 581 | 582 | ##### Link as Dir Mappings 583 | 584 | Above you can see a mapping using the `link_as_dir` option. When this is set to `true` 585 | for a mapping the `match:` value will be used as a directory relative to the root of the 586 | dotfile repo and will be linked as a directory. Normally DFM only links files, this can 587 | cause issues with some types of configuration where you regularly generate files like 588 | snippet tools. Consider the following dotfiles in a dotfile repository: 589 | 590 | ``` 591 | $REPO/.config/nvim 592 | ├── UltiSnips 593 | │   ├── gitcommit.snippets 594 | │   └── python.snippets 595 | ``` 596 | 597 | That would produce the following links in `$HOME/.config/nvim`: 598 | 599 | ``` 600 | $HOME/.config/nvim 601 | ├── UltiSnips 602 | │   ├── gitcommit.snippets -> $HOME/.config/dfm/profiles/chasinglogic/.config/nvim/UltiSnips/gitcommit.snippets 603 | │   └── python.snippets -> $HOME/.config/dfm/profiles/chasinglogic/.config/nvim/UltiSnips/python.snippets 604 | ``` 605 | 606 | Every time you used `:UltiSnipsEdit` to create a new snippet file type you'd have to 607 | then remember to manually move that into your dotfile repository and re-run `dfm link`. 608 | To solve this problem you can use the following mapping in your `.dfm.yml` you can 609 | instead link `UltiSnips` the directory instead of it's files: 610 | 611 | ``` 612 | mappings: 613 | - match: .config/nvim/UltiSnips 614 | link_as_dir: true 615 | ``` 616 | 617 | Now DFM links the `$HOME/.config/nvim/UltiSnips` directory to the 618 | `$REPO/.config/nvim/UltiSnips`: 619 | 620 | ``` 621 | $HOME/.config/nvim 622 | ├── UltiSnips -> $HOME/.config/dfm/profiles/chasinglogic/.config/nvim/UltiSnips 623 | ``` 624 | 625 | 626 | #### Available configuration 627 | 628 | Mappings support the following configuration options: 629 | 630 | - [match](#match) 631 | - [skip](#skip) 632 | - [dest](#dest) 633 | - [target\_dir](#target\_dir) 634 | - [target\_os](#target\_os) 635 | 636 | ##### match 637 | 638 | Match is a regular expression used to match the file path of any files 639 | in your dotfile repository. This is used to determine if the custom 640 | linking behavior for a file should be used. 641 | 642 | These are python style regular expressions and are matched using the 643 | [`re.findall`](https://docs.python.org/3/library/re.html#re.findall) 644 | method so are by default fuzzy matching. 645 | 646 | ##### skip 647 | 648 | If provided the file/s will not be linked. 649 | 650 | ##### dest 651 | 652 | The new full path to the file. This can be used to completely change a file's 653 | name or put it in a wholly new location. This is more explicity than 654 | `target_dir` and covers cases that `target_dir` is not suited for (for example 655 | if a file is a dotfile on one OS but not on another.) 656 | 657 | ##### target\_dir 658 | 659 | Where to link the file to. The `~` expansion for `$HOME` is supported 660 | here but no other expansions are available. It is worth noting that if 661 | you're using `~` in your target_dir then you should probably just 662 | create the directory structure in your git repo. 663 | 664 | ##### target\_os 665 | 666 | A string or list of strings matching the OS's to link this file 667 | on. A non-exhaustive list of common values are: `Linux`, `Darwin`, or 668 | `Windows`. This matches the string returned by [Python's 669 | `platform.system()` 670 | function.](https://docs.python.org/3/library/platform.html#platform.system) 671 | 672 | ### Hooks 673 | 674 | Hooks in dfm are used for those few extra tasks that you need to do whenever 675 | your dotfiles are synced or linked. 676 | 677 | An example from my personal dotfiles is running an Ansible playbook 678 | whenever I sync my dotfiles. To accomplish this I wrote an 679 | `after_sync` hook as follows: 680 | 681 | ```yaml 682 | hooks: 683 | after_sync: 684 | - ansible-playbook ansible/dev-mac.yml 685 | ``` 686 | 687 | Now whenever I sync my dotfiles Ansible will run my `dev-mac` playbook to make 688 | sure that my packages etc are also in sync! 689 | 690 | The hooks option is just a YAML map which supports the following keys: 691 | `after_link`, `before_link`, `after_sync`, and `before_sync`. The 692 | values of any of those keys is a YAML list of strings which will be 693 | executed in a shell via `/bin/sh -c '$YOUR COMMAND'`. An example would 694 | be: 695 | 696 | ```yaml 697 | hooks: 698 | after_link: 699 | - ls -l 700 | - whoami 701 | - echo "All done!" 702 | ``` 703 | 704 | All commands are ran with a working directory of your dotfile 705 | repository and the current process environment is passed down to the 706 | process so you can use `$HOME` etc environment variables in your 707 | commands. 708 | 709 | By default the comamnds will run with the interpreter `/bin/sh -c`. So the 710 | expanded comamnd line for the first hook above would be: 711 | 712 | ``` 713 | /bin/sh -c 'ls -l' 714 | ``` 715 | 716 | If you want to use a different interpreter you can use instead use this hook 717 | format: 718 | 719 | ``` 720 | hooks: 721 | after_link: 722 | - interpreter: python -c 723 | script: | 724 | print("hello world from Python") 725 | ``` 726 | 727 | You may want to do this in cases where you need complex logic (like that which 728 | should live in a Python script) or for example on Debian based systems which 729 | use dash instead of bash as the /bin/sh interpreter and so have a very limited 730 | expansion feature set. 731 | 732 | ## Contributing 733 | 734 | 1. Fork it! 735 | 2. Create your feature branch: `git checkout -b my-new-feature` 736 | 3. Commit your changes: `git commit -am 'Add some feature'` 737 | 4. Push to the branch: `git push origin my-new-feature` 738 | 5. :fire: Submit a pull request :D :fire: 739 | 740 | All pull requests should go to the develop branch not master. Thanks! 741 | 742 | ## License 743 | 744 | This code is distributed under the GNU General Public License 745 | 746 | ``` 747 | Copyright (C) 2018 Mathew Robinson 748 | 749 | This program is free software: you can redistribute it and/or modify 750 | it under the terms of the GNU General Public License as published by 751 | the Free Software Foundation, either version 3 of the License, or 752 | (at your option) any later version. 753 | 754 | This program is distributed in the hope that it will be useful, 755 | but WITHOUT ANY WARRANTY; without even the implied warranty of 756 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 757 | GNU General Public License for more details. 758 | 759 | You should have received a copy of the GNU General Public License 760 | along with this program. If not, see . 761 | ``` 762 | -------------------------------------------------------------------------------- /scripts/integration_tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function log() { 4 | if [[ $2 == "DEBUG" ]] && [[ "$DEBUG_TESTS" == "" ]]; then 5 | return 6 | fi 7 | echo "[$(date)]" $1 8 | } 9 | 10 | function list_dir() { 11 | ls -I .git -alR $1 12 | } 13 | 14 | function generate_git_config() { 15 | echo "[user] 16 | email = example@example.com 17 | name = DFM Tester 18 | " > $HOME/.gitconfig 19 | } 20 | 21 | function cleanup() { 22 | rm -rf $HOME_DIR 23 | rm -rf $CONFIG_DIR 24 | 25 | if [[ -n $1 ]]; then 26 | echo "EXITING WITH FAILURE" 27 | exit $1 28 | fi 29 | 30 | export HOME_DIR=$(mktemp -d) 31 | export DFM_CONFIG_DIR="$HOME_DIR/.config/dfm" 32 | export HOME=$HOME_DIR 33 | 34 | generate_git_config 35 | } 36 | 37 | function x() { 38 | cmd="$@" 39 | log "Running: $cmd" "DEBUG" 40 | stdoutfile=$(mktemp) 41 | stderrfile=$(mktemp) 42 | $cmd 1>$stdoutfile 2>$stderrfile 43 | if [[ $? != 0 ]]; then 44 | FAILED_CODE=$? 45 | log "Failed to run '$cmd'" 46 | cat $stdoutfile 47 | cat $stderrfile 48 | rm -f $stdoutfile $stderrfile 49 | cleanup $FAILED_CODE 50 | fi 51 | 52 | rm -f $stdoutfile $stderrfile 53 | } 54 | 55 | ############## 56 | # CLONE TEST # 57 | ############## 58 | function dfm_clone_test() { 59 | local DFM=$1 60 | shift; 61 | local PROFILE_NAME=$1 62 | shift; 63 | local PROFILE_REPOSITORY=$1 64 | 65 | log "Running clone tests..." "DEBUG" 66 | 67 | x $DFM_BIN clone --name $PROFILE_NAME $PROFILE_REPOSITORY 68 | x $DFM_BIN link $PROFILE_NAME 69 | 70 | if [ ! -d $DFM_CONFIG_DIR/profiles/integration ]; then 71 | log "Failed to clone integration profile! \$DFM_CONFIG_DIR contents:" 72 | ls -laR $DFM_CONFIG_DIR 73 | exit 1 74 | fi 75 | 76 | log "[PASS] Integration profile cloned" 77 | 78 | if [ ! -L $HOME/.dotfile ]; then 79 | log "Failed to link integration profile! \$HOME contents:" 80 | ls -laR $HOME 81 | exit 1 82 | fi 83 | 84 | log "[PASS] Integration profile linked" 85 | 86 | cleanup 87 | } 88 | 89 | function dfm_clone_and_link_test() { 90 | local DFM=$1 91 | shift; 92 | local PROFILE_NAME=$1 93 | shift; 94 | local PROFILE_REPOSITORY=$1 95 | 96 | log "Running clone tests..." "DEBUG" 97 | 98 | x $DFM_BIN clone --link --name $PROFILE_NAME $PROFILE_REPOSITORY 99 | 100 | if [ ! -d $DFM_CONFIG_DIR/profiles/integration ]; then 101 | log "Failed to clone integration profile! \$DFM_CONFIG_DIR contents:" 102 | ls -laR $DFM_CONFIG_DIR 103 | exit 1 104 | fi 105 | 106 | log "[PASS] (--link tests) Integration profile cloned" 107 | 108 | if [ ! -L $HOME/.dotfile ]; then 109 | log "Failed to link integration profile! \$HOME contents:" 110 | ls -laR $HOME 111 | exit 1 112 | fi 113 | 114 | log "[PASS] (--link tests) Integration profile linked" 115 | 116 | cleanup 117 | } 118 | 119 | ############# 120 | # INIT TEST # 121 | ############# 122 | function dfm_init_and_add_test() { 123 | local DFM=$1; 124 | 125 | log "Running init tests..." "DEBUG" 126 | 127 | x $DFM init integration-test 128 | x $DFM link integration-test 129 | 130 | if [ ! -d $DFM_CONFIG_DIR/profiles/integration-test/.git ]; then 131 | log "Failed to create git repository in \$DFM_CONFIG_DIR/profiles/integration-test. \$DFM_CONFIG_DIR contents:" 132 | ls -laR $DFM_CONFIG_DIR 133 | exit 1 134 | fi 135 | 136 | log "[PASS] Integration profile created" 137 | 138 | echo "# A fake dotfile" > $HOME/.dfm_dotfile 139 | 140 | x $DFM add $HOME/.dfm_dotfile 141 | 142 | if [ ! -L $HOME/.dfm_dotfile ]; then 143 | log "\$HOME/.dfm_dotfile is not a link. \$HOME contents:" 144 | list_dir $HOME 145 | log "\$DFM_CONFIG_DIR contents" 146 | list_dir $DFM_CONFIG_DIR 147 | exit 1 148 | fi 149 | 150 | log "[PASS] Added dotfile is now a symlink" 151 | 152 | if [ ! -f $DFM_CONFIG_DIR/profiles/integration-test/.dfm_dotfile ]; then 153 | log "\$DFM_CONFIG_DIR/profiles/integration-test/.dfm_dotfile is not a file. \$HOME contents:" 154 | list_dir $HOME 155 | log "\$DFM_CONFIG_DIR contents" 156 | list_dir $DFM_CONFIG_DIR 157 | exit 1 158 | fi 159 | 160 | log "[PASS] Added dotfile is in git repository" 161 | 162 | cleanup 163 | } 164 | 165 | DFM_BIN="${DFM_BIN:-dfm}" 166 | export PROFILE_REPOSITORY="https://github.com/chasinglogic/dfm_dotfile_test.git" 167 | export PROFILE_NAME="integration" 168 | export HOME_DIR=$(mktemp -d) 169 | export DFM_CONFIG_DIR="$HOME_DIR/.config/dfm" 170 | 171 | while getopts ":b:" opt; do 172 | case $opt in 173 | b) DFM_BIN="$OPTARG" ;; 174 | \?) echo "Invalid option: -$OPTARG" >&2 ; exit 1 ;; 175 | esac 176 | done 177 | 178 | mkdir -p $HOME_DIR 179 | export HOME=$HOME_DIR 180 | 181 | generate_git_config 182 | 183 | log "Using dfm binary: $DFM_BIN $($DFM_BIN --version)" 184 | dfm_clone_test $DFM_BIN $PROFILE_NAME $PROFILE_REPOSITORY 185 | dfm_clone_and_link_test $DFM_BIN $PROFILE_NAME $PROFILE_REPOSITORY 186 | dfm_init_and_add_test $DFM_BIN 187 | -------------------------------------------------------------------------------- /scripts/local_install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -o errexit 3 | set -o pipefail 4 | set -x 5 | 6 | INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" 7 | 8 | REPO=$(git rev-parse --show-toplevel) 9 | 10 | cd "$REPO" || exit 1 11 | 12 | cargo build --release 13 | 14 | if [[ -x $(which strip) ]]; then 15 | strip ./target/release/dfm 16 | fi 17 | 18 | mv ./target/release/dfm "$INSTALL_DIR/" 19 | -------------------------------------------------------------------------------- /src/cli/add.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod add; 2 | pub mod state; 3 | -------------------------------------------------------------------------------- /src/cli/state.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::fs::{self, File}; 3 | use std::io::{self, BufReader}; 4 | use std::path::{Path, PathBuf}; 5 | use std::process; 6 | 7 | use crate::profiles::Profile; 8 | 9 | #[derive(Debug, serde::Deserialize, serde::Serialize)] 10 | pub struct State { 11 | pub current_profile: String, 12 | } 13 | 14 | impl Default for State { 15 | fn default() -> Self { 16 | State { 17 | current_profile: "".to_string(), 18 | } 19 | } 20 | } 21 | 22 | impl State { 23 | pub fn load(fp: &Path) -> Result { 24 | let fh = File::open(fp)?; 25 | let buffer = BufReader::new(fh); 26 | Ok(serde_json::from_reader(buffer)?) 27 | } 28 | 29 | pub fn default_save(&self) -> Result<(), io::Error> { 30 | self.save(&state_file()) 31 | } 32 | 33 | pub fn save(&self, filepath: &Path) -> Result<(), io::Error> { 34 | if let Some(parent) = filepath.parent() { 35 | if !parent.exists() { 36 | fs::create_dir_all(parent).expect("Unable to create dfm directory!"); 37 | } 38 | } 39 | 40 | let file_handle = File::create(filepath)?; 41 | Ok(serde_json::to_writer(file_handle, self)?) 42 | } 43 | } 44 | 45 | pub fn home_dir() -> PathBuf { 46 | let home = env::var("HOME").unwrap_or("".to_string()); 47 | PathBuf::from(home) 48 | } 49 | 50 | pub fn dfm_dir() -> PathBuf { 51 | let mut path = home_dir(); 52 | path.push(".config"); 53 | path.push("dfm"); 54 | path 55 | } 56 | 57 | pub fn state_file() -> PathBuf { 58 | let mut state_fp = dfm_dir(); 59 | state_fp.push("state.json"); 60 | state_fp 61 | } 62 | 63 | pub fn profiles_dir() -> PathBuf { 64 | let mut path = dfm_dir(); 65 | path.push("profiles"); 66 | if !path.exists() { 67 | fs::create_dir_all(&path).expect("Unable to create profiles directory!"); 68 | } 69 | 70 | path 71 | } 72 | 73 | pub fn modules_dir() -> PathBuf { 74 | let mut path = dfm_dir(); 75 | path.push("modules"); 76 | if !path.exists() { 77 | fs::create_dir_all(&path).expect("Unable to create modules directory!"); 78 | } 79 | 80 | path 81 | } 82 | 83 | pub fn load_profile(name: &str) -> Profile { 84 | let mut path = profiles_dir(); 85 | path.push(name); 86 | Profile::load(&path) 87 | } 88 | 89 | pub fn force_available(profile: Option) -> Profile { 90 | match profile { 91 | None => { 92 | eprintln!("No profile is currently loaded!"); 93 | process::exit(1); 94 | } 95 | Some(p) => p, 96 | } 97 | } 98 | 99 | pub fn load_or_default() -> State { 100 | let state_fp = state_file(); 101 | match State::load(&state_fp) { 102 | Ok(state) => state, 103 | Err(err) => match err.kind() { 104 | io::ErrorKind::NotFound => State::default(), 105 | _ => panic!("{}", err), 106 | }, 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod cli; 2 | mod profiles; 3 | 4 | use crate::cli::state::{force_available, profiles_dir}; 5 | 6 | use std::{ 7 | env, fs, io, 8 | path::Path, 9 | process::{self, Command}, 10 | }; 11 | 12 | use clap::{command, crate_version, CommandFactory, Parser, Subcommand, ValueEnum}; 13 | use clap_complete::{generate, Shell}; 14 | use profiles::Profile; 15 | use walkdir::WalkDir; 16 | 17 | #[derive(Debug, Parser)] 18 | #[command( 19 | name = "dfm", 20 | about = "A dotfile manager written for pair programmers and lazy people. 21 | 22 | Examples on getting started with dfm are available at https://github.com/chasinglogic/dfm", 23 | version = crate_version!(), 24 | )] 25 | struct Cli { 26 | #[command(subcommand)] 27 | command: Commands, 28 | } 29 | 30 | #[derive(Debug, Subcommand)] 31 | enum Commands { 32 | #[command( 33 | visible_alias = "w", 34 | about = "Prints the location of the current dotfile profile" 35 | )] 36 | Where, 37 | #[command( 38 | visible_alias = "st", 39 | about = "Print the git status of the current dotfile profile" 40 | )] 41 | Status, 42 | #[command( 43 | visible_alias = "g", 44 | about = "Run the given git command on the current profile" 45 | )] 46 | Git { 47 | #[arg(trailing_var_arg = true, allow_hyphen_values = true)] 48 | args: Vec, 49 | }, 50 | #[command( 51 | visible_alias = "ls", 52 | about = "List available dotfile profiles on this system" 53 | )] 54 | List, 55 | #[command( 56 | visible_alias = "l", 57 | about = "Create links for a profile", 58 | long_about = "Creates symlinks in HOME for a dotfile Profile and makes it the active profile" 59 | )] 60 | Link { 61 | // New profile to switch to and link 62 | #[arg( 63 | default_value_t, 64 | help = "The profile name to link, if none given relinks the current profile" 65 | )] 66 | profile_name: String, 67 | #[arg( 68 | default_value_t, 69 | short, 70 | long, 71 | help = "If provided dfm will delete files and directories which exist at the target \ 72 | link locations. DO NOT USE THIS IF YOU ARE UNSURE AS IT WILL RESULT IN DATA LOSS" 73 | )] 74 | overwrite: bool, 75 | }, 76 | #[command(visible_alias = "i", about = "Create a new profile")] 77 | Init { 78 | #[arg(required = true, help = "Name of the profile to create")] 79 | profile_name: String, 80 | }, 81 | #[command(visible_alias = "rm", about = "Remove a profile")] 82 | Remove { 83 | #[arg(required = true, help = "Name of the profile to remove")] 84 | profile_name: String, 85 | }, 86 | #[command( 87 | visible_alias = "rh", 88 | about = "Run dfm hooks without using normal commands", 89 | long_about = "Runs a hook without the need to invoke the side effects of a dfm command" 90 | )] 91 | RunHook { 92 | #[arg(required = true)] 93 | hook_name: String, 94 | }, 95 | #[command(visible_alias = "s", about = "Sync your dotfiles")] 96 | Sync { 97 | #[arg( 98 | default_value_t, 99 | short, 100 | long, 101 | help = "Use the given message as the commit message" 102 | )] 103 | message: String, 104 | }, 105 | #[command(about = "Use git clone to download an existing profile")] 106 | Clone { 107 | #[arg(required = true)] 108 | url: String, 109 | #[arg( 110 | default_value_t, 111 | short, 112 | long, 113 | help = "Name of the profile to create, defaults to the basename of " 114 | )] 115 | name: String, 116 | #[arg( 117 | default_value_t, 118 | short, 119 | long, 120 | help = "If provided the profile will be immediately linked" 121 | )] 122 | link: bool, 123 | #[arg( 124 | default_value_t, 125 | short, 126 | long, 127 | help = "If provided dfm will delete files and directories which exist at the target \ 128 | link locations. DO NOT USE THIS IF YOU ARE UNSURE AS IT WILL RESULT IN DATA LOSS" 129 | )] 130 | overwrite: bool, 131 | }, 132 | #[command(about = "Clean dead symlinks. Will ignore symlinks unrelated to DFM.")] 133 | Clean, 134 | #[command( 135 | about = "Add files to the current dotfile profile", 136 | long_about = "Add files to the current dotfile profile" 137 | )] 138 | Add { 139 | #[arg(required = true)] 140 | files: Vec, 141 | }, 142 | #[command(about = "Generate shell completions and print them to stdout")] 143 | GenCompletions { 144 | #[arg(required = true, help = "The shell to generate completions for.")] 145 | shell: String, 146 | }, 147 | } 148 | 149 | fn main() { 150 | env_logger::builder().format_timestamp(None).init(); 151 | 152 | let args = Cli::parse(); 153 | let mut state = cli::state::load_or_default(); 154 | 155 | let current_profile: Option = if !state.current_profile.is_empty() { 156 | Some(cli::state::load_profile(&state.current_profile)) 157 | } else { 158 | None 159 | }; 160 | 161 | match args.command { 162 | Commands::Where => println!( 163 | "{}", 164 | force_available(current_profile) 165 | .get_location() 166 | .to_string_lossy() 167 | ), 168 | Commands::List => { 169 | for entry in WalkDir::new(profiles_dir()).min_depth(1).max_depth(1) { 170 | println!("{}", entry.unwrap().file_name().to_string_lossy()); 171 | } 172 | } 173 | Commands::Git { args } => force_available(current_profile) 174 | .git(args) 175 | .map(|_| ()) 176 | .expect("Unable to run git on the current profile!"), 177 | Commands::RunHook { hook_name } => force_available(current_profile) 178 | .run_hook(&hook_name) 179 | .expect("Unable to run hook!"), 180 | Commands::Link { 181 | profile_name, 182 | overwrite, 183 | } => { 184 | let new_profile = if !profile_name.is_empty() { 185 | cli::state::load_profile(&profile_name) 186 | } else { 187 | force_available(current_profile) 188 | }; 189 | if let Err(e) = new_profile.link(overwrite) { 190 | eprintln!("Error linking profile: {}", e); 191 | process::exit(10); 192 | }; 193 | state.current_profile = new_profile.name(); 194 | } 195 | Commands::Sync { message } => { 196 | let profile = force_available(current_profile); 197 | profile 198 | .sync_with_message(&message) 199 | .expect("Unable to sync all profiles!"); 200 | } 201 | Commands::Init { profile_name } => { 202 | let mut path = profiles_dir(); 203 | path.push(&profile_name); 204 | if path.exists() { 205 | eprintln!( 206 | "Unable to create profile as {} already exists!", 207 | path.to_string_lossy() 208 | ); 209 | process::exit(1); 210 | } 211 | 212 | fs::create_dir_all(&path).expect("Unable to create profile directory!"); 213 | let new_profile = Profile::load(&path); 214 | new_profile.init().expect("Error initialising profile!"); 215 | } 216 | Commands::Clone { 217 | url, 218 | name, 219 | link, 220 | overwrite, 221 | } => { 222 | let mut work_dir = profiles_dir(); 223 | let mut args = vec!["clone", &url]; 224 | let profile_name = if !name.is_empty() { 225 | name 226 | } else { 227 | url.clone() 228 | .split('/') 229 | .last() 230 | .expect("Unable to parse url!") 231 | .to_string() 232 | }; 233 | 234 | args.push(&profile_name); 235 | 236 | Command::new("git") 237 | .args(args) 238 | .current_dir(&work_dir) 239 | .spawn() 240 | .expect("Error starting git!") 241 | .wait() 242 | .expect("Error cloning repository!"); 243 | 244 | work_dir.push(&profile_name); 245 | 246 | let profile = Profile::load(&work_dir); 247 | state.current_profile = profile.name(); 248 | 249 | if link { 250 | profile.link(overwrite).expect("Error linking profile!"); 251 | } 252 | } 253 | Commands::Remove { profile_name } => { 254 | let mut path = profiles_dir(); 255 | path.push(&profile_name); 256 | if !path.exists() { 257 | eprintln!("No profile with exists at path: {}", path.to_string_lossy()); 258 | process::exit(1); 259 | } 260 | 261 | if !path.is_dir() { 262 | eprintln!("Profile exists but is not a directory!"); 263 | process::exit(1); 264 | } 265 | 266 | fs::remove_dir_all(&path).expect("Unable to remove profile directory!"); 267 | println!("Profile {} successfully removed.", profile_name); 268 | } 269 | Commands::Status => force_available(current_profile) 270 | .status() 271 | .map(|_| ()) 272 | .expect("Unexpected error running git!"), 273 | Commands::Clean => { 274 | let home = cli::state::home_dir(); 275 | let walker = WalkDir::new(&home).into_iter().filter_entry(|entry| { 276 | // Git repos and node_modules have tons of files and are 277 | // unlikely to contain dotfiles so this speeds thing up 278 | // significantly. 279 | entry.file_name() != ".git" && entry.file_name() != "node_modules" 280 | }); 281 | let prefix_path = cli::state::dfm_dir(); 282 | 283 | for possible_entry in walker { 284 | if possible_entry.is_err() { 285 | continue; 286 | } 287 | 288 | let entry = possible_entry.unwrap(); 289 | let path = entry.path(); 290 | if !path.is_symlink() { 291 | continue; 292 | } 293 | 294 | let target = match path.read_link() { 295 | Ok(p) => p, 296 | Err(_) => continue, 297 | }; 298 | 299 | // If it's not a DFM related symlink ignore it. 300 | if !target.starts_with(&prefix_path) { 301 | continue; 302 | } 303 | 304 | let printable_path = path.to_string_lossy(); 305 | println!("Checking {}", printable_path); 306 | let file_exists = target.exists(); 307 | if !file_exists { 308 | println!("Link {} is dead removing.", printable_path); 309 | fs::remove_file(path) 310 | .unwrap_or_else(|_| panic!("Unable to remove file: {}", printable_path)); 311 | } 312 | } 313 | } 314 | Commands::Add { files } => { 315 | let profile = force_available(current_profile); 316 | let profile_root = profile.get_location(); 317 | 318 | for file in files { 319 | // Get the absolute path of the file so it has $HOME as the 320 | // prefix. 321 | let path = Path::new(&file) 322 | .canonicalize() 323 | .unwrap_or_else(|_| panic!("Unable to find file: {}", &file)); 324 | 325 | let home = cli::state::home_dir() 326 | .canonicalize() 327 | .expect("Unable to canonicalize home!"); 328 | 329 | // Make the path relative to the home directory 330 | let relative_path = match path.strip_prefix(&home) { 331 | Ok(p) => p, 332 | Err(_) => { 333 | eprintln!("File {} is not in your home directory! If you have a mapping please add it manually.", &file); 334 | process::exit(1); 335 | } 336 | }; 337 | 338 | // Join the relative directory to the profile root 339 | let mut target_path = profile_root.clone(); 340 | target_path.push(relative_path); 341 | 342 | let parent = target_path.parent().unwrap(); 343 | if parent != profile_root { 344 | fs::create_dir_all(parent).expect("Unable to create preceding directories!"); 345 | } 346 | 347 | // Move the file / directory into the profile root 348 | fs::rename(path, target_path) 349 | .unwrap_or_else(|_| panic!("Unable to move file: {}", &file)); 350 | } 351 | 352 | // Link the profile to create symlinks where files were before. 353 | profile.link(false).expect("Unable to link profile!"); 354 | } 355 | Commands::GenCompletions { shell } => match Shell::from_str(&shell, true) { 356 | Ok(generator) => { 357 | eprintln!("Generating completion file for {}...", generator); 358 | let cmd = Cli::command(); 359 | generate( 360 | generator, 361 | &mut cmd.clone(), 362 | cmd.get_name().to_string(), 363 | &mut io::stdout(), 364 | ); 365 | } 366 | Err(failed) => { 367 | eprintln!("{} is not a known shell.", failed); 368 | process::exit(1); 369 | } 370 | }, 371 | } 372 | 373 | state.default_save().expect("Unable to save state!"); 374 | } 375 | -------------------------------------------------------------------------------- /src/profiles/config.rs: -------------------------------------------------------------------------------- 1 | use std::{fs::File, io::BufReader, path::Path}; 2 | 3 | use super::hooks::Hooks; 4 | use super::mapping::Mapping; 5 | 6 | #[derive(Default, Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] 7 | #[serde(rename_all = "snake_case")] 8 | pub enum LinkMode { 9 | Pre, 10 | #[default] 11 | Post, 12 | None, 13 | } 14 | 15 | fn default_off() -> bool { 16 | false 17 | } 18 | 19 | #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] 20 | pub struct DFMConfig { 21 | #[serde(default, alias = "repository")] 22 | pub repo: String, 23 | #[serde(default)] 24 | pub location: String, 25 | #[serde(default = "Hooks::new")] 26 | pub hooks: Hooks, 27 | 28 | #[serde(default)] 29 | pub clone_flags: Vec, 30 | 31 | #[serde(default = "default_off")] 32 | pub prompt_for_commit_message: bool, 33 | #[serde(default = "default_off")] 34 | pub pull_only: bool, 35 | #[serde(default)] 36 | pub link: LinkMode, 37 | #[serde(default = "Vec::new")] 38 | pub modules: Vec, 39 | pub mappings: Option>, 40 | } 41 | 42 | impl Default for DFMConfig { 43 | fn default() -> Self { 44 | DFMConfig { 45 | prompt_for_commit_message: false, 46 | pull_only: false, 47 | link: LinkMode::default(), 48 | repo: "".to_string(), 49 | location: "".to_string(), 50 | hooks: Hooks::new(), 51 | modules: Vec::new(), 52 | mappings: None, 53 | clone_flags: Vec::new(), 54 | } 55 | } 56 | } 57 | 58 | impl DFMConfig { 59 | pub fn load(file: &Path) -> DFMConfig { 60 | let fh = File::open(file).unwrap_or_else(|_| { 61 | panic!( 62 | "Unexpected error reading {}", 63 | file.to_str().unwrap_or(".dfm.yml") 64 | ) 65 | }); 66 | let reader = BufReader::new(fh); 67 | let mut config: DFMConfig = serde_yaml::from_reader(reader).expect("Malformed .dfm.yml"); 68 | if config.location.is_empty() { 69 | config.location = file 70 | .parent() 71 | .expect("Unexpected error getting profile location!") 72 | .to_str() 73 | .expect("Unexpected error turning profile location to a string!") 74 | .to_string(); 75 | } 76 | 77 | for module in &mut config.modules { 78 | module.expand_module(); 79 | } 80 | 81 | config 82 | } 83 | 84 | fn expand_module(&mut self) { 85 | if self.location.starts_with('~') { 86 | self.location = shellexpand::tilde(&self.location).to_string(); 87 | } 88 | 89 | if self.location.is_empty() { 90 | let name = self 91 | .repo 92 | .split('/') 93 | .last() 94 | .expect("A module must define a repository!") 95 | .replace(".git", ""); 96 | 97 | let mut module_dir = crate::cli::state::modules_dir(); 98 | module_dir.push(name); 99 | 100 | self.location = module_dir.to_string_lossy().to_string(); 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/profiles/hooks.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, io, path::Path, process::Command}; 2 | 3 | use log::debug; 4 | 5 | #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] 6 | pub struct HookDefinition { 7 | interpreter: String, 8 | script: String, 9 | } 10 | 11 | #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] 12 | #[serde(untagged)] 13 | pub enum Hook { 14 | String(String), 15 | HookDefinition(HookDefinition), 16 | } 17 | 18 | #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] 19 | pub struct Hooks(HashMap>); 20 | 21 | impl Hooks { 22 | pub fn new() -> Hooks { 23 | Hooks(HashMap::new()) 24 | } 25 | 26 | pub fn run_hook(&self, name: &str, working_directory: &Path) -> Result<(), io::Error> { 27 | debug!("running hook {}", name); 28 | 29 | match self.0.get(name) { 30 | Some(hooks) => { 31 | for hook in hooks { 32 | let (interpreter_command, script): (&str, &str) = match hook { 33 | Hook::String(script) => ("sh -c", script.as_ref()), 34 | Hook::HookDefinition(HookDefinition { 35 | interpreter, 36 | script, 37 | }) => (interpreter.as_ref(), script.as_ref()), 38 | }; 39 | 40 | debug!("hook: {} {}", interpreter_command, script); 41 | 42 | let mut argv = shlex::split(interpreter_command).ok_or_else(|| { 43 | io::Error::new( 44 | io::ErrorKind::InvalidInput, 45 | format!("malformed interpreter: {}", &interpreter_command), 46 | ) 47 | })?; 48 | argv.push(script.to_string()); 49 | 50 | let shell = argv 51 | .drain(0..1) 52 | .next() 53 | .expect("Unable to determine interpreter!"); 54 | 55 | Command::new(shell) 56 | .args(&argv) 57 | .current_dir(working_directory) 58 | .spawn() 59 | .expect("Unable to start shell!") 60 | .wait()?; 61 | } 62 | 63 | Ok(()) 64 | } 65 | None => Ok(()), 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/profiles/mapping.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | 3 | #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] 4 | pub enum OS { 5 | Linux, 6 | Darwin, 7 | Windows, 8 | } 9 | 10 | #[derive(Default, Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] 11 | #[serde(untagged)] 12 | pub enum TargetOS { 13 | String(OS), 14 | Vec(Vec), 15 | #[default] 16 | All, 17 | } 18 | 19 | #[cfg(target_os = "linux")] 20 | const CURRENT_OS: OS = OS::Linux; 21 | #[cfg(target_os = "macos")] 22 | const CURRENT_OS: OS = OS::Darwin; 23 | #[cfg(target_os = "windows")] 24 | const CURRENT_OS: OS = OS::Windows; 25 | 26 | impl TargetOS { 27 | fn is_this_os(target: &TargetOS) -> bool { 28 | match target { 29 | &TargetOS::All => true, 30 | TargetOS::Vec(targets) => targets.iter().any(|t| *t == CURRENT_OS), 31 | TargetOS::String(desired) => *desired == CURRENT_OS, 32 | } 33 | } 34 | } 35 | 36 | fn default_off() -> bool { 37 | false 38 | } 39 | 40 | #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] 41 | pub struct Mapping { 42 | #[serde(rename = "match", with = "serde_regex")] 43 | term: Regex, 44 | #[serde(default = "default_off")] 45 | link_as_dir: bool, 46 | #[serde(default = "default_off")] 47 | skip: bool, 48 | #[serde(default)] 49 | target_os: TargetOS, 50 | #[serde(default)] 51 | dest: String, 52 | #[serde(default)] 53 | target_dir: String, 54 | } 55 | 56 | impl Mapping { 57 | fn new(term: &str) -> Mapping { 58 | Mapping { 59 | term: Regex::new(term).expect("Unable to compile regex!"), 60 | link_as_dir: false, 61 | skip: false, 62 | target_os: TargetOS::All, 63 | dest: "".to_string(), 64 | target_dir: "".to_string(), 65 | } 66 | } 67 | 68 | fn skip(term: &str) -> Mapping { 69 | let mut mapping = Mapping::new(term); 70 | mapping.skip = true; 71 | mapping 72 | } 73 | 74 | fn does_match(&self, path: &str) -> bool { 75 | if self.term.is_match(path) { 76 | return TargetOS::is_this_os(&self.target_os); 77 | } 78 | 79 | false 80 | } 81 | } 82 | 83 | #[derive(Default, Debug, Clone, PartialEq, Eq)] 84 | pub enum MapAction { 85 | NewDest(String), 86 | NewTargetDir(String), 87 | LinkAsDir, 88 | Skip, 89 | #[default] 90 | None, 91 | } 92 | 93 | impl From for MapAction { 94 | fn from(mapping: Mapping) -> MapAction { 95 | MapAction::from(&mapping) 96 | } 97 | } 98 | 99 | impl From<&Mapping> for MapAction { 100 | fn from(mapping: &Mapping) -> MapAction { 101 | if mapping.skip { 102 | return MapAction::Skip; 103 | } 104 | 105 | if !mapping.dest.is_empty() { 106 | return MapAction::NewDest(shellexpand::tilde(mapping.dest.as_str()).into_owned()); 107 | } 108 | 109 | if !mapping.target_dir.is_empty() { 110 | return MapAction::NewTargetDir( 111 | shellexpand::tilde(mapping.target_dir.as_str()).into_owned(), 112 | ); 113 | } 114 | 115 | if mapping.link_as_dir { 116 | return MapAction::LinkAsDir; 117 | } 118 | 119 | MapAction::default() 120 | } 121 | } 122 | 123 | pub struct Mapper { 124 | mappings: Vec, 125 | } 126 | 127 | impl From> for Mapper { 128 | fn from(mappings: Vec) -> Mapper { 129 | Mapper { mappings } 130 | } 131 | } 132 | 133 | impl From>> for Mapper { 134 | fn from(mappings: Option>) -> Mapper { 135 | let mut configured = mappings.unwrap_or_default(); 136 | 137 | let default_mappings = vec![ 138 | Mapping::skip("^README.[a-z]+$"), 139 | Mapping::skip("^LICENSE$"), 140 | Mapping::skip("^\\.gitignore$"), 141 | Mapping::skip("^\\.git$"), 142 | Mapping::skip("^\\.dfm\\.yml"), 143 | ]; 144 | configured.extend(default_mappings); 145 | 146 | Mapper::from(configured) 147 | } 148 | } 149 | 150 | impl Mapper { 151 | pub fn get_mapped_action(&self, relative_path: &str) -> MapAction { 152 | for mapping in &self.mappings { 153 | if mapping.does_match(relative_path) { 154 | return MapAction::from(mapping); 155 | } 156 | } 157 | 158 | MapAction::None 159 | } 160 | } 161 | 162 | #[cfg(test)] 163 | mod test { 164 | use super::*; 165 | 166 | #[test] 167 | fn test_skip_map_action_from_mapping() { 168 | assert_eq!(MapAction::Skip, MapAction::from(Mapping::skip("README.*"))) 169 | } 170 | 171 | #[test] 172 | fn test_link_as_dir_map_action_from_mapping() { 173 | let config = r#"match: .*snippets.* 174 | link_as_dir: true"#; 175 | let mapping: Mapping = serde_yaml::from_str(config).expect("invalid yaml config in test!"); 176 | assert_eq!(MapAction::LinkAsDir, MapAction::from(mapping)) 177 | } 178 | 179 | #[test] 180 | fn test_new_dest_map_action_from_mapping() { 181 | let config = r#"match: LICENSE 182 | dest: /some/new/path.txt"#; 183 | let mapping: Mapping = serde_yaml::from_str(config).expect("invalid yaml config in test!"); 184 | assert_eq!( 185 | MapAction::NewDest("/some/new/path.txt".to_string()), 186 | MapAction::from(mapping) 187 | ) 188 | } 189 | 190 | #[test] 191 | fn test_new_target_dir_map_action_from_mapping() { 192 | let config = r#"match: LICENSE 193 | target_dir: /some/new/"#; 194 | let mapping: Mapping = serde_yaml::from_str(config).expect("invalid yaml config in test!"); 195 | assert_eq!( 196 | MapAction::NewTargetDir("/some/new/".to_string()), 197 | MapAction::from(mapping) 198 | ) 199 | } 200 | 201 | #[test] 202 | fn test_new_dest_map_action_expands_tilde() { 203 | let config = r#"match: LICENSE 204 | dest: ~/.LICENSE.txt"#; 205 | let mapping: Mapping = serde_yaml::from_str(config).expect("invalid yaml config in test!"); 206 | let action = MapAction::from(mapping); 207 | 208 | match action { 209 | MapAction::NewDest(value) => { 210 | assert_ne!(value, "~/.LICENSE.txt"); 211 | assert!(value.starts_with("/")); 212 | } 213 | _ => panic!("Reached what should be an unreachable path!"), 214 | } 215 | } 216 | 217 | #[test] 218 | fn test_new_target_dir_map_action_expands_tilde() { 219 | let config = r#"match: LICENSE 220 | target_dir: ~/some/subfolder"#; 221 | let mapping: Mapping = serde_yaml::from_str(config).expect("invalid yaml config in test!"); 222 | let action = MapAction::from(mapping); 223 | 224 | match action { 225 | MapAction::NewTargetDir(value) => { 226 | assert_ne!(value, "~/some/subfolder"); 227 | assert!(value.starts_with("/")); 228 | } 229 | _ => panic!("Reached what should be an unreachable path!"), 230 | } 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/profiles/mod.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | mod hooks; 3 | mod mapping; 4 | mod profile; 5 | 6 | pub use profile::Profile; 7 | -------------------------------------------------------------------------------- /src/profiles/profile.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env, 3 | ffi::OsStr, 4 | fs::{self, File}, 5 | io::{self, Write}, 6 | os, 7 | path::{Path, PathBuf}, 8 | process::{Command, ExitStatus}, 9 | str::FromStr, 10 | }; 11 | 12 | use super::config::{DFMConfig, LinkMode}; 13 | use crate::profiles::mapping::{MapAction, Mapper}; 14 | 15 | use log::debug; 16 | use rustyline::error::ReadlineError; 17 | use rustyline::DefaultEditor; 18 | use walkdir::{DirEntry, WalkDir}; 19 | 20 | #[derive(Debug)] 21 | pub struct Profile { 22 | config: DFMConfig, 23 | 24 | location: PathBuf, 25 | modules: Vec, 26 | } 27 | 28 | impl Default for Profile { 29 | fn default() -> Self { 30 | Profile { 31 | config: DFMConfig::default(), 32 | location: PathBuf::new(), 33 | modules: Vec::new(), 34 | } 35 | } 36 | } 37 | 38 | type GitResult = Result; 39 | 40 | fn is_dotfile(entry: &DirEntry) -> bool { 41 | let filename = entry.file_name().to_str().unwrap_or(""); 42 | // .git files and .dfm.yml are not dotfiles so should be ignored. 43 | let is_sys_file = filename == ".dfm.yml" || filename == ".git"; 44 | !is_sys_file 45 | } 46 | 47 | // Should return an error 48 | fn remove_if_able(path: &Path, force_remove: bool) -> Option { 49 | if path.exists() && !path.is_symlink() && !force_remove { 50 | return Some(io::Error::new( 51 | io::ErrorKind::AlreadyExists, 52 | format!( 53 | "{}: file exists and is not a symlink, cowardly refusing to remove.", 54 | path.to_string_lossy() 55 | ), 56 | )); 57 | } 58 | 59 | if !path.exists() { 60 | return None; 61 | } 62 | 63 | if path.is_dir() { 64 | fs::remove_dir_all(path).err() 65 | } else { 66 | fs::remove_file(path).err() 67 | } 68 | } 69 | 70 | impl Profile { 71 | pub fn load(directory: &Path) -> Profile { 72 | let path = if directory.starts_with("~") { 73 | let expanded = shellexpand::tilde(directory.to_str().expect("Invalid directory!")); 74 | PathBuf::from_str(&expanded).expect("Invalid profile directory!") 75 | } else { 76 | directory.to_path_buf().clone() 77 | }; 78 | let dotdfm = path.join(".dfm.yml"); 79 | if dotdfm.exists() { 80 | let config = DFMConfig::load(&dotdfm); 81 | return Profile::from_config(config); 82 | } 83 | 84 | let mut profile = Profile::default(); 85 | profile.config.location = path.to_string_lossy().to_string(); 86 | profile.location = path; 87 | profile 88 | } 89 | 90 | pub fn from_config(config: DFMConfig) -> Profile { 91 | let modules: Vec = config 92 | .modules 93 | .iter() 94 | .map(Profile::from_config_ref) 95 | .collect(); 96 | 97 | for module in modules.iter() { 98 | if !module.get_location().exists() { 99 | module.download().expect("unable to clone module"); 100 | } 101 | } 102 | 103 | let location = PathBuf::from_str(&config.location) 104 | .expect("Unable to convert config location into a path!"); 105 | 106 | Profile { 107 | config, 108 | location, 109 | modules, 110 | } 111 | } 112 | 113 | fn from_config_ref(config: &DFMConfig) -> Profile { 114 | Profile::from_config(config.clone()) 115 | } 116 | 117 | fn download(&self) -> Result<(), io::Error> { 118 | let mut args = vec!["clone"]; 119 | if !self.config.clone_flags.is_empty() { 120 | args.extend(self.config.clone_flags.iter().map(|s| s.as_str())); 121 | } 122 | 123 | args.push(&self.config.repo); 124 | 125 | let location = self.get_location(); 126 | args.push(location.to_str().expect("Unexpected error!")); 127 | 128 | debug!("running: git {:?}", args); 129 | 130 | Command::new("git") 131 | .args(args) 132 | .spawn() 133 | .expect("Unable to start git clone!") 134 | .wait() 135 | .map_err(|err| { 136 | io::Error::new( 137 | io::ErrorKind::InvalidData, 138 | format!("Unable to clone module! {} {}", self.config.repo, err), 139 | ) 140 | })?; 141 | 142 | self.run_hook("after_sync") 143 | } 144 | 145 | pub fn name(&self) -> String { 146 | match self.location.file_name() { 147 | None => "".to_string(), 148 | Some(basename) => basename.to_string_lossy().to_string(), 149 | } 150 | } 151 | 152 | pub fn is_dirty(&self) -> bool { 153 | let mut proc = Command::new("git"); 154 | proc.args(["status", "--porcelain"]); 155 | proc.current_dir(&self.location); 156 | 157 | match proc.output() { 158 | Ok(output) => output.stdout != "".as_bytes(), 159 | Err(_) => false, 160 | } 161 | } 162 | 163 | pub fn has_origin(&self) -> bool { 164 | let mut proc = Command::new("git"); 165 | proc.args(["remote", "-v"]); 166 | proc.current_dir(&self.location); 167 | 168 | match proc.output() { 169 | Ok(output) => { 170 | let remotes = String::from_utf8(output.stdout).unwrap_or("".to_string()); 171 | remotes.contains("origin") 172 | } 173 | Err(_) => false, 174 | } 175 | } 176 | 177 | pub fn branch_name(&self) -> String { 178 | let mut proc = Command::new("git"); 179 | proc.args(["rev-parse", "--abbrev-ref", "HEAD"]); 180 | proc.current_dir(&self.location); 181 | 182 | match proc.output() { 183 | Ok(output) => { 184 | let branch = String::from_utf8(output.stdout).unwrap_or("".to_string()); 185 | branch.trim().to_string() 186 | } 187 | Err(_) => "main".to_string(), 188 | } 189 | } 190 | 191 | pub fn sync(&self) -> Result<(), io::Error> { 192 | self.sync_with_message("") 193 | } 194 | 195 | pub fn sync_with_message(&self, commit_msg: &str) -> Result<(), io::Error> { 196 | debug!( 197 | "Syncing: {} at {}", 198 | self.name(), 199 | self.get_location().to_string_lossy(), 200 | ); 201 | 202 | let is_dirty = self.is_dirty(); 203 | let has_origin = self.has_origin(); 204 | let branch_name = self.branch_name(); 205 | let pull_only = self.config.pull_only; 206 | 207 | if is_dirty && !pull_only { 208 | let msg = if self.config.prompt_for_commit_message && commit_msg.is_empty() { 209 | self.git(["--no-pager", "diff"])?; 210 | let mut rl = DefaultEditor::new().expect("Unable to instantiate readline!"); 211 | match rl.readline("Commit message: ") { 212 | Ok(line) => line, 213 | Err(ReadlineError::Interrupted) => return Ok(()), 214 | Err(ReadlineError::Eof) => return Ok(()), 215 | Err(err) => panic!("{}", err), 216 | } 217 | } else if !commit_msg.is_empty() { 218 | commit_msg.to_string() 219 | } else { 220 | "Dotfiles managed by DFM! https://github.com/chasinglogic/dfm".to_string() 221 | }; 222 | 223 | self.run_hook("before_sync")?; 224 | self.git(["add", "--all"])?; 225 | self.git(["commit", "-m", &msg])?; 226 | } 227 | 228 | if has_origin { 229 | self.git(["pull", "--rebase", "origin", &branch_name])?; 230 | } 231 | 232 | if is_dirty && has_origin && !pull_only { 233 | self.git(["push", "origin", &branch_name])?; 234 | self.run_hook("after_sync_dirty")?; 235 | } else { 236 | self.run_hook("after_sync_clean")?; 237 | } 238 | 239 | self.run_hook("after_sync")?; 240 | 241 | for profile in &self.modules { 242 | profile.sync()?; 243 | } 244 | 245 | Ok(()) 246 | } 247 | 248 | pub fn link(&self, overwrite_existing_files: bool) -> Result<(), io::Error> { 249 | for profile in self 250 | .modules 251 | .iter() 252 | .filter(|p| p.config.link == LinkMode::Pre) 253 | { 254 | profile.link(overwrite_existing_files)?; 255 | } 256 | 257 | self.run_hook("before_link")?; 258 | 259 | let mut walker = WalkDir::new(&self.location) 260 | .min_depth(1) 261 | .into_iter() 262 | .filter_entry(is_dotfile); 263 | 264 | let mapper = Mapper::from(self.config.mappings.clone()); 265 | 266 | let home = PathBuf::from(env::var("HOME").unwrap_or("".to_string())); 267 | loop { 268 | let entry = match walker.next() { 269 | None => break, 270 | Some(Ok(e)) => e, 271 | Some(Err(_)) => continue, 272 | }; 273 | 274 | let full_path = entry.path(); 275 | let relative_path = full_path.strip_prefix(&self.location).unwrap(); 276 | let action = mapper.get_mapped_action( 277 | relative_path 278 | .as_os_str() 279 | .to_str() 280 | .expect("Something weird happened!"), 281 | ); 282 | 283 | if full_path.is_dir() && action != MapAction::LinkAsDir { 284 | continue; 285 | } 286 | 287 | let target_path = match action { 288 | MapAction::Skip => { 289 | debug!( 290 | "Skipping {} because it matched a skip mapping", 291 | relative_path.as_os_str().to_str().unwrap_or_default() 292 | ); 293 | continue; 294 | } 295 | MapAction::NewDest(ref dest) => Path::new(&dest).to_owned(), 296 | MapAction::None => home.join(relative_path), 297 | MapAction::NewTargetDir(ref target_dir) => { 298 | let pb = PathBuf::from(target_dir); 299 | pb.join(relative_path) 300 | } 301 | MapAction::LinkAsDir => { 302 | walker.skip_current_dir(); 303 | home.join(relative_path) 304 | } 305 | }; 306 | 307 | debug!( 308 | "Link {} -> {}", 309 | target_path.to_string_lossy(), 310 | full_path.to_string_lossy() 311 | ); 312 | 313 | if let Some(err) = remove_if_able(&target_path, overwrite_existing_files) { 314 | if err.kind() == io::ErrorKind::AlreadyExists { 315 | eprintln!("{}", err); 316 | continue; 317 | } 318 | 319 | return Err(err); 320 | } 321 | 322 | if let Some(path) = target_path.parent() { 323 | if !path.exists() { 324 | fs::create_dir_all(path)?; 325 | } 326 | } 327 | 328 | os::unix::fs::symlink(full_path, target_path).map_err(|err| { 329 | io::Error::new( 330 | err.kind(), 331 | format!( 332 | "{}: {}", 333 | full_path 334 | .to_str() 335 | .expect("file could not be made a string?"), 336 | err 337 | ), 338 | ) 339 | })?; 340 | } 341 | 342 | self.run_hook("after_link")?; 343 | 344 | for profile in self 345 | .modules 346 | .iter() 347 | .filter(|p| p.config.link == LinkMode::Post) 348 | { 349 | profile.link(overwrite_existing_files)?; 350 | } 351 | 352 | Ok(()) 353 | } 354 | 355 | pub fn git(&self, args: I) -> GitResult 356 | where 357 | I: IntoIterator, 358 | S: AsRef, 359 | { 360 | Command::new("git") 361 | .args(args) 362 | .current_dir(&self.location) 363 | .spawn()? 364 | .wait() 365 | } 366 | 367 | pub fn status(&self) -> GitResult { 368 | self.git(["status"]) 369 | } 370 | 371 | pub fn init(&self) -> Result<(), io::Error> { 372 | self.git(["init"])?; 373 | 374 | let mut dotdfm = self.location.clone(); 375 | dotdfm.push(".dfm.yml"); 376 | let fh = &mut File::create(&dotdfm)?; 377 | // TODO: Embed a hardcoded default config with documentation comments 378 | // and good formatting in the binary and use it here. 379 | let content = serde_yaml::to_string(&self.config) 380 | .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; 381 | fh.write_all(content.as_bytes())?; 382 | 383 | self.git(["add", ".dfm.yml"])?; 384 | self.git(["commit", "-m", "initial commit"])?; 385 | 386 | Ok(()) 387 | } 388 | 389 | pub fn run_hook(&self, hook_name: &str) -> Result<(), io::Error> { 390 | self.config.hooks.run_hook(hook_name, &self.location) 391 | } 392 | 393 | pub fn get_location(&self) -> PathBuf { 394 | self.location.clone() 395 | } 396 | } 397 | --------------------------------------------------------------------------------