├── .editorconfig ├── .github ├── CODEOWNERS ├── dependabot.yml ├── settings.yml └── workflows │ ├── build.yaml │ └── upgrade-flakes.yaml ├── .gitignore ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── flake.lock ├── flake.nix ├── scripts ├── dura.fish └── pre-commit.sh ├── src ├── config.rs ├── database.rs ├── git_repo_iter.rs ├── lib.rs ├── log.rs ├── logger.rs ├── main.rs ├── metrics.rs ├── poll_guard.rs ├── poller.rs └── snapshots.rs └── tests ├── poll_guard_test.rs ├── snapshots_test.rs ├── startup_test.rs ├── util ├── daemon.rs ├── dura.rs ├── git_repo.rs ├── macros.rs └── mod.rs └── watch_test.rs /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.md] 16 | # double whitespace at end of line 17 | # denotes a line break in Markdown 18 | trim_trailing_whitespace = false 19 | 20 | [{*.yml, *.yaml, *.nix}] 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @tkellogg 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/probot/settings 2 | 3 | branches: 4 | - name: master 5 | protection: 6 | enforce_admins: false 7 | required_pull_request_reviews: 8 | dismiss_stale_reviews: true 9 | require_code_owner_reviews: true 10 | required_approving_review_count: 1 11 | required_status_checks: 12 | strict: true 13 | restrictions: null 14 | required_linear_history: true 15 | 16 | labels: 17 | - name: backward breaking change 18 | color: ff0000 19 | 20 | - name: bug 21 | color: ee0701 22 | 23 | - name: dependencies 24 | color: 0366d6 25 | 26 | - name: enhancement 27 | color: 0e8a16 28 | 29 | - name: experimentation 30 | color: eeeeee 31 | 32 | - name: question 33 | color: cc317c 34 | 35 | - name: new feature 36 | color: 0e8a16 37 | 38 | - name: security 39 | color: ee0701 40 | 41 | - name: stale 42 | color: eeeeee 43 | 44 | repository: 45 | allow_merge_commit: true 46 | allow_rebase_merge: true 47 | allow_squash_merge: true 48 | default_branch: main 49 | description: "You shouldn't ever lose your work if you're using Git" 50 | topics: git 51 | has_downloads: true 52 | has_issues: true 53 | has_pages: false 54 | has_projects: false 55 | has_wiki: false 56 | name: dura 57 | private: false 58 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [ workflow_call, workflow_dispatch, push, pull_request ] 4 | 5 | jobs: 6 | build: 7 | name: build 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | include: 12 | - os: ubuntu-latest 13 | binary-suffix: linux-x86_64 14 | - os: macos-latest 15 | binary-suffix: macos-x86_64 16 | - os: windows-latest 17 | binary-suffix: windows-x86_64 18 | os: [ ubuntu-latest, macos-latest, windows-latest ] 19 | 20 | steps: 21 | - name: Check out source files 22 | uses: actions/checkout@v3 23 | with: 24 | fetch-depth: 1 25 | 26 | - name: Update Toolchain 27 | uses: actions-rs/toolchain@v1 28 | with: 29 | toolchain: stable 30 | components: clippy 31 | 32 | - name: Build 33 | uses: actions-rs/cargo@v1 34 | with: 35 | command: build 36 | args: --release --all-features 37 | 38 | - name: Rustfmt 39 | uses: actions-rs/cargo@v1 40 | with: 41 | command: fmt 42 | args: --check 43 | 44 | - name: Cargo Clippy 45 | uses: actions-rs/cargo@v1 46 | with: 47 | command: clippy 48 | args: --all-targets --all-features -- -D warnings 49 | 50 | - name: Cargo Test 51 | uses: actions-rs/cargo@v1 52 | with: 53 | command: test 54 | args: --profile release 55 | 56 | - name: Basic Testing 57 | continue-on-error: true 58 | if: ${{ matrix.os != 'windows-latest' }} 59 | run: | 60 | ${{ github.workspace }}/target/release/dura serve & 61 | sleep 15s 62 | ${{ github.workspace }}/target/release/dura kill 63 | 64 | - name: Basic Testing (Windows) 65 | continue-on-error: true 66 | if: ${{ matrix.os == 'windows-latest' }} 67 | run: | 68 | Start-Process -NoNewWindow ${{ github.workspace }}\target\release\dura.exe serve 69 | Start-Sleep -s 15 70 | ${{ github.workspace }}\target\release\dura.exe kill 71 | 72 | - name: Upload Binary 73 | uses: actions/upload-artifact@v3 74 | with: 75 | name: dura-${{ matrix.binary-suffix }}_${{ github.sha }} 76 | path: ${{ github.workspace }}/target/release/dura 77 | 78 | -------------------------------------------------------------------------------- /.github/workflows/upgrade-flakes.yaml: -------------------------------------------------------------------------------- 1 | name: 'Update flake lock file' 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * 1' 6 | 7 | jobs: 8 | createPullRequest: 9 | uses: loophp/flake-lock-update-workflow/.github/workflows/upgrade-flakes.yaml@main 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled files and executables 2 | /target/ 3 | debug/ 4 | 5 | # backup files from rustfmt 6 | **/*.rs.bk 7 | 8 | # debugging information from rustc on MSVC Windows 9 | *.pdb 10 | /.idea/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `dura` 2 | 3 | # Pull request process 4 | 1. Discuss changes before starting. This helps avoid awkward situations, like where something has already been tried or isn't feasible for a non-obvious reason. 5 | 2. Add tests, if possible 6 | * [`startup_test.rs`](https://github.com/tkellogg/dura/blob/master/tests/startup_test.rs) is a good place to test out new functionality, and the test code reads fairly well. 7 | * Unit tests are preferred, when feasible. They go inside source files. 8 | 3. Run `$ ./scripts/pre-commit.sh` before pushing. This does almost everything that happens in CI, just faster. 9 | 4. Explain the behavior as best as possible. Things like screenshots and GIFs can be helpful when it's visual. 10 | 5. Breathe deep. Smell the fresh clean air. 11 | 12 | We try to get to PRs within a day. We're usually quicker than that, but sometimes things slide through the cracks. 13 | 14 | Oh! And please be kind. We're all here because we want to help other people. Please remember that. 15 | 16 | 17 | # Coding guidelines 18 | 19 | ## Printing output 20 | * All `stdout` is routed through the logger and is JSON. 21 | * Messages to the user should be on `stderr` and are plain text (e.g. can't take a lock) 22 | * Use serialized structs to write JSON logs, so that the structure remains mostly backward compatible. Try not to rename fields, in case someone has written scripts against it. 23 | 24 | 25 | ## Unit tests vs Integration tests 26 | For the purposes of this project, "integration tests" use the filesystem. The [official Rust recommendation](https://doc.rust-lang.org/book/ch11-03-test-organization.html) 27 | is: 28 | 29 | * **Unit tests** go inline inside source files, in a `#[cfg(test)]` module. Structure your code so that 30 | you can use these to test private functions without using the external dependencies like the 31 | filesystem. 32 | * **Integration tests** go "externally", in the `/tests` folder. Use the utilities in `tests/util` to 33 | work with external dependencies easier. 34 | * `git_repo` — makes it easy to work with Git repositories in a temp directory. It does it in a way 35 | that tests can continue to run in parallel without interfering with each other. 36 | * `dura` — makes it easy to call the real `dura` executable in a sub-process. This makes it 37 | possible to run tests in parallel by setting environment varibales only for the sub-process 38 | (e.g. `$DURA_HOME`). It also uses the `util::daemon` module to facilitate working with `dura serve` 39 | by allowing you to make a blocking call to `read_line` to wait the minimum amount of time for 40 | an activity to happen (like startup or snapshots). 41 | 42 | 43 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "adler" 7 | version = "1.0.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 10 | 11 | [[package]] 12 | name = "ansi_term" 13 | version = "0.12.1" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" 16 | dependencies = [ 17 | "winapi", 18 | ] 19 | 20 | [[package]] 21 | name = "anyhow" 22 | version = "1.0.66" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6" 25 | 26 | [[package]] 27 | name = "autocfg" 28 | version = "1.1.0" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 31 | 32 | [[package]] 33 | name = "base64" 34 | version = "0.13.1" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" 37 | 38 | [[package]] 39 | name = "bitflags" 40 | version = "1.3.2" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 43 | 44 | [[package]] 45 | name = "byteorder" 46 | version = "1.4.3" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" 49 | 50 | [[package]] 51 | name = "bytes" 52 | version = "1.1.0" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" 55 | 56 | [[package]] 57 | name = "cc" 58 | version = "1.0.72" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "22a9137b95ea06864e018375b72adfb7db6e6f68cfc8df5a04d00288050485ee" 61 | dependencies = [ 62 | "jobserver", 63 | ] 64 | 65 | [[package]] 66 | name = "cfg-if" 67 | version = "1.0.0" 68 | source = "registry+https://github.com/rust-lang/crates.io-index" 69 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 70 | 71 | [[package]] 72 | name = "chrono" 73 | version = "0.4.19" 74 | source = "registry+https://github.com/rust-lang/crates.io-index" 75 | checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" 76 | dependencies = [ 77 | "libc", 78 | "num-integer", 79 | "num-traits", 80 | "time", 81 | "winapi", 82 | ] 83 | 84 | [[package]] 85 | name = "clap" 86 | version = "4.0.27" 87 | source = "registry+https://github.com/rust-lang/crates.io-index" 88 | checksum = "0acbd8d28a0a60d7108d7ae850af6ba34cf2d1257fc646980e5f97ce14275966" 89 | dependencies = [ 90 | "bitflags", 91 | "clap_lex", 92 | "is-terminal", 93 | "once_cell", 94 | "strsim", 95 | "termcolor", 96 | ] 97 | 98 | [[package]] 99 | name = "clap_lex" 100 | version = "0.3.0" 101 | source = "registry+https://github.com/rust-lang/crates.io-index" 102 | checksum = "0d4198f73e42b4936b35b5bb248d81d2b595ecb170da0bac7655c54eedfa8da8" 103 | dependencies = [ 104 | "os_str_bytes", 105 | ] 106 | 107 | [[package]] 108 | name = "crc32fast" 109 | version = "1.3.2" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" 112 | dependencies = [ 113 | "cfg-if", 114 | ] 115 | 116 | [[package]] 117 | name = "crossbeam-channel" 118 | version = "0.5.6" 119 | source = "registry+https://github.com/rust-lang/crates.io-index" 120 | checksum = "c2dd04ddaf88237dc3b8d8f9a3c1004b506b54b3313403944054d23c0870c521" 121 | dependencies = [ 122 | "cfg-if", 123 | "crossbeam-utils", 124 | ] 125 | 126 | [[package]] 127 | name = "crossbeam-utils" 128 | version = "0.8.12" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "edbafec5fa1f196ca66527c1b12c2ec4745ca14b50f1ad8f9f6f720b55d11fac" 131 | dependencies = [ 132 | "cfg-if", 133 | ] 134 | 135 | [[package]] 136 | name = "dashmap" 137 | version = "5.4.0" 138 | source = "registry+https://github.com/rust-lang/crates.io-index" 139 | checksum = "907076dfda823b0b36d2a1bb5f90c96660a5bbcd7729e10727f07858f22c4edc" 140 | dependencies = [ 141 | "cfg-if", 142 | "hashbrown", 143 | "lock_api", 144 | "once_cell", 145 | "parking_lot_core 0.9.4", 146 | ] 147 | 148 | [[package]] 149 | name = "dirs" 150 | version = "4.0.0" 151 | source = "registry+https://github.com/rust-lang/crates.io-index" 152 | checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" 153 | dependencies = [ 154 | "dirs-sys", 155 | ] 156 | 157 | [[package]] 158 | name = "dirs-sys" 159 | version = "0.3.6" 160 | source = "registry+https://github.com/rust-lang/crates.io-index" 161 | checksum = "03d86534ed367a67548dc68113a0f5db55432fdfbb6e6f9d77704397d95d5780" 162 | dependencies = [ 163 | "libc", 164 | "redox_users", 165 | "winapi", 166 | ] 167 | 168 | [[package]] 169 | name = "dura" 170 | version = "0.2.0-dev" 171 | dependencies = [ 172 | "anyhow", 173 | "chrono", 174 | "clap", 175 | "dirs", 176 | "git2", 177 | "hdrhistogram", 178 | "serde", 179 | "serde_json", 180 | "serial_test", 181 | "sudo", 182 | "tempfile", 183 | "tokio", 184 | "toml", 185 | "tracing", 186 | "tracing-subscriber", 187 | "walkdir", 188 | ] 189 | 190 | [[package]] 191 | name = "errno" 192 | version = "0.2.8" 193 | source = "registry+https://github.com/rust-lang/crates.io-index" 194 | checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" 195 | dependencies = [ 196 | "errno-dragonfly", 197 | "libc", 198 | "winapi", 199 | ] 200 | 201 | [[package]] 202 | name = "errno-dragonfly" 203 | version = "0.1.2" 204 | source = "registry+https://github.com/rust-lang/crates.io-index" 205 | checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" 206 | dependencies = [ 207 | "cc", 208 | "libc", 209 | ] 210 | 211 | [[package]] 212 | name = "fastrand" 213 | version = "1.6.0" 214 | source = "registry+https://github.com/rust-lang/crates.io-index" 215 | checksum = "779d043b6a0b90cc4c0ed7ee380a6504394cee7efd7db050e3774eee387324b2" 216 | dependencies = [ 217 | "instant", 218 | ] 219 | 220 | [[package]] 221 | name = "flate2" 222 | version = "1.0.24" 223 | source = "registry+https://github.com/rust-lang/crates.io-index" 224 | checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6" 225 | dependencies = [ 226 | "crc32fast", 227 | "miniz_oxide", 228 | ] 229 | 230 | [[package]] 231 | name = "form_urlencoded" 232 | version = "1.0.1" 233 | source = "registry+https://github.com/rust-lang/crates.io-index" 234 | checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" 235 | dependencies = [ 236 | "matches", 237 | "percent-encoding", 238 | ] 239 | 240 | [[package]] 241 | name = "futures" 242 | version = "0.3.25" 243 | source = "registry+https://github.com/rust-lang/crates.io-index" 244 | checksum = "38390104763dc37a5145a53c29c63c1290b5d316d6086ec32c293f6736051bb0" 245 | dependencies = [ 246 | "futures-channel", 247 | "futures-core", 248 | "futures-executor", 249 | "futures-io", 250 | "futures-sink", 251 | "futures-task", 252 | "futures-util", 253 | ] 254 | 255 | [[package]] 256 | name = "futures-channel" 257 | version = "0.3.25" 258 | source = "registry+https://github.com/rust-lang/crates.io-index" 259 | checksum = "52ba265a92256105f45b719605a571ffe2d1f0fea3807304b522c1d778f79eed" 260 | dependencies = [ 261 | "futures-core", 262 | "futures-sink", 263 | ] 264 | 265 | [[package]] 266 | name = "futures-core" 267 | version = "0.3.25" 268 | source = "registry+https://github.com/rust-lang/crates.io-index" 269 | checksum = "04909a7a7e4633ae6c4a9ab280aeb86da1236243a77b694a49eacd659a4bd3ac" 270 | 271 | [[package]] 272 | name = "futures-executor" 273 | version = "0.3.25" 274 | source = "registry+https://github.com/rust-lang/crates.io-index" 275 | checksum = "7acc85df6714c176ab5edf386123fafe217be88c0840ec11f199441134a074e2" 276 | dependencies = [ 277 | "futures-core", 278 | "futures-task", 279 | "futures-util", 280 | ] 281 | 282 | [[package]] 283 | name = "futures-io" 284 | version = "0.3.25" 285 | source = "registry+https://github.com/rust-lang/crates.io-index" 286 | checksum = "00f5fb52a06bdcadeb54e8d3671f8888a39697dcb0b81b23b55174030427f4eb" 287 | 288 | [[package]] 289 | name = "futures-sink" 290 | version = "0.3.25" 291 | source = "registry+https://github.com/rust-lang/crates.io-index" 292 | checksum = "39c15cf1a4aa79df40f1bb462fb39676d0ad9e366c2a33b590d7c66f4f81fcf9" 293 | 294 | [[package]] 295 | name = "futures-task" 296 | version = "0.3.25" 297 | source = "registry+https://github.com/rust-lang/crates.io-index" 298 | checksum = "2ffb393ac5d9a6eaa9d3fdf37ae2776656b706e200c8e16b1bdb227f5198e6ea" 299 | 300 | [[package]] 301 | name = "futures-util" 302 | version = "0.3.25" 303 | source = "registry+https://github.com/rust-lang/crates.io-index" 304 | checksum = "197676987abd2f9cadff84926f410af1c183608d36641465df73ae8211dc65d6" 305 | dependencies = [ 306 | "futures-channel", 307 | "futures-core", 308 | "futures-io", 309 | "futures-sink", 310 | "futures-task", 311 | "memchr", 312 | "pin-project-lite", 313 | "pin-utils", 314 | "slab", 315 | ] 316 | 317 | [[package]] 318 | name = "getrandom" 319 | version = "0.2.3" 320 | source = "registry+https://github.com/rust-lang/crates.io-index" 321 | checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" 322 | dependencies = [ 323 | "cfg-if", 324 | "libc", 325 | "wasi", 326 | ] 327 | 328 | [[package]] 329 | name = "git2" 330 | version = "0.15.0" 331 | source = "registry+https://github.com/rust-lang/crates.io-index" 332 | checksum = "2994bee4a3a6a51eb90c218523be382fd7ea09b16380b9312e9dbe955ff7c7d1" 333 | dependencies = [ 334 | "bitflags", 335 | "libc", 336 | "libgit2-sys", 337 | "log", 338 | "openssl-probe", 339 | "openssl-sys", 340 | "url", 341 | ] 342 | 343 | [[package]] 344 | name = "hashbrown" 345 | version = "0.12.3" 346 | source = "registry+https://github.com/rust-lang/crates.io-index" 347 | checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" 348 | 349 | [[package]] 350 | name = "hdrhistogram" 351 | version = "7.5.2" 352 | source = "registry+https://github.com/rust-lang/crates.io-index" 353 | checksum = "7f19b9f54f7c7f55e31401bb647626ce0cf0f67b0004982ce815b3ee72a02aa8" 354 | dependencies = [ 355 | "base64", 356 | "byteorder", 357 | "crossbeam-channel", 358 | "flate2", 359 | "nom", 360 | "num-traits", 361 | ] 362 | 363 | [[package]] 364 | name = "hermit-abi" 365 | version = "0.1.19" 366 | source = "registry+https://github.com/rust-lang/crates.io-index" 367 | checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" 368 | dependencies = [ 369 | "libc", 370 | ] 371 | 372 | [[package]] 373 | name = "hermit-abi" 374 | version = "0.2.6" 375 | source = "registry+https://github.com/rust-lang/crates.io-index" 376 | checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" 377 | dependencies = [ 378 | "libc", 379 | ] 380 | 381 | [[package]] 382 | name = "idna" 383 | version = "0.2.3" 384 | source = "registry+https://github.com/rust-lang/crates.io-index" 385 | checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" 386 | dependencies = [ 387 | "matches", 388 | "unicode-bidi", 389 | "unicode-normalization", 390 | ] 391 | 392 | [[package]] 393 | name = "instant" 394 | version = "0.1.12" 395 | source = "registry+https://github.com/rust-lang/crates.io-index" 396 | checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" 397 | dependencies = [ 398 | "cfg-if", 399 | ] 400 | 401 | [[package]] 402 | name = "io-lifetimes" 403 | version = "1.0.3" 404 | source = "registry+https://github.com/rust-lang/crates.io-index" 405 | checksum = "46112a93252b123d31a119a8d1a1ac19deac4fac6e0e8b0df58f0d4e5870e63c" 406 | dependencies = [ 407 | "libc", 408 | "windows-sys", 409 | ] 410 | 411 | [[package]] 412 | name = "is-terminal" 413 | version = "0.4.0" 414 | source = "registry+https://github.com/rust-lang/crates.io-index" 415 | checksum = "aae5bc6e2eb41c9def29a3e0f1306382807764b9b53112030eff57435667352d" 416 | dependencies = [ 417 | "hermit-abi 0.2.6", 418 | "io-lifetimes", 419 | "rustix", 420 | "windows-sys", 421 | ] 422 | 423 | [[package]] 424 | name = "itoa" 425 | version = "1.0.1" 426 | source = "registry+https://github.com/rust-lang/crates.io-index" 427 | checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" 428 | 429 | [[package]] 430 | name = "jobserver" 431 | version = "0.1.24" 432 | source = "registry+https://github.com/rust-lang/crates.io-index" 433 | checksum = "af25a77299a7f711a01975c35a6a424eb6862092cc2d6c72c4ed6cbc56dfc1fa" 434 | dependencies = [ 435 | "libc", 436 | ] 437 | 438 | [[package]] 439 | name = "lazy_static" 440 | version = "1.4.0" 441 | source = "registry+https://github.com/rust-lang/crates.io-index" 442 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 443 | 444 | [[package]] 445 | name = "libc" 446 | version = "0.2.137" 447 | source = "registry+https://github.com/rust-lang/crates.io-index" 448 | checksum = "fc7fcc620a3bff7cdd7a365be3376c97191aeaccc2a603e600951e452615bf89" 449 | 450 | [[package]] 451 | name = "libgit2-sys" 452 | version = "0.14.0+1.5.0" 453 | source = "registry+https://github.com/rust-lang/crates.io-index" 454 | checksum = "47a00859c70c8a4f7218e6d1cc32875c4b55f6799445b842b0d8ed5e4c3d959b" 455 | dependencies = [ 456 | "cc", 457 | "libc", 458 | "libssh2-sys", 459 | "libz-sys", 460 | "openssl-sys", 461 | "pkg-config", 462 | ] 463 | 464 | [[package]] 465 | name = "libssh2-sys" 466 | version = "0.2.23" 467 | source = "registry+https://github.com/rust-lang/crates.io-index" 468 | checksum = "b094a36eb4b8b8c8a7b4b8ae43b2944502be3e59cd87687595cf6b0a71b3f4ca" 469 | dependencies = [ 470 | "cc", 471 | "libc", 472 | "libz-sys", 473 | "openssl-sys", 474 | "pkg-config", 475 | "vcpkg", 476 | ] 477 | 478 | [[package]] 479 | name = "libz-sys" 480 | version = "1.1.3" 481 | source = "registry+https://github.com/rust-lang/crates.io-index" 482 | checksum = "de5435b8549c16d423ed0c03dbaafe57cf6c3344744f1242520d59c9d8ecec66" 483 | dependencies = [ 484 | "cc", 485 | "libc", 486 | "pkg-config", 487 | "vcpkg", 488 | ] 489 | 490 | [[package]] 491 | name = "linux-raw-sys" 492 | version = "0.1.3" 493 | source = "registry+https://github.com/rust-lang/crates.io-index" 494 | checksum = "8f9f08d8963a6c613f4b1a78f4f4a4dbfadf8e6545b2d72861731e4858b8b47f" 495 | 496 | [[package]] 497 | name = "lock_api" 498 | version = "0.4.9" 499 | source = "registry+https://github.com/rust-lang/crates.io-index" 500 | checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" 501 | dependencies = [ 502 | "autocfg", 503 | "scopeguard", 504 | ] 505 | 506 | [[package]] 507 | name = "log" 508 | version = "0.4.14" 509 | source = "registry+https://github.com/rust-lang/crates.io-index" 510 | checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" 511 | dependencies = [ 512 | "cfg-if", 513 | ] 514 | 515 | [[package]] 516 | name = "matchers" 517 | version = "0.1.0" 518 | source = "registry+https://github.com/rust-lang/crates.io-index" 519 | checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" 520 | dependencies = [ 521 | "regex-automata", 522 | ] 523 | 524 | [[package]] 525 | name = "matches" 526 | version = "0.1.9" 527 | source = "registry+https://github.com/rust-lang/crates.io-index" 528 | checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" 529 | 530 | [[package]] 531 | name = "memchr" 532 | version = "2.4.1" 533 | source = "registry+https://github.com/rust-lang/crates.io-index" 534 | checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" 535 | 536 | [[package]] 537 | name = "minimal-lexical" 538 | version = "0.2.1" 539 | source = "registry+https://github.com/rust-lang/crates.io-index" 540 | checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 541 | 542 | [[package]] 543 | name = "miniz_oxide" 544 | version = "0.5.4" 545 | source = "registry+https://github.com/rust-lang/crates.io-index" 546 | checksum = "96590ba8f175222643a85693f33d26e9c8a015f599c216509b1a6894af675d34" 547 | dependencies = [ 548 | "adler", 549 | ] 550 | 551 | [[package]] 552 | name = "mio" 553 | version = "0.7.14" 554 | source = "registry+https://github.com/rust-lang/crates.io-index" 555 | checksum = "8067b404fe97c70829f082dec8bcf4f71225d7eaea1d8645349cb76fa06205cc" 556 | dependencies = [ 557 | "libc", 558 | "log", 559 | "miow", 560 | "ntapi", 561 | "winapi", 562 | ] 563 | 564 | [[package]] 565 | name = "miow" 566 | version = "0.3.7" 567 | source = "registry+https://github.com/rust-lang/crates.io-index" 568 | checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" 569 | dependencies = [ 570 | "winapi", 571 | ] 572 | 573 | [[package]] 574 | name = "nom" 575 | version = "7.1.1" 576 | source = "registry+https://github.com/rust-lang/crates.io-index" 577 | checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36" 578 | dependencies = [ 579 | "memchr", 580 | "minimal-lexical", 581 | ] 582 | 583 | [[package]] 584 | name = "ntapi" 585 | version = "0.3.6" 586 | source = "registry+https://github.com/rust-lang/crates.io-index" 587 | checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44" 588 | dependencies = [ 589 | "winapi", 590 | ] 591 | 592 | [[package]] 593 | name = "num-integer" 594 | version = "0.1.44" 595 | source = "registry+https://github.com/rust-lang/crates.io-index" 596 | checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" 597 | dependencies = [ 598 | "autocfg", 599 | "num-traits", 600 | ] 601 | 602 | [[package]] 603 | name = "num-traits" 604 | version = "0.2.14" 605 | source = "registry+https://github.com/rust-lang/crates.io-index" 606 | checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" 607 | dependencies = [ 608 | "autocfg", 609 | ] 610 | 611 | [[package]] 612 | name = "num_cpus" 613 | version = "1.13.1" 614 | source = "registry+https://github.com/rust-lang/crates.io-index" 615 | checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" 616 | dependencies = [ 617 | "hermit-abi 0.1.19", 618 | "libc", 619 | ] 620 | 621 | [[package]] 622 | name = "once_cell" 623 | version = "1.16.0" 624 | source = "registry+https://github.com/rust-lang/crates.io-index" 625 | checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860" 626 | 627 | [[package]] 628 | name = "openssl-probe" 629 | version = "0.1.4" 630 | source = "registry+https://github.com/rust-lang/crates.io-index" 631 | checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a" 632 | 633 | [[package]] 634 | name = "openssl-sys" 635 | version = "0.9.72" 636 | source = "registry+https://github.com/rust-lang/crates.io-index" 637 | checksum = "7e46109c383602735fa0a2e48dd2b7c892b048e1bf69e5c3b1d804b7d9c203cb" 638 | dependencies = [ 639 | "autocfg", 640 | "cc", 641 | "libc", 642 | "pkg-config", 643 | "vcpkg", 644 | ] 645 | 646 | [[package]] 647 | name = "os_str_bytes" 648 | version = "6.0.0" 649 | source = "registry+https://github.com/rust-lang/crates.io-index" 650 | checksum = "8e22443d1643a904602595ba1cd8f7d896afe56d26712531c5ff73a15b2fbf64" 651 | 652 | [[package]] 653 | name = "parking_lot" 654 | version = "0.11.2" 655 | source = "registry+https://github.com/rust-lang/crates.io-index" 656 | checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" 657 | dependencies = [ 658 | "instant", 659 | "lock_api", 660 | "parking_lot_core 0.8.5", 661 | ] 662 | 663 | [[package]] 664 | name = "parking_lot" 665 | version = "0.12.1" 666 | source = "registry+https://github.com/rust-lang/crates.io-index" 667 | checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" 668 | dependencies = [ 669 | "lock_api", 670 | "parking_lot_core 0.9.4", 671 | ] 672 | 673 | [[package]] 674 | name = "parking_lot_core" 675 | version = "0.8.5" 676 | source = "registry+https://github.com/rust-lang/crates.io-index" 677 | checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216" 678 | dependencies = [ 679 | "cfg-if", 680 | "instant", 681 | "libc", 682 | "redox_syscall", 683 | "smallvec", 684 | "winapi", 685 | ] 686 | 687 | [[package]] 688 | name = "parking_lot_core" 689 | version = "0.9.4" 690 | source = "registry+https://github.com/rust-lang/crates.io-index" 691 | checksum = "4dc9e0dc2adc1c69d09143aff38d3d30c5c3f0df0dad82e6d25547af174ebec0" 692 | dependencies = [ 693 | "cfg-if", 694 | "libc", 695 | "redox_syscall", 696 | "smallvec", 697 | "windows-sys", 698 | ] 699 | 700 | [[package]] 701 | name = "percent-encoding" 702 | version = "2.1.0" 703 | source = "registry+https://github.com/rust-lang/crates.io-index" 704 | checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" 705 | 706 | [[package]] 707 | name = "pin-project-lite" 708 | version = "0.2.8" 709 | source = "registry+https://github.com/rust-lang/crates.io-index" 710 | checksum = "e280fbe77cc62c91527259e9442153f4688736748d24660126286329742b4c6c" 711 | 712 | [[package]] 713 | name = "pin-utils" 714 | version = "0.1.0" 715 | source = "registry+https://github.com/rust-lang/crates.io-index" 716 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 717 | 718 | [[package]] 719 | name = "pkg-config" 720 | version = "0.3.24" 721 | source = "registry+https://github.com/rust-lang/crates.io-index" 722 | checksum = "58893f751c9b0412871a09abd62ecd2a00298c6c83befa223ef98c52aef40cbe" 723 | 724 | [[package]] 725 | name = "proc-macro-error" 726 | version = "1.0.4" 727 | source = "registry+https://github.com/rust-lang/crates.io-index" 728 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 729 | dependencies = [ 730 | "proc-macro-error-attr", 731 | "proc-macro2", 732 | "quote", 733 | "syn", 734 | "version_check", 735 | ] 736 | 737 | [[package]] 738 | name = "proc-macro-error-attr" 739 | version = "1.0.4" 740 | source = "registry+https://github.com/rust-lang/crates.io-index" 741 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 742 | dependencies = [ 743 | "proc-macro2", 744 | "quote", 745 | "version_check", 746 | ] 747 | 748 | [[package]] 749 | name = "proc-macro2" 750 | version = "1.0.36" 751 | source = "registry+https://github.com/rust-lang/crates.io-index" 752 | checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029" 753 | dependencies = [ 754 | "unicode-xid", 755 | ] 756 | 757 | [[package]] 758 | name = "quote" 759 | version = "1.0.14" 760 | source = "registry+https://github.com/rust-lang/crates.io-index" 761 | checksum = "47aa80447ce4daf1717500037052af176af5d38cc3e571d9ec1c7353fc10c87d" 762 | dependencies = [ 763 | "proc-macro2", 764 | ] 765 | 766 | [[package]] 767 | name = "redox_syscall" 768 | version = "0.2.10" 769 | source = "registry+https://github.com/rust-lang/crates.io-index" 770 | checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" 771 | dependencies = [ 772 | "bitflags", 773 | ] 774 | 775 | [[package]] 776 | name = "redox_users" 777 | version = "0.4.0" 778 | source = "registry+https://github.com/rust-lang/crates.io-index" 779 | checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" 780 | dependencies = [ 781 | "getrandom", 782 | "redox_syscall", 783 | ] 784 | 785 | [[package]] 786 | name = "regex" 787 | version = "1.5.5" 788 | source = "registry+https://github.com/rust-lang/crates.io-index" 789 | checksum = "1a11647b6b25ff05a515cb92c365cec08801e83423a235b51e231e1808747286" 790 | dependencies = [ 791 | "regex-syntax", 792 | ] 793 | 794 | [[package]] 795 | name = "regex-automata" 796 | version = "0.1.10" 797 | source = "registry+https://github.com/rust-lang/crates.io-index" 798 | checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" 799 | dependencies = [ 800 | "regex-syntax", 801 | ] 802 | 803 | [[package]] 804 | name = "regex-syntax" 805 | version = "0.6.25" 806 | source = "registry+https://github.com/rust-lang/crates.io-index" 807 | checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" 808 | 809 | [[package]] 810 | name = "remove_dir_all" 811 | version = "0.5.3" 812 | source = "registry+https://github.com/rust-lang/crates.io-index" 813 | checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" 814 | dependencies = [ 815 | "winapi", 816 | ] 817 | 818 | [[package]] 819 | name = "rustix" 820 | version = "0.36.3" 821 | source = "registry+https://github.com/rust-lang/crates.io-index" 822 | checksum = "0b1fbb4dfc4eb1d390c02df47760bb19a84bb80b301ecc947ab5406394d8223e" 823 | dependencies = [ 824 | "bitflags", 825 | "errno", 826 | "io-lifetimes", 827 | "libc", 828 | "linux-raw-sys", 829 | "windows-sys", 830 | ] 831 | 832 | [[package]] 833 | name = "ryu" 834 | version = "1.0.9" 835 | source = "registry+https://github.com/rust-lang/crates.io-index" 836 | checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" 837 | 838 | [[package]] 839 | name = "same-file" 840 | version = "1.0.6" 841 | source = "registry+https://github.com/rust-lang/crates.io-index" 842 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 843 | dependencies = [ 844 | "winapi-util", 845 | ] 846 | 847 | [[package]] 848 | name = "scopeguard" 849 | version = "1.1.0" 850 | source = "registry+https://github.com/rust-lang/crates.io-index" 851 | checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" 852 | 853 | [[package]] 854 | name = "serde" 855 | version = "1.0.133" 856 | source = "registry+https://github.com/rust-lang/crates.io-index" 857 | checksum = "97565067517b60e2d1ea8b268e59ce036de907ac523ad83a0475da04e818989a" 858 | dependencies = [ 859 | "serde_derive", 860 | ] 861 | 862 | [[package]] 863 | name = "serde_derive" 864 | version = "1.0.133" 865 | source = "registry+https://github.com/rust-lang/crates.io-index" 866 | checksum = "ed201699328568d8d08208fdd080e3ff594e6c422e438b6705905da01005d537" 867 | dependencies = [ 868 | "proc-macro2", 869 | "quote", 870 | "syn", 871 | ] 872 | 873 | [[package]] 874 | name = "serde_json" 875 | version = "1.0.74" 876 | source = "registry+https://github.com/rust-lang/crates.io-index" 877 | checksum = "ee2bb9cd061c5865d345bb02ca49fcef1391741b672b54a0bf7b679badec3142" 878 | dependencies = [ 879 | "itoa", 880 | "ryu", 881 | "serde", 882 | ] 883 | 884 | [[package]] 885 | name = "serial_test" 886 | version = "0.9.0" 887 | source = "registry+https://github.com/rust-lang/crates.io-index" 888 | checksum = "92761393ee4dc3ff8f4af487bd58f4307c9329bbedea02cac0089ad9c411e153" 889 | dependencies = [ 890 | "dashmap", 891 | "futures", 892 | "lazy_static", 893 | "log", 894 | "parking_lot 0.12.1", 895 | "serial_test_derive", 896 | ] 897 | 898 | [[package]] 899 | name = "serial_test_derive" 900 | version = "0.9.0" 901 | source = "registry+https://github.com/rust-lang/crates.io-index" 902 | checksum = "4b6f5d1c3087fb119617cff2966fe3808a80e5eb59a8c1601d5994d66f4346a5" 903 | dependencies = [ 904 | "proc-macro-error", 905 | "proc-macro2", 906 | "quote", 907 | "syn", 908 | ] 909 | 910 | [[package]] 911 | name = "sharded-slab" 912 | version = "0.1.4" 913 | source = "registry+https://github.com/rust-lang/crates.io-index" 914 | checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31" 915 | dependencies = [ 916 | "lazy_static", 917 | ] 918 | 919 | [[package]] 920 | name = "signal-hook-registry" 921 | version = "1.4.0" 922 | source = "registry+https://github.com/rust-lang/crates.io-index" 923 | checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" 924 | dependencies = [ 925 | "libc", 926 | ] 927 | 928 | [[package]] 929 | name = "slab" 930 | version = "0.4.7" 931 | source = "registry+https://github.com/rust-lang/crates.io-index" 932 | checksum = "4614a76b2a8be0058caa9dbbaf66d988527d86d003c11a94fbd335d7661edcef" 933 | dependencies = [ 934 | "autocfg", 935 | ] 936 | 937 | [[package]] 938 | name = "smallvec" 939 | version = "1.7.0" 940 | source = "registry+https://github.com/rust-lang/crates.io-index" 941 | checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309" 942 | 943 | [[package]] 944 | name = "strsim" 945 | version = "0.10.0" 946 | source = "registry+https://github.com/rust-lang/crates.io-index" 947 | checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 948 | 949 | [[package]] 950 | name = "sudo" 951 | version = "0.6.0" 952 | source = "registry+https://github.com/rust-lang/crates.io-index" 953 | checksum = "88bd84d4c082e18e37fef52c0088e4407dabcef19d23a607fb4b5ee03b7d5b83" 954 | dependencies = [ 955 | "libc", 956 | "log", 957 | ] 958 | 959 | [[package]] 960 | name = "syn" 961 | version = "1.0.85" 962 | source = "registry+https://github.com/rust-lang/crates.io-index" 963 | checksum = "a684ac3dcd8913827e18cd09a68384ee66c1de24157e3c556c9ab16d85695fb7" 964 | dependencies = [ 965 | "proc-macro2", 966 | "quote", 967 | "unicode-xid", 968 | ] 969 | 970 | [[package]] 971 | name = "tempfile" 972 | version = "3.3.0" 973 | source = "registry+https://github.com/rust-lang/crates.io-index" 974 | checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" 975 | dependencies = [ 976 | "cfg-if", 977 | "fastrand", 978 | "libc", 979 | "redox_syscall", 980 | "remove_dir_all", 981 | "winapi", 982 | ] 983 | 984 | [[package]] 985 | name = "termcolor" 986 | version = "1.1.2" 987 | source = "registry+https://github.com/rust-lang/crates.io-index" 988 | checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" 989 | dependencies = [ 990 | "winapi-util", 991 | ] 992 | 993 | [[package]] 994 | name = "thread_local" 995 | version = "1.1.4" 996 | source = "registry+https://github.com/rust-lang/crates.io-index" 997 | checksum = "5516c27b78311c50bf42c071425c560ac799b11c30b31f87e3081965fe5e0180" 998 | dependencies = [ 999 | "once_cell", 1000 | ] 1001 | 1002 | [[package]] 1003 | name = "time" 1004 | version = "0.1.44" 1005 | source = "registry+https://github.com/rust-lang/crates.io-index" 1006 | checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" 1007 | dependencies = [ 1008 | "libc", 1009 | "wasi", 1010 | "winapi", 1011 | ] 1012 | 1013 | [[package]] 1014 | name = "tinyvec" 1015 | version = "1.5.1" 1016 | source = "registry+https://github.com/rust-lang/crates.io-index" 1017 | checksum = "2c1c1d5a42b6245520c249549ec267180beaffcc0615401ac8e31853d4b6d8d2" 1018 | dependencies = [ 1019 | "tinyvec_macros", 1020 | ] 1021 | 1022 | [[package]] 1023 | name = "tinyvec_macros" 1024 | version = "0.1.0" 1025 | source = "registry+https://github.com/rust-lang/crates.io-index" 1026 | checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" 1027 | 1028 | [[package]] 1029 | name = "tokio" 1030 | version = "1.15.0" 1031 | source = "registry+https://github.com/rust-lang/crates.io-index" 1032 | checksum = "fbbf1c778ec206785635ce8ad57fe52b3009ae9e0c9f574a728f3049d3e55838" 1033 | dependencies = [ 1034 | "bytes", 1035 | "libc", 1036 | "memchr", 1037 | "mio", 1038 | "num_cpus", 1039 | "once_cell", 1040 | "parking_lot 0.11.2", 1041 | "pin-project-lite", 1042 | "signal-hook-registry", 1043 | "tokio-macros", 1044 | "winapi", 1045 | ] 1046 | 1047 | [[package]] 1048 | name = "tokio-macros" 1049 | version = "1.7.0" 1050 | source = "registry+https://github.com/rust-lang/crates.io-index" 1051 | checksum = "b557f72f448c511a979e2564e55d74e6c4432fc96ff4f6241bc6bded342643b7" 1052 | dependencies = [ 1053 | "proc-macro2", 1054 | "quote", 1055 | "syn", 1056 | ] 1057 | 1058 | [[package]] 1059 | name = "toml" 1060 | version = "0.5.8" 1061 | source = "registry+https://github.com/rust-lang/crates.io-index" 1062 | checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" 1063 | dependencies = [ 1064 | "serde", 1065 | ] 1066 | 1067 | [[package]] 1068 | name = "tracing" 1069 | version = "0.1.29" 1070 | source = "registry+https://github.com/rust-lang/crates.io-index" 1071 | checksum = "375a639232caf30edfc78e8d89b2d4c375515393e7af7e16f01cd96917fb2105" 1072 | dependencies = [ 1073 | "cfg-if", 1074 | "pin-project-lite", 1075 | "tracing-attributes", 1076 | "tracing-core", 1077 | ] 1078 | 1079 | [[package]] 1080 | name = "tracing-attributes" 1081 | version = "0.1.18" 1082 | source = "registry+https://github.com/rust-lang/crates.io-index" 1083 | checksum = "f4f480b8f81512e825f337ad51e94c1eb5d3bbdf2b363dcd01e2b19a9ffe3f8e" 1084 | dependencies = [ 1085 | "proc-macro2", 1086 | "quote", 1087 | "syn", 1088 | ] 1089 | 1090 | [[package]] 1091 | name = "tracing-core" 1092 | version = "0.1.21" 1093 | source = "registry+https://github.com/rust-lang/crates.io-index" 1094 | checksum = "1f4ed65637b8390770814083d20756f87bfa2c21bf2f110babdc5438351746e4" 1095 | dependencies = [ 1096 | "lazy_static", 1097 | ] 1098 | 1099 | [[package]] 1100 | name = "tracing-log" 1101 | version = "0.1.2" 1102 | source = "registry+https://github.com/rust-lang/crates.io-index" 1103 | checksum = "a6923477a48e41c1951f1999ef8bb5a3023eb723ceadafe78ffb65dc366761e3" 1104 | dependencies = [ 1105 | "lazy_static", 1106 | "log", 1107 | "tracing-core", 1108 | ] 1109 | 1110 | [[package]] 1111 | name = "tracing-subscriber" 1112 | version = "0.3.5" 1113 | source = "registry+https://github.com/rust-lang/crates.io-index" 1114 | checksum = "5d81bfa81424cc98cb034b837c985b7a290f592e5b4322f353f94a0ab0f9f594" 1115 | dependencies = [ 1116 | "ansi_term", 1117 | "lazy_static", 1118 | "matchers", 1119 | "regex", 1120 | "sharded-slab", 1121 | "smallvec", 1122 | "thread_local", 1123 | "tracing", 1124 | "tracing-core", 1125 | "tracing-log", 1126 | ] 1127 | 1128 | [[package]] 1129 | name = "unicode-bidi" 1130 | version = "0.3.7" 1131 | source = "registry+https://github.com/rust-lang/crates.io-index" 1132 | checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f" 1133 | 1134 | [[package]] 1135 | name = "unicode-normalization" 1136 | version = "0.1.19" 1137 | source = "registry+https://github.com/rust-lang/crates.io-index" 1138 | checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" 1139 | dependencies = [ 1140 | "tinyvec", 1141 | ] 1142 | 1143 | [[package]] 1144 | name = "unicode-xid" 1145 | version = "0.2.2" 1146 | source = "registry+https://github.com/rust-lang/crates.io-index" 1147 | checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" 1148 | 1149 | [[package]] 1150 | name = "url" 1151 | version = "2.2.2" 1152 | source = "registry+https://github.com/rust-lang/crates.io-index" 1153 | checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" 1154 | dependencies = [ 1155 | "form_urlencoded", 1156 | "idna", 1157 | "matches", 1158 | "percent-encoding", 1159 | ] 1160 | 1161 | [[package]] 1162 | name = "vcpkg" 1163 | version = "0.2.15" 1164 | source = "registry+https://github.com/rust-lang/crates.io-index" 1165 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 1166 | 1167 | [[package]] 1168 | name = "version_check" 1169 | version = "0.9.4" 1170 | source = "registry+https://github.com/rust-lang/crates.io-index" 1171 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 1172 | 1173 | [[package]] 1174 | name = "walkdir" 1175 | version = "2.3.2" 1176 | source = "registry+https://github.com/rust-lang/crates.io-index" 1177 | checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" 1178 | dependencies = [ 1179 | "same-file", 1180 | "winapi", 1181 | "winapi-util", 1182 | ] 1183 | 1184 | [[package]] 1185 | name = "wasi" 1186 | version = "0.10.0+wasi-snapshot-preview1" 1187 | source = "registry+https://github.com/rust-lang/crates.io-index" 1188 | checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" 1189 | 1190 | [[package]] 1191 | name = "winapi" 1192 | version = "0.3.9" 1193 | source = "registry+https://github.com/rust-lang/crates.io-index" 1194 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1195 | dependencies = [ 1196 | "winapi-i686-pc-windows-gnu", 1197 | "winapi-x86_64-pc-windows-gnu", 1198 | ] 1199 | 1200 | [[package]] 1201 | name = "winapi-i686-pc-windows-gnu" 1202 | version = "0.4.0" 1203 | source = "registry+https://github.com/rust-lang/crates.io-index" 1204 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1205 | 1206 | [[package]] 1207 | name = "winapi-util" 1208 | version = "0.1.5" 1209 | source = "registry+https://github.com/rust-lang/crates.io-index" 1210 | checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" 1211 | dependencies = [ 1212 | "winapi", 1213 | ] 1214 | 1215 | [[package]] 1216 | name = "winapi-x86_64-pc-windows-gnu" 1217 | version = "0.4.0" 1218 | source = "registry+https://github.com/rust-lang/crates.io-index" 1219 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1220 | 1221 | [[package]] 1222 | name = "windows-sys" 1223 | version = "0.42.0" 1224 | source = "registry+https://github.com/rust-lang/crates.io-index" 1225 | checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" 1226 | dependencies = [ 1227 | "windows_aarch64_gnullvm", 1228 | "windows_aarch64_msvc", 1229 | "windows_i686_gnu", 1230 | "windows_i686_msvc", 1231 | "windows_x86_64_gnu", 1232 | "windows_x86_64_gnullvm", 1233 | "windows_x86_64_msvc", 1234 | ] 1235 | 1236 | [[package]] 1237 | name = "windows_aarch64_gnullvm" 1238 | version = "0.42.0" 1239 | source = "registry+https://github.com/rust-lang/crates.io-index" 1240 | checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e" 1241 | 1242 | [[package]] 1243 | name = "windows_aarch64_msvc" 1244 | version = "0.42.0" 1245 | source = "registry+https://github.com/rust-lang/crates.io-index" 1246 | checksum = "dd0f252f5a35cac83d6311b2e795981f5ee6e67eb1f9a7f64eb4500fbc4dcdb4" 1247 | 1248 | [[package]] 1249 | name = "windows_i686_gnu" 1250 | version = "0.42.0" 1251 | source = "registry+https://github.com/rust-lang/crates.io-index" 1252 | checksum = "fbeae19f6716841636c28d695375df17562ca208b2b7d0dc47635a50ae6c5de7" 1253 | 1254 | [[package]] 1255 | name = "windows_i686_msvc" 1256 | version = "0.42.0" 1257 | source = "registry+https://github.com/rust-lang/crates.io-index" 1258 | checksum = "84c12f65daa39dd2babe6e442988fc329d6243fdce47d7d2d155b8d874862246" 1259 | 1260 | [[package]] 1261 | name = "windows_x86_64_gnu" 1262 | version = "0.42.0" 1263 | source = "registry+https://github.com/rust-lang/crates.io-index" 1264 | checksum = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed" 1265 | 1266 | [[package]] 1267 | name = "windows_x86_64_gnullvm" 1268 | version = "0.42.0" 1269 | source = "registry+https://github.com/rust-lang/crates.io-index" 1270 | checksum = "09d525d2ba30eeb3297665bd434a54297e4170c7f1a44cad4ef58095b4cd2028" 1271 | 1272 | [[package]] 1273 | name = "windows_x86_64_msvc" 1274 | version = "0.42.0" 1275 | source = "registry+https://github.com/rust-lang/crates.io-index" 1276 | checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" 1277 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dura" 3 | version = "0.2.0-dev" 4 | edition = "2021" 5 | authors = ["Tim Kellogg and the Internet"] 6 | description = "Dura backs up your work automatically via Git commits." 7 | license = "Apache-2.0" 8 | homepage = "https://github.com/tkellogg/dura/" 9 | repository = "https://github.com/tkellogg/dura/" 10 | documentation = "https://github.com/tkellogg/dura/blob/master/README.md" 11 | 12 | [dependencies] 13 | anyhow = "1.0.66" 14 | clap = { version = "4.0", features = ["cargo", "string"] } 15 | git2 = "0.15" 16 | hdrhistogram = "7.5.2" 17 | dirs = "4.0.0" 18 | tokio = { version = "1", features = ["full"] } 19 | serde = { version = "1.0", features = ["derive", "rc"] } 20 | serde_json = "1.0" 21 | chrono = "0.4" 22 | toml = "0.5.8" 23 | tracing = { version = "0.1.5"} 24 | tracing-subscriber = { version = "0.3", features = ["env-filter", "registry"] } 25 | walkdir = "2.3.2" 26 | sudo = "0.6.0" 27 | 28 | [dev-dependencies] 29 | tempfile = "3.2.0" 30 | serial_test = "0.9.0" 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Tim Kellogg 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dura 2 | 3 | [![Build][build badge]][build action] 4 | 5 | Dura is a background process that watches your Git repositories and commits your uncommitted changes without impacting 6 | HEAD, the current branch, or the Git index (staged files). If you ever get into an "oh snap!" situation where you think 7 | you just lost days of work, checkout a `dura` branch and recover. 8 | 9 | Without `dura`, you use Ctrl-Z in your editor to get back to a good state. That's so 2021. Computers crash and Ctrl-Z 10 | only works on files independently. Dura snapshots changes across the entire repository as-you-go, so you can revert to 11 | "4 hours ago" instead of "hit Ctrl-Z like 40 times or whatever". Finally, some sanity. 12 | 13 | ## How to use 14 | 15 | Run it in the background: 16 | 17 | ```bash 18 | $ dura serve & 19 | ``` 20 | 21 | The `serve` can happen in any directory. The `&` is Unix shell syntax to run the process in the background, meaning that you can start 22 | `dura` and then keep using the same terminal window while `dura` keeps running. You could also run `dura serve` in a 23 | window that you keep open. 24 | 25 | Let `dura` know which repositories to watch: 26 | 27 | ```bash 28 | $ cd some/git/repo 29 | $ dura watch 30 | ``` 31 | 32 | Right now, you have to `cd` into each repo that you want to watch, one at a time. 33 | 34 | If you have thoughts on how to do this better, share them [here](https://github.com/tkellogg/dura/issues/3). Until that's sorted, you can 35 | run something like `find ~ -type d -name .git -prune | xargs -I= sh -c "cd =/..; dura watch"` to get started on your existing repos. 36 | 37 | Make some changes. No need to commit or even stage them. Use any Git tool to see the `dura` branches: 38 | 39 | ```bash 40 | $ git log --all 41 | ``` 42 | 43 | `dura` produces a branch for every real commit you make and makes commits to that branch without impacting your working 44 | copy. You keep using Git exactly as you did before. 45 | 46 | 47 | Let `dura` know that it should stop running in the background with the `kill` command. 48 | 49 | ```bash 50 | $ dura kill 51 | ``` 52 | 53 | The `kill` can happen in any directory. It indicates to the `serve` 54 | process that it should exit if there is a `serve` process running. 55 | 56 | ## How to recover 57 | 58 | The `dura` branch that's tracking your current uncommitted changes looks like `dura/f4a88e5ea0f1f7492845f7021ae82db70f14c725`. 59 | In $SHELL, you can get the branch name via: 60 | 61 | ```bash 62 | $ echo "dura/$(git rev-parse HEAD)" 63 | ``` 64 | 65 | Use `git log` or [`tig`](https://jonas.github.io/tig/) to figure out which commit you want to rollback to. Copy the hash 66 | and then run something like 67 | 68 | ```bash 69 | # Or, if you don't trust dura yet, `git stash` 70 | $ git reset HEAD --hard 71 | # get the changes into your working directory 72 | $ git checkout $THE_HASH 73 | # last few commands reset HEAD back to master but with changes uncommitted 74 | $ git checkout -b temp-branch 75 | $ git reset master 76 | $ git checkout master 77 | $ git branch -D temp-branch 78 | ``` 79 | 80 | If you're interested in improving this experience, [collaborate here](https://github.com/tkellogg/dura/issues/4). 81 | 82 | ## Install 83 | 84 | ### Cargo Install 85 | 1. Install Cargo 86 | 2. If you want run release version, type ```cargo install dura``` else type ```cargo install --git https://github.com/tkellogg/dura``` 87 | 88 | ### By Source 89 | 90 | 1. Install Rust (e.g., `brew install rustup && brew install rust`) 91 | 2. Clone this repository (e.g., `git clone https://github.com/tkellogg/dura.git`) 92 | 3. Navigate to repository base directory (`cd dura`) 93 | 4. Run `cargo install --path .` **Note:** If you receive a failure fetching the cargo dependencies try using the local [git client for cargo fetches](https://doc.rust-lang.org/cargo/reference/config.html#netgit-fetch-with-cli). `CARGO_NET_GIT_FETCH_WITH_CLI=true cargo install --path .` 94 | 95 | ### Mac OS X 96 | 97 | This installs `dura` and sets up a launchctl service to keep it running. 98 | 99 | ```bash 100 | $ brew install dura 101 | ``` 102 | 103 | ### Windows 104 | 1. Download [rustup-init](https://www.rust-lang.org/tools/install) 105 | 2. Clone this repository (e.g., `git clone https://github.com/tkellogg/dura.git`) 106 | 3. Navigate to repository base directory (`cd dura`) 107 | 4. Run `cargo install --path .` **Note:** If you receive a failure fetching the cargo dependencies try using the local [git client for cargo fetches](https://doc.rust-lang.org/cargo/reference/config.html#netgit-fetch-with-cli). `CARGO_NET_GIT_FETCH_WITH_CLI=true cargo install --path .` 108 | 109 | ### Arch Linux 110 | 111 | ```bash 112 | $ paru -S dura-git 113 | ``` 114 | 115 | ### Nix / Nixos 116 | 117 | [Nix][nix website] is a tool that takes a unique approach to package 118 | management and system configuration. NixOS is a Linux distribution 119 | built on top of the Nix package manager. 120 | 121 | To run `dura` locally using pre-compiled binaries: 122 | 123 | ```bash 124 | nix shell nixpkgs#dura 125 | ``` 126 | 127 | If you're willing to contribute and develop, `dura` also provides its 128 | own ready-to-use [Nix flake][nix flake]. 129 | 130 | To build and run the latest development version of `dura` locally: 131 | 132 | ```bash 133 | nix run github:tkellogg/dura 134 | ``` 135 | 136 | To run a development environment with the required tools 137 | to develop: 138 | 139 | ```bash 140 | nix develop github:tkellogg/dura 141 | ``` 142 | 143 | ## FAQ 144 | 145 | ### Is this stable? 146 | 147 | Yes. Lots of people have been using it since 2022-01-01 without issue. It uses [libgit2](https://libgit2.org/) to make the commits, so it's fairly battle hardened. 148 | 149 | ### How often does this check for changes? 150 | 151 | Every now and then, like 5 seconds or so. Internally there's a control loop that sleeps 5 seconds between iterations, so it 152 | runs less frequently than every 5 seconds (potentially a lot less frequently, if there's a lot of work to do). 153 | 154 | 155 | Brought to you by Tim Kellogg. 156 | 157 | 158 | [build badge]: https://github.com/tkellogg/dura/actions/workflows/build.yaml/badge.svg 159 | [build action]: https://github.com/tkellogg/dura/actions/workflows/build.yaml 160 | [nix website]: https://nixos.org/ 161 | [nix flake]: https://nixos.wiki/wiki/Flakes 162 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "locked": { 5 | "lastModified": 1653893745, 6 | "narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=", 7 | "owner": "numtide", 8 | "repo": "flake-utils", 9 | "rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "numtide", 14 | "repo": "flake-utils", 15 | "type": "github" 16 | } 17 | }, 18 | "flake-utils_2": { 19 | "locked": { 20 | "lastModified": 1637014545, 21 | "narHash": "sha256-26IZAc5yzlD9FlDT54io1oqG/bBoyka+FJk5guaX4x4=", 22 | "owner": "numtide", 23 | "repo": "flake-utils", 24 | "rev": "bba5dcc8e0b20ab664967ad83d24d64cb64ec4f4", 25 | "type": "github" 26 | }, 27 | "original": { 28 | "owner": "numtide", 29 | "repo": "flake-utils", 30 | "type": "github" 31 | } 32 | }, 33 | "nixpkgs": { 34 | "locked": { 35 | "lastModified": 1654953433, 36 | "narHash": "sha256-TwEeh4r50NdWHFAHQSyjCk2cZxgwUfcCCAJOhPdXB28=", 37 | "owner": "nixos", 38 | "repo": "nixpkgs", 39 | "rev": "90cd5459a1fd707819b9a3fb9c852beaaac3b79a", 40 | "type": "github" 41 | }, 42 | "original": { 43 | "owner": "nixos", 44 | "ref": "nixos-unstable", 45 | "repo": "nixpkgs", 46 | "type": "github" 47 | } 48 | }, 49 | "nixpkgs_2": { 50 | "locked": { 51 | "lastModified": 1637453606, 52 | "narHash": "sha256-Gy6cwUswft9xqsjWxFYEnx/63/qzaFUwatcbV5GF/GQ=", 53 | "owner": "NixOS", 54 | "repo": "nixpkgs", 55 | "rev": "8afc4e543663ca0a6a4f496262cd05233737e732", 56 | "type": "github" 57 | }, 58 | "original": { 59 | "owner": "NixOS", 60 | "ref": "nixpkgs-unstable", 61 | "repo": "nixpkgs", 62 | "type": "github" 63 | } 64 | }, 65 | "root": { 66 | "inputs": { 67 | "flake-utils": "flake-utils", 68 | "nixpkgs": "nixpkgs", 69 | "rust-overlay": "rust-overlay" 70 | } 71 | }, 72 | "rust-overlay": { 73 | "inputs": { 74 | "flake-utils": "flake-utils_2", 75 | "nixpkgs": "nixpkgs_2" 76 | }, 77 | "locked": { 78 | "lastModified": 1655002087, 79 | "narHash": "sha256-ApxncWKkIIrckV851+S6Xlw7yO+ymLOp0h7De+frCT8=", 80 | "owner": "oxalica", 81 | "repo": "rust-overlay", 82 | "rev": "e04a88d7f859ae9ec42267866bb68c1a741e6859", 83 | "type": "github" 84 | }, 85 | "original": { 86 | "owner": "oxalica", 87 | "repo": "rust-overlay", 88 | "type": "github" 89 | } 90 | } 91 | }, 92 | "root": "root", 93 | "version": 7 94 | } 95 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Dura build and development environment"; 3 | 4 | # Provides abstraction to boiler-code when specifying multi-platform outputs. 5 | inputs = { 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 8 | rust-overlay.url = "github:oxalica/rust-overlay"; 9 | }; 10 | 11 | outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }: 12 | flake-utils.lib.eachDefaultSystem (system: 13 | let 14 | shortRev = if (self ? shortRev) then self.shortRev else "dev-${self.lastModifiedDate}"; 15 | 16 | pkgs = import nixpkgs { 17 | inherit system; 18 | overlays = [ rust-overlay.overlay ]; 19 | }; 20 | 21 | dura = pkgs.rustPlatform.buildRustPackage { 22 | pname = "dura"; 23 | version = "${shortRev}"; 24 | description = "A background process that saves uncommited changes on git"; 25 | 26 | src = self; 27 | 28 | cargoLock = { 29 | lockFile = self + "/Cargo.lock"; 30 | }; 31 | 32 | buildInputs = [ 33 | pkgs.openssl 34 | ]; 35 | 36 | nativeBuildInputs = [ 37 | pkgs.rust-bin.stable.latest.minimal 38 | pkgs.pkg-config 39 | ]; 40 | 41 | DURA_VERSION_SUFFIX = "${shortRev}"; 42 | }; 43 | 44 | packages = flake-utils.lib.flattenTree { 45 | inherit dura; 46 | }; 47 | 48 | apps = { 49 | dura = flake-utils.lib.mkApp { drv = packages.dura; }; 50 | }; 51 | in 52 | rec { 53 | defaultPackage = packages.dura; 54 | defaultApp = apps.dura; 55 | devShell = pkgs.mkShell { 56 | DURA_VERSION_SUFFIX = dura.version; 57 | RUST_SRC_PATH = "${pkgs.rust.packages.stable.rustPlatform.rustLibSrc}"; 58 | 59 | buildInputs = [ 60 | pkgs.openssl 61 | pkgs.pkgconfig 62 | (pkgs.rust-bin.stable.latest.default.override { extensions = [ "rust-src" ]; }) 63 | ]; 64 | }; 65 | 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /scripts/dura.fish: -------------------------------------------------------------------------------- 1 | set script (realpath (status --current-filename)) 2 | if pgrep -f $script >/dev/null 2>/dev/null 3 | exit 0 4 | end 5 | 6 | function tempfile 7 | if command -v mktemp >/dev/null 2>/dev/null 8 | command mktemp 9 | else 10 | command tempfile 11 | end 12 | end 13 | 14 | set MONITOR_TEMP_FILE (tempfile) 15 | set MONITOR_PID_FILE (tempfile) 16 | 17 | function duraMonitor 18 | pkill -P (cat $MONITOR_PID_FILE) 2>/dev/null 19 | pkill (cat $MONITOR_PID_FILE) 2>/dev/null 20 | 21 | echo ' 22 | set repos (cat ~/.config/dura/config.json | jq -rc \'.repos | keys | join("§")\' 2>/dev/null) 23 | set pollingSeconds (cat ~/.config/dura/config.json | jq -r ".pollingSeconds // 5") 24 | 25 | fswatch -e .git -0 -l $pollingSeconds -r (string split "§" -- $repos) | while read -l -z path 26 | cd $path 2>/dev/null || cd (dirname $path) && cd (git rev-parse --show-toplevel) && dura capture 27 | end 28 | ' >$MONITOR_TEMP_FILE 29 | 30 | fish $MONITOR_TEMP_FILE & 31 | jobs -p >$MONITOR_PID_FILE 32 | end 33 | 34 | 35 | duraMonitor 36 | fswatch -0 -l 3 ~/.config/dura/config.json | while read -l -z path 37 | duraMonitor 38 | end 39 | -------------------------------------------------------------------------------- /scripts/pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # This script is intended to be run before committing. It represents what's don in CI anyway, 3 | # but should reduce the frustration of getting your commits rejected. This isn't identical to 4 | # what happens in CI, it's a much faster version, it does everything in DEBUG. 5 | 6 | # Failed commands should cause the entire script to fail immediately 7 | set -e 8 | 9 | echo "################################" 10 | echo "### cargo test" 11 | echo "################################" 12 | cargo test 13 | 14 | echo "################################" 15 | echo "### cargo clippy" 16 | echo "################################" 17 | cargo clippy --all-targets --all-features -- -D warnings 18 | 19 | echo "################################" 20 | echo "### cargo fmt" 21 | echo "################################" 22 | # This doesn't fail, it just leaves files changed 23 | cargo fmt 24 | if [[ ! -z "$(git diff-index --name-only HEAD --)" ]]; then 25 | echo "Error: Git changes present, maybe from 'cargo fmt', consider committing" 26 | exit 1 27 | fi 28 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | use std::fs::{create_dir_all, File}; 3 | use std::io::{BufReader, Read}; 4 | use std::path::{Path, PathBuf}; 5 | use std::rc::Rc; 6 | use std::{env, fs}; 7 | 8 | use serde::{Deserialize, Serialize}; 9 | 10 | use crate::git_repo_iter::GitRepoIter; 11 | 12 | type Result = std::result::Result>; 13 | 14 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] 15 | pub struct WatchConfig { 16 | pub include: Vec, 17 | pub exclude: Vec, 18 | pub max_depth: u8, 19 | } 20 | 21 | impl WatchConfig { 22 | pub fn new() -> Self { 23 | Self { 24 | include: vec![], 25 | exclude: vec![], 26 | max_depth: 255, 27 | } 28 | } 29 | } 30 | 31 | impl Default for WatchConfig { 32 | fn default() -> Self { 33 | WatchConfig::new() 34 | } 35 | } 36 | 37 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 38 | pub struct Config { 39 | // When commit_exclude_git_config is true, 40 | // never use any git configuration to sign dura's commits. 41 | // Defaults to false 42 | #[serde(default)] 43 | pub commit_exclude_git_config: bool, 44 | pub commit_author: Option, 45 | pub commit_email: Option, 46 | pub repos: BTreeMap>, 47 | } 48 | 49 | impl Config { 50 | pub fn empty() -> Self { 51 | Self { 52 | commit_exclude_git_config: false, 53 | commit_author: None, 54 | commit_email: None, 55 | repos: BTreeMap::new(), 56 | } 57 | } 58 | 59 | pub fn default_path() -> PathBuf { 60 | Self::get_dura_config_home().join("config.toml") 61 | } 62 | 63 | /// Location of all config. By default 64 | /// 65 | /// Linux : $XDG_CONFIG_HOME/dura or $HOME/.config/dura 66 | /// macOS : $HOME/Library/Application Support 67 | /// Windows : %AppData%\Roaming\dura 68 | /// 69 | /// This can be overridden by setting DURA_CONFIG_HOME environment variable. 70 | fn get_dura_config_home() -> PathBuf { 71 | // The environment variable lets us run tests independently, but I'm sure someone will come 72 | // up with another reason to use it. 73 | if let Ok(env_var) = env::var("DURA_CONFIG_HOME") { 74 | if !env_var.is_empty() { 75 | return env_var.into(); 76 | } 77 | } 78 | 79 | dirs::config_dir() 80 | .expect("Could not find your config directory. The default is ~/.config/dura but it can also \ 81 | be controlled by setting the DURA_CONFIG_HOME environment variable.") 82 | .join("dura") 83 | } 84 | 85 | /// Load Config from default path 86 | pub fn load() -> Self { 87 | Self::load_file(Self::default_path().as_path()).unwrap_or_else(|_| Self::empty()) 88 | } 89 | 90 | pub fn load_file(path: &Path) -> Result { 91 | let mut reader = BufReader::new(File::open(path)?); 92 | 93 | let mut buffer = Vec::new(); 94 | reader.read_to_end(&mut buffer)?; 95 | 96 | let res = toml::from_slice(buffer.as_slice())?; 97 | Ok(res) 98 | } 99 | 100 | /// Save config to disk in ~/.config/dura/config.toml 101 | pub fn save(&self) { 102 | self.save_to_path(Self::default_path().as_path()) 103 | } 104 | 105 | pub fn create_dir(path: &Path) { 106 | if let Some(dir) = path.parent() { 107 | create_dir_all(dir) 108 | .unwrap_or_else(|_| panic!("Failed to create directory at `{}`.\ 109 | Dura stores its configuration in `{}/config.toml`, \ 110 | where you can instruct dura to watch patterns of Git repositories, among other things. \ 111 | See https://github.com/tkellogg/dura for more information.", dir.display(), path.display())) 112 | } 113 | } 114 | 115 | /// Attempts to create parent dirs, serialize `self` as TOML and write to disk. 116 | pub fn save_to_path(&self, path: &Path) { 117 | Self::create_dir(path); 118 | 119 | let config_string = match toml::to_string(self) { 120 | Ok(v) => v, 121 | Err(e) => { 122 | println!("Unexpected error when deserializing config: {e}"); 123 | return; 124 | } 125 | }; 126 | 127 | match fs::write(path, config_string) { 128 | Ok(_) => (), 129 | Err(e) => println!("Unable to initialize dura config file: {e}"), 130 | } 131 | } 132 | 133 | pub fn set_watch(&mut self, path: String, cfg: WatchConfig) { 134 | let abs_path = fs::canonicalize(path).expect("The provided path is not a directory"); 135 | let abs_path = abs_path 136 | .to_str() 137 | .expect("The provided path is not valid unicode"); 138 | 139 | if self.repos.contains_key(abs_path) { 140 | println!("{abs_path} is already being watched") 141 | } else { 142 | self.repos.insert(abs_path.to_string(), Rc::new(cfg)); 143 | println!("Started watching {abs_path}") 144 | } 145 | } 146 | 147 | pub fn set_unwatch(&mut self, path: String) { 148 | let abs_path = fs::canonicalize(path).expect("The provided path is not a directory"); 149 | let abs_path = abs_path 150 | .to_str() 151 | .expect("The provided path is not valid unicode") 152 | .to_string(); 153 | 154 | match self.repos.remove(&abs_path) { 155 | Some(_) => { 156 | println!("Stopped watching {abs_path}"); 157 | } 158 | None => println!("{abs_path} is not being watched"), 159 | } 160 | } 161 | 162 | pub fn git_repos(&self) -> GitRepoIter { 163 | GitRepoIter::new(self) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/database.rs: -------------------------------------------------------------------------------- 1 | use std::fs::{create_dir_all, File}; 2 | use std::io::Result; 3 | use std::path::{Path, PathBuf}; 4 | use std::{env, fs, io}; 5 | 6 | use serde::{Deserialize, Serialize}; 7 | 8 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 9 | pub struct RuntimeLock { 10 | pub pid: Option, 11 | } 12 | 13 | impl RuntimeLock { 14 | pub fn empty() -> Self { 15 | Self { pid: None } 16 | } 17 | 18 | pub fn default_path() -> PathBuf { 19 | Self::get_dura_cache_home().join("runtime.db") 20 | } 21 | 22 | /// Location of all database files. By default 23 | /// 24 | /// Linux : $XDG_CACHE_HOME/dura or $HOME/.cache/dura 25 | /// macOS : $HOME/Library/Caches 26 | /// Windows : %AppData%\Local\dura 27 | /// 28 | /// This can be overridden by setting DURA_CACHE_HOME environment variable. 29 | fn get_dura_cache_home() -> PathBuf { 30 | // The environment variable lets us run tests independently, but I'm sure someone will come 31 | // up with another reason to use it. 32 | if let Ok(env_var) = env::var("DURA_CACHE_HOME") { 33 | if !env_var.is_empty() { 34 | return env_var.into(); 35 | } 36 | } 37 | 38 | dirs::cache_dir() 39 | .expect("Could not find your cache directory. The default is ~/.cache/dura but it can also \ 40 | be controlled by setting the DURA_CACHE_HOME environment variable.") 41 | .join("dura") 42 | } 43 | 44 | /// Load Config from default path 45 | pub fn load() -> Self { 46 | Self::load_file(Self::default_path().as_path()).unwrap_or_else(|_| Self::empty()) 47 | } 48 | 49 | pub fn load_file(path: &Path) -> Result { 50 | let reader = io::BufReader::new(File::open(path)?); 51 | let res = serde_json::from_reader(reader)?; 52 | Ok(res) 53 | } 54 | 55 | /// Save config to disk in ~/.cache/dura/runtime.db 56 | pub fn save(&self) { 57 | self.save_to_path(Self::default_path().as_path()) 58 | } 59 | 60 | pub fn create_dir(path: &Path) { 61 | if let Some(dir) = path.parent() { 62 | create_dir_all(dir).unwrap_or_else(|_| { 63 | panic!( 64 | "Failed to create directory at `{}`.\ 65 | Dura stores its runtime cache in `{}/runtime.db`. \ 66 | See https://github.com/tkellogg/dura for more information.", 67 | dir.display(), 68 | path.display() 69 | ) 70 | }) 71 | } 72 | } 73 | 74 | /// Attempts to create parent dirs, serialize `self` as JSON and write to disk. 75 | pub fn save_to_path(&self, path: &Path) { 76 | Self::create_dir(path); 77 | 78 | let json = serde_json::to_string(self).unwrap(); 79 | fs::write(path, json).unwrap() 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/git_repo_iter.rs: -------------------------------------------------------------------------------- 1 | use std::collections::btree_map; 2 | use std::fs; 3 | use std::path::{Path, PathBuf}; 4 | use std::rc::Rc; 5 | 6 | use crate::config::{Config, WatchConfig}; 7 | use crate::snapshots; 8 | 9 | /// Internal structure to facilitate "recursion" without blowing up the stack. Without this, we 10 | /// could call self.next() recursively whenever there was an I/O error or when we reached the end 11 | /// of a directory listing. There's no stack space used because we just mutate GitRepoIter, so 12 | /// might as well turn it into a loop. 13 | enum CallState { 14 | Yield(PathBuf), 15 | Recurse, 16 | Done, 17 | } 18 | 19 | /// Iterator over all Git repos covered by a config. 20 | /// 21 | /// The process is naturally recursive, traversing a directory structure, which made it a poor fit 22 | /// for a more typical filter/map chain. 23 | /// 24 | /// Function recursion is used in a few cases: 25 | /// 1. Errors: If we get an I/O error, we'll call self.next() again 26 | /// 2. Empty iterator: If we get to the end of a sub-iterator, pop & start from the top 27 | /// 28 | pub struct GitRepoIter<'a> { 29 | config_iter: btree_map::Iter<'a, String, Rc>, 30 | /// A stack, because we can't use recursion with an iterator (at least not between elements) 31 | sub_iter: Vec<(Rc, Rc, fs::ReadDir)>, 32 | } 33 | 34 | impl<'a> GitRepoIter<'a> { 35 | pub fn new(config: &'a Config) -> Self { 36 | Self { 37 | config_iter: config.repos.iter(), 38 | sub_iter: Vec::new(), 39 | } 40 | } 41 | 42 | fn get_next(&mut self) -> CallState { 43 | // pop 44 | // 45 | // Use pop here to manage the lifetime of the iterator. If we used last/peek, we would 46 | // borrow a shared reference, which precludes us from borrowing as mutable when we want to 47 | // use the iterator. But that means we have to return it to the vec. 48 | match self.sub_iter.pop() { 49 | Some((base_path, watch_config, mut dir_iter)) => { 50 | let mut next_next: Option<(Rc, Rc, fs::ReadDir)> = None; 51 | let mut ret_val = CallState::Recurse; 52 | let max_depth: usize = watch_config.max_depth.into(); 53 | if let Some(Ok(entry)) = dir_iter.next() { 54 | let child_path = entry.path(); 55 | if is_valid_directory(base_path.as_path(), child_path.as_path(), &watch_config) 56 | { 57 | if snapshots::is_repo(child_path.as_path()) { 58 | ret_val = CallState::Yield(child_path); 59 | } else if self.sub_iter.len() < max_depth { 60 | if let Ok(child_dir_iter) = fs::read_dir(child_path.as_path()) { 61 | next_next = Some(( 62 | Rc::clone(&base_path), 63 | Rc::clone(&watch_config), 64 | child_dir_iter, 65 | )) 66 | } 67 | } 68 | } 69 | // un-pop 70 | self.sub_iter 71 | .push((Rc::clone(&base_path), Rc::clone(&watch_config), dir_iter)); 72 | } 73 | if let Some(tuple) = next_next { 74 | // directory recursion 75 | self.sub_iter.push(tuple); 76 | } 77 | ret_val 78 | } 79 | None => { 80 | // Finished dir, queue up next hashmap pair 81 | match self.config_iter.next() { 82 | Some((base_path, watch_config)) => { 83 | let path = PathBuf::from(base_path); 84 | let dir_iter_opt = path.parent().and_then(|p| fs::read_dir(p).ok()); 85 | if let Some(dir_iter) = dir_iter_opt { 86 | // clone because we're going from more global to less global scope 87 | self.sub_iter 88 | .push((Rc::new(path), Rc::clone(watch_config), dir_iter)); 89 | } 90 | CallState::Recurse 91 | } 92 | // The end. The real end. This is it. 93 | None => CallState::Done, 94 | } 95 | } 96 | } 97 | } 98 | } 99 | 100 | impl<'a> Iterator for GitRepoIter<'a> { 101 | type Item = PathBuf; 102 | 103 | fn next(&mut self) -> Option { 104 | loop { 105 | match self.get_next() { 106 | CallState::Yield(path) => return Some(path), 107 | CallState::Recurse => continue, 108 | CallState::Done => return None, 109 | } 110 | } 111 | } 112 | } 113 | 114 | /// Checks the provided `child_path` is a directory. 115 | /// If either `includes` or `excludes` are set, 116 | /// checks whether the path is included/excluded respectively. 117 | fn is_valid_directory(base_path: &Path, child_path: &Path, value: &WatchConfig) -> bool { 118 | if !child_path.is_dir() { 119 | return false; 120 | } 121 | 122 | if !child_path.starts_with(base_path) { 123 | return false; 124 | } 125 | 126 | let includes = &value.include; 127 | let excludes = &value.exclude; 128 | 129 | let mut include = true; 130 | 131 | if !excludes.is_empty() { 132 | include = !excludes 133 | .iter() 134 | .any(|exclude| child_path.starts_with(base_path.join(exclude))); 135 | } 136 | 137 | if !include && !includes.is_empty() { 138 | include = includes 139 | .iter() 140 | .any(|include| base_path.join(include).starts_with(child_path)); 141 | } 142 | 143 | include 144 | } 145 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod database; 3 | pub mod git_repo_iter; 4 | pub mod log; 5 | pub mod logger; 6 | pub mod metrics; 7 | pub mod poll_guard; 8 | pub mod poller; 9 | pub mod snapshots; 10 | -------------------------------------------------------------------------------- /src/log.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | use std::time::{Duration, Instant}; 3 | 4 | use hdrhistogram::Histogram; 5 | use serde::{Deserialize, Serialize}; 6 | use tracing::trace; 7 | 8 | use crate::snapshots::CaptureStatus; 9 | 10 | #[derive(Debug, Serialize, Deserialize)] 11 | pub enum Operation { 12 | Snapshot { 13 | repo: String, 14 | op: Option, 15 | error: Option, 16 | latency: f32, 17 | }, 18 | CollectStats { 19 | per_dir_stats: Histo, 20 | loop_stats: Histo, 21 | }, 22 | } 23 | 24 | impl Operation { 25 | pub fn should_log(&self) -> bool { 26 | match self { 27 | Operation::Snapshot { 28 | repo: _, 29 | op, 30 | error, 31 | latency: _, 32 | } => op.is_some() || error.is_some(), 33 | Operation::CollectStats { .. } => { 34 | true // logic punted to StatCollector 35 | } 36 | } 37 | } 38 | 39 | pub fn log_str(&mut self) -> String { 40 | // This unwrap seems safe, afaict. We're not cramming any user supplied strings in here. 41 | serde_json::to_string(self).expect("Couldn't serialize to JSON") 42 | } 43 | } 44 | 45 | #[derive(Debug, Serialize, Deserialize)] 46 | struct Stats { 47 | dir_stats: Histo, 48 | loop_stats: Histo, 49 | } 50 | 51 | /// A serializable form of a hdrhistogram, mainly just for logging out 52 | /// in a way we want to read it 53 | #[derive(Debug, Serialize, Deserialize)] 54 | pub struct Histo { 55 | mean: f64, 56 | count: u64, 57 | min: u64, 58 | max: u64, 59 | percentiles: Vec, 60 | } 61 | 62 | /// For serializing to JSON 63 | /// 64 | /// Choice of tiny names because this one shows up a lot, one 65 | /// for each percentile bucket. It shows a lot more data 66 | /// points at the upper percentiles, so we need to capture 67 | /// both percentile and associated millisecond value. 68 | #[derive(Debug, Serialize, Deserialize)] 69 | pub struct Percentile { 70 | pct: f64, 71 | val: u64, 72 | } 73 | 74 | impl Histo { 75 | pub fn from_histogram(hist: &Histogram) -> Histo { 76 | Self { 77 | mean: hist.mean(), 78 | count: hist.len(), 79 | min: hist.min(), 80 | max: hist.max(), 81 | percentiles: hist 82 | .iter_quantiles(2) 83 | .map(|q| Percentile { 84 | pct: q.percentile(), 85 | val: q.value_iterated_to(), 86 | }) 87 | .collect(), 88 | } 89 | } 90 | } 91 | 92 | #[derive(Debug)] 93 | pub struct StatCollector { 94 | start: Instant, 95 | per_dir_stats: Histogram, 96 | loop_stats: Histogram, 97 | } 98 | 99 | /// 5 minutes in milliseconds 100 | const MAX_LATENCY_IMAGINABLE: u64 = 5 * 60 * 1000; 101 | 102 | /// How many seconds between logging stats? 103 | const STAT_LOG_INTERVAL: f32 = 600.0; 104 | 105 | impl StatCollector { 106 | pub fn new() -> Self { 107 | Self { 108 | start: Instant::now(), 109 | per_dir_stats: Histogram::::new_with_max(MAX_LATENCY_IMAGINABLE, 3).unwrap(), 110 | loop_stats: Histogram::::new_with_max(MAX_LATENCY_IMAGINABLE, 3).unwrap(), 111 | } 112 | } 113 | 114 | pub fn to_op(&self) -> Operation { 115 | Operation::CollectStats { 116 | per_dir_stats: Histo::from_histogram(&self.per_dir_stats), 117 | loop_stats: Histo::from_histogram(&self.loop_stats), 118 | } 119 | } 120 | 121 | pub fn should_log(&self) -> bool { 122 | let elapsed = (Instant::now() - self.start).as_secs_f32(); 123 | trace!( 124 | elapsed = elapsed, 125 | target = STAT_LOG_INTERVAL, 126 | "Should we log metrics?" 127 | ); 128 | elapsed > STAT_LOG_INTERVAL 129 | } 130 | 131 | pub fn log_str(&mut self) -> String { 132 | let mut op = self.to_op(); 133 | let ret = op.log_str(); 134 | self.reset(); 135 | ret 136 | } 137 | 138 | fn reset(&mut self) { 139 | self.start = Instant::now(); 140 | self.per_dir_stats.clear(); 141 | self.loop_stats.clear(); 142 | } 143 | 144 | /// Record the time it takes to process a single directory. Mainly interested to see if 145 | /// there's any outliers, the histogram should be interesting. 146 | pub fn record_dir(&mut self, latency: Duration) { 147 | let value = latency.as_millis().try_into().unwrap(); 148 | self.per_dir_stats.saturating_record(value); 149 | } 150 | 151 | /// Record the time it takes to go through all directories. I expect mean will be the 152 | /// most interesting datum. Mainly for projecting CPU usage. 153 | pub fn record_loop(&mut self, latency: Duration) { 154 | let value = latency.as_millis().try_into().unwrap(); 155 | self.loop_stats.saturating_record(value); 156 | } 157 | } 158 | 159 | impl Default for StatCollector { 160 | fn default() -> Self { 161 | Self::new() 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/logger.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use serde::ser::SerializeMap; 3 | use serde::Serializer; 4 | use std::collections::BTreeMap; 5 | use std::fmt; 6 | use std::io::Write; 7 | use tracing::field::{Field, Visit}; 8 | use tracing::Subscriber; 9 | use tracing_subscriber::fmt::MakeWriter; 10 | use tracing_subscriber::Layer; 11 | 12 | pub struct NestedJsonLayer MakeWriter<'a> + 'static> { 13 | mw: W, 14 | } 15 | 16 | impl MakeWriter<'a> + 'static> NestedJsonLayer { 17 | pub fn new(mw: W) -> Self { 18 | Self { mw } 19 | } 20 | 21 | pub fn serialize_and_write( 22 | &self, 23 | event: &tracing::Event<'_>, 24 | hm: BTreeMap<&'static str, serde_json::Value>, 25 | ) -> Result, serde_json::Error> { 26 | let mut buffer = Vec::new(); 27 | let mut serializer = serde_json::Serializer::new(&mut buffer); 28 | let mut ser_map = serializer.serialize_map(None)?; 29 | 30 | ser_map.serialize_entry("target", event.metadata().target())?; 31 | ser_map.serialize_entry("file", &event.metadata().file())?; 32 | ser_map.serialize_entry("name", event.metadata().name())?; 33 | ser_map.serialize_entry("level", &format!("{:?}", event.metadata().level()))?; 34 | ser_map.serialize_entry("fields", &hm)?; 35 | ser_map.serialize_entry("time", &Utc::now().to_rfc3339())?; 36 | ser_map.end()?; 37 | Ok(buffer) 38 | } 39 | 40 | pub fn write_all(&self, mut buffer: Vec) -> std::io::Result<()> { 41 | buffer.write_all(b"\n")?; 42 | self.mw.make_writer().write_all(&buffer) 43 | } 44 | } 45 | 46 | impl Layer for NestedJsonLayer 47 | where 48 | S: Subscriber, 49 | W: for<'a> MakeWriter<'a> + 'static, 50 | { 51 | fn on_event( 52 | &self, 53 | event: &tracing::Event<'_>, 54 | _ctx: tracing_subscriber::layer::Context<'_, S>, 55 | ) { 56 | let mut visitor = JsonVisitor::default(); 57 | event.record(&mut visitor); 58 | 59 | if let Ok(buffer) = self.serialize_and_write(event, visitor.0) { 60 | { 61 | let _ = self.write_all(buffer); 62 | } 63 | } 64 | } 65 | } 66 | 67 | #[derive(Default)] 68 | struct JsonVisitor(BTreeMap<&'static str, serde_json::Value>); 69 | 70 | impl Visit for JsonVisitor { 71 | fn record_i64(&mut self, field: &Field, value: i64) { 72 | self.0.insert(field.name(), value.into()); 73 | } 74 | 75 | fn record_u64(&mut self, field: &Field, value: u64) { 76 | self.0.insert(field.name(), value.into()); 77 | } 78 | 79 | fn record_bool(&mut self, field: &Field, value: bool) { 80 | self.0.insert(field.name(), value.into()); 81 | } 82 | 83 | fn record_str(&mut self, field: &Field, value: &str) { 84 | match serde_json::from_str::(value) { 85 | Ok(value) => { 86 | self.0.insert(field.name(), value); 87 | } 88 | Err(_) => { 89 | self.0.insert(field.name(), value.to_string().into()); 90 | } 91 | } 92 | } 93 | 94 | fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) { 95 | self.0.insert(field.name(), value.to_string().into()); 96 | } 97 | 98 | fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { 99 | let s = format!("{value:?}"); 100 | match serde_json::from_str::(&s) { 101 | Ok(value) => { 102 | self.0.insert(field.name(), value); 103 | } 104 | Err(_) => { 105 | self.0.insert(field.name(), s.into()); 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::fs::{File, OpenOptions}; 2 | use std::io::{stdin, stdout, BufReader, BufWriter, Read, Write}; 3 | use std::path::Path; 4 | use std::process; 5 | 6 | use clap::builder::IntoResettable; 7 | use clap::{ 8 | arg, crate_authors, crate_description, crate_name, crate_version, value_parser, Arg, Command, 9 | }; 10 | use dura::config::{Config, WatchConfig}; 11 | use dura::database::RuntimeLock; 12 | use dura::logger::NestedJsonLayer; 13 | use dura::metrics; 14 | use dura::poller; 15 | use dura::snapshots; 16 | use tracing::info; 17 | use tracing_subscriber::prelude::__tracing_subscriber_SubscriberExt; 18 | use tracing_subscriber::util::SubscriberInitExt; 19 | use tracing_subscriber::{EnvFilter, Registry}; 20 | 21 | #[tokio::main] 22 | async fn main() { 23 | if !check_if_user() { 24 | eprintln!("Dura cannot be run as root, to avoid data corruption"); 25 | process::exit(1); 26 | } 27 | 28 | let cwd = std::env::current_dir().expect("Failed to get current directory"); 29 | 30 | let suffix = option_env!("DURA_VERSION_SUFFIX") 31 | .map(|v| format!(" @ {}", v)) 32 | .unwrap_or_else(|| String::from("")); 33 | 34 | let version = format!("{}{}", crate_version!(), suffix); 35 | 36 | let arg_directory = Arg::new("directory") 37 | .default_value(cwd.into_os_string().into_resettable()) 38 | .help("The directory to watch. Defaults to current directory"); 39 | 40 | let matches = Command::new(crate_name!()) 41 | .about(crate_description!()) 42 | .version(version.into_resettable()) 43 | .subcommand_required(true) 44 | .arg_required_else_help(true) 45 | .author(crate_authors!()) 46 | .subcommand( 47 | Command::new("capture") 48 | .short_flag('C') 49 | .long_flag("capture") 50 | .about("Run a single backup of an entire repository. This is the one single iteration of the `serve` control loop.") 51 | .arg(arg_directory.clone()) 52 | ) 53 | .subcommand( 54 | Command::new("serve") 55 | .short_flag('S') 56 | .long_flag("serve") 57 | .about("Starts the worker that listens for file changes. If another process is already running, this will do it's best to terminate the other process.") 58 | .arg( 59 | arg!(--logfile ) 60 | .required(false) 61 | .help("Sets custom logfile. Default is logging to stdout") 62 | )) 63 | .subcommand( 64 | Command::new("watch") 65 | .short_flag('W') 66 | .long_flag("watch") 67 | .about("Add the current working directory as a repository to watch.") 68 | .arg(arg_directory.clone()) 69 | .arg(arg!(-i --include) 70 | .required(false) 71 | .action(clap::builder::ArgAction::Set) 72 | .num_args(0..) 73 | .value_parser(value_parser!(String)) 74 | .value_delimiter(',') 75 | .help("Overrides excludes by re-including specific directories relative to the watch directory.") 76 | ) 77 | .arg(arg!(-e --exclude) 78 | .required(false) 79 | .action(clap::builder::ArgAction::Set) 80 | .num_args(0..) 81 | .value_parser(value_parser!(String)) 82 | .value_delimiter(',') 83 | .help("Excludes specific directories relative to the watch directory") 84 | ) 85 | .arg(arg!(-d --maxdepth) 86 | .required(false) 87 | .action(clap::builder::ArgAction::Set) 88 | .value_parser(value_parser!(String)) 89 | .default_value(&"255".to_string()) 90 | .num_args(0..=1) 91 | .help("Determines the depth to recurse into when scanning directories") 92 | ) 93 | ) 94 | .subcommand( 95 | Command::new("unwatch") 96 | .short_flag('U') 97 | .long_flag("unwatch") 98 | .about("Remove the current working directory as a repository to watch.") 99 | .arg(arg_directory) 100 | ) 101 | .subcommand( 102 | Command::new("kill") 103 | .short_flag('K') 104 | .long_flag("kill") 105 | .about("Stop the running worker (should only be a single worker).") 106 | ) 107 | .subcommand( 108 | Command::new("metrics") 109 | .short_flag('M') 110 | .long_flag("metrics") 111 | .about("Convert logs into richer metrics about snapshots.") 112 | .arg(arg!(-i --input) 113 | .required(false) 114 | .num_args(1) 115 | .help("The log file to read. Defaults to stdin.") 116 | ) 117 | .arg(arg!(-o --output) 118 | .required(false) 119 | .num_args(1) 120 | .help("The json file to write. Defaults to stdout.") 121 | ) 122 | ) 123 | .get_matches(); 124 | 125 | match matches.subcommand() { 126 | Some(("capture", arg_matches)) => { 127 | let dir = Path::new(arg_matches.get_one::("directory").unwrap()); 128 | match snapshots::capture(dir) { 129 | Ok(oid_opt) => { 130 | if let Some(oid) = oid_opt { 131 | println!("{oid}"); 132 | } 133 | } 134 | Err(e) => { 135 | println!("Dura capture failed: {e}"); 136 | process::exit(1); 137 | } 138 | } 139 | } 140 | Some(("serve", arg_matches)) => { 141 | let env_filter = 142 | EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); 143 | 144 | match arg_matches.get_one::("logfile") { 145 | Some(logfile) => { 146 | let file = logfile.to_string(); 147 | Registry::default() 148 | .with(env_filter) 149 | .with(NestedJsonLayer::new(move || { 150 | let result_open_file = 151 | OpenOptions::new().append(true).create(true).open(&file); 152 | match result_open_file { 153 | Ok(f) => f, 154 | Err(e) => { 155 | eprintln!("Unable to open file {file} for logging due to {e}"); 156 | std::process::exit(1); 157 | } 158 | } 159 | })) 160 | .init(); 161 | } 162 | None => { 163 | Registry::default() 164 | .with(env_filter) 165 | .with(NestedJsonLayer::new(std::io::stdout)) 166 | .init(); 167 | } 168 | } 169 | 170 | info!("Started serving with dura v{}", crate_version!()); 171 | poller::start().await; 172 | } 173 | Some(("watch", arg_matches)) => { 174 | let dir = Path::new(arg_matches.get_one::("directory").unwrap()); 175 | 176 | let include = arg_matches 177 | .get_many::("include") 178 | .unwrap_or_default() 179 | .map(|s| s.to_string()) 180 | .collect::>(); 181 | let exclude = arg_matches 182 | .get_many::("exclude") 183 | .unwrap_or_default() 184 | .map(|s| s.to_string()) 185 | .collect::>(); 186 | let max_depth = arg_matches 187 | .get_one::("maxdepth") 188 | .unwrap_or(&"255".to_string()) 189 | .parse::() 190 | .expect("Max depth must be between 0-255"); 191 | 192 | let watch_config = WatchConfig { 193 | include, 194 | exclude, 195 | max_depth, 196 | }; 197 | 198 | watch_dir(dir, watch_config); 199 | } 200 | Some(("unwatch", arg_matches)) => { 201 | let dir = Path::new(arg_matches.get_one::("directory").unwrap()); 202 | unwatch_dir(dir) 203 | } 204 | Some(("kill", _)) => { 205 | kill(); 206 | } 207 | Some(("metrics", arg_matches)) => { 208 | let mut input: Box = match arg_matches.get_one::("input") { 209 | Some(input) => Box::new( 210 | File::open(input).unwrap_or_else(|_| panic!("Couldn't open '{}'", input)), 211 | ), 212 | None => Box::new(BufReader::new(stdin())), 213 | }; 214 | let mut output: Box = match arg_matches.get_one::("output") { 215 | Some(output) => Box::new( 216 | File::open(output).unwrap_or_else(|_| panic!("Couldn't open '{}'", output)), 217 | ), 218 | None => Box::new(BufWriter::new(stdout())), 219 | }; 220 | if let Err(e) = metrics::get_snapshot_metrics(&mut input, &mut output) { 221 | eprintln!("Failed: {}", e); 222 | process::exit(1); 223 | } 224 | } 225 | _ => unreachable!(), 226 | } 227 | } 228 | 229 | fn watch_dir(path: &std::path::Path, watch_config: WatchConfig) { 230 | let mut config = Config::load(); 231 | let path = path 232 | .to_str() 233 | .expect("The provided path is not valid unicode") 234 | .to_string(); 235 | 236 | config.set_watch(path, watch_config); 237 | config.save(); 238 | } 239 | 240 | fn unwatch_dir(path: &std::path::Path) { 241 | let mut config = Config::load(); 242 | let path = path 243 | .to_str() 244 | .expect("The provided path is not valid unicode") 245 | .to_string(); 246 | 247 | config.set_unwatch(path); 248 | config.save(); 249 | } 250 | 251 | #[cfg(all(unix))] 252 | fn check_if_user() -> bool { 253 | sudo::check() != sudo::RunningAs::Root 254 | } 255 | 256 | #[cfg(target_os = "windows")] 257 | fn check_if_user() -> bool { 258 | true 259 | } 260 | 261 | /// kills running dura poller 262 | /// 263 | /// poller's check to make sure that their pid is the same as the pid 264 | /// found in config, and if they are not the same they exit. This 265 | /// function does not actually kill a poller but instead indicates 266 | /// that any living poller should exit during their next check. 267 | fn kill() { 268 | let mut runtime_lock = RuntimeLock::load(); 269 | runtime_lock.pid = None; 270 | runtime_lock.save(); 271 | } 272 | -------------------------------------------------------------------------------- /src/metrics.rs: -------------------------------------------------------------------------------- 1 | use crate::log::Operation; 2 | use git2::{Oid, Repository}; 3 | use serde_json::map::Map; 4 | use serde_json::value::from_value; 5 | use serde_json::{json, Number, Value}; 6 | use std::collections::HashMap; 7 | use std::io::{self, BufRead, Write}; 8 | use std::rc::Rc; 9 | 10 | type FlexResult = std::result::Result>; 11 | 12 | /// Reads an input stream that contains dura logs and enriches them with more analytics-ready info 13 | /// like number of insertions & deletions. The result is written back out to an output stream. 14 | pub fn get_snapshot_metrics( 15 | input: &mut dyn io::Read, 16 | output: &mut dyn io::Write, 17 | ) -> FlexResult<()> { 18 | let mut reader = io::BufReader::new(input); 19 | let mut writer = io::BufWriter::new(output); 20 | let mut line: u64 = 0; // for printing better error messages 21 | let mut repo_cache: HashMap> = HashMap::new(); 22 | loop { 23 | line += 1; 24 | let mut input_line = String::new(); 25 | if reader.read_line(&mut input_line)? == 0 { 26 | return Ok(()); 27 | } 28 | match scrape_log(input_line) { 29 | Ok(Some(mut output)) => { 30 | scrape_git(&mut output, &mut repo_cache)?; 31 | writeln!(&mut writer, "{output}")?; 32 | } 33 | Ok(None) => {} 34 | // Seems like a good way to report errors, idk... 35 | Err(e) => eprintln!("line {line}: {e}"), 36 | } 37 | } 38 | } 39 | 40 | /// Scrape information out of the snapshot log. 41 | fn scrape_log(line: String) -> serde_json::Result> { 42 | let input_val: Value = serde_json::from_str(line.as_str())?; 43 | let mut output_val = Value::Object(Map::new()); 44 | 45 | if let Some(t) = input_val.get("time") { 46 | output_val["time"] = t.clone(); 47 | } 48 | 49 | if let Some(op_value) = input_val.get("fields").and_then(|f| f.get("operation")) { 50 | match from_value(op_value.clone())? { 51 | Operation::Snapshot { 52 | repo, 53 | op: Some(op), 54 | error: _, 55 | latency, 56 | } => { 57 | output_val["repo"] = Value::String(repo); 58 | if let Some(latency) = Number::from_f64(latency as f64) { 59 | output_val["latency"] = Value::Number(latency); 60 | } 61 | output_val["dura_branch"] = Value::String(op.dura_branch); 62 | output_val["commit_hash"] = Value::String(op.commit_hash); 63 | output_val["base_hash"] = Value::String(op.base_hash); 64 | } 65 | _ => return Ok(None), 66 | } 67 | } else { 68 | return Ok(None); 69 | } 70 | 71 | Ok(Some(output_val)) 72 | } 73 | 74 | /// Use the info captured from scrape_log to open a repo and capture information about the commit 75 | /// 76 | /// The repo_cache is retained between calls. This cache seems to cut runtime by 50% in a 77 | /// completely non-scientific measure. It still seems to take unexpectedly long, probably because 78 | /// it still has to open lots of files (for each commit & tree object) behind the scenes, and this 79 | /// is inherently not cache-able. 80 | fn scrape_git( 81 | value: &mut Value, 82 | repo_cache: &mut HashMap>, 83 | ) -> Result<(), git2::Error> { 84 | if let Some(repo_path_value) = value.get("repo") { 85 | let repo_path = match repo_path_value.as_str() { 86 | Some(x) => Ok(x), 87 | None => Err(git2::Error::from_str("Couldn't find 'repo' in JSON")), 88 | }?; 89 | let repo = match repo_cache.get(repo_path) { 90 | Some(repo) => Rc::clone(repo), 91 | None => { 92 | let repo = Rc::new(Repository::open(repo_path)?); 93 | repo_cache.insert(repo_path.to_string(), Rc::clone(&repo)); 94 | repo 95 | } 96 | }; 97 | let commit_opt = value 98 | .get("commit_hash") 99 | .and_then(|c| c.as_str()) 100 | .and_then(|c| Oid::from_str(c).ok()) 101 | .and_then(|c| repo.find_commit(c).ok()); 102 | let parent_commit = commit_opt.as_ref().and_then(|c| c.parents().last()); 103 | if let (Some(commit), Some(parent)) = (commit_opt, parent_commit) { 104 | let diff = 105 | repo.diff_tree_to_tree(Some(&parent.tree()?), Some(&commit.tree()?), None)?; 106 | let stats = diff.stats()?; 107 | value["num_files_changed"] = json!(stats.files_changed()); 108 | value["insertions"] = json!(stats.insertions()); 109 | value["deletions"] = json!(stats.deletions()); 110 | 111 | let files: Vec<_> = diff 112 | .deltas() 113 | .flat_map(|d| d.new_file().path()) 114 | .map(|p| p.to_str()) 115 | .collect(); 116 | value["files_changed"] = json!(files); 117 | }; 118 | } 119 | Ok(()) 120 | } 121 | 122 | #[cfg(test)] 123 | mod tests { 124 | use crate::metrics::scrape_log; 125 | 126 | #[test] 127 | fn scrape_log_happy_path() { 128 | // broken up into multiple lines to satisfy style checker, but serde_json will handle it 129 | // fine 130 | let line = r#"{"target":"dura::poller","file":"src/poller.rs", 131 | "name":"event src/poller.rs:70","level":"Level(Info)", 132 | "fields":{ 133 | "message":"info_operation","operation":{"Snapshot":{ 134 | "error":null,"latency":0.00988253,"op":{ 135 | "base_hash":"3e8e8c99b5434e726b13f56ba00d139bab57d5eb", 136 | "commit_hash":"3423d21a2937d95119982395bc1281d3d8ebe3b6", 137 | "dura_branch":"dura/3e8e8c99b5434e726b13f56ba00d139bab57d5eb" 138 | }, 139 | "repo":"/Users/timkellogg/code/dura"} 140 | } 141 | },"time":"2022-01-14T01:49:51.638031+00:00" 142 | }"#; 143 | 144 | let output = scrape_log(line.to_string()).unwrap().unwrap(); 145 | 146 | assert_eq!( 147 | output["time"].as_str(), 148 | Some("2022-01-14T01:49:51.638031+00:00") 149 | ); 150 | assert_eq!(output["repo"].as_str(), Some("/Users/timkellogg/code/dura")); 151 | assert_eq!( 152 | output["dura_branch"].as_str(), 153 | Some("dura/3e8e8c99b5434e726b13f56ba00d139bab57d5eb") 154 | ); 155 | assert_eq!( 156 | output["commit_hash"].as_str(), 157 | Some("3423d21a2937d95119982395bc1281d3d8ebe3b6") 158 | ); 159 | assert_eq!( 160 | output["base_hash"].as_str(), 161 | Some("3e8e8c99b5434e726b13f56ba00d139bab57d5eb") 162 | ); 163 | let latency = output["latency"].as_f64().unwrap(); 164 | assert!(latency < (0.00988253 + f32::EPSILON).into()); 165 | assert!(latency > (0.00988253 - f32::EPSILON).into()); 166 | } 167 | 168 | #[test] 169 | fn scrape_log_no_snapshot() { 170 | // broken up into multiple lines to satisfy style checker, but serde_json will handle it 171 | // fine 172 | let line = r#"{"target":"dura","file":"src/main.rs","name":"event src/main.rs:96", 173 | "level":"Level(Info)","fields":{"pid":5416}, 174 | "time":"2022-01-14T01:45:37.469819+00:00"}"#; 175 | 176 | let output = scrape_log(line.to_string()).unwrap(); 177 | 178 | assert_eq!(output, None); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/poll_guard.rs: -------------------------------------------------------------------------------- 1 | use git2::{BranchType, Commit, Repository}; 2 | use std::collections::hash_map::Entry; 3 | use std::collections::HashMap; 4 | use std::fmt::{Debug, Formatter}; 5 | use std::ops::Add; 6 | use std::path::{Path, PathBuf}; 7 | use std::time::{Duration, SystemTime}; 8 | 9 | use anyhow::Result; 10 | use walkdir::{DirEntry, WalkDir}; 11 | 12 | /// OPTIMIZATION for checking for changes 13 | /// 14 | /// Provides a function, dir_changed, that is a much faster way to detect if any files in 15 | /// a repository have changed, vs the naive method of trying to commit the repo. This peeks at 16 | /// the file timestamp, which is typically cached in memory. The previous way to do it was to 17 | /// let Git2 make a commit, which triggered a whole lot of I/O and hashing. 18 | pub struct PollGuard { 19 | git_cache: HashMap, 20 | } 21 | 22 | impl PollGuard { 23 | pub fn new() -> Self { 24 | Self { 25 | git_cache: Default::default(), 26 | } 27 | } 28 | 29 | pub fn dir_changed(&mut self, dir: &Path) -> bool { 30 | let watermark = match self.get_watermark(dir) { 31 | Ok(watermark) => watermark, 32 | // True because we want to turn off this optimization 33 | Err(_) => return true, 34 | }; 35 | 36 | fn compare_times(modified: SystemTime, watermark: SystemTime) -> Result { 37 | let duration = modified.duration_since(watermark)?; 38 | Ok(duration.as_secs_f32() > 1.0) 39 | } 40 | 41 | fn get_file_time(entry: walkdir::Result) -> Result { 42 | Ok(entry?.metadata()?.modified()?) 43 | } 44 | 45 | for entry in WalkDir::new(dir) { 46 | if let Ok(modified) = get_file_time(entry) { 47 | if compare_times(modified, watermark).unwrap_or(false) { 48 | dbg!(modified, watermark); 49 | return true; 50 | } 51 | } 52 | } 53 | false 54 | } 55 | 56 | /// Find the last known commit timestamp 57 | fn get_watermark(&mut self, path: &Path) -> Result { 58 | // Get git repo, create if necessary 59 | let repo: &Repository = match self.git_cache.entry(path.into()) { 60 | Entry::Occupied(entry) => entry.into_mut(), 61 | Entry::Vacant(entry) => { 62 | let new = Repository::open(path)?; 63 | entry.insert(new) 64 | } 65 | }; 66 | 67 | fn get_time(commit: &Commit) -> SystemTime { 68 | SystemTime::UNIX_EPOCH.add(Duration::from_secs(commit.time().seconds() as u64)) 69 | } 70 | 71 | fn get_dura_time(head: &Commit, repo: &Repository) -> Result { 72 | let branch_name = format!("dura/{}", head.id()); 73 | let ret = repo 74 | .find_branch(&branch_name, BranchType::Local)? 75 | .get() 76 | .peel_to_commit()?; 77 | Ok(get_time(&ret)) 78 | } 79 | 80 | // get commit time and fallback to time of HEAD 81 | let head = repo.head()?.peel_to_commit()?; 82 | Ok(get_dura_time(&head, repo).unwrap_or_else(|_| get_time(&head))) 83 | } 84 | } 85 | 86 | /// Implemented manually because Repository doesn't implement it 87 | impl Debug for PollGuard { 88 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 89 | f.write_str("PollGuard { ")?; 90 | for dir in self.git_cache.keys() { 91 | f.write_str(dir.to_str().unwrap_or("n/a"))?; 92 | f.write_str(", ")?; 93 | } 94 | f.write_str(" }")?; 95 | Ok(()) 96 | } 97 | } 98 | 99 | impl Default for PollGuard { 100 | fn default() -> Self { 101 | Self::new() 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/poller.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use std::process; 3 | use std::time::Instant; 4 | 5 | use tokio::time; 6 | use tracing::{debug, error, info, trace}; 7 | 8 | use crate::config::Config; 9 | use crate::database::RuntimeLock; 10 | use crate::log::{Operation, StatCollector}; 11 | use crate::poll_guard::PollGuard; 12 | use crate::snapshots; 13 | 14 | /// If the directory is a repo, attempts to create a snapshot. 15 | /// Otherwise, recurses into each child directory. 16 | #[tracing::instrument] 17 | fn process_directory(current_path: &Path, guard: &mut PollGuard) { 18 | let mut op: Option = None; 19 | let mut error: Option = None; 20 | let start_time = Instant::now(); 21 | 22 | if guard.dir_changed(current_path) { 23 | debug!( 24 | "Potential change detected in repo: path = {path}", 25 | path = current_path.to_str().unwrap_or("") 26 | ); 27 | match snapshots::capture(current_path) { 28 | Ok(Some(status)) => op = Some(status), 29 | Ok(None) => (), 30 | Err(err) => { 31 | error = Some(format!("{err}")); 32 | } 33 | } 34 | } else { 35 | trace!( 36 | "No files in repo have changed: path = {path}", 37 | path = current_path.to_str().unwrap_or("") 38 | ); 39 | } 40 | 41 | let latency = (Instant::now() - start_time).as_secs_f32(); 42 | let repo = current_path 43 | .to_str() 44 | .unwrap_or("") 45 | .to_string(); 46 | let mut operation = Operation::Snapshot { 47 | repo, 48 | op, 49 | error, 50 | latency, 51 | }; 52 | if operation.should_log() { 53 | info!(operation = operation.log_str().as_str(), "info_operation") 54 | } 55 | } 56 | 57 | #[tracing::instrument] 58 | fn do_task(stats: &mut StatCollector, guard: &mut PollGuard) { 59 | let runtime_lock = RuntimeLock::load(); 60 | if runtime_lock.pid != Some(process::id()) { 61 | error!( 62 | "Shutting down because other poller took lock: {:?}", 63 | runtime_lock.pid 64 | ); 65 | process::exit(1); 66 | } 67 | 68 | let config = Config::load(); 69 | 70 | let loop_start = Instant::now(); 71 | for repo in config.git_repos() { 72 | let dir_start = Instant::now(); 73 | process_directory(repo.as_path(), guard); 74 | stats.record_dir(Instant::now() - dir_start); 75 | } 76 | stats.record_loop(Instant::now() - loop_start); 77 | 78 | if stats.should_log() { 79 | info!(operation = stats.log_str().as_str(), "poller_stats"); 80 | } 81 | } 82 | 83 | pub async fn start() { 84 | let mut runtime_lock = RuntimeLock::load(); 85 | runtime_lock.pid = Some(process::id()); 86 | runtime_lock.save(); 87 | info!(pid = std::process::id()); 88 | 89 | let mut stats = StatCollector::new(); 90 | let mut guard = PollGuard::new(); 91 | loop { 92 | time::sleep(time::Duration::from_secs(5)).await; 93 | do_task(&mut stats, &mut guard); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/snapshots.rs: -------------------------------------------------------------------------------- 1 | use git2::{BranchType, DiffOptions, Error, IndexAddOption, Repository, Signature}; 2 | use serde::{Deserialize, Serialize}; 3 | use std::fmt; 4 | use std::path::Path; 5 | 6 | use crate::config::Config; 7 | 8 | #[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] 9 | pub struct CaptureStatus { 10 | pub dura_branch: String, 11 | pub commit_hash: String, 12 | pub base_hash: String, 13 | } 14 | 15 | impl fmt::Display for CaptureStatus { 16 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 17 | write!( 18 | f, 19 | "dura: {}, commit_hash: {}, base: {}", 20 | self.dura_branch, self.commit_hash, self.base_hash 21 | ) 22 | } 23 | } 24 | 25 | pub fn is_repo(path: &Path) -> bool { 26 | Repository::open(path).is_ok() 27 | } 28 | 29 | pub fn capture(path: &Path) -> Result, Error> { 30 | let repo = Repository::open(path)?; 31 | let head = repo.head()?.peel_to_commit()?; 32 | let message = "dura auto-backup"; 33 | 34 | // status check 35 | if repo.statuses(None)?.is_empty() { 36 | return Ok(None); 37 | } 38 | 39 | let branch_name = format!("dura/{}", head.id()); 40 | let branch_commit = match repo.find_branch(&branch_name, BranchType::Local) { 41 | Ok(mut branch) => { 42 | match branch.get().peel_to_commit() { 43 | Ok(commit) if commit.id() != head.id() => Some(commit), 44 | _ => { 45 | // Dura branch exist but no commit is made by dura 46 | // So we clean this branch 47 | branch.delete()?; 48 | None 49 | } 50 | } 51 | } 52 | Err(_) => None, 53 | }; 54 | let parent_commit = branch_commit.as_ref().unwrap_or(&head); 55 | 56 | // tree 57 | let mut index = repo.index()?; 58 | index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)?; 59 | 60 | let dirty_diff = repo.diff_tree_to_index( 61 | Some(&parent_commit.tree()?), 62 | Some(&index), 63 | Some(DiffOptions::new().include_untracked(true)), 64 | )?; 65 | if dirty_diff.deltas().len() == 0 { 66 | return Ok(None); 67 | } 68 | 69 | let tree_oid = index.write_tree()?; 70 | let tree = repo.find_tree(tree_oid)?; 71 | if repo.find_branch(&branch_name, BranchType::Local).is_err() { 72 | repo.branch(branch_name.as_str(), &head, false)?; 73 | } 74 | 75 | let committer = Signature::now(&get_git_author(&repo), &get_git_email(&repo))?; 76 | let oid = repo.commit( 77 | Some(&format!("refs/heads/{}", &branch_name)), 78 | &committer, 79 | &committer, 80 | message, 81 | &tree, 82 | &[parent_commit], 83 | )?; 84 | 85 | Ok(Some(CaptureStatus { 86 | dura_branch: branch_name, 87 | commit_hash: oid.to_string(), 88 | base_hash: head.id().to_string(), 89 | })) 90 | } 91 | 92 | fn get_git_author(repo: &Repository) -> String { 93 | let dura_cfg = Config::load(); 94 | if let Some(value) = dura_cfg.commit_author { 95 | return value; 96 | } 97 | 98 | if !dura_cfg.commit_exclude_git_config { 99 | if let Ok(git_cfg) = repo.config() { 100 | if let Ok(value) = git_cfg.get_string("user.name") { 101 | return value; 102 | } 103 | } 104 | } 105 | 106 | "dura".to_string() 107 | } 108 | 109 | fn get_git_email(repo: &Repository) -> String { 110 | let dura_cfg = Config::load(); 111 | if let Some(value) = dura_cfg.commit_email { 112 | return value; 113 | } 114 | 115 | if !dura_cfg.commit_exclude_git_config { 116 | if let Ok(git_cfg) = repo.config() { 117 | if let Ok(value) = git_cfg.get_string("user.email") { 118 | return value; 119 | } 120 | } 121 | } 122 | 123 | "dura@github.io".to_string() 124 | } 125 | -------------------------------------------------------------------------------- /tests/poll_guard_test.rs: -------------------------------------------------------------------------------- 1 | use dura::poll_guard::PollGuard; 2 | use dura::snapshots; 3 | use std::thread::sleep; 4 | use std::time::Duration; 5 | 6 | mod util; 7 | 8 | #[test] 9 | fn changed_file() { 10 | let tmp = tempfile::tempdir().unwrap(); 11 | let mut repo = repo_and_file!(tmp, "foo.txt"); 12 | let mut pg = PollGuard::new(); 13 | assert!(!pg.dir_changed(repo.dir.as_path())); 14 | 15 | sleep(Duration::from_secs_f64(1.5)); 16 | repo.change_file("foo.txt"); 17 | assert!(pg.dir_changed(repo.dir.as_path())); 18 | } 19 | 20 | /// Changing a branch still looks like a file change. 21 | /// 22 | /// The reason is because `Repository::is_path_ignored` takes a ton of time, 23 | /// mostly in stat() calls trying to find the ignore file and git attributes. 24 | /// `PollGuard` is hit far too often to be able to use `Repository.is_path_ignored`. 25 | /// 26 | /// We could ignore all files in `.git/`, but the name of that directory can change, 27 | /// and the flame graphs aren't showing a lot of time being used there. 28 | #[test] 29 | fn branch_changed() { 30 | let tmp = tempfile::tempdir().unwrap(); 31 | let repo = repo_and_file!(tmp, "foo.txt"); 32 | let mut pg = PollGuard::new(); 33 | assert!(!pg.dir_changed(repo.dir.as_path())); 34 | 35 | sleep(Duration::from_secs_f64(1.5)); 36 | repo.git(&["checkout", "-b", "new-branch"]) 37 | .expect("checkout failed"); 38 | assert!(pg.dir_changed(repo.dir.as_path())); 39 | } 40 | 41 | #[test] 42 | fn file_changed_after_snapshot() { 43 | let tmp = tempfile::tempdir().unwrap(); 44 | let mut repo = repo_and_file!(tmp, "foo.txt"); 45 | let mut pg = PollGuard::new(); 46 | assert!(!pg.dir_changed(repo.dir.as_path())); 47 | 48 | sleep(Duration::from_secs_f64(1.5)); 49 | repo.change_file("foo.txt"); 50 | assert!(pg.dir_changed(repo.dir.as_path())); 51 | 52 | sleep(Duration::from_secs_f64(1.5)); 53 | snapshots::capture(repo.dir.as_path()).expect("snapshot failed"); 54 | assert!(!pg.dir_changed(repo.dir.as_path())); 55 | 56 | sleep(Duration::from_secs_f64(1.5)); 57 | repo.change_file("foo.txt"); 58 | assert!(pg.dir_changed(repo.dir.as_path())); 59 | } 60 | -------------------------------------------------------------------------------- /tests/snapshots_test.rs: -------------------------------------------------------------------------------- 1 | use dura::{config::Config, snapshots}; 2 | 3 | use std::env; 4 | 5 | mod util; 6 | 7 | #[macro_use] 8 | extern crate serial_test; 9 | 10 | #[test] 11 | fn change_single_file() { 12 | let tmp = tempfile::tempdir().unwrap(); 13 | let mut repo = repo_and_file!(tmp, "foo.txt"); 14 | repo.change_file("foo.txt"); 15 | let status = snapshots::capture(repo.dir.as_path()).unwrap().unwrap(); 16 | 17 | assert_ne!(status.commit_hash, status.base_hash); 18 | assert_eq!(status.dura_branch, format!("dura/{}", status.base_hash)); 19 | assert_eq!(status.dura_branch, format!("dura/{}", status.base_hash)); 20 | } 21 | 22 | #[test] 23 | fn no_changes() { 24 | let tmp = tempfile::tempdir().unwrap(); 25 | let repo = repo_and_file!(tmp, "foo.txt"); 26 | let status = snapshots::capture(repo.dir.as_path()).unwrap(); 27 | 28 | assert_eq!(status, None); 29 | } 30 | 31 | /// It keeps capturing commits during a merge conflict 32 | #[test] 33 | fn during_merge_conflicts() { 34 | let tmp = tempfile::tempdir().unwrap(); 35 | let mut repo = repo_and_file!(tmp, "foo.txt"); 36 | 37 | // branch1 38 | repo.change_file("foo.txt"); 39 | repo.commit_all(); 40 | repo.git(&["checkout", "-b", "branch1"]).unwrap(); 41 | 42 | // branch2 43 | repo.git(&["checkout", "-b", "branch2"]).unwrap(); 44 | repo.git(&["reset", "HEAD^", "--hard"]).unwrap(); 45 | repo.change_file("foo.txt"); 46 | repo.commit_all(); 47 | 48 | // MERGE FAIL 49 | let merge_result = repo.git(&["merge", "branch1"]); 50 | assert_eq!(merge_result, None); 51 | repo.git(&["status"]).unwrap(); // debug info 52 | 53 | // change a file anyway 54 | repo.change_file("foo.txt"); 55 | let status = snapshots::capture(repo.dir.as_path()).unwrap().unwrap(); 56 | 57 | // Regular dura commit 58 | assert_ne!(status.commit_hash, status.base_hash); 59 | assert_eq!(status.dura_branch, format!("dura/{}", status.base_hash)); 60 | } 61 | 62 | #[test] 63 | #[serial] 64 | fn test_commit_signature_using_dura_config() { 65 | let tmp = tempfile::tempdir().unwrap(); 66 | let mut repo = util::git_repo::GitRepo::new(tmp.path().to_path_buf()); 67 | repo.init(); 68 | repo.set_config("user.name", "git-author"); 69 | repo.set_config("user.email", "git@someemail.com"); 70 | 71 | env::set_var("DURA_CONFIG_HOME", tmp.path()); 72 | let mut dura_config = Config::empty(); 73 | dura_config.commit_author = Some("dura-config".to_string()); 74 | dura_config.commit_email = Some("dura-config@email.com".to_string()); 75 | dura_config.save(); 76 | 77 | repo.write_file("foo.txt"); 78 | repo.commit_all(); 79 | 80 | repo.change_file("foo.txt"); 81 | let status = snapshots::capture(repo.dir.as_path()).unwrap().unwrap(); 82 | 83 | let commit_author = repo.git(&["show", "-s", "--format=format:%an", &status.commit_hash]); 84 | assert_eq!(commit_author, dura_config.commit_author); 85 | 86 | let commit_email = repo.git(&["show", "-s", "--format=format:%ae", &status.commit_hash]); 87 | assert_eq!(commit_email, dura_config.commit_email); 88 | } 89 | 90 | #[test] 91 | #[serial] 92 | fn test_commit_signature_using_git_config() { 93 | let tmp = tempfile::tempdir().unwrap(); 94 | let mut repo = util::git_repo::GitRepo::new(tmp.path().to_path_buf()); 95 | repo.init(); 96 | repo.set_config("user.name", "git-author"); 97 | repo.set_config("user.email", "git@someemail.com"); 98 | 99 | env::set_var("DURA_CONFIG_HOME", tmp.path()); 100 | let dura_config = Config::empty(); 101 | dura_config.save(); 102 | 103 | repo.write_file("foo.txt"); 104 | repo.commit_all(); 105 | 106 | repo.change_file("foo.txt"); 107 | let status = snapshots::capture(repo.dir.as_path()).unwrap().unwrap(); 108 | 109 | let commit_author = repo 110 | .git(&["show", "-s", "--format=format:%an", &status.commit_hash]) 111 | .unwrap(); 112 | assert_eq!(commit_author, "git-author"); 113 | 114 | let commit_email = repo 115 | .git(&["show", "-s", "--format=format:%ae", &status.commit_hash]) 116 | .unwrap(); 117 | assert_eq!(commit_email, "git@someemail.com"); 118 | } 119 | 120 | #[test] 121 | #[serial] 122 | fn test_commit_signature_exclude_git_config() { 123 | let tmp = tempfile::tempdir().unwrap(); 124 | let mut repo = util::git_repo::GitRepo::new(tmp.path().to_path_buf()); 125 | repo.init(); 126 | repo.set_config("user.name", "git-author"); 127 | repo.set_config("user.email", "git@someemail.com"); 128 | 129 | env::set_var("DURA_CONFIG_HOME", tmp.path()); 130 | let mut dura_config = Config::empty(); 131 | dura_config.commit_exclude_git_config = true; 132 | dura_config.save(); 133 | 134 | repo.write_file("foo.txt"); 135 | repo.commit_all(); 136 | repo.change_file("foo.txt"); 137 | let status = snapshots::capture(repo.dir.as_path()).unwrap().unwrap(); 138 | 139 | let commit_author = repo 140 | .git(&["show", "-s", "--format=format:%an", &status.commit_hash]) 141 | .unwrap(); 142 | assert_eq!(commit_author, "dura"); 143 | 144 | let commit_email = repo 145 | .git(&["show", "-s", "--format=format:%ae", &status.commit_hash]) 146 | .unwrap(); 147 | assert_eq!(commit_email, "dura@github.io"); 148 | } 149 | -------------------------------------------------------------------------------- /tests/startup_test.rs: -------------------------------------------------------------------------------- 1 | mod util; 2 | 3 | use dura::config::Config; 4 | use dura::database::RuntimeLock; 5 | use std::fs; 6 | 7 | /// How many seconds to wait, at most, for dura to start? 8 | const START_TIMEOUT: u64 = 8; 9 | 10 | #[test] 11 | fn start_serve() { 12 | let mut dura = util::dura::Dura::new(); 13 | assert_eq!(None, dura.pid(true)); 14 | assert_eq!(None, dura.get_runtime_lock()); 15 | 16 | dura.start_async(&["serve"], true); 17 | dura.primary 18 | .as_ref() 19 | .map(|d| d.read_line(START_TIMEOUT).unwrap()); 20 | 21 | assert_ne!(None, dura.pid(true)); 22 | let runtime_lock = dura.get_runtime_lock(); 23 | assert_ne!(None, runtime_lock); 24 | assert_eq!(dura.pid(true), runtime_lock.unwrap().pid); 25 | } 26 | 27 | #[test] 28 | fn start_serve_with_null_pid_in_config() { 29 | let mut dura = util::dura::Dura::new(); 30 | let mut runtime_lock = RuntimeLock::empty(); 31 | runtime_lock.pid = None; 32 | dura.save_runtime_lock(&runtime_lock); 33 | 34 | assert_eq!(None, dura.pid(true)); 35 | assert_ne!(None, dura.get_runtime_lock()); 36 | 37 | dura.start_async(&["serve"], true); 38 | dura.primary 39 | .as_ref() 40 | .map(|d| d.read_line(START_TIMEOUT).unwrap()); 41 | 42 | assert_ne!(None, dura.pid(true)); 43 | let runtime_lock = dura.get_runtime_lock(); 44 | assert_ne!(None, runtime_lock); 45 | assert_eq!(dura.pid(true), runtime_lock.unwrap().pid); 46 | } 47 | 48 | #[test] 49 | fn start_serve_with_other_pid_in_config() { 50 | let mut dura = util::dura::Dura::new(); 51 | let mut runtime_lock = RuntimeLock::empty(); 52 | runtime_lock.pid = Some(12345); 53 | dura.save_runtime_lock(&runtime_lock); 54 | 55 | println!("db:: {:?}", dura.get_runtime_lock()); 56 | 57 | assert_eq!(None, dura.pid(true)); 58 | assert_ne!(None, dura.get_runtime_lock()); 59 | 60 | dura.start_async(&["serve"], true); 61 | dura.primary 62 | .as_ref() 63 | .map(|d| d.read_line(START_TIMEOUT).unwrap()); 64 | 65 | assert_ne!(None, dura.pid(true)); 66 | let runtime_lock = dura.get_runtime_lock(); 67 | assert_ne!(None, runtime_lock); 68 | assert_eq!(dura.pid(true), runtime_lock.unwrap().pid); 69 | } 70 | 71 | #[test] 72 | fn start_serve_with_invalid_json() { 73 | let mut dura = util::dura::Dura::new(); 74 | let runtime_lock_path = dura.runtime_lock_path(); 75 | Config::create_dir(runtime_lock_path.as_path()); 76 | fs::write(runtime_lock_path, "{\"pid\":34725").unwrap(); 77 | 78 | assert_eq!(None, dura.pid(true)); 79 | assert_eq!(None, dura.get_runtime_lock()); 80 | 81 | dura.start_async(&["serve"], true); 82 | dura.primary 83 | .as_ref() 84 | .map(|d| d.read_line(START_TIMEOUT).unwrap()); 85 | 86 | assert_ne!(None, dura.pid(true)); 87 | let runtime_lock = dura.get_runtime_lock(); 88 | assert_ne!(None, runtime_lock); 89 | assert_eq!(dura.pid(true), runtime_lock.unwrap().pid); 90 | } 91 | -------------------------------------------------------------------------------- /tests/util/daemon.rs: -------------------------------------------------------------------------------- 1 | use std::io::{BufRead, BufReader}; 2 | use std::process::{Child, ChildStdout}; 3 | use std::sync::mpsc::{channel, Receiver}; 4 | use std::sync::{Arc, Mutex}; 5 | use std::thread; 6 | use std::time::Duration; 7 | 8 | /// Main-thread side of a process watcher. The process that's launched is exposed as messages 9 | /// (per-line) over a mpsc channel. This is intended to simplify, speed up, and generally make the 10 | /// tests more reliable when they dispatch asynchronously to `dura serve`. However, nothing abot 11 | /// this is intended to be specific to dura. 12 | pub struct Daemon { 13 | mailbox: Receiver>, 14 | pub child: Child, 15 | /// Signals to kill daemon thread if this goes <= 0, like a CountDownLatch 16 | kill_sign: Arc>, 17 | } 18 | 19 | impl Daemon { 20 | pub fn new(mut child: Child) -> Self { 21 | let kill_sign = Arc::new(Mutex::new(1)); 22 | Self { 23 | mailbox: Self::attach( 24 | child 25 | .stdout 26 | .take() 27 | .expect("Configure Command to capture stdout"), 28 | Arc::clone(&kill_sign), 29 | ), 30 | child, 31 | kill_sign, 32 | } 33 | } 34 | 35 | /// Spawn another thread to watch the child process. It attaches to stdout and sends each line 36 | /// over the channel. It sends a None right before it quits, either due to an error or EOF. 37 | fn attach(stdout: ChildStdout, kill_sign: Arc>) -> Receiver> { 38 | fn is_ignored(msg: &str) -> bool { 39 | msg.contains("Started serving with dura") 40 | } 41 | let (sender, receiver) = channel(); 42 | thread::spawn(move || { 43 | let mut reader = BufReader::new(stdout); 44 | loop { 45 | { 46 | // check to see if the daemon is killed 47 | if *kill_sign.lock().unwrap() <= 0 { 48 | break; 49 | } 50 | } 51 | let mut line = String::new(); 52 | match reader.read_line(&mut line) { 53 | Ok(0) => { 54 | sender.send(None).unwrap(); 55 | break; 56 | } 57 | Ok(_) => { 58 | if !is_ignored(line.as_str()) { 59 | sender.send(Some(line)).unwrap(); 60 | } 61 | } 62 | Err(e) => { 63 | eprintln!("Error in daemon: {e:?}"); 64 | sender.send(None).unwrap(); 65 | break; 66 | } 67 | } 68 | } 69 | }); 70 | receiver 71 | } 72 | 73 | /// Read a line from the child process, waiting at most timeout_secs. 74 | pub fn read_line(&self, timeout_secs: u64) -> Option { 75 | self.mailbox 76 | .recv_timeout(Duration::from_secs(timeout_secs)) 77 | .unwrap() 78 | } 79 | 80 | pub fn kill(&mut self) { 81 | let mut kill_sign = self.kill_sign.lock().unwrap(); 82 | *kill_sign -= 1; 83 | self.child.kill().unwrap(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /tests/util/dura.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashSet, 3 | ops, path, 4 | process::{Command, Stdio}, 5 | thread, time, 6 | }; 7 | 8 | use crate::util::daemon::Daemon; 9 | use dura::config::Config; 10 | use dura::database::RuntimeLock; 11 | 12 | /// Utility to start dura asynchronously (e.g. dura serve) and kill the process when this goes out 13 | /// of scope. This helps us do end-to-end tests where we invoke the executable, possibly multiple 14 | /// different processes. 15 | pub struct Dura { 16 | pub primary: Option, 17 | pub secondary: Option, 18 | config_dir: tempfile::TempDir, 19 | cache_dir: tempfile::TempDir, 20 | } 21 | 22 | impl Dura { 23 | pub fn new() -> Self { 24 | Self { 25 | primary: None, 26 | secondary: None, 27 | config_dir: tempfile::tempdir().unwrap(), 28 | cache_dir: tempfile::tempdir().unwrap(), 29 | } 30 | } 31 | 32 | pub fn start_async(&mut self, args: &[&str], is_primary: bool) { 33 | println!("$ dura {} &", args.join(" ")); 34 | let exe = env!("CARGO_BIN_EXE_dura").to_string(); 35 | let child = Command::new(exe) 36 | .args(args) 37 | .env("DURA_CONFIG_HOME", self.config_dir.path()) 38 | .env("DURA_CACHE_HOME", self.cache_dir.path()) 39 | .stdout(Stdio::piped()) 40 | .spawn() 41 | .unwrap(); 42 | 43 | if is_primary { 44 | self.primary = Some(Daemon::new(child)); 45 | } else { 46 | self.secondary = Some(Daemon::new(child)); 47 | } 48 | } 49 | 50 | pub fn run(&self, args: &[&str]) { 51 | println!("$ dura {}", args.join(" ")); 52 | let exe = env!("CARGO_BIN_EXE_dura").to_string(); 53 | let child_proc = Command::new(exe) 54 | .args(args) 55 | .env("DURA_CONFIG_HOME", self.config_dir.path()) 56 | .env("DURA_CACHE_HOME", self.cache_dir.path()) 57 | .output(); 58 | 59 | if let Ok(output) = child_proc { 60 | if !output.status.success() { 61 | // This cleans up test development by causing us to fail earlier 62 | return; 63 | } 64 | let text = String::from_utf8(output.stdout).unwrap(); 65 | if !text.is_empty() { 66 | println!("{text}"); 67 | } 68 | let err = String::from_utf8(output.stderr).unwrap(); 69 | if !err.is_empty() { 70 | println!("{err}"); 71 | } 72 | } 73 | } 74 | 75 | pub fn run_in_dir(&self, args: &[&str], dir: &path::Path) { 76 | println!("$ dura {}", args.join(" ")); 77 | let exe = env!("CARGO_BIN_EXE_dura").to_string(); 78 | let child_proc = Command::new(exe) 79 | .args(args) 80 | .env("DURA_CONFIG_HOME", self.config_dir.path()) 81 | .env("DURA_CACHE_HOME", self.cache_dir.path()) 82 | .current_dir(dir) 83 | .output(); 84 | 85 | if let Ok(output) = child_proc { 86 | if !output.status.success() { 87 | // This cleans up test development by causing us to fail earlier 88 | return; 89 | } 90 | let text = String::from_utf8(output.stdout).unwrap(); 91 | if !text.is_empty() { 92 | println!("{text}"); 93 | } 94 | let err = String::from_utf8(output.stderr).unwrap(); 95 | if !err.is_empty() { 96 | println!("{err}"); 97 | } 98 | } 99 | } 100 | 101 | pub fn pid(&self, is_primary: bool) -> Option { 102 | if is_primary { 103 | self.primary.as_ref().map(|d| d.child.id()) 104 | } else { 105 | self.secondary.as_ref().map(|d| d.child.id()) 106 | } 107 | } 108 | 109 | pub fn config_path(&self) -> path::PathBuf { 110 | self.config_dir.path().join("config.toml") 111 | } 112 | 113 | pub fn get_config(&self) -> Option { 114 | println!("$ cat ~/.config/dura/config.toml"); 115 | let cfg = Config::load_file(self.config_path().as_path()).ok(); 116 | println!("{cfg:?}"); 117 | cfg 118 | } 119 | 120 | pub fn save_config(&self, cfg: &Config) { 121 | cfg.save_to_path(self.config_path().as_path()); 122 | } 123 | 124 | pub fn runtime_lock_path(&self) -> path::PathBuf { 125 | self.cache_dir.path().join("runtime.db") 126 | } 127 | 128 | pub fn get_runtime_lock(&self) -> Option { 129 | println!("$ cat ~/.cache/dura/runtime.db"); 130 | let cfg = RuntimeLock::load_file(self.runtime_lock_path().as_path()); 131 | cfg.ok() 132 | } 133 | 134 | pub fn save_runtime_lock(&self, cfg: &RuntimeLock) { 135 | cfg.save_to_path(self.runtime_lock_path().as_path()); 136 | } 137 | 138 | pub fn git_repos(&self) -> HashSet { 139 | match self.get_config() { 140 | Some(cfg) => cfg.git_repos().collect(), 141 | None => HashSet::new(), 142 | } 143 | } 144 | 145 | pub fn wait(&self) { 146 | // This hack isn't going to work. Another idea is to read lines 147 | // from stdout as a signal to proceed. 148 | thread::sleep(time::Duration::from_secs(6)); 149 | } 150 | } 151 | 152 | impl ops::Drop for Dura { 153 | /// Force Kill. Not the "kind kill" that is `dura kill` 154 | /// 155 | /// Ensure the process is stopped. Each test gets a unique config file path, so processes 156 | /// should stay independent and isolated as long as no one is running `ps aux` 157 | fn drop(&mut self) { 158 | // don't handle kill errors. 159 | let _ = self.primary.as_mut().map(|d| d.child.kill()); 160 | let _ = self.secondary.as_mut().map(|d| d.child.kill()); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /tests/util/git_repo.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, path, process::Command}; 2 | 3 | /// A test utility to make our tests more readable 4 | pub struct GitRepo { 5 | // implements Drop to delete the directory 6 | pub dir: path::PathBuf, 7 | 8 | // Source of entropy for change_file 9 | counter: u32, 10 | } 11 | 12 | impl GitRepo { 13 | pub fn new(dir: path::PathBuf) -> Self { 14 | Self { dir, counter: 0 } 15 | } 16 | 17 | pub fn git(&self, args: &[&str]) -> Option { 18 | println!("$ git {}", args.join(" ")); 19 | let git_dir = self.dir.as_path().join(path::Path::new(".git")); 20 | 21 | let child_proc = Command::new("git") 22 | .args( 23 | [ 24 | &[ 25 | "--git-dir", 26 | git_dir.to_str().unwrap(), 27 | "--work-tree", 28 | self.dir.as_path().to_str().unwrap(), 29 | ], 30 | args, 31 | ] 32 | .concat(), 33 | ) 34 | .output(); 35 | 36 | if let Ok(output) = child_proc { 37 | let text = String::from_utf8(output.stdout).unwrap(); 38 | if !text.is_empty() { 39 | println!("{text}"); 40 | } 41 | let err = String::from_utf8(output.stderr).unwrap(); 42 | if !err.is_empty() { 43 | println!("{err}"); 44 | } 45 | if !output.status.success() { 46 | // This cleans up test development by causing us to fail earlier 47 | None 48 | } else { 49 | Some(text) 50 | } 51 | } else { 52 | None 53 | } 54 | } 55 | 56 | pub fn init(&self) { 57 | fs::create_dir_all(self.dir.as_path()).unwrap(); 58 | let _ = self.git(&["init"]).unwrap(); 59 | let _ = self.git(&["--version"]).unwrap(); 60 | let _ = self.git(&["checkout", "-b", "master"]).unwrap(); 61 | // Linux & Windows will fail on `git commit` if these aren't set 62 | let _ = self.git(&["config", "user.name", "duratest"]).unwrap(); 63 | let _ = self 64 | .git(&["config", "user.email", "duratest@dura.io"]) 65 | .unwrap(); 66 | } 67 | 68 | pub fn commit_all(&self) { 69 | self.git(&["add", "."]).unwrap(); 70 | self.git(&["status"]).unwrap(); 71 | // We disable gpg signing to avoid interfering with local global 72 | // ~/.gitconfig file, if any. 73 | self.git(&["commit", "--no-gpg-sign", "-m", "test"]) 74 | .unwrap(); 75 | } 76 | 77 | pub fn write_file(&self, path: &str) { 78 | let content = "initial rev"; 79 | let path_obj = self.dir.as_path().join(path); 80 | println!("$ echo '{content}' > {path}"); 81 | fs::write(path_obj, content).unwrap(); 82 | } 83 | 84 | /// Every time this is called it overwrites the file with **different** contents. 85 | pub fn change_file(&mut self, path: &str) { 86 | self.counter += 1; 87 | let content = format!("change {}", self.counter); 88 | println!("$ echo '{content}' > {path}"); 89 | let path_obj = self.dir.as_path().join(path); 90 | fs::write(path_obj, content).unwrap(); 91 | } 92 | 93 | pub fn set_config(&self, name: &str, value: &str) { 94 | self.git(&["config", name, value]).unwrap(); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/util/macros.rs: -------------------------------------------------------------------------------- 1 | /// Create a Git repo with a single file and commit 2 | #[macro_export] 3 | macro_rules! repo_and_file { 4 | ( $tmp:expr, $file_name:expr ) => {{ 5 | let repo = util::git_repo::GitRepo::new($tmp.path().to_path_buf()); 6 | repo.init(); 7 | repo.write_file($file_name); 8 | repo.commit_all(); 9 | repo 10 | }}; 11 | } 12 | -------------------------------------------------------------------------------- /tests/util/mod.rs: -------------------------------------------------------------------------------- 1 | // Not every test module -- which are compiled at seperate binaries, use every function, causing dead_code to be emitted. 2 | #![allow(dead_code)] 3 | 4 | pub mod daemon; 5 | pub mod dura; 6 | pub mod git_repo; 7 | pub mod macros; 8 | -------------------------------------------------------------------------------- /tests/watch_test.rs: -------------------------------------------------------------------------------- 1 | mod util; 2 | 3 | use crate::util::dura::Dura; 4 | use crate::util::git_repo::GitRepo; 5 | use std::collections::HashSet; 6 | 7 | #[test] 8 | fn watch_repo() { 9 | let tmp = tempfile::tempdir().unwrap(); 10 | let repo = GitRepo::new(tmp.path().to_path_buf()); 11 | repo.init(); 12 | 13 | let dura = Dura::new(); 14 | dura.run_in_dir(&["watch"], tmp.path()); 15 | 16 | let mut tmp_set = HashSet::new(); 17 | tmp_set.insert(tmp.path().canonicalize().unwrap()); 18 | 19 | assert_eq!(dura.git_repos(), tmp_set); 20 | } 21 | 22 | #[test] 23 | fn watch_1_dir_with_2_repos() { 24 | let tmp = tempfile::tempdir().unwrap(); 25 | let repo1 = GitRepo::new(tmp.path().join("repo1")); 26 | repo1.init(); 27 | let repo2 = GitRepo::new(tmp.path().join("repo2")); 28 | repo2.init(); 29 | 30 | let dura = Dura::new(); 31 | dura.run_in_dir(&["watch"], tmp.path()); 32 | 33 | let mut tmp_set = HashSet::new(); 34 | tmp_set.insert(repo1.dir.canonicalize().unwrap()); 35 | tmp_set.insert(repo2.dir.canonicalize().unwrap()); 36 | 37 | assert_eq!(dura.git_repos(), tmp_set); 38 | } 39 | 40 | #[test] 41 | fn watch_dir_with_repo_nested_3_folders_deep() { 42 | let tmp = tempfile::tempdir().unwrap(); 43 | let repo = GitRepo::new(tmp.path().join("a/b/c")); 44 | repo.init(); 45 | 46 | let dura = Dura::new(); 47 | dura.run_in_dir(&["watch"], tmp.path()); 48 | 49 | let mut tmp_set = HashSet::new(); 50 | tmp_set.insert(repo.dir.canonicalize().unwrap()); 51 | 52 | assert_eq!(dura.git_repos(), tmp_set); 53 | } 54 | --------------------------------------------------------------------------------