diff --git a/CHANGELOG.md b/CHANGELOG.md index bd25d89..d2e6ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,17 @@ This page documents the changes and fixes made in Chronos Backups, in reverse chronological order. +## 1.2.0 + +- Add `compressionMethod = "git"` for incremental Git backups (space + speed). Prefers native `git` when available, otherwise uses bundled gitoxide. `maxStoredBackups` is not applied when in git mode. +- Add `allowNativeGit` config (default `true`) to forbid native git and force bundled gitoxide +- Pre-prune xxHash3 source manifest skips unchanged files before copy/prune for git-mode +- Add `/chronos list` to list created backups for the current world, or git tags when in git mode +- Add `/chronos export ` for git-mode snapshots, exporting to `backups/exported/`, while keeping the live world untouched + ## 1.1.4 -- Added a Chat Prefix config option (only works for 1.13 and above) +- Added a Chat Prefix config option (only works for 1.13 and above) - Speedtest backups write under `backups/.speedtest` and are deleted when the session ends, so they no longer trim real backups or upload to the cloud ## 1.1.3 diff --git a/README.md b/README.md index 6571ee8..87ae445 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ | `/chronos backup` | Run a manual backup immediately. | | `/chronos cancel` | Stop the backup currently in progress. | | `/chronos speedtest ` | Run repeated backups for benchmarking (diagnostic, not for normal use). Writes under `backups/.speedtest` and deletes them when finished. | +| `/chronos list` | List the 10 newest backups (zip/folder names, or git tags in git mode). For older git tags, open the repo in a Git client/CLI. | +| `/chronos export ` | Export a git backup into `backups/exported/` (without touching the live world). | The required permission level defaults to **4**. @@ -58,7 +60,7 @@ Chronos can upload finished backups to a remote cloud provider, then optionally ### Behaviour - Local backups missing from the remote are uploaded, including catch-up after a failed upload or a restart. -- `maxStoredBackups` also caps how many Chronos backups each enabled provider keeps per world (oldest removed first). Values below 1 disable automatic removal locally and remotely. +- `maxStoredBackups` also caps how many Chronos backups each enabled provider keeps per world (oldest removed first). Values below 1 disable automatic removal locally and remotely. Does not apply when `compressionMethod = "git"`. - If `shouldKeepLocalBackups` is `false`, a backup is deleted from disk (only after it has been uploaded successfully). When multiple worlds would map to the same remote folder (e.g. two servers both named "world"), Chronos attempts to resolve this by assigning a unique alias in `chronos-alias.txt` instead of overwriting. This favors safety over storage, so orphaned remote data may remain. Watch the console when enabling cloud sync on multiple servers. @@ -120,8 +122,9 @@ The mod's configuration is stored in the `config/chronos.toml` file. This file i - `pruneMaxWorkerThreads`: Maximum worker threads for pruning. 0 (or less) means "auto" (pruner picks a sensible default). - `scheduleBackups`: Whether to run backups on a timer. - `backupIntervalSeconds`: Seconds between automatic backup runs. -- `maxStoredBackups`: Maximum backups kept per world locally and on each enabled cloud destination. After a successful backup, oldest local backups are removed if the limit is exceeded. Cloud sync also trims remote Chronos backups to this cap. Recommended value: 5. Values lower than 3 can be used to save space, but risks serious data loss if a catastrophic error occurs. Values below 1 disable automatic removal. -- `compressionMethod`: Whether to compress the backups into a zip file or store it as an uncompressed folder. Accepts `"zip"` or `"none"`. +- `maxStoredBackups`: Maximum backups kept per world locally and on each enabled cloud destination for zip/none. After a successful backup, oldest local backups are removed if the limit is exceeded. Cloud sync also trims remote Chronos backups to this cap. **Not enforced in git mode**. Recommended value: 5. Values lower than 3 can be used to save space, but risks serious data loss if a catastrophic error occurs. Values below 1 disable automatic removal. +- `compressionMethod`: How backups are stored. Accepts `"zip"` (default), `"none"` (uncompressed folder), or `"git"` (incremental Git repo under the world backup folder, openable in Git clients/CLI). Git mode probes for native `git` on PATH and prefers it. Otherwise it uses bundled gitoxide. Use `/chronos export ` to export a git snapshot. +- `allowNativeGit`: When `true` (default), git-mode may use native `git` on PATH. Set to `false` to force bundled gitoxide. - `googleDriveEnabled`: Enable Google Drive as a cloud sync destination (see [Cloud sync](#cloud-sync)). - `shouldKeepLocalBackups`: When `true`, keep local backups after a successful cloud upload. When `false`, delete each local backup once it has been uploaded. - `commandRequiredPermissionLevel`: Permission level required to run `/chronos`. @@ -182,7 +185,7 @@ cd core/native/rust-pruner cargo +nightly run --release --features world-test --bin prune-world-test ``` -It copies the world, prunes the snapshot, prints the results, and discards the backup. +It copies the world, prunes the snapshot, prints the results, and discards it. ### Project structure diff --git a/core/native/rust-pruner/Cargo.lock b/core/native/rust-pruner/Cargo.lock index 73a9f36..ffdb470 100644 --- a/core/native/rust-pruner/Cargo.lock +++ b/core/native/rust-pruner/Cargo.lock @@ -8,18 +8,83 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "arc-swap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64ct" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytesize" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b" + [[package]] name = "cesu8" version = "1.1.0" @@ -32,6 +97,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "combine" version = "4.6.7" @@ -42,6 +116,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -71,26 +160,1113 @@ dependencies = [ ] [[package]] -name = "crossbeam-utils" -version = "0.8.21" +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "defmt" +version = "0.3.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4deef977660e373c458df605a913d4ab121db0ccd18d1ad6976dff7d37cddb53" +dependencies = [ + "defmt 1.2.0", +] + +[[package]] +name = "defmt" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "defmt-parser" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "faster-hex" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed" +dependencies = [ + "autocfg", + "defmt 0.3.101", + "heapless", + "serde", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gix" +version = "0.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514c29cc879bdc0286b0cbc205585a49b252809eb86c69df4ce4f855ee75f635" +dependencies = [ + "gix-actor", + "gix-attributes", + "gix-command", + "gix-commitgraph", + "gix-config", + "gix-date", + "gix-diff", + "gix-discover", + "gix-features", + "gix-filter", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-ignore", + "gix-index", + "gix-lock", + "gix-object", + "gix-odb", + "gix-pack", + "gix-path", + "gix-pathspec", + "gix-protocol", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-submodule", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree", + "once_cell", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-actor" +version = "0.35.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "987a51a7e66db6ef4dc030418eb2a42af6b913a79edd8670766122d8af3ba59e" +dependencies = [ + "bstr", + "gix-date", + "gix-utils", + "itoa", + "thiserror 2.0.18", + "winnow", +] + +[[package]] +name = "gix-attributes" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45442188216d08a5959af195f659cb1f244a50d7d2d0c3873633b1cd7135f638" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-quote", + "gix-trace", + "kstring", + "smallvec", + "thiserror 2.0.18", + "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d982fc7ef0608e669851d0d2a6141dae74c60d5a27e8daa451f2a4857bbf41e2" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "gix-chunk" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c356b3825677cb6ff579551bb8311a81821e184453cbd105e2fc5311b288eeb" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "gix-command" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f9c425730a654835351e6da8c3c69ba1804f8b8d4e96d027254151138d5c64" +dependencies = [ + "bstr", + "gix-path", + "gix-quote", + "gix-trace", + "shell-words", +] + +[[package]] +name = "gix-commitgraph" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb23121e952f43a5b07e3e80890336cb847297467a410475036242732980d06" +dependencies = [ + "bstr", + "gix-chunk", + "gix-hash", + "memmap2", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-config" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfb898c5b695fd4acfc3c0ab638525a65545d47706064dcf7b5ead6cdb136c0" +dependencies = [ + "bstr", + "gix-config-value", + "gix-features", + "gix-glob", + "gix-path", + "gix-ref", + "gix-sec", + "memchr", + "once_cell", + "smallvec", + "thiserror 2.0.18", + "unicode-bom", + "winnow", +] + +[[package]] +name = "gix-config-value" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c489abb061c74b0c3ad790e24a606ef968cebab48ec673d6a891ece7d5aef64" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "gix-path", + "libc", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-date" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "661245d045aa7c16ba4244daaabd823c562c3e45f1f25b816be2c57ee09f2171" +dependencies = [ + "bstr", + "itoa", + "jiff", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-diff" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de854852010d44a317f30c92d67a983e691c9478c8a3fb4117c1f48626bcdea8" +dependencies = [ + "bstr", + "gix-hash", + "gix-object", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-discover" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb180c91ca1a2cf53e828bb63d8d8f8fa7526f49b83b33d7f46cbeb5d79d30a" +dependencies = [ + "bstr", + "dunce", + "gix-fs", + "gix-hash", + "gix-path", + "gix-ref", + "gix-sec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-features" +version = "0.43.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1543cd9b8abcbcebaa1a666a5c168ee2cda4dea50d3961ee0e6d1c42f81e5b" +dependencies = [ + "bytesize", + "crc32fast", + "flate2", + "gix-path", + "gix-trace", + "gix-utils", + "libc", + "once_cell", + "prodash", + "thiserror 2.0.18", + "walkdir", +] + +[[package]] +name = "gix-filter" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa6571a3927e7ab10f64279a088e0dae08e8da05547771796d7389bbe28ad9ff" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-hash", + "gix-object", + "gix-packetline-blocking", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-fs" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a4d90307d064fa7230e0f87b03231be28f8ba63b913fc15346f489519d0c304" +dependencies = [ + "bstr", + "fastrand", + "gix-features", + "gix-path", + "gix-utils", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-glob" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b947db8366823e7a750c254f6bb29e27e17f27e457bf336ba79b32423db62cd5" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "gix-features", + "gix-path", +] + +[[package]] +name = "gix-hash" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251fad79796a731a2a7664d9ea95ee29a9e99474de2769e152238d4fdb69d50e" +dependencies = [ + "faster-hex", + "gix-features", + "sha1-checked", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-hashtable" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c35300b54896153e55d53f4180460931ccd69b7e8d2f6b9d6401122cdedc4f07" +dependencies = [ + "gix-hash", + "hashbrown 0.15.5", + "parking_lot", +] + +[[package]] +name = "gix-ignore" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "564d6fddf46e2c981f571b23d6ad40cb08bddcaf6fc7458b1d49727ad23c2870" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-index" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af39fde3ce4ce11371d9ce826f2936ec347318f2d1972fe98c2e7134e267e25" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.15.5", + "itoa", + "libc", + "memmap2", + "rustix", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-lock" +version = "18.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9fa71da90365668a621e184eb5b979904471af1b3b09b943a84bc50e8ad42ed" +dependencies = [ + "gix-tempfile", + "gix-utils", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-object" +version = "0.50.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69ce108ab67b65fbd4fb7e1331502429d78baeb2eee10008bdef55765397c07" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-path", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", + "thiserror 2.0.18", + "winnow", +] + +[[package]] +name = "gix-odb" +version = "0.70.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9d7af10fda9df0bb4f7f9bd507963560b3c66cb15a5b825caf752e0eb109ac" +dependencies = [ + "arc-swap", + "gix-date", + "gix-features", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-path", + "gix-quote", + "parking_lot", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-pack" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8571df89bfca5abb49c3e3372393f7af7e6f8b8dbe2b96303593cef5b263019" +dependencies = [ + "clru", + "gix-chunk", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-path", + "memmap2", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-packetline" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64286a8b5148e76ab80932e72762dd27ccf6169dd7a134b027c8a262a8262fcf" +dependencies = [ + "bstr", + "faster-hex", + "gix-trace", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-packetline-blocking" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89c59c3ad41e68cb38547d849e9ef5ccfc0d00f282244ba1441ae856be54d001" +dependencies = [ + "bstr", + "faster-hex", + "gix-trace", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-path" +version = "0.10.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cb06c3e4f8eed6e24fd915fa93145e28a511f4ea0e768bae16673e05ed3f366" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-pathspec" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daedead611c9bd1f3640dc90a9012b45f790201788af4d659f28d94071da7fba" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "gix-attributes", + "gix-config-value", + "gix-glob", + "gix-path", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-protocol" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12b4b807c47ffcf7c1e5b8119585368a56449f3493da93b931e1d4239364e922" +dependencies = [ + "bstr", + "gix-date", + "gix-features", + "gix-hash", + "gix-ref", + "gix-shallow", + "gix-transport", + "gix-utils", + "maybe-async", + "thiserror 2.0.18", + "winnow", +] + +[[package]] +name = "gix-quote" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96fc2ff2ec8cc0c92807f02eab1f00eb02619fc2810d13dc42679492fcc36757" +dependencies = [ + "bstr", + "gix-utils", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-ref" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b966f578079a42f4a51413b17bce476544cca1cf605753466669082f94721758" +dependencies = [ + "gix-actor", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror 2.0.18", + "winnow", +] + +[[package]] +name = "gix-refspec" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29cae1ae31108826e7156a5e60bffacab405f4413f5bc0375e19772cce0055" +dependencies = [ + "bstr", + "gix-hash", + "gix-revision", + "gix-validate", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-revision" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f651f2b1742f760bb8161d6743229206e962b73d9c33c41f4e4aefa6586cbd3d" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "gix-trace", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-revwalk" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06e74f91709729e099af6721bd0fa7d62f243f2005085152301ca5cdd86ec02c" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-sec" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea9962ed6d9114f7f100efe038752f41283c225bb507a2888903ac593dffa6be" +dependencies = [ + "bitflags 2.13.2", + "gix-path", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-shallow" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d936745103243ae4c510f19e0760ce73fb0f08096588fdbe0f0d7fb7ce8944b7" +dependencies = [ + "bstr", + "gix-hash", + "gix-lock", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-submodule" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "657cc5dd43cbc7a14d9c5aaf02cfbe9c2a15d077cded3f304adb30ef78852d3e" +dependencies = [ + "bstr", + "gix-config", + "gix-path", + "gix-pathspec", + "gix-refspec", + "gix-url", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-tempfile" +version = "18.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666c0041bcdedf5fa05e9bef663c897debab24b7dc1741605742412d1d47da57" +dependencies = [ + "gix-fs", + "libc", + "once_cell", + "parking_lot", + "tempfile", +] + +[[package]] +name = "gix-trace" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814" + +[[package]] +name = "gix-transport" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f7cc0179fc89d53c54e1f9ce51229494864ab4bf136132d69db1b011741ca3" +dependencies = [ + "bstr", + "gix-command", + "gix-features", + "gix-packetline", + "gix-quote", + "gix-sec", + "gix-url", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-traverse" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7cdc82509d792ba0ad815f86f6b469c7afe10f94362e96c4494525a6601bdd5" +dependencies = [ + "bitflags 2.13.2", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-url" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b76a9d266254ad287ffd44467cd88e7868799b08f4d52e02d942b93e514d16f" +dependencies = [ + "bstr", + "gix-features", + "gix-path", + "percent-encoding", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "gix-utils" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da1c46491b49458a446cc76f0085860f8164c2290742e0aa8c653ce67240a97" +dependencies = [ + "fastrand", + "getrandom", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b1e63a5b516e970a594f870ed4571a8fdcb8a344e7bd407a20db8bd61dbfde4" +dependencies = [ + "bstr", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-worktree" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55f625ac9126c19bef06dbc6d2703cdd7987e21e35b497bb265ac37d383877b1" +dependencies = [ + "bstr", + "gix-attributes", + "gix-features", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-validate", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "human_format" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaec953f16e5bcf6b8a3cb3aa959b17e5577dbd2693e94554c462c08be22624b" + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt 1.2.0", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt 1.2.0", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "either" -version = "1.15.0" +name = "jiff-tzdb" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" [[package]] -name = "flate2" -version = "1.1.9" +name = "jiff-tzdb-platform" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" dependencies = [ - "crc32fast", - "miniz_oxide", - "zlib-rs", + "jiff-tzdb", ] [[package]] @@ -134,7 +1310,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "kstring" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +dependencies = [ + "static_assertions", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", ] [[package]] @@ -159,6 +1371,17 @@ version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +[[package]] +name = "maybe-async" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "mca" version = "2.1.2" @@ -177,6 +1400,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" +dependencies = [ + "libc", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -187,6 +1419,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "na_nbt" version = "0.2.1" @@ -199,6 +1437,65 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -208,6 +1505,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prodash" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6efc566849d3d9d737c5cb06cc50e48950ebe3d3f9d70631490fff3a07b139" +dependencies = [ + "bytesize", + "human_format", + "parking_lot", +] + [[package]] name = "quote" version = "1.0.45" @@ -217,6 +1525,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rawzip" version = "0.4.4" @@ -243,17 +1557,34 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" + [[package]] name = "rust-pruner" version = "1.1.1" dependencies = [ "flate2", + "gix", "jni", "mca", "na_nbt", "rawzip", "rayon", "walkdir", + "xxhash-rust", ] [[package]] @@ -265,6 +1596,25 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "same-file" version = "1.0.6" @@ -274,12 +1624,74 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest", + "sha1", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -302,6 +1714,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -319,6 +1743,41 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -345,7 +1804,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -356,9 +1815,25 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "twox-hash" version = "1.6.3" @@ -369,12 +1844,57 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -475,6 +1995,50 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xxhash-rust" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.52" @@ -492,7 +2056,61 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", ] [[package]] diff --git a/core/native/rust-pruner/Cargo.toml b/core/native/rust-pruner/Cargo.toml index a1126b5..08eebaf 100644 --- a/core/native/rust-pruner/Cargo.toml +++ b/core/native/rust-pruner/Cargo.toml @@ -22,6 +22,14 @@ rayon = "1" walkdir = "2" jni = "0.21" rawzip = "0.4.4" +xxhash-rust = { version = "0.8", features = ["xxh3"] } +# Object-store + commit/tag APIs. High-level "git add" porcelain is incomplete, +# so git_backup.rs walks the worktree and writes blobs/trees itself. +gix = { version = "0.73", default-features = false, features = [ + "comfort", + "revision", + "attributes", +] } [profile.release] lto = "thin" diff --git a/core/native/rust-pruner/src/git_backup.rs b/core/native/rust-pruner/src/git_backup.rs new file mode 100644 index 0000000..5c297bf --- /dev/null +++ b/core/native/rust-pruner/src/git_backup.rs @@ -0,0 +1,312 @@ +//! Minimal Git backup store via gitoxide (`gix`). +//! We walk the worktree ourselves and write blobs/trees/commits/tags. +//! Native `git` is preferred from Java when available. This is the bundled fallback. + +use gix::bstr::ByteSlice; +use gix::objs::{tree, Object, Tree}; +use std::fs; +use std::io::{self, ErrorKind}; +use std::path::Path; + +const BRANCH: &str = "main"; +const AUTHOR_NAME: &str = "Chronos Backups"; +const AUTHOR_EMAIL: &str = "chronos@localhost"; + +pub fn init_repo(worktree: &Path) -> io::Result<()> { + fs::create_dir_all(worktree)?; + let git_dir = worktree.join(".git"); + if git_dir.exists() { + tune_config(worktree)?; + return Ok(()); + } + gix::init(worktree).map_err(gix_err)?; + // Point HEAD at main before first commit. + let head = git_dir.join("HEAD"); + fs::write(&head, format!("ref: refs/heads/{BRANCH}\n"))?; + fs::create_dir_all(git_dir.join("refs/heads"))?; + fs::create_dir_all(git_dir.join("refs/tags"))?; + tune_config(worktree)?; + Ok(()) +} + +fn tune_config(worktree: &Path) -> io::Result<()> { + let config_path = worktree.join(".git/config"); + let mut body = if config_path.exists() { + fs::read_to_string(&config_path)? + } else { + String::new() + }; + ensure_config_kv(&mut body, "core", "autocrlf", "false"); + ensure_config_kv(&mut body, "core", "bigFileThreshold", "1073741824"); + ensure_config_kv(&mut body, "pack", "window", "0"); + ensure_config_kv(&mut body, "user", "name", AUTHOR_NAME); + ensure_config_kv(&mut body, "user", "email", AUTHOR_EMAIL); + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(config_path, body)?; + Ok(()) +} + +fn ensure_config_kv(body: &mut String, section: &str, key: &str, value: &str) { + let header = format!("[{section}]"); + let line = format!("\t{key} = {value}"); + if let Some(sec_idx) = body.find(&header) { + let after = &body[sec_idx + header.len()..]; + // Index of the '\n' before the next "[section]", not the '[' itself. + // Inserting at '[' would glue the new line onto the next header. + let next_sec = after.find("\n[").unwrap_or(after.len()); + let section_body = &after[..next_sec]; + if section_body.contains(&format!("{key} =")) || section_body.contains(&format!("{key}=")) { + return; + } + // next_sec is relative to `after`. Absolute index is after's start + next_sec. + let insert_at = sec_idx + header.len() + next_sec; + body.insert_str(insert_at, &format!("\n{line}")); + } else { + if !body.is_empty() && !body.ends_with('\n') { + body.push('\n'); + } + body.push_str(&format!("{header}\n{line}\n")); + } +} + +#[cfg(test)] +mod ensure_config_kv_tests { + use super::ensure_config_kv; + + #[test] + fn inserts_before_next_section_without_gluing_header() { + let mut body = String::from("[core]\n\trepositoryformatversion = 0\n[user]\n\tname = Someone\n"); + ensure_config_kv(&mut body, "core", "autocrlf", "false"); + assert_eq!( + body, + "[core]\n\trepositoryformatversion = 0\n\tautocrlf = false\n[user]\n\tname = Someone\n" + ); + } + + #[test] + fn skips_existing_key() { + let mut body = String::from("[core]\n\tautocrlf = false\n[user]\n\tname = Someone\n"); + ensure_config_kv(&mut body, "core", "autocrlf", "true"); + assert_eq!(body, "[core]\n\tautocrlf = false\n[user]\n\tname = Someone\n"); + } +} + +pub fn commit_and_tag(worktree: &Path, backup_id: &str, message: &str) -> io::Result { + init_repo(worktree)?; + let repo = gix::open(worktree).map_err(gix_err)?; + let tree_id = write_worktree_tree(&repo, worktree)?; + let parent = repo.head_id().ok().map(|id| id.detach()); + let parents: Vec<_> = parent.into_iter().collect(); + let msg = if message.is_empty() { + format!("Chronos backup {backup_id}") + } else { + message.to_string() + }; + let commit_id = repo + .commit(format!("refs/heads/{BRANCH}"), msg, tree_id, parents) + .map_err(gix_err)?; + let tag = tag_name_for_backup_id(backup_id); + write_lightweight_tag(worktree, &tag, &commit_id.to_string())?; + Ok(commit_id.to_string()) +} + +fn write_lightweight_tag(worktree: &Path, tag: &str, commit_sha: &str) -> io::Result<()> { + let path = worktree.join(".git/refs/tags").join(tag); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, format!("{commit_sha}\n"))?; + Ok(()) +} + +pub fn list_backup_tags(worktree: &Path) -> io::Result> { + init_repo(worktree)?; + let tags_dir = worktree.join(".git/refs/tags"); + if !tags_dir.is_dir() { + return Ok(Vec::new()); + } + let mut tags = Vec::new(); + for entry in fs::read_dir(tags_dir)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + if entry.path().is_file() { + tags.push(name); + } + } + tags.sort_by(|a, b| b.cmp(a)); + Ok(tags) +} + +pub fn restore_tag(worktree: &Path, tag_name: &str, dest: &Path) -> io::Result<()> { + let tag = normalize_tag(tag_name)?; + init_repo(worktree)?; + if dest.exists() { + return Err(io::Error::new( + ErrorKind::AlreadyExists, + format!("Export destination already exists: {}", dest.display()), + )); + } + fs::create_dir_all(dest)?; + let repo = gix::open(worktree).map_err(gix_err)?; + let tag_ref = format!("refs/tags/{tag}"); + let id = repo + .find_reference(tag_ref.as_str()) + .map_err(gix_err)? + .into_fully_peeled_id() + .map_err(gix_err)? + .object() + .map_err(gix_err)?; + let commit = id.peel_to_commit().map_err(gix_err)?; + let tree = commit.tree().map_err(gix_err)?; + checkout_tree(&repo, &tree, dest)?; + Ok(()) +} + +fn checkout_tree(repo: &gix::Repository, tree: &gix::Tree<'_>, dest: &Path) -> io::Result<()> { + for entry in tree.iter() { + let entry = entry.map_err(gix_err)?; + let name = entry.filename().to_str().map_err(|_| { + io::Error::new(ErrorKind::InvalidData, "non-utf8 path in git tree") + })?; + let out = dest.join(name); + let mode = entry.mode(); + let oid = entry.oid(); + if mode.is_tree() { + fs::create_dir_all(&out)?; + let obj = repo.find_object(oid).map_err(gix_err)?; + let subtree = obj.try_into_tree().map_err(gix_err)?; + checkout_tree(repo, &subtree, &out)?; + } else if mode.is_blob() || mode.is_executable() || mode.is_link() { + if let Some(parent) = out.parent() { + fs::create_dir_all(parent)?; + } + let obj = repo.find_object(oid).map_err(gix_err)?; + let blob = obj.try_into_blob().map_err(gix_err)?; + // Symlink targets are written as plain files for restore portability. + fs::write(&out, &blob.data)?; + #[cfg(unix)] + if mode.is_executable() { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&out)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&out, perms)?; + } + } + } + Ok(()) +} + +fn write_worktree_tree(repo: &gix::Repository, dir: &Path) -> io::Result { + let mut entries: Vec = Vec::new(); + let mut children: Vec = Vec::new(); + for entry in fs::read_dir(dir)? { + children.push(entry?); + } + children.sort_by_key(|e| e.file_name()); + + for child in children { + let name = child.file_name(); + let name_str = name.to_string_lossy(); + if name_str == ".git" { + continue; + } + let path = child.path(); + let file_name = gix::bstr::BString::from(name_str.as_bytes()); + if path.is_dir() { + let oid = write_worktree_tree(repo, &path)?; + entries.push(tree::Entry { + mode: tree::EntryKind::Tree.into(), + filename: file_name, + oid, + }); + } else if path.is_file() { + let bytes = fs::read(&path)?; + let oid = repo.write_blob(&bytes).map_err(gix_err)?.detach(); + let executable = is_executable(&path); + let kind = if executable { + tree::EntryKind::BlobExecutable + } else { + tree::EntryKind::Blob + }; + entries.push(tree::Entry { + mode: kind.into(), + filename: file_name, + oid, + }); + } + } + + // Git sorts tree entries by name, with trees as name + '/' + entries.sort_by(|a, b| { + let mut an = a.filename.to_vec(); + let mut bn = b.filename.to_vec(); + if a.mode.is_tree() { + an.push(b'/'); + } + if b.mode.is_tree() { + bn.push(b'/'); + } + an.cmp(&bn) + }); + + let tree = Tree { entries }; + Ok(repo.write_object(Object::Tree(tree)).map_err(gix_err)?.detach()) +} + +fn is_executable(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::metadata(path) + .map(|m| m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + let _ = path; + false + } +} + +fn normalize_tag(tag_name: &str) -> io::Result { + // Must match tag_name_for_backup_id so restore finds tags created via commit_and_tag. + let tag = tag_name_for_backup_id(tag_name); + if tag.is_empty() { + return Err(io::Error::new(ErrorKind::InvalidInput, "Backup tag is empty")); + } + Ok(tag) +} + +pub fn tag_name_for_backup_id(backup_id: &str) -> String { + backup_id.trim().replace(' ', "_") +} + +#[cfg(test)] +mod tag_name_tests { + use super::{normalize_tag, tag_name_for_backup_id}; + + #[test] + fn normalize_tag_matches_commit_tag_with_spaces() { + let backup_id = "backup id"; + let created = tag_name_for_backup_id(backup_id); + let looked_up = normalize_tag(backup_id).unwrap(); + assert_eq!(created, "backup_id"); + assert_eq!(looked_up, created); + } + + #[test] + fn normalize_tag_idempotent_for_underscored_names() { + assert_eq!(normalize_tag("backup_id").unwrap(), "backup_id"); + } + + #[test] + fn normalize_tag_rejects_empty() { + assert!(normalize_tag(" ").is_err()); + } +} + +fn gix_err(err: E) -> io::Error { + io::Error::new(ErrorKind::Other, format!("gitoxide: {err}")) +} diff --git a/core/native/rust-pruner/src/lib.rs b/core/native/rust-pruner/src/lib.rs index 46f8e01..9d7c828 100644 --- a/core/native/rust-pruner/src/lib.rs +++ b/core/native/rust-pruner/src/lib.rs @@ -4,9 +4,11 @@ //! Copy files in parallel using rayon //! Zip the snapshot using rawzip, and instead of writing back to disk (cache) we stream the pruned files directly into the zip +mod git_backup; mod jni_log; mod pruner; mod snapshot_zip; +mod source_hash; mod world_copy; #[cfg(feature = "world-test")] @@ -248,7 +250,7 @@ pub extern "system" fn Java_com_magicjinn_chronos_core_RustPrunerBridge_pruneWor const COPY_ABORT_POLL_FILES: usize = 2048; -fn parse_copy_blacklist(env: &mut JNIEnv, arr: jobjectArray) -> Result, jni::errors::Error> { +fn parse_jstring_array(env: &mut JNIEnv, arr: jobjectArray) -> Result, jni::errors::Error> { if arr.is_null() { return Ok(Vec::new()); } @@ -292,7 +294,7 @@ pub extern "system" fn Java_com_magicjinn_chronos_core_RustPrunerBridge_copyWorl return 4; } }; - let blacklist = match parse_copy_blacklist(&mut env, blacklist) { + let blacklist = match parse_jstring_array(&mut env, blacklist) { Ok(b) => b, Err(err) => { eprintln!("Error: failed to read copy blacklist from Java: {err}"); @@ -390,3 +392,272 @@ pub extern "system" fn Java_com_magicjinn_chronos_core_RustPrunerBridge_getNativ Err(_) => std::ptr::null_mut(), } } + +/// Hashes live world sources with xxHash3, compares to a previous files JSON map, +/// and writes a scan-result JSON file for Java. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_magicjinn_chronos_core_RustPrunerBridge_scanWorldSourcesNative( + mut env: JNIEnv, + _class: JClass, + world_root: JString, + blacklist: jobjectArray, + previous_files_json: JString, + prune_policy_hash: JString, + force_all: jint, + result_json_path: JString, + max_worker_threads: jint, +) -> jint { + let result = catch_unwind(AssertUnwindSafe(|| { + let world_str = match env.get_string(&world_root) { + Ok(s) => s.to_string_lossy().into_owned(), + Err(_) => return 4, + }; + let prev_json = match env.get_string(&previous_files_json) { + Ok(s) => s.to_string_lossy().into_owned(), + Err(_) => return 4, + }; + let policy = match env.get_string(&prune_policy_hash) { + Ok(s) => s.to_string_lossy().into_owned(), + Err(_) => return 4, + }; + let out_path = match env.get_string(&result_json_path) { + Ok(s) => s.to_string_lossy().into_owned(), + Err(_) => return 4, + }; + let blacklist = match parse_jstring_array(&mut env, blacklist) { + Ok(b) => b, + Err(_) => return 4, + }; + let threads = if max_worker_threads <= 0 { + 0 + } else { + max_worker_threads as usize + }; + let previous = source_hash::fingerprints_from_json_object(&prev_json); + let scan = match source_hash::scan_world_sources( + PathBuf::from(world_str).as_path(), + &blacklist, + &previous, + force_all != 0, + threads, + ) { + Ok(s) => s, + Err(_) => return 1, + }; + let json = source_hash::scan_result_to_json(&scan, &policy); + if let Err(_) = std::fs::write(PathBuf::from(out_path), json.as_bytes()) { + return 1; + } + 0 + })); + + match result { + Ok(code) => code, + Err(_) => { + let _ = env.throw_new("java/lang/RuntimeException", "rust-pruner source scan panicked"); + 3 + } + } +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_magicjinn_chronos_core_RustPrunerBridge_copySelectedFilesNative( + mut env: JNIEnv, + _class: JClass, + world_root: JString, + dest_root: JString, + relative_paths: jobjectArray, + max_copy_worker_threads: jint, + out_copied_file_count: jintArray, +) -> jint { + let result = catch_unwind(AssertUnwindSafe(|| { + let world_str = match env.get_string(&world_root) { + Ok(s) => s.to_string_lossy().into_owned(), + Err(_) => return 4, + }; + let dest_str = match env.get_string(&dest_root) { + Ok(s) => s.to_string_lossy().into_owned(), + Err(_) => return 4, + }; + let paths = match parse_jstring_array(&mut env, relative_paths) { + Ok(b) => b, + Err(_) => return 4, + }; + let threads = if max_copy_worker_threads <= 0 { + 0 + } else { + max_copy_worker_threads as usize + }; + match world_copy::copy_selected_files( + PathBuf::from(world_str).as_path(), + PathBuf::from(dest_str).as_path(), + &paths, + threads, + ) { + Ok(total) => { + if !out_copied_file_count.is_null() { + let out_arr = unsafe { JIntArray::from_raw(out_copied_file_count) }; + if env.set_int_array_region(&out_arr, 0, &[total as jint]).is_err() { + return 4; + } + } + 0 + } + Err(err) if err.kind() == ErrorKind::PermissionDenied => 5, + Err(_) => 1, + } + })); + + match result { + Ok(code) => code, + Err(_) => { + let _ = env.throw_new( + "java/lang/RuntimeException", + "rust-pruner selected copy panicked", + ); + 3 + } + } +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_magicjinn_chronos_core_RustPrunerBridge_gitInitNative( + mut env: JNIEnv, + _class: JClass, + worktree: JString, +) -> jint { + let result = catch_unwind(AssertUnwindSafe(|| { + let path = match env.get_string(&worktree) { + Ok(s) => s.to_string_lossy().into_owned(), + Err(_) => return 4, + }; + match git_backup::init_repo(PathBuf::from(path).as_path()) { + Ok(()) => 0, + Err(_) => 1, + } + })); + match result { + Ok(code) => code, + Err(_) => { + let _ = env.throw_new("java/lang/RuntimeException", "gitoxide init panicked"); + 3 + } + } +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_magicjinn_chronos_core_RustPrunerBridge_gitCommitAndTagNative( + mut env: JNIEnv, + _class: JClass, + worktree: JString, + backup_id: JString, + message: JString, +) -> jstring { + let result = catch_unwind(AssertUnwindSafe(|| { + let path = match env.get_string(&worktree) { + Ok(s) => s.to_string_lossy().into_owned(), + Err(_) => return std::ptr::null_mut(), + }; + let id = match env.get_string(&backup_id) { + Ok(s) => s.to_string_lossy().into_owned(), + Err(_) => return std::ptr::null_mut(), + }; + let msg = match env.get_string(&message) { + Ok(s) => s.to_string_lossy().into_owned(), + Err(_) => return std::ptr::null_mut(), + }; + match git_backup::commit_and_tag(PathBuf::from(path).as_path(), &id, &msg) { + Ok(sha) => match env.new_string(sha) { + Ok(s) => s.into_raw(), + Err(_) => std::ptr::null_mut(), + }, + Err(err) => { + let _ = env.throw_new( + "java/io/IOException", + &format!("gitoxide commit failed: {err}"), + ); + std::ptr::null_mut() + } + } + })); + match result { + Ok(s) => s, + Err(_) => { + let _ = env.throw_new("java/lang/RuntimeException", "gitoxide commit panicked"); + std::ptr::null_mut() + } + } +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_magicjinn_chronos_core_RustPrunerBridge_gitListBackupTagsNative( + mut env: JNIEnv, + _class: JClass, + worktree: JString, +) -> jstring { + let result = catch_unwind(AssertUnwindSafe(|| { + let path = match env.get_string(&worktree) { + Ok(s) => s.to_string_lossy().into_owned(), + Err(_) => return std::ptr::null_mut(), + }; + match git_backup::list_backup_tags(PathBuf::from(path).as_path()) { + Ok(tags) => match env.new_string(tags.join("\n")) { + Ok(s) => s.into_raw(), + Err(_) => std::ptr::null_mut(), + }, + Err(err) => { + let _ = env.throw_new( + "java/io/IOException", + &format!("gitoxide list tags failed: {err}"), + ); + std::ptr::null_mut() + } + } + })); + match result { + Ok(s) => s, + Err(_) => { + let _ = env.throw_new("java/lang/RuntimeException", "gitoxide list tags panicked"); + std::ptr::null_mut() + } + } +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_magicjinn_chronos_core_RustPrunerBridge_gitExportTagNative( + mut env: JNIEnv, + _class: JClass, + worktree: JString, + tag_name: JString, + dest: JString, +) -> jint { + let result = catch_unwind(AssertUnwindSafe(|| { + let path = match env.get_string(&worktree) { + Ok(s) => s.to_string_lossy().into_owned(), + Err(_) => return 4, + }; + let tag = match env.get_string(&tag_name) { + Ok(s) => s.to_string_lossy().into_owned(), + Err(_) => return 4, + }; + let dest_path = match env.get_string(&dest) { + Ok(s) => s.to_string_lossy().into_owned(), + Err(_) => return 4, + }; + match git_backup::restore_tag( + PathBuf::from(path).as_path(), + &tag, + PathBuf::from(dest_path).as_path(), + ) { + Ok(()) => 0, + Err(_) => 1, + } + })); + match result { + Ok(code) => code, + Err(_) => { + let _ = env.throw_new("java/lang/RuntimeException", "gitoxide export panicked"); + 3 + } + } +} diff --git a/core/native/rust-pruner/src/source_hash.rs b/core/native/rust-pruner/src/source_hash.rs new file mode 100644 index 0000000..8f1cfc2 --- /dev/null +++ b/core/native/rust-pruner/src/source_hash.rs @@ -0,0 +1,359 @@ +//! Pre-prune source hashing for incremental Git backups. +//! Uses xxHash3-64 for fast change detection. + +use rayon::prelude::*; +use std::collections::HashMap; +use std::fs::{self, File}; +use std::io::{self, ErrorKind, Read}; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; +use xxhash_rust::xxh3::Xxh3; + +use crate::world_copy; + +const HASH_BUF_SIZE: usize = 1024 * 1024; + +#[derive(Clone, Debug)] +pub struct FileFingerprint { + pub hash: String, + pub size: u64, +} + +#[derive(Debug, Default)] +pub struct ScanResult { + /// Relative path (slash-separated) -> fingerprint of live source bytes. + pub files: HashMap, + pub changed: Vec, + pub deleted: Vec, + pub unchanged: Vec, +} + +/// Walks `world_root` (respecting blacklist), hashes each file with xxHash3-64, +/// and compares against `previous` fingerprints. +/// When `force_all` is true (prune policy changed), every file is marked changed. +pub fn scan_world_sources( + world_root: &Path, + blacklist: &[String], + previous: &HashMap, + force_all: bool, + max_worker_threads: usize, +) -> io::Result { + let plan = world_copy::build_hash_plan(world_root, blacklist)?; + let threads = crate::pruner::resolve_pruner_threads(max_worker_threads); + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(threads) + .build() + .map_err(|e| io::Error::new(ErrorKind::Other, format!("rayon thread pool: {e}")))?; + + let hashed: Vec<(String, io::Result)> = pool.install(|| { + plan.par_iter() + .map(|(abs, rel)| { + let rel_key = rel.replace('\\', "/"); + (rel_key, hash_file(abs)) + }) + .collect() + }); + + let mut result = ScanResult::default(); + let mut seen = HashMap::new(); + + for (rel, fp_result) in hashed { + let fp = match fp_result { + Ok(fp) => fp, + Err(err) if err.kind() == ErrorKind::NotFound => continue, + Err(err) => return Err(err), + }; + let prev = previous.get(&rel); + let changed = force_all + || prev.map(|p| p.hash != fp.hash || p.size != fp.size).unwrap_or(true); + if changed { + result.changed.push(rel.clone()); + } else { + result.unchanged.push(rel.clone()); + } + seen.insert(rel.clone(), ()); + result.files.insert(rel, fp); + } + + for key in previous.keys() { + if !seen.contains_key(key) { + result.deleted.push(key.clone()); + } + } + + result.changed.sort(); + result.deleted.sort(); + result.unchanged.sort(); + Ok(result) +} + +fn hash_file(path: &Path) -> io::Result { + let meta = fs::metadata(path)?; + let size = meta.len(); + let mut file = File::open(path)?; + let mut hasher = Xxh3::new(); + let mut buf = vec![0u8; HASH_BUF_SIZE]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(FileFingerprint { + hash: format!("{:016x}", hasher.digest()), + size, + }) +} + +/// Serialize scan result as JSON for the Java bridge. +pub fn scan_result_to_json(result: &ScanResult, prune_policy_hash: &str) -> String { + let mut out = String::with_capacity(256 + result.files.len() * 64); + out.push_str("{\"version\":1,\"prunePolicyHash\":"); + push_json_string(&mut out, prune_policy_hash); + out.push_str(",\"changed\":["); + for (i, p) in result.changed.iter().enumerate() { + if i > 0 { + out.push(','); + } + push_json_string(&mut out, p); + } + out.push_str("],\"deleted\":["); + for (i, p) in result.deleted.iter().enumerate() { + if i > 0 { + out.push(','); + } + push_json_string(&mut out, p); + } + out.push_str("],\"unchanged\":["); + for (i, p) in result.unchanged.iter().enumerate() { + if i > 0 { + out.push(','); + } + push_json_string(&mut out, p); + } + out.push_str("],\"files\":{"); + let mut first = true; + let mut keys: Vec<&String> = result.files.keys().collect(); + keys.sort(); + for key in keys { + let fp = &result.files[key]; + if !first { + out.push(','); + } + first = false; + push_json_string(&mut out, key); + out.push_str(":{\"hash\":"); + push_json_string(&mut out, &fp.hash); + out.push_str(",\"size\":"); + out.push_str(&fp.size.to_string()); + out.push('}'); + } + out.push_str("}}"); + out +} + +pub fn fingerprints_from_json_object(files_json: &str) -> HashMap { + // Minimal parser for {"path":{"hash":"...","size":N},...} produced by Java. + // Prefer empty on parse failure rather than aborting the backup. + // Backup still completes, but slower, since we lose the efficiency gain from fingerprinting. + let mut map = HashMap::new(); + let trimmed = files_json.trim(); + if trimmed.is_empty() || trimmed == "{}" { + return map; + } + + if let Ok(parsed) = parse_files_map(trimmed) { + map = parsed; + } + + map +} + +fn parse_files_map(s: &str) -> Result, ()> { + let mut map = HashMap::new(); + let bytes = s.as_bytes(); + if bytes.first() != Some(&b'{') || bytes.last() != Some(&b'}') { + return Err(()); + } + let inner = &s[1..s.len() - 1]; + if inner.trim().is_empty() { + return Ok(map); + } + // Split top-level entries by tracking braces/quotes. + let mut depth = 0i32; + let mut in_str = false; + let mut escape = false; + let mut start = 0usize; + let chars: Vec = inner.chars().collect(); + let mut entries: Vec = Vec::new(); + for (i, ch) in chars.iter().enumerate() { + if escape { + escape = false; + continue; + } + match ch { + '\\' if in_str => escape = true, + '"' => in_str = !in_str, + '{' if !in_str => depth += 1, + '}' if !in_str => depth -= 1, + ',' if !in_str && depth == 0 => { + entries.push(chars[start..i].iter().collect()); + start = i + 1; + } + _ => {} + } + } + entries.push(chars[start..].iter().collect()); + + for entry in entries { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + let colon = find_top_colon(entry).ok_or(())?; + let key = unquote(entry[..colon].trim())?; + let value = entry[colon + 1..].trim(); + let hash = extract_string_field(value, "hash").ok_or(())?; + let size = extract_u64_field(value, "size").ok_or(())?; + map.insert(key, FileFingerprint { hash, size }); + } + Ok(map) +} + +fn find_top_colon(s: &str) -> Option { + let mut in_str = false; + let mut escape = false; + for (i, ch) in s.char_indices() { + if escape { + escape = false; + continue; + } + match ch { + '\\' if in_str => escape = true, + '"' => in_str = !in_str, + ':' if !in_str => return Some(i), + _ => {} + } + } + None +} + +fn unquote(s: &str) -> Result { + let s = s.trim(); + if s.len() < 2 || !s.starts_with('"') || !s.ends_with('"') { + return Err(()); + } + let mut out = String::new(); + let mut escape = false; + for ch in s[1..s.len() - 1].chars() { + if escape { + out.push(ch); + escape = false; + } else if ch == '\\' { + escape = true; + } else { + out.push(ch); + } + } + // A well-formed JSON string cannot end mid-escape (that would escape the closing quote). + // Reject truncated input rather than dropping a dangling '\\'. + if escape { + return Err(()); + } + Ok(out) +} + +#[cfg(test)] +mod unquote_tests { + use super::unquote; + + #[test] + fn trailing_backslash_json_keeps_backslash() { + // JSON text "\\" (quote, backslash, backslash, quote) decodes to one '\'. + assert_eq!(unquote("\"\\\\\"").unwrap(), "\\"); + } + + #[test] + fn path_ending_with_backslash() { + assert_eq!(unquote("\"dir\\\\\"").unwrap(), "dir\\"); + } + + #[test] + fn rejects_dangling_escape_before_close() { + // quote + backslash + quote is not a closed JSON string content. + assert!(unquote("\"\\\"").is_err()); + } +} + +fn extract_string_field(obj: &str, field: &str) -> Option { + let needle = format!("\"{field}\""); + let idx = obj.find(&needle)?; + let after = &obj[idx + needle.len()..]; + let colon = after.find(':')?; + let rest = after[colon + 1..].trim_start(); + unquote(take_json_string(rest)?).ok() +} + +fn extract_u64_field(obj: &str, field: &str) -> Option { + let needle = format!("\"{field}\""); + let idx = obj.find(&needle)?; + let after = &obj[idx + needle.len()..]; + let colon = after.find(':')?; + let rest = after[colon + 1..].trim_start(); + let end = rest + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(rest.len()); + rest[..end].parse().ok() +} + +fn take_json_string(s: &str) -> Option<&str> { + if !s.starts_with('"') { + return None; + } + let mut escape = false; + for (i, ch) in s.char_indices().skip(1) { + if escape { + escape = false; + continue; + } + if ch == '\\' { + escape = true; + } else if ch == '"' { + return Some(&s[..=i]); + } + } + None +} + +fn push_json_string(out: &mut String, s: &str) { + out.push('"'); + for ch in s.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); +} + +#[allow(dead_code)] +pub fn collect_rel_paths(world_root: &Path, blacklist: &[String]) -> io::Result> { + let mut paths = Vec::new(); + let walker = WalkDir::new(world_root) + .follow_links(false) + .into_iter() + .filter_entry(|e| world_copy::entry_allowed_for_hash(e, world_root, blacklist)); + for entry in walker { + let entry = entry.map_err(|e| io::Error::new(ErrorKind::Other, e.to_string()))?; + if entry.file_type().is_file() { + paths.push(entry.path().to_path_buf()); + } + } + Ok(paths) +} diff --git a/core/native/rust-pruner/src/world_copy.rs b/core/native/rust-pruner/src/world_copy.rs index d3645f0..d13bff9 100644 --- a/core/native/rust-pruner/src/world_copy.rs +++ b/core/native/rust-pruner/src/world_copy.rs @@ -42,18 +42,27 @@ fn is_copy_blacklisted(relative_to_world_root: &Path, patterns: &[String]) -> bo } fn should_include_entry(entry: &DirEntry, world_root: &Path, blacklist: &[String]) -> bool { + entry_allowed_for_hash(entry, world_root, blacklist) +} + +/// Public for the source-hash scanner (same include rules as copy). +pub fn entry_allowed_for_hash(entry: &DirEntry, world_root: &Path, blacklist: &[String]) -> bool { let path = entry.path(); let Ok(rel) = path.strip_prefix(world_root) else { return true; }; let name = entry.file_name().to_string_lossy(); - // Excplicitly exclude the session lock file + // Explicitly exclude the session lock file if name == SESSION_LOCK_FILE_NAME { return false; } if name.ends_with(NEOFORGE_ATOMIC_TMP_SUFFIX) || name.ends_with(FABRIC_ATOMIC_TMP_SUFFIX) { return false; } + // Never walk into Chronos/Git metadata inside a worktree used as a source. + if name == ".git" || name == ".chronos" { + return false; + } !is_copy_blacklisted(rel, blacklist) } @@ -105,6 +114,64 @@ pub fn build_copy_plan( Ok(copy_jobs) } +/// Walks `world_root` and returns (absolute source path, relative path string) for hashing. +pub fn build_hash_plan( + world_root: &Path, + blacklist: &[String], +) -> io::Result> { + let world_root = world_root.to_path_buf(); + let mut jobs: Vec<(PathBuf, String)> = Vec::new(); + + let walker = WalkDir::new(&world_root) + .follow_links(false) + .into_iter() + .filter_entry(|e| should_include_entry(e, &world_root, blacklist)); + + for entry in walker { + let entry = entry.map_err(|e| io::Error::new(ErrorKind::Other, e.to_string()))?; + if !entry.file_type().is_file() { + continue; + } + let rel = entry + .path() + .strip_prefix(&world_root) + .map_err(|_| io::Error::new(ErrorKind::InvalidInput, "walk path left world root"))?; + jobs.push(( + entry.path().to_path_buf(), + rel.to_string_lossy().replace('\\', "/"), + )); + } + + Ok(jobs) +} + +/// Copies only the listed relative files from `world_root` into `dest_root`. +pub fn copy_selected_files( + world_root: &Path, + dest_root: &Path, + relative_paths: &[String], + max_copy_worker_threads: usize, +) -> io::Result { + assert_cache_outside_world(world_root, dest_root)?; + let mut jobs: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(relative_paths.len()); + for rel in relative_paths { + let rel_norm = rel.replace('\\', "/"); + if rel_norm.is_empty() || rel_norm.contains("..") { + continue; + } + let src = world_root.join(Path::new(&rel_norm)); + let dst = dest_root.join(Path::new(&rel_norm)); + if src.is_file() { + jobs.push((src, dst)); + } + } + let total = jobs.len(); + for chunk in jobs.chunks(2048) { + parallel_copy_file_chunk(chunk, max_copy_worker_threads)?; + } + Ok(total) +} + /// Windows: ERROR_SHARING_VIOLATION / ERROR_LOCK_VIOLATION while Minecraft still holds region files. #[cfg(windows)] fn is_transient_windows_copy_lock(err: &io::Error) -> bool { diff --git a/core/src/main/java/com/magicjinn/chronos/core/Backupper.java b/core/src/main/java/com/magicjinn/chronos/core/Backupper.java index bd99877..cabc3ea 100644 --- a/core/src/main/java/com/magicjinn/chronos/core/Backupper.java +++ b/core/src/main/java/com/magicjinn/chronos/core/Backupper.java @@ -20,6 +20,9 @@ import com.magicjinn.chronos.core.config.CompressionMethod; import com.magicjinn.chronos.core.config.Config; import com.magicjinn.chronos.core.config.ModConfig; +import com.magicjinn.chronos.core.git.GitBackupEngine; +import com.magicjinn.chronos.core.git.GitBackupRepository; +import com.magicjinn.chronos.core.git.NativeGit; import com.magicjinn.cloudintegration.CloudSync; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -259,6 +262,86 @@ static void clearShutdownRequest() { pendingServerChats.clear(); } + /** + * Lists git backup tags for the active world (newest first). Empty when the + * repo does not exist yet. + */ + public static List listGitBackupTags() throws IOException { + Path worktree = activeWorldBackupDir(); + if (worktree == null || !Files.isDirectory(worktree.resolve(".git"))) + return Collections.emptyList(); + + return GitBackupEngine.listSnapshots(worktree); + } + + /** + * Lists zip/folder Chronos backup artifact names for the active world + * (newest first). Empty when the world backup folder is missing. + */ + public static List listLocalBackupNames() throws IOException { + Path worldBackupDir = activeWorldBackupDir(); + if (worldBackupDir == null || !Files.isDirectory(worldBackupDir)) + return Collections.emptyList(); + + String worldName = worldBackupDir.getFileName().toString(); + List backups = new ArrayList<>(); + try (DirectoryStream ds = Files.newDirectoryStream(worldBackupDir)) { + for (Path p : ds) { + String name = p.getFileName().toString(); + if (!ChronosBackupArtifacts.isChronosBackupName(name, worldName)) + continue; + if (Files.isRegularFile(p) && name.endsWith(".zip")) + backups.add(p); + else if (Files.isDirectory(p)) + backups.add(p); + } + } + backups.sort(Comparator.comparingLong( + (Path p) -> ChronosBackupArtifacts.timestampSortKey(p.getFileName().toString())) + .reversed()); + List names = new ArrayList<>(backups.size()); + for (Path p : backups) + names.add(p.getFileName().toString()); + return names; + } + + /** + * Exports a git backup tag into {@code backups/exported/{world}-{tag}} and + * returns that path. Does not touch the live world. + */ + public static Path exportGitBackup(String tagOrId) throws IOException { + if (Config.getCompressionMethod() != CompressionMethod.GIT) + throw new IOException("compressionMethod is not \"git\""); + + Path worktree = activeWorldBackupDir(); + if (worktree == null) + throw new IOException("No active world backup folder"); + + BackupRuntimeContext ctx = Scheduler.tryGetRuntimeContext(); + String world = ctx != null + ? ChronosBackupArtifacts.sanitizeWorldDirName(ctx.getWorldName()) + : "world"; + String tag = GitBackupRepository.tagNameForBackupId(tagOrId); + String safeTag = ChronosBackupArtifacts.sanitizeWorldDirName(tag); + Path dest = chronosFolder + .resolve(ChronosBackupArtifacts.EXPORTED_FOLDER_NAME) + .resolve(world + "-" + safeTag); + + return GitBackupEngine.exportSnapshot(worktree, tag, dest); + } + + /** Active world's backup folder under {@link #chronosFolder}, or null. */ + private static Path activeWorldBackupDir() { + if (chronosFolder == null) + return null; + + BackupRuntimeContext ctx = Scheduler.tryGetRuntimeContext(); + if (ctx == null) + return null; + + return chronosFolder.resolve(ChronosBackupArtifacts.sanitizeWorldDirName(ctx.getWorldName())); + } + public static void InitializeBackupper() { refreshBackupPaths(true); ChronosLogger.info("Backupper is ready and on standby..."); @@ -443,10 +526,16 @@ private static boolean startBackupWorker( final Path zipOutputPath; final Path cacheSnapshotPath; final Path folderOutputPath; + final String backupIdForRun = backupId; if (compressionMethod == CompressionMethod.ZIP) { cacheSnapshotPath = cacheFolder.resolve(backupId); zipOutputPath = worldBackupDir.resolve(ChronosBackupArtifacts.zipFileName(backupId)); folderOutputPath = null; + } else if (compressionMethod == CompressionMethod.GIT) { + // Persistent per-world Git worktree (incremental commits + tags). + cacheSnapshotPath = null; + zipOutputPath = null; + folderOutputPath = worldBackupDir; } else { cacheSnapshotPath = null; zipOutputPath = null; @@ -462,6 +551,7 @@ private static boolean startBackupWorker( run.zipOutputPath = zipOutputPath; run.cacheSnapshotPath = cacheSnapshotPath; run.folderOutputPath = folderOutputPath; + run.backupId = backupIdForRun; run.worldPath = worldPath; run.saveContainers = scope.saveContainers(); run.worldController = worldController; @@ -609,83 +699,108 @@ private static void runHeavyBackupWork(InFlightBackup run) { : Collections.emptyList(); int pruneThreads = Math.max(0, cfg.pruneMaxWorkerThreads); - if (run.compressionMethod == CompressionMethod.ZIP) { - run.context.logInfo("Chronos backups: copying world into cache (this can take a while)..."); - assertCacheOutsideSaveContainers(run.saveContainers, layoutRootAbs, run.cacheSnapshotPath); - deleteDirectory(run.cacheSnapshotPath); - Files.createDirectories(run.cacheSnapshotPath); - int[] outCopied = new int[1]; - copySaveContainersToSnapshot( - layoutRootAbs, - run.saveContainers, - run.cacheSnapshotPath, - blacklist, - pruneThreads, - outCopied); - run.context.logInfo( - "Chronos backups: finished copying " + outCopied[0] + " files into cache."); - } else { + if (run.compressionMethod == CompressionMethod.GIT) { run.context.logInfo( - "Chronos backups: copying world to backup folder..."); - assertCacheOutsideSaveContainers(run.saveContainers, layoutRootAbs, run.folderOutputPath); - deleteDirectory(run.folderOutputPath); - Files.createDirectories(run.folderOutputPath); - int[] outCopied = new int[1]; - copySaveContainersToSnapshot( + "Chronos backups: incremental git backup" + + (cfg.allowNativeGit && NativeGit.isAvailable() + ? " (native git)" + : " (bundled gitoxide)") + + "..."); + GitBackupEngine.runIncrementalBackup( + run.context, layoutRootAbs, run.saveContainers, run.folderOutputPath, - blacklist, - pruneThreads, - outCopied); - run.context.logInfo( - "Chronos backups: finished copying " + outCopied[0] + " files into backup folder."); - } + run.backupId); + // Duration / completion exclude remote push (kicked off after announce, like + // Drive sync). + run.durationEndNanos = System.nanoTime(); + run.copyComplete = true; + if (shouldAbortBackupWork()) + throw new InterruptedIOException("Backup aborted"); + + run.backupFinishedSuccessfully = true; + } else { + if (run.compressionMethod == CompressionMethod.ZIP) { + run.context.logInfo("Chronos backups: copying world into cache (this can take a while)..."); + assertCacheOutsideSaveContainers(run.saveContainers, layoutRootAbs, run.cacheSnapshotPath); + deleteDirectory(run.cacheSnapshotPath); + Files.createDirectories(run.cacheSnapshotPath); + int[] outCopied = new int[1]; + copySaveContainersToSnapshot( + layoutRootAbs, + run.saveContainers, + run.cacheSnapshotPath, + blacklist, + pruneThreads, + outCopied); + run.context.logInfo( + "Chronos backups: finished copying " + outCopied[0] + " files into cache."); + } else { + run.context.logInfo( + "Chronos backups: copying world to backup folder..."); + assertCacheOutsideSaveContainers(run.saveContainers, layoutRootAbs, run.folderOutputPath); + deleteDirectory(run.folderOutputPath); + Files.createDirectories(run.folderOutputPath); + int[] outCopied = new int[1]; + copySaveContainersToSnapshot( + layoutRootAbs, + run.saveContainers, + run.folderOutputPath, + blacklist, + pruneThreads, + outCopied); + run.context.logInfo( + "Chronos backups: finished copying " + outCopied[0] + + " files into backup folder."); + } - run.copyComplete = true; + run.copyComplete = true; - if (shouldAbortBackupWork()) { - throw new InterruptedIOException("Backup aborted"); - } + if (shouldAbortBackupWork()) + throw new InterruptedIOException("Backup aborted"); - Path snapshotRoot = run.compressionMethod == CompressionMethod.ZIP - ? run.cacheSnapshotPath - : run.folderOutputPath; - if (run.compressionMethod == CompressionMethod.ZIP) { - if (cfg.pruneChunks) { - run.context.logInfo( - "Chronos backups: pruning snapshot and writing zip..."); - Files.deleteIfExists(run.zipOutputPath); - boolean singleSaveContainer = run.saveContainers.size() == 1 - && SaveRootDiscovery.snapshotPathForSourceRoot( - run.worldPath, run.saveContainers.get(0), snapshotRoot) - .equals(snapshotRoot); - if (singleSaveContainer) { - RustPrunerBridge.pruneWorldToZip( - snapshotRoot, - run.zipOutputPath, - cfg.pruneTimeRequirementSeconds, - pruneThreads); + Path snapshotRoot = run.compressionMethod == CompressionMethod.ZIP + ? run.cacheSnapshotPath + : run.folderOutputPath; + if (run.compressionMethod == CompressionMethod.ZIP) { + if (cfg.pruneChunks) { + run.context.logInfo( + "Chronos backups: pruning snapshot and writing zip..."); + Files.deleteIfExists(run.zipOutputPath); + boolean singleSaveContainer = run.saveContainers.size() == 1 + && SaveRootDiscovery.snapshotPathForSourceRoot( + run.worldPath, run.saveContainers.get(0), snapshotRoot) + .equals(snapshotRoot); + if (singleSaveContainer) { + RustPrunerBridge.pruneWorldToZip( + snapshotRoot, + run.zipOutputPath, + cfg.pruneTimeRequirementSeconds, + pruneThreads); + } else { + pruneSnapshotSaveRoots(run, snapshotRoot); + zipSnapshot(snapshotRoot, run.zipOutputPath); + } } else { - pruneSnapshotSaveRoots(run, snapshotRoot); - zipSnapshot(snapshotRoot, run.zipOutputPath); + run.context.logInfo("Chronos backups: snapshot pruning disabled by config."); + run.context.logInfo("Chronos backups: writing zip archive..."); + zipSnapshot(run.cacheSnapshotPath, run.zipOutputPath); } + } else if (cfg.pruneChunks) { + run.context.logInfo("Chronos backups: pruning snapshot in backup folder..."); + pruneSnapshotSaveRoots(run, snapshotRoot); } else { run.context.logInfo("Chronos backups: snapshot pruning disabled by config."); - run.context.logInfo("Chronos backups: writing zip archive..."); - zipSnapshot(run.cacheSnapshotPath, run.zipOutputPath); } - } else if (cfg.pruneChunks) { - run.context.logInfo("Chronos backups: pruning snapshot in backup folder..."); - pruneSnapshotSaveRoots(run, snapshotRoot); - } else { - run.context.logInfo("Chronos backups: snapshot pruning disabled by config."); - } - run.backupFinishedSuccessfully = true; - // Speedtest writes under .speedtest and must not trim real retention. - if (!run.speedtestArtifact) - trimOldBackupsAfterNewSuccess(run.worldBackupDir, cfg.maxStoredBackups, run.context); + run.backupFinishedSuccessfully = true; + // Speedtest writes under .speedtest and must not trim real retention. + if (!run.speedtestArtifact) { + trimOldBackupsAfterNewSuccess( + run.worldBackupDir, cfg.maxStoredBackups, run.context); + } + } } catch (InterruptedIOException e) { if (shutdownRequested) { run.context.logInfo("Chronos backup aborted (shutdown)."); @@ -756,17 +871,22 @@ private static void finalizeInFlightBackup( } if (backupFinishedSuccessfully) { - final String duration = formatBackupDurationNanos(run.backupStartNanos); + final String duration = formatBackupDuration(run); if (run.compressionMethod == CompressionMethod.ZIP) { run.context.logInfo("Chronos backup completed in " + duration + ": " + run.zipOutputPath); + } else if (run.compressionMethod == CompressionMethod.GIT) { + run.context.logInfo( + "Chronos git backup completed in " + duration + ": " + run.folderOutputPath + + " tag=" + GitBackupRepository.tagNameForBackupId(run.backupId)); } else { run.context.logInfo("Chronos backup completed in " + duration + ": " + run.folderOutputPath); } run.context.sendChat("Backup completed in " + duration + "."); // Skip sync if a speedtest is in progress - if (!isSpeedtestSessionActive()) { + if (!isSpeedtestSessionActive()) CloudSync.requestSync(); - } + // Git worktrees sync via Google Drive like zip/none. Native git push is + // disabled for now. } } finally { if (!backupFinishedSuccessfully) { @@ -781,7 +901,9 @@ private static void finalizeInFlightBackup( + e.getMessage()); } } - if (run.folderOutputPath != null) { + // Never delete a git worktree on failure + if (run.folderOutputPath != null + && run.compressionMethod != CompressionMethod.GIT) { try { deleteDirectory(run.folderOutputPath); } catch (IOException e) { @@ -875,11 +997,18 @@ private static final class InFlightBackup { Path zipOutputPath; Path cacheSnapshotPath; Path folderOutputPath; + /** Backup id / git tag base for this run. */ + String backupId; Path worldPath; List saveContainers = Collections.emptyList(); BackupWorldController worldController; Object serverHandle; long backupStartNanos; + /** + * When non-zero, reported backup duration ends here instead of at finalize + * time. To prevent printing a wrong duration (that includes sync/push time) + */ + long durationEndNanos; boolean attemptedSavingPause; volatile boolean copyComplete; volatile boolean savesRestoredOnMainThread; @@ -989,12 +1118,19 @@ private static void trimOldBackupsAfterNewSuccess( } } - /** - * Elapsed time since {@code startNanos} from {@link System#nanoTime()} for - * log/chat messages. - */ private static String formatBackupDurationNanos(long startNanos) { - long elapsedNanos = System.nanoTime() - startNanos; + return formatElapsedNanos(System.nanoTime() - startNanos); + } + + private static String formatBackupDuration(InFlightBackup run) { + long end = run.durationEndNanos != 0L ? run.durationEndNanos : System.nanoTime(); + return formatElapsedNanos(end - run.backupStartNanos); + } + + private static String formatElapsedNanos(long elapsedNanos) { + if (elapsedNanos < 0L) + elapsedNanos = 0L; + double seconds = elapsedNanos / 1_000_000_000.0; if (seconds < 60) { return String.format(Locale.ROOT, "%.2f s", seconds); diff --git a/core/src/main/java/com/magicjinn/chronos/core/ChronosBackupArtifacts.java b/core/src/main/java/com/magicjinn/chronos/core/ChronosBackupArtifacts.java index 18d3e7b..da6cda2 100644 --- a/core/src/main/java/com/magicjinn/chronos/core/ChronosBackupArtifacts.java +++ b/core/src/main/java/com/magicjinn/chronos/core/ChronosBackupArtifacts.java @@ -33,8 +33,22 @@ public final class ChronosBackupArtifacts { /** Default backups folder name */ public static final String DEFAULT_BACKUP_FOLDER_NAME = "backups"; + /** + * Git export output under backups/. + * Not a world folder, cloud sync should skip it. + */ + public static final String EXPORTED_FOLDER_NAME = "exported"; + private ChronosBackupArtifacts() {} + /** Meta dirs under the backups root that are not world backup folders. */ + public static boolean isBackupMetaFolderName(String name) { + if (name == null || name.isEmpty()) + return true; + + return name.startsWith(".") || EXPORTED_FOLDER_NAME.equals(name); + } + /** * Single folder name under the Minecraft run directory. Rejects blank, * {@code .}/{@code ..}, path separators, drive letters, and control/reserved diff --git a/core/src/main/java/com/magicjinn/chronos/core/RustPrunerBridge.java b/core/src/main/java/com/magicjinn/chronos/core/RustPrunerBridge.java index 36e37ce..cb4c3e2 100644 --- a/core/src/main/java/com/magicjinn/chronos/core/RustPrunerBridge.java +++ b/core/src/main/java/com/magicjinn/chronos/core/RustPrunerBridge.java @@ -8,7 +8,8 @@ import java.nio.file.StandardCopyOption; import java.util.List; -final class RustPrunerBridge { +/** JNI bridge to the rust native library. */ +public final class RustPrunerBridge { private static final Object LOAD_LOCK = new Object(); private static volatile boolean loaded; @@ -24,7 +25,7 @@ private RustPrunerBridge() {} /** * Prunes region/entities/poi MCA files in {@code worldPath} in place */ - static void pruneWorld( + public static void pruneWorld( Path worldPath, int spentTimeRequirementSeconds, int maxWorkerThreads) @@ -119,6 +120,79 @@ static void copyWorldToCache( } } + /** + * Hashes live source files under {@code worldRoot} with xxHash3 and writes a + * scan-result JSON to {@code resultJsonPath}. + * + * @param previousFilesJson Gson JSON object of path -> {@code {hash,size}} from + * the previous manifest (may be {@code "{}"}) + * @param forceAll when true, every file is treated as changed (prune + * policy invalidation) + */ + public static void scanWorldSources( + Path worldRoot, + List copyBlacklistPatterns, + String previousFilesJson, + String prunePolicyHash, + boolean forceAll, + Path resultJsonPath, + int maxWorkerThreads) + throws IOException { + ensureLoaded(); + + if (resultJsonPath == null) + throw new IllegalArgumentException("resultJsonPath is null"); + + String[] arr = copyBlacklistPatterns == null || copyBlacklistPatterns.isEmpty() + ? new String[0] + : copyBlacklistPatterns.toArray(new String[0]); + int code = scanWorldSourcesNative( + worldRoot.toAbsolutePath().normalize().toString(), + arr, + previousFilesJson == null ? "{}" : previousFilesJson, + prunePolicyHash == null ? "" : prunePolicyHash, + forceAll ? 1 : 0, + resultJsonPath.toAbsolutePath().normalize().toString(), + maxWorkerThreads); + + if (code != 0) + throw new IOException("Native rust-pruner source scan failed with status code " + code); + } + + /** + * Copies only the listed relative paths from {@code worldRoot} into + * {@code destRoot}. + */ + public static void copySelectedFiles( + Path worldRoot, + Path destRoot, + List relativePaths, + int maxCopyWorkerThreads, + int[] outCopiedFileCount) + throws IOException { + ensureLoaded(); + + if (outCopiedFileCount == null || outCopiedFileCount.length < 1) + throw new IllegalArgumentException("outCopiedFileCount must hold at least one int"); + + String[] arr = relativePaths == null || relativePaths.isEmpty() + ? new String[0] + : relativePaths.toArray(new String[0]); + int code = copySelectedFilesNative( + worldRoot.toAbsolutePath().normalize().toString(), + destRoot.toAbsolutePath().normalize().toString(), + arr, + maxCopyWorkerThreads, + outCopiedFileCount); + + if (code == 5) + throw new IOException( + "Native selected copy refused: dest path appears to be inside the world directory."); + + if (code != 0) + throw new IOException("Native rust-pruner selected copy failed with status code " + code); + } + /** * Polled from native code between copy batches, must stay cheap. */ @@ -217,4 +291,85 @@ private static native int copyWorldToCacheNative( String[] copyBlacklistPatterns, int maxCopyWorkerThreads, int[] outCopiedFileCount); + + private static native int scanWorldSourcesNative( + String worldRootPath, + String[] copyBlacklistPatterns, + String previousFilesJson, + String prunePolicyHash, + int forceAll, + String resultJsonPath, + int maxWorkerThreads); + + private static native int copySelectedFilesNative( + String worldRootPath, + String destRootPath, + String[] relativePaths, + int maxCopyWorkerThreads, + int[] outCopiedFileCount); + + private static native int gitInitNative(String worktreePath); + + private static native String gitCommitAndTagNative( + String worktreePath, String backupId, String message); + + private static native String gitListBackupTagsNative(String worktreePath); + + private static native int gitExportTagNative( + String worktreePath, String tagName, String destPath); + + /** + * Bundled gitoxide: init a Git worktree (no-op if {@code .git} already exists). + */ + public static void gitInit(Path worktree) throws IOException { + ensureLoaded(); + int code = gitInitNative(worktree.toAbsolutePath().normalize().toString()); + if (code != 0) { + throw new IOException("gitoxide init failed with status " + code); + } + } + + /** Bundled gitoxide: commit the full worktree and create a backup tag. */ + public static String gitCommitAndTag(Path worktree, String backupId, String message) + throws IOException { + ensureLoaded(); + String sha = gitCommitAndTagNative( + worktree.toAbsolutePath().normalize().toString(), + backupId == null ? "" : backupId, + message == null ? "" : message); + if (sha == null || sha.isEmpty()) { + throw new IOException("gitoxide commit returned no SHA"); + } + return sha; + } + + /** Bundled gitoxide: list backup tags, newest first. */ + public static java.util.List gitListBackupTags(Path worktree) throws IOException { + ensureLoaded(); + String raw = gitListBackupTagsNative(worktree.toAbsolutePath().normalize().toString()); + if (raw == null || raw.trim().isEmpty()) { + return java.util.Collections.emptyList(); + } + java.util.List out = new java.util.ArrayList(); + for (String line : raw.split("\n")) { + String t = line.trim(); + if (!t.isEmpty()) { + out.add(t); + } + } + return out; + } + + /** Bundled gitoxide: export a tagged snapshot into {@code destDir}. */ + public static void gitExportTag(Path worktree, String tagName, Path destDir) + throws IOException { + ensureLoaded(); + int code = gitExportTagNative( + worktree.toAbsolutePath().normalize().toString(), + tagName, + destDir.toAbsolutePath().normalize().toString()); + if (code != 0) { + throw new IOException("gitoxide export failed with status " + code); + } + } } diff --git a/core/src/main/java/com/magicjinn/chronos/core/config/ChronosTomlSpec.java b/core/src/main/java/com/magicjinn/chronos/core/config/ChronosTomlSpec.java index 95ed781..7e6cd1f 100644 --- a/core/src/main/java/com/magicjinn/chronos/core/config/ChronosTomlSpec.java +++ b/core/src/main/java/com/magicjinn/chronos/core/config/ChronosTomlSpec.java @@ -24,6 +24,7 @@ public final class ChronosTomlSpec { public static final String KEY_BACKUP_INTERVAL_SECONDS = "backupIntervalSeconds"; public static final String KEY_MAX_STORED_BACKUPS = "maxStoredBackups"; public static final String KEY_COMPRESSION_METHOD = "compressionMethod"; + public static final String KEY_ALLOW_NATIVE_GIT = "allowNativeGit"; public static final String KEY_COPY_BLACKLIST = "copyBlacklist"; public static final String KEY_GOOGLE_DRIVE_ENABLED = "googleDriveEnabled"; public static final String KEY_COMMAND_REQUIRED_PERMISSION_LEVEL = "commandRequiredPermissionLevel"; @@ -78,11 +79,22 @@ public final class ChronosTomlSpec { "# Maximum backups kept per world.", "# After a successful backup, oldest local snapshots are removed if this would be exceeded.", "# Cloud sync also trims remote Chronos backups to this cap.", + "# Not enforced when compressionMethod = \"git\".", "# Recommended value: 5. Values lower than 3 save space but risk losing older restore points.", "# Values below 1 disable automatic removal."), compressionMethod( - "# Snapshot storage: \"zip\" or \"none\".", - "# \"zip\" uses zip for compression. \"none\" stores an uncompressed folder."), + "# Snapshot storage: \"zip\", \"none\", or \"git\".", + "# \"zip\": writes a compressed .zip per backup.", + "# \"none\": stores an uncompressed folder per backup.", + "# \"git\": instead of multiple loose backups, keeps an incremental Git repo for Git based backups,", + "# which can be managed by git (cli) and Git-clients like Github Desktop.", + "# Unchanged files are skipped for speed and space-efficiency.", + "# maxStoredBackups is not applied in git mode."), + bool(KEY_ALLOW_NATIVE_GIT, + (c, v) -> c.allowNativeGit = v, + c -> c.allowNativeGit, + "# When true, git-mode prefers native git on PATH if available.", + "# When false, git-mode always uses bundled gitoxide."), bool(KEY_GOOGLE_DRIVE_ENABLED, (c, v) -> c.googleDriveEnabled = v, c -> c.googleDriveEnabled, diff --git a/core/src/main/java/com/magicjinn/chronos/core/config/CompressionMethod.java b/core/src/main/java/com/magicjinn/chronos/core/config/CompressionMethod.java index 62abb06..3e123b8 100644 --- a/core/src/main/java/com/magicjinn/chronos/core/config/CompressionMethod.java +++ b/core/src/main/java/com/magicjinn/chronos/core/config/CompressionMethod.java @@ -4,25 +4,35 @@ * How each backup snapshot is stored on disk. */ public enum CompressionMethod { - /** Uncompressed backup stored in a folder */ + /** Uncompressed backup stored in a folder. */ NONE, - /** Compressed backup stored in a zip file */ - ZIP; + /** Compressed backup stored in a zip file. */ + ZIP, + /** + * Incremental Git repository under the world backup folder. + * Unchanged files are content-addressed across snapshots (space) and skipped + * before copy/prune when the pre-prune source hash matches (speed). + */ + GIT; public static CompressionMethod fromTomlValue(Object raw, CompressionMethod fallback) { - if (raw == null) { + if (raw == null) return fallback; - } + String s = raw.toString().trim(); - if (s.isEmpty()) { + + if (s.isEmpty()) return fallback; - } - if ("none".equalsIgnoreCase(s)) { + + if ("none".equalsIgnoreCase(s)) return NONE; - } - if ("zip".equalsIgnoreCase(s)) { + + if ("zip".equalsIgnoreCase(s)) return ZIP; - } + + if ("git".equalsIgnoreCase(s)) + return GIT; + return fallback; } } diff --git a/core/src/main/java/com/magicjinn/chronos/core/config/Config.java b/core/src/main/java/com/magicjinn/chronos/core/config/Config.java index ef4ae2c..cbbf1d7 100644 --- a/core/src/main/java/com/magicjinn/chronos/core/config/Config.java +++ b/core/src/main/java/com/magicjinn/chronos/core/config/Config.java @@ -5,6 +5,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; @@ -342,9 +344,7 @@ public static ModConfig snapshot() { return live().copy(); } - /** - * Test helper. Next {@link #pollForFileChanges()} ignores the poll interval. - */ + /** Test helper. Next {@link #pollForFileChanges()} ignores the poll interval. */ static void resetPollThrottleForTests() { lastPollEpochMs = 0L; } @@ -364,6 +364,51 @@ static void restoreForTests(ModConfig previous) { MOD_CONFIG.set(previous); } + /** Whether backup snapshots should run chunk pruning. */ + public static boolean getPruneChunksEnabled() { + return live().pruneChunks; + } + + /** Minimum in-world playtime (seconds) before a chunk may no longer be pruned. */ + public static int getPruneTimeRequirementSeconds() { + return live().pruneTimeRequirementSeconds; + } + + /** Maximum pruning worker threads. */ + public static int getPruneMaxWorkerThreads() { + return Math.max(0, live().pruneMaxWorkerThreads); + } + + /** World-relative paths skipped when copying a save for backup. */ + public static List getCopyBlacklist() { + List src = live().copyBlacklist != null + ? live().copyBlacklist + : BUILTIN_DEFAULTS.copyBlacklist; + return Collections.unmodifiableList(new ArrayList(src)); + } + + /** Compression / storage method used for backups ({@code zip}, {@code none}, or {@code git}). */ + public static CompressionMethod getCompressionMethod() { + return live().compressionMethod; + } + + /** + * When true, git-mode may use native {@code git} on PATH. When false, git-mode + * always uses bundled gitoxide. + */ + public static boolean getAllowNativeGit() { + return live().allowNativeGit; + } + + /** + * Prefix for Chronos server chat on 1.13+ ({@code /tellraw}). Unused for + * legacy {@code /say} broadcasts on 1.12 and below. + */ + public static String getChatPrefix() { + String raw = live().chatPrefix; + return raw != null ? raw : BUILTIN_DEFAULTS.chatPrefix; + } + private Config() { } } diff --git a/core/src/main/java/com/magicjinn/chronos/core/config/ModConfig.java b/core/src/main/java/com/magicjinn/chronos/core/config/ModConfig.java index 8a5e493..ef5674b 100644 --- a/core/src/main/java/com/magicjinn/chronos/core/config/ModConfig.java +++ b/core/src/main/java/com/magicjinn/chronos/core/config/ModConfig.java @@ -33,7 +33,7 @@ public final class ModConfig { /** * Maximum number of backup artifacts kept per world (local and remote). - * Values less than 1 disable automatic deletion of older backups. + * Not enforced in git mode. Values less than 1 disable automatic deletion of older backups. */ public int maxStoredBackups = 5; @@ -42,9 +42,16 @@ public final class ModConfig { * subdirectory. * {@code none}: copy the world tree directly into a folder named like the zip * basename (no cache, no archive). + * {@code git}: incremental Git repository under the world backup folder */ public CompressionMethod compressionMethod = CompressionMethod.ZIP; + /** + * When true (default), git-mode prefers native {@code git} on PATH when available. + * When false, git-mode always uses bundled gitoxide. + */ + public boolean allowNativeGit = true; + /** When true, local backups will be kept even if a cloud integration is enabled, and upload succeeds. */ public boolean shouldKeepLocalBackups = true; @@ -99,6 +106,7 @@ public ModConfig copy() { out.backupIntervalSeconds = backupIntervalSeconds; out.maxStoredBackups = maxStoredBackups; out.compressionMethod = compressionMethod; + out.allowNativeGit = allowNativeGit; out.shouldKeepLocalBackups = shouldKeepLocalBackups; out.googleDriveEnabled = googleDriveEnabled; out.copyBlacklist = copyBlacklist == null ? new ArrayList() : new ArrayList(copyBlacklist); diff --git a/core/src/main/java/com/magicjinn/chronos/core/git/GitBackupEngine.java b/core/src/main/java/com/magicjinn/chronos/core/git/GitBackupEngine.java new file mode 100644 index 0000000..a7b6873 --- /dev/null +++ b/core/src/main/java/com/magicjinn/chronos/core/git/GitBackupEngine.java @@ -0,0 +1,224 @@ +package com.magicjinn.chronos.core.git; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.magicjinn.chronos.core.BackupRuntimeContext; +import com.magicjinn.chronos.core.RustPrunerBridge; +import com.magicjinn.chronos.core.SaveRootDiscovery; +import com.magicjinn.chronos.core.config.Config; + +/** + * Incremental Git backup. Hash live sources, copy/prune only changed files into + * the per-world worktree, commit + tag. (Hash is not the same as a git diff. We + * need to do this due to the nature of Chronos) + */ +public final class GitBackupEngine { + private static final Gson META_GSON = new GsonBuilder().setPrettyPrinting().create(); + + /** + * Local incremental backup only (hash, copy, prune, commit). + * {@code maxStoredBackups} is not applied in git mode. Remote push is + * intentionally separate so backup duration excludes network time. + */ + public static void runIncrementalBackup( + BackupRuntimeContext context, + Path layoutRoot, + List saveContainers, + Path worktree, + String backupId) + throws IOException { + GitBackupRepository repo = new GitBackupRepository(worktree); + repo.ensureInitialized(); + + String policyHash = SourceManifest.computePrunePolicyHash( + Config.getPruneChunksEnabled(), + Config.getPruneTimeRequirementSeconds(), + Config.getCopyBlacklist()); + SourceManifest previous = SourceManifest.load(SourceManifest.manifestPath(worktree)); + boolean forceAll = !previous.policyMatches(policyHash); + + List sources = saveContainersToCopy(layoutRoot, saveContainers); + Map mergedFiles = + new LinkedHashMap(); + List allChangedWorktreeRel = new ArrayList(); + List allDeletedWorktreeRel = new ArrayList(); + int copied = 0; + + Path scanTmp = Files.createTempFile("chronos-git-scan-", ".json"); + try { + for (Path sourceRoot : sources) { + Path dest = SaveRootDiscovery.snapshotPathForSourceRoot( + layoutRoot, sourceRoot, worktree); + String prefix = worktreePrefix(worktree, dest); + + Map previousForSource = + filterByPrefix(previous.files, prefix); + SourceManifest scoped = SourceManifest.empty(previous.prunePolicyHash); + scoped.files.putAll(previousForSource); + + RustPrunerBridge.scanWorldSources( + sourceRoot, + Config.getCopyBlacklist(), + SourceScanResult.filesJson(scoped), + policyHash, + forceAll, + scanTmp, + Config.getPruneMaxWorkerThreads()); + SourceScanResult scan = SourceScanResult.load(scanTmp); + + List changedRel = scan.changed; + if (!changedRel.isEmpty()) { + int[] outCopied = new int[1]; + RustPrunerBridge.copySelectedFiles( + sourceRoot, + dest, + changedRel, + Config.getPruneMaxWorkerThreads(), + outCopied); + copied += outCopied[0]; + } + + for (String rel : changedRel) { + allChangedWorktreeRel.add(joinPrefix(prefix, rel)); + } + for (String rel : scan.deleted) { + String workRel = joinPrefix(prefix, rel); + allDeletedWorktreeRel.add(workRel); + Path victim = worktree.resolve(workRel.replace('/', java.io.File.separatorChar)); + Files.deleteIfExists(victim); + } + for (Map.Entry e : scan.files.entrySet()) { + mergedFiles.put(joinPrefix(prefix, e.getKey()), e.getValue()); + } + + boolean needsPrune = Config.getPruneChunksEnabled() + && (forceAll || anyRegionChanged(changedRel)); + if (needsPrune) { + context.logInfo( + "Chronos backups: pruning changed regions in " + dest.getFileName() + "..."); + RustPrunerBridge.pruneWorld( + dest, + Config.getPruneTimeRequirementSeconds(), + Config.getPruneMaxWorkerThreads()); + } + } + } finally { + Files.deleteIfExists(scanTmp); + } + + SourceManifest next = SourceManifest.empty(policyHash); + next.files.putAll(mergedFiles); + next.save(SourceManifest.manifestPath(worktree)); + writeBackupMeta(worktree, backupId, policyHash, repo.usingNativeGit()); + + context.logInfo( + "Chronos backups: git incremental copy updated " + + copied + + " file(s), removed " + + allDeletedWorktreeRel.size() + + ", unchanged skipped."); + + String sha = repo.commitSnapshot(backupId, "Chronos backup " + backupId); + context.logInfo("Chronos backups: git commit " + sha + " (" + + (repo.usingNativeGit() ? "native git" : "gitoxide") + ")"); + } + + // This method is currently not used anywhere. Backing up to Google Drive is + // good enough for now. + /** Pushes the per-world git repo to {@code remoteUrl}. Not included in backup duration. */ + public static void pushRemote(Path worktree, String remoteUrl) throws IOException { + if (remoteUrl == null || remoteUrl.trim().isEmpty()) + return; + + new GitBackupRepository(worktree).push(remoteUrl.trim()); + } + + public static List listSnapshots(Path worktree) throws IOException { + return new GitBackupRepository(worktree).listBackupTagsNewestFirst(); + } + + public static Path exportSnapshot( + Path worktree, String tagOrId, Path destDir) throws IOException { + GitBackupRepository repo = new GitBackupRepository(worktree); + repo.exportTagTo(tagOrId, destDir); + return destDir; + } + + private static void writeBackupMeta( + Path worktree, String backupId, String policyHash, boolean nativeGit) + throws IOException { + Map meta = new LinkedHashMap(); + meta.put("backupId", backupId); + meta.put("prunePolicyHash", policyHash); + meta.put("gitBackend", nativeGit ? "native" : "gitoxide"); + Path path = SourceManifest.metaPath(worktree); + Files.createDirectories(path.getParent()); + Files.write(path, META_GSON.toJson(meta).getBytes(StandardCharsets.UTF_8)); + } + + private static boolean anyRegionChanged(List changedRel) { + for (String rel : changedRel) { + String n = rel.replace('\\', '/'); + if (n.contains("/region/") || n.startsWith("region/")) + if (n.endsWith(".mca")) + return true; + } + return false; + } + + private static String worktreePrefix(Path worktree, Path dest) { + Path w = worktree.toAbsolutePath().normalize(); + Path d = dest.toAbsolutePath().normalize(); + if (w.equals(d)) + return ""; + + return w.relativize(d).toString().replace('\\', '/'); + } + + private static String joinPrefix(String prefix, String rel) { + String r = rel.replace('\\', '/'); + if (prefix == null || prefix.isEmpty()) + return r; + + return prefix + "/" + r; + } + + private static Map filterByPrefix( + Map all, String prefix) { + Map out = + new LinkedHashMap(); + if (all == null) + return out; + + if (prefix == null || prefix.isEmpty()) { + out.putAll(all); + return out; + } + + String p = prefix.endsWith("/") ? prefix : prefix + "/"; + for (Map.Entry e : all.entrySet()) { + String key = e.getKey().replace('\\', '/'); + if (key.startsWith(p)) + out.put(key.substring(p.length()), e.getValue()); + } + return out; + } + + private static List saveContainersToCopy(Path layoutRoot, List saveContainers) { + if (saveContainers == null || saveContainers.isEmpty()) { + List one = new ArrayList(1); + one.add(layoutRoot); + return one; + } + return saveContainers; + } +} diff --git a/core/src/main/java/com/magicjinn/chronos/core/git/GitBackupRepository.java b/core/src/main/java/com/magicjinn/chronos/core/git/GitBackupRepository.java new file mode 100644 index 0000000..66660f1 --- /dev/null +++ b/core/src/main/java/com/magicjinn/chronos/core/git/GitBackupRepository.java @@ -0,0 +1,81 @@ +package com.magicjinn.chronos.core.git; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; + +import com.magicjinn.chronos.core.RustPrunerBridge; +import com.magicjinn.chronos.core.config.Config; + +/** + * Per-world Git worktree under {@code backups/{world}/}. Prefers native + * {@code git} when {@link Config#getAllowNativeGit()} and available, otherwise + * bundled gitoxide via {@link RustPrunerBridge}. + * Backups are commits on {@code main} tagged with the backup id. + */ +public final class GitBackupRepository { + // Always commit to main + public static final String BRANCH = "main"; + + private final Path worktree; + private final boolean useNative; + + public GitBackupRepository(Path worktree) { + if (worktree == null) + throw new IllegalArgumentException("worktree is null"); + + this.worktree = worktree.toAbsolutePath().normalize(); + this.useNative = Config.getAllowNativeGit() && NativeGit.isAvailable(); + } + + public Path worktree() { + return worktree; + } + + public boolean usingNativeGit() { + return useNative; + } + + public void ensureInitialized() throws IOException { + if (useNative) { + NativeGit.ensureInitialized(worktree); + } else { + RustPrunerBridge.gitInit(worktree); + } + } + + public String commitSnapshot(String backupId, String message) throws IOException { + if (useNative) + return NativeGit.commitAndTag(worktree, backupId, message); + + return RustPrunerBridge.gitCommitAndTag(worktree, backupId, message); + } + + public List listBackupTagsNewestFirst() throws IOException { + if (useNative) + return NativeGit.listBackupTagsNewestFirst(worktree); + + return RustPrunerBridge.gitListBackupTags(worktree); + } + + public void exportTagTo(String tagName, Path destDir) throws IOException { + if (useNative) { + NativeGit.exportTagTo(worktree, tagName, destDir); + } else { + RustPrunerBridge.gitExportTag(worktree, tagName, destDir); + } + } + + /** Remote push always needs native git (credentials / LFS). */ + public void push(String remoteUrl) throws IOException { + NativeGit.push(worktree, remoteUrl); + } + + public static String tagNameForBackupId(String backupId) { + String id = backupId == null || backupId.trim().isEmpty() + ? Instant.now().toString() + : backupId.trim(); + return id.replace(' ', '_'); + } +} diff --git a/core/src/main/java/com/magicjinn/chronos/core/git/NativeGit.java b/core/src/main/java/com/magicjinn/chronos/core/git/NativeGit.java new file mode 100644 index 0000000..e35594b --- /dev/null +++ b/core/src/main/java/com/magicjinn/chronos/core/git/NativeGit.java @@ -0,0 +1,295 @@ +package com.magicjinn.chronos.core.git; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import com.magicjinn.chronos.core.ChronosLogger; + +/** + * Native {@code git} on PATH. Preferred over bundled gitoxide when available. + */ +public final class NativeGit { + private static final Object PROBE_LOCK = new Object(); + private static volatile Boolean available; + private static volatile String versionLine = ""; + + public static boolean isAvailable() { + Boolean cached = available; + if (cached != null) + return cached.booleanValue(); + + synchronized (PROBE_LOCK) { + if (available != null) + return available.booleanValue(); + + ProbeResult probe = probe(); + available = Boolean.valueOf(probe.ok); + versionLine = probe.version; + if (probe.ok) { + ChronosLogger.info("Chronos git: using native git (" + probe.version + ")"); + } else { + ChronosLogger.info( + "Chronos git: native git not on PATH; using bundled gitoxide." + + " Install git for faster ops and remote push (git-lfs recommended for large worlds)."); + } + return available.booleanValue(); + } + } + + public static String versionLine() { + isAvailable(); + return versionLine; + } + + public static void ensureInitialized(Path worktree) throws IOException { + Files.createDirectories(worktree); + Path gitDir = worktree.resolve(".git"); + if (!Files.exists(gitDir)) + runRequired(worktree, "init", "-b", "main"); + + run(worktree, "config", "core.autocrlf", "false"); + run(worktree, "config", "pack.window", "0"); + run(worktree, "config", "core.bigFileThreshold", "1073741824"); + run(worktree, "config", "user.name", "Chronos Backups"); + run(worktree, "config", "user.email", "chronos@localhost"); + } + + public static String commitAndTag(Path worktree, String backupId, String message) + throws IOException { + ensureInitialized(worktree); + // Export used to leave HEAD detached; keep commits on main so push advances the branch. + ensureOnMain(worktree); + runRequired(worktree, "add", "-A"); + String msg = message == null || message.isEmpty() + ? "Chronos backup " + backupId + : message; + // Allow empty commit so scheduled backups still create a tagged restore point. + runRequired(worktree, "commit", "--allow-empty", "-m", msg); + String tag = GitBackupRepository.tagNameForBackupId(backupId); + run(worktree, "tag", "-d", tag); + runRequired(worktree, "tag", tag); + String sha = runCapture(worktree, "rev-parse", "HEAD"); + return sha == null ? "" : sha.trim(); + } + + public static List listBackupTagsNewestFirst(Path worktree) throws IOException { + ensureInitialized(worktree); + String out = runCapture(worktree, "tag", "-l"); + if (out == null || out.trim().isEmpty()) + return Collections.emptyList(); + + List tags = new ArrayList(); + for (String line : out.split("\n")) { + String t = line.trim(); + if (!t.isEmpty()) + tags.add(t); + } + Collections.sort(tags, Comparator.reverseOrder()); + return tags; + } + + public static void exportTagTo(Path worktree, String tagName, Path destDir) throws IOException { + String tag = normalizeTag(tagName); + ensureInitialized(worktree); + if (Files.exists(destDir)) { + throw new IOException("Export destination already exists: " + destDir); + } + Files.createDirectories(destDir); + Path gitDir = worktree.resolve(".git"); + // restore --source does not move HEAD (unlike checkout, which detached the backup repo). + runRequired( + worktree, + "--git-dir=" + gitDir.toAbsolutePath().normalize(), + "--work-tree=" + destDir.toAbsolutePath().normalize(), + "restore", + "--source=" + tag, + "--worktree", + "--", + "."); + } + + public static void push(Path worktree, String remoteUrl) throws IOException { + if (worktree == null || remoteUrl == null || remoteUrl.trim().isEmpty()) { + return; + } + if (!isAvailable()) { + throw new IOException( + "Native git is required for remote push but was not found on PATH." + + " Install git (and git-lfs for large worlds)."); + } + String url = remoteUrl.trim(); + ensureInitialized(worktree); + ensureOnMain(worktree); + ensureRemote(worktree, url); + // Chronos owns this remote. --force (not --force-with-lease) so recreated + // remotes still push. + runRequired( + worktree, + "push", + "--force", + "-u", + "chronos-origin", + GitBackupRepository.BRANCH); + runRequired(worktree, "push", "chronos-origin", "--tags"); + } + + private static void ensureRemote(Path worktree, String url) throws IOException { + String existing = runCapture(worktree, "remote"); + boolean hasOrigin = false; + if (existing != null) { + for (String line : existing.split("\n")) { + if ("chronos-origin".equals(line.trim())) { + hasOrigin = true; + break; + } + } + } + if (hasOrigin) { + runRequired(worktree, "remote", "set-url", "chronos-origin", url); + } else { + runRequired(worktree, "remote", "add", "chronos-origin", url); + } + } + + // This means something, I'm sure. + /** Attach HEAD to main at the current tip without rewriting worktree files. */ + private static void ensureOnMain(Path worktree) throws IOException { + String branch = runCapture(worktree, "rev-parse", "--abbrev-ref", "HEAD"); + if (branch != null && GitBackupRepository.BRANCH.equals(branch.trim())) + return; + + String sha = runCapture(worktree, "rev-parse", "HEAD"); + if (sha == null || sha.trim().isEmpty()) + throw new IOException("Cannot resolve HEAD while repairing detached git backup repo"); + + runRequired(worktree, "branch", "-f", GitBackupRepository.BRANCH, sha.trim()); + runRequired(worktree, "symbolic-ref", "HEAD", "refs/heads/" + GitBackupRepository.BRANCH); + } + + private static String normalizeTag(String tagName) throws IOException { + if (tagName == null || tagName.trim().isEmpty()) + throw new IOException("Backup tag is empty"); + + return tagName.trim(); + } + + private static ProbeResult probe() { + try { + ProcessBuilder pb = new ProcessBuilder("git", "--version"); + pb.redirectErrorStream(true); + Process p = pb.start(); + String line; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { + line = reader.readLine(); + } + boolean finished = p.waitFor(10, TimeUnit.SECONDS); + if (!finished) { + p.destroyForcibly(); + return ProbeResult.fail(); + } + + if (p.exitValue() != 0 || line == null || line.trim().isEmpty()) + return ProbeResult.fail(); + + return ProbeResult.ok(line.trim()); + } catch (Exception e) { + return ProbeResult.fail(); + } + } + + private static void run(Path worktree, String... args) { + try { + runRequired(worktree, args); + } catch (IOException ignored) { + // Whatever + } + } + + private static void runRequired(Path worktree, String... args) throws IOException { + runInternal(worktree, true, args); + } + + private static String runCapture(Path worktree, String... args) throws IOException { + return runInternal(worktree, true, args); + } + + private static String runInternal(Path worktree, boolean required, String... args) + throws IOException { + List cmd = new ArrayList(); + cmd.add("git"); + for (String arg : args) { + cmd.add(arg); + } + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.directory(worktree.toFile()); + pb.redirectErrorStream(true); + Process p = pb.start(); + StringBuilder out = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (out.length() > 0) + out.append('\n'); + out.append(line); + } + } + try { + boolean finished = p.waitFor(10, TimeUnit.MINUTES); + if (!finished) { + p.destroyForcibly(); + throw new IOException("git command timed out: " + cmd); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("git command interrupted: " + cmd, e); + } + if (p.exitValue() != 0) { + if (!required) + return out.toString(); + + throw new IOException( + "git " + joinArgs(args) + " failed (" + p.exitValue() + "): " + out); + } + return out.toString(); + } + + private static String joinArgs(String[] args) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < args.length; i++) { + if (i > 0) + sb.append(' '); + + sb.append(args[i]); + } + return sb.toString(); + } + + private static final class ProbeResult { + final boolean ok; + final String version; + + private ProbeResult(boolean ok, String version) { + this.ok = ok; + this.version = version; + } + + static ProbeResult ok(String version) { + return new ProbeResult(true, version); + } + + static ProbeResult fail() { + return new ProbeResult(false, ""); + } + } +} diff --git a/core/src/main/java/com/magicjinn/chronos/core/git/SourceManifest.java b/core/src/main/java/com/magicjinn/chronos/core/git/SourceManifest.java new file mode 100644 index 0000000..3b32e56 --- /dev/null +++ b/core/src/main/java/com/magicjinn/chronos/core/git/SourceManifest.java @@ -0,0 +1,164 @@ +package com.magicjinn.chronos.core.git; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; + +/** + * We cannot do a git-diff of the post-prune files, because this would require + * pruning files before diff, which wastes compute. Therefor, we store a + * manifest of the previously live source files in a special directory. We + * compare it to the current live source files to get the list of files that + * were changed. If changed, re-prune the files. + */ +public final class SourceManifest { + public static final int SCHEMA_VERSION = 1; // Increment when the on-disk layout changes. + public static final String CHRONOS_DIR = ".chronos"; + public static final String MANIFEST_FILE_NAME = "source-manifest.json"; + public static final String META_FILE_NAME = "backup-meta.json"; + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + public int version = SCHEMA_VERSION; + + /** + * Hash of prune settings + blacklist that affect pruned output. If changed, all + * source files must be re-pruned. + */ + public String prunePolicyHash = ""; + + public Map files = new LinkedHashMap(); + + public static final class FileEntry { + public String hash = ""; + public long size; + + public FileEntry(String hash, long size) { + this.hash = hash; + this.size = size; + } + } + + public static Path chronosDir(Path worktree) { + return worktree.resolve(CHRONOS_DIR); + } + + public static Path manifestPath(Path worktree) { + return chronosDir(worktree).resolve(MANIFEST_FILE_NAME); + } + + public static Path metaPath(Path worktree) { + return chronosDir(worktree).resolve(META_FILE_NAME); + } + + public static SourceManifest empty(String prunePolicyHash) { + SourceManifest m = new SourceManifest(); + m.prunePolicyHash = prunePolicyHash == null ? "" : prunePolicyHash; + return m; + } + + public static SourceManifest load(Path path) throws IOException { + if (path == null || !Files.isRegularFile(path)) + return empty(""); + + try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + SourceManifest loaded = GSON.fromJson(reader, SourceManifest.class); + if (loaded == null) + return empty(""); + + if (loaded.files == null) + loaded.files = new LinkedHashMap(); + + if (loaded.prunePolicyHash == null) + loaded.prunePolicyHash = ""; + + if (loaded.version <= 0) + loaded.version = SCHEMA_VERSION; + + return loaded; + } catch (JsonParseException e) { + throw new IOException("Invalid Chronos source manifest at " + path + ": " + e.getMessage(), e); + } + } + + public void save(Path path) throws IOException { + Path parent = path.getParent(); + if (parent != null) + Files.createDirectories(parent); + + if (files == null) + files = new LinkedHashMap(); + + version = SCHEMA_VERSION; + try (Writer writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { + GSON.toJson(this, writer); + } + } + + /** + * Stable policy id. When this changes, every region file must be re-pruned even + * if source hashes match. + */ + public static String computePrunePolicyHash( + boolean pruneChunks, + int pruneTimeRequirementSeconds, + List copyBlacklist) { + StringBuilder sb = new StringBuilder(128); + sb.append("schema=").append(SCHEMA_VERSION); + sb.append(";pruneChunks=").append(pruneChunks); + sb.append(";pruneSeconds=").append(Math.max(0, pruneTimeRequirementSeconds)); + sb.append(";blacklist="); + List sorted = new ArrayList(); + if (copyBlacklist != null) + for (String entry : copyBlacklist) { + if (entry != null && !entry.trim().isEmpty()) + sorted.add(entry.trim().replace('\\', '/')); + } + + // Sort the blacklist to ensure consistent ordering. + Collections.sort(sorted); + for (int i = 0; i < sorted.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append(sorted.get(i)); + } + return sha256Hex(sb.toString()); + } + + public boolean policyMatches(String expectedPolicyHash) { + return Objects.equals( + prunePolicyHash == null ? "" : prunePolicyHash, + expectedPolicyHash == null ? "" : expectedPolicyHash); + } + + private static String sha256Hex(String input) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] dig = md.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(dig.length * 2); + for (byte b : dig) { + hex.append(String.format(Locale.ROOT, "%02x", b)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + return Integer.toHexString(input.hashCode()); + } + } +} diff --git a/core/src/main/java/com/magicjinn/chronos/core/git/SourceScanResult.java b/core/src/main/java/com/magicjinn/chronos/core/git/SourceScanResult.java new file mode 100644 index 0000000..7d3732d --- /dev/null +++ b/core/src/main/java/com/magicjinn/chronos/core/git/SourceScanResult.java @@ -0,0 +1,72 @@ +package com.magicjinn.chronos.core.git; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import com.magicjinn.chronos.core.git.SourceManifest.FileEntry; + +/** Parsed output of the native xxHash3 source scan. */ +public final class SourceScanResult { + private static final Gson GSON = new Gson(); + + public int version = 1; + public String prunePolicyHash = ""; + public List changed = new ArrayList(); + public List deleted = new ArrayList(); + public List unchanged = new ArrayList(); + public Map files = new LinkedHashMap(); + + public static SourceScanResult load(Path path) throws IOException { + try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + SourceScanResult loaded = GSON.fromJson(reader, SourceScanResult.class); + if (loaded == null) + return empty(); + + if (loaded.changed == null) + loaded.changed = new ArrayList(); + + if (loaded.deleted == null) + loaded.deleted = new ArrayList(); + + if (loaded.unchanged == null) + loaded.unchanged = new ArrayList(); + + if (loaded.files == null) + loaded.files = new LinkedHashMap(); + return loaded; + } catch (JsonParseException e) { + throw new IOException("Invalid source scan result at " + path + ": " + e.getMessage(), e); + } + } + + public static SourceScanResult empty() { + return new SourceScanResult(); + } + + public SourceManifest toManifest(String prunePolicyHash) { + SourceManifest m = SourceManifest.empty(prunePolicyHash); + if (files != null) + m.files.putAll(files); + + return m; + } + + /** Serialize previous manifest files map for the native scanner. */ + public static String filesJson(SourceManifest previous) { + Map files = previous == null || previous.files == null + ? Collections.emptyMap() + : previous.files; + + return GSON.toJson(files); + } +} diff --git a/core/src/main/java/com/magicjinn/cloudintegration/GoogleDrive.java b/core/src/main/java/com/magicjinn/cloudintegration/GoogleDrive.java index 76d3696..36a2388 100644 --- a/core/src/main/java/com/magicjinn/cloudintegration/GoogleDrive.java +++ b/core/src/main/java/com/magicjinn/cloudintegration/GoogleDrive.java @@ -181,7 +181,7 @@ public void synchronize() throws IOException { continue; String worldName = worldDir.getFileName().toString(); - if (worldName.startsWith(".")) + if (ChronosBackupArtifacts.isBackupMetaFolderName(worldName)) continue; syncWorld(worldDir, worldName, rootFolderId, maxStored, shouldKeepLocal); diff --git a/core/src/test/java/com/magicjinn/chronos/core/git/SourceManifestTest.java b/core/src/test/java/com/magicjinn/chronos/core/git/SourceManifestTest.java new file mode 100644 index 0000000..b5382ae --- /dev/null +++ b/core/src/test/java/com/magicjinn/chronos/core/git/SourceManifestTest.java @@ -0,0 +1,47 @@ +package com.magicjinn.chronos.core.git; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class SourceManifestTest { + @TempDir + Path temp; + + @Test + void prunePolicyHashStableForSameInputs() { + String a = SourceManifest.computePrunePolicyHash(true, 120, Arrays.asList("voxy", "dynmap")); + String b = SourceManifest.computePrunePolicyHash(true, 120, Arrays.asList("dynmap", "voxy")); + assertEquals(a, b); + } + + @Test + void prunePolicyHashChangesWhenPruneSecondsChange() { + String a = SourceManifest.computePrunePolicyHash(true, 120, Collections.emptyList()); + String b = SourceManifest.computePrunePolicyHash(true, 60, Collections.emptyList()); + assertFalse(a.equals(b)); + } + + @Test + void roundTripSaveLoad() throws Exception { + Path path = temp.resolve(".chronos").resolve("source-manifest.json"); + SourceManifest m = SourceManifest.empty("abc123"); + m.files.put("region/r.0.0.mca", new SourceManifest.FileEntry("deadbeefcafebabe", 4096)); + m.save(path); + + SourceManifest loaded = SourceManifest.load(path); + assertEquals(SourceManifest.SCHEMA_VERSION, loaded.version); + assertEquals("abc123", loaded.prunePolicyHash); + assertTrue(loaded.policyMatches("abc123")); + assertEquals("deadbeefcafebabe", loaded.files.get("region/r.0.0.mca").hash); + assertEquals(4096L, loaded.files.get("region/r.0.0.mca").size); + } +} diff --git a/gradle.properties b/gradle.properties index f234365..55c0bb1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ org.gradle.workers.max=2 chronos.gradle.retry.max=3 # Central mod metadata (single source of truth for human-facing strings) -chronos.mod.version=1.1.4 +chronos.mod.version=1.2.0 chronos.mod.id=chronosbackups chronos.mod.name=Chronos Backups chronos.mod.author=MagicJinn diff --git a/shell-brigadier/src/main/java/com/magicjinn/chronos/shell/ChronosBrigadier.java b/shell-brigadier/src/main/java/com/magicjinn/chronos/shell/ChronosBrigadier.java index a7f8fc2..8065da9 100644 --- a/shell-brigadier/src/main/java/com/magicjinn/chronos/shell/ChronosBrigadier.java +++ b/shell-brigadier/src/main/java/com/magicjinn/chronos/shell/ChronosBrigadier.java @@ -1,16 +1,21 @@ package com.magicjinn.chronos.shell; +import java.util.concurrent.CompletableFuture; + import com.magicjinn.chronos.core.Scheduler.EnqueueResult; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; /** - * Single Brigadier tree for {@code /chronos backup|cancel|speedtest|reload}, - * shared by loaders that ship Brigadier. Uses - * {@link LiteralArgumentBuilder#literal} so we do not depend on - * version-specific {@code Commands} classes. + * Single Brigadier tree for + * {@code /chronos backup|cancel|speedtest|list|export|reload}, shared by + * loaders that ship Brigadier. Uses {@link LiteralArgumentBuilder#literal} so + * we do not depend on version-specific {@code Commands} classes. * *

* Kept in {@code shell-brigadier} (not {@code shell-shared}) so Forge 1.12 @@ -99,6 +104,26 @@ public static LiteralArgumentBuilder buildRoot(Hooks hooks) { } return hooks.backupReturnCode(start == EnqueueResult.QUEUED); }))) + .then(LiteralArgumentBuilder.literal(ChronosCommandLiterals.LIST) + .executes(ctx -> { + hooks.feedback( + ctx.getSource(), + ChronosCommandActions.tryListBackups(), + false); + return hooks.successReturnCode(); + })) + .then(LiteralArgumentBuilder.literal(ChronosCommandLiterals.EXPORT) + .then(RequiredArgumentBuilder.argument( + "tag", StringArgumentType.word()) + .suggests((ctx, builder) -> suggestExportTags(builder)) + .executes(ctx -> { + String tag = StringArgumentType.getString(ctx, "tag"); + hooks.feedback( + ctx.getSource(), + ChronosCommandActions.tryExportGitBackup(tag), + false); + return hooks.successReturnCode(); + }))) .then(LiteralArgumentBuilder.literal(ChronosCommandLiterals.RELOAD) .executes(ctx -> { boolean applied = ChronosCommandActions.tryReloadConfig(); @@ -112,6 +137,13 @@ public static LiteralArgumentBuilder buildRoot(Hooks hooks) { })); } + private static CompletableFuture suggestExportTags(SuggestionsBuilder builder) { + for (String tag : ChronosCommandActions.suggestExportTags(builder.getRemaining())) { + builder.suggest(tag); + } + return builder.buildFuture(); + } + public static void register(CommandDispatcher dispatcher, Hooks hooks) { dispatcher.register(buildRoot(hooks)); } diff --git a/shell-forge/forge-common-1_10-1_12/src/main/java/com/magicjinn/chronos/shell/forge/ChronosBackupCommand.java b/shell-forge/forge-common-1_10-1_12/src/main/java/com/magicjinn/chronos/shell/forge/ChronosBackupCommand.java index a1c7916..5aa34f9 100644 --- a/shell-forge/forge-common-1_10-1_12/src/main/java/com/magicjinn/chronos/shell/forge/ChronosBackupCommand.java +++ b/shell-forge/forge-common-1_10-1_12/src/main/java/com/magicjinn/chronos/shell/forge/ChronosBackupCommand.java @@ -42,7 +42,11 @@ public List getAliases() { public List getTabCompletions( MinecraftServer server, ICommandSender sender, String[] args, BlockPos targetPos) { if (args.length == 1) - return getListOfStringsMatchingLastWord(args, ChronosCommandLiterals.TAB_SUBCOMMANDS); + return getListOfStringsMatchingLastWord(args, ChronosCommandLiterals.SUBCOMMANDS); + + // Suggest export tags for the export subcommand. + if (args.length == 2 && ChronosCommandLiterals.EXPORT.equalsIgnoreCase(args[0])) + return ChronosCommandActions.suggestExportTags(args[1]); return Collections.emptyList(); } diff --git a/shell-forge/v1_14_v1_15/src/main/java/com/magicjinn/chronos/shell/forge/Forge114CommandRegistrar.java b/shell-forge/v1_14_v1_15/src/main/java/com/magicjinn/chronos/shell/forge/Forge114CommandRegistrar.java index d1de3e3..133aa0f 100644 --- a/shell-forge/v1_14_v1_15/src/main/java/com/magicjinn/chronos/shell/forge/Forge114CommandRegistrar.java +++ b/shell-forge/v1_14_v1_15/src/main/java/com/magicjinn/chronos/shell/forge/Forge114CommandRegistrar.java @@ -10,6 +10,7 @@ import com.mojang.brigadier.Command; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.builder.RequiredArgumentBuilder; import com.mojang.brigadier.context.CommandContext; @@ -92,6 +93,32 @@ private static LiteralArgumentBuilder buildRoot( } return hooks.backupReturnCode(start == EnqueueResult.QUEUED); })))) + .then(LiteralArgumentBuilder.literal(ChronosCommandLiterals.LIST) + .executes(ctx -> onServerThread(ctx, c -> { + hooks.feedback( + c.getSource(), + ChronosCommandActions.tryListBackups(), + false); + return hooks.successReturnCode(); + }))) + .then(LiteralArgumentBuilder.literal(ChronosCommandLiterals.EXPORT) + .then(RequiredArgumentBuilder.argument( + "tag", StringArgumentType.word()) + .suggests((ctx, builder) -> { + for (String tag : ChronosCommandActions.suggestExportTags( + builder.getRemaining())) { + builder.suggest(tag); + } + return builder.buildFuture(); + }) + .executes(ctx -> onServerThread(ctx, c -> { + String tag = StringArgumentType.getString(c, "tag"); + hooks.feedback( + c.getSource(), + ChronosCommandActions.tryExportGitBackup(tag), + false); + return hooks.successReturnCode(); + })))) .then(LiteralArgumentBuilder.literal(ChronosCommandLiterals.RELOAD) .executes(ctx -> onServerThread(ctx, c -> { boolean applied = ChronosCommandActions.tryReloadConfig(); diff --git a/shell-forge/v1_7_10/src/main/java/com/magicjinn/chronos/shell/forge/ChronosBackupCommand.java b/shell-forge/v1_7_10/src/main/java/com/magicjinn/chronos/shell/forge/ChronosBackupCommand.java index c4ecfdd..584d51b 100644 --- a/shell-forge/v1_7_10/src/main/java/com/magicjinn/chronos/shell/forge/ChronosBackupCommand.java +++ b/shell-forge/v1_7_10/src/main/java/com/magicjinn/chronos/shell/forge/ChronosBackupCommand.java @@ -43,8 +43,12 @@ public List getCommandAliases() { @Override public List addTabCompletionOptions(ICommandSender sender, String[] args) { - if (args.length == 1) - return getListOfStringsMatchingLastWord(args, ChronosCommandLiterals.TAB_SUBCOMMANDS); + if (args.length == 1) { + return getListOfStringsMatchingLastWord(args, ChronosCommandLiterals.SUBCOMMANDS); + } else if (args.length == 2) { + if (ChronosCommandLiterals.EXPORT.equalsIgnoreCase(args[0])) + return ChronosCommandActions.suggestExportTags(args[1]); + } return Collections.emptyList(); } diff --git a/shell-forge/v1_8/src/main/java/com/magicjinn/chronos/shell/forge/ChronosBackupCommand.java b/shell-forge/v1_8/src/main/java/com/magicjinn/chronos/shell/forge/ChronosBackupCommand.java index 18204c0..1e674e2 100644 --- a/shell-forge/v1_8/src/main/java/com/magicjinn/chronos/shell/forge/ChronosBackupCommand.java +++ b/shell-forge/v1_8/src/main/java/com/magicjinn/chronos/shell/forge/ChronosBackupCommand.java @@ -42,9 +42,12 @@ public List getCommandAliases() { @Override public List addTabCompletionOptions( ICommandSender sender, String[] args, BlockPos targetPos) { - if (args.length == 1) - return getListOfStringsMatchingLastWord(args, ChronosCommandLiterals.TAB_SUBCOMMANDS); - + if (args.length == 1) { + return getListOfStringsMatchingLastWord(args, ChronosCommandLiterals.SUBCOMMANDS); + } else if (args.length == 2) { + if (ChronosCommandLiterals.EXPORT.equalsIgnoreCase(args[0])) + return ChronosCommandActions.suggestExportTags(args[1]); + } return Collections.emptyList(); } diff --git a/shell-forge/v1_9/src/main/java/com/magicjinn/chronos/shell/forge/ChronosBackupCommand.java b/shell-forge/v1_9/src/main/java/com/magicjinn/chronos/shell/forge/ChronosBackupCommand.java index 3dd72b4..7b427c4 100644 --- a/shell-forge/v1_9/src/main/java/com/magicjinn/chronos/shell/forge/ChronosBackupCommand.java +++ b/shell-forge/v1_9/src/main/java/com/magicjinn/chronos/shell/forge/ChronosBackupCommand.java @@ -38,7 +38,10 @@ public List getCommandAliases() { public List getTabCompletionOptions( MinecraftServer server, ICommandSender sender, String[] args, BlockPos pos) { if (args.length == 1) - return getListOfStringsMatchingLastWord(args, ChronosCommandLiterals.TAB_SUBCOMMANDS); + return getListOfStringsMatchingLastWord(args, ChronosCommandLiterals.SUBCOMMANDS); + + if (args.length == 2 && ChronosCommandLiterals.EXPORT.equalsIgnoreCase(args[0])) + return ChronosCommandActions.suggestExportTags(args[1]); return Collections.emptyList(); } diff --git a/shell-paper/src/main/java/com/magicjinn/chronos/shell/paper/PaperCommands.java b/shell-paper/src/main/java/com/magicjinn/chronos/shell/paper/PaperCommands.java index 3f6c005..5250f46 100644 --- a/shell-paper/src/main/java/com/magicjinn/chronos/shell/paper/PaperCommands.java +++ b/shell-paper/src/main/java/com/magicjinn/chronos/shell/paper/PaperCommands.java @@ -1,6 +1,7 @@ package com.magicjinn.chronos.shell.paper; import com.magicjinn.chronos.core.ChronosLogger; +import com.magicjinn.chronos.shell.ChronosCommandActions; import com.magicjinn.chronos.shell.ChronosCommandLiterals; import com.magicjinn.chronos.shell.LegacyCommandSupport; import java.util.ArrayList; @@ -56,12 +57,16 @@ public List onTabComplete( if (args.length == 1) { String prefix = args[0].toLowerCase(Locale.ROOT); List choices = new ArrayList<>(); - for (String candidate : ChronosCommandLiterals.TAB_SUBCOMMANDS) { + for (String candidate : ChronosCommandLiterals.SUBCOMMANDS) { if (candidate.startsWith(prefix)) choices.add(candidate); } return choices; } + if (args.length == 2 + && ChronosCommandLiterals.EXPORT.equalsIgnoreCase(args[0])) + return ChronosCommandActions.suggestExportTags(args[1]); + return Collections.emptyList(); } } diff --git a/shell-shared/src/main/java/com/magicjinn/chronos/shell/ChronosCommandActions.java b/shell-shared/src/main/java/com/magicjinn/chronos/shell/ChronosCommandActions.java index 1de44d0..00a11b1 100644 --- a/shell-shared/src/main/java/com/magicjinn/chronos/shell/ChronosCommandActions.java +++ b/shell-shared/src/main/java/com/magicjinn/chronos/shell/ChronosCommandActions.java @@ -1,9 +1,16 @@ package com.magicjinn.chronos.shell; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + import com.magicjinn.chronos.core.Backupper; import com.magicjinn.chronos.core.Core; import com.magicjinn.chronos.core.Scheduler; import com.magicjinn.chronos.core.Scheduler.EnqueueResult; +import com.magicjinn.chronos.core.config.CompressionMethod; import com.magicjinn.chronos.core.config.Config; /** @@ -13,21 +20,24 @@ public final class ChronosCommandActions { /** Short hint for legacy {@code ICommand} usage lines. */ public static final String USAGE_LINE = "/" - + ChronosCommandLiterals.ROOT - + " " - + ChronosCommandLiterals.BACKUP + + ChronosCommandLiterals.ROOT + " " + ChronosCommandLiterals.BACKUP + + " | /" + + ChronosCommandLiterals.ROOT + " " + ChronosCommandLiterals.CANCEL + + " | /" + + ChronosCommandLiterals.ROOT + " " + ChronosCommandLiterals.SPEEDTEST + " " + " | /" - + ChronosCommandLiterals.ROOT - + " " - + ChronosCommandLiterals.CANCEL + + ChronosCommandLiterals.ROOT + " " + ChronosCommandLiterals.LIST + " | /" - + ChronosCommandLiterals.ROOT - + " " - + ChronosCommandLiterals.SPEEDTEST - + " | /" - + ChronosCommandLiterals.ROOT - + " " - + ChronosCommandLiterals.RELOAD; + + ChronosCommandLiterals.ROOT + " " + ChronosCommandLiterals.EXPORT + + " " + + " | /" + + ChronosCommandLiterals.ROOT + " " + ChronosCommandLiterals.RELOAD; + + /** Newest backups shown by {@code /chronos list} and export tab-complete. */ + private static final int BACKUP_LIST_UI_LIMIT = 10; + + private static final String GIT_OLDER_TAGS_HINT = "Only " + BACKUP_LIST_UI_LIMIT + + " backups are shown. For older backups, open the backup folder in a Git client/CLI."; private ChronosCommandActions() {} @@ -82,6 +92,17 @@ public static String messageReloadFailed() { + "Could not reload chronos.toml. Keeping previous config. Check the console for further details."; } + public static String messageGitModeRequired() { + return chatPrefix() + + "This command requires compressionMethod = \"git\" in config/chronos.toml."; + } + + public static String messageExportUsage() { + return chatPrefix() + + "Usage: /" + + ChronosCommandLiterals.ROOT + " " + ChronosCommandLiterals.EXPORT + " "; + } + /** Queues a manual backup when the world scheduler is active and no backup is running. */ public static EnqueueResult tryStartManualBackup() { Backupper.tickBackupTracker(); @@ -114,6 +135,94 @@ public static EnqueueResult tryStartSpeedtest(int s) { } } + /** Lists backups for the active world (git tags, or zip/folder names). */ + public static String tryListBackups() { + if (Scheduler.tryGetRuntimeContext() == null) + return messageRuntimeInactive(); + + boolean git = Config.getCompressionMethod() == CompressionMethod.GIT; + try { + List names = git + ? Backupper.listGitBackupTags() + : Backupper.listLocalBackupNames(); + if (names.isEmpty()) + return chatPrefix() + + (git ? "No git backup snapshots yet." : "No backups yet."); + + StringBuilder sb = new StringBuilder(); + sb.append(chatPrefix()) + .append(git ? "Git snapshots (newest first):" : "Backups (newest first):"); + int limit = Math.min(names.size(), BACKUP_LIST_UI_LIMIT); + for (int i = 0; i < limit; i++) { + sb.append("\n ").append(names.get(i)); + } + // If above limit, print a hint on how to view older ones + if (names.size() > limit) { + sb.append("\n ... ").append(names.size() - limit).append(" older not shown."); + if (git) + sb.append(" ").append(GIT_OLDER_TAGS_HINT); + } + + return sb.toString(); + } catch (Exception e) { + return chatPrefix() + "Failed to list backups: " + e.getMessage(); + } + } + + /** Exports a git tag to backups/exported/. */ + public static String tryExportGitBackup(String tag) { + if (Config.getCompressionMethod() != CompressionMethod.GIT) + return messageGitModeRequired(); + + if (tag == null || tag.trim().isEmpty()) + return messageExportUsage(); + + if (Scheduler.tryGetRuntimeContext() == null) + return messageRuntimeInactive(); + + try { + Path dest = Backupper.exportGitBackup(tag.trim()); + return chatPrefix() + + "Exported snapshot to " + dest + " (live world was not modified)."; + } catch (Exception e) { + return chatPrefix() + "Export failed: " + e.getMessage(); + } + } + + /** + * Tab-completion candidates for {@code /chronos export }. Empty when not + * in git mode or the repo is unavailable. + */ + public static List suggestExportTags(String prefix) { + if (Config.getCompressionMethod() != CompressionMethod.GIT) + return Collections.emptyList(); + + if (Scheduler.tryGetRuntimeContext() == null) + return Collections.emptyList(); + + try { + List tags = Backupper.listGitBackupTags(); + if (tags.isEmpty()) + return Collections.emptyList(); + + List newest = tags.size() <= BACKUP_LIST_UI_LIMIT + ? tags + : tags.subList(0, BACKUP_LIST_UI_LIMIT); + if (prefix == null || prefix.isEmpty()) + return new ArrayList(newest); + + String p = prefix.toLowerCase(Locale.ROOT); + List out = new ArrayList(); + for (String tag : newest) { + if (tag.toLowerCase(Locale.ROOT).startsWith(p)) + out.add(tag); + } + return out; + } catch (Exception e) { + return Collections.emptyList(); + } + } + /** * Forces a config reload from disk. Returns {@code true} when a new config was * applied. diff --git a/shell-shared/src/main/java/com/magicjinn/chronos/shell/ChronosCommandLiterals.java b/shell-shared/src/main/java/com/magicjinn/chronos/shell/ChronosCommandLiterals.java index 3b2de0d..05183ab 100644 --- a/shell-shared/src/main/java/com/magicjinn/chronos/shell/ChronosCommandLiterals.java +++ b/shell-shared/src/main/java/com/magicjinn/chronos/shell/ChronosCommandLiterals.java @@ -9,16 +9,12 @@ public final class ChronosCommandLiterals { public static final String BACKUP = "backup"; public static final String CANCEL = "cancel"; public static final String SPEEDTEST = "speedtest"; + public static final String LIST = "list"; + public static final String EXPORT = "export"; public static final String RELOAD = "reload"; /** Tab-completion candidates for the first argument. */ - public static final String[] TAB_SUBCOMMANDS = { - BACKUP, - CANCEL, - SPEEDTEST, - RELOAD + public static final String[] SUBCOMMANDS = { + BACKUP, CANCEL, SPEEDTEST, LIST, EXPORT, RELOAD }; - - private ChronosCommandLiterals() { - } } diff --git a/shell-shared/src/main/java/com/magicjinn/chronos/shell/LegacyCommandSupport.java b/shell-shared/src/main/java/com/magicjinn/chronos/shell/LegacyCommandSupport.java index fb139ba..eabbb8c 100644 --- a/shell-shared/src/main/java/com/magicjinn/chronos/shell/LegacyCommandSupport.java +++ b/shell-shared/src/main/java/com/magicjinn/chronos/shell/LegacyCommandSupport.java @@ -53,6 +53,20 @@ public static void execute(String[] args, MessageSink sink) throws UnknownSubcom return; } + if (ChronosCommandLiterals.LIST.equalsIgnoreCase(subcommand)) { + sink.send(ChronosCommandActions.tryListBackups()); + return; + } + + if (ChronosCommandLiterals.EXPORT.equalsIgnoreCase(subcommand)) { + if (args.length < 2) { + sink.send(ChronosCommandActions.messageExportUsage()); + return; + } + sink.send(ChronosCommandActions.tryExportGitBackup(args[1])); + return; + } + if (ChronosCommandLiterals.RELOAD.equalsIgnoreCase(subcommand)) { if (ChronosCommandActions.tryReloadConfig()) { sink.send(ChronosCommandActions.messageReloadApplied());