From 4b27e9afd5152f849fa76a3d6445a981bbe619b9 Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 16:07:57 +0000 Subject: [PATCH] userland/git: git-compatible CLI on v2's gix code (T10) Local workflow, smart-HTTP clone/fetch/push and public HTTPS clone/fetch match native git in stdout and exit codes. Only WASI imports (wasi:http, exit-with-code). --- .gitignore | 3 + userland/git/Cargo.toml | 17 + userland/git/README.md | 76 + userland/git/http-test.nu | 52 + userland/git/public-https-test.nu | 16 + userland/git/src/branches.rs | 322 ++++ userland/git/src/changes.rs | 600 +++++++ userland/git/src/checkout.rs | 424 +++++ userland/git/src/head.inc | 8 + userland/git/src/http.rs | 431 +++++ userland/git/src/lib.rs | 827 +++++++++ userland/git/src/main.rs | 39 + userland/git/src/merge.rs | 407 +++++ userland/git/src/network.rs | 241 +++ userland/git/src/output.rs | 177 ++ userland/git/src/push.rs | 672 +++++++ userland/git/src/rebase.rs | 581 ++++++ userland/git/src/remotes.rs | 187 ++ userland/git/src/repo.rs | 112 ++ userland/git/src/staging.rs | 771 ++++++++ userland/git/src/worktrees.rs | 447 +++++ userland/git/test.nu | 97 + userland/git/tests/http-server/Cargo.toml | 5 + userland/git/tests/http-server/src/main.rs | 86 + userland/git/vendor/PATCHES.md | 137 ++ .../git/vendor/filetime/.cargo_vcs_info.json | 6 + .../filetime/.github/workflows/main.yml | 86 + userland/git/vendor/filetime/.gitignore | 2 + userland/git/vendor/filetime/Cargo.toml | 53 + userland/git/vendor/filetime/Cargo.toml.orig | 30 + userland/git/vendor/filetime/LICENSE-APACHE | 201 +++ userland/git/vendor/filetime/LICENSE-MIT | 25 + userland/git/vendor/filetime/README.md | 45 + userland/git/vendor/filetime/src/lib.rs | 748 ++++++++ userland/git/vendor/filetime/src/redox.rs | 37 + .../git/vendor/filetime/src/unix/android.rs | 31 + .../git/vendor/filetime/src/unix/linux.rs | 47 + .../git/vendor/filetime/src/unix/macos.rs | 68 + userland/git/vendor/filetime/src/unix/mod.rs | 73 + .../git/vendor/filetime/src/unix/utimensat.rs | 37 + .../git/vendor/filetime/src/unix/utimes.rs | 75 + userland/git/vendor/filetime/src/wasi.rs | 21 + userland/git/vendor/filetime/src/wasm.rs | 24 + userland/git/vendor/filetime/src/windows.rs | 45 + .../git/vendor/gix-index/.cargo_vcs_info.json | 6 + userland/git/vendor/gix-index/Cargo.lock | 1533 ++++++++++++++++ userland/git/vendor/gix-index/Cargo.toml | 224 +++ userland/git/vendor/gix-index/Cargo.toml.orig | 78 + userland/git/vendor/gix-index/LICENSE-APACHE | 177 ++ userland/git/vendor/gix-index/LICENSE-MIT | 17 + userland/git/vendor/gix-index/README.md | 11 + .../git/vendor/gix-index/src/access/mod.rs | 708 ++++++++ .../git/vendor/gix-index/src/access/sparse.rs | 59 + .../vendor/gix-index/src/decode/entries.rs | 234 +++ .../git/vendor/gix-index/src/decode/header.rs | 35 + .../git/vendor/gix-index/src/decode/mod.rs | 349 ++++ .../git/vendor/gix-index/src/entry/flags.rs | 175 ++ .../git/vendor/gix-index/src/entry/mod.rs | 145 ++ .../git/vendor/gix-index/src/entry/mode.rs | 144 ++ .../git/vendor/gix-index/src/entry/stat.rs | 207 +++ .../git/vendor/gix-index/src/entry/write.rs | 37 + .../vendor/gix-index/src/extension/decode.rs | 82 + .../extension/end_of_index_entry/decode.rs | 57 + .../src/extension/end_of_index_entry/mod.rs | 14 + .../src/extension/end_of_index_entry/write.rs | 33 + .../gix-index/src/extension/fs_monitor.rs | 51 + .../src/extension/index_entry_offset_table.rs | 43 + .../vendor/gix-index/src/extension/iter.rs | 56 + .../vendor/gix-index/src/extension/link.rs | 158 ++ .../git/vendor/gix-index/src/extension/mod.rs | 100 ++ .../gix-index/src/extension/resolve_undo.rs | 67 + .../vendor/gix-index/src/extension/sparse.rs | 11 + .../gix-index/src/extension/tree/decode.rs | 57 + .../gix-index/src/extension/tree/mod.rs | 29 + .../gix-index/src/extension/tree/verify.rs | 171 ++ .../gix-index/src/extension/tree/write.rs | 52 + .../src/extension/untracked_cache.rs | 271 +++ .../git/vendor/gix-index/src/file/init.rs | 151 ++ userland/git/vendor/gix-index/src/file/mod.rs | 137 ++ .../git/vendor/gix-index/src/file/verify.rs | 33 + .../git/vendor/gix-index/src/file/write.rs | 83 + userland/git/vendor/gix-index/src/fs.rs | 229 +++ userland/git/vendor/gix-index/src/init.rs | 214 +++ userland/git/vendor/gix-index/src/lib.rs | 285 +++ userland/git/vendor/gix-index/src/verify.rs | 38 + userland/git/vendor/gix-index/src/write.rs | 229 +++ .../git/vendor/gix-odb/.cargo_vcs_info.json | 6 + userland/git/vendor/gix-odb/Cargo.lock | 1035 +++++++++++ userland/git/vendor/gix-odb/Cargo.toml | 218 +++ userland/git/vendor/gix-odb/Cargo.toml.orig | 72 + userland/git/vendor/gix-odb/LICENSE-APACHE | 177 ++ userland/git/vendor/gix-odb/LICENSE-MIT | 17 + .../git/vendor/gix-odb/src/alternate/mod.rs | 112 ++ .../git/vendor/gix-odb/src/alternate/parse.rs | 44 + userland/git/vendor/gix-odb/src/cache.rs | 254 +++ userland/git/vendor/gix-odb/src/find.rs | 62 + userland/git/vendor/gix-odb/src/lib.rs | 215 +++ userland/git/vendor/gix-odb/src/memory.rs | 316 ++++ userland/git/vendor/gix-odb/src/sink.rs | 99 ++ .../gix-odb/src/store_impls/dynamic/access.rs | 24 + .../gix-odb/src/store_impls/dynamic/find.rs | 542 ++++++ .../gix-odb/src/store_impls/dynamic/handle.rs | 427 +++++ .../gix-odb/src/store_impls/dynamic/header.rs | 198 +++ .../gix-odb/src/store_impls/dynamic/init.rs | 152 ++ .../gix-odb/src/store_impls/dynamic/iter.rs | 243 +++ .../src/store_impls/dynamic/load_index.rs | 757 ++++++++ .../src/store_impls/dynamic/load_one.rs | 149 ++ .../src/store_impls/dynamic/metrics.rs | 80 + .../gix-odb/src/store_impls/dynamic/mod.rs | 107 ++ .../gix-odb/src/store_impls/dynamic/prefix.rs | 178 ++ .../src/store_impls/dynamic/structure.rs | 119 ++ .../gix-odb/src/store_impls/dynamic/types.rs | 460 +++++ .../gix-odb/src/store_impls/dynamic/verify.rs | 271 +++ .../gix-odb/src/store_impls/dynamic/write.rs | 59 + .../gix-odb/src/store_impls/loose/find.rs | 223 +++ .../gix-odb/src/store_impls/loose/iter.rs | 74 + .../gix-odb/src/store_impls/loose/mod.rs | 105 ++ .../gix-odb/src/store_impls/loose/verify.rs | 87 + .../gix-odb/src/store_impls/loose/write.rs | 193 ++ .../git/vendor/gix-odb/src/store_impls/mod.rs | 2 + userland/git/vendor/gix-odb/src/traits.rs | 62 + .../git/vendor/gix-pack/.cargo_vcs_info.json | 6 + userland/git/vendor/gix-pack/Cargo.lock | 1571 +++++++++++++++++ userland/git/vendor/gix-pack/Cargo.toml | 265 +++ userland/git/vendor/gix-pack/Cargo.toml.orig | 96 + userland/git/vendor/gix-pack/LICENSE-APACHE | 177 ++ userland/git/vendor/gix-pack/LICENSE-MIT | 17 + .../git/vendor/gix-pack/src/bundle/find.rs | 74 + .../git/vendor/gix-pack/src/bundle/init.rs | 45 + .../git/vendor/gix-pack/src/bundle/mod.rs | 57 + .../vendor/gix-pack/src/bundle/write/mod.rs | 405 +++++ .../vendor/gix-pack/src/bundle/write/types.rs | 132 ++ .../gix-pack/src/cache/delta/from_offsets.rs | 142 ++ .../vendor/gix-pack/src/cache/delta/mod.rs | 34 + .../gix-pack/src/cache/delta/traverse/mod.rs | 171 ++ .../src/cache/delta/traverse/resolve.rs | 1013 +++++++++++ .../gix-pack/src/cache/delta/traverse/util.rs | 57 + .../vendor/gix-pack/src/cache/delta/tree.rs | 575 ++++++ userland/git/vendor/gix-pack/src/cache/lru.rs | 241 +++ userland/git/vendor/gix-pack/src/cache/mod.rs | 102 ++ .../git/vendor/gix-pack/src/cache/object.rs | 111 ++ .../git/vendor/gix-pack/src/data/delta.rs | 122 ++ .../vendor/gix-pack/src/data/entry/decode.rs | 251 +++ .../vendor/gix-pack/src/data/entry/header.rs | 172 ++ .../git/vendor/gix-pack/src/data/entry/mod.rs | 77 + .../gix-pack/src/data/file/decode/entry.rs | 536 ++++++ .../gix-pack/src/data/file/decode/header.rs | 159 ++ .../gix-pack/src/data/file/decode/mod.rs | 85 + .../git/vendor/gix-pack/src/data/file/init.rs | 75 + .../git/vendor/gix-pack/src/data/file/mod.rs | 9 + .../vendor/gix-pack/src/data/file/verify.rs | 40 + .../git/vendor/gix-pack/src/data/header.rs | 43 + .../src/data/input/bytes_to_entries.rs | 316 ++++ .../src/data/input/entries_to_bytes.rs | 162 ++ .../vendor/gix-pack/src/data/input/entry.rs | 77 + .../data/input/lookup_ref_delta_objects.rs | 215 +++ .../git/vendor/gix-pack/src/data/input/mod.rs | 41 + .../vendor/gix-pack/src/data/input/types.rs | 51 + userland/git/vendor/gix-pack/src/data/mod.rs | 156 ++ .../vendor/gix-pack/src/data/output/bytes.rs | 142 ++ .../gix-pack/src/data/output/count/mod.rs | 49 + .../src/data/output/count/objects/mod.rs | 442 +++++ .../src/data/output/count/objects/reduce.rs | 40 + .../src/data/output/count/objects/tree.rs | 127 ++ .../src/data/output/count/objects/types.rs | 78 + .../src/data/output/count/objects/util.rs | 24 + .../src/data/output/entry/iter_from_counts.rs | 439 +++++ .../gix-pack/src/data/output/entry/mod.rs | 190 ++ .../vendor/gix-pack/src/data/output/mod.rs | 41 + userland/git/vendor/gix-pack/src/find.rs | 11 + .../git/vendor/gix-pack/src/find_traits.rs | 279 +++ .../git/vendor/gix-pack/src/index/access.rs | 305 ++++ .../git/vendor/gix-pack/src/index/encode.rs | 168 ++ .../git/vendor/gix-pack/src/index/init.rs | 176 ++ userland/git/vendor/gix-pack/src/index/mod.rs | 151 ++ .../vendor/gix-pack/src/index/traverse/mod.rs | 250 +++ .../gix-pack/src/index/traverse/reduce.rs | 116 ++ .../gix-pack/src/index/traverse/types.rs | 113 ++ .../gix-pack/src/index/traverse/with_index.rs | 231 +++ .../src/index/traverse/with_lookup.rs | 181 ++ .../git/vendor/gix-pack/src/index/util.rs | 23 + .../git/vendor/gix-pack/src/index/verify.rs | 244 +++ .../vendor/gix-pack/src/index/write/mod.rs | 295 ++++ userland/git/vendor/gix-pack/src/lib.rs | 145 ++ .../vendor/gix-pack/src/multi_index/access.rs | 163 ++ .../vendor/gix-pack/src/multi_index/chunk.rs | 304 ++++ .../vendor/gix-pack/src/multi_index/init.rs | 155 ++ .../vendor/gix-pack/src/multi_index/mod.rs | 55 + .../vendor/gix-pack/src/multi_index/verify.rs | 242 +++ .../vendor/gix-pack/src/multi_index/write.rs | 241 +++ userland/git/vendor/gix-pack/src/testing.rs | 52 + userland/git/vendor/gix-pack/src/verify.rs | 54 + .../vendor/gix-tempfile/.cargo_vcs_info.json | 6 + userland/git/vendor/gix-tempfile/Cargo.lock | 518 ++++++ userland/git/vendor/gix-tempfile/Cargo.toml | 167 ++ .../git/vendor/gix-tempfile/Cargo.toml.orig | 64 + .../git/vendor/gix-tempfile/LICENSE-APACHE | 177 ++ userland/git/vendor/gix-tempfile/LICENSE-MIT | 17 + userland/git/vendor/gix-tempfile/README.md | 12 + .../git/vendor/gix-tempfile/src/forksafe.rs | 249 +++ .../git/vendor/gix-tempfile/src/handle.rs | 367 ++++ userland/git/vendor/gix-tempfile/src/lib.rs | 271 +++ .../git/vendor/gix-tempfile/src/registry.rs | 65 + .../git/vendor/gix-tempfile/src/signal.rs | 101 ++ 204 files changed, 37736 insertions(+) create mode 100644 userland/git/Cargo.toml create mode 100644 userland/git/README.md create mode 100644 userland/git/http-test.nu create mode 100644 userland/git/public-https-test.nu create mode 100644 userland/git/src/branches.rs create mode 100644 userland/git/src/changes.rs create mode 100644 userland/git/src/checkout.rs create mode 100644 userland/git/src/head.inc create mode 100644 userland/git/src/http.rs create mode 100644 userland/git/src/lib.rs create mode 100644 userland/git/src/main.rs create mode 100644 userland/git/src/merge.rs create mode 100644 userland/git/src/network.rs create mode 100644 userland/git/src/output.rs create mode 100644 userland/git/src/push.rs create mode 100644 userland/git/src/rebase.rs create mode 100644 userland/git/src/remotes.rs create mode 100644 userland/git/src/repo.rs create mode 100644 userland/git/src/staging.rs create mode 100644 userland/git/src/worktrees.rs create mode 100644 userland/git/test.nu create mode 100644 userland/git/tests/http-server/Cargo.toml create mode 100644 userland/git/tests/http-server/src/main.rs create mode 100644 userland/git/vendor/PATCHES.md create mode 100644 userland/git/vendor/filetime/.cargo_vcs_info.json create mode 100644 userland/git/vendor/filetime/.github/workflows/main.yml create mode 100644 userland/git/vendor/filetime/.gitignore create mode 100644 userland/git/vendor/filetime/Cargo.toml create mode 100644 userland/git/vendor/filetime/Cargo.toml.orig create mode 100644 userland/git/vendor/filetime/LICENSE-APACHE create mode 100644 userland/git/vendor/filetime/LICENSE-MIT create mode 100644 userland/git/vendor/filetime/README.md create mode 100644 userland/git/vendor/filetime/src/lib.rs create mode 100644 userland/git/vendor/filetime/src/redox.rs create mode 100644 userland/git/vendor/filetime/src/unix/android.rs create mode 100644 userland/git/vendor/filetime/src/unix/linux.rs create mode 100644 userland/git/vendor/filetime/src/unix/macos.rs create mode 100644 userland/git/vendor/filetime/src/unix/mod.rs create mode 100644 userland/git/vendor/filetime/src/unix/utimensat.rs create mode 100644 userland/git/vendor/filetime/src/unix/utimes.rs create mode 100644 userland/git/vendor/filetime/src/wasi.rs create mode 100644 userland/git/vendor/filetime/src/wasm.rs create mode 100644 userland/git/vendor/filetime/src/windows.rs create mode 100644 userland/git/vendor/gix-index/.cargo_vcs_info.json create mode 100644 userland/git/vendor/gix-index/Cargo.lock create mode 100644 userland/git/vendor/gix-index/Cargo.toml create mode 100644 userland/git/vendor/gix-index/Cargo.toml.orig create mode 100644 userland/git/vendor/gix-index/LICENSE-APACHE create mode 100644 userland/git/vendor/gix-index/LICENSE-MIT create mode 100644 userland/git/vendor/gix-index/README.md create mode 100644 userland/git/vendor/gix-index/src/access/mod.rs create mode 100644 userland/git/vendor/gix-index/src/access/sparse.rs create mode 100644 userland/git/vendor/gix-index/src/decode/entries.rs create mode 100644 userland/git/vendor/gix-index/src/decode/header.rs create mode 100644 userland/git/vendor/gix-index/src/decode/mod.rs create mode 100644 userland/git/vendor/gix-index/src/entry/flags.rs create mode 100644 userland/git/vendor/gix-index/src/entry/mod.rs create mode 100644 userland/git/vendor/gix-index/src/entry/mode.rs create mode 100644 userland/git/vendor/gix-index/src/entry/stat.rs create mode 100644 userland/git/vendor/gix-index/src/entry/write.rs create mode 100644 userland/git/vendor/gix-index/src/extension/decode.rs create mode 100644 userland/git/vendor/gix-index/src/extension/end_of_index_entry/decode.rs create mode 100644 userland/git/vendor/gix-index/src/extension/end_of_index_entry/mod.rs create mode 100644 userland/git/vendor/gix-index/src/extension/end_of_index_entry/write.rs create mode 100644 userland/git/vendor/gix-index/src/extension/fs_monitor.rs create mode 100644 userland/git/vendor/gix-index/src/extension/index_entry_offset_table.rs create mode 100644 userland/git/vendor/gix-index/src/extension/iter.rs create mode 100644 userland/git/vendor/gix-index/src/extension/link.rs create mode 100644 userland/git/vendor/gix-index/src/extension/mod.rs create mode 100644 userland/git/vendor/gix-index/src/extension/resolve_undo.rs create mode 100644 userland/git/vendor/gix-index/src/extension/sparse.rs create mode 100644 userland/git/vendor/gix-index/src/extension/tree/decode.rs create mode 100644 userland/git/vendor/gix-index/src/extension/tree/mod.rs create mode 100644 userland/git/vendor/gix-index/src/extension/tree/verify.rs create mode 100644 userland/git/vendor/gix-index/src/extension/tree/write.rs create mode 100644 userland/git/vendor/gix-index/src/extension/untracked_cache.rs create mode 100644 userland/git/vendor/gix-index/src/file/init.rs create mode 100644 userland/git/vendor/gix-index/src/file/mod.rs create mode 100644 userland/git/vendor/gix-index/src/file/verify.rs create mode 100644 userland/git/vendor/gix-index/src/file/write.rs create mode 100644 userland/git/vendor/gix-index/src/fs.rs create mode 100644 userland/git/vendor/gix-index/src/init.rs create mode 100644 userland/git/vendor/gix-index/src/lib.rs create mode 100644 userland/git/vendor/gix-index/src/verify.rs create mode 100644 userland/git/vendor/gix-index/src/write.rs create mode 100644 userland/git/vendor/gix-odb/.cargo_vcs_info.json create mode 100644 userland/git/vendor/gix-odb/Cargo.lock create mode 100644 userland/git/vendor/gix-odb/Cargo.toml create mode 100644 userland/git/vendor/gix-odb/Cargo.toml.orig create mode 100644 userland/git/vendor/gix-odb/LICENSE-APACHE create mode 100644 userland/git/vendor/gix-odb/LICENSE-MIT create mode 100644 userland/git/vendor/gix-odb/src/alternate/mod.rs create mode 100644 userland/git/vendor/gix-odb/src/alternate/parse.rs create mode 100644 userland/git/vendor/gix-odb/src/cache.rs create mode 100644 userland/git/vendor/gix-odb/src/find.rs create mode 100644 userland/git/vendor/gix-odb/src/lib.rs create mode 100644 userland/git/vendor/gix-odb/src/memory.rs create mode 100644 userland/git/vendor/gix-odb/src/sink.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/dynamic/access.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/dynamic/find.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/dynamic/handle.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/dynamic/header.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/dynamic/init.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/dynamic/iter.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/dynamic/load_index.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/dynamic/load_one.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/dynamic/metrics.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/dynamic/mod.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/dynamic/prefix.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/dynamic/structure.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/dynamic/types.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/dynamic/verify.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/dynamic/write.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/loose/find.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/loose/iter.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/loose/mod.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/loose/verify.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/loose/write.rs create mode 100644 userland/git/vendor/gix-odb/src/store_impls/mod.rs create mode 100644 userland/git/vendor/gix-odb/src/traits.rs create mode 100644 userland/git/vendor/gix-pack/.cargo_vcs_info.json create mode 100644 userland/git/vendor/gix-pack/Cargo.lock create mode 100644 userland/git/vendor/gix-pack/Cargo.toml create mode 100644 userland/git/vendor/gix-pack/Cargo.toml.orig create mode 100644 userland/git/vendor/gix-pack/LICENSE-APACHE create mode 100644 userland/git/vendor/gix-pack/LICENSE-MIT create mode 100644 userland/git/vendor/gix-pack/src/bundle/find.rs create mode 100644 userland/git/vendor/gix-pack/src/bundle/init.rs create mode 100644 userland/git/vendor/gix-pack/src/bundle/mod.rs create mode 100644 userland/git/vendor/gix-pack/src/bundle/write/mod.rs create mode 100644 userland/git/vendor/gix-pack/src/bundle/write/types.rs create mode 100644 userland/git/vendor/gix-pack/src/cache/delta/from_offsets.rs create mode 100644 userland/git/vendor/gix-pack/src/cache/delta/mod.rs create mode 100644 userland/git/vendor/gix-pack/src/cache/delta/traverse/mod.rs create mode 100644 userland/git/vendor/gix-pack/src/cache/delta/traverse/resolve.rs create mode 100644 userland/git/vendor/gix-pack/src/cache/delta/traverse/util.rs create mode 100644 userland/git/vendor/gix-pack/src/cache/delta/tree.rs create mode 100644 userland/git/vendor/gix-pack/src/cache/lru.rs create mode 100644 userland/git/vendor/gix-pack/src/cache/mod.rs create mode 100644 userland/git/vendor/gix-pack/src/cache/object.rs create mode 100644 userland/git/vendor/gix-pack/src/data/delta.rs create mode 100644 userland/git/vendor/gix-pack/src/data/entry/decode.rs create mode 100644 userland/git/vendor/gix-pack/src/data/entry/header.rs create mode 100644 userland/git/vendor/gix-pack/src/data/entry/mod.rs create mode 100644 userland/git/vendor/gix-pack/src/data/file/decode/entry.rs create mode 100644 userland/git/vendor/gix-pack/src/data/file/decode/header.rs create mode 100644 userland/git/vendor/gix-pack/src/data/file/decode/mod.rs create mode 100644 userland/git/vendor/gix-pack/src/data/file/init.rs create mode 100644 userland/git/vendor/gix-pack/src/data/file/mod.rs create mode 100644 userland/git/vendor/gix-pack/src/data/file/verify.rs create mode 100644 userland/git/vendor/gix-pack/src/data/header.rs create mode 100644 userland/git/vendor/gix-pack/src/data/input/bytes_to_entries.rs create mode 100644 userland/git/vendor/gix-pack/src/data/input/entries_to_bytes.rs create mode 100644 userland/git/vendor/gix-pack/src/data/input/entry.rs create mode 100644 userland/git/vendor/gix-pack/src/data/input/lookup_ref_delta_objects.rs create mode 100644 userland/git/vendor/gix-pack/src/data/input/mod.rs create mode 100644 userland/git/vendor/gix-pack/src/data/input/types.rs create mode 100644 userland/git/vendor/gix-pack/src/data/mod.rs create mode 100644 userland/git/vendor/gix-pack/src/data/output/bytes.rs create mode 100644 userland/git/vendor/gix-pack/src/data/output/count/mod.rs create mode 100644 userland/git/vendor/gix-pack/src/data/output/count/objects/mod.rs create mode 100644 userland/git/vendor/gix-pack/src/data/output/count/objects/reduce.rs create mode 100644 userland/git/vendor/gix-pack/src/data/output/count/objects/tree.rs create mode 100644 userland/git/vendor/gix-pack/src/data/output/count/objects/types.rs create mode 100644 userland/git/vendor/gix-pack/src/data/output/count/objects/util.rs create mode 100644 userland/git/vendor/gix-pack/src/data/output/entry/iter_from_counts.rs create mode 100644 userland/git/vendor/gix-pack/src/data/output/entry/mod.rs create mode 100644 userland/git/vendor/gix-pack/src/data/output/mod.rs create mode 100644 userland/git/vendor/gix-pack/src/find.rs create mode 100644 userland/git/vendor/gix-pack/src/find_traits.rs create mode 100644 userland/git/vendor/gix-pack/src/index/access.rs create mode 100644 userland/git/vendor/gix-pack/src/index/encode.rs create mode 100644 userland/git/vendor/gix-pack/src/index/init.rs create mode 100644 userland/git/vendor/gix-pack/src/index/mod.rs create mode 100644 userland/git/vendor/gix-pack/src/index/traverse/mod.rs create mode 100644 userland/git/vendor/gix-pack/src/index/traverse/reduce.rs create mode 100644 userland/git/vendor/gix-pack/src/index/traverse/types.rs create mode 100644 userland/git/vendor/gix-pack/src/index/traverse/with_index.rs create mode 100644 userland/git/vendor/gix-pack/src/index/traverse/with_lookup.rs create mode 100644 userland/git/vendor/gix-pack/src/index/util.rs create mode 100644 userland/git/vendor/gix-pack/src/index/verify.rs create mode 100644 userland/git/vendor/gix-pack/src/index/write/mod.rs create mode 100644 userland/git/vendor/gix-pack/src/lib.rs create mode 100644 userland/git/vendor/gix-pack/src/multi_index/access.rs create mode 100644 userland/git/vendor/gix-pack/src/multi_index/chunk.rs create mode 100644 userland/git/vendor/gix-pack/src/multi_index/init.rs create mode 100644 userland/git/vendor/gix-pack/src/multi_index/mod.rs create mode 100644 userland/git/vendor/gix-pack/src/multi_index/verify.rs create mode 100644 userland/git/vendor/gix-pack/src/multi_index/write.rs create mode 100644 userland/git/vendor/gix-pack/src/testing.rs create mode 100644 userland/git/vendor/gix-pack/src/verify.rs create mode 100644 userland/git/vendor/gix-tempfile/.cargo_vcs_info.json create mode 100644 userland/git/vendor/gix-tempfile/Cargo.lock create mode 100644 userland/git/vendor/gix-tempfile/Cargo.toml create mode 100644 userland/git/vendor/gix-tempfile/Cargo.toml.orig create mode 100644 userland/git/vendor/gix-tempfile/LICENSE-APACHE create mode 100644 userland/git/vendor/gix-tempfile/LICENSE-MIT create mode 100644 userland/git/vendor/gix-tempfile/README.md create mode 100644 userland/git/vendor/gix-tempfile/src/forksafe.rs create mode 100644 userland/git/vendor/gix-tempfile/src/handle.rs create mode 100644 userland/git/vendor/gix-tempfile/src/lib.rs create mode 100644 userland/git/vendor/gix-tempfile/src/registry.rs create mode 100644 userland/git/vendor/gix-tempfile/src/signal.rs diff --git a/.gitignore b/.gitignore index 2d0690a..b588a40 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ target/ # Lock files of test and example crates: they resolve against the workspace lock. **/tests/**/Cargo.lock /examples/*/Cargo.lock + +# Cargo homes that the devshell creates where it is entered. +.cargo-home/ diff --git a/userland/git/Cargo.toml b/userland/git/Cargo.toml new file mode 100644 index 0000000..c1a0b78 --- /dev/null +++ b/userland/git/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "git" +version = "0.1.0" +edition = "2024" + +[dependencies] +clap = { version = "4", features = ["derive"] } +gix = { version = "0.90", default-features = false, features = ["sha1", "blob-diff", "revision", "index", "status", "merge", "blocking-network-client"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +base64 = "0.22" +gix-transport = { version = "0.62", features = ["http-client"] } +gix-error = "0.5" +gix-pack = { version = "0.77", default-features = false, features = ["sha1", "generate"] } +gix-zlib = "0.4" +url = "2" +wasi = "0.14" diff --git a/userland/git/README.md b/userland/git/README.md new file mode 100644 index 0000000..85babeb --- /dev/null +++ b/userland/git/README.md @@ -0,0 +1,76 @@ +# Git for WASI 0.2 + +This crate adapts the v2 Git plugin's local operations and HTTP transport. +It uses gix 0.90 and the patches in [`vendor/PATCHES.md`](vendor/PATCHES.md). +It does not use the plugin SDK, RPC dispatch, the vault, or SSH. +The host supplies credentials through standard `wasi:http`. + +## Build + +Run the build from `userland/` to include the workspace's cwd constructor: + +```sh +CARGO_TARGET_DIR=/tmp/p1-git/target nix develop .. -c cargo build -p git --release +``` + +The following commands run from the repository root. + +## Native/WASI comparison and import check + +```sh +nix develop -c nu userland/git/test.nu /tmp/p1-git/target/wasm32-wasip2/release/git.wasm +``` + +The script checks that the component imports only WASI interfaces. +It creates separate native and WASI repositories under `/tmp/p1-git/`. +It fixes both identities and timestamps. +It compares stdout and exit codes without filtering command output. +Initialization and worktree listings compare exit codes only because their paths differ. +The script retains its fixtures for inspection. + +`wasmtime run` needs `-S http`, including for local operations. +It also needs `-S cli-exit-with-code=y` to preserve Git's numeric exit codes. +The component uses WASI's `exit-with-code`, not a custom host import. +The world host must enable that optional WASI function. +The tests pass explicit `-C` paths. + +## Local smart-HTTP test + +Build the native fixture server: + +```sh +CARGO_TARGET_DIR=/tmp/p1-git/server-target nix develop -c cargo build --manifest-path userland/git/tests/http-server/Cargo.toml +``` + +The server wraps native `git http-backend` without TLS. +It accepts a repository directory and a readiness file. +The readiness file receives the bound localhost port. +Start the server in a separate terminal: + +```sh +mkdir -p /tmp/p1-git/http-repos +/tmp/p1-git/server-target/debug/git-test-http-server /tmp/p1-git/http-repos /tmp/p1-git/http-port +``` + +Use that port in the integration test: + +```sh +nix develop -c nu userland/git/http-test.nu /tmp/p1-git/target/wasm32-wasip2/release/git.wasm /tmp/p1-git/http-repos http://127.0.0.1:PORT/repo.git +``` + +Use a fresh repository name for each test run. +The script checks anonymous clone, fetch, and push through WASI. +It checks the resulting object IDs with native Git. + +## Anonymous HTTPS test + +The host handles TLS, not the guest. +Test HTTPS separately against the small public Hello-World repository: + +```sh +nix develop -c nu userland/git/public-https-test.nu /tmp/p1-git/target/wasm32-wasip2/release/git.wasm +``` + +This test clones the repository and fetches it again. +It does not need credentials. +HTTPS push requires the host's later credential injection. diff --git a/userland/git/http-test.nu b/userland/git/http-test.nu new file mode 100644 index 0000000..07d2d6e --- /dev/null +++ b/userland/git/http-test.nu @@ -0,0 +1,52 @@ +#!/usr/bin/env nu +# The HTTP server serves server-root through native git http-backend. +use std/assert +def main [component: path, server_root: path, url: string] { + let component = ($component | path expand) + let server_root = ($server_root | path expand) + let fixture = (mktemp -d -p /tmp/p1-git https.XXXXXX) + let seed = ($fixture | path join seed) + mkdir $seed + let envs = {HOME: '/tmp/p1-git/empty-home', GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'Agent', GIT_AUTHOR_EMAIL: 'agent@example.test', GIT_COMMITTER_NAME: 'Agent', GIT_COMMITTER_EMAIL: 'agent@example.test', GIT_AUTHOR_DATE: '1700000000 +0000', GIT_COMMITTER_DATE: '1700000000 +0000'} + with-env $envs { + run-native [init -b main $seed] + run-native [-C $seed config user.name Agent] + run-native [-C $seed config user.email agent@example.test] + 'base' | save ($seed | path join file) + run-native [-C $seed add .] + run-native [-C $seed commit -m base] + let remote = ($server_root | path join ($url | url parse | get path | path basename)) + run-native [clone --bare $seed $remote] + run-native [-C $remote config http.receivepack 'true'] + wasi $component $fixture [clone $url /clone] + wasi $component $fixture [-C /clone config user.name Agent] + wasi $component $fixture [-C /clone config user.email agent@example.test] + let clone = ($fixture | path join clone) + assert ((open --raw ($clone | path join file)) == 'base') + 'native' | save --force ($seed | path join file) + run-native [-C $seed commit -am native] + run-native [-C $seed push $remote main] + wasi $component $fixture [-C /clone fetch] + let expected = (^git -C $remote rev-parse main | str trim) + let actual = (^git -C $clone rev-parse origin/main | str trim) + assert ($expected == $actual) + wasi $component $fixture [-C /clone merge origin/main] + 'guest' | save --force ($clone | path join guest) + wasi $component $fixture [-C /clone add .] + wasi $component $fixture [-C /clone commit -m guest] + wasi $component $fixture [-C /clone push origin main] + let expected = (^git -C $clone rev-parse HEAD | str trim) + let actual = (^git -C $remote rev-parse main | str trim) + assert ($expected == $actual) + print 'Local smart-HTTP clone, fetch, and push: PASS' + print $'Fixture: ($fixture)' + } +} +def run-native [args: list] { + let result = (^git ...$args | complete) + if $result.exit_code != 0 {error make {msg: ($result | to json)}} +} +def wasi [component: path, fixture: path, args: list] { + let result = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($fixture)::/' --env GIT_AUTHOR_NAME --env GIT_AUTHOR_EMAIL --env GIT_COMMITTER_NAME --env GIT_COMMITTER_EMAIL --env GIT_AUTHOR_DATE --env GIT_COMMITTER_DATE $component ...$args | complete) + if $result.exit_code != 0 {error make {msg: ($result | to json)}} +} diff --git a/userland/git/public-https-test.nu b/userland/git/public-https-test.nu new file mode 100644 index 0000000..fbdbe74 --- /dev/null +++ b/userland/git/public-https-test.nu @@ -0,0 +1,16 @@ +#!/usr/bin/env nu +# The host handles TLS. Test anonymous HTTPS against a small public repository. +use std/assert +def main [component: path] { + let component = ($component | path expand) + let fixture = (mktemp -d -p /tmp/p1-git public-https.XXXXXX) + with-env {GIT_AUTHOR_NAME: 'Agent', GIT_AUTHOR_EMAIL: 'agent@example.test', GIT_COMMITTER_NAME: 'Agent', GIT_COMMITTER_EMAIL: 'agent@example.test'} { + for args in [[clone https://github.com/octocat/Hello-World.git /hello] [-C /hello config user.name Agent] [-C /hello config user.email agent@example.test] [-C /hello fetch]] { + let result = (^wasmtime run -S http -S cli-exit-with-code=y --dir $'($fixture)::/' --env GIT_AUTHOR_NAME --env GIT_AUTHOR_EMAIL --env GIT_COMMITTER_NAME --env GIT_COMMITTER_EMAIL $component ...$args | complete) + if $result.exit_code != 0 {error make {msg: ($result | to json)}} + print $'HTTPS ($args | str join " "): PASS' + } + assert (($fixture | path join hello README) | path exists) + print $'Fixture: ($fixture)' + } +} diff --git a/userland/git/src/branches.rs b/userland/git/src/branches.rs new file mode 100644 index 0000000..a8f3c4c --- /dev/null +++ b/userland/git/src/branches.rs @@ -0,0 +1,322 @@ +//! Branch/HEAD plumbing. All mutators are called under the common .git lock. +use crate::Result; +use crate::{HeadInfo, checkout::write_tree_to_worktree, head_info, repo::error}; + +/// Switch a branch, tag, or commit; optionally create a branch at HEAD. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct CheckoutParams { + /// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash). + pub repo: String, + /// Branch, tag or commit revision; with create=true, the new branch name. + pub target: String, + /// Create a new branch at HEAD and switch to it (default false). + pub create: Option, + /// Discard overwritten tracked changes, never untracked files (default false). + pub force: Option, +} + +/// Create a local branch, optionally switching the worktree to it. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct BranchCreateParams { + /// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash). + pub repo: String, + /// Short new branch name. + pub name: String, + /// Starting commit revision, default HEAD. + pub from: Option, + /// Switch to the new branch, refusing overwritten dirty changes (default false). + pub checkout: Option, +} + +/// Delete a local branch. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct BranchDeleteParams { + /// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash). + pub repo: String, + /// Short existing branch name. + pub name: String, + /// Allow deletion when not merged into HEAD (default false). Current branch is always protected. + pub force: Option, +} + +/// Result of a checkout or branch creation. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct CheckoutResult { + /// HEAD after the operation. + pub head: HeadInfo, + /// Full branch reference created, if any. + pub created: Option, + /// Unsupported worktree features (symlinks/submodules/chmod); raw content has no filters or eol conversion. + pub warnings: Vec, +} + +/// Deleted branch identity. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct BranchDeleted { + /// Full reference deleted. + pub reference: String, +} + +/// Reflogs require an identity, but switching branches must not require configuring one. +/// With no repository identity, leave reflogs untouched and report that explicitly; never invent an author. +pub(crate) fn edit_refs( + repo: &gix::Repository, + edits: Vec, +) -> Result> { + let committer = repo + .committer() + .transpose() + .map_err(|e| error("reflog identity", e))?; + if let Some(committer) = committer { + repo.edit_references_as(edits, Some(committer)) + .map_err(|e| error("checkout refs", e))?; + Ok(Vec::new()) + } else { + let mut local = repo.clone(); + local.refs.write_reflog = gix::refs::store::WriteReflog::Disable; + local + .edit_references_as(edits, None) + .map_err(|e| error("checkout refs", e))?; + Ok(vec![ + "reflog not written: no repository committer identity configured".into(), + ]) + } +} + +pub(crate) fn branch_name(name: &str) -> Result { + if name.is_empty() || name.starts_with('-') || name.starts_with("refs/") || name == "HEAD" { + return Err(error("branch name", "expected short local branch name")); + } + let full = format!("refs/heads/{name}"); + let _: gix::refs::FullName = full + .as_str() + .try_into() + .map_err(|e| error("branch name", e))?; + Ok(full) +} + +fn unused(repo: &gix::Repository, name: &str) -> Result<()> { + if repo + .try_find_reference(name) + .map_err(|e| error("branch lookup", e))? + .is_some() + { + return Err(error("branch create", "branch already exists")); + } + Ok(()) +} + +pub(crate) fn resolve(repo: &gix::Repository, rev: &str) -> Result { + Ok(repo + .rev_parse_single(rev) + .map_err(|e| error("checkout revision", e))? + .object() + .map_err(|e| error("checkout object", e))? + .peel_to_kind(gix::objs::Kind::Commit) + .map_err(|e| error("checkout commit", e))? + .id) +} + +fn old_tree(repo: &gix::Repository) -> Result> { + if repo + .head() + .map_err(|e| error("checkout HEAD", e))? + .is_unborn() + { + return Ok(None); + } + let id = resolve(repo, "HEAD")?; + Ok(Some( + repo.find_commit(id) + .map_err(|e| error("checkout HEAD", e))? + .tree_id() + .map_err(|e| error("checkout HEAD tree", e))? + .detach(), + )) +} + +fn switch( + repo: &gix::Repository, + id: gix::ObjectId, + branch: Option<&str>, + create: bool, + force: bool, +) -> Result> { + crate::merge::ensure_idle(repo)?; + if let Some(branch) = branch { + crate::worktrees::ensure_available(repo, branch)?; + } + let tree = repo + .find_commit(id) + .map_err(|e| error("checkout commit", e))? + .tree_id() + .map_err(|e| error("checkout tree", e))? + .detach(); + // Validate identity before publishing worktree/index. edit_refs resolves the same + // immutable repository config again when publishing refs, but must not discover a + // malformed identity only after files have changed. + repo.committer() + .transpose() + .map_err(|e| error("reflog identity", e))?; + let mut warnings = write_tree_to_worktree(repo, old_tree(repo)?, tree, force)?; + // Index already published. Refs last, using gix transactions (including packed refs and reflogs). + use gix::refs::{ + Target, + transaction::{Change, PreviousValue, RefEdit}, + }; + let mut edits = Vec::new(); + if create { + let name = branch.ok_or_else(|| error("checkout", "missing new branch"))?; + edits.push(RefEdit { + name: name.try_into().map_err(|e| error("branch name", e))?, + deref: false, + change: Change::Update { + log: Default::default(), + expected: PreviousValue::MustNotExist, + new: Target::Object(id), + }, + }); + } + let target = match branch { + Some(name) => Target::Symbolic(name.try_into().map_err(|e| error("branch name", e))?), + None => Target::Object(id), + }; + edits.push(RefEdit { + name: "HEAD".try_into().map_err(|e| error("HEAD", e))?, + deref: false, + change: Change::Update { + log: Default::default(), + expected: PreviousValue::Any, + new: target, + }, + }); + warnings.extend(edit_refs(repo, edits)?); + Ok(warnings) +} + +pub(crate) fn checkout(repo: &gix::Repository, p: CheckoutParams) -> Result { + crate::repo::worktree(repo)?; + let create = p.create.unwrap_or(false); + let (id, branch) = if create { + let name = branch_name(&p.target)?; + crate::worktrees::ensure_available(repo, &name)?; + unused(repo, &name)?; + (resolve(repo, "HEAD")?, Some(name)) + } else { + // Local branches take precedence over tags; tags and all other revisions detach HEAD. + let name = if p.target.starts_with("refs/heads/") { + p.target.clone() + } else { + format!("refs/heads/{}", p.target) + }; + // Revision operators are not valid reference names. Only probe a local + // branch when its full name validates; otherwise let rev_parse interpret it. + let reference = if >::try_from(name.as_str()).is_ok() { + crate::worktrees::ensure_available(repo, &name)?; + repo.try_find_reference(name.as_str()) + .map_err(|e| error("checkout branch", e))? + } else { + None + }; + if let Some(mut reference) = reference { + let id = reference + .peel_to_id() + .map_err(|e| error("checkout branch", e))? + .detach(); + (id, Some(name)) + } else { + (resolve(repo, &p.target)?, None) + } + }; + let warnings = switch( + repo, + id, + branch.as_deref(), + create, + p.force.unwrap_or(false), + )?; + Ok(CheckoutResult { + head: head_info(repo)?, + created: if create { branch } else { None }, + warnings, + }) +} + +pub(crate) fn create(repo: &gix::Repository, p: BranchCreateParams) -> Result { + let name = branch_name(&p.name)?; + if p.checkout.unwrap_or(false) { + crate::worktrees::ensure_available(repo, &name)?; + } + unused(repo, &name)?; + let id = resolve(repo, p.from.as_deref().unwrap_or("HEAD"))?; + let warnings = if p.checkout.unwrap_or(false) { + switch(repo, id, Some(&name), true, false)? + } else { + use gix::refs::{ + Target, + transaction::{Change, PreviousValue, RefEdit}, + }; + edit_refs( + repo, + vec![RefEdit { + name: name + .as_str() + .try_into() + .map_err(|e| error("branch name", e))?, + deref: false, + change: Change::Update { + log: Default::default(), + expected: PreviousValue::MustNotExist, + new: Target::Object(id), + }, + }], + )? + }; + Ok(CheckoutResult { + head: head_info(repo)?, + created: Some(name), + warnings, + }) +} + +pub(crate) fn delete(repo: &gix::Repository, p: BranchDeleteParams) -> Result { + crate::merge::ensure_idle(repo)?; + let name = branch_name(&p.name)?; + crate::worktrees::ensure_available(repo, &name)?; + if head_info(repo)?.branch.as_deref() == Some(&name) { + return Err(error("branch delete", "cannot delete current branch")); + } + let mut reference = repo + .find_reference(name.as_str()) + .map_err(|e| error("branch delete", e))?; + let tip = reference + .peel_to_id() + .map_err(|e| error("branch delete", e))? + .detach(); + if !p.force.unwrap_or(false) { + let head = resolve(repo, "HEAD")?; + // Explicit traversal avoids commit-graph mmap on WASI; includes every parent. + let walk = repo + .find_commit(head) + .map_err(|e| error("branch merged", e))? + .ancestors() + .use_commit_graph(false) + .all() + .map_err(|e| error("branch merged", e))?; + let mut merged = false; + for entry in walk { + if entry.map_err(|e| error("branch merged", e))?.id == tip { + merged = true; + break; + } + } + if !merged { + return Err(error( + "branch delete", + "branch is not merged into HEAD; use force", + )); + } + } + reference.delete().map_err(|e| error("branch delete", e))?; + Ok(BranchDeleted { reference: name }) +} diff --git a/userland/git/src/changes.rs b/userland/git/src/changes.rs new file mode 100644 index 0000000..cfe740f --- /dev/null +++ b/userland/git/src/changes.rs @@ -0,0 +1,600 @@ +//! Read-only snapshots and patches. Ordinary Git state only; never refresh/write the index. +use crate::Result; +use crate::{ + HeadInfo, head_info, + repo::{self, error}, +}; +use gix::bstr::ByteSlice; +use std::collections::{BTreeMap, BTreeSet}; + +/// Change classification. Renames are exact-content matches, not similarity guesses. +#[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq, Debug)] +#[serde(rename_all = "snake_case")] +pub enum Kind { + Added, + Modified, + Deleted, + Renamed, +} + +/// One changed repository-relative path. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct Change { + /// Destination path (old path for a deletion), invalid UTF-8 replaced. + pub path: String, + /// Classification; mode/type changes are modified. + pub status: Kind, + /// Source path for exact-content renames. + pub old_path: Option, +} +/// Repository status without index refresh or other writes. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct Status { + /// Symbolic, detached or unborn HEAD. + pub head: HeadInfo, + /// HEAD tree to stage-zero index, excluding conflicts. + pub staged: Vec, + /// Index to worktree; conflicts listed separately. + pub unstaged: Vec, + /// Individual untracked files, excluding ignored paths. + pub untracked: Vec, + /// Paths with nonzero index stages. + pub conflicted: Vec, +} +/// Diff query with mutually exclusive modes. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct DiffParams { + /// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash). + pub repo: String, + /// Old revision; must be supplied together with to, without staged=true. + pub from: Option, + /// New revision (commit, tag or tree). + pub to: Option, + /// Compare HEAD to index instead of index to worktree; default false. + pub staged: Option, + /// Literal relative file/directory filter, matches either rename endpoint. + pub path: Option, + /// Unchanged lines around each hunk; default 3. + pub context: Option, +} +/// One changed file and its patch. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct FileDiff { + /// Destination path, or old path for deletion. + pub path: String, + /// Added, modified, deleted or exact-content renamed. + pub status: Kind, + /// Rename source, otherwise null. + pub old_path: Option, + /// True for NUL-containing or non-UTF-8 content; patch is empty. + pub binary: bool, + /// Unified patch with file headers and mode/rename metadata. Empty for binary files. + pub patch: String, +} +#[derive(Clone, PartialEq, Eq)] +struct Entry { + id: gix::ObjectId, + mode: u32, +} +type Snapshot = BTreeMap, Entry>; + +fn index(repo: &gix::Repository) -> Result<(Snapshot, BTreeSet>)> { + let index = repo.index_or_empty().map_err(|e| error("index", e))?; + let mut entries = Snapshot::new(); + let mut conflicts = BTreeSet::new(); + for entry in index.entries() { + let path = entry.path(&index).to_vec(); + if entry.mode == gix::index::entry::Mode::DIR + || entry + .flags + .contains(gix::index::entry::Flags::INTENT_TO_ADD) + { + return Err(error( + "index", + "sparse-directory and intent-to-add entries are not supported by status/diff", + )); + } + if entry.stage() != gix::index::entry::Stage::Unconflicted { + conflicts.insert(path); + } else { + entries.insert( + path, + Entry { + id: entry.id, + mode: entry.mode.bits(), + }, + ); + } + } + Ok((entries, conflicts)) +} +fn tree(repo: &gix::Repository, rev: &str) -> Result { + if rev == "HEAD" && repo.head().map_err(|e| error("diff HEAD", e))?.is_unborn() { + return Ok(Snapshot::new()); + } + let obj = repo + .rev_parse_single(rev) + .map_err(|e| error("diff revision", e))? + .object() + .map_err(|e| error("diff object", e))? + .peel_to_kind(gix::objs::Kind::Tree) + .map_err(|e| error("diff tree", e))?; + let mut result = Snapshot::new(); + let mut pending = vec![(Vec::new(), obj.id)]; + while let Some((prefix, id)) = pending.pop() { + let tree = repo.find_tree(id).map_err(|e| error("diff tree", e))?; + for entry in tree.iter() { + let entry = entry.map_err(|e| error("diff entry", e))?; + let mut path = prefix.clone(); + path.extend_from_slice(entry.filename()); + if entry.mode().is_tree() { + path.push(b'/'); + pending.push((path, entry.oid().to_owned())); + } else { + result.insert( + path, + Entry { + id: entry.oid().to_owned(), + mode: entry.mode().kind() as u32, + }, + ); + } + } + } + Ok(result) +} +fn display(path: &[u8]) -> String { + String::from_utf8_lossy(path).into_owned() +} +/// Git C-style byte quoting, so patch paths remain exact even when RPC display paths are lossy. +fn quote(prefix: &str, path: &[u8]) -> String { + if prefix + .as_bytes() + .iter() + .chain(path) + .all(|b| matches!(b, 32..=126) && !matches!(b, b'"' | b'\\')) + { + return format!("{prefix}{}", display(path)); + } + let mut out = String::from("\""); + for &byte in prefix.as_bytes().iter().chain(path) { + match byte { + b'"' => out.push_str("\\\""), + b'\\' => out.push_str("\\\\"), + b'\n' => out.push_str("\\n"), + b'\r' => out.push_str("\\r"), + b'\t' => out.push_str("\\t"), + 32..=126 => out.push(byte as char), + _ => out.push_str(&format!("\\{byte:03o}")), + } + } + out.push('"'); + out +} +// Internal byte paths preserve identity even when the RPC display is lossy. +fn compare(old: &Snapshot, new: &Snapshot) -> Vec<(Vec, Option>, Kind)> { + let mut deleted: BTreeSet<_> = old + .keys() + .filter(|p| !new.contains_key(*p)) + .cloned() + .collect(); + let mut result = Vec::new(); + for (path, entry) in new { + match old.get(path) { + Some(before) if before == entry => (), + Some(_) => result.push((path.clone(), None, Kind::Modified)), + None => { + // Git does not pair empty blobs as renames by default. + let empty = + gix::objs::compute_hash(entry.id.kind(), gix::objs::Kind::Blob, b"").ok(); + let source = if Some(entry.id) == empty { + None + } else { + deleted.iter().find(|p| old.get(*p) == Some(entry)).cloned() + }; + if let Some(source) = source { + deleted.remove(&source); + result.push((path.clone(), Some(source), Kind::Renamed)); + } else { + result.push((path.clone(), None, Kind::Added)); + } + } + } + } + result.extend(deleted.into_iter().map(|p| (p, None, Kind::Deleted))); + result.sort_by(|a, b| a.0.cmp(&b.0)); + result +} +fn changes(old: &Snapshot, new: &Snapshot) -> Vec { + compare(old, new) + .into_iter() + .map(|(path, source, status)| Change { + path: display(&path), + old_path: source.map(|p| display(&p)), + status, + }) + .collect() +} +pub fn status(repo: &gix::Repository) -> Result { + repo::worktree(repo)?; + let (index, conflicts) = index(repo)?; + let mut head = tree(repo, "HEAD")?; + for path in &conflicts { + head.remove(path); + } + let mut result = Status { + head: head_info(repo)?, + staged: changes(&head, &index), + unstaged: vec![], + untracked: vec![], + conflicted: conflicts.iter().map(|p| display(p)).collect(), + }; + use gix::status::index_worktree::{Item, iter::Summary}; + let iter = repo + .status(gix::progress::Discard) + .map_err(|e| error("status", e))? + .untracked_files(gix::status::UntrackedFiles::Files) + .into_index_worktree_iter(Vec::::new()) + .map_err(|e| error("status", e))?; + for item in iter { + let item = item.map_err(|e| error("status", e))?; + let path = item.rela_path().to_str_lossy().into_owned(); + let Some(summary) = item.summary() else { + continue; + }; + if conflicts.contains(item.rela_path().as_bytes()) { + continue; + } + if matches!(item, Item::DirectoryContents { .. }) { + result.untracked.push(path); + continue; + } + if summary == Summary::Conflict { + continue; + } + result.unstaged.push(Change { + path, + old_path: None, + status: if summary == Summary::Removed { + Kind::Deleted + } else { + Kind::Modified + }, + }); + } + result.unstaged.sort_by(|a, b| a.path.cmp(&b.path)); + result.untracked.sort(); + Ok(result) +} +fn blob(repo: &gix::Repository, entry: Option<&Entry>) -> Result> { + match entry { + None => Ok(vec![]), + Some(e) if e.mode == 0o160000 => Ok(format!("Subproject commit {}\n", e.id).into_bytes()), + Some(e) => Ok(repo + .find_blob(e.id) + .map_err(|e| error("diff blob", e))? + .data + .clone()), + } +} +fn matches_path(path: &[u8], filter: Option<&str>) -> bool { + filter.is_none_or(|filter| { + path == filter.as_bytes() + || path + .strip_prefix(filter.as_bytes()) + .is_some_and(|rest| rest.starts_with(b"/")) + }) +} +// Read at most one worktree file at a time. No repository-wide blob-byte cache. +fn worktree_file( + repo: &gix::Repository, + path: &[u8], + entry: &Entry, +) -> Result)>> { + let root = repo.workdir().ok_or_else(|| error("diff", "no worktree"))?; + let caps = repo + .filesystem_options() + .map_err(|e| error("diff filesystem", e))?; + if entry.mode == 0o160000 { + return Err(error( + "diff", + "worktree submodule patches are not supported", + )); + } + let file = root.join(gix::path::from_bstr(path.as_bstr()).map_err(|e| error("diff path", e))?); + let meta = match std::fs::symlink_metadata(&file) { + Ok(m) => m, + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) => + { + return Ok(None); + } + Err(e) => return Err(error("diff stat", e)), + }; + if meta.is_dir() { + return Ok(None); + } + let bytes = if meta.file_type().is_symlink() { + gix::path::into_bstr(std::fs::read_link(&file).map_err(|e| error("diff link", e))?) + .map_err(|e| error("diff link path", e))? + .into_owned() + .to_vec() + } else { + std::fs::read(&file).map_err(|e| error("diff read", e))? + }; + let mode = if meta.file_type().is_symlink() { + 0o120000 + } else if entry.mode == 0o120000 && caps.symlink { + 0o100644 + } else { + entry.mode + }; + #[cfg(unix)] + let mode = if mode != 0o120000 && caps.executable_bit { + use std::os::unix::fs::PermissionsExt; + if meta.permissions().mode() & 0o111 != 0 { + 0o100755 + } else { + 0o100644 + } + } else { + mode + }; + let id = gix::objs::compute_hash(repo.object_hash(), gix::objs::Kind::Blob, &bytes) + .map_err(|e| error("diff hash", e))?; + Ok(Some((Entry { id, mode }, bytes))) +} +fn worktree(repo: &gix::Repository, index: &Snapshot, filter: Option<&str>) -> Result { + repo::worktree(repo)?; + let mut snapshot = index.clone(); + for (path, entry) in index { + // Filter before stat, read, or hashing (including unsupported submodules). + if !matches_path(path, filter) { + continue; + } + match worktree_file(repo, path, entry)? { + Some((entry, _bytes)) => { + snapshot.insert(path.clone(), entry); + } + None => { + snapshot.remove(path); + } + } + } + Ok(snapshot) +} +pub fn diff(p: DiffParams) -> Result> { + if let Some(path) = &p.path { + repo::validate_path(path)?; + } + let repo = repo::open(&p.repo)?; + let (old, new, work) = match (&p.from, &p.to, p.staged.unwrap_or(false)) { + (Some(a), Some(b), false) => (tree(&repo, a)?, tree(&repo, b)?, false), + (None, None, staged) => { + repo::worktree(&repo)?; + let (index, conflicts) = index(&repo)?; + if !conflicts.is_empty() { + return Err(error( + "diff", + "unmerged index: resolve conflicts first (see status.conflicted)", + )); + } + if staged { + (tree(&repo, "HEAD")?, index, false) + } else { + let new = worktree(&repo, &index, p.path.as_deref())?; + (index, new, true) + } + } + _ => { + return Err(error( + "diff", + "supply both from and to, without staged=true, or neither revision", + )); + } + }; + let mut result = Vec::new(); + for (path, source, status) in compare(&old, &new) { + let before_path = source.as_ref().unwrap_or(&path); + if !matches_path(&path, p.path.as_deref()) && !matches_path(before_path, p.path.as_deref()) + { + continue; + } + let before = old.get(before_path); + let after = new.get(&path); + let a = blob(&repo, before)?; + let b = if work { + match after { + Some(expected) => { + let (actual, bytes) = worktree_file(&repo, &path, expected)? + .ok_or_else(|| error("diff", "worktree changed during diff; retry"))?; + if &actual != expected { + return Err(error("diff", "worktree changed during diff; retry")); + } + bytes + } + None => Vec::new(), + } + } else { + blob(&repo, after)? + }; + let binary = a.contains(&0) + || b.contains(&0) + || std::str::from_utf8(&a).is_err() + || std::str::from_utf8(&b).is_err(); + let mut patch = String::new(); + if !binary { + patch.push_str(&format!( + "diff --git {} {}\n", + quote("a/", before_path), + quote("b/", &path) + )); + match (before, after) { + (None, Some(e)) => patch.push_str(&format!("new file mode {:06o}\n", e.mode)), + (Some(e), None) => patch.push_str(&format!("deleted file mode {:06o}\n", e.mode)), + (Some(a), Some(b)) if a.mode != b.mode => patch.push_str(&format!( + "old mode {:06o}\nnew mode {:06o}\n", + a.mode, b.mode + )), + _ => (), + } + if status == Kind::Renamed { + patch.push_str(&format!( + "similarity index 100%\nrename from {}\nrename to {}\n", + quote("", before_path), + quote("", &path) + )); + } + if a != b { + let old_id = before + .map(|e| e.id.to_string()) + .unwrap_or_else(|| "0".repeat(40)); + let new_id = after + .map(|e| e.id.to_string()) + .unwrap_or_else(|| "0".repeat(40)); + patch.push_str(&format!( + "index {}..{}{}\n", + &old_id[..7], + &new_id[..7], + match (before, after) { + (Some(a), Some(b)) if a.mode == b.mode => format!(" {:06o}", a.mode), + _ => String::new(), + } + )); + patch.push_str(&format!( + "--- {}\n+++ {}\n", + if before.is_some() { + quote("a/", before_path) + } else { + "/dev/null".into() + }, + if after.is_some() { + quote("b/", &path) + } else { + "/dev/null".into() + } + )); + patch.push_str(&unified( + std::str::from_utf8(&a).map_err(|e| error("diff text", e))?, + std::str::from_utf8(&b).map_err(|e| error("diff text", e))?, + p.context.unwrap_or(3) as usize, + )); + } + } + result.push(FileDiff { + path: display(&path), + old_path: source.map(|p| display(&p)), + status, + binary, + patch, + }); + } + Ok(result) +} + +/// Format imara's edit ranges, coalescing context-overlapping hunks and preserving final-newline differences. +fn unified(a: &str, b: &str, context: usize) -> String { + use gix::diff::blob::{Algorithm, Diff, InternedInput}; + let left: Vec<_> = a.split_inclusive('\n').collect(); + let right: Vec<_> = b.split_inclusive('\n').collect(); + let mut input = InternedInput::<&str>::default(); + input.update_before(left.iter().copied()); + input.update_after(right.iter().copied()); + let diff = Diff::compute(Algorithm::Myers, &input); + let hunks: Vec<_> = diff.hunks().collect(); + let mut out = String::new(); + let mut i = 0; + let line = |out: &mut String, prefix: char, s: &str| { + out.push(prefix); + out.push_str(s); + if !s.ends_with('\n') { + out.push_str("\n\\ No newline at end of file\n"); + } + }; + while i < hunks.len() { + let first = &hunks[i]; + let start_a = (first.before.start as usize).saturating_sub(context); + let start_b = (first.after.start as usize).saturating_sub(context); + let mut last = i; + while last + 1 < hunks.len() + && (hunks[last + 1].before.start as usize) + .saturating_sub(hunks[last].before.end as usize) + <= context.saturating_mul(2) + { + last += 1; + } + let end_a = (hunks[last].before.end as usize) + .saturating_add(context) + .min(left.len()); + let end_b = (hunks[last].after.end as usize) + .saturating_add(context) + .min(right.len()); + let range = |start: usize, end: usize| { + let count = end - start; + if count == 1 { + format!("{}", start + 1) + } else { + format!("{},{}", if count == 0 { start } else { start + 1 }, count) + } + }; + out.push_str(&format!( + "@@ -{} +{} @@\n", + range(start_a, end_a), + range(start_b, end_b) + )); + let mut cursor = start_a; + for h in &hunks[i..=last] { + for s in &left[cursor..h.before.start as usize] { + line(&mut out, ' ', s); + } + for s in &left[h.before.start as usize..h.before.end as usize] { + line(&mut out, '-', s); + } + for s in &right[h.after.start as usize..h.after.end as usize] { + line(&mut out, '+', s); + } + cursor = h.before.end as usize; + } + for s in &left[cursor..end_a] { + line(&mut out, ' ', s); + } + i = last + 1; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn unified_context_coalescing_and_final_newline() { + assert_eq!( + unified( + "a\nb\nc\nd\ne\nf\ng\nh\ni\n", + "a\nB\nc\nd\ne\nf\ng\nH\ni\n", + 1 + ), + "@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n@@ -7,3 +7,3 @@\n g\n-h\n+H\n i\n" + ); + assert_eq!( + unified("a\nb\nc\nd\ne\n", "a\nB\nc\nD\ne\n", 1), + "@@ -1,5 +1,5 @@\n a\n-b\n+B\n c\n-d\n+D\n e\n" + ); + assert_eq!( + unified("a\n", "a", 0), + "@@ -1 +1 @@\n-a\n+a\n\\ No newline at end of file\n" + ); + assert_eq!(unified("a\n", "a\nb\n", 0), "@@ -1,0 +2 @@\n+b\n"); + assert_eq!(unified("a\nb\n", "a\n", 0), "@@ -2 +1,0 @@\n-b\n"); + assert_eq!(unified("", "x\n", usize::MAX), "@@ -0,0 +1 @@\n+x\n"); + assert_eq!(quote("a/", b"plain/file"), "a/plain/file"); + assert_eq!(quote("", b"with space"), "with space"); + assert_eq!( + quote("a/", b"x\t\"\\\n\x01\xff"), + "\"a/x\\t\\\"\\\\\\n\\001\\377\"" + ); + } +} diff --git a/userland/git/src/checkout.rs b/userland/git/src/checkout.rs new file mode 100644 index 0000000..179ea8a --- /dev/null +++ b/userland/git/src/checkout.rs @@ -0,0 +1,424 @@ +//! Worktree writer shared by checkout and future clone/merge operations. +//! Caller must hold the repository .git lock. No refs are changed here. +use crate::Result; +use crate::repo::error; +use gix::bstr::ByteSlice; +use gix::index::{ + State, + entry::{Mode, Stat}, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::Path, +}; + +type Files = BTreeMap; + +fn tree_state(repo: &gix::Repository, tree: Option) -> Result { + match tree { + Some(id) => State::from_tree(&id, &repo.objects, Default::default()) + .map_err(|e| error("checkout tree", e)), + None => Ok(State::new(repo.object_hash())), + } +} + +fn files(state: &State) -> Result { + state + .entries() + .iter() + .map(|e| { + let path = e + .path(state) + .to_str() + .map_err(|e| error("checkout path UTF-8", e))? + .to_owned(); + crate::repo::validate_path(&path)?; + if path + .split('/') + .any(|s| s.eq_ignore_ascii_case(".git") || s.contains('\\')) + { + return Err(error("checkout path", path)); + } + Ok((path, (e.id, e.mode))) + }) + .collect() +} + +fn metadata(path: &Path) -> Result> { + match fs::symlink_metadata(path) { + Ok(m) => Ok(Some(m)), + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) => + { + Ok(None) + } + Err(e) => Err(error( + "checkout metadata", + format!("{}: {e}", path.display()), + )), + } +} + +fn regular(mode: Mode) -> bool { + mode == Mode::FILE || mode == Mode::FILE_EXECUTABLE +} + +fn clean(repo: &gix::Repository, path: &Path, expected: (gix::ObjectId, Mode)) -> Result { + let Some(meta) = metadata(path)? else { + // These entries are intentionally not materialized by this writer. + return Ok(!regular(expected.1)); + }; + if expected.1 == Mode::SYMLINK && meta.file_type().is_symlink() { + let target = fs::read_link(path).map_err(|e| error("checkout symlink", e))?; + let target = target + .to_str() + .ok_or_else(|| error("checkout symlink", "non-UTF-8 link target"))?; + return Ok(gix::objs::compute_hash( + repo.object_hash(), + gix::objs::Kind::Blob, + target.as_bytes(), + ) + .map_err(|e| error("checkout symlink hash", e))? + == expected.0); + } + if !meta.is_file() || !regular(expected.1) { + return Ok(false); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if (meta.permissions().mode() & 0o111 != 0) != (expected.1 == Mode::FILE_EXECUTABLE) { + return Ok(false); + } + } + let bytes = fs::read(path).map_err(|e| error("checkout read", e))?; + let id = gix::objs::compute_hash(repo.object_hash(), gix::objs::Kind::Blob, &bytes) + .map_err(|e| error("checkout hash", e))?; + Ok(id == expected.0) +} + +/// Verify every leaf in a directory is tracked and scheduled for removal. Never remove untracked leaves. +fn removable_dir(root: &Path, dir: &Path, removed: &BTreeSet) -> Result { + for child in fs::read_dir(dir).map_err(|e| error("checkout directory", e))? { + let child = child.map_err(|e| error("checkout directory", e))?; + let path = child.path(); + let meta = fs::symlink_metadata(&path).map_err(|e| error("checkout metadata", e))?; + if meta.is_dir() { + if !removable_dir(root, &path, removed)? { + return Ok(false); + } + } else if !path + .strip_prefix(root) + .ok() + .and_then(Path::to_str) + .is_some_and(|p| removed.contains(p)) + { + return Ok(false); + } + } + Ok(true) +} + +/// Diff trees, preflight all overwrite risks, write raw blobs, remove tracked paths and empty +/// directories, then atomically publish the index last. On I/O failure HEAD/refs remain unchanged; +/// worktree edits already completed are not rolled back. Filters/attributes are not applied. +/// Unrelated staged and unstaged edits survive a non-force switch. Force resets tracked paths, +/// but never overwrites/deletes an untracked file or follows a symlink directory. +pub(crate) fn write_tree_to_worktree( + repo: &gix::Repository, + old_tree: Option, + new_tree: gix::ObjectId, + force: bool, +) -> Result> { + write_tree(repo, old_tree, new_tree, force, false) +} + +/// Abort discards tracked changes without refusing dirty files. Paths no longer in the +/// index are untracked, even if HEAD once contained them. Preserve any such obstruction, +/// restoring the HEAD index but leaving a warning/local difference rather than losing bytes. +pub(crate) fn abort_to_tree(repo: &gix::Repository, tree: gix::ObjectId) -> Result> { + write_tree(repo, None, tree, true, true) +} +fn write_tree( + repo: &gix::Repository, + old_tree: Option, + new_tree: gix::ObjectId, + force: bool, + preserve_obstructions: bool, +) -> Result> { + let root = repo + .workdir() + .ok_or_else(|| error("checkout", "bare repository"))?; + let old = files(&tree_state(repo, old_tree)?)?; + let mut state = tree_state(repo, Some(new_tree))?; + let new = files(&state)?; + let index_path = repo.git_dir().join("index"); + let existing = if index_path + .try_exists() + .map_err(|e| error("checkout index", e))? + { + Some( + gix::index::File::at(&index_path, repo.object_hash(), false, Default::default()) + .map_err(|e| error("checkout index", e))?, + ) + } else { + None + }; + if existing.as_ref().is_some_and(|i| i.is_sparse()) { + return Err(error( + "checkout index", + "sparse indexes are unsupported; expand the index before checkout", + )); + } + let indexed = existing + .as_ref() + .map(|i| files(i)) + .transpose()? + .unwrap_or_default(); + let mut affected: BTreeSet = old + .keys() + .chain(new.keys()) + .filter(|p| force || old.get(*p) != new.get(*p)) + .cloned() + .collect(); + if force { + affected.extend(indexed.keys().cloned()); + } else { + // A staged addition can conflict structurally even when missing from the worktree. + // Include index ancestors/descendants of changed paths, so it cannot silently become a D/F conflict. + let changed = affected.clone(); + affected.extend( + indexed + .keys() + .filter(|p| { + let prefix = format!("{p}/"); + changed + .range(prefix.clone()..) + .next() + .is_some_and(|q| q.starts_with(&prefix)) + || Path::new(p) + .ancestors() + .skip(1) + .filter_map(Path::to_str) + .any(|q| changed.contains(q)) + }) + .cloned(), + ); + } + let conflicts: BTreeSet<_> = existing + .as_ref() + .map(|i| { + i.entries() + .iter() + .filter(|e| e.stage_raw() != 0) + .map(|e| e.path(i).to_str_lossy().into_owned()) + .collect() + }) + .unwrap_or_default(); + let tracked: BTreeSet<_> = old.keys().chain(indexed.keys()).cloned().collect(); + let removed: BTreeSet<_> = affected + .iter() + .filter(|p| !new.get(*p).is_some_and(|e| regular(e.1))) + .cloned() + .collect(); + let mut dirty = BTreeSet::new(); + let mut blocked = BTreeSet::new(); + let mut warnings = Vec::new(); + // Validate paths in every source before touching anything, including index entries. + for path in &affected { + let dest = root.join(path); + // A symlink/file parent may be replaced only if tracked and scheduled for deletion. + let mut parent = Path::new(path).parent(); + while let Some(p) = parent.filter(|p| !p.as_os_str().is_empty()) { + if let Some(m) = metadata(&root.join(p))? { + if m.file_type().is_symlink() + || (!m.is_dir() + && !removed.contains(p.to_str().unwrap_or("")) + && new.get(path).is_some_and(|e| regular(e.1))) + { + blocked.insert(path.clone()); + } + } + parent = p.parent(); + } + // Inspect the actual worktree: a removed tracked ancestor may have been replaced + // by a directory containing untracked files. A real file parent yields None via + // metadata's NotADirectory handling, without hiding existing descendants. + let meta = metadata(&dest)?; + if let Some(m) = &meta { + if m.is_dir() && new.get(path).is_some_and(|e| regular(e.1)) { + if !removable_dir(root, &dest, &removed)? { + blocked.insert(path.clone()); + } + } else if !m.is_dir() && !tracked.contains(path) { + blocked.insert(path.clone()); + } + } + if !force { + if indexed.get(path) != old.get(path) && existing.is_some() { + dirty.insert(path.clone()); + } + if conflicts.contains(path) { + dirty.insert(path.clone()); + } + if let Some(expected) = old.get(path) { + if !clean(repo, &dest, *expected)? { + dirty.insert(path.clone()); + } + } + } + } + if !blocked.is_empty() { + let paths = blocked.iter().cloned().collect::>().join(", "); + if preserve_obstructions { + warnings.push(format!("abort preserved untracked/obstructing paths; HEAD index restored but worktree differs: {paths}")); + } else { + return Err(error( + "checkout untracked/obstructing paths (also protected with force)", + paths, + )); + } + } + if !dirty.is_empty() { + return Err(error( + "checkout would overwrite uncommitted changes", + dirty.into_iter().collect::>().join(", "), + )); + } + // Read every required blob before beginning mutations (missing/corrupt objects must not partially checkout). + let mut blobs = BTreeMap::new(); + for path in &affected { + if blocked.contains(path) { + continue; + } + if let Some((id, mode)) = new.get(path) { + if regular(*mode) { + blobs.insert( + path.clone(), + repo.find_blob(*id) + .map_err(|e| error("checkout blob", e))? + .data + .to_vec(), + ); + } else { + warnings.push(format!( + "{path}: symlink/submodule unsupported; skipped worktree materialization" + )); + } + } + } + // Remove old tracked leaves first, deepest first; never recursively delete a directory. + let mut deletions: Vec<_> = affected + .iter() + .filter(|p| tracked.contains(*p) && !blocked.contains(*p)) + .collect(); + deletions.sort_by_key(|p| std::cmp::Reverse(p.len())); + for path in deletions { + let dest = root.join(path); + // Ancestors may have been removed already (only affects structurally conflicting staged entries). + match fs::symlink_metadata(&dest) { + Ok(m) if !m.is_dir() => { + fs::remove_file(&dest).map_err(|e| error("checkout remove", e))? + } + Ok(_) => {} + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) => {} + Err(e) => return Err(error("checkout remove", e)), + } + prune_empty(root, dest.parent()); + } + for (path, bytes) in blobs { + let dest = root.join(&path); + if dest.is_dir() { + remove_empty_dirs(&dest)?; + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).map_err(|e| error("checkout mkdir", e))?; + } + fs::write(&dest, bytes).map_err(|e| error("checkout write", e))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = if new[&path].1 == Mode::FILE_EXECUTABLE { + 0o755 + } else { + 0o644 + }; + fs::set_permissions(&dest, fs::Permissions::from_mode(mode)) + .map_err(|e| error("checkout chmod", e))?; + } + } + #[cfg(not(unix))] + if affected + .iter() + .any(|p| new.get(p).is_some_and(|e| e.1 == Mode::FILE_EXECUTABLE)) + { + warnings.push( + "executable bits preserved in index; filesystem chmod unavailable on this target" + .into(), + ); + } + // Preserve unrelated staging, including staged additions, deletions, and conflict stages. + if let Some(index) = &existing { + state.remove_entries(|_, path, _| !affected.contains(path.to_str().unwrap_or(""))); + for entry in index.entries() { + if !affected.contains(entry.path(index).to_str().unwrap_or("")) { + state.dangerously_push_entry( + entry.stat, + entry.id, + entry.flags, + entry.mode, + entry.path(index), + ); + } + } + state.sort_entries(); + state.remove_tree(); // Unrelated staging may differ from the target tree. + } + for (entry, path) in state.entries_mut_with_paths() { + if affected.contains(path.to_str().unwrap_or("")) + && !blocked.contains(path.to_str().unwrap_or("")) + && regular(entry.mode) + { + let stat = gix::index::fs::Metadata::from_path_no_follow( + &root.join(path.to_str().map_err(|e| error("checkout path", e))?), + ) + .map_err(|e| error("checkout stat", e))?; + entry.stat = Stat::from_fs(&stat).map_err(|e| error("checkout stat", e))?; + } + } + gix::index::File::from_state(state, index_path) + .write(Default::default()) + .map_err(|e| error("checkout index write", e))?; + Ok(warnings) +} + +fn prune_empty(root: &Path, mut parent: Option<&Path>) { + while let Some(p) = parent.filter(|p| *p != root && p.starts_with(root)) { + if fs::remove_dir(p).is_err() { + break; + } + parent = p.parent(); + } +} + +fn remove_empty_dirs(path: &Path) -> Result<()> { + for child in fs::read_dir(path).map_err(|e| error("checkout directory", e))? { + let child = child.map_err(|e| error("checkout directory", e))?; + if child + .file_type() + .map_err(|e| error("checkout directory", e))? + .is_dir() + { + remove_empty_dirs(&child.path())?; + } + } + fs::remove_dir(path).map_err(|e| error("checkout directory not empty", e)) +} diff --git a/userland/git/src/head.inc b/userland/git/src/head.inc new file mode 100644 index 0000000..10c04db --- /dev/null +++ b/userland/git/src/head.inc @@ -0,0 +1,8 @@ +/// Read HEAD without mistaking an unborn branch for an I/O error. +fn head_info(repo: &gix::Repository) -> Result { + let head = repo.head().map_err(|e| error("HEAD", e))?; + Ok(HeadInfo { detached: head.is_detached(), unborn: head.is_unborn(), + branch: head.referent_name().map(|name| name.as_bstr().to_str_lossy().into_owned()), + id: head.id().map(|id| id.to_string()) }) +} + diff --git a/userland/git/src/http.rs b/userland/git/src/http.rs new file mode 100644 index 0000000..3f74f26 --- /dev/null +++ b/userland/git/src/http.rs @@ -0,0 +1,431 @@ +//! Synchronous wasi:http backend. Response packs stream; replayable request bodies spool to the current directory. +//! No executor/block_on, worker threads, signal handling, or secret-bearing diagnostics. +use gix_error::ErrorExt; +use gix_transport::client::blocking_io::http::{GetResponse, Http, PostBodyDataKind, PostResponse}; +use std::{ + cell::RefCell, + fs::File, + io::{self, BufRead, BufReader, Cursor, Read, Seek, Write}, + rc::Rc, +}; +use wasi::http::{outgoing_handler, types::*}; + +type Shared = Rc>; +pub struct WasiHttp { + timeout: Option, + redirect: Rc>>, +} +impl Default for WasiHttp { + fn default() -> Self { + Self { + timeout: None, + redirect: Default::default(), + } + } +} +fn err(text: impl Into) -> io::Error { + io::Error::other(text.into()) +} + +/// The host injects credentials. Git accepts credential-free HTTP and HTTPS URLs. +pub fn clean_url(input: &str) -> io::Result { + let u = url::Url::parse(input).map_err(|_| err("invalid HTTP URL"))?; + if !matches!(u.scheme(), "http" | "https") + || u.host_str().is_none() + || !u.username().is_empty() + || u.password().is_some() + || u.query().is_some() + || u.fragment().is_some() + { + return Err(err( + "use an HTTP or HTTPS repository URL without credentials, query, or fragment", + )); + } + Ok(u) +} + +fn status_error(status: u16, host: &str) -> io::Error { + // Other rather than PermissionDenied prevents external credential helpers. + err(format!("HTTP {status} from {host}")) +} + +struct Incoming { + stream: Option, + body: Option, +} +impl Read for Incoming { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if buf.is_empty() { + return Ok(0); + } + let Some(stream) = self.stream.as_ref() else { + return Ok(0); + }; + loop { + match stream.blocking_read(buf.len().min(64 * 1024) as u64) { + Ok(bytes) if bytes.is_empty() => { + stream.subscribe().block(); + continue; + } + Ok(bytes) => { + buf[..bytes.len()].copy_from_slice(&bytes); + return Ok(bytes.len()); + } + Err(wasi::io::streams::StreamError::Closed) => { + self.stream.take(); + if let Some(body) = self.body.take() { + let trailers = IncomingBody::finish(body); + trailers.subscribe().block(); + match trailers.get() { + Some(Ok(Ok(_))) => {} + _ => return Err(err("HTTP response body failed")), + } + } + return Ok(0); + } + Err(_) => return Err(err("HTTP response read failed or timed out")), + } + } + } +} +impl Drop for Incoming { + fn drop(&mut self) { + self.stream.take(); + self.body.take(); + } +} + +// State is shared only within one synchronous request, not across handlers/processes. +struct Pending { + timeout: Option, + url: String, + base: String, + headers: Vec, + spool: Option, + path: Option, + writer_open: bool, + response_headers: Option>>, + response: Option>>, + redirect: Rc>>, +} +impl Drop for Pending { + fn drop(&mut self) { + self.spool.take(); + if let Some(path) = self.path.take() { + let _ = std::fs::remove_file(&path); + } + } +} +impl Pending { + fn start(&mut self) -> io::Result<()> { + if self.response.is_some() { + return Ok(()); + } + if self.writer_open { + return Err(err( + "HTTP request body must be closed before reading response", + )); + } + let mut target = url::Url::parse(&self.url).map_err(|_| err("invalid HTTP request URL"))?; + for attempt in 0..2 { + let response = self.send(&target)?; + let status = response.status(); + if matches!(status, 301 | 302 | 303 | 307 | 308) { + if attempt == 1 { + return Err(err("HTTP redirect limit (one) exceeded")); + } + let locations = response.headers().get(&"location".to_string()); + let location = locations + .first() + .and_then(|v| std::str::from_utf8(v).ok()) + .ok_or_else(|| err("HTTP redirect has no valid Location"))?; + let next = target + .join(location) + .map_err(|_| err("invalid HTTP redirect"))?; + if !matches!(next.scheme(), "http" | "https") + || (target.scheme() == "https" && next.scheme() != "https") + || !next.username().is_empty() + || next.password().is_some() + { + return Err(err("redirect requires an HTTPS URL without credentials")); + } + // Preserve the git endpoint suffix so subsequent POSTs use the new repository base. + let tail = self + .url + .strip_prefix(&self.base) + .ok_or_else(|| err("invalid HTTP base"))?; + let next_text = next.as_str(); + let base = next_text + .strip_suffix(tail) + .ok_or_else(|| err("redirect changed the Git endpoint suffix"))?; + *self.redirect.borrow_mut() = Some(base.to_owned()); + target = next; + continue; + } + if !(200..300).contains(&status) { + return Err(status_error(status, target.host_str().unwrap_or("unknown"))); + } + let mut headers = Vec::new(); + for (name, value) in response.headers().entries() { + headers.extend_from_slice(name.as_bytes()); + headers.extend_from_slice(b": "); + headers.extend_from_slice(&value); + headers.push(b'\n'); + } + let body = response + .consume() + .map_err(|_| err("could not consume HTTP response"))?; + let stream = body + .stream() + .map_err(|_| err("could not open HTTP response stream"))?; + self.response_headers = Some(Cursor::new(headers)); + self.response = Some(BufReader::new(Box::new(Incoming { + stream: Some(stream), + body: Some(body), + }))); + return Ok(()); + } + Err(err("HTTP redirect failed")) + } + fn send(&mut self, target: &url::Url) -> io::Result { + let fields = Fields::new(); + for header in &self.headers { + let (name, value) = header + .split_once(':') + .ok_or_else(|| err("invalid HTTP header"))?; + // Never accept Authorization from Git config or URL. + if name.eq_ignore_ascii_case("authorization") { + continue; + } + fields + .append(&name.to_string(), &value.trim().as_bytes().to_vec()) + .map_err(|_| err("invalid HTTP header"))?; + } + if let Some(file) = self.spool.as_ref() { + fields + .append( + "content-length", + &file.metadata()?.len().to_string().into_bytes(), + ) + .map_err(|_| err("invalid content length"))?; + } + let request = OutgoingRequest::new(fields); + request + .set_method(&if self.spool.is_some() { + Method::Post + } else { + Method::Get + }) + .map_err(|_| err("invalid HTTP method"))?; + let scheme = if target.scheme() == "https" { + Scheme::Https + } else { + Scheme::Http + }; + request + .set_scheme(Some(&scheme)) + .map_err(|_| err("invalid HTTP scheme"))?; + let authority = &target[url::Position::BeforeHost..url::Position::AfterPort]; + request + .set_authority(Some(authority)) + .map_err(|_| err("invalid HTTP authority"))?; + request + .set_path_with_query(Some( + &target[url::Position::BeforePath..url::Position::AfterQuery], + )) + .map_err(|_| err("invalid HTTP path"))?; + let body = request + .body() + .map_err(|_| err("could not open HTTP request body"))?; + let options = RequestOptions::new(); + options + .set_connect_timeout(Some( + self.timeout.map_or(120_000_000_000, |s| s * 1_000_000_000), + )) + .map_err(|_| err("connect timeout unsupported"))?; + options + .set_first_byte_timeout(Some( + self.timeout.map_or(120_000_000_000, |s| s * 1_000_000_000), + )) + .map_err(|_| err("read timeout unsupported"))?; + options + .set_between_bytes_timeout(Some( + self.timeout.map_or(120_000_000_000, |s| s * 1_000_000_000), + )) + .map_err(|_| err("read timeout unsupported"))?; + let future = outgoing_handler::handle(request, Some(options)) + .map_err(|_| err("HTTP connection failed"))?; + if let Some(file) = self.spool.as_mut() { + file.rewind()?; + let stream = body.write().map_err(|_| err("could not write HTTP body"))?; + let mut bytes = [0; 4096]; // WASI blocking_write_and_flush accepts at most 4096 bytes + loop { + let n = file.read(&mut bytes)?; + if n == 0 { + break; + } + stream + .blocking_write_and_flush(&bytes[..n]) + .map_err(|_| err("HTTP request write failed or timed out"))?; + } + drop(stream); + } + OutgoingBody::finish(body, None).map_err(|_| err("HTTP request finish failed"))?; + future.subscribe().block(); + future + .get() + .ok_or_else(|| err("HTTP response not ready"))? + .map_err(|_| err("HTTP response already consumed"))? + .map_err(|code| err(format!("HTTP request failed: {code:?}"))) + } +} + +pub struct Headers(Shared); +pub struct Response(Shared, Vec, usize); +pub struct PostBody(Shared); +impl Write for PostBody { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0 + .borrow_mut() + .spool + .as_mut() + .ok_or_else(|| err("request closed"))? + .write(bytes) + } + fn flush(&mut self) -> io::Result<()> { + self.0 + .borrow_mut() + .spool + .as_mut() + .ok_or_else(|| err("request closed"))? + .flush() + } +} +impl Drop for PostBody { + fn drop(&mut self) { + self.0.borrow_mut().writer_open = false; + } +} +impl Read for Headers { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let mut p = self.0.borrow_mut(); + p.start()?; + p.response_headers + .as_mut() + .ok_or_else(|| err("missing headers"))? + .read(buf) + } +} +impl Read for Response { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let available = self.fill_buf()?; + let n = available.len().min(buf.len()); + buf[..n].copy_from_slice(&available[..n]); + self.consume(n); + Ok(n) + } +} +impl BufRead for Response { + fn fill_buf(&mut self) -> io::Result<&[u8]> { + if self.2 == self.1.len() { + let mut p = self.0.borrow_mut(); + p.start()?; + self.1.resize(16 * 1024, 0); + let n = p + .response + .as_mut() + .ok_or_else(|| err("missing response"))? + .read(&mut self.1)?; + self.1.truncate(n); + self.2 = 0; + } + Ok(&self.1[self.2..]) + } + fn consume(&mut self, n: usize) { + self.2 = (self.2 + n).min(self.1.len()); + } +} +impl WasiHttp { + fn pending( + &self, + url: &str, + base: &str, + headers: impl IntoIterator>, + post: bool, + ) -> io::Result { + let (spool, path) = if post { + static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let dir = "."; + let path = std::path::PathBuf::from(format!( + "{dir}/git-http-{}", + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + ( + Some( + File::options() + .read(true) + .write(true) + .create_new(true) + .open(&path)?, + ), + Some(path), + ) + } else { + (None, None) + }; + Ok(Rc::new(RefCell::new(Pending { + timeout: self.timeout, + url: url.into(), + base: base.into(), + headers: headers.into_iter().map(|s| s.as_ref().to_owned()).collect(), + spool, + path, + writer_open: post, + response_headers: None, + response: None, + redirect: self.redirect.clone(), + }))) + } +} +impl Http for WasiHttp { + type Headers = BufReader; + type ResponseBody = Response; + type PostBody = PostBody; + fn get( + &mut self, + url: &str, + base: &str, + headers: impl IntoIterator>, + ) -> gix_error::Result> { + let p = self + .pending(url, base, headers, false) + .map_err(|e| e.raise())?; + p.borrow_mut().start().map_err(|e| e.raise())?; + Ok(GetResponse { + headers: BufReader::new(Headers(p.clone())), + body: Response(p, vec![], 0), + }) + } + fn post( + &mut self, + url: &str, + base: &str, + headers: impl IntoIterator>, + _: PostBodyDataKind, + ) -> gix_error::Result> { + let p = self + .pending(url, base, headers, true) + .map_err(|e| e.raise())?; + Ok(PostResponse { + headers: BufReader::new(Headers(p.clone())), + body: Response(p.clone(), vec![], 0), + post_body: PostBody(p), + }) + } + fn configure(&mut self, _: &dyn std::any::Any) -> gix_error::Result<()> { + Ok(()) + } + fn redirected_base_url(&self) -> Option { + self.redirect.borrow().clone() + } +} diff --git a/userland/git/src/lib.rs b/userland/git/src/lib.rs new file mode 100644 index 0000000..8d0e2ec --- /dev/null +++ b/userland/git/src/lib.rs @@ -0,0 +1,827 @@ +//! Local Git operations adapted from the v2 gix plugin, without RPC or host imports. +#![allow(dead_code)] +mod branches; +mod changes; +mod checkout; +mod http; +mod merge; +mod network; +mod output; +mod push; +mod rebase; +mod remotes; +mod repo; +mod staging; +mod worktrees; +use gix::bstr::ByteSlice; +use repo::error; +#[derive(Debug)] +pub struct Error { + pub message: String, +} +impl Error { + pub fn new(_: i32, message: String) -> Self { + Self { message } + } +} +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} +impl std::error::Error for Error {} +pub type Result = std::result::Result; +#[derive(serde::Serialize, serde::Deserialize)] +pub struct HeadInfo { + detached: bool, + unborn: bool, + branch: Option, + id: Option, +} +include!("head.inc"); +struct Identity { + name: Option, + email: Option, +} +fn identity(name: Option, email: Option) -> Result { + let repo = gix::discover(".").map_err(|e| error("identity", e))?; + let config = repo.config_snapshot(); + Ok(Identity { + name: name + .or_else(|| std::env::var("GIT_AUTHOR_NAME").ok()) + .or_else(|| config.string("user.name").map(|s| s.to_string())), + email: email + .or_else(|| std::env::var("GIT_AUTHOR_EMAIL").ok()) + .or_else(|| config.string("user.email").map(|s| s.to_string())), + }) +} +use clap::{Parser, Subcommand}; +#[derive(Parser)] +#[command(name = "git", disable_help_subcommand = true)] +pub struct Cli { + #[arg(short = 'C', global = true)] + directory: Option, + #[command(subcommand)] + command: Command, +} +#[derive(Subcommand)] +enum Command { + Show { + revision: Option, + }, + Clone { + url: String, + path: Option, + }, + Fetch { + #[arg(default_value = "origin")] + remote: String, + }, + Push { + #[arg(short = 'u', long)] + set_upstream: bool, + #[arg(short = 'f', long)] + force: bool, + #[arg(default_value = "origin")] + remote: String, + refspec: Option, + }, + Init { + #[arg(long)] + bare: bool, + #[arg(short = 'b', default_value = "main")] + initial_branch: String, + path: Option, + }, + Config { + #[arg(long)] + local: bool, + key: String, + value: Option, + }, + RevParse { + #[arg(long)] + show_toplevel: bool, + #[arg(long)] + git_dir: bool, + #[arg(long)] + abbrev_ref: bool, + revision: Option, + }, + Status { + #[arg(short, long)] + short: bool, + #[arg(long)] + porcelain: bool, + }, + Add { + #[arg(short = 'A', long)] + all: bool, + paths: Vec, + }, + Rm { + #[arg(long)] + cached: bool, + paths: Vec, + }, + Commit { + #[arg(short = 'm')] + message: String, + #[arg(short, long)] + all: bool, + #[arg(long)] + allow_empty: bool, + }, + Log { + #[arg(long)] + oneline: bool, + #[arg(short = 'n', default_value = "50")] + limit: usize, + revision: Option, + }, + Diff { + #[arg(long, alias = "staged")] + cached: bool, + #[arg(long)] + stat: bool, + }, + Branch { + #[arg(short = 'd')] + delete: bool, + #[arg(short = 'D')] + force_delete: bool, + name: Option, + start: Option, + }, + Switch { + #[arg(short = 'c')] + create: bool, + target: String, + }, + Checkout { + #[arg(short = 'b')] + create: bool, + target: String, + }, + Merge { + #[arg(long)] + ff_only: bool, + #[arg(long)] + no_ff: bool, + #[arg(long)] + no_commit: bool, + #[arg(long)] + abort: bool, + #[arg(short = 'm')] + message: Option, + revision: Option, + }, + Rebase { + #[arg(long)] + abort: bool, + #[arg(long = "continue")] + resume: bool, + #[arg(long)] + skip: bool, + upstream: Option, + }, + Worktree { + #[command(subcommand)] + command: Worktree, + }, +} +#[derive(Subcommand)] +enum Worktree { + List { + #[arg(long)] + porcelain: bool, + }, + Add { + #[arg(short = 'b')] + branch: Option, + path: String, + revision: Option, + }, + Remove { + #[arg(long)] + force: bool, + path: String, + }, +} +fn required(value: Option, what: &str) -> Result { + value.ok_or_else(|| error(what, "argument required")) +} +pub fn run(cli: Cli) -> Result { + if let Some(dir) = cli.directory { + std::env::set_current_dir(dir).map_err(|e| error("chdir", e))?; + } + if let Command::Init { + bare, + initial_branch, + path, + } = cli.command + { + let path = path.unwrap_or_else(|| ".".into()); + let repo = if bare { + gix::init_bare(&path) + } else { + gix::init(&path) + } + .map_err(|e| error("init", e))?; + std::fs::write( + repo.git_dir().join("HEAD"), + format!("ref: refs/heads/{initial_branch}\n"), + ) + .map_err(|e| error("init HEAD", e))?; + println!( + "Initialized empty Git repository in {}/", + repo.git_dir() + .canonicalize() + .map_err(|e| error("init", e))? + .display() + ); + return Ok(0); + } + if let Command::Clone { url, path } = cli.command { + let path = path.unwrap_or_else(|| { + url.trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or("repo") + .trim_end_matches(".git") + .to_owned() + }); + network::clone(&url, &path)?; + return Ok(0); + } + let discovered = gix::discover(".").map_err(|e| error("repository", e))?; + let root = discovered + .workdir() + .unwrap_or(discovered.git_dir()) + .to_owned(); + std::env::set_current_dir(&root).map_err(|e| error("chdir", e))?; + let path = root.to_string_lossy().into_owned(); + let repository = repo::open(&path)?; + match cli.command { + Command::Config { key, value, .. } => { + let file = repository.common_dir().join("config"); + if let Some(value) = value { + let mut config = gix::config::File::from_path_no_includes( + file.clone(), + gix::config::Source::Local, + ) + .map_err(|e| error("config", e))?; + config + .set_raw_value(&key, value.as_bytes().as_bstr()) + .map_err(|e| error("config", e))?; + let mut output = std::fs::File::create(file).map_err(|e| error("config", e))?; + config + .write_to(&mut output) + .map_err(|e| error("config", e))?; + } else if let Some(value) = repository.config_snapshot().string(&key) { + println!("{value}"); + } else { + return Ok(1); + } + } + Command::RevParse { + show_toplevel, + git_dir, + abbrev_ref, + revision, + } => { + if show_toplevel { + println!("{}", root.display()); + } else if git_dir { + println!(".git"); + } else if abbrev_ref { + println!( + "{}", + head_info(&repository)? + .branch + .unwrap_or_else(|| "HEAD".into()) + .trim_start_matches("refs/heads/") + ); + } else { + println!( + "{}", + repository + .rev_parse_single(required(revision, "revision")?.as_str()) + .map_err(|e| error("revision", e))? + ); + } + } + Command::Add { all, mut paths } => { + let all = all || paths.iter().any(|p| p == "."); + paths.retain(|p| p != "."); + staging::add( + &repository, + staging::AddParams { + repo: path, + paths, + all: Some(all), + }, + )?; + } + Command::Rm { cached, paths } => { + let result = staging::rm( + &repository, + staging::RmParams { + repo: path, + paths, + keep_file: Some(cached), + }, + )?; + for path in result.paths { + println!("rm '{path}'"); + } + } + Command::Commit { + message, + all, + allow_empty, + } => { + let before = head_info(&repository)?; + if !allow_empty && merge::pending_parent(&repository)?.is_none() { + let status = changes::status(&repository)?; + if status.staged.is_empty() + && (!all || status.unstaged.is_empty()) + && status.conflicted.is_empty() + { + output::long_status(&status); + return Ok(1); + } + } + let result = staging::commit( + &repository, + staging::CommitParams { + repo: path.clone(), + message: format!("{message}\n"), + author_name: None, + author_email: None, + all: Some(all), + amend: None, + allow_empty: Some(allow_empty), + }, + )?; + println!( + "[{}{} {}] {}", + before + .branch + .unwrap_or_else(|| "HEAD".into()) + .trim_start_matches("refs/heads/"), + if before.unborn { " (root-commit)" } else { "" }, + &result.id[..7], + message + ); + let old = match before.id { + Some(id) => id, + None => repository + .write_object(&gix::objs::Tree { entries: vec![] }) + .map_err(|e| error("commit summary", e))? + .to_string(), + }; + output::commit_stat(&changes::diff(changes::DiffParams { + repo: path, + from: Some(old), + to: Some(result.id), + staged: None, + path: None, + context: None, + })?); + } + Command::Status { short, porcelain } => { + let mut status = changes::status(&repository)?; + let index = repository + .index_or_empty() + .map_err(|e| error("status index", e))?; + let tracked: Vec<_> = index + .entries() + .iter() + .map(|e| e.path(&index).to_str_lossy().into_owned()) + .collect(); + let mut untracked = std::collections::BTreeSet::new(); + for path in status.untracked { + let collapsed = path + .match_indices('/') + .map(|(i, _)| &path[..=i]) + .find(|prefix| !tracked.iter().any(|t| t.starts_with(*prefix))) + .unwrap_or(&path); + untracked.insert(collapsed.to_owned()); + } + status.untracked = untracked.into_iter().collect(); + if !short && !porcelain { + output::long_status(&status); + return Ok(0); + } + let mut rows = std::collections::BTreeMap::new(); + for c in status.staged { + rows.entry(c.path).or_insert([' '; 2])[0] = code(&c.status); + } + for c in status.unstaged { + rows.entry(c.path).or_insert([' '; 2])[1] = code(&c.status); + } + for p in status.untracked { + rows.insert(p, ['?', '?']); + } + for p in status.conflicted { + rows.insert(p, ['U', 'U']); + } + for (p, c) in rows { + println!("{}{} {p}", c[0], c[1]); + } + } + Command::Diff { cached, stat } => { + let diffs = changes::diff(changes::DiffParams { + repo: path, + from: None, + to: None, + staged: Some(cached), + path: None, + context: None, + })?; + if stat { + output::stat(&diffs, false); + } else { + for diff in diffs { + print!("{}", diff.patch); + } + } + } + Command::Log { + oneline, + limit, + revision, + } => { + let commit = repository + .rev_parse_single(revision.as_deref().unwrap_or("HEAD")) + .map_err(|e| error("log", e))? + .object() + .map_err(|e| error("log", e))? + .peel_to_commit() + .map_err(|e| error("log", e))?; + let mut first = true; + for info in commit + .ancestors() + .use_commit_graph(false) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + Default::default(), + )) + .all() + .map_err(|e| error("log", e))? + .take(limit) + { + let c = info + .map_err(|e| error("log", e))? + .object() + .map_err(|e| error("log", e))?; + let decoded = c.decode().map_err(|e| error("log", e))?; + let message = decoded.message.to_str_lossy(); + if oneline { + println!( + "{} {}", + &c.id.to_string()[..7], + message.lines().next().unwrap_or("") + ); + } else { + if !first { + println!(); + } + output::commit(&c)?; + first = false; + } + } + } + Command::Branch { + delete, + force_delete, + name, + start, + } => { + if let Some(name) = name { + if delete || force_delete { + branches::delete( + &repository, + branches::BranchDeleteParams { + repo: path, + name, + force: Some(force_delete), + }, + )?; + } else { + branches::create( + &repository, + branches::BranchCreateParams { + repo: path, + name, + from: start, + checkout: Some(false), + }, + )?; + } + } else { + let head = head_info(&repository)?; + for branch in repository + .references() + .map_err(|e| error("branch", e))? + .local_branches() + .map_err(|e| error("branch", e))? + { + let branch = branch.map_err(|e| error("branch", e))?; + println!( + "{} {}", + if head.branch.as_deref() + == Some(branch.name().as_bstr().to_str_lossy().as_ref()) + { + "*" + } else { + " " + }, + branch.name().shorten() + ); + } + } + } + Command::Switch { create, target } | Command::Checkout { create, target } => { + branches::checkout( + &repository, + branches::CheckoutParams { + repo: path, + target, + create: Some(create), + force: None, + }, + )?; + } + Command::Merge { + ff_only, + no_ff, + no_commit, + abort, + message, + revision, + } => { + let old = head_info(&repository)?.id; + let message = + message.or_else(|| revision.as_ref().map(|r| format!("Merge branch '{r}'\n"))); + let r = if abort { + merge::abort(&repository)? + } else { + merge::merge( + &repository, + merge::MergeParams { + repo: path.clone(), + rev: required(revision, "merge")?, + message, + ff: Some(if ff_only { + merge::FastForward::Only + } else if no_ff { + merge::FastForward::Never + } else { + merge::FastForward::Allow + }), + no_commit: Some(no_commit), + }, + )? + }; + if r.already_up_to_date { + println!("Already up to date."); + } + output::conflicts(&r.conflicts, true); + if !r.conflicts.is_empty() { + return Ok(1); + } + if !abort && !r.already_up_to_date && !r.pending { + let new = head_info(&repo::open(&path)?)?.id.unwrap(); + if let Some(old) = old { + if r.fast_forward { + println!("Updating {}..{}\nFast-forward", &old[..7], &new[..7]); + } else { + println!("Merge made by the 'ort' strategy."); + } + output::stat( + &changes::diff(changes::DiffParams { + repo: path, + from: Some(old), + to: Some(new), + staged: None, + path: None, + context: None, + })?, + true, + ); + } + } + } + Command::Rebase { + abort, + resume, + skip, + upstream, + } => { + let r = if abort { + rebase::abort(&repository)? + } else if resume || skip { + rebase::resume(&repository, skip)? + } else { + rebase::start( + &repository, + rebase::RebaseParams { + repo: path.clone(), + onto: Some(required(upstream, "rebase")?), + branch: None, + }, + )? + }; + if let Some(id) = &r.resolved { + let commit = repository + .find_commit( + gix::ObjectId::from_hex(id.as_bytes()) + .map_err(|e| error("rebase output", e))?, + ) + .map_err(|e| error("rebase output", e))?; + let decoded = commit.decode().map_err(|e| error("rebase output", e))?; + println!( + "[detached HEAD {}] {}", + &id[..7], + decoded.message.to_str_lossy().lines().next().unwrap_or("") + ); + output::commit_stat(&changes::diff(changes::DiffParams { + repo: path, + from: Some(decoded.parents[0].to_string()), + to: Some(id.clone()), + staged: None, + path: None, + context: None, + })?); + } + output::conflicts(&r.conflicts, false); + if !r.conflicts.is_empty() { + return Ok(1); + } + } + Command::Worktree { command } => match command { + Worktree::List { porcelain } => { + let common = repository + .common_dir() + .canonicalize() + .map_err(|e| error("worktree list", e))?; + let mut paths = vec![ + common + .parent() + .ok_or_else(|| error("worktree list", "no worktree"))? + .to_owned(), + ]; + if let Ok(admins) = std::fs::read_dir(common.join("worktrees")) { + let mut linked = Vec::new(); + for admin in admins { + let admin = admin.map_err(|e| error("worktree list", e))?; + let gitfile = std::fs::read_to_string(admin.path().join("gitdir")) + .map_err(|e| error("worktree list", e))?; + if let Some(parent) = std::path::Path::new(gitfile.trim()).parent() { + linked.push(parent.to_owned()); + } + } + linked.sort(); + paths.extend(linked); + } + let width = paths + .iter() + .map(|p| p.to_string_lossy().len()) + .max() + .unwrap_or(0) + + 2; + for p in paths { + let linked = repo::open(&p.to_string_lossy())?; + let head = head_info(&linked)?; + let id = head.id.unwrap_or_else(|| "0".repeat(40)); + if porcelain { + println!("worktree {}\nHEAD {}", p.display(), id); + if let Some(branch) = head.branch { + println!("branch {branch}"); + } else { + println!("detached"); + } + println!(); + } else { + let branch = head + .branch + .map(|b| format!("[{}]", b.trim_start_matches("refs/heads/"))) + .unwrap_or_else(|| "(detached HEAD)".into()); + println!("{:width$}{} {}", p.display().to_string(), &id[..7], branch); + } + } + } + Worktree::Add { + branch, + path: target, + revision, + } => { + let branch = branch.unwrap_or_else(|| { + std::path::Path::new(&target) + .file_name() + .unwrap() + .to_string_lossy() + .into_owned() + }); + let info = worktrees::add( + &repository, + worktrees::AddParams { + repo: path, + path: std::path::absolute(target) + .map_err(|e| error("worktree path", e))? + .to_string_lossy() + .into_owned(), + branch, + from: revision, + adopt: None, + }, + )?; + let linked = repo::open(&info.path)?; + let commit = linked + .head_commit() + .map_err(|e| error("worktree HEAD", e))?; + let decoded = commit.decode().map_err(|e| error("worktree HEAD", e))?; + println!( + "HEAD is now at {} {}", + &commit.id.to_string()[..7], + decoded.message.to_str_lossy().lines().next().unwrap_or("") + ); + } + Worktree::Remove { force, path } => { + let target = std::path::absolute(path) + .map_err(|e| error("worktree remove", e))? + .to_string_lossy() + .into_owned(); + worktrees::remove( + &repo::open(&target)?, + worktrees::RemoveParams { + repo: target, + force: Some(force), + }, + )?; + } + }, + Command::Show { revision } => { + let object = repository + .rev_parse_single(revision.as_deref().unwrap_or("HEAD")) + .map_err(|e| error("show", e))? + .object() + .map_err(|e| error("show", e))?; + if object.kind == gix::objs::Kind::Blob { + use std::io::Write; + std::io::stdout() + .write_all(&object.data) + .map_err(|e| error("show", e))?; + } else { + let commit = object.peel_to_commit().map_err(|e| error("show", e))?; + output::commit(&commit)?; + println!(); + let old = match commit.parent_ids().next() { + Some(id) => id.to_string(), + None => repository + .write_object(&gix::objs::Tree { entries: vec![] }) + .map_err(|e| error("show", e))? + .to_string(), + }; + for diff in changes::diff(changes::DiffParams { + repo: path, + from: Some(old), + to: Some(commit.id.to_string()), + staged: None, + path: None, + context: None, + })? { + print!("{}", diff.patch); + } + } + } + Command::Fetch { remote } => network::fetch(&path, &remote)?, + Command::Push { + set_upstream, + force, + remote, + refspec, + } => { + push::push(push::PushParams { + repo: path, + remote: Some(remote), + branch: None, + refspec, + force: Some(force), + set_upstream: Some(set_upstream), + delete: None, + })?; + } + Command::Init { .. } | Command::Clone { .. } => unreachable!(), + } + Ok(0) +} +fn code(k: &changes::Kind) -> char { + match k { + changes::Kind::Added => 'A', + changes::Kind::Modified => 'M', + changes::Kind::Deleted => 'D', + changes::Kind::Renamed => 'R', + } +} diff --git a/userland/git/src/main.rs b/userland/git/src/main.rs new file mode 100644 index 0000000..df32fca --- /dev/null +++ b/userland/git/src/main.rs @@ -0,0 +1,39 @@ +//! Parse Git CLI arguments and return Git-compatible usage and operation exit codes. +use clap::{Parser, error::ErrorKind}; +fn main() { + let cli = match git::Cli::try_parse() { + Ok(cli) => cli, + Err(error) => { + let code = if !error.use_stderr() { + 0 + } else { + match error.kind() { + ErrorKind::InvalidSubcommand | ErrorKind::MissingSubcommand => 1, + _ => 129, + } + }; + let _ = error.print(); + exit(code); + } + }; + let code = match git::run(cli) { + Ok(code) => code, + Err(error) => { + eprintln!("fatal: {error}"); + 128 + } + }; + exit(code); +} +fn exit(code: i32) -> ! { + #[cfg(target_os = "wasi")] + { + use std::io::Write; + let _ = std::io::stdout().flush(); + let _ = std::io::stderr().flush(); + wasi::cli::exit::exit_with_code(code as u8); + unreachable!("WASI exit returned"); + } + #[cfg(not(target_os = "wasi"))] + std::process::exit(code) +} diff --git a/userland/git/src/merge.rs b/userland/git/src/merge.rs new file mode 100644 index 0000000..a6d02d2 --- /dev/null +++ b/userland/git/src/merge.rs @@ -0,0 +1,407 @@ +//! Merge workflow around gix's recursive commit/tree merge. Callers hold .git lock. +use crate::Result; +use crate::{branches, checkout, repo::error, staging}; +use gix::bstr::ByteSlice; +use std::{collections::BTreeSet, fs}; + +/// Fast-forward policy. +#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FastForward { + /// Advance HEAD directly when possible, otherwise perform a three-way merge. + Allow, + /// Refuse divergent histories instead of making a merge commit. + Only, + /// Make a two-parent commit even when a fast-forward is possible. + Never, +} +/// Merge a single commit into HEAD. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct MergeParams { + /// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash). + pub repo: String, + /// Revision to merge. + pub rev: String, + /// Merge message; defaults to Merge ''. + pub message: Option, + /// Fast-forward policy, default allow. + pub ff: Option, + /// Stop before creating a merge commit (does not stop a fast-forward). + pub no_commit: Option, +} +/// Resume a merge after git.add resolves every conflict. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct ContinueParams { + /// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash). + pub repo: String, + /// Override MERGE_MSG. + pub message: Option, +} +/// Merge outcome. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct MergeResult { + /// HEAD after the operation. + pub head: crate::HeadInfo, + /// True when HEAD was advanced without creating a merge commit. + pub fast_forward: bool, + /// True when the requested commit was already reachable. + pub already_up_to_date: bool, + /// True when merge_continue or merge_abort is needed. + pub pending: bool, + /// Sorted unresolved index paths; stage 1=base, 2=ours, 3=theirs. + pub conflicts: Vec, + /// Checkout feature limitations. + pub warnings: Vec, +} +fn result( + repo: &gix::Repository, + fast_forward: bool, + already: bool, + pending: bool, + conflicts: Vec, + warnings: Vec, +) -> Result { + Ok(MergeResult { + head: crate::head_info(repo)?, + fast_forward, + already_up_to_date: already, + pending, + conflicts, + warnings, + }) +} +pub fn ensure_idle(repo: &gix::Repository) -> Result<()> { + for name in [ + "MERGE_HEAD", + "CHERRY_PICK_HEAD", + "REVERT_HEAD", + "rebase-merge", + "rebase-apply", + ] { + if repo.git_dir().join(name).exists() { + return Err(error( + "merge", + format!("{name} exists; finish or abort the current operation first"), + )); + } + } + Ok(()) +} +fn tree(repo: &gix::Repository, id: gix::ObjectId) -> Result { + Ok(repo + .find_commit(id) + .map_err(|e| error("merge commit", e))? + .tree_id() + .map_err(|e| error("merge tree", e))? + .detach()) +} +fn reachable(repo: &gix::Repository, tip: gix::ObjectId, ancestor: gix::ObjectId) -> Result { + let walk = repo + .find_commit(tip) + .map_err(|e| error("merge ancestry", e))? + .ancestors() + .use_commit_graph(false) + .all() + .map_err(|e| error("merge ancestry", e))?; + for entry in walk { + if entry.map_err(|e| error("merge ancestry", e))?.id == ancestor { + return Ok(true); + } + } + Ok(false) +} +pub(crate) fn clean(repo: &gix::Repository) -> Result<()> { + check_index_lock(repo)?; + let s = crate::changes::status(repo)?; + let paths: BTreeSet<_> = s + .staged + .into_iter() + .chain(s.unstaged) + .flat_map(|c| std::iter::once(c.path).chain(c.old_path)) + .chain(s.conflicted) + .collect(); + if !paths.is_empty() { + return Err(error( + "merge dirty worktree/index", + paths.into_iter().collect::>().join(", "), + )); + } + // Untracked files may remain; the checkout engine preflights all obstructions, including ignored files. + Ok(()) +} +fn validate_message(message: &str) -> Result<()> { + if message.trim().is_empty() || message.contains('\0') { + return Err(error( + "merge", + "message must be nonblank and contain no NUL", + )); + } + Ok(()) +} +fn save( + repo: &gix::Repository, + ours: gix::ObjectId, + theirs: gix::ObjectId, + message: &str, + ff: FastForward, +) -> Result<()> { + for (name, bytes) in [ + ("ORIG_HEAD", format!("{ours}\n")), + ("MERGE_MSG", message.to_owned()), + ( + "MERGE_MODE", + if ff == FastForward::Never { + "no-ff".into() + } else { + String::new() + }, + ), + ("MERGE_HEAD", format!("{theirs}\n")), + ] { + fs::write(repo.git_dir().join(name), bytes).map_err(|e| error("merge state", e))?; + } + Ok(()) +} +pub fn clear(repo: &gix::Repository) -> Result<()> { + for name in ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE"] { + match fs::remove_file(repo.git_dir().join(name)) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(error("merge cleanup", e)), + } + } + Ok(()) +} +pub fn pending_parent(repo: &gix::Repository) -> Result> { + let bytes = match fs::read_to_string(repo.git_dir().join("MERGE_HEAD")) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(error("merge state", e)), + }; + let ids: Vec<_> = bytes.lines().collect(); + if ids.len() != 1 { + return Err(error( + "merge state", + "expected one MERGE_HEAD; octopus merges unsupported", + )); + } + let id = gix::ObjectId::from_hex(ids[0].as_bytes()).map_err(|e| error("MERGE_HEAD", e))?; + repo.find_commit(id).map_err(|e| error("MERGE_HEAD", e))?; + Ok(Some(id)) +} +fn advance( + repo: &gix::Repository, + old: Option, + new: gix::ObjectId, +) -> Result> { + use gix::refs::{ + Target, + transaction::{Change, PreviousValue, RefEdit}, + }; + // Identity checked by caller before any worktree mutation. + branches::edit_refs( + repo, + vec![RefEdit { + name: "HEAD".try_into().map_err(|e| error("merge HEAD", e))?, + deref: true, + change: Change::Update { + expected: old + .map(|id| PreviousValue::MustExistAndMatch(Target::Object(id))) + .unwrap_or(PreviousValue::MustNotExist), + new: Target::Object(new), + log: Default::default(), + }, + }], + ) +} +pub fn merge(repo: &gix::Repository, p: MergeParams) -> Result { + // In-memory policy only; never rewrite the person's .git/config. No mmap, + // renormalization/eol filters, or external merge-driver processes on WASI. + let local = merge_repository(repo)?; + let repo = &local; + ensure_idle(repo)?; + clean(repo)?; + let theirs = branches::resolve(repo, &p.rev)?; + merge_inner(repo, p, theirs) +} +/// Object-only merge policy shared with the rebase sequencer. Never rewrites config. +pub(crate) fn merge_repository(repo: &gix::Repository) -> Result { + let mut local = repo.clone(); + { + let mut config = local.config_snapshot_mut(); + config + .set_raw_value("core.commitGraph", "false") + .map_err(|e| error("merge config", e))?; + config + .set_raw_value("merge.renormalize", "false") + .map_err(|e| error("merge config", e))?; + config + .set_raw_value("merge.default", "text") + .map_err(|e| error("merge config", e))?; + let names: Vec<_> = config + .sections_by_name("merge") + .into_iter() + .flatten() + .filter_map(|s| s.header().subsection_name().map(ToOwned::to_owned)) + .collect(); + for name in names { + while config.remove_section("merge", name.as_bstr()).is_some() {} + } + config.commit().map_err(|e| error("merge config", e))?; + } + Ok(local) +} +fn merge_inner( + repo: &gix::Repository, + p: MergeParams, + theirs: gix::ObjectId, +) -> Result { + let ours = repo + .head() + .map_err(|e| error("merge HEAD", e))? + .id() + .map(|id| id.detach()); + let ff = p.ff.unwrap_or(FastForward::Allow); + let their_tree = tree(repo, theirs)?; + if let Some(ours) = ours { + if reachable(repo, ours, theirs)? { + return result(repo, false, true, false, vec![], vec![]); + } + } + let can_ff = match ours { + Some(ours) => reachable(repo, theirs, ours)?, + None => true, + }; + if ff == FastForward::Only && !can_ff { + return Err(error( + "merge ff-only", + "not possible: histories have diverged", + )); + } + if can_ff && (ff != FastForward::Never || ours.is_none()) { + repo.committer() + .transpose() + .map_err(|e| error("merge reflog identity", e))?; + let mut warnings = checkout::write_tree_to_worktree( + repo, + ours.map(|id| tree(repo, id)).transpose()?, + their_tree, + false, + )?; + if let Some(id) = ours { + fs::write(repo.git_dir().join("ORIG_HEAD"), format!("{id}\n")) + .map_err(|e| error("merge ORIG_HEAD", e))?; + } + warnings.extend(advance(repo, ours, theirs)?); + return result(repo, true, false, false, vec![], warnings); + } + let ours = ours.ok_or_else(|| error("merge", "unborn HEAD"))?; + let message = p + .message + .unwrap_or_else(|| format!("Merge '{}'\n", p.rev.replace(['\n', '\r'], " "))); + validate_message(&message)?; + // Resolve identity before changing files. no_commit/conflicts may be continued after configuring identity. + if !p.no_commit.unwrap_or(false) { + staging::signature(None, None)?; + } + let labels = gix::merge::blob::builtin_driver::text::Labels { + ancestor: None, + current: Some("ours".into()), + other: Some("theirs".into()), + }; + let mut outcome = repo + .merge_commits( + ours, + theirs, + labels, + repo.tree_merge_options() + .map_err(|e| error("merge options", e))? + .into(), + ) + .map_err(|e| error("merge three-way", e))? + .tree_merge; + let unresolved = outcome.has_unresolved_conflicts(Default::default()); + let merged = outcome + .tree + .write() + .map_err(|e| error("merge tree write", e))? + .detach(); + let mut state = gix::index::State::from_tree(&merged, &repo.objects, Default::default()) + .map_err(|e| error("merge index", e))?; + outcome.index_changed_after_applying_conflicts( + &mut state, + Default::default(), + gix::merge::tree::apply_index_entries::RemovalMode::Prune, + ); + state.sort_entries(); + let conflicts: BTreeSet<_> = state + .entries() + .iter() + .filter(|e| e.stage_raw() != 0) + .map(|e| e.path(&state).to_str_lossy().into_owned()) + .collect(); + if unresolved && conflicts.is_empty() { + return Err(error( + "merge", + "gix reported a conflict without index stages; cannot safely publish this merge", + )); + } + // The result tree contains gix-generated marker blobs and evasively renamed paths. + let warnings = checkout::write_tree_to_worktree(repo, Some(tree(repo, ours)?), merged, false)?; + save(repo, ours, theirs, &message, ff)?; + staging::publish(repo, state)?; + if !conflicts.is_empty() || p.no_commit.unwrap_or(false) { + return result( + repo, + false, + false, + true, + conflicts.into_iter().collect(), + warnings, + ); + } + continue_merge( + repo, + ContinueParams { + repo: p.repo, + message: Some(message), + }, + )?; + result(repo, false, false, false, vec![], warnings) +} +pub fn continue_merge(repo: &gix::Repository, p: ContinueParams) -> Result { + pending_parent(repo)?.ok_or_else(|| error("merge continue", "no merge in progress"))?; + let message = match p.message { + Some(m) => m, + None => fs::read_to_string(repo.git_dir().join("MERGE_MSG")) + .map_err(|e| error("MERGE_MSG", e))?, + }; + staging::commit( + repo, + staging::CommitParams { + repo: p.repo, + message, + author_name: None, + author_email: None, + all: None, + amend: None, + allow_empty: Some(true), + }, + ) +} +fn check_index_lock(repo: &gix::Repository) -> Result<()> { + if repo.git_dir().join("index.lock").exists() { + return Err(error("merge index", "index.lock already exists")); + } + Ok(()) +} +pub fn abort(repo: &gix::Repository) -> Result { + check_index_lock(repo)?; + if !repo.git_dir().join("MERGE_HEAD").exists() { + return Err(error("merge abort", "no merge in progress")); + } + let head = branches::resolve(repo, "HEAD")?; + let warnings = checkout::abort_to_tree(repo, tree(repo, head)?)?; + clear(repo)?; + result(repo, false, false, false, vec![], warnings) +} diff --git a/userland/git/src/network.rs b/userland/git/src/network.rs new file mode 100644 index 0000000..b88c41c --- /dev/null +++ b/userland/git/src/network.rs @@ -0,0 +1,241 @@ +//! Anonymous HTTP and HTTPS clone/fetch through standard wasi:http. +use crate::{ + Result, + http::{WasiHttp, clean_url}, + remotes, + repo::{self, error}, +}; +use gix::bstr::ByteSlice; +use std::{collections::BTreeSet, sync::atomic::AtomicBool}; +struct FetchResult { + remote: String, + advertised_refs: usize, + received_pack: bool, + pruned: Vec, + copied_objects: Option, + updated_refs: Option>, +} +pub(crate) fn fetch(path: &str, remote: &str) -> Result<()> { + let mut repo = repo::open(path)?; + repo.refs.write_reflog = gix::refs::store::WriteReflog::Disable; + perform(&repo, remote, None, false)?; + Ok(()) +} +pub(crate) fn clone(url: &str, path: &str) -> Result<()> { + clean_url(url).map_err(|e| error("clone URL", e))?; + let path = std::path::absolute(path).map_err(|e| error("clone path", e))?; + std::fs::create_dir(&path).map_err(|e| error("clone destination", e))?; + gix::init(&path).map_err(|e| error("clone init", e))?; + let name = path.to_string_lossy(); + let repo = repo::open(&name)?; + remotes::set(&repo, "origin", url, true)?; + let mut repo = repo::open(&name)?; + repo.refs.write_reflog = gix::refs::store::WriteReflog::Disable; + let fetched = perform(&repo, "origin", None, false)?; + let branch = fetched + .default_branch + .ok_or_else(|| error("clone", "server HEAD does not name a branch"))?; + if !fetched.empty { + crate::branches::create( + &repo, + crate::branches::BranchCreateParams { + repo: name.to_string(), + name: branch.clone(), + from: Some(format!("refs/remotes/origin/{branch}")), + checkout: Some(true), + }, + )?; + } + remotes::upstream(&repo, &branch)?; + Ok(()) +} +struct Fetched { + summary: FetchResult, + default_branch: Option, + heads: BTreeSet, + empty: bool, +} +fn perform( + repo: &gix::Repository, + remote_name: &str, + depth: Option, + prune: bool, +) -> Result { + remotes::validate_name(remote_name)?; + let remote = repo + .find_remote(remote_name) + .map_err(|_| error("fetch", "configured remote not found"))?; + let url = remote + .url(gix::remote::Direction::Fetch) + .ok_or_else(|| error("fetch", "remote has no fetch URL"))? + .to_bstring() + .to_str_lossy() + .into_owned(); + clean_url(&url).map_err(|e| error("fetch URL", e))?; + if prune { + let expected = format!("+refs/heads/*:refs/remotes/{remote_name}/*"); + let specs = remote.refspecs(gix::remote::Direction::Fetch); + if specs.len() != 1 || specs[0].to_ref().to_bstring() != expected.as_bytes() { + return Err(error( + "prune", + "prune requires the standard +refs/heads/*:refs/remotes//* refspec", + )); + } + } + let transport: Box = { + let transport_url = gix::url::parse(url.as_bytes().as_bstr()) + .map_err(|_| error("fetch", "invalid HTTPS URL"))?; + Box::new( + gix_transport::client::blocking_io::http::Transport::new_http( + WasiHttp::default(), + transport_url, + gix_transport::Protocol::V2, + false, + ), + ) + }; + let mut options = gix::remote::ref_map::Options::default(); + options.extra_refspecs.push( + gix::refspec::parse("HEAD".into(), gix::refspec::parse::Operation::Fetch) + .map_err(|e| error("fetch HEAD", e))? + .to_owned(), + ); + let mut prepared = remote + .to_connection_with_transport(transport) + .prepare_fetch(gix::progress::Discard, options) + .map_err(|e| error("fetch handshake", format!("{e:#}")))?; + let refs = &prepared.ref_map().remote_refs; + let mut heads = BTreeSet::new(); + let mut default_branch = None; + for reference in refs { + let (name, _, _) = reference.unpack(); + if let Some(branch) = name.to_str_lossy().strip_prefix("refs/heads/") { + heads.insert(branch.to_owned()); + } + match reference { + gix::protocol::handshake::Ref::Symbolic { + full_ref_name, + target, + .. + } + | gix::protocol::handshake::Ref::Unborn { + full_ref_name, + target, + } if full_ref_name.as_slice() == b"HEAD" => { + default_branch = target + .to_str_lossy() + .strip_prefix("refs/heads/") + .map(str::to_owned); + } + _ => {} + } + } + let advertised_refs = refs.len(); + let empty = heads.is_empty() && refs.iter().all(|r| r.unpack().1.is_none()); + if let Some(depth) = depth { + prepared = prepared.with_shallow(gix::remote::fetch::Shallow::DepthAtRemote( + depth + .try_into() + .map_err(|_| error("fetch", "depth must be positive"))?, + )); + } + let outcome = prepared + .receive(gix::progress::Discard, &AtomicBool::new(false)) + .map_err(|e| error("fetch", format!("{e:#}")))?; + let received_pack = matches!(outcome.status, gix::remote::fetch::Status::Change { .. }); + let updates = match &outcome.status { + gix::remote::fetch::Status::Change { update_refs, .. } + | gix::remote::fetch::Status::NoPackReceived { update_refs, .. } => update_refs, + }; + use gix::remote::fetch::refs::update::Mode; + for update in &updates.updates { + if !matches!( + update.mode, + Mode::NoChangeNeeded + | Mode::FastForward + | Mode::Forced + | Mode::New + | Mode::ImplicitTagNotSentByRemote + ) { + return Err(error( + "fetch refs (other refs may already be updated)", + &update.mode, + )); + } + } + if let Some(branch) = default_branch.as_deref().filter(|b| heads.contains(*b)) { + use gix::refs::{ + Target, + transaction::{Change, PreviousValue, RefEdit}, + }; + repo.edit_references_as( + [RefEdit { + name: format!("refs/remotes/{remote_name}/HEAD") + .as_str() + .try_into() + .map_err(|e| error("remote HEAD", e))?, + deref: false, + change: Change::Update { + log: Default::default(), + expected: PreviousValue::Any, + new: Target::Symbolic( + format!("refs/remotes/{remote_name}/{branch}") + .as_str() + .try_into() + .map_err(|e| error("remote HEAD target", e))?, + ), + }, + }], + None, + ) + .map_err(|e| error("remote HEAD", e))?; + } + let pruned = if prune { + prune_refs(repo, remote_name, &heads)? + } else { + vec![] + }; + Ok(Fetched { + summary: FetchResult { + remote: remote_name.into(), + advertised_refs, + received_pack, + pruned, + copied_objects: None, + updated_refs: None, + }, + default_branch, + heads, + empty, + }) +} +/// Delete only stale refs in this remote's namespace. Symbolic HEAD is not a branch. +pub fn prune_refs( + repo: &gix::Repository, + remote: &str, + heads: &BTreeSet, +) -> Result> { + remotes::validate_name(remote)?; + let prefix = format!("refs/remotes/{remote}/"); + let refs = repo.references().map_err(|e| error("prune refs", e))?; + let mut stale = Vec::new(); + for reference in refs + .prefixed(prefix.as_str()) + .map_err(|e| error("prune refs", e))? + { + let reference = reference.map_err(|e| error("prune ref", e))?; + let name = reference.name().as_bstr().to_str_lossy().into_owned(); + if let Some(short) = name.strip_prefix(&prefix) { + if short != "HEAD" && !heads.contains(short) { + stale.push(name); + } + } + } + for name in &stale { + repo.find_reference(name.as_str()) + .map_err(|e| error("prune ref", e))? + .delete() + .map_err(|e| error("prune delete", e))?; + } + Ok(stale) +} diff --git a/userland/git/src/output.rs b/userland/git/src/output.rs new file mode 100644 index 0000000..5586bd7 --- /dev/null +++ b/userland/git/src/output.rs @@ -0,0 +1,177 @@ +//! Git's text summaries for small, textual diffs. +use crate::changes::{FileDiff, Kind, Status}; +use gix::bstr::ByteSlice; +pub(crate) fn commit(commit: &gix::Commit<'_>) -> crate::Result<()> { + let decoded = commit + .decode() + .map_err(|e| crate::repo::error("commit output", e))?; + println!("commit {}", commit.id); + if decoded.parents.len() > 1 { + println!( + "Merge: {}", + decoded + .parents + .iter() + .map(|id| id.to_string()[..7].to_owned()) + .collect::>() + .join(" ") + ); + } + let author = commit + .author() + .map_err(|e| crate::repo::error("commit author", e))?; + let time = author + .time() + .map_err(|e| crate::repo::error("commit date", e))?; + println!( + "Author: {} <{}>\nDate: {}\n\n {}", + author.name, + author.email, + time.format_or_unix(gix::date::time::format::DEFAULT), + decoded + .message + .to_str_lossy() + .trim_end() + .replace('\n', "\n ") + ); + Ok(()) +} +pub(crate) fn long_status(s: &Status) { + let branch = s + .head + .branch + .as_deref() + .unwrap_or("HEAD") + .trim_start_matches("refs/heads/"); + println!("On branch {branch}"); + if s.head.unborn { + println!("\nNo commits yet\n"); + } + if !s.staged.is_empty() { + println!("Changes to be committed:"); + println!( + " (use \"git {} ...\" to unstage)", + if s.head.unborn { + "rm --cached" + } else { + "restore --staged" + } + ); + for c in &s.staged { + println!("\t{:12}{}", label(&c.status), c.path); + } + println!(); + } + if !s.unstaged.is_empty() { + println!("Changes not staged for commit:"); + println!(" (use \"git add ...\" to update what will be committed)"); + println!(" (use \"git restore ...\" to discard changes in working directory)"); + for c in &s.unstaged { + println!("\t{:12}{}", label(&c.status), c.path); + } + println!(); + } + if !s.untracked.is_empty() { + println!( + "Untracked files:\n (use \"git add ...\" to include in what will be committed)" + ); + for path in &s.untracked { + println!("\t{path}"); + } + println!(); + } + if s.staged.is_empty() { + if !s.unstaged.is_empty() { + println!("no changes added to commit (use \"git add\" and/or \"git commit -a\")"); + } else if !s.untracked.is_empty() { + println!( + "nothing added to commit but untracked files present (use \"git add\" to track)" + ); + } else if s.head.unborn { + println!("nothing to commit (create/copy files and use \"git add\" to track)"); + } else { + println!("nothing to commit, working tree clean"); + } + } +} +fn label(kind: &Kind) -> &'static str { + match kind { + Kind::Added => "new file:", + Kind::Modified => "modified:", + Kind::Deleted => "deleted:", + Kind::Renamed => "renamed:", + } +} +pub(crate) fn stat(diffs: &[FileDiff], summary: bool) { + summarize(diffs, true, summary); +} +pub(crate) fn commit_stat(diffs: &[FileDiff]) { + summarize(diffs, false, true); +} +fn summarize(diffs: &[FileDiff], names: bool, summary: bool) { + let width = diffs.iter().map(|d| d.path.len()).max().unwrap_or(0); + let mut insertions = 0; + let mut deletions = 0; + for diff in diffs { + let added = diff + .patch + .lines() + .filter(|l| l.starts_with('+') && !l.starts_with("+++")) + .count(); + let removed = diff + .patch + .lines() + .filter(|l| l.starts_with('-') && !l.starts_with("---")) + .count(); + insertions += added; + deletions += removed; + if names { + println!( + " {:width$} | {} {}{}", + diff.path, + added + removed, + "+".repeat(added), + "-".repeat(removed) + ); + } + } + if diffs.is_empty() { + return; + } + print!( + " {} file{} changed", + diffs.len(), + if diffs.len() == 1 { "" } else { "s" } + ); + if insertions > 0 { + print!( + ", {insertions} insertion{}(+)", + if insertions == 1 { "" } else { "s" } + ); + } + if deletions > 0 { + print!( + ", {deletions} deletion{}(-)", + if deletions == 1 { "" } else { "s" } + ); + } + println!(); + if summary { + for d in diffs { + match d.status { + Kind::Added => println!(" create mode 100644 {}", d.path), + Kind::Deleted => println!(" delete mode 100644 {}", d.path), + _ => (), + } + } + } +} +pub(crate) fn conflicts(paths: &[String], merge: bool) { + for path in paths { + println!("Auto-merging {path}"); + println!("CONFLICT (content): Merge conflict in {path}"); + } + if merge && !paths.is_empty() { + println!("Automatic merge failed; fix conflicts and then commit the result."); + } +} diff --git a/userland/git/src/push.rs b/userland/git/src/push.rs new file mode 100644 index 0000000..31bde06 --- /dev/null +++ b/userland/git/src/push.rs @@ -0,0 +1,672 @@ +//! Single-ref receive-pack client over standard wasi:http. + +use crate::Result; +use crate::{ + http::{WasiHttp, clean_url}, + remotes, repo, + repo::error, +}; +use gix::bstr::ByteSlice; +use gix_transport::client::blocking_io::http::{Http, PostBodyDataKind}; +use std::{ + collections::{BTreeMap, BTreeSet}, + io::{Read, Write}, +}; + +/// Push one branch or explicit single branch refspec over HTTPS or the SSH exec transport. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct PushParams { + /// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash). + pub repo: String, + /// Configured remote, default origin (pushurl takes precedence). + pub remote: Option, + /// Short local branch, default current branch. + pub branch: Option, + /// Single branch refspec: source:destination (including HEAD:destination), source, or :destination to delete. No wildcards. + pub refspec: Option, + /// Allow non-fast-forward replacement. A leading + in refspec also enables this. + pub force: Option, + /// Set source branch upstream to the remote destination after success. + pub set_upstream: Option, + /// Delete the destination instead of sending objects (default false). + pub delete: Option, +} +/// Confirmed remote update; local tracking state is updated only after report-status success. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct PushResult { + /// Remote used. + pub remote: String, + /// Full destination ref. + pub reference: String, + /// Advertised old object ID, or null for a new ref. + pub old: Option, + /// New object ID, or null for deletion. + pub new: Option, + /// Whether the server already had the requested value. + pub up_to_date: bool, + /// Number of objects sent (no delta compression). + pub objects: usize, +} +fn branch_ref(name: &str) -> Result { + let full = if name.starts_with("refs/") { + name.to_owned() + } else { + format!("refs/heads/{name}") + }; + if name.is_empty() || name.starts_with('-') || !full.starts_with("refs/heads/") { + return Err(error( + "push refspec", + "expected a branch name or refs/heads/; tags and wildcard/multi-ref pushes are not supported", + )); + } + let _: gix::refs::FullName = full + .as_str() + .try_into() + .map_err(|e| error("push refspec", e))?; + Ok(full) +} +pub(crate) struct Plan { + pub source: Option, + pub destination: String, + pub new: Option, + pub force: bool, +} +pub(crate) fn plan(repo: &gix::Repository, p: &PushParams) -> Result { + let mut force = p.force.unwrap_or(false); + let (source, destination, deleting) = if let Some(spec) = &p.refspec { + if p.branch.is_some() { + return Err(error("push", "branch and refspec are mutually exclusive")); + } + let spec = if let Some(s) = spec.strip_prefix('+') { + force = true; + s + } else { + spec.as_str() + }; + let (src, dst) = spec.split_once(':').unwrap_or((spec, spec)); + if dst.is_empty() || dst.contains(':') || spec.contains('*') { + return Err(error( + "push refspec", + "expected one source:destination or :destination", + )); + } + let deleting = p.delete.unwrap_or(false) || src.is_empty(); + let source = if deleting { + None + } else if src == "HEAD" { + Some( + crate::head_info(repo)? + .branch + .unwrap_or_else(|| "HEAD".into()), + ) + } else { + Some(branch_ref(src)?) + }; + (source, branch_ref(dst)?, deleting) + } else { + let source = match &p.branch { + Some(branch) => branch_ref(branch)?, + None => crate::head_info(repo)? + .branch + .ok_or_else(|| error("push", "detached HEAD: specify branch or refspec"))?, + }; + let deleting = p.delete.unwrap_or(false); + ( + if deleting { None } else { Some(source.clone()) }, + source, + deleting, + ) + }; + if deleting && p.set_upstream.unwrap_or(false) { + return Err(error("push", "set_upstream cannot be used with deletion")); + } + if source.as_deref() == Some("HEAD") && p.set_upstream.unwrap_or(false) { + return Err(error( + "push upstream", + "detached HEAD has no branch to set upstream for", + )); + } + let new = source + .as_deref() + .map(|src| -> Result { + let id = if src == "HEAD" { + repo.head_id() + .map_err(|e| error("push source", e))? + .detach() + } else { + let reference = repo + .find_reference(src) + .map_err(|e| error("push source", e))?; + reference + .try_id() + .ok_or_else(|| error("push source", "source must directly name a commit"))? + .detach() + }; + repo.find_commit(id) + .map_err(|e| error("push source commit", e))?; + Ok(id) + }) + .transpose()?; + Ok(Plan { + source, + destination, + new, + force, + }) +} + +// pkt-line readers reject truncation and reserved lengths rather than treating missing status as success. +pub(crate) fn pkt(out: &mut impl Write, data: &[u8]) -> Result<()> { + if data.len() > 65516 { + return Err(error("push packet", "packet too large")); + } + write!(out, "{:04x}", data.len() + 4) + .and_then(|_| out.write_all(data)) + .map_err(|e| error("push packet", e)) +} +fn packet(input: &mut impl Read) -> Result>> { + let mut header = [0; 4]; + input + .read_exact(&mut header) + .map_err(|e| error("push packet header", e))?; + let len = std::str::from_utf8(&header) + .ok() + .and_then(|s| u16::from_str_radix(s, 16).ok()) + .ok_or_else(|| error("push packet", "invalid length"))? as usize; + if len == 0 { + return Ok(None); + } + if !(4..=65520).contains(&len) { + return Err(error("push packet", "invalid packet length")); + } + let mut bytes = vec![0; len - 4]; + input + .read_exact(&mut bytes) + .map_err(|e| error("push packet body", e))?; + Ok(Some(bytes)) +} +pub(crate) struct Advertisement { + pub refs: BTreeMap, + pub caps: BTreeSet, +} +fn advertise(input: &mut impl Read) -> Result { + if packet(input)?.as_deref() != Some(b"# service=git-receive-pack\n") + || packet(input)?.is_some() + { + return Err(error( + "push advertisement", + "expected smart HTTP git-receive-pack service", + )); + } + advertise_refs(input) +} +/// SSH hook: git-receive-pack exec channels begin directly with these ref packets, +/// without the smart-HTTP service prelude. commands(), pack(), and report() are also transport-neutral. +pub(crate) fn advertise_refs(input: &mut impl Read) -> Result { + let mut refs = BTreeMap::new(); + let mut caps = BTreeSet::new(); + let mut first = true; + let mut count = 0; + while let Some(line) = packet(input)? { + count += 1; + if count > 100_000 { + return Err(error("push advertisement", "too many refs")); + } + let text = std::str::from_utf8(&line) + .map_err(|e| error("push advertisement", e))? + .trim_end_matches('\n'); + if let Some(message) = text.strip_prefix("ERR ") { + return Err(error("push remote", message)); + } + if text.starts_with("shallow ") { + return Err(error( + "push advertisement", + "shallow remotes are not supported", + )); + } + let (reference, capabilities) = text + .split_once('\0') + .map(|(a, b)| (a, Some(b))) + .unwrap_or((text, None)); + if let Some(capabilities) = capabilities { + if !first { + return Err(error("push advertisement", "capabilities after first ref")); + } + caps.extend(capabilities.split_ascii_whitespace().map(str::to_owned)); + } + first = false; + let (oid, name) = reference + .split_once(' ') + .ok_or_else(|| error("push advertisement", "invalid ref line"))?; + let id = + gix::ObjectId::from_hex(oid.as_bytes()).map_err(|e| error("push advertisement", e))?; + if id.kind() != gix::hash::Kind::Sha1 { + return Err(error("push", "only SHA-1 repositories supported")); + } + if !id.is_null() { + refs.insert(name.to_owned(), id); + } + } + if !caps.contains("report-status") { + return Err(error("push", "remote does not support report-status")); + } + if caps.contains("object-format=sha256") { + return Err(error("push", "SHA-256 remote is unsupported")); + } + Ok(Advertisement { refs, caps }) +} +pub(crate) fn commands( + old: Option, + plan: &Plan, + caps: &[&str], + out: &mut impl Write, +) -> Result<()> { + let zero = gix::hash::Kind::Sha1.null(); + let command = format!( + "{} {} {}\0{}\n", + old.unwrap_or(zero), + plan.new.unwrap_or(zero), + plan.destination, + caps.join(" ") + ); + pkt(out, command.as_bytes())?; + out.write_all(b"0000") + .map_err(|e| error("push commands", e)) +} +/// Unknown remote tips are not assumed to be ancestors. Force is required until fetched. +pub(crate) fn is_ancestor( + repo: &gix::Repository, + old: gix::ObjectId, + new: gix::ObjectId, +) -> Result { + if old == new { + return Ok(true); + } + if repo + .try_find_object(old) + .map_err(|e| error("push ancestry", e))? + .is_none() + { + return Ok(false); + } + let walk = repo + .find_commit(new) + .map_err(|e| error("push ancestry", e))? + .ancestors() + .use_commit_graph(false) + .all() + .map_err(|e| error("push ancestry", e))?; + for item in walk { + if item.map_err(|e| error("push ancestry", e))?.id == old { + return Ok(true); + } + } + Ok(false) +} +/// Walk the object graph once, stopping BEFORE decoding seen objects or remote boundaries. +/// Returns the number of objects decoded, also used by the traversal-cost regression tests. +/// The boundary must be transitively closed (the reachable remote object set). +/// Gitlink commits belong to another repository and are never followed. +pub(crate) fn reachable( + repo: &gix::Repository, + tip: gix::ObjectId, + seen: &mut BTreeSet, + boundary: &BTreeSet, +) -> Result { + let mut pending = vec![tip]; + let mut decoded = 0; + while let Some(id) = pending.pop() { + if boundary.contains(&id) || !seen.insert(id) { + continue; + } + let obj = repo + .find_object(id) + .map_err(|e| error("push reachable object", e))?; + decoded += 1; + match obj.kind { + gix::objs::Kind::Commit => { + let commit = obj.try_into_commit().map_err(|e| error("push commit", e))?; + pending.push( + commit + .tree_id() + .map_err(|e| error("push tree", e))? + .detach(), + ); + pending.extend(commit.parent_ids().map(|id| id.detach())); + } + // Follow one tag at a time, including intermediate annotated tags in seen. + gix::objs::Kind::Tag => { + let tag = obj + .try_into_tag() + .map_err(|e| error("push remote tag", e))?; + pending.push( + tag.target_id() + .map_err(|e| error("push remote tag target", e))? + .detach(), + ); + } + gix::objs::Kind::Tree => { + let tree = obj.try_into_tree().map_err(|e| error("push tree", e))?; + for entry in tree.iter() { + let entry = entry.map_err(|e| error("push tree entry", e))?; + if entry.mode().is_tree() { + pending.push(entry.object_id()); + } else if !entry.mode().is_commit() && !boundary.contains(&entry.object_id()) { + seen.insert(entry.object_id()); + } + } + } + gix::objs::Kind::Blob => {} + } + } + Ok(decoded) +} +pub(crate) fn objects_to_send( + repo: &gix::Repository, + new: gix::ObjectId, + remote: impl IntoIterator, +) -> Result> { + let mut theirs = BTreeSet::new(); + let empty = BTreeSet::new(); + for id in remote { + if !theirs.contains(&id) + && repo + .try_find_object(id) + .map_err(|e| error("push remote object", e))? + .is_some() + { + reachable(repo, id, &mut theirs, &empty)?; + } + } + let mut ours = BTreeSet::new(); + reachable(repo, new, &mut ours, &theirs)?; + Ok(ours) +} +/// Compress one object at a time and stream pack bytes to the HTTP request spool. +pub(crate) fn pack( + repo: &gix::Repository, + ids: &BTreeSet, + out: &mut impl Write, +) -> Result<()> { + use gix_pack::data::output::{Count, Entry, bytes::FromEntriesIter}; + let count = u32::try_from(ids.len()).map_err(|e| error("push pack count", e))?; + let entries = ids.iter().map(|id| { + let object = repo.find_object(*id)?; + Entry::from_data( + &Count::from_data(*id, None), + &gix::objs::Data { + kind: object.kind, + data: &object.data, + object_hash: gix::hash::Kind::Sha1, + }, + gix_zlib::Compression::default(), + ) + .map(|entry| vec![entry]) + }); + for written in FromEntriesIter::new( + entries, + out, + count, + gix_pack::data::Version::V2, + gix::hash::Kind::Sha1, + ) { + written.map_err(|e| error("push pack", format!("{e:#}")))?; + } + Ok(()) +} +fn parse_status(input: &mut impl Read, reference: &str) -> Result<()> { + let unpack = packet(input)?.ok_or_else(|| error("push status", "missing unpack status"))?; + let unpack = String::from_utf8_lossy(&unpack); + if unpack.trim_end() != "unpack ok" { + return Err(error("push remote", unpack.trim_end())); + } + let mut confirmed = false; + while let Some(line) = packet(input)? { + let line = String::from_utf8_lossy(&line); + let line = line.trim_end(); + if line == format!("ok {reference}") && !confirmed { + confirmed = true; + } else if let Some(message) = line.strip_prefix("ng ") { + return Err(error("push rejected", message)); + } else { + return Err(error("push status", format!("unexpected status: {line}"))); + } + } + if !confirmed { + return Err(error( + "push status", + "remote did not confirm the destination ref", + )); + } + Ok(()) +} +// Hook explanations often arrive on band 2, with only a generic reason in ng. +// Keep a bounded byte tail (lossy UTF-8 conversion happens only when rendering it). +const REMOTE_DETAIL_LIMIT: usize = 8192; +fn with_remote_detail(result: Result<()>, tail: &[u8]) -> Result<()> { + result.map_err(|mut e| { + let detail = String::from_utf8_lossy(tail); + if !detail.trim().is_empty() { + e.message.push_str(&format!("\nremote: {}", detail.trim())); + } + e + }) +} +pub(crate) fn report(input: &mut impl Read, reference: &str, sideband: bool) -> Result<()> { + if !sideband { + return parse_status(input, reference); + } + let mut status = Vec::new(); + let mut remote_detail = Vec::new(); + while let Some(line) = packet(input)? { + match line.split_first() { + Some((1, bytes)) => { + if status.len() + bytes.len() > 1024 * 1024 { + return Err(error("push status", "report-status too large")); + } + status.extend_from_slice(bytes); + } + Some((2, bytes)) => { + remote_detail.extend_from_slice(bytes); + if remote_detail.len() > REMOTE_DETAIL_LIMIT { + remote_detail.drain(..remote_detail.len() - REMOTE_DETAIL_LIMIT); + } + } + Some((3, bytes)) => { + return with_remote_detail( + Err(error( + "push remote", + String::from_utf8_lossy(bytes).trim_end(), + )), + &remote_detail, + ); + } + _ => return Err(error("push sideband", "invalid channel")), + } + } + with_remote_detail( + parse_status(&mut status.as_slice(), reference), + &remote_detail, + ) +} +fn update_local(repo: &gix::Repository, remote: &str, plan: &Plan, upstream: bool) -> Result<()> { + let short = plan + .destination + .strip_prefix("refs/heads/") + .ok_or_else(|| error("push tracking", "not a branch"))?; + let tracking = format!("refs/remotes/{remote}/{short}"); + use gix::refs::{ + Target, + transaction::{Change, PreviousValue, RefEdit}, + }; + if let Some(new) = plan.new { + repo.edit_references_as( + [RefEdit { + name: tracking + .as_str() + .try_into() + .map_err(|e| error("push tracking", e))?, + deref: false, + change: Change::Update { + log: Default::default(), + expected: PreviousValue::Any, + new: Target::Object(new), + }, + }], + None, + ) + .map_err(|e| error("push tracking", e))?; + } else if let Some(reference) = repo + .try_find_reference(&tracking) + .map_err(|e| error("push tracking", e))? + { + reference.delete().map_err(|e| error("push tracking", e))?; + } + if upstream { + let branch = plan + .source + .as_deref() + .and_then(|s| s.strip_prefix("refs/heads/")) + .ok_or_else(|| error("push upstream", "missing source branch"))?; + let mut config = remotes::load(repo)?; + let ids: Vec<_> = config + .sections_and_ids_by_name("branch") + .into_iter() + .flatten() + .filter(|(s, _)| { + s.header() + .subsection_name() + .is_some_and(|s| s == branch.as_bytes()) + }) + .map(|(_, id)| id) + .collect(); + for id in ids { + if let Some(mut section) = config.section_mut_by_id(id) { + while section.remove("remote").is_some() {} + while section.remove("merge").is_some() {} + } + } + let mut section = config + .section_mut_or_create_new("branch", branch) + .map_err(|e| error("push upstream", e))?; + section + .push("remote", Some(remote.into())) + .map_err(|e| error("push upstream", e))?; + section + .push("merge", Some(plan.destination.as_str().into())) + .map_err(|e| error("push upstream", e))?; + remotes::save(repo, &config)?; + } + Ok(()) +} +/// Send one reference update through the smart-HTTP receive-pack service. +pub fn push(p: PushParams) -> Result { + let mut repo = repo::open(&p.repo)?; + repo.refs.write_reflog = gix::refs::store::WriteReflog::Disable; + let remote = p.remote.as_deref().unwrap_or("origin"); + remotes::validate_name(remote)?; + let plan = plan(&repo, &p)?; + if plan.new.is_some() + && repo + .path() + .join("shallow") + .try_exists() + .map_err(|e| error("push shallow", e))? + { + return Err(error( + "push", + "shallow pushes are not supported; fetch full history first", + )); + } + let configured = repo + .find_remote(remote) + .map_err(|_| error("push", "configured remote not found"))?; + let url = configured + .url(gix::remote::Direction::Push) + .ok_or_else(|| error("push", "remote has no push URL"))? + .to_bstring() + .to_str_lossy() + .into_owned(); + let base = clean_url(&url) + .map_err(|e| error("push URL", e))? + .to_string() + .trim_end_matches('/') + .to_owned(); + let mut http = WasiHttp::default(); + + let result = (|| { + let mut discovery = http + .get( + &format!("{base}/info/refs?service=git-receive-pack"), + &base, + ["Accept: application/x-git-receive-pack-advertisement"], + ) + .map_err(|e| error("push discovery", format!("{e:#}")))?; + let advertised = advertise(&mut discovery.body)?; + drop(discovery); + let old = advertised.refs.get(&plan.destination).copied(); + if let (Some(old), Some(new)) = (old, plan.new) { + if old != new && !plan.force && !is_ancestor(&repo, old, new)? { + return Err(error( + "push", + "non-fast-forward (or remote tip unknown locally); fetch first or use force", + )); + } + } + let up_to_date = old == plan.new; + let mut objects = 0; + if !up_to_date { + if plan.new.is_none() && !advertised.caps.contains("delete-refs") { + return Err(error("push", "remote does not allow deleting refs")); + } + let ids = match plan.new { + Some(new) => objects_to_send(&repo, new, advertised.refs.values().copied())?, + None => BTreeSet::new(), + }; + objects = ids.len(); + let sideband = advertised.caps.contains("side-band-64k"); + let mut caps = vec!["report-status"]; + if sideband { + caps.push("side-band-64k"); + } + if advertised.caps.iter().any(|c| c.starts_with("agent=")) { + caps.push("agent=sausage-git/0.1"); + } + let base = http.redirected_base_url().unwrap_or(base); + let mut post = http + .post( + &format!("{base}/git-receive-pack"), + &base, + [ + "Content-Type: application/x-git-receive-pack-request", + "Accept: application/x-git-receive-pack-result", + ], + PostBodyDataKind::Unbounded, + ) + .map_err(|e| error("push request", format!("{e:#}")))?; + commands(old, &plan, &caps, &mut post.post_body)?; + if plan.new.is_some() { + pack(&repo, &ids, &mut post.post_body)?; + } + drop(post.post_body); + + report(&mut post.body, &plan.destination, sideband)?; + } + update_local(&repo, remote, &plan, p.set_upstream.unwrap_or(false)).map_err(|e| { + error( + "push succeeded remotely, but local state update failed", + e.message, + ) + })?; + + Ok(PushResult { + remote: remote.into(), + reference: plan.destination, + old: old.map(|id| id.to_string()), + new: plan.new.map(|id| id.to_string()), + up_to_date, + objects, + }) + })(); + result +} diff --git a/userland/git/src/rebase.rs b/userland/git/src/rebase.rs new file mode 100644 index 0000000..13cf51f --- /dev/null +++ b/userland/git/src/rebase.rs @@ -0,0 +1,581 @@ +//! Linear rebase sequencer. Every entry point is called under the common .git lock. +//! +//! Own persistent format (NOT Git's sequencer format): rebase-merge/sausage.json, +//! version 1. Full hex OIDs; onto/orig_head/orig_tip/branch are immutable; todo is +//! oldest-first, done lists consumed original commits (including skipped/empty), +//! current is the conflicted original commit. tip is the unpublished replay tip. +//! work_tree records the last materialized tree; orig_index is base64 of the exact +//! original index. phase checkpoints precede worktree/ref writes. Atomic JSON rename +//! makes each pick restartable; refs stay original until Finish. New fields must +//! default; unknown versions refuse. Real git is neither used nor expected to read it. +use crate::Result; +use crate::{branches, checkout, merge, repo::error, staging}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use gix::bstr::ByteSlice; +use std::{collections::BTreeSet, fs}; + +/// Replay a local branch's linear commits onto another revision. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct RebaseParams { + /// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash). + pub repo: String, + /// New base; defaults to the selected branch's configured upstream. + pub onto: Option, + /// Short or full local branch name; defaults to current branch (not detached HEAD). + pub branch: Option, +} +/// Rebase progress. HEAD and the branch ref remain original until completion. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct RebaseResult { + /// HEAD after this call (original while pending). + pub head: crate::HeadInfo, + /// Unpublished replay tip while pending; final branch tip otherwise. + pub tip: String, + /// True when continue, skip or abort is needed. + pub pending: bool, + /// True when the selected branch already contains the requested base. + pub already_up_to_date: bool, + /// True when no replay was required and the branch advanced to onto. + pub fast_forward: bool, + /// Sorted unresolved paths in stages 1/2/3. + pub conflicts: Vec, + /// Checkout limitations, including preserved abort obstructions. + pub warnings: Vec, + /// Commit created from a staged conflict resolution during this call. + pub resolved: Option, +} +#[derive(serde::Serialize, serde::Deserialize, PartialEq, Eq)] +enum Phase { + Replay, + CheckoutConflict, + Conflict, + Reset, + Finish, +} +#[derive(serde::Serialize, serde::Deserialize)] +struct State { + version: u32, + onto: String, + orig_head: String, + orig_branch: Option, + branch: String, + orig_tip: String, + tip: String, + todo: Vec, + done: Vec, + current: Option, + work_tree: String, + orig_index: Option, + phase: Phase, + already: bool, + fast_forward: bool, +} +fn oid(s: &str) -> Result { + gix::ObjectId::from_hex(s.as_bytes()).map_err(|e| error("rebase state OID", e)) +} +fn tree(repo: &gix::Repository, id: gix::ObjectId) -> Result { + Ok(repo + .find_commit(id) + .map_err(|e| error("rebase commit", e))? + .tree_id() + .map_err(|e| error("rebase tree", e))? + .detach()) +} +fn state_path(repo: &gix::Repository) -> std::path::PathBuf { + repo.git_dir().join("rebase-merge/sausage.json") +} +fn save(repo: &gix::Repository, s: &State) -> Result<()> { + let bytes = serde_json::to_vec(s).map_err(|e| error("rebase state", e))?; + let tmp = repo.git_dir().join("rebase-merge/sausage.json.tmp"); + fs::write(&tmp, bytes).map_err(|e| error("rebase state write", e))?; + fs::rename(tmp, state_path(repo)).map_err(|e| error("rebase state rename", e)) +} +fn load(repo: &gix::Repository) -> Result { + let bytes = fs::read(state_path(repo)) + .map_err(|e| error("rebase: no readable Sausage rebase state", e))?; + let s: State = serde_json::from_slice(&bytes).map_err(|e| error("rebase state", e))?; + if s.version != 1 { + return Err(error("rebase state", "unsupported version")); + } + Ok(s) +} +fn clear(repo: &gix::Repository) -> Result<()> { + fs::remove_file(state_path(repo)).map_err(|e| error("rebase cleanup", e))?; + let tmp = repo.git_dir().join("rebase-merge/sausage.json.tmp"); + if tmp.exists() { + fs::remove_file(tmp).map_err(|e| error("rebase cleanup", e))?; + } + // Never recursively remove someone else's sequencer files. + fs::remove_dir(repo.git_dir().join("rebase-merge")).map_err(|e| error("rebase cleanup", e)) +} +fn conflicts(repo: &gix::Repository) -> Result> { + let index = repo + .index_or_empty() + .map_err(|e| error("rebase index", e))?; + Ok(index + .entries() + .iter() + .filter(|e| e.stage_raw() != 0) + .map(|e| e.path(&index).to_str_lossy().into_owned()) + .collect::>() + .into_iter() + .collect()) +} +fn result( + repo: &gix::Repository, + s: &State, + pending: bool, + warnings: Vec, +) -> Result { + Ok(RebaseResult { + head: crate::head_info(repo)?, + tip: s.tip.clone(), + pending, + already_up_to_date: s.already, + fast_forward: s.fast_forward, + conflicts: if pending { conflicts(repo)? } else { vec![] }, + warnings, + resolved: None, + }) +} +fn index_lock(repo: &gix::Repository) -> Result<()> { + if repo.git_dir().join("index.lock").exists() { + return Err(error("rebase", "index.lock already exists")); + } + Ok(()) +} +fn verify_refs(repo: &gix::Repository, s: &State) -> Result<()> { + let head = crate::head_info(repo)?; + if head.id.as_deref() != Some(&s.orig_head) + || head.branch != s.orig_branch + || branches::resolve(repo, &s.branch)? != oid(&s.orig_tip)? + { + return Err(error( + "rebase", + "HEAD or branch changed since rebase began; refusing to overwrite refs", + )); + } + Ok(()) +} +fn upstream(repo: &gix::Repository, branch: &str) -> Result { + // gix's remote-tracking lookup doesn't map local (remote=".") upstreams. + let short = branch + .strip_prefix("refs/heads/") + .ok_or_else(|| error("rebase upstream", "expected local branch"))?; + let config = repo.config_snapshot(); + if config + .string(format!("branch.{short}.remote").as_str()) + .is_some_and(|s| s.as_bstr() == b".".as_bstr()) + { + return config + .string(format!("branch.{short}.merge").as_str()) + .map(|s| s.to_str_lossy().into_owned()) + .ok_or_else(|| error("rebase", "no upstream merge branch configured; supply onto")); + } + repo.find_reference(branch) + .map_err(|e| error("rebase branch", e))? + .remote_tracking_ref_name(gix::remote::Direction::Fetch) + .transpose() + .map_err(|e| error("rebase upstream", e))? + .map(|n| n.as_bstr().to_str_lossy().into_owned()) + .ok_or_else(|| { + error( + "rebase", + "no upstream configured; supply onto or configure branch upstream", + ) + }) +} +pub fn start(repo: &gix::Repository, p: RebaseParams) -> Result { + let local = merge::merge_repository(repo)?; + let repo = &local; + merge::ensure_idle(repo)?; + merge::clean(repo)?; + let head = crate::head_info(repo)?; + let orig_head = head + .id + .ok_or_else(|| error("rebase", "unborn HEAD is unsupported"))?; + let branch = p + .branch + .map(|b| { + if b.starts_with("refs/heads/") { + b + } else { + format!("refs/heads/{b}") + } + }) + .or_else(|| head.branch.clone()) + .ok_or_else(|| error("rebase", "detached HEAD; supply a local branch"))?; + let _: gix::refs::FullName = branch + .as_str() + .try_into() + .map_err(|e| error("rebase branch", e))?; + if !branch.starts_with("refs/heads/") { + return Err(error("rebase", "expected local branch")); + } + let orig = branches::resolve(repo, &branch)?; + let onto = branches::resolve( + repo, + &match p.onto { + Some(r) => r, + None => upstream(repo, &branch)?, + }, + )?; + let base = repo + .merge_base(orig, onto) + .map_err(|e| error("rebase merge-base", e))? + .ok_or_else(|| error("rebase", "unrelated histories are unsupported"))? + .detach(); + let mut cursor = orig; + let mut todo = Vec::new(); + while cursor != base { + let c = repo + .find_commit(cursor) + .map_err(|e| error("rebase range", e))?; + let parents: Vec<_> = c.parent_ids().map(|id| id.detach()).collect(); + if parents.len() != 1 { + return Err(error( + "rebase", + "linear history only: merge commit in replay range (no rebase-merges)", + )); + } + todo.push(cursor.to_string()); + cursor = parents[0]; + } + todo.reverse(); + let already = base == onto; + if already && head.branch.as_deref() == Some(&branch) { + return Ok(RebaseResult { + head: crate::head_info(repo)?, + tip: orig.to_string(), + pending: false, + already_up_to_date: true, + fast_forward: false, + conflicts: vec![], + warnings: vec![], + resolved: None, + }); + } + if already { + todo.clear(); + } + if !todo.is_empty() { + staging::signature(None, None)?; + } + // Preflight reflog configuration before materializing anything. + repo.committer() + .transpose() + .map_err(|e| error("rebase identity", e))?; + let orig_index = match fs::read(repo.git_dir().join("index")) { + Ok(b) => Some(STANDARD.encode(b)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(error("rebase snapshot index", e)), + }; + let s = State { + version: 1, + onto: onto.to_string(), + orig_head: orig_head.clone(), + orig_branch: head.branch, + branch, + orig_tip: orig.to_string(), + tip: if already { orig } else { onto }.to_string(), + todo, + done: vec![], + current: None, + work_tree: tree(repo, oid(&orig_head)?)?.to_string(), + orig_index, + phase: Phase::Replay, + already, + fast_forward: !already && base == orig, + }; + fs::create_dir(repo.git_dir().join("rebase-merge")) + .map_err(|e| error("rebase state directory", e))?; + save(repo, &s)?; + run(repo, s, vec![]) +} +fn pick( + repo: &gix::Repository, + original: &str, + tip: &str, + new_tree: gix::ObjectId, +) -> Result { + if new_tree == tree(repo, oid(tip)?)? { + return Ok(tip.to_owned()); + } + let original = repo + .find_commit(oid(original)?) + .map_err(|e| error("rebase original", e))?; + let decoded = original.decode().map_err(|e| error("rebase original", e))?; + let author = decoded + .author() + .map_err(|e| error("rebase author", e))? + .to_owned() + .map_err(|e| error("rebase author", e))?; + let object = gix::objs::Commit { + tree: new_tree, + parents: vec![oid(tip)?].into_iter().collect(), + author, + committer: staging::signature(None, None)?, + encoding: decoded.encoding.map(ToOwned::to_owned), + message: decoded.message.to_owned(), + extra_headers: vec![], + }; + Ok(repo + .write_object(&object) + .map_err(|e| error("rebase commit write", e))? + .to_string()) +} +fn consume(s: &mut State) { + if !s.todo.is_empty() { + s.done.push(s.todo.remove(0)); + } + s.current = None; +} +fn run(repo: &gix::Repository, mut s: State, mut warnings: Vec) -> Result { + verify_refs(repo, &s)?; + while let Some(current) = s.todo.first().cloned() { + let commit = repo + .find_commit(oid(¤t)?) + .map_err(|e| error("rebase pick", e))?; + let parent = commit + .parent_ids() + .next() + .ok_or_else(|| error("rebase pick", "root commit in range"))? + .detach(); + let labels = gix::merge::blob::builtin_driver::text::Labels { + ancestor: None, + current: Some("ours".into()), + other: Some("theirs".into()), + }; + let mut outcome = repo + .merge_trees( + tree(repo, parent)?, + tree(repo, oid(&s.tip)?)?, + tree(repo, oid(¤t)?)?, + labels, + repo.tree_merge_options() + .map_err(|e| error("rebase options", e))?, + ) + .map_err(|e| error("rebase merge trees", e))?; + let unresolved = outcome.has_unresolved_conflicts(Default::default()); + let merged = outcome + .tree + .write() + .map_err(|e| error("rebase tree write", e))? + .detach(); + let mut index = gix::index::State::from_tree(&merged, &repo.objects, Default::default()) + .map_err(|e| error("rebase index", e))?; + outcome.index_changed_after_applying_conflicts( + &mut index, + Default::default(), + gix::merge::tree::apply_index_entries::RemovalMode::Prune, + ); + index.sort_entries(); + let has_conflicts = index.entries().iter().any(|e| e.stage_raw() != 0); + if unresolved && !has_conflicts { + return Err(error( + "rebase", + "gix conflict without index stages; refusing unsafe replay", + )); + } + if has_conflicts { + s.current = Some(current); + s.phase = Phase::CheckoutConflict; + save(repo, &s)?; + warnings.extend(checkout::write_tree_to_worktree( + repo, + Some(oid(&s.work_tree)?), + merged, + false, + )?); + staging::publish(repo, index)?; + s.work_tree = merged.to_string(); + s.phase = Phase::Conflict; + save(repo, &s)?; + return result(repo, &s, true, warnings); + } + s.tip = pick(repo, ¤t, &s.tip, merged)?; + consume(&mut s); + save(repo, &s)?; + } + s.phase = Phase::Finish; + save(repo, &s)?; + finish(repo, s, warnings) +} +fn finish(repo: &gix::Repository, s: State, mut warnings: Vec) -> Result { + verify_refs(repo, &s)?; + let target = tree(repo, oid(&s.tip)?)?; + // A process may exit after checkout publishes its index but before the ref CAS. + // In that case the worktree is already final; do not compare it to the old tree. + if resolved_tree(repo)? != target { + warnings.extend(checkout::write_tree_to_worktree( + repo, + Some(oid(&s.work_tree)?), + target, + false, + )?); + } + use gix::refs::{ + Target, + transaction::{Change, PreviousValue, RefEdit}, + }; + let old_head = match &s.orig_branch { + Some(b) => Target::Symbolic(b.as_str().try_into().map_err(|e| error("rebase HEAD", e))?), + None => Target::Object(oid(&s.orig_head)?), + }; + warnings.extend(branches::edit_refs( + repo, + vec![ + RefEdit { + name: s + .branch + .as_str() + .try_into() + .map_err(|e| error("rebase branch", e))?, + deref: false, + change: Change::Update { + expected: PreviousValue::MustExistAndMatch(Target::Object(oid(&s.orig_tip)?)), + new: Target::Object(oid(&s.tip)?), + log: Default::default(), + }, + }, + RefEdit { + name: "HEAD".try_into().map_err(|e| error("rebase HEAD", e))?, + deref: false, + change: Change::Update { + expected: PreviousValue::MustExistAndMatch(old_head), + new: Target::Symbolic( + s.branch + .as_str() + .try_into() + .map_err(|e| error("rebase branch", e))?, + ), + log: Default::default(), + }, + }, + ], + )?); + clear(repo)?; + result(repo, &s, false, warnings) +} +/// Check only unstaged changes: HEAD intentionally still names the original branch tip. +fn resolved_tree(repo: &gix::Repository) -> Result { + let index = repo + .index_or_empty() + .map_err(|e| error("rebase index", e))?; + let tree = staging::tree_from_index(repo, &index)?; + let status = crate::changes::status(repo)?; + if !status.unstaged.is_empty() { + return Err(error( + "rebase continue", + "unstaged changes; stage the resolved paths first", + )); + } + Ok(tree) +} +pub fn resume(repo: &gix::Repository, skip: bool) -> Result { + index_lock(repo)?; + let local = merge::merge_repository(repo)?; + let repo = &local; + let mut s = load(repo)?; + // Ref transaction completed but cleanup was interrupted: do not replay twice. + let head = crate::head_info(repo)?; + if s.phase == Phase::Finish + && head.branch.as_deref() == Some(&s.branch) + && head.id.as_deref() == Some(&s.tip) + && branches::resolve(repo, &s.branch)? == oid(&s.tip)? + { + clear(repo)?; + return result(repo, &s, false, vec![]); + } + verify_refs(repo, &s)?; + if s.phase == Phase::CheckoutConflict { + return Err(error( + "rebase", + "interrupted conflict publication; use rebase_abort (resolution index may be incomplete)", + )); + } + if skip && s.phase != Phase::Conflict { + return Err(error("rebase skip", "no conflicted pick to skip")); + } + let mut warnings = vec![]; + let mut resolved = None; + if s.phase == Phase::Conflict { + if skip { + consume(&mut s); + s.phase = Phase::Reset; + save(repo, &s)?; + } else { + let merged = resolved_tree(repo)?; + let current = s + .current + .as_deref() + .ok_or_else(|| error("rebase state", "missing current pick"))?; + s.tip = pick(repo, current, &s.tip, merged)?; + resolved = Some(s.tip.clone()); + s.work_tree = merged.to_string(); + consume(&mut s); + s.phase = Phase::Replay; + save(repo, &s)?; + } + } + if s.phase == Phase::Reset { + let target = tree(repo, oid(&s.tip)?)?; + warnings.extend(checkout::write_tree_to_worktree( + repo, + Some(oid(&s.work_tree)?), + target, + true, + )?); + s.work_tree = target.to_string(); + s.phase = Phase::Replay; + save(repo, &s)?; + } + if s.phase == Phase::Finish { + return finish(repo, s, warnings); + } + if resolved_tree(repo)? != oid(&s.work_tree)? { + return Err(error( + "rebase", + "index changed during interrupted replay; abort or restore the index before continuing", + )); + } + let mut result = run(repo, s, warnings)?; + result.resolved = resolved; + Ok(result) +} +pub fn abort(repo: &gix::Repository) -> Result { + index_lock(repo)?; + let s = load(repo)?; + verify_refs(repo, &s)?; + let warnings = checkout::abort_to_tree(repo, tree(repo, oid(&s.orig_head)?)?)?; + if let Some(bytes) = &s.orig_index { + use std::io::Write; + let bytes = STANDARD + .decode(bytes) + .map_err(|e| error("rebase original index", e))?; + let lock = repo.git_dir().join("index.lock"); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock) + .map_err(|e| error("rebase index lock", e))?; + let write = file.write_all(&bytes).and_then(|_| file.flush()); + drop(file); + if let Err(e) = write { + let _ = fs::remove_file(&lock); + return Err(error("rebase index restore", e)); + } + fs::rename(&lock, repo.git_dir().join("index")) + .map_err(|e| error("rebase index restore", e))?; + } else { + match fs::remove_file(repo.git_dir().join("index")) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(error("rebase index restore", e)), + } + } + clear(repo)?; + let mut r = result(repo, &s, false, warnings)?; + r.tip = s.orig_tip; + Ok(r) +} diff --git a/userland/git/src/remotes.rs b/userland/git/src/remotes.rs new file mode 100644 index 0000000..ac32790 --- /dev/null +++ b/userland/git/src/remotes.rs @@ -0,0 +1,187 @@ +//! Non-destructive local Git config edits. No plugin-owned persistent format. +use crate::Result; +use crate::{http::clean_url, repo::error}; +use gix::bstr::ByteSlice; + +/// Remote identity. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct RemoteParams { + /// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash). + pub repo: String, + /// Remote name. + pub name: String, +} +/// Add or change a remote URL. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct RemoteUrlParams { + /// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash). + pub repo: String, + /// Remote name. + pub name: String, + /// HTTPS without credentials, ssh:// URL, or [user@]host:path. + pub url: String, +} +/// Remote settings stored in local .git/config. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct RemoteInfo { + /// Remote name. + pub name: String, + /// Credential-free URL, if configured. + pub url: Option, + /// Fetch refspecs in config order. + pub fetch: Vec, +} +pub fn validate_name(name: &str) -> Result<()> { + if name.is_empty() + || name.starts_with('-') + || name.contains('/') + || name.contains("..") + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) + { + return Err(error( + "remote", + "expected a remote name using letters, digits, ._-", + )); + } + let _: gix::refs::FullName = format!("refs/remotes/{name}/branch") + .as_str() + .try_into() + .map_err(|e| error("remote name", e))?; + Ok(()) +} +pub fn load(repo: &gix::Repository) -> Result { + let text = std::fs::read_to_string(repo.common_dir().join("config")) + .map_err(|e| error("remote config", e))?; + gix::config::File::try_from(text.as_str()) + .map_err(|_| error("remote config", "could not parse .git/config")) +} +pub fn save(repo: &gix::Repository, config: &gix::config::File) -> Result<()> { + let mut bytes = Vec::new(); + config + .write_to(&mut bytes) + .map_err(|e| error("remote config", e))?; + std::fs::write(repo.common_dir().join("config"), bytes).map_err(|e| error("config write", e)) +} +pub fn list(repo: &gix::Repository) -> Result> { + let config = load(repo)?; + let mut names = std::collections::BTreeSet::new(); + for section in config.sections_by_name("remote").into_iter().flatten() { + if let Some(name) = section.header().subsection_name() { + names.insert(name.to_str_lossy().into_owned()); + } + } + names + .into_iter() + .map(|name| { + let url = config + .string_by("remote", name.as_str(), "url") + .map(|s| s.to_str_lossy().into_owned()); + // Do not return legacy embedded credentials from repositories imported outside this plugin. + let url = url + .map(|s| { + clean_url(&s).map(|u| u.to_string()).map_err(|_| { + error( + "remote list", + "remote URL must be HTTPS without credentials or SSH without password", + ) + }) + }) + .transpose()?; + let fetch = config + .strings_by("remote", name.as_str(), "fetch") + .unwrap_or_default() + .into_iter() + .map(|s| s.to_str_lossy().into_owned()) + .collect(); + Ok(RemoteInfo { name, url, fetch }) + }) + .collect() +} +pub fn set(repo: &gix::Repository, name: &str, url: &str, add: bool) -> Result { + validate_name(name)?; + let url = clean_url(url) + .map_err(|e| error("remote URL", e))? + .to_string(); + let mut config = load(repo)?; + let exists = config.section("remote", name).is_ok(); + if add == exists { + return Err(error( + "remote", + if add { + "remote already exists" + } else { + "remote does not exist" + }, + )); + } + // Replace all URL occurrences (including duplicate sections), preserving other keys/comments. + if !add { + let ids: Vec<_> = config + .sections_and_ids_by_name("remote") + .into_iter() + .flatten() + .filter(|(s, _)| { + s.header() + .subsection_name() + .is_some_and(|s| s == name.as_bytes()) + }) + .map(|(_, id)| id) + .collect(); + for id in ids { + if let Some(mut section) = config.section_mut_by_id(id) { + while section.remove("url").is_some() {} + } + } + } + let mut section = config + .section_mut_or_create_new("remote", name) + .map_err(|e| error("remote config", e))?; + section + .push("url", Some(url.as_str().into())) + .map_err(|e| error("remote config", e))?; + if add { + section + .push( + "fetch", + Some( + format!("+refs/heads/*:refs/remotes/{name}/*") + .as_str() + .into(), + ), + ) + .map_err(|e| error("remote config", e))?; + } + save(repo, &config)?; + list(repo)? + .into_iter() + .find(|r| r.name == name) + .ok_or_else(|| error("remote", "remote missing after config edit")) +} +pub fn remove(repo: &gix::Repository, name: &str) -> Result<()> { + validate_name(name)?; + let mut config = load(repo)?; + if config.remove_section("remote", name).is_none() { + return Err(error("remote", "remote does not exist")); + } + while config.remove_section("remote", name).is_some() {} + save(repo, &config) +} +/// Configure the newly cloned branch's upstream with the same non-destructive editor. +pub fn upstream(repo: &gix::Repository, branch: &str) -> Result<()> { + let mut config = load(repo)?; + let mut section = config + .section_mut_or_create_new("branch", branch) + .map_err(|e| error("branch config", e))?; + section + .push("remote", Some("origin".into())) + .map_err(|e| error("branch config", e))?; + section + .push( + "merge", + Some(format!("refs/heads/{branch}").as_str().into()), + ) + .map_err(|e| error("branch config", e))?; + save(repo, &config) +} diff --git a/userland/git/src/repo.rs b/userland/git/src/repo.rs new file mode 100644 index 0000000..c573dd5 --- /dev/null +++ b/userland/git/src/repo.rs @@ -0,0 +1,112 @@ +//! Repository layout and shared operation helpers. Persistent state is ordinary Git files only. +use crate::{Error, Result}; +use std::path::PathBuf; + +/// Add operation context to Git and filesystem errors. +pub fn error(operation: &str, error: impl std::fmt::Display) -> Error { + Error::new(-32000, format!("{operation}: {error}")) +} + +/// Resolve a legacy name or a literal absolute path under /plugins/ or /repos/. +pub fn repo_path(name: &str) -> Result { + Ok(PathBuf::from(name)) +} + +/// Resolve metadata without opening another repository or acquiring another lock. +/// Missing .git is retained for the existing non-bare init/clone lock key. +fn git_dir(path: &std::path::Path) -> Result { + let dot_git = path.join(".git"); + if dot_git.is_file() { + let text = std::fs::read_to_string(&dot_git).map_err(|e| error("gitfile", e))?; + let target = text + .trim_end_matches(['\r', '\n']) + .strip_prefix("gitdir: ") + .filter(|p| !p.is_empty() && !p.contains(['\r', '\n', '\0'])) + .ok_or_else(|| error("gitfile", "expected gitdir: "))?; + return std::fs::canonicalize(path.join(target)).map_err(|e| error("gitfile target", e)); + } + if !dot_git.exists() && path.join("HEAD").is_file() && path.join("objects").is_dir() { + return Ok(path.to_owned()); + } + Ok(dot_git) +} + +/// Common-dir advisory lock key, shared by the main and all linked worktrees. +/// A plain .git directory retains exactly the legacy lock path. +pub fn lock_path(name: &str) -> Result { + let git_dir = git_dir(&repo_path(name)?)?; + let common = match std::fs::read_to_string(git_dir.join("commondir")) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(git_dir), + Err(e) => return Err(error("commondir", e)), + }; + let common = common.trim_end_matches(['\r', '\n']); + if common.is_empty() || common.contains(['\r', '\n', '\0']) { + return Err(error("commondir", "expected one nonempty path")); + } + std::fs::canonicalize(git_dir.join(common)).map_err(|e| error("commondir target", e)) +} + +/// Open only the addressed worktree or bare directory, never discover an ancestor. +pub fn open(name: &str) -> Result { + let path = repo_path(name)?; + let dot_git = path.join(".git"); + let mut repo = if dot_git.is_file() { + // gix follows gitfiles and commondir, including the private HEAD/index. + git_dir(&path)?; // Give malformed gitfiles a useful error before opening. + let repo = gix::open(&path).map_err(|e| error("open", e))?; + // gix 0.90 reports is_bare()==true from the common config even though + // it resolves the workdir and private index correctly. Check workdir, + // not that cached flag; status and our tree writer work without config edits. + worktree(&repo)?; + repo + } else if dot_git.is_dir() { + let repo = gix::open(&dot_git).map_err(|e| error("open", e))?; + if repo.workdir().is_none() || repo.is_bare() { + return Err(error( + "open", + "expected worktree metadata in .git; address bare repositories by their bare directory", + )); + } + repo + } else { + let repo = gix::open_opts(&path, gix::open::Options::default().open_path_as_is(true)) + .map_err(|e| error("open bare", e))?; + if !repo.is_bare() { + return Err(error( + "open", + "expected a worktree root or bare repository directory", + )); + } + repo + }; + // gix-ref otherwise uses unsupported mmap for packed-refs above 32 KiB. + #[cfg(target_os = "wasi")] + repo.refs.set_packed_buffer_mmap_threshold(u64::MAX); + Ok(repo) +} + +/// Require a worktree before any status/index/worktree-style operation. +pub fn worktree(repo: &gix::Repository) -> Result<&std::path::Path> { + repo.workdir().ok_or_else(|| { + error( + "worktree", + "no worktree: operation is not available on a bare repository", + ) + }) +} + +/// Validate a literal repository-relative path (not a pathspec). +pub fn validate_path(path: &str) -> Result<()> { + if path.is_empty() + || path + .split('/') + .any(|c| c.is_empty() || c == "." || c == "..") + { + return Err(error( + "path", + "expected a nonempty repository-relative literal path", + )); + } + Ok(()) +} diff --git a/userland/git/src/staging.rs b/userland/git/src/staging.rs new file mode 100644 index 0000000..a6b9f23 --- /dev/null +++ b/userland/git/src/staging.rs @@ -0,0 +1,771 @@ +//! Staging uses ordinary Git index entries; callers hold the repository .git lock. +use crate::Result; +use crate::repo::{self, error}; +use gix::{ + bstr::ByteSlice, + index::{ + State, + entry::{Flags, Mode, Stat}, + }, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::Path, +}; + +/// Stage literal paths or the whole worktree. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct AddParams { + /// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash). + pub repo: String, + /// Literal files/directories; no globbing. May be empty with all=true. + pub paths: Vec, + /// Stage tracked changes/deletions and nonignored untracked files throughout the repo. + pub all: Option, +} +/// Reset selected index paths to HEAD, leaving worktree bytes untouched. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct PathsParams { + /// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash). + pub repo: String, + /// Nonempty list of literal file/directory paths; no globbing. + pub paths: Vec, +} +/// Remove tracked paths from the index and optionally worktree. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct RmParams { + /// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash). + pub repo: String, + /// Nonempty list of literal file/directory paths. + pub paths: Vec, + /// Leave files on disk (default false). Otherwise refuse staged or local modifications. + pub keep_file: Option, +} +/// Paths whose index entries were selected by an operation. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct StagedPaths { + /// Selected paths, sorted, including staged deletions. + pub paths: Vec, +} +/// Create a commit from the stage-zero index. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct CommitParams { + /// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash). + pub repo: String, + /// Nonblank commit message, stored verbatim. + pub message: String, + /// Author display name, falling back to /config/git.yaml user.name. + pub author_name: Option, + /// Author email, falling back to /config/git.yaml user.email. + pub author_email: Option, + /// Stage modifications/deletions of tracked files first, not untracked files (default false). + pub all: Option, + /// Replace HEAD, retaining its parents and, unless overridden, original author (default false). + pub amend: Option, + /// Permit a tree equal to the first parent's tree, or an empty root tree (default false). + pub allow_empty: Option, +} +/// Published commit and resulting HEAD. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct CommitResult { + /// Full commit object ID. + pub id: String, + /// HEAD after the checked reference update. + pub head: crate::HeadInfo, + /// Detached-HEAD warning, if applicable. + pub warnings: Vec, +} + +fn validate(path: &str) -> Result<()> { + repo::validate_path(path)?; + if path.contains('\\') || path.split('/').any(|s| s.eq_ignore_ascii_case(".git")) { + return Err(error( + "path", + "paths under .git and backslashes are not allowed", + )); + } + Ok(()) +} +fn validate_paths(paths: &[String]) -> Result<()> { + if paths.is_empty() { + return Err(error("paths", "supply at least one literal path")); + } + for p in paths { + validate(p)?; + } + Ok(()) +} +fn matches(path: &str, selected: &[String]) -> bool { + selected + .iter() + .any(|p| path == p || path.strip_prefix(p).is_some_and(|s| s.starts_with('/'))) +} +fn meta(path: &Path) -> Result> { + match fs::symlink_metadata(path) { + Ok(m) => Ok(Some(m)), + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) => + { + Ok(None) + } + Err(e) => Err(error("stage stat", e)), + } +} +// Do not traverse symlink parents even when an explicit path points outside via a link. +fn safe(root: &Path, path: &str) -> Result<()> { + validate(path)?; + for parent in Path::new(path) + .ancestors() + .skip(1) + .filter(|p| !p.as_os_str().is_empty()) + { + if meta(&root.join(parent))?.is_some_and(|m| m.file_type().is_symlink()) { + return Err(error("path", format!("symlink parent: {path}"))); + } + } + Ok(()) +} +fn load(repo: &gix::Repository) -> Result { + repo::worktree(repo)?; + let index = repo.index_or_empty().map_err(|e| error("stage index", e))?; + let mut state = State::new(repo.object_hash()); + for e in index.entries() { + let p = e + .path(&index) + .to_str() + .map_err(|e| error("index path UTF-8", e))?; + validate(p)?; + if e.mode == Mode::DIR || e.flags.contains(Flags::SKIP_WORKTREE) { + return Err(error("stage", "sparse indexes are unsupported")); + } + state.dangerously_push_entry(e.stat, e.id, e.flags, e.mode, p.as_bytes().as_bstr()); + } + state.sort_entries(); + Ok(state) +} +struct PendingIndex { + lock: std::path::PathBuf, + dest: std::path::PathBuf, + published: bool, +} +impl Drop for PendingIndex { + fn drop(&mut self) { + if !self.published { + let _ = fs::remove_file(&self.lock); + } + } +} +impl PendingIndex { + fn commit(mut self) -> Result<()> { + fs::rename(&self.lock, &self.dest).map_err(|e| error("stage index rename", e))?; + self.published = true; + Ok(()) + } +} +fn prepare(repo: &gix::Repository, mut state: State) -> Result { + use std::io::Write; + state.sort_entries(); + state.remove_tree(); + let dest = repo.git_dir().join("index"); + let lock = repo.git_dir().join("index.lock"); + // Do not create the cleanup guard until create_new succeeds: an existing lock is not ours. + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock) + .map_err(|e| error("stage index lock", e))?; + let pending = PendingIndex { + lock, + dest: dest.clone(), + published: false, + }; + gix::index::File::from_state(state, dest) + .write_to(&mut file, Default::default()) + .map_err(|e| error("stage index write", e))?; + file.flush().map_err(|e| error("stage index flush", e))?; + drop(file); + Ok(pending) +} +pub(crate) fn publish(repo: &gix::Repository, state: State) -> Result<()> { + prepare(repo, state)?.commit() +} +fn head_state(repo: &gix::Repository) -> Result { + if repo.head().map_err(|e| error("stage HEAD", e))?.is_unborn() { + return Ok(State::new(repo.object_hash())); + } + let tree = repo + .head_commit() + .map_err(|e| error("stage HEAD", e))? + .tree_id() + .map_err(|e| error("stage tree", e))? + .detach(); + State::from_tree(&tree, &repo.objects, Default::default()).map_err(|e| error("stage tree", e)) +} +fn work_entry( + repo: &gix::Repository, + path: &str, + previous: Option, +) -> Result> { + let root = repo.workdir().ok_or_else(|| error("add", "no worktree"))?; + safe(root, path)?; + let file = root.join(path); + let Some(m) = meta(&file)? else { + return Ok(None); + }; + if m.is_dir() { + return Ok(None); + } + let (bytes, mode) = if m.file_type().is_symlink() { + let target = fs::read_link(&file).map_err(|e| error("add symlink", e))?; + ( + gix::path::into_bstr(target) + .map_err(|e| error("add symlink", e))? + .into_owned() + .to_vec(), + Mode::SYMLINK, + ) + } else if m.is_file() { + #[cfg(unix)] + let mode = { + use std::os::unix::fs::PermissionsExt; + if m.permissions().mode() & 0o111 != 0 { + Mode::FILE_EXECUTABLE + } else { + Mode::FILE + } + }; + #[cfg(not(unix))] + let mode = if previous == Some(Mode::FILE_EXECUTABLE) { + Mode::FILE_EXECUTABLE + } else { + Mode::FILE + }; + let _ = previous; + (fs::read(&file).map_err(|e| error("add read", e))?, mode) + } else { + return Err(error( + "add", + format!("not a regular file or symlink: {path}"), + )); + }; + let stat = + gix::index::fs::Metadata::from_path_no_follow(&file).map_err(|e| error("add stat", e))?; + let stat = Stat::from_fs(&stat).map_err(|e| error("add stat", e))?; + let id = repo + .write_blob(&bytes) + .map_err(|e| error("add blob", e))? + .detach(); + Ok(Some((stat, id, mode))) +} +fn stage(repo: &gix::Repository, state: State, selected: BTreeSet) -> Result { + // Group conflict stages too; keep unrelated entries verbatim. Avoid scanning and + // compacting the entire index for each selected path (quadratic for add-all). + let mut entries = BTreeMap::>::new(); + for e in state.entries() { + let path = e + .path(&state) + .to_str() + .map_err(|e| error("index path UTF-8", e))? + .to_owned(); + entries + .entry(path) + .or_default() + .push((e.stat, e.id, e.flags, e.mode)); + } + for path in selected { + let previous = entries.get(&path).and_then(|es| es.first()).map(|e| e.3); + if previous == Some(Mode::COMMIT) { + return Err(error("add", "staging submodules is unsupported")); + } + let entry = work_entry(repo, &path, previous)?; + entries.remove(&path); + if let Some((stat, id, mode)) = entry { + // Resolve file/directory replacements using a prefix range and ancestor + // lookups, not two full-index passes. Never implicitly remove a gitlink. + let prefix = format!("{path}/"); + let mut collisions: Vec<_> = entries + .range(prefix.clone()..) + .take_while(|(p, _)| p.starts_with(&prefix)) + .map(|(p, _)| p.clone()) + .collect(); + collisions.extend( + Path::new(&path) + .ancestors() + .skip(1) + .filter_map(Path::to_str) + .filter(|p| entries.contains_key(*p)) + .map(str::to_owned), + ); + for p in collisions { + if entries + .get(&p) + .is_some_and(|es| es.iter().any(|e| e.3 == Mode::COMMIT)) + { + return Err(error( + "add", + "file/directory replacement would remove a submodule", + )); + } + entries.remove(&p); + } + entries.insert(path, vec![(stat, id, Flags::empty(), mode)]); + } + } + let mut state = State::new(repo.object_hash()); + for (path, es) in entries { + for (stat, id, flags, mode) in es { + state.dangerously_push_entry(stat, id, flags, mode, path.as_bytes().as_bstr()); + } + } + state.sort_entries(); + Ok(state) +} +pub(crate) fn add(repo: &gix::Repository, p: AddParams) -> Result { + if !p.all.unwrap_or(false) { + validate_paths(&p.paths)?; + } + for path in &p.paths { + validate(path)?; + } + let root = repo.workdir().ok_or_else(|| error("add", "no worktree"))?; + let state = load(repo)?; + let tracked: BTreeSet<_> = state + .entries() + .iter() + .filter(|e| e.mode != Mode::COMMIT) + .map(|e| e.path(&state).to_str_lossy().into_owned()) + .collect(); + let gitlinks: BTreeSet<_> = state + .entries() + .iter() + .filter(|e| e.mode == Mode::COMMIT) + .map(|e| e.path(&state).to_str_lossy().into_owned()) + .collect(); + // Directory expansion must use the ignore-aware walker, just like add-all. + // A directly named file remains force-addable even when ignored. + let needs_walk = p.all.unwrap_or(false) || p.paths.iter().any(|p| root.join(p).is_dir()); + let untracked: BTreeSet<_> = if needs_walk { + crate::changes::status(repo)? + .untracked + .into_iter() + .collect() + } else { + BTreeSet::new() + }; + let mut selected = BTreeSet::new(); + if p.all.unwrap_or(false) { + selected.extend(tracked.iter().cloned()); + selected.extend(untracked.iter().filter(|p| !gitlinks.contains(*p)).cloned()); + } + for path in &p.paths { + safe(root, path)?; + if gitlinks.contains(path) { + return Err(error("add", "staging submodules is unsupported")); + } + selected.extend( + tracked + .iter() + .filter(|s| matches(s, std::slice::from_ref(path))) + .cloned(), + ); + match meta(&root.join(path))? { + Some(m) if m.is_dir() => { + if root.join(path).join(".git").exists() { + return Err(error("add", "nested repositories are unsupported")); + } + selected.extend( + untracked + .iter() + .filter(|s| { + matches(s, std::slice::from_ref(path)) && !gitlinks.contains(*s) + }) + .cloned(), + ); + } + Some(_) => { + selected.insert(path.clone()); + } + None => { + if !tracked + .iter() + .chain(&gitlinks) + .any(|s| matches(s, std::slice::from_ref(path))) + { + return Err(error( + "add", + format!("path does not exist and is not tracked: {path}"), + )); + } + } + } + } + let paths = selected.iter().cloned().collect(); + publish(repo, stage(repo, state, selected)?)?; + Ok(StagedPaths { paths }) +} +pub(crate) fn unstage(repo: &gix::Repository, p: PathsParams) -> Result { + validate_paths(&p.paths)?; + let mut state = load(repo)?; + let head = head_state(repo)?; + let mut selected = BTreeSet::new(); + for s in [&state, &head] { + selected.extend( + s.entries() + .iter() + .map(|e| e.path(s).to_str_lossy().into_owned()) + .filter(|s| matches(s, &p.paths)), + ); + } + state.remove_entries(|_, path, _| selected.contains(path.to_str().unwrap_or(""))); + for e in head.entries() { + let path = e.path(&head); + if selected.contains(path.to_str().unwrap_or("")) { + state.dangerously_push_entry(e.stat, e.id, e.flags, e.mode, path); + } + } + // A partial reset can otherwise create a D/F conflict with unrelated staging. + check_structure(&state)?; + publish(repo, state)?; + Ok(StagedPaths { + paths: selected.into_iter().collect(), + }) +} +fn check_structure(state: &State) -> Result<()> { + let paths: BTreeSet<_> = state + .entries() + .iter() + .map(|e| e.path(state).to_str_lossy().into_owned()) + .collect(); + for path in &paths { + for parent in Path::new(path).ancestors().skip(1).filter_map(Path::to_str) { + if paths.contains(parent) { + return Err(error("index", "file/directory conflict; select both paths")); + } + } + } + Ok(()) +} +pub(crate) fn rm(repo: &gix::Repository, p: RmParams) -> Result { + validate_paths(&p.paths)?; + let mut state = load(repo)?; + let root = repo.workdir().ok_or_else(|| error("rm", "no worktree"))?; + let selected: BTreeSet<_> = state + .entries() + .iter() + .map(|e| e.path(&state).to_str_lossy().into_owned()) + .filter(|s| matches(s, &p.paths)) + .collect(); + for path in &p.paths { + if !selected + .iter() + .any(|s| matches(s, std::slice::from_ref(path))) + { + return Err(error("rm", format!("path is not tracked: {path}"))); + } + } + if !p.keep_file.unwrap_or(false) { + let head = head_state(repo)?; + // Preflight every path before deleting any file. Do not delete staged or local-only work. + for path in &selected { + safe(root, path)?; + for e in state + .entries() + .iter() + .filter(|e| e.path(&state) == path.as_bytes().as_bstr()) + { + if e.stage_raw() != 0 + || !head.entries().iter().any(|h| { + h.path(&head) == e.path(&state) && h.id == e.id && h.mode == e.mode + }) + { + return Err(error( + "rm", + format!("staged changes: {path}; use keep_file to preserve bytes"), + )); + } + if let Some((_, id, mode)) = work_entry(repo, path, Some(e.mode))? { + if id != e.id || mode != e.mode { + return Err(error("rm", format!("local changes: {path}; use keep_file"))); + } + } else if meta(&root.join(path))?.is_some() { + return Err(error("rm", format!("obstructing directory: {path}"))); + } + if !matches!(e.mode, Mode::FILE | Mode::FILE_EXECUTABLE | Mode::SYMLINK) { + return Err(error("rm", "submodule removal unsupported")); + } + } + } + } + state.remove_entries(|_, path, _| selected.contains(path.to_str().unwrap_or(""))); + // Reserve and fully serialize index.lock before any destructive worktree operation. + let pending = prepare(repo, state)?; + if !p.keep_file.unwrap_or(false) { + for path in &selected { + if meta(&root.join(path))?.is_some() { + fs::remove_file(root.join(path)).map_err(|e| error("rm file", e))?; + } + } + } + pending.commit()?; + Ok(StagedPaths { + paths: selected.into_iter().collect(), + }) +} + +pub(crate) fn commit(repo: &gix::Repository, p: CommitParams) -> Result { + if p.message.trim().is_empty() || p.message.contains('\0') { + return Err(error( + "commit", + "message must be nonblank and contain no NUL", + )); + } + if repo.git_dir().join("rebase-merge").exists() || repo.git_dir().join("rebase-apply").exists() + { + return Err(error( + "commit", + "rebase in progress; use rebase_continue after staging", + )); + } + let override_author = p.author_name.is_some() || p.author_email.is_some(); + let sig = signature(p.author_name, p.author_email)?; + let merge_parent = crate::merge::pending_parent(repo)?; + if merge_parent.is_some() && p.amend.unwrap_or(false) { + return Err(error("commit", "cannot amend during a merge")); + } + let head = repo.head().map_err(|e| error("commit HEAD", e))?; + let old = head.id().map(|id| id.detach()); + let amend = p.amend.unwrap_or(false); + if amend && old.is_none() { + return Err(error("commit amend", "HEAD is unborn")); + } + let previous = old + .map(|id| repo.find_commit(id).map_err(|e| error("commit HEAD", e))) + .transpose()?; + let mut parents: Vec<_> = if amend { + previous + .as_ref() + .into_iter() + .flat_map(|c| c.parent_ids().map(|id| id.detach())) + .collect() + } else { + old.into_iter().collect() + }; + if let Some(id) = merge_parent { + parents.push(id); + } + let mut state = load(repo)?; + if state + .entries() + .iter() + .any(|e| e.stage_raw() != 0 || e.flags.contains(Flags::INTENT_TO_ADD)) + { + return Err(error( + "commit", + "unmerged or intent-to-add index; resolve/stage paths first", + )); + } + if p.all.unwrap_or(false) { + let selected = state + .entries() + .iter() + .filter(|e| e.mode != Mode::COMMIT) + .map(|e| e.path(&state).to_str_lossy().into_owned()) + .collect(); + state = stage(repo, state, selected)?; + } + let tree = tree_from_index(repo, &state)?; + let empty = gix::ObjectId::empty_tree(repo.object_hash()); + let base = if let Some(id) = parents.first() { + repo.find_commit(*id) + .map_err(|e| error("commit parent", e))? + .tree_id() + .map_err(|e| error("commit parent tree", e))? + .detach() + } else { + empty + }; + if tree == base && merge_parent.is_none() && !p.allow_empty.unwrap_or(false) { + return Err(error( + "commit", + "empty commit refused; set allow_empty=true", + )); + } + let author = if amend && !override_author { + previous + .as_ref() + .ok_or_else(|| error("amend", "missing HEAD"))? + .decode() + .map_err(|e| error("amend author", e))? + .author() + .map_err(|e| error("amend author", e))? + .to_owned() + .map_err(|e| error("amend author", e))? + } else { + sig.clone() + }; + let object = gix::objs::Commit { + tree, + parents: parents.into_iter().collect(), + author, + committer: sig.clone(), + encoding: None, + message: p.message.clone().into(), + extra_headers: vec![], + }; + let id = repo + .write_object(&object) + .map_err(|e| error("commit object", e))? + .detach(); + if p.all.unwrap_or(false) { + publish(repo, state)?; + } + update_head( + repo, + old, + id, + &sig, + &format!( + "commit{}: {}", + if amend { + " (amend)" + } else if old.is_none() { + " (initial)" + } else { + "" + }, + p.message.lines().next().unwrap_or("").replace('\r', "") + ), + )?; + if merge_parent.is_some() { + crate::merge::clear(repo)?; + } + Ok(CommitResult { + id: id.to_string(), + head: crate::head_info(repo)?, + warnings: if head.is_detached() { + vec!["commit created on detached HEAD; no branch was advanced".into()] + } else { + vec![] + }, + }) +} +/// Serialize the resolved index without updating any reference (also used by rebase). +pub(crate) fn tree_from_index(repo: &gix::Repository, state: &State) -> Result { + if state + .entries() + .iter() + .any(|e| e.stage_raw() != 0 || e.flags.contains(Flags::INTENT_TO_ADD)) + { + return Err(error( + "commit", + "unmerged or intent-to-add index; resolve/stage paths first", + )); + } + check_structure(state)?; + let empty = repo + .write_object(&gix::objs::Tree { entries: vec![] }) + .map_err(|e| error("commit empty tree", e))? + .detach(); + let tree = repo.find_tree(empty).map_err(|e| error("commit tree", e))?; + let mut editor = + gix::object::tree::Editor::new(&tree).map_err(|e| error("commit tree editor", e))?; + for e in state.entries() { + use gix::objs::tree::EntryKind; + let kind = match e.mode { + Mode::FILE => EntryKind::Blob, + Mode::FILE_EXECUTABLE => EntryKind::BlobExecutable, + Mode::SYMLINK => EntryKind::Link, + Mode::COMMIT => EntryKind::Commit, + _ => return Err(error("commit", "unsupported index mode")), + }; + editor + .upsert(e.path(&state), kind, e.id) + .map_err(|e| error("commit tree entry", e))?; + } + let tree = editor + .write() + .map_err(|e| error("commit tree write", e))? + .detach(); + Ok(tree) +} +pub(crate) fn signature( + name: Option, + email: Option, +) -> Result { + let identity = crate::identity(name, email)?; + let name = identity + .name + .ok_or_else(|| error("identity", "missing name"))?; + let email = identity + .email + .ok_or_else(|| error("identity", "missing email"))?; + if [&name, &email] + .iter() + .any(|s| s.contains(['\n', '\r', '<', '>', '\0'])) + { + return Err(error( + "identity", + "name/email must not contain newlines, NUL or angle brackets", + )); + } + let seconds = std::env::var("GIT_AUTHOR_DATE") + .ok() + .and_then(|s| { + s.split_whitespace() + .next()? + .trim_start_matches("@") + .parse() + .ok() + }) + .unwrap_or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + }); + Ok(gix::actor::Signature { + name: name.into(), + email: email.into(), + time: gix::date::Time::new(seconds, 0), + }) +} +// Separate from parent selection: amend must compare against the replaced commit, not its parent. +pub(crate) fn update_head( + repo: &gix::Repository, + old: Option, + new: gix::ObjectId, + sig: &gix::actor::Signature, + message: &str, +) -> Result<()> { + use gix::refs::{ + Target, + transaction::{Change, LogChange, PreviousValue, RefEdit}, + }; + let mut time = Default::default(); + repo.edit_references_as( + [RefEdit { + name: "HEAD" + .try_into() + .map_err(|e| error("commit HEAD name", e))?, + deref: true, + change: Change::Update { + expected: old + .map(|id| PreviousValue::MustExistAndMatch(Target::Object(id))) + .unwrap_or(PreviousValue::MustNotExist), + new: Target::Object(new), + log: LogChange { + message: message.into(), + force_create_reflog: true, + ..Default::default() + }, + }, + }], + Some(sig.to_ref(&mut time)), + ) + .map_err(|e| error("commit reference compare-and-swap", e))?; + Ok(()) +} diff --git a/userland/git/src/worktrees.rs b/userland/git/src/worktrees.rs new file mode 100644 index 0000000..0dbb4be --- /dev/null +++ b/userland/git/src/worktrees.rs @@ -0,0 +1,447 @@ +//! Linked worktrees use ordinary Git files. Callers hold only the common-dir lock. +use crate::Result; +use crate::{ + branches, + repo::{self, error}, +}; +use std::{ + collections::BTreeSet, + fs, + io::Write, + path::{Path, PathBuf}, +}; + +/// Attach a linked worktree, or adopt existing bytes onto an unborn branch. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct AddParams { + /// Repository name or absolute worktree/bare path under /repos/ or /plugins/. + pub repo: String, + /// Absolute worktree path under /plugins/ or /repos/, without dot segments. + pub path: String, + /// Short local branch name; an existing branch must be checked out nowhere. + pub branch: String, + /// Starting revision for a new branch, default HEAD. Not used for existing branches; forbidden with adopt. + pub from: Option, + /// Attach an existing directory (including empty) to a new unborn branch; only .git is written. + pub adopt: Option, +} + +/// Delete a linked worktree and its private metadata, never the common repository. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct RemoveParams { + /// Absolute linked-worktree root under /plugins/ or /repos/. + pub repo: String, + /// Discard dirty, untracked and pending operation state, default false. + pub force: Option, +} + +/// Linked worktree identity. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct WorktreeInfo { + /// Absolute worktree root. + pub path: String, + /// Private Git metadata directory. + pub admin: String, + /// Checkout feature warnings, empty for adoption and removal. + pub warnings: Vec, +} + +fn canonical(path: &Path) -> Result { + fs::canonicalize(path).map_err(|e| error("worktree path", format!("{}: {e}", path.display()))) +} +fn read(path: &Path) -> Result { + fs::read_to_string(path) + .map_err(|e| error("worktree metadata", format!("{}: {e}", path.display()))) +} +fn main_worktree(common: &Path) -> Result> { + let main = gix::open_opts(common, gix::open::Options::default().open_path_as_is(true)) + .map_err(|e| error("main worktree", e))?; + Ok(main.workdir().map(Path::to_owned)) +} + +/// Scan symbolic HEADs, including unborn branches. Ignore only this handle's private HEAD. +/// A bare common HEAD is a default branch, not a checked-out worktree. +pub(crate) fn ensure_available(repo: &gix::Repository, branch: &str) -> Result<()> { + let common = canonical(repo.common_dir())?; + let current = canonical(repo.git_dir())?; + let mut heads = Vec::new(); + if let Some(path) = main_worktree(&common)? { + heads.push((common.clone(), path)); + } + let entries = match fs::read_dir(common.join("worktrees")) { + Ok(entries) => Some(entries), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(error("worktree scan", e)), + }; + if let Some(entries) = entries { + for entry in entries { + let entry = entry.map_err(|e| error("worktree scan", e))?; + if !entry + .file_type() + .map_err(|e| error("worktree scan", e))? + .is_dir() + { + continue; + } + let admin = entry.path(); + let gitfile = read(&admin.join("gitdir"))?; + let gitfile = PathBuf::from(gitfile.trim()); + let path = gitfile + .parent() + .ok_or_else(|| error("worktree gitdir", "missing worktree path"))? + .to_owned(); + heads.push((admin, path)); + } + } + for (admin, path) in heads { + if canonical(&admin)? == current { + continue; + } + let head = read(&admin.join("HEAD"))?; + if head.trim().strip_prefix("ref: ") == Some(branch) { + return Err(error( + "branch checked out", + format!("{branch} is checked out at {}", path.display()), + )); + } + } + Ok(()) +} + +fn absolute(path: &str) -> Result { + if !path.starts_with('/') || path.contains(['\r', '\n']) { + return Err(error( + "worktree path", + "expected an absolute path without line breaks", + )); + } + repo::repo_path(path) +} +fn separate(path: &Path, common: &Path) -> Result<()> { + if path.starts_with(common) || common.starts_with(path) { + return Err(error( + "worktree path", + "worktree and common repository must not contain one another", + )); + } + Ok(()) +} +fn write_new(path: &Path, bytes: &str) -> Result<()> { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|e| error("worktree create", format!("{}: {e}", path.display())))?; + if let Err(e) = file.write_all(bytes.as_bytes()) { + drop(file); + fs::remove_file(path).map_err(|cleanup| error("worktree write cleanup", cleanup))?; + return Err(error("worktree write", e)); + } + Ok(()) +} + +/// Attach existing bytes to an existing branch without checking out any files. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct AttachParams { + /// Bare common repository path. + pub repo: String, + /// Existing worktree directory; only .git metadata is written. + pub path: String, + /// Existing short local branch name. + pub branch: String, +} +pub(crate) fn attach(repo: &gix::Repository, p: AttachParams) -> Result { + add_with( + repo, + AddParams { + repo: p.repo, + path: p.path, + branch: p.branch, + from: None, + adopt: None, + }, + true, + ) +} +pub(crate) fn add(repo: &gix::Repository, p: AddParams) -> Result { + add_with(repo, p, false) +} +fn add_with(repo: &gix::Repository, p: AddParams, keep_files: bool) -> Result { + let path = absolute(&p.path)?; + let common = canonical(repo.common_dir())?; + separate(&path, &common)?; + let branch = branches::branch_name(&p.branch)?; + ensure_available(repo, &branch)?; + if let Some(current_path) = repo.workdir() { + if crate::head_info(repo)?.branch.as_deref() == Some(&branch) { + return Err(error( + "branch checked out", + format!("{branch} is checked out at {}", current_path.display()), + )); + } + } + let exists = repo + .try_find_reference(branch.as_str()) + .map_err(|e| error("worktree branch", e))? + .is_some(); + let adopt = p.adopt.unwrap_or(false); + if adopt && (exists || p.from.is_some()) { + return Err(error( + "worktree adopt", + "requires a new unborn branch and no from revision", + )); + } + let existed = path.try_exists().map_err(|e| error("worktree path", e))?; + if existed && !path.is_dir() { + return Err(error("worktree path", "not a directory")); + } + let nonempty = if existed { + fs::read_dir(&path) + .map_err(|e| error("worktree directory", e))? + .next() + .transpose() + .map_err(|e| error("worktree directory", e))? + .is_some() + } else { + false + }; + if adopt && !existed { + return Err(error("worktree adopt", "requires an existing directory")); + } + if keep_files && !exists { + return Err(error("worktree attach", "requires an existing branch")); + } + if keep_files && !existed { + return Err(error("worktree attach", "requires an existing directory")); + } + if !adopt && !keep_files && nonempty { + return Err(error( + "worktree path", + format!("{} is nonempty", path.display()), + )); + } + if path.join(".git").exists() { + return Err(error("worktree adopt", ".git already exists")); + } + let id_path = if path.file_name().is_some_and(|s| s == "source") { + path.parent().unwrap_or(&path) + } else { + &path + }; + let id = id_path + .file_name() + .ok_or_else(|| error("worktree id", "missing folder name"))?; + let admin = common.join("worktrees").join(id); + // Resolve before any mutation. Existing branches ignore from, matching checkout semantics. + let start = if adopt { + None + } else { + Some(branches::resolve( + repo, + if exists { + &branch + } else { + p.from.as_deref().unwrap_or("HEAD") + }, + )?) + }; + fs::create_dir_all(common.join("worktrees")).map_err(|e| error("worktree admin", e))?; + fs::create_dir(&admin) + .map_err(|e| error("worktree id clash", format!("{}: {e}", admin.display())))?; + let mut gitfile_created = false; + let result: Result = (|| { + fs::create_dir_all(&path).map_err(|e| error("worktree mkdir", e))?; + separate(&canonical(&path)?, &common)?; + // Start detached to let checkout create/attach the branch after index publication. + fs::write( + admin.join("HEAD"), + start + .map(|id| format!("{id}\n")) + .unwrap_or_else(|| format!("ref: {branch}\n")), + ) + .map_err(|e| error("worktree HEAD", e))?; + fs::write(admin.join("commondir"), "../..\n") + .map_err(|e| error("worktree commondir", e))?; + fs::write(admin.join("gitdir"), format!("{}/.git\n", path.display())) + .map_err(|e| error("worktree gitdir", e))?; + write_new( + &path.join(".git"), + &format!("gitdir: {}\n", admin.display()), + )?; + gitfile_created = true; + let linked = repo::open(&p.path)?; + let warnings = if let Some(id) = start { + let tree = linked + .find_commit(id) + .map_err(|e| error("worktree commit", e))? + .tree_id() + .map_err(|e| error("worktree tree", e))? + .detach(); + if keep_files { + let state = + gix::index::State::from_tree(&tree, &linked.objects, Default::default()) + .map_err(|e| error("worktree attach index", e))?; + crate::staging::publish(&linked, state)?; + fs::write(admin.join("HEAD"), format!("ref: {branch}\n")) + .map_err(|e| error("worktree attach HEAD", e))?; + return Ok(WorktreeInfo { + path: p.path.clone(), + admin: admin.to_string_lossy().into_owned(), + warnings: vec![], + }); + } + let mut warnings = crate::checkout::write_tree_to_worktree(&linked, None, tree, false)?; + warnings.extend( + branches::checkout( + &linked, + branches::CheckoutParams { + repo: p.path.clone(), + target: p.branch.clone(), + create: Some(!exists), + force: None, + }, + )? + .warnings, + ); + warnings + } else { + Vec::new() + }; + Ok(WorktreeInfo { + path: p.path.clone(), + admin: admin.to_string_lossy().into_owned(), + warnings, + }) + })(); + if result.is_err() { + // Adoption never removes source bytes. Non-adoption started empty, so all leaves are ours. + let cleanup = (|| -> std::io::Result<()> { + if adopt || keep_files { + if gitfile_created { + fs::remove_file(path.join(".git"))?; + } + } else if gitfile_created { + fs::remove_dir_all(&path)?; + if existed { + fs::create_dir(&path)?; + } + } else if !existed && path.is_dir() { + fs::remove_dir(&path)?; + } + Ok(()) + })(); + let admin_cleanup = fs::remove_dir_all(&admin); + if let Err(e) = cleanup.and(admin_cleanup) { + return Err(error( + "worktree rollback", + format!("{}; original error: {}", e, result.err().unwrap().message), + )); + } + } + result +} + +pub(crate) fn remove(repo: &gix::Repository, p: RemoveParams) -> Result { + let path = canonical(&absolute(&p.repo)?)?; + let common = canonical(repo.common_dir())?; + let admin = canonical(repo.git_dir())?; + separate(&path, &common)?; + // Only linked metadata directly under common/worktrees is removable, even with force. + if admin.parent() != Some(common.join("worktrees").as_path()) || !path.join(".git").is_file() { + return Err(error( + "worktree remove", + "cannot remove the original/main repository worktree", + )); + } + let backlink = PathBuf::from(read(&admin.join("gitdir"))?.trim()); + if canonical(&backlink)? != canonical(&path.join(".git"))? { + return Err(error( + "worktree remove", + "admin gitdir does not point back to this worktree", + )); + } + if !p.force.unwrap_or(false) { + let s = crate::changes::status(repo)?; + let mut paths: BTreeSet<_> = s + .staged + .into_iter() + .chain(s.unstaged) + .flat_map(|c| std::iter::once(c.path).chain(c.old_path)) + .chain(s.untracked) + .chain(s.conflicted) + .collect(); + if let Err(e) = crate::merge::ensure_idle(repo) { + paths.insert(e.message); + } + if repo.git_dir().join("index.lock").exists() { + paths.insert("index.lock".into()); + } + // Include ignored untracked files too: removing a worktree must not silently lose them. + let index = repo + .index_or_empty() + .map_err(|e| error("worktree index", e))?; + let tracked: BTreeSet<_> = index + .entries() + .iter() + .map(|e| e.path(&index).to_vec()) + .collect(); + collect_untracked(&path, &path, &tracked, &mut paths)?; + if !paths.is_empty() { + return Err(error( + "worktree remove dirty/untracked/pending paths", + paths.into_iter().collect::>().join(", "), + )); + } + } + fs::remove_dir_all(&path).map_err(|e| error("worktree remove files", e))?; + fs::remove_dir_all(&admin).map_err(|e| error("worktree remove admin", e))?; + if let Some(parent) = path.parent() { + if parent.parent() == Some(Path::new("/plugins")) + && parent + .file_name() + .is_some_and(|s| s.to_string_lossy().starts_with("wt-")) + { + match fs::remove_dir(parent) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::DirectoryNotEmpty => {} + Err(e) => return Err(error("worktree remove empty parent", e)), + } + } + } + Ok(WorktreeInfo { + path: p.repo, + admin: admin.to_string_lossy().into_owned(), + warnings: vec![], + }) +} + +fn collect_untracked( + root: &Path, + dir: &Path, + tracked: &BTreeSet>, + paths: &mut BTreeSet, +) -> Result<()> { + for entry in fs::read_dir(dir).map_err(|e| error("worktree directory", e))? { + let entry = entry.map_err(|e| error("worktree directory", e))?; + let path = entry.path(); + if path == root.join(".git") { + continue; + } + if entry + .file_type() + .map_err(|e| error("worktree directory", e))? + .is_dir() + { + collect_untracked(root, &path, tracked, paths)?; + } else { + let relative = path + .strip_prefix(root) + .map_err(|e| error("worktree path", e))? + .to_string_lossy(); + if !tracked.contains(relative.as_bytes()) { + paths.insert(relative.into_owned()); + } + } + } + Ok(()) +} diff --git a/userland/git/test.nu b/userland/git/test.nu new file mode 100644 index 0000000..bf35d4e --- /dev/null +++ b/userland/git/test.nu @@ -0,0 +1,97 @@ +#!/usr/bin/env nu +# Compare a deterministic agent workflow with native Git. Fail on every difference. +def main [component: path] { + let component = ($component | path expand) + let wit = (^wasm-tools component wit $component | complete) + if $wit.exit_code != 0 {error make {msg: $wit.stderr}} + let imports = ($wit.stdout | lines | str trim | where {|line| $line | str starts-with 'import '}) + let forbidden = ($imports | where {|line| not ($line | str starts-with 'import wasi:')}) + if ($imports | is-empty) or not ($forbidden | is-empty) {error make {msg: $'Unexpected component imports: ($imports)'}} + print 'Component imports: PASS (WASI only)' + print ($imports | str join (char newline)) + let root = (mktemp -d -p /tmp/p1-git workflow.XXXXXX) + print $'Fixture: ($root)' + let envs = {GIT_AUTHOR_NAME: 'Agent', GIT_AUTHOR_EMAIL: 'agent@example.test', GIT_COMMITTER_NAME: 'Agent', GIT_COMMITTER_EMAIL: 'agent@example.test', GIT_AUTHOR_DATE: '1700000000 +0000', GIT_COMMITTER_DATE: '1700000000 +0000', GIT_CONFIG_NOSYSTEM: '1', GIT_EDITOR: 'true', HOME: '/tmp/p1-git/empty-home', LC_ALL: 'C', TZ: 'UTC'} + mkdir $envs.HOME + with-env $envs { + for side in [native wasi] { mkdir ($root | path join $side repo) } + let steps = [ + {args: [init -b main], compare: false} + {args: [config user.name Agent], compare: true} + {args: [config user.email agent@example.test], compare: true} + {write: 'base\n', file: 'file.txt'} + {args: [add .], compare: true} + {args: [commit -m initial], compare: true} + {args: [status --short], compare: true} + {args: [status], compare: true} + {args: [log -n 1], compare: true} + {args: [show HEAD], compare: true} + {args: [show HEAD:file.txt], compare: true} + {args: [branch topic], compare: true} + {args: [switch topic], compare: true} + {write: 'topic\n', file: 'topic.txt'} + {args: [add .], compare: true} + {args: [commit -m topic], compare: true} + {args: [switch main], compare: true} + {args: [merge topic], compare: true} + {write: 'changed\n', file: 'file.txt'} + {args: [status --short], compare: true} + {args: [diff], compare: true} + {args: [diff --stat], compare: true} + {args: [status], compare: true} + {args: [commit -am changed], compare: true} + {args: [log --oneline], compare: true} + {args: [switch topic], compare: true} + {write: 'conflict\n', file: 'file.txt'} + {args: [commit -am conflict], compare: true} + {args: [switch main], compare: true} + {args: [merge topic], compare: true, exit: 1} + {args: [status --short], compare: true} + {args: [merge --abort], compare: true} + {args: [worktree add -b linked ../linked], compare: true} + {args: [worktree list], compare: false} + {args: [worktree remove ../linked], compare: true} + {args: [switch topic], compare: true} + {args: [rebase main], compare: true, exit: 1} + {write: 'resolved\n', file: 'file.txt'} + {args: [add file.txt], compare: true} + {args: [rebase --continue], compare: true} + {args: [log --oneline], compare: true} + {args: [switch main], compare: true} + {write: 'left\n', file: 'left.txt'} + {args: [add .], compare: true} + {args: [commit -m left], compare: true} + {args: [switch topic], compare: true} + {write: 'right\n', file: 'right.txt'} + {args: [add .], compare: true} + {args: [commit -m right], compare: true} + {args: [switch main], compare: true} + {args: [merge topic], compare: true} + {args: [branch], compare: true} + {args: [rm right.txt], compare: true} + {args: [diff --cached], compare: true} + {args: [status --porcelain], compare: true} + {args: [commit -m removed], compare: true} + {args: [status --bad-option], compare: true, exit: 129} + {args: [not-a-command], compare: true, exit: 1} + ] + mut failures = 0 + for step in $steps { + if 'write' in $step { + for side in [native wasi] { $step.write | str replace --all '\n' (char newline) | save --force ($root | path join $side repo $step.file) } + continue + } + let args = $step.args + let native = (^git -C ($root | path join native repo) ...$args | complete) + let wasi = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($root)/wasi::/' --env GIT_AUTHOR_NAME --env GIT_AUTHOR_EMAIL --env GIT_COMMITTER_NAME --env GIT_COMMITTER_EMAIL --env GIT_AUTHOR_DATE --env GIT_COMMITTER_DATE $component -C /repo ...$args | complete) + let expected = ($step.exit? | default 0) + let equal = ($native.exit_code == $expected and $wasi.exit_code == $expected and (not $step.compare or $native.stdout == $wasi.stdout)) + print $'($args | str join " "): (if $equal {"PASS"} else {"FAIL"}) native=($native.exit_code) wasi=($wasi.exit_code)' + if not $equal { + $failures += 1 + print ({native: $native, wasi: $wasi} | to json) + } + } + if $failures > 0 {error make {msg: $'($failures) workflow comparisons failed'}} + } +} diff --git a/userland/git/tests/http-server/Cargo.toml b/userland/git/tests/http-server/Cargo.toml new file mode 100644 index 0000000..dc32623 --- /dev/null +++ b/userland/git/tests/http-server/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "git-test-http-server" +version = "0.1.0" +edition = "2024" +[workspace] diff --git a/userland/git/tests/http-server/src/main.rs b/userland/git/tests/http-server/src/main.rs new file mode 100644 index 0000000..72d35af --- /dev/null +++ b/userland/git/tests/http-server/src/main.rs @@ -0,0 +1,86 @@ +//! A local HTTP CGI server for integration tests against native git http-backend. +use std::{ + io::{Read, Write}, + net::TcpListener, + process::{Command, Stdio}, +}; +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().collect(); + let listener = TcpListener::bind("127.0.0.1:0")?; + std::fs::write(&args[2], listener.local_addr()?.port().to_string())?; + for stream in listener.incoming() { + let mut stream = stream?; + if let Err(error) = serve(&mut stream, &args[1]) { + eprintln!("request failed: {error}"); + } + } + Ok(()) +} +fn serve(io: &mut (impl Read + Write), root: &str) -> Result<(), Box> { + let mut header = Vec::new(); + while !header.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + io.read_exact(&mut byte)?; + header.push(byte[0]); + } + let text = std::str::from_utf8(&header)?; + let mut lines = text.split("\r\n"); + let request: Vec<_> = lines + .next() + .ok_or("no request")? + .split_whitespace() + .collect(); + let mut length = 0; + let mut content_type = ""; + for line in lines { + if let Some((key, value)) = line.split_once(':') { + if key.eq_ignore_ascii_case("content-length") { + length = value.trim().parse()?; + } + if key.eq_ignore_ascii_case("content-type") { + content_type = value.trim(); + } + } + } + let mut body = vec![0; length]; + io.read_exact(&mut body)?; + let (path, query) = request[1].split_once('?').unwrap_or((request[1], "")); + let mut child = Command::new("git") + .arg("http-backend") + .env("GIT_PROJECT_ROOT", root) + .env("GIT_HTTP_EXPORT_ALL", "1") + .env("PATH_INFO", path) + .env("QUERY_STRING", query) + .env("REQUEST_METHOD", request[0]) + .env("CONTENT_TYPE", content_type) + .env("CONTENT_LENGTH", length.to_string()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + child.stdin.take().ok_or("no CGI stdin")?.write_all(&body)?; + let response = child.wait_with_output()?; + let split = response + .stdout + .windows(4) + .position(|w| w == b"\r\n\r\n") + .ok_or("invalid CGI response")?; + let headers = std::str::from_utf8(&response.stdout[..split])?; + let status = headers + .lines() + .find_map(|l| l.strip_prefix("Status: ")) + .unwrap_or("200 OK"); + write!( + io, + "HTTP/1.1 {status}\r\nConnection: close\r\nContent-Length: {}\r\n", + response.stdout.len() - split - 4 + )?; + for line in headers.split("\r\n") { + if !line.starts_with("Status:") { + write!(io, "{line}\r\n")?; + } + } + io.write_all(b"\r\n")?; + io.write_all(&response.stdout[split + 4..])?; + io.flush()?; + Ok(()) +} diff --git a/userland/git/vendor/PATCHES.md b/userland/git/vendor/PATCHES.md new file mode 100644 index 0000000..701a163 --- /dev/null +++ b/userland/git/vendor/PATCHES.md @@ -0,0 +1,137 @@ +# WASI patches for Git foundation (SAU-21) + +All crates here are complete crates.io source archives, with their original licenses. +`Cargo.toml` uses local `[patch.crates-io]` paths. No upstream submission has been made. +Changes apply only to `target_os = "wasi"`; native paths retain upstream behavior. +No parallel, signal/interrupt, or worktree-mutation features are enabled. +SAU-24 enables blocking-network-client and a custom wasi:http backend (no curl/reqwest). + +## gix-tempfile 26.0.0: no OS PID on WASI + +Exact panic: `src/forksafe.rs`, `ForksafeTempfile::new`, field +`owning_process_id: std::process::id()`. Ref transactions acquire gix-lock files, +which register these tempfiles. This is registry fork ownership, **not filename generation**. +`std::process::id()` panics with `no pids on this platform` on wasm32-wasip2. + +Patch: a private `process_id()` returns 0 on WASI (no fork, registry is per component +instance), and the native OS PID elsewhere. Use it in creation and both registry +cleanup methods. Random tempfile naming and exclusive file creation are unchanged. +Verified the unpatched ref-updating commit panics; patched commits and index writes pass. + +## gix-pack 0.77.0: owned pack/index/multi-index bytes + +`src/lib.rs`: WASI's `MMap` is `Vec` and the shared `mmap::read_only()` helper +uses `std::fs::read`. Other targets retain memmap2. This covers the pack data, +pack index, and multi-pack-index constructors that all use this helper. +memmap2 0.9.11 selects its unsupported stub on WASI; the original map call returns +`ErrorKind::Unsupported` rather than reading a pack. + +Tests generate a tiny pack via `gix-pack`'s `data::output::bytes::FromEntriesIter`; +the simple v2 index is encoded in the test (fanout, sorted IDs, CRCs, offsets, +pack checksum, index checksum). This avoids enabling the broken streaming-input +feature just for the test. A second test packs all commit/tree/blob objects, +removes every loose object copy and stores the branch only in `packed-refs`, +reopens the repository and reads history, paths, branches, +blob contents, and creates/reads its index. No shell or Git executable is used. + +## gix-odb 0.87.0: owned loose-object bytes + +Additional breakage found by the full tests: `src/store_impls/loose/find.rs` +uses memmap2 for *every loose object read and header lookup*, not just packs. +Without this patch logs and tree/index construction return `unsupported`. +Use the same platform-dependent `gix_pack::MMap` backing and std file reads on WASI. +Decompression, missing-object handling, and validation remain upstream code. + +## gix-index 0.58.0: read index without mmap + +`src/file/init.rs`: WASI reads into a Vec from the already opened file and rewinds +that same handle before checksum streaming. This preserves a single inode snapshot +across concurrent atomic index replacements. Native mmap code is unchanged. +Nonempty v2 index round trip and checksum-corruption rejection pass under WASI. + +## filetime 0.2.29: metadata timestamp readers + +Additional runtime panic found after the mmap fixes: filetime routes WASI to its +generic wasm backend, whose metadata timestamp readers call `unimplemented!()`. +`src/lib.rs` now selects `src/wasi.rs` before that backend. It obtains modified, +accessed, and optional creation times through std Metadata; inaccessible modified/ +accessed times fall back to zero, because the upstream API cannot return an error. +Symlink time mutation remains an explicit Unsupported error; ordinary file-time +setters retain the upstream std File::set_times implementation. Metadata reads are +exercised by index tests; timestamp setters and symlink mutation are not exercised. + +## gix-ref 0.69.0: disable packed-refs mmap through existing API + +No additional vendored crate is needed. On WASI, `src/repo.rs::open` calls +`repo.refs.set_packed_buffer_mmap_threshold(u64::MAX)` before any reference lookup. +Upstream `store/packed/buffer.rs::Buffer::open` uses owned bytes for files at or +below that threshold; otherwise it uses unsupported memmap2. The default threshold +is 32 KiB. Native repository handles retain the upstream default. + +Regression `large_packed_refs_head_branches_and_log` writes 65,638 bytes of sorted +packed refs (1,025 branches), removes the loose HEAD branch and reopens through +our helper. It checks symbolic HEAD, all branch tips/current flag, HEAD log order, +and log by a non-HEAD packed branch. Before the fix this fails on WASI with +`HEAD: Could not open packed refs`; with the fix it passes. Packed refs also consume +owned linear memory; this is not a repository size limit. + +## Intentionally disabled / work for later cards + +- `worktree-mutation`: `gix-worktree-state` depends on `io-close 0.3.7`; it lacks + a WASI File close implementation (`no method close for File`). Add a WASI backend + or write the checkout engine using std file writes. Do not silently ignore writes. +- `blocking-network-client` is now enabled by SAU-24: see the streaming-input patch below. +- `gix-commitgraph` remains unpatched: `src/file/init.rs` maps commit-graph files + with memmap2, unsupported on WASI. Current `log` explicitly uses + `use_commit_graph(false)` and does not need this cache. This is NOT disabled + centrally by `repo::open`. Later merge-base/ahead-behind/revision operations must + disable or avoid commit-graph loading (including direct graph APIs), or patch + that crate to read owned bytes before using it. Some high-level gix walks fall + back to the object database on graph-load errors; direct graph loading can fail. + Existing native Git `objects/info/commit-graph` files are not fixture-tested here. +- No signals or threads are provided; keep `interrupt` and `parallel` off. +- No large-repo memory benchmark was run. Pack + pack-index bytes are held in + 32-bit linear memory, in addition to decoded objects; whole-pack reads have a + materially higher resident-memory cost than demand-paged native mmap. The tiny + fixture is 53 pack bytes for one blob. No peak RSS/linear-memory measurement. +- Multi-pack-index reading uses the patched helper but is not fixture-tested here. + Delta packs, split index, and external Git-generated fixtures are not tested. + +## SAU-24: gix-pack 0.77.0 streaming-input on WASI + +Existing vendored crate extended (no additional crate patch). In `Cargo.toml` and +`Cargo.toml.orig`, allow gix-tempfile under +`cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))`. Browser wasm is still +excluded; gix-tempfile remains `default-features = false`, so no signals are added. +The foundation's PID patch applies unchanged. + +Additional compile error exposed by enabling the feature: bundle writer's +`new_pack_file_resolver` and `resolve_entry` explicitly used `memmap2::Mmap`. +Change those three type annotations in `src/bundle/write/mod.rs` to `crate::MMap`, +reusing the existing owned-byte WASI helper and keeping native mmap behavior. +No pack/index format change. No upstream submission made. + +Verified by local fake Git HTTP servers through wasi:http in cargo.build: V1 +clone writes a received 227-byte / 3-object pack and real index, updates tracking +refs and checks out exact bytes; V2 shallow clone writes the shallow boundary. +This tests the bundle writer, not merely hand-encoded pack indexes. Pack input +is streamed to a tempfile; indexing still reads a whole-pack Vec on WASI and +therefore needs 32-bit memory proportional to pack size. Use clone `depth` to +reduce history. No large-repo memory measurement. + +## Sources inspected + +- https://raw.githubusercontent.com/GitoxideLabs/gitoxide/gix-v0.90.0/gix-ref/src/store/file/mod.rs +- https://raw.githubusercontent.com/GitoxideLabs/gitoxide/gix-v0.90.0/gix-ref/src/store/packed/buffer.rs +- https://raw.githubusercontent.com/GitoxideLabs/gitoxide/gix-v0.90.0/gix/src/types.rs +- https://raw.githubusercontent.com/GitoxideLabs/gitoxide/gix-v0.90.0/gix-commitgraph/src/file/init.rs +- https://raw.githubusercontent.com/GitoxideLabs/gitoxide/gix-v0.90.0/gix/src/revision/walk.rs + +- https://raw.githubusercontent.com/GitoxideLabs/gitoxide/gix-v0.90.0/gix/Cargo.toml +- https://static.crates.io/crates/gix-tempfile/gix-tempfile-26.0.0.crate +- https://static.crates.io/crates/gix-pack/gix-pack-0.77.0.crate +- https://static.crates.io/crates/gix-index/gix-index-0.58.0.crate +- https://static.crates.io/crates/gix-odb/gix-odb-0.87.0.crate +- https://raw.githubusercontent.com/RazrFalcon/memmap2-rs/v0.9.11/src/stub.rs +- https://raw.githubusercontent.com/alexcrichton/filetime/0.2.29/src/wasm.rs +- https://static.crates.io/crates/filetime/filetime-0.2.29.crate diff --git a/userland/git/vendor/filetime/.cargo_vcs_info.json b/userland/git/vendor/filetime/.cargo_vcs_info.json new file mode 100644 index 0000000..f883e3a --- /dev/null +++ b/userland/git/vendor/filetime/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "ab5ee65b5e4fe2de19dbe7d4fe08bc31e945949c" + }, + "path_in_vcs": "" +} \ No newline at end of file diff --git a/userland/git/vendor/filetime/.github/workflows/main.yml b/userland/git/vendor/filetime/.github/workflows/main.yml new file mode 100644 index 0000000..4d7b4a9 --- /dev/null +++ b/userland/git/vendor/filetime/.github/workflows/main.yml @@ -0,0 +1,86 @@ +name: CI +on: [push, pull_request] + +jobs: + test: + name: Test + runs-on: ${{ matrix.os }} + strategy: + matrix: + build: [stable, beta, nightly, macos, win32, win64, mingw] + include: + - build: stable + os: ubuntu-latest + rust: stable + - build: beta + os: ubuntu-latest + rust: beta + - build: nightly + os: ubuntu-latest + rust: nightly + - build: macos + os: macos-latest + rust: stable + - build: win32 + os: windows-latest + rust: stable-i686 + - build: win64 + os: windows-latest + rust: stable-x86_64 + - build: mingw + os: windows-latest + rust: stable-x86_64-gnu + steps: + - uses: actions/checkout@master + - name: Install Rust (rustup) + run: rustup update ${{ matrix.rust }} --no-self-update && rustup default ${{ matrix.rust }} + shell: bash + - run: cargo test + + msrv: + name: MSRV + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - name: Install Rust + run: rustup update 1.75.0 && rustup default 1.75.0 + - run: cargo build + + rustfmt: + name: Rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - name: Install Rust + run: rustup update stable && rustup default stable && rustup component add rustfmt + - run: cargo fmt -- --check + + build: + name: Build for Android and Redox + runs-on: ubuntu-latest + strategy: + matrix: + target: [x86_64-linux-android, x86_64-unknown-redox] + steps: + - uses: actions/checkout@master + - name: Install Rust + run: rustup update stable && rustup default stable && rustup target add ${{ matrix.target }} + - run: cargo build --target ${{ matrix.target }} + + publish_docs: + name: Publish Documentation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - name: Install Rust + run: rustup update stable && rustup default stable + - name: Build documentation + run: cargo doc --no-deps --all-features + - name: Publish documentation + run: | + cd target/doc + git init + git add . + git -c user.name='ci' -c user.email='ci' commit -m init + git push -f -q https://git:${{ secrets.github_token }}@github.com/${{ github.repository }} HEAD:gh-pages + if: github.event_name == 'push' && github.event.ref == 'refs/heads/master' diff --git a/userland/git/vendor/filetime/.gitignore b/userland/git/vendor/filetime/.gitignore new file mode 100644 index 0000000..a9d37c5 --- /dev/null +++ b/userland/git/vendor/filetime/.gitignore @@ -0,0 +1,2 @@ +target +Cargo.lock diff --git a/userland/git/vendor/filetime/Cargo.toml b/userland/git/vendor/filetime/Cargo.toml new file mode 100644 index 0000000..b558a35 --- /dev/null +++ b/userland/git/vendor/filetime/Cargo.toml @@ -0,0 +1,53 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2018" +rust-version = "1.75.0" +name = "filetime" +version = "0.2.29" +authors = ["Alex Crichton "] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = """ +Platform-agnostic accessors of timestamps in File metadata +""" +homepage = "https://github.com/alexcrichton/filetime" +documentation = "https://docs.rs/filetime" +readme = "README.md" +keywords = [ + "timestamp", + "mtime", +] +license = "MIT/Apache-2.0" +repository = "https://github.com/alexcrichton/filetime" + +[lib] +name = "filetime" +path = "src/lib.rs" + +[dependencies.cfg-if] +version = "1.0.0" + +[dev-dependencies.tempfile] +version = "3" + +[target."cfg(unix)".dependencies.libc] +version = "0.2.27" + +[lints.rust.unexpected_cfgs] +level = "warn" +priority = 0 +check-cfg = ["cfg(emulate_second_only_system)"] diff --git a/userland/git/vendor/filetime/Cargo.toml.orig b/userland/git/vendor/filetime/Cargo.toml.orig new file mode 100644 index 0000000..13552c8 --- /dev/null +++ b/userland/git/vendor/filetime/Cargo.toml.orig @@ -0,0 +1,30 @@ +[package] +name = "filetime" +authors = ["Alex Crichton "] +version = "0.2.29" +license = "MIT/Apache-2.0" +readme = "README.md" +keywords = ["timestamp", "mtime"] +repository = "https://github.com/alexcrichton/filetime" +homepage = "https://github.com/alexcrichton/filetime" +documentation = "https://docs.rs/filetime" +description = """ +Platform-agnostic accessors of timestamps in File metadata +""" +edition = "2018" +rust-version = "1.75.0" + +[dependencies] +cfg-if = "1.0.0" + +[target.'cfg(unix)'.dependencies] +libc = "0.2.27" + +[dev-dependencies] +tempfile = "3" + +[lints.rust.unexpected_cfgs] +level = "warn" +check-cfg = [ + 'cfg(emulate_second_only_system)', +] diff --git a/userland/git/vendor/filetime/LICENSE-APACHE b/userland/git/vendor/filetime/LICENSE-APACHE new file mode 100644 index 0000000..16fe87b --- /dev/null +++ b/userland/git/vendor/filetime/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/userland/git/vendor/filetime/LICENSE-MIT b/userland/git/vendor/filetime/LICENSE-MIT new file mode 100644 index 0000000..39e0ed6 --- /dev/null +++ b/userland/git/vendor/filetime/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright (c) 2014 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/userland/git/vendor/filetime/README.md b/userland/git/vendor/filetime/README.md new file mode 100644 index 0000000..86b18cc --- /dev/null +++ b/userland/git/vendor/filetime/README.md @@ -0,0 +1,45 @@ +# filetime + +[Documentation](https://docs.rs/filetime) + +> **Note**: this library is quite old and superseded by functionality in the +> standard library. Nowadays you shouldn't use this crate and you should use +> [`FileTimes`] and [`File::set_times`] instead. + +[`FileTimes`]: https://doc.rust-lang.org/stable/std/fs/struct.FileTimes.html +[`File::set_times`]: https://doc.rust-lang.org/stable/std/fs/struct.File.html#method.set_times + +A helper library for inspecting and setting the various timestamps of files in +Rust. This library takes into account cross-platform differences in terms of +where the timestamps are located, what they are called, and how to convert them +into a platform-independent representation. + +```toml +# Cargo.toml +[dependencies] +filetime = "0.2" +``` + +# Advantages over using `std::fs::Metadata` + +This library, when built with `RUSTFLAGS=--cfg emulate_second_only_system` set, +will return all times rounded down to the second. This emulates the behavior of +some file systems, mostly [HFS](https://en.wikipedia.org/wiki/HFS_Plus), +allowing debugging on other hardware. + +# License + +This project is licensed under either of + + * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or + http://www.apache.org/licenses/LICENSE-2.0) + * MIT license ([LICENSE-MIT](LICENSE-MIT) or + http://opensource.org/licenses/MIT) + +at your option. + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally submitted +for inclusion in Filetime by you, as defined in the Apache-2.0 license, shall be +dual licensed as above, without any additional terms or conditions. diff --git a/userland/git/vendor/filetime/src/lib.rs b/userland/git/vendor/filetime/src/lib.rs new file mode 100644 index 0000000..18ea852 --- /dev/null +++ b/userland/git/vendor/filetime/src/lib.rs @@ -0,0 +1,748 @@ +//! Timestamps for files in Rust +//! +//! This library provides platform-agnostic inspection of the various timestamps +//! present in the standard `fs::Metadata` structure. +//! +//! # Installation +//! +//! Add this to your `Cargo.toml`: +//! +//! ```toml +//! [dependencies] +//! filetime = "0.2" +//! ``` +//! +//! # Usage +//! +//! ```no_run +//! use std::fs; +//! use filetime::FileTime; +//! +//! let metadata = fs::metadata("foo.txt").unwrap(); +//! +//! let mtime = FileTime::from_last_modification_time(&metadata); +//! println!("{}", mtime); +//! +//! let atime = FileTime::from_last_access_time(&metadata); +//! assert!(mtime < atime); +//! +//! // Inspect values that can be interpreted across platforms +//! println!("{}", mtime.unix_seconds()); +//! println!("{}", mtime.nanoseconds()); +//! +//! // Print the platform-specific value of seconds +//! println!("{}", mtime.seconds()); +//! ``` + +use std::fmt; +use std::fs; +use std::io; +use std::path::Path; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +cfg_if::cfg_if! { + if #[cfg(target_os = "redox")] { + #[path = "redox.rs"] + mod imp; + } else if #[cfg(windows)] { + #[path = "windows.rs"] + mod imp; + } else if #[cfg(target_os = "wasi")] { + #[path = "wasi.rs"] + mod imp; + } else if #[cfg(all(target_family = "wasm", not(target_os = "emscripten")))] { + #[path = "wasm.rs"] + mod imp; + } else { + #[path = "unix/mod.rs"] + mod imp; + } +} + +/// A helper structure to represent a timestamp for a file. +/// +/// The actual value contined within is platform-specific and does not have the +/// same meaning across platforms, but comparisons and stringification can be +/// significant among the same platform. +#[derive(Eq, PartialEq, Ord, PartialOrd, Debug, Copy, Clone, Hash)] +pub struct FileTime { + seconds: i64, + nanos: u32, +} + +impl FileTime { + /// Creates a new timestamp representing a 0 time. + /// + /// Useful for creating the base of a cmp::max chain of times. + pub const fn zero() -> FileTime { + FileTime { + seconds: 0, + nanos: 0, + } + } + + const fn emulate_second_only_system(self) -> FileTime { + if cfg!(emulate_second_only_system) { + FileTime { + seconds: self.seconds, + nanos: 0, + } + } else { + self + } + } + + /// Creates a new timestamp representing the current system time. + /// + /// ``` + /// # use filetime::FileTime; + /// # + /// # fn example() -> std::io::Result<()> { + /// # let path = ""; + /// # + /// filetime::set_file_mtime(path, FileTime::now())?; + /// # + /// # Ok(()) + /// # } + /// ``` + /// + /// Equivalent to `FileTime::from_system_time(SystemTime::now())`. + pub fn now() -> FileTime { + FileTime::from_system_time(SystemTime::now()) + } + + /// Creates a new instance of `FileTime` with a number of seconds and + /// nanoseconds relative to the Unix epoch, 1970-01-01T00:00:00Z. + /// + /// Negative seconds represent times before the Unix epoch, and positive + /// values represent times after it. Nanos always count forwards in time. + /// + /// Note that this is typically the relative point that Unix time stamps are + /// from, but on Windows the native time stamp is relative to January 1, + /// 1601 so the return value of `seconds` from the returned `FileTime` + /// instance may not be the same as that passed in. + pub const fn from_unix_time(seconds: i64, nanos: u32) -> FileTime { + FileTime { + seconds: seconds + if cfg!(windows) { 11644473600 } else { 0 }, + nanos, + } + .emulate_second_only_system() + } + + /// Creates a new timestamp from the last modification time listed in the + /// specified metadata. + /// + /// The returned value corresponds to the `mtime` field of `stat` on Unix + /// platforms and the `ftLastWriteTime` field on Windows platforms. + pub fn from_last_modification_time(meta: &fs::Metadata) -> FileTime { + imp::from_last_modification_time(meta).emulate_second_only_system() + } + + /// Creates a new timestamp from the last access time listed in the + /// specified metadata. + /// + /// The returned value corresponds to the `atime` field of `stat` on Unix + /// platforms and the `ftLastAccessTime` field on Windows platforms. + pub fn from_last_access_time(meta: &fs::Metadata) -> FileTime { + imp::from_last_access_time(meta).emulate_second_only_system() + } + + /// Creates a new timestamp from the creation time listed in the specified + /// metadata. + /// + /// The returned value corresponds to the `birthtime` field of `stat` on + /// Unix platforms and the `ftCreationTime` field on Windows platforms. Note + /// that not all Unix platforms have this field available and may return + /// `None` in some circumstances. + pub fn from_creation_time(meta: &fs::Metadata) -> Option { + imp::from_creation_time(meta).map(|x| x.emulate_second_only_system()) + } + + /// Creates a new timestamp from the given SystemTime. + /// + /// Windows counts file times since 1601-01-01T00:00:00Z, and cannot + /// represent times before this, but it's possible to create a SystemTime + /// that does. This function will error if passed such a SystemTime. + pub fn from_system_time(time: SystemTime) -> FileTime { + let epoch = if cfg!(windows) { + UNIX_EPOCH - Duration::from_secs(11644473600) + } else { + UNIX_EPOCH + }; + + time.duration_since(epoch) + .map(|d| FileTime { + seconds: d.as_secs() as i64, + nanos: d.subsec_nanos(), + }) + .unwrap_or_else(|e| { + let until_epoch = e.duration(); + let (sec_offset, nanos) = if until_epoch.subsec_nanos() == 0 { + (0, 0) + } else { + (-1, 1_000_000_000 - until_epoch.subsec_nanos()) + }; + + FileTime { + seconds: -1 * until_epoch.as_secs() as i64 + sec_offset, + nanos, + } + }) + .emulate_second_only_system() + } + + /// Returns the whole number of seconds represented by this timestamp. + /// + /// Note that this value's meaning is **platform specific**. On Unix + /// platform time stamps are typically relative to January 1, 1970, but on + /// Windows platforms time stamps are relative to January 1, 1601. + pub const fn seconds(&self) -> i64 { + self.seconds + } + + /// Returns the whole number of seconds represented by this timestamp, + /// relative to the Unix epoch start of January 1, 1970. + /// + /// Note that this does not return the same value as `seconds` for Windows + /// platforms as seconds are relative to a different date there. + pub const fn unix_seconds(&self) -> i64 { + self.seconds - if cfg!(windows) { 11644473600 } else { 0 } + } + + /// Returns the nanosecond precision of this timestamp. + /// + /// The returned value is always less than one billion and represents a + /// portion of a second forward from the seconds returned by the `seconds` + /// method. + pub const fn nanoseconds(&self) -> u32 { + self.nanos + } +} + +impl fmt::Display for FileTime { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}.{:09}s", self.seconds, self.nanos) + } +} + +impl From for FileTime { + fn from(time: SystemTime) -> FileTime { + FileTime::from_system_time(time) + } +} + +impl From for SystemTime { + fn from(time: FileTime) -> SystemTime { + let seconds = time.unix_seconds(); + if seconds < 0 { + SystemTime::UNIX_EPOCH - Duration::from_secs(-seconds as u64) + + Duration::from_nanos(time.nanoseconds() as u64) + } else { + SystemTime::UNIX_EPOCH + Duration::new(seconds as u64, time.nanoseconds()) + } + } +} + +/// Set the last access and modification times for a file on the filesystem. +/// +/// This function will set the `atime` and `mtime` metadata fields for a file +/// on the local filesystem, returning any error encountered. +pub fn set_file_times

(p: P, atime: FileTime, mtime: FileTime) -> io::Result<()> +where + P: AsRef, +{ + imp::open(p.as_ref())?.set_times( + fs::FileTimes::new() + .set_accessed(atime.into()) + .set_modified(mtime.into()), + ) +} + +/// Set the last access and modification times for a file handle. +/// +/// This function will either or both of the `atime` and `mtime` metadata +/// fields for a file handle , returning any error encountered. If `None` is +/// specified then the time won't be updated. If `None` is specified for both +/// options then no action is taken. +pub fn set_file_handle_times( + f: &fs::File, + atime: Option, + mtime: Option, +) -> io::Result<()> { + let mut times = fs::FileTimes::new(); + if let Some(t) = atime { + times = times.set_accessed(t.into()); + } + if let Some(t) = mtime { + times = times.set_modified(t.into()); + } + f.set_times(times) +} + +/// Set the last access and modification times for a file on the filesystem. +/// This function does not follow symlink. +/// +/// This function will set the `atime` and `mtime` metadata fields for a file +/// on the local filesystem, returning any error encountered. +pub fn set_symlink_file_times

(p: P, atime: FileTime, mtime: FileTime) -> io::Result<()> +where + P: AsRef, +{ + imp::set_symlink_file_times(p.as_ref(), atime, mtime) +} + +/// Set the last modification time for a file on the filesystem. +/// +/// This function will set the `mtime` metadata field for a file on the local +/// filesystem, returning any error encountered. +/// +/// # Platform support +/// +/// Where supported this will attempt to issue just one syscall to update only +/// the `mtime`, but where not supported this may issue one syscall to learn the +/// existing `atime` so only the `mtime` can be configured. +pub fn set_file_mtime

(p: P, mtime: FileTime) -> io::Result<()> +where + P: AsRef, +{ + imp::open(p.as_ref())?.set_times(fs::FileTimes::new().set_modified(mtime.into())) +} + +/// Set the last access time for a file on the filesystem. +/// +/// This function will set the `atime` metadata field for a file on the local +/// filesystem, returning any error encountered. +/// +/// # Platform support +/// +/// Where supported this will attempt to issue just one syscall to update only +/// the `atime`, but where not supported this may issue one syscall to learn the +/// existing `mtime` so only the `atime` can be configured. +pub fn set_file_atime

(p: P, atime: FileTime) -> io::Result<()> +where + P: AsRef, +{ + imp::open(p.as_ref())?.set_times(fs::FileTimes::new().set_accessed(atime.into())) +} + +#[cfg(test)] +mod tests { + use super::{ + set_file_atime, set_file_handle_times, set_file_mtime, set_file_times, + set_symlink_file_times, FileTime, + }; + use std::fs::{self, File}; + use std::io; + use std::path::Path; + use std::time::{Duration, UNIX_EPOCH}; + use tempfile::Builder; + + #[cfg(unix)] + fn make_symlink_file(src: P, dst: Q) -> io::Result<()> + where + P: AsRef, + Q: AsRef, + { + use std::os::unix::fs::symlink; + symlink(src, dst) + } + + #[cfg(windows)] + fn make_symlink_file(src: P, dst: Q) -> io::Result<()> + where + P: AsRef, + Q: AsRef, + { + use std::os::windows::fs::symlink_file; + symlink_file(src, dst) + } + + #[cfg(unix)] + fn make_symlink_dir(src: P, dst: Q) -> io::Result<()> + where + P: AsRef, + Q: AsRef, + { + use std::os::unix::fs::symlink; + symlink(src, dst) + } + + #[cfg(windows)] + fn make_symlink_dir(src: P, dst: Q) -> io::Result<()> + where + P: AsRef, + Q: AsRef, + { + use std::os::windows::fs::symlink_dir; + symlink_dir(src, dst) + } + + #[test] + #[cfg(windows)] + fn from_unix_time_test() { + let time = FileTime::from_unix_time(10, 100_000_000); + assert_eq!(11644473610, time.seconds); + assert_eq!(100_000_000, time.nanos); + + let time = FileTime::from_unix_time(-10, 100_000_000); + assert_eq!(11644473590, time.seconds); + assert_eq!(100_000_000, time.nanos); + + let time = FileTime::from_unix_time(-12_000_000_000, 0); + assert_eq!(-355526400, time.seconds); + assert_eq!(0, time.nanos); + } + + #[test] + #[cfg(not(windows))] + fn from_unix_time_test() { + let time = FileTime::from_unix_time(10, 100_000_000); + assert_eq!(10, time.seconds); + assert_eq!(100_000_000, time.nanos); + + let time = FileTime::from_unix_time(-10, 100_000_000); + assert_eq!(-10, time.seconds); + assert_eq!(100_000_000, time.nanos); + + let time = FileTime::from_unix_time(-12_000_000_000, 0); + assert_eq!(-12_000_000_000, time.seconds); + assert_eq!(0, time.nanos); + } + + #[test] + #[cfg(windows)] + fn to_from_system_time_test() { + let systime = UNIX_EPOCH + Duration::from_secs(10); + let time = FileTime::from_system_time(systime); + assert_eq!(11644473610, time.seconds); + assert_eq!(0, time.nanos); + assert_eq!(systime, time.into()); + + let systime = UNIX_EPOCH - Duration::from_secs(10); + let time = FileTime::from_system_time(systime); + assert_eq!(11644473590, time.seconds); + assert_eq!(0, time.nanos); + assert_eq!(systime, time.into()); + + let systime = UNIX_EPOCH - Duration::from_millis(1100); + let time = FileTime::from_system_time(systime); + assert_eq!(11644473598, time.seconds); + assert_eq!(900_000_000, time.nanos); + assert_eq!(systime, time.into()); + } + + #[test] + #[cfg(not(windows))] + fn to_from_system_time_test() { + let systime = UNIX_EPOCH + Duration::from_secs(10); + let time = FileTime::from_system_time(systime); + assert_eq!(10, time.seconds); + assert_eq!(0, time.nanos); + assert_eq!(systime, time.into()); + + let systime = UNIX_EPOCH - Duration::from_secs(10); + let time = FileTime::from_system_time(systime); + assert_eq!(-10, time.seconds); + assert_eq!(0, time.nanos); + assert_eq!(systime, time.into()); + + let systime = UNIX_EPOCH - Duration::from_millis(1100); + let time = FileTime::from_system_time(systime); + assert_eq!(-2, time.seconds); + assert_eq!(900_000_000, time.nanos); + assert_eq!(systime, time.into()); + + let systime = UNIX_EPOCH - Duration::from_secs(12_000_000); + let time = FileTime::from_system_time(systime); + assert_eq!(-12_000_000, time.seconds); + assert_eq!(0, time.nanos); + assert_eq!(systime, time.into()); + } + + #[test] + fn set_file_times_test() -> io::Result<()> { + let td = Builder::new().prefix("filetime").tempdir()?; + let path = td.path().join("foo.txt"); + let mut f = File::create(&path)?; + + let metadata = fs::metadata(&path)?; + let mtime = FileTime::from_last_modification_time(&metadata); + let atime = FileTime::from_last_access_time(&metadata); + set_file_times(&path, atime, mtime)?; + + let new_mtime = FileTime::from_unix_time(10_000, 0); + set_file_times(&path, atime, new_mtime)?; + + let metadata = fs::metadata(&path)?; + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, new_mtime, "modification should be updated"); + + // Update just mtime + let new_mtime = FileTime::from_unix_time(20_000, 0); + set_file_handle_times(&mut f, None, Some(new_mtime))?; + let metadata = f.metadata()?; + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, new_mtime, "modification time should be updated"); + let new_atime = FileTime::from_last_access_time(&metadata); + assert_eq!(atime, new_atime, "accessed time should not be updated"); + + // Update just atime + let new_atime = FileTime::from_unix_time(30_000, 0); + set_file_handle_times(&mut f, Some(new_atime), None)?; + let metadata = f.metadata()?; + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, new_mtime, "modification time should not be updated"); + let atime = FileTime::from_last_access_time(&metadata); + assert_eq!(atime, new_atime, "accessed time should be updated"); + + let spath = td.path().join("bar.txt"); + make_symlink_file(&path, &spath)?; + let metadata = fs::symlink_metadata(&spath)?; + let smtime = FileTime::from_last_modification_time(&metadata); + + set_file_times(&spath, atime, mtime)?; + + let metadata = fs::metadata(&path)?; + let cur_mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, cur_mtime); + + let metadata = fs::symlink_metadata(&spath)?; + let cur_mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(smtime, cur_mtime); + + set_file_times(&spath, atime, new_mtime)?; + + let metadata = fs::metadata(&path)?; + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, new_mtime); + + let metadata = fs::symlink_metadata(&spath)?; + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, smtime); + Ok(()) + } + + #[test] + fn set_dir_times_test() -> io::Result<()> { + let td = Builder::new().prefix("filetime").tempdir()?; + let path = td.path().join("foo"); + fs::create_dir(&path)?; + + let metadata = fs::metadata(&path)?; + let mtime = FileTime::from_last_modification_time(&metadata); + let atime = FileTime::from_last_access_time(&metadata); + set_file_times(&path, atime, mtime)?; + + let new_mtime = FileTime::from_unix_time(10_000, 0); + set_file_times(&path, atime, new_mtime)?; + + let metadata = fs::metadata(&path)?; + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, new_mtime, "modification should be updated"); + + // Update just mtime + let new_mtime = FileTime::from_unix_time(20_000, 0); + set_file_mtime(&path, new_mtime)?; + let metadata = fs::metadata(&path)?; + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, new_mtime, "modification time should be updated"); + let new_atime = FileTime::from_last_access_time(&metadata); + assert_eq!(atime, new_atime, "accessed time should not be updated"); + + // Update just atime + let new_atime = FileTime::from_unix_time(30_000, 0); + set_file_atime(&path, new_atime)?; + let metadata = fs::metadata(&path)?; + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, new_mtime, "modification time should not be updated"); + let atime = FileTime::from_last_access_time(&metadata); + assert_eq!(atime, new_atime, "accessed time should be updated"); + + let spath = td.path().join("bar"); + make_symlink_dir(&path, &spath)?; + let metadata = fs::symlink_metadata(&spath)?; + let smtime = FileTime::from_last_modification_time(&metadata); + + set_file_times(&spath, atime, mtime)?; + + let metadata = fs::metadata(&path)?; + let cur_mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, cur_mtime); + + let metadata = fs::symlink_metadata(&spath)?; + let cur_mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(smtime, cur_mtime); + + set_file_times(&spath, atime, new_mtime)?; + + let metadata = fs::metadata(&path)?; + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, new_mtime); + + let metadata = fs::symlink_metadata(&spath)?; + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, smtime); + Ok(()) + } + + #[test] + fn set_file_times_pre_unix_epoch_test() { + let td = Builder::new().prefix("filetime").tempdir().unwrap(); + let path = td.path().join("foo.txt"); + File::create(&path).unwrap(); + + let metadata = fs::metadata(&path).unwrap(); + let mtime = FileTime::from_last_modification_time(&metadata); + let atime = FileTime::from_last_access_time(&metadata); + set_file_times(&path, atime, mtime).unwrap(); + + let new_mtime = FileTime::from_unix_time(-10_000, 0); + if cfg!(target_os = "aix") { + // On AIX, os checks if the unix timestamp is valid. + let result = set_file_times(&path, atime, new_mtime); + assert!(result.is_err()); + assert!(result.err().unwrap().kind() == std::io::ErrorKind::InvalidInput); + } else { + set_file_times(&path, atime, new_mtime).unwrap(); + + let metadata = fs::metadata(&path).unwrap(); + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, new_mtime); + } + } + + #[test] + #[cfg(windows)] + fn set_file_times_pre_windows_epoch_test() { + let td = Builder::new().prefix("filetime").tempdir().unwrap(); + let path = td.path().join("foo.txt"); + File::create(&path).unwrap(); + + let metadata = fs::metadata(&path).unwrap(); + let mtime = FileTime::from_last_modification_time(&metadata); + let atime = FileTime::from_last_access_time(&metadata); + set_file_times(&path, atime, mtime).unwrap(); + } + + #[test] + fn set_symlink_file_times_test() { + let td = Builder::new().prefix("filetime").tempdir().unwrap(); + let path = td.path().join("foo.txt"); + File::create(&path).unwrap(); + + let metadata = fs::metadata(&path).unwrap(); + let mtime = FileTime::from_last_modification_time(&metadata); + let atime = FileTime::from_last_access_time(&metadata); + set_symlink_file_times(&path, atime, mtime).unwrap(); + + let new_mtime = FileTime::from_unix_time(10_000, 0); + set_symlink_file_times(&path, atime, new_mtime).unwrap(); + + let metadata = fs::metadata(&path).unwrap(); + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, new_mtime); + + let spath = td.path().join("bar.txt"); + make_symlink_file(&path, &spath).unwrap(); + + let metadata = fs::symlink_metadata(&spath).unwrap(); + let smtime = FileTime::from_last_modification_time(&metadata); + let satime = FileTime::from_last_access_time(&metadata); + set_symlink_file_times(&spath, smtime, satime).unwrap(); + + let metadata = fs::metadata(&path).unwrap(); + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, new_mtime); + + let new_smtime = FileTime::from_unix_time(20_000, 0); + set_symlink_file_times(&spath, atime, new_smtime).unwrap(); + + let metadata = fs::metadata(&spath).unwrap(); + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, new_mtime); + + let metadata = fs::symlink_metadata(&spath).unwrap(); + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, new_smtime); + } + + #[test] + fn set_symlink_dir_times_test() { + let td = Builder::new().prefix("filetime").tempdir().unwrap(); + let path = td.path().join("foo"); + fs::create_dir(&path).unwrap(); + + let metadata = fs::metadata(&path).unwrap(); + let mtime = FileTime::from_last_modification_time(&metadata); + let atime = FileTime::from_last_access_time(&metadata); + set_symlink_file_times(&path, atime, mtime).unwrap(); + + let new_mtime = FileTime::from_unix_time(10_000, 0); + set_symlink_file_times(&path, atime, new_mtime).unwrap(); + + let metadata = fs::metadata(&path).unwrap(); + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, new_mtime); + + let spath = td.path().join("bar"); + make_symlink_dir(&path, &spath).unwrap(); + + let metadata = fs::symlink_metadata(&spath).unwrap(); + let smtime = FileTime::from_last_modification_time(&metadata); + let satime = FileTime::from_last_access_time(&metadata); + set_symlink_file_times(&spath, smtime, satime).unwrap(); + + let metadata = fs::metadata(&path).unwrap(); + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, new_mtime); + + let new_smtime = FileTime::from_unix_time(20_000, 0); + set_symlink_file_times(&spath, atime, new_smtime).unwrap(); + + let metadata = fs::metadata(&spath).unwrap(); + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, new_mtime); + + let metadata = fs::symlink_metadata(&spath).unwrap(); + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, new_smtime); + } + + #[test] + fn set_single_time_test() { + use super::{set_file_atime, set_file_mtime}; + + let td = Builder::new().prefix("filetime").tempdir().unwrap(); + let path = td.path().join("foo.txt"); + File::create(&path).unwrap(); + + let metadata = fs::metadata(&path).unwrap(); + let mtime = FileTime::from_last_modification_time(&metadata); + let atime = FileTime::from_last_access_time(&metadata); + set_file_times(&path, atime, mtime).unwrap(); + + let new_mtime = FileTime::from_unix_time(10_000, 0); + set_file_mtime(&path, new_mtime).unwrap(); + + let metadata = fs::metadata(&path).unwrap(); + let mtime = FileTime::from_last_modification_time(&metadata); + assert_eq!(mtime, new_mtime, "modification time should be updated"); + assert_eq!( + atime, + FileTime::from_last_access_time(&metadata), + "access time should not be updated", + ); + + let new_atime = FileTime::from_unix_time(20_000, 0); + set_file_atime(&path, new_atime).unwrap(); + + let metadata = fs::metadata(&path).unwrap(); + let atime = FileTime::from_last_access_time(&metadata); + assert_eq!(atime, new_atime, "access time should be updated"); + assert_eq!( + mtime, + FileTime::from_last_modification_time(&metadata), + "modification time should not be updated" + ); + } +} diff --git a/userland/git/vendor/filetime/src/redox.rs b/userland/git/vendor/filetime/src/redox.rs new file mode 100644 index 0000000..7fc7599 --- /dev/null +++ b/userland/git/vendor/filetime/src/redox.rs @@ -0,0 +1,37 @@ +use crate::FileTime; +use std::fs::{self}; +use std::io; +use std::os::unix::prelude::*; +use std::path::Path; + +pub const O_NOFOLLOW: i32 = 0x20000; + +pub fn set_symlink_file_times(p: &Path, atime: FileTime, mtime: FileTime) -> io::Result<()> { + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(O_NOFOLLOW) + .open(p)?; + crate::set_file_handle_times(&file, Some(atime), Some(mtime)) +} + +pub fn from_last_modification_time(meta: &fs::Metadata) -> FileTime { + FileTime { + seconds: meta.mtime(), + nanos: meta.mtime_nsec() as u32, + } +} + +pub fn from_last_access_time(meta: &fs::Metadata) -> FileTime { + FileTime { + seconds: meta.atime(), + nanos: meta.atime_nsec() as u32, + } +} + +pub fn from_creation_time(_meta: &fs::Metadata) -> Option { + None +} + +pub fn open(path: &Path) -> io::Result { + fs::File::open(path) +} diff --git a/userland/git/vendor/filetime/src/unix/android.rs b/userland/git/vendor/filetime/src/unix/android.rs new file mode 100644 index 0000000..f85089d --- /dev/null +++ b/userland/git/vendor/filetime/src/unix/android.rs @@ -0,0 +1,31 @@ +use crate::FileTime; +use std::ffi::CString; +use std::io; +use std::os::unix::prelude::*; +use std::path::Path; + +pub fn set_symlink_file_times(p: &Path, atime: FileTime, mtime: FileTime) -> io::Result<()> { + set_times(p, Some(atime), Some(mtime), true) +} + +fn set_times( + p: &Path, + atime: Option, + mtime: Option, + symlink: bool, +) -> io::Result<()> { + let flags = if symlink { + libc::AT_SYMLINK_NOFOLLOW + } else { + 0 + }; + + let p = CString::new(p.as_os_str().as_bytes())?; + let times = [super::to_timespec(&atime), super::to_timespec(&mtime)]; + let rc = unsafe { libc::utimensat(libc::AT_FDCWD, p.as_ptr(), times.as_ptr(), flags) }; + if rc == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} diff --git a/userland/git/vendor/filetime/src/unix/linux.rs b/userland/git/vendor/filetime/src/unix/linux.rs new file mode 100644 index 0000000..e963dc3 --- /dev/null +++ b/userland/git/vendor/filetime/src/unix/linux.rs @@ -0,0 +1,47 @@ +//! On Linux we try to use the more accurate `utimensat` syscall but this isn't +//! always available so we also fall back to `utimes` if we couldn't find +//! `utimensat` at runtime. + +use crate::FileTime; +use std::ffi::CString; +use std::io; +use std::os::unix::prelude::*; +use std::path::Path; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering::SeqCst; + +pub fn set_symlink_file_times(p: &Path, atime: FileTime, mtime: FileTime) -> io::Result<()> { + set_times(p, Some(atime), Some(mtime), true) +} + +fn set_times( + p: &Path, + atime: Option, + mtime: Option, + symlink: bool, +) -> io::Result<()> { + let flags = if symlink { + libc::AT_SYMLINK_NOFOLLOW + } else { + 0 + }; + + // Same as the `if` statement above. + static INVALID: AtomicBool = AtomicBool::new(false); + if !INVALID.load(SeqCst) { + let p = CString::new(p.as_os_str().as_bytes())?; + let times = [super::to_timespec(&atime), super::to_timespec(&mtime)]; + let rc = unsafe { libc::utimensat(libc::AT_FDCWD, p.as_ptr(), times.as_ptr(), flags) }; + if rc == 0 { + return Ok(()); + } + let err = io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::ENOSYS) { + INVALID.store(true, SeqCst); + } else { + return Err(err); + } + } + + super::utimes::set_times(p, atime, mtime, symlink) +} diff --git a/userland/git/vendor/filetime/src/unix/macos.rs b/userland/git/vendor/filetime/src/unix/macos.rs new file mode 100644 index 0000000..6c0fa61 --- /dev/null +++ b/userland/git/vendor/filetime/src/unix/macos.rs @@ -0,0 +1,68 @@ +//! Beginning with macOS 10.13, `utimensat` is supported by the OS, so here, we check if the symbol exists +//! and if not, we fallback to `utimes`. + +use crate::FileTime; +use libc::{c_char, c_int, timespec}; +use std::ffi::{CStr, CString}; +use std::os::unix::prelude::*; +use std::path::Path; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering::SeqCst; +use std::{io, mem}; + +pub fn set_symlink_file_times(p: &Path, atime: FileTime, mtime: FileTime) -> io::Result<()> { + set_times(p, Some(atime), Some(mtime), true) +} + +fn set_times( + p: &Path, + atime: Option, + mtime: Option, + symlink: bool, +) -> io::Result<()> { + // Attempt to use the `utimensat` syscall, but if it's not supported by the + // current kernel then fall back to an older syscall. + if let Some(func) = utimensat() { + let flags = if symlink { + libc::AT_SYMLINK_NOFOLLOW + } else { + 0 + }; + + let p = CString::new(p.as_os_str().as_bytes())?; + let times = [super::to_timespec(&atime), super::to_timespec(&mtime)]; + let rc = unsafe { func(libc::AT_FDCWD, p.as_ptr(), times.as_ptr(), flags) }; + if rc == 0 { + return Ok(()); + } else { + return Err(io::Error::last_os_error()); + } + } + + super::utimes::set_times(p, atime, mtime, symlink) +} + +fn utimensat() -> Option c_int> +{ + static ADDR: AtomicUsize = AtomicUsize::new(0); + unsafe { + fetch(&ADDR, CStr::from_bytes_with_nul_unchecked(b"utimensat\0")) + .map(|sym| mem::transmute(sym)) + } +} + +fn fetch(cache: &AtomicUsize, name: &CStr) -> Option { + match cache.load(SeqCst) { + 0 => {} + 1 => return None, + n => return Some(n), + } + let sym = unsafe { libc::dlsym(libc::RTLD_DEFAULT, name.as_ptr() as *const _) }; + let (val, ret) = if sym.is_null() { + (1, None) + } else { + (sym as usize, Some(sym as usize)) + }; + cache.store(val, SeqCst); + return ret; +} diff --git a/userland/git/vendor/filetime/src/unix/mod.rs b/userland/git/vendor/filetime/src/unix/mod.rs new file mode 100644 index 0000000..72dea9b --- /dev/null +++ b/userland/git/vendor/filetime/src/unix/mod.rs @@ -0,0 +1,73 @@ +use crate::FileTime; +use libc::{time_t, timespec, UTIME_OMIT}; +use std::fs; +use std::io; +use std::os::unix::prelude::*; +use std::path::Path; + +cfg_if::cfg_if! { + if #[cfg(target_os = "linux")] { + mod utimes; + mod linux; + pub use self::linux::*; + } else if #[cfg(target_os = "android")] { + mod android; + pub use self::android::*; + } else if #[cfg(target_os = "macos")] { + mod utimes; + mod macos; + pub use self::macos::*; + } else if #[cfg(any(target_os = "aix", + target_os = "nto", + target_os = "solaris", + target_os = "illumos", + target_os = "emscripten", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "haiku"))] { + mod utimensat; + pub use self::utimensat::*; + } else { + mod utimes; + pub use self::utimes::*; + } +} + +#[allow(dead_code)] +fn to_timespec(ft: &Option) -> timespec { + let mut ts: timespec = unsafe { std::mem::zeroed() }; + if let &Some(ft) = ft { + ts.tv_sec = ft.seconds() as time_t; + ts.tv_nsec = ft.nanoseconds() as _; + } else { + ts.tv_sec = 0; + ts.tv_nsec = UTIME_OMIT as _; + } + + ts +} + +pub fn from_last_modification_time(meta: &fs::Metadata) -> FileTime { + FileTime { + seconds: meta.mtime(), + nanos: meta.mtime_nsec() as u32, + } +} + +pub fn from_last_access_time(meta: &fs::Metadata) -> FileTime { + FileTime { + seconds: meta.atime(), + nanos: meta.atime_nsec() as u32, + } +} + +pub fn from_creation_time(meta: &fs::Metadata) -> Option { + meta.created().map(|i| i.into()).ok() +} + +pub fn open(path: &Path) -> io::Result { + // Try a read-only open, but if that fails try a write-only open, and if + // that fails then give up. + fs::File::open(path).or_else(|_| fs::OpenOptions::new().write(true).open(path)) +} diff --git a/userland/git/vendor/filetime/src/unix/utimensat.rs b/userland/git/vendor/filetime/src/unix/utimensat.rs new file mode 100644 index 0000000..2de5b66 --- /dev/null +++ b/userland/git/vendor/filetime/src/unix/utimensat.rs @@ -0,0 +1,37 @@ +use crate::FileTime; +use std::ffi::CString; +use std::io; +use std::os::unix::prelude::*; +use std::path::Path; + +pub fn set_symlink_file_times(p: &Path, atime: FileTime, mtime: FileTime) -> io::Result<()> { + set_times(p, Some(atime), Some(mtime), true) +} + +fn set_times( + p: &Path, + atime: Option, + mtime: Option, + symlink: bool, +) -> io::Result<()> { + let flags = if symlink { + if cfg!(target_os = "emscripten") { + return Err(io::Error::new( + io::ErrorKind::Other, + "emscripten does not support utimensat for symlinks", + )); + } + libc::AT_SYMLINK_NOFOLLOW + } else { + 0 + }; + + let p = CString::new(p.as_os_str().as_bytes())?; + let times = [super::to_timespec(&atime), super::to_timespec(&mtime)]; + let rc = unsafe { libc::utimensat(libc::AT_FDCWD, p.as_ptr(), times.as_ptr(), flags) }; + if rc == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} diff --git a/userland/git/vendor/filetime/src/unix/utimes.rs b/userland/git/vendor/filetime/src/unix/utimes.rs new file mode 100644 index 0000000..57fb189 --- /dev/null +++ b/userland/git/vendor/filetime/src/unix/utimes.rs @@ -0,0 +1,75 @@ +use crate::FileTime; +use std::ffi::CString; +use std::fs; +use std::io; +use std::os::unix::prelude::*; +use std::path::Path; + +fn get_times( + atime: Option, + mtime: Option, + current: impl FnOnce() -> io::Result, +) -> io::Result> { + let pair = match (atime, mtime) { + (Some(a), Some(b)) => (a, b), + (None, None) => return Ok(None), + (Some(a), None) => { + let meta = current()?; + (a, FileTime::from_last_modification_time(&meta)) + } + (None, Some(b)) => { + let meta = current()?; + (FileTime::from_last_access_time(&meta), b) + } + }; + Ok(Some(pair)) +} + +#[allow(dead_code)] +pub fn set_symlink_file_times(p: &Path, atime: FileTime, mtime: FileTime) -> io::Result<()> { + set_times(p, Some(atime), Some(mtime), true) +} + +pub fn set_times( + p: &Path, + atime: Option, + mtime: Option, + symlink: bool, +) -> io::Result<()> { + let (atime, mtime) = match get_times(atime, mtime, || p.metadata())? { + Some(pair) => pair, + None => return Ok(()), + }; + let p = CString::new(p.as_os_str().as_bytes())?; + let times = [to_timeval(&atime), to_timeval(&mtime)]; + let rc = unsafe { + if symlink { + libc::lutimes(p.as_ptr(), times.as_ptr()) + } else { + libc::utimes(p.as_ptr(), times.as_ptr()) + } + }; + return if rc == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + }; +} + +fn to_timeval(ft: &FileTime) -> libc::timeval { + libc::timeval { + tv_sec: ft.seconds() as libc::time_t, + tv_usec: (ft.nanoseconds() / 1000) as libc::suseconds_t, + } +} + +#[cfg(target_env = "uclibc")] +fn to_timespec(ft: &FileTime) -> libc::timespec { + libc::timespec { + tv_sec: ft.seconds() as libc::time_t, + #[cfg(all(target_arch = "x86_64", target_pointer_width = "32"))] + tv_nsec: (ft.nanoseconds()) as i64, + #[cfg(not(all(target_arch = "x86_64", target_pointer_width = "32")))] + tv_nsec: (ft.nanoseconds()) as libc::c_long, + } +} diff --git a/userland/git/vendor/filetime/src/wasi.rs b/userland/git/vendor/filetime/src/wasi.rs new file mode 100644 index 0000000..7a3cfee --- /dev/null +++ b/userland/git/vendor/filetime/src/wasi.rs @@ -0,0 +1,21 @@ +// WASI exposes timestamps through std metadata, but has no Unix MetadataExt. +use crate::FileTime; +use std::{fs, io, path::Path}; + +pub fn from_last_modification_time(meta: &fs::Metadata) -> FileTime { + meta.modified().map(FileTime::from_system_time).unwrap_or_else(|_| FileTime::zero()) +} + +pub fn from_last_access_time(meta: &fs::Metadata) -> FileTime { + meta.accessed().map(FileTime::from_system_time).unwrap_or_else(|_| FileTime::zero()) +} + +pub fn from_creation_time(meta: &fs::Metadata) -> Option { + meta.created().ok().map(FileTime::from_system_time) +} + +pub fn set_symlink_file_times(_p: &Path, _atime: FileTime, _mtime: FileTime) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::Unsupported, "WASI symlink timestamp mutation is not implemented")) +} + +pub fn open(path: &Path) -> io::Result { fs::File::open(path) } diff --git a/userland/git/vendor/filetime/src/wasm.rs b/userland/git/vendor/filetime/src/wasm.rs new file mode 100644 index 0000000..68bf202 --- /dev/null +++ b/userland/git/vendor/filetime/src/wasm.rs @@ -0,0 +1,24 @@ +use crate::FileTime; +use std::fs; +use std::io; +use std::path::Path; + +pub fn set_symlink_file_times(_p: &Path, _atime: FileTime, _mtime: FileTime) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::Other, "Wasm not implemented")) +} + +pub fn from_last_modification_time(_meta: &fs::Metadata) -> FileTime { + unimplemented!() +} + +pub fn from_last_access_time(_meta: &fs::Metadata) -> FileTime { + unimplemented!() +} + +pub fn from_creation_time(_meta: &fs::Metadata) -> Option { + unimplemented!() +} + +pub fn open(path: &Path) -> io::Result { + fs::File::open(path) +} diff --git a/userland/git/vendor/filetime/src/windows.rs b/userland/git/vendor/filetime/src/windows.rs new file mode 100644 index 0000000..305edac --- /dev/null +++ b/userland/git/vendor/filetime/src/windows.rs @@ -0,0 +1,45 @@ +use crate::FileTime; +use std::fs::{self, OpenOptions}; +use std::io; +use std::os::windows::fs::OpenOptionsExt; +use std::os::windows::prelude::*; +use std::path::Path; + +const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x2000000; +const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x200000; + +pub fn open(p: &Path) -> io::Result { + OpenOptions::new() + .write(true) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(p) +} + +pub fn set_symlink_file_times(p: &Path, atime: FileTime, mtime: FileTime) -> io::Result<()> { + let f = OpenOptions::new() + .write(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS) + .open(p)?; + crate::set_file_handle_times(&f, Some(atime), Some(mtime)) +} + +pub fn from_last_modification_time(meta: &fs::Metadata) -> FileTime { + from_intervals(meta.last_write_time()) +} + +pub fn from_last_access_time(meta: &fs::Metadata) -> FileTime { + from_intervals(meta.last_access_time()) +} + +pub fn from_creation_time(meta: &fs::Metadata) -> Option { + Some(from_intervals(meta.creation_time())) +} + +fn from_intervals(ticks: u64) -> FileTime { + // Windows write times are in 100ns intervals, so do a little math to + // get it into the right representation. + FileTime { + seconds: (ticks / (1_000_000_000 / 100)) as i64, + nanos: ((ticks % (1_000_000_000 / 100)) * 100) as u32, + } +} diff --git a/userland/git/vendor/gix-index/.cargo_vcs_info.json b/userland/git/vendor/gix-index/.cargo_vcs_info.json new file mode 100644 index 0000000..ff3ddd9 --- /dev/null +++ b/userland/git/vendor/gix-index/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "dd39fb66e3b1551c9fe849043e39b357a9799ca1" + }, + "path_in_vcs": "gix-index" +} \ No newline at end of file diff --git a/userland/git/vendor/gix-index/Cargo.lock b/userland/git/vendor/gix-index/Cargo.lock new file mode 100644 index 0000000..ccf9ecb --- /dev/null +++ b/userland/git/vendor/gix-index/Cargo.lock @@ -0,0 +1,1533 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.1.0", +] + +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.60.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.100", + "heapless", + "serde", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[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 = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[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-actor" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78243d3371019288a40a75ba1a58de2815c32591fc87b9f7cb01918bce364ca8" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-bitmap" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "422cd23418fd80e9e965595323975d72abd6364a66ff8f0daade5c105a7f35d2" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-chunk" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6a775aba54f0d65698126cc4214b6bf8ddb208312e4ef4240dfe34b0697b87" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-commitgraph" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b53b700d2a506cf98075099df1fcc0558265cbb47fd86bc6d03c231e4bd72a" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-date" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c538190012dbb536df54734ec38cc1109270c73f2f76585c011dd5b0c9f087c" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", +] + +[[package]] +name = "gix-error" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87ff4b3f3daf01234d38d176b0ec00e92de514cc0e3fb8a8898c5cb7ccd22bb" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-fs" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808f23d897ddfe847d9b7206bda947901885be623bdf96b226a2dccf87f018c7" +dependencies = [ + "bstr", + "gix-error", + "gix-parallel", + "gix-path", + "gix-utils", + "libc", +] + +[[package]] +name = "gix-hash" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4802ffb87e051795822bfdaa8ab9075b23e316684f7054e6ce4a246b2b76f4e" +dependencies = [ + "faster-hex", + "gix-error", + "gix-utils", + "serde", + "sha1dc", + "sha2", +] + +[[package]] +name = "gix-hashtable" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916cb6b2ec42ffaca624e7867bb382a4673015cb4ffcd7330b48085d02e4c615" +dependencies = [ + "gix-hash", + "hashbrown", + "parking_lot", +] + +[[package]] +name = "gix-index" +version = "0.58.0" +dependencies = [ + "bitflags 2.13.0", + "bstr", + "criterion", + "document-features", + "filetime", + "fnv", + "gix-bitmap", + "gix-error", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-parallel", + "gix-trace", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown", + "insta", + "itoa", + "libc", + "memmap2", + "rustix", + "serde", + "smallvec", +] + +[[package]] +name = "gix-lock" +version = "26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64783de116c477c5ea4a7f3b56e18ab363dabfdae2724291ea0587b642cadb3d" +dependencies = [ + "gix-error", + "gix-fs", + "gix-tempfile", + "gix-utils", +] + +[[package]] +name = "gix-object" +version = "0.66.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beaf9086dd00b9d2a177ca3ae83210ce926cee7c0faa112e053851221cd8f94d" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", +] + +[[package]] +name = "gix-parallel" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994cf1eec247ba4be7f9e7c0d58cc73f83eecb5fcb9feb595df5ba3d2078546" +dependencies = [ + "gix-trace", +] + +[[package]] +name = "gix-path" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83976e926a485e6fb4b8d9a0d7a30c53c1f607b81b5d310f06f92fd8385bf16a" +dependencies = [ + "bstr", + "gix-error", + "gix-trace", + "gix-validate", +] + +[[package]] +name = "gix-revwalk" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b46edeacd25d9d3131941ae6bc91002bf7ba10e130393b7e9bc23d528625447" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", +] + +[[package]] +name = "gix-tempfile" +version = "26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fdb135a043d6106b59bc58d59a326b277e495ec26fb9e02a1e2b6354bf4b5a" +dependencies = [ + "gix-fs", + "libc", + "parking_lot", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-trace" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c477c029518d3f235c26db21ff7cccc1fa13c0c31b05c005a55b7affd4ffbd81" + +[[package]] +name = "gix-traverse" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00171e00e91508300d0361072c4eb110ec0f1322d8b95ed36887231702a38ebc" +dependencies = [ + "bitflags 2.13.0", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", +] + +[[package]] +name = "gix-utils" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8e770b8c34311044d1addc7b18957db0ac27183707e1140dc90e6f674a6354e" +dependencies = [ + "fastrand", + "getrandom", + "prodash", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b617c17754bf37ee7a9b48e33ffb806abecbbed779ba415199ac1480acf699" +dependencies = [ + "bstr", + "gix-error", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[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.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[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 = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "regex", + "similar", + "strip-ansi-escapes", + "tempfile", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[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.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" +dependencies = [ + "defmt 1.1.0", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-static" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[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 = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prodash" +version = "31.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf52d87428247c5aa3760ac8e7d8b13ec7b3579b955a8c3f311668b6860acba5" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +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 = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "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.0", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1dc" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce90db4b52deca3f18199593dda33de804c4837d7c94423e5407e8248d37ecf" + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[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.60.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[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.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/userland/git/vendor/gix-index/Cargo.toml b/userland/git/vendor/gix-index/Cargo.toml new file mode 100644 index 0000000..fc31ba9 --- /dev/null +++ b/userland/git/vendor/gix-index/Cargo.toml @@ -0,0 +1,224 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +rust-version = "1.88" +name = "gix-index" +version = "0.58.0" +authors = ["Sebastian Thiel "] +build = false +include = [ + "/src/**/*", + "/LICENSE-*", + "/README.md", +] +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "A work-in-progress crate of the gitoxide project dedicated implementing the git index file" +readme = "README.md" +license = "MIT OR Apache-2.0" +repository = "https://github.com/GitoxideLabs/gitoxide" +resolver = "2" + +[package.metadata.docs.rs] +features = [ + "sha1", + "document-features", + "serde", +] + +[features] +serde = [ + "dep:serde", + "smallvec/serde", + "gix-hash/serde", +] +sha1 = ["gix-hash/sha1"] +sha256 = ["gix-hash/sha256"] + +[lib] +name = "gix_index" +path = "src/lib.rs" +test = true +doctest = false + +[dependencies.bitflags] +version = "2" + +[dependencies.bstr] +version = "1.12.0" +default-features = false + +[dependencies.document-features] +version = "0.2.0" +optional = true + +[dependencies.filetime] +version = "0.2.29" + +[dependencies.fnv] +version = "1.0.7" + +[dependencies.gix-bitmap] +version = "^0.6.1" + +[dependencies.gix-error] +version = "^0.5.2" + +[dependencies.gix-fs] +version = "^0.24.1" + +[dependencies.gix-hash] +version = "^0.28.1" + +[dependencies.gix-lock] +version = "^26.0.0" + +[dependencies.gix-object] +version = "^0.66.1" + +[dependencies.gix-parallel] +version = "^0.50.0" + +[dependencies.gix-trace] +version = "^0.3.1" + +[dependencies.gix-traverse] +version = "^0.64.0" + +[dependencies.gix-utils] +version = "^0.5.1" +features = ["progress"] + +[dependencies.gix-validate] +version = "^0.13.1" + +[dependencies.hashbrown] +version = "0.17.1" + +[dependencies.itoa] +version = "1.0.17" + +[dependencies.memmap2] +version = "0.9.11" + +[dependencies.serde] +version = "1.0.114" +features = ["derive"] +optional = true +default-features = false + +[dependencies.smallvec] +version = "1.15.1" + +[dev-dependencies.criterion] +version = "0.8.2" + +[dev-dependencies.filetime] +version = "0.2.29" + +[dev-dependencies.insta] +version = "1.46.3" +features = ["filters"] + +[target."cfg(unix)".dependencies.libc] +version = "0.2.186" + +[target."cfg(unix)".dependencies.rustix] +version = "1.1.2" +features = [ + "std", + "fs", +] +default-features = false + +[lints.clippy] +assert_is_empty = "allow" +bool_to_int_with_if = "allow" +borrow_as_ptr = "allow" +cast_lossless = "allow" +cast_possible_truncation = "allow" +cast_possible_wrap = "allow" +cast_precision_loss = "allow" +cast_sign_loss = "allow" +checked_conversions = "allow" +copy_iterator = "allow" +default_trait_access = "allow" +doc_markdown = "allow" +empty_docs = "allow" +enum_glob_use = "allow" +explicit_deref_methods = "allow" +explicit_into_iter_loop = "allow" +explicit_iter_loop = "allow" +filter_map_next = "allow" +fn_params_excessive_bools = "allow" +if_not_else = "allow" +ignored_unit_patterns = "allow" +implicit_clone = "allow" +inconsistent_struct_constructor = "allow" +inefficient_to_string = "allow" +inline_always = "allow" +items_after_statements = "allow" +iter_not_returning_iterator = "allow" +iter_without_into_iter = "allow" +large_enum_variant = "allow" +large_stack_arrays = "allow" +manual_assert = "allow" +manual_is_variant_and = "allow" +manual_let_else = "allow" +manual_string_new = "allow" +many_single_char_names = "allow" +match_bool = "allow" +match_same_arms = "allow" +match_wild_err_arm = "allow" +match_wildcard_for_single_variants = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +module_name_repetitions = "allow" +must_use_candidate = "allow" +mut_mut = "allow" +naive_bytecount = "allow" +needless_continue = "allow" +needless_for_each = "allow" +needless_pass_by_value = "allow" +needless_raw_string_hashes = "allow" +no_effect_underscore_binding = "allow" +option_option = "allow" +range_plus_one = "allow" +redundant_else = "allow" +result_large_err = "allow" +return_self_not_must_use = "allow" +should_panic_without_expect = "allow" +similar_names = "allow" +single_match_else = "allow" +stable_sort_primitive = "allow" +struct_excessive_bools = "allow" +struct_field_names = "allow" +too_long_first_doc_paragraph = "allow" +too_many_lines = "allow" +transmute_ptr_to_ptr = "allow" +trivially_copy_pass_by_ref = "allow" +unnecessary_join = "allow" +unnecessary_wraps = "allow" +unreadable_literal = "allow" +unused_self = "allow" +used_underscore_binding = "allow" +wildcard_imports = "allow" + +[lints.clippy.pedantic] +level = "warn" +priority = -1 + +[lints.rust] diff --git a/userland/git/vendor/gix-index/Cargo.toml.orig b/userland/git/vendor/gix-index/Cargo.toml.orig new file mode 100644 index 0000000..8794a4d --- /dev/null +++ b/userland/git/vendor/gix-index/Cargo.toml.orig @@ -0,0 +1,78 @@ +lints.workspace = true + +[package] +name = "gix-index" +version = "0.58.0" +repository = "https://github.com/GitoxideLabs/gitoxide" +license = "MIT OR Apache-2.0" +description = "A work-in-progress crate of the gitoxide project dedicated implementing the git index file" +authors = ["Sebastian Thiel "] +edition = "2024" +include = ["/src/**/*", "/LICENSE-*", "/README.md"] +rust-version = "1.88" + + +[lib] +doctest = false +test = true + +[[bench]] +name = "from-tree" +harness = false +path = "./benches/from_tree.rs" + +[features] +## Enable support for the SHA-1 hash by enabling the respective feature in the `gix-hash` crate. +sha1 = ["gix-hash/sha1"] +## Enable support for the SHA-256 hash by enabling the respective feature in the `gix-hash` crate. +sha256 = ["gix-hash/sha256"] +## Data structures implement `serde::Serialize` and `serde::Deserialize`. +serde = ["dep:serde", "smallvec/serde", "gix-hash/serde"] + +[dependencies] +gix-trace = { version = "^0.3.1", path = "../gix-trace" } +gix-parallel = { version = "^0.50.0", path = "../gix-parallel" } +gix-hash = { version = "^0.28.1", path = "../gix-hash" } +gix-error = { version = "^0.5.2", path = "../gix-error" } +gix-bitmap = { version = "^0.6.1", path = "../gix-bitmap" } +gix-object = { version = "^0.66.1", path = "../gix-object" } +gix-validate = { version = "^0.13.1", path = "../gix-validate" } +gix-traverse = { version = "^0.64.0", path = "../gix-traverse" } +gix-lock = { version = "^26.0.0", path = "../gix-lock" } +gix-fs = { version = "^0.24.1", path = "../gix-fs" } +gix-utils = { version = "^0.5.1", path = "../gix-utils", features = ["progress"] } + +hashbrown = "0.17.1" +fnv = "1.0.7" +memmap2 = "0.9.11" +filetime = "0.2.29" +bstr = { version = "1.12.0", default-features = false } + +serde = { version = "1.0.114", optional = true, default-features = false, features = [ + "derive", +] } +smallvec = "1.15.1" +itoa = "1.0.17" +bitflags = "2" + +document-features = { version = "0.2.0", optional = true } + +[target.'cfg(unix)'.dependencies] +rustix = { version = "1.1.2", default-features = false, features = [ + "std", + "fs", +] } +libc = { version = "0.2.186" } + +[dev-dependencies] +criterion = "0.8.2" +gix-testtools = { path = "../tests/tools", default-features = false, features = ["sha1", "sha256"] } +gix-hash = { path = "../gix-hash", features = ["sha1", "sha256"] } +gix-hashtable = { path = "../gix-hashtable" } +gix-odb = { path = "../gix-odb" } +gix-object = { path = "../gix-object" } +filetime = "0.2.29" +insta = { version = "1.46.3", features = ["filters"] } + +[package.metadata.docs.rs] +features = ["sha1", "document-features", "serde"] diff --git a/userland/git/vendor/gix-index/LICENSE-APACHE b/userland/git/vendor/gix-index/LICENSE-APACHE new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/userland/git/vendor/gix-index/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/userland/git/vendor/gix-index/LICENSE-MIT b/userland/git/vendor/gix-index/LICENSE-MIT new file mode 100644 index 0000000..969d061 --- /dev/null +++ b/userland/git/vendor/gix-index/LICENSE-MIT @@ -0,0 +1,17 @@ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/userland/git/vendor/gix-index/README.md b/userland/git/vendor/gix-index/README.md new file mode 100644 index 0000000..f239be6 --- /dev/null +++ b/userland/git/vendor/gix-index/README.md @@ -0,0 +1,11 @@ + +#### Test fixtures + +Most of the test indices are snatched directly from the unit test suite of `git` itself, usually by running something like the following + +```shell + ./t1700-split-index.sh -r 2 --debug +``` + +Then one finds all test state and the index in particular in `trash directory/t1700-split-index/.git/index` and can possibly copy it over and use as fixture. +The preferred way is to find a test of interest, and use its setup code within one of our own fixture scripts that are executed once to generate the file of interest. diff --git a/userland/git/vendor/gix-index/src/access/mod.rs b/userland/git/vendor/gix-index/src/access/mod.rs new file mode 100644 index 0000000..420ee7f --- /dev/null +++ b/userland/git/vendor/gix-index/src/access/mod.rs @@ -0,0 +1,708 @@ +use std::{cmp::Ordering, ops::Range}; + +use bstr::{BStr, ByteSlice, ByteVec}; +use filetime::FileTime; + +use crate::{ + AccelerateLookup, Entry, PathStorage, PathStorageRef, State, Version, entry, + entry::{Stage, StageRaw}, + extension, +}; + +// TODO: integrate this somehow, somewhere, depending on later usage. +#[expect(dead_code, reason = "to be used for when we handle checkouts/resets better")] +mod sparse; + +/// General information and entries +impl State { + /// Return the version used to store this state's information on disk. + pub fn version(&self) -> Version { + self.version + } + + /// Returns time at which the state was created, indicating its freshness compared to other files on disk. + pub fn timestamp(&self) -> FileTime { + self.timestamp + } + + /// Updates the timestamp of this state, indicating its freshness compared to other files on disk. + /// + /// Be careful about using this as setting a timestamp without correctly updating the index + /// **will cause (file system) race conditions** see racy-git.txt in the git documentation + /// for more details. + pub fn set_timestamp(&mut self, timestamp: FileTime) { + self.timestamp = timestamp; + } + + /// Return the kind of hashes used in this instance. + pub fn object_hash(&self) -> gix_hash::Kind { + self.object_hash + } + + /// Return our entries + pub fn entries(&self) -> &[Entry] { + &self.entries + } + /// Return our path backing, the place which keeps all paths one after another, with entries storing only the range to access them. + pub fn path_backing(&self) -> &PathStorageRef { + &self.path_backing + } + + /// Runs `filter_map` on all entries, returning an iterator over all paths along with the result of `filter_map`. + pub fn entries_with_paths_by_filter_map<'a, T>( + &'a self, + mut filter_map: impl FnMut(&'a BStr, &Entry) -> Option + 'a, + ) -> impl Iterator + 'a { + self.entries.iter().filter_map(move |e| { + let p = e.path(self); + filter_map(p, e).map(|t| (p, t)) + }) + } + /// Return mutable entries along with their path, as obtained from `backing`. + pub fn entries_mut_with_paths_in<'state, 'backing>( + &'state mut self, + backing: &'backing PathStorageRef, + ) -> impl Iterator { + self.entries.iter_mut().map(move |e| { + let path = backing[e.path.clone()].as_bstr(); + (e, path) + }) + } + + /// Find the entry index in [`entries()`][State::entries()] matching the given `path` and `stage`, or `None`. + /// + /// The `path` must use the repository-relative, slash-separated [`State`] path format. + /// + /// Use the index for accessing multiple stages if they exists, but at least the single matching entry. + pub fn entry_index_by_path_and_stage(&self, path: &BStr, stage: entry::Stage) -> Option { + let mut stage_cmp = Ordering::Equal; + let idx = self + .entries + .binary_search_by(|e| { + let res = e.path(self).cmp(path); + if res.is_eq() { + stage_cmp = e.stage().cmp(&stage); + } + res + }) + .ok()?; + self.entry_index_by_idx_and_stage(path, idx, stage as StageRaw, stage_cmp) + } + + /// Walk as far in `direction` as possible, with [`Ordering::Greater`] towards higher stages, and [`Ordering::Less`] + /// towards lower stages, and return the lowest or highest seen stage. + /// Return `None` if there is no greater or smaller stage. + fn walk_entry_stages(&self, path: &BStr, base: usize, direction: Ordering) -> Option { + match direction { + Ordering::Greater => self + .entries + .get(base + 1..)? + .iter() + .enumerate() + .take_while(|(_, e)| e.path(self) == path) + .last() + .map(|(idx, _)| base + 1 + idx), + Ordering::Equal => Some(base), + Ordering::Less => self.entries[..base] + .iter() + .enumerate() + .rev() + .take_while(|(_, e)| e.path(self) == path) + .last() + .map(|(idx, _)| idx), + } + } + + fn entry_index_by_idx_and_stage( + &self, + path: &BStr, + idx: usize, + wanted_stage: entry::StageRaw, + stage_cmp: Ordering, + ) -> Option { + match stage_cmp { + Ordering::Greater => self.entries[..idx] + .iter() + .enumerate() + .rev() + .take_while(|(_, e)| e.path(self) == path) + .find_map(|(idx, e)| (e.stage_raw() == wanted_stage).then_some(idx)), + Ordering::Equal => Some(idx), + Ordering::Less => self + .entries + .get(idx + 1..)? + .iter() + .enumerate() + .take_while(|(_, e)| e.path(self) == path) + .find_map(|(ofs, e)| (e.stage_raw() == wanted_stage).then_some(idx + ofs + 1)), + } + } + + /// Return a data structure to help with case-insensitive lookups. + /// + /// It's required perform any case-insensitive lookup. + /// TODO: needs multi-threaded insertion, raw-table to have multiple locks depending on bucket. + pub fn prepare_icase_backing(&self) -> AccelerateLookup<'_> { + let _span = gix_trace::detail!("prepare_icase_backing", entries = self.entries.len()); + let mut out = AccelerateLookup::with_capacity(self.entries.len()); + for entry in &self.entries { + let entry_path = entry.path(self); + let hash = AccelerateLookup::icase_hash(entry_path); + out.icase_entries + .insert_unique(hash, entry, |e| AccelerateLookup::icase_hash(e.path(self))); + if entry_is_dir(entry) { + out.icase_dirs.insert_unique( + hash, + crate::DirEntry { + entry, + dir_end: entry.path.end, + }, + |dir| AccelerateLookup::icase_hash(dir.path(self)), + ); + } + + let mut last_pos = entry_path.len(); + while let Some(slash_idx) = entry_path[..last_pos].rfind_byte(b'/') { + let dir = entry_path[..slash_idx].as_bstr(); + last_pos = slash_idx; + let dir_range = entry.path.start..(entry.path.start + dir.len()); + + let hash = AccelerateLookup::icase_hash(dir); + if out + .icase_dirs + .find(hash, |dir| { + dir.path(self) == self.path_backing[dir_range.clone()].as_bstr() + }) + .is_none() + { + out.icase_dirs.insert_unique( + hash, + crate::DirEntry { + entry, + dir_end: dir_range.end, + }, + |dir| AccelerateLookup::icase_hash(dir.path(self)), + ); + } else { + break; + } + } + } + gix_trace::debug!(directories = out.icase_dirs.len(), "stored directories"); + out + } + + /// Return the entry at `path` that is at the lowest available stage, using `lookup` for acceleration. + /// It must have been created from this instance, and was ideally kept up-to-date with it. + /// + /// The `path` must use the repository-relative, slash-separated [`State`] path format. + /// + /// If `ignore_case` is `true`, a case-insensitive (ASCII-folding only) search will be performed. + pub fn entry_by_path_icase<'a>( + &'a self, + path: &BStr, + ignore_case: bool, + lookup: &AccelerateLookup<'a>, + ) -> Option<&'a Entry> { + lookup + .icase_entries + .find(AccelerateLookup::icase_hash(path), |e| { + let entry_path = e.path(self); + if entry_path == path { + return true; + } + if !ignore_case { + return false; + } + entry_path.eq_ignore_ascii_case(path) + }) + .copied() + } + + /// Return the entry (at any stage) that is inside `directory`, or `None`, + /// or a directory itself like a submodule or sparse directory, using `lookup` for acceleration. + /// + /// The `directory` must use the repository-relative, slash-separated [`State`] path format. + /// + /// If `ignore_case` is set, a case-insensitive (ASCII-folding only) search will be performed. + pub fn entry_closest_to_directory_or_directory_icase<'a>( + &'a self, + directory: &BStr, + ignore_case: bool, + lookup: &AccelerateLookup<'a>, + ) -> Option<&'a Entry> { + lookup + .icase_dirs + .find(AccelerateLookup::icase_hash(directory), |dir| { + let dir_path = dir.path(self); + if dir_path == directory { + return true; + } + if !ignore_case { + return false; + } + dir_path.eq_ignore_ascii_case(directory) + }) + .map(|dir| dir.entry) + } + + /// Return the entry (at any stage) that is inside `directory`, or `None`, + /// or that is a directory itself like a submodule or sparse directory. + /// + /// The `directory` must use the repository-relative, slash-separated [`State`] path format. + /// + /// Note that this is a *case-sensitive* search. + pub fn entry_closest_to_directory_or_directory(&self, directory: &BStr) -> Option<&Entry> { + let idx = match self.entry_index_by_path(directory) { + Ok(idx) => { + let entry = &self.entries[idx]; + return entry_is_dir(entry).then_some(entry); + } + Err(closest_idx) => closest_idx, + }; + for entry in &self.entries[idx..] { + let path = entry.path(self); + if path.get(..directory.len())? != directory { + break; + } + let dir_char = path.get(directory.len())?; + if *dir_char > b'/' { + break; + } + if *dir_char < b'/' { + continue; + } + return Some(entry); + } + None + } + + /// Check if `path` is a directory that contains entries in the index, or is a submodule. + /// + /// The `path` must use the repository-relative, slash-separated [`State`] path format. + /// + /// Returns `true` if there is at least one entry in the index whose path starts with `path/`, + /// indicating that `path` is a directory containing indexed files. + /// + /// For example, if the index contains an entry at `dirname/file`, then calling this method + /// with `dirname` would return `true`, but calling it with `dir` would return `false`. + /// + /// Note that this is a case-sensitive search. + pub fn path_is_directory(&self, path: &BStr) -> bool { + self.entry_closest_to_directory_or_directory(path).is_some() + } + + /// Check if `path` is a directory that contains entries in the index or is a submodule, + /// with optional case-insensitive matching. + /// + /// The `path` must use the repository-relative, slash-separated [`State`] path format. + /// + /// Returns `true` if there is at least one entry in the index whose path starts with `path/`, + /// indicating that `path` is a directory containing indexed files. + /// + /// If `ignore_case` is `true`, a case-insensitive (ASCII-folding only) search will be performed. + /// + /// For example, if the index contains an entry at `dirname/file`, then calling this method + /// with `dirname` (or `DirName` with `ignore_case = true`) would return `true`, but calling it + /// with `dir` would return `false`. + pub fn path_is_directory_icase<'a>( + &'a self, + path: &BStr, + ignore_case: bool, + lookup: &AccelerateLookup<'a>, + ) -> bool { + self.entry_closest_to_directory_or_directory_icase(path, ignore_case, lookup) + .is_some() + } + + /// Find the entry index in [`entries()[..upper_bound]`][State::entries()] matching the given `path` and `stage`, + /// or `None`. + /// + /// The `path` must use the repository-relative, slash-separated [`State`] path format. + /// + /// Use the index for accessing multiple stages if they exists, but at least the single matching entry. + /// + /// # Panics + /// + /// If `upper_bound` is out of bounds of our entries array. + pub fn entry_index_by_path_and_stage_bounded( + &self, + path: &BStr, + stage: entry::Stage, + upper_bound: usize, + ) -> Option { + self.entries[..upper_bound] + .binary_search_by(|e| e.path(self).cmp(path).then_with(|| e.stage().cmp(&stage))) + .ok() + } + + /// Like [`entry_index_by_path_and_stage()`](State::entry_index_by_path_and_stage()), + /// but returns the entry instead of the index. + /// + /// The `path` must use the repository-relative, slash-separated [`State`] path format. + pub fn entry_by_path_and_stage(&self, path: &BStr, stage: entry::Stage) -> Option<&Entry> { + self.entry_index_by_path_and_stage(path, stage) + .map(|idx| &self.entries[idx]) + } + + /// Return the entry at `path` that is either at stage 0, or at stage 2 (ours) in case of a merge conflict. + /// + /// The `path` must use the repository-relative, slash-separated [`State`] path format. + /// + /// Using this method is more efficient in comparison to doing two searches, one for stage 0 and one for stage 2. + pub fn entry_by_path(&self, path: &BStr) -> Option<&Entry> { + let mut stage_at_index = 0; + let idx = self + .entries + .binary_search_by(|e| { + let res = e.path(self).cmp(path); + if res.is_eq() { + stage_at_index = e.stage_raw(); + } + res + }) + .ok()?; + let idx = if stage_at_index == 0 || stage_at_index == 2 { + idx + } else { + self.entry_index_by_idx_and_stage(path, idx, Stage::Ours as StageRaw, stage_at_index.cmp(&2))? + }; + Some(&self.entries[idx]) + } + + /// Return the index at `Ok(index)` where the entry matching `path` (in any stage) can be found, or return + /// `Err(index)` to indicate the insertion position at which an entry with `path` would fit in. + /// + /// The `path` must use the repository-relative, slash-separated [`State`] path format. + pub fn entry_index_by_path(&self, path: &BStr) -> Result { + self.entries.binary_search_by(|e| e.path(self).cmp(path)) + } + + /// Return the slice of entries which all share the same `prefix`, or `None` if there isn't a single such entry. + /// + /// The `prefix` must use the repository-relative, slash-separated [`State`] path format. + /// + /// If `prefix` is empty, all entries are returned. + pub fn prefixed_entries(&self, prefix: &BStr) -> Option<&[Entry]> { + self.prefixed_entries_range(prefix).map(|range| &self.entries[range]) + } + + /// Return the range of entries which all share the same `prefix`, or `None` if there isn't a single such entry. + /// + /// The `prefix` must use the repository-relative, slash-separated [`State`] path format. + /// + /// If `prefix` is empty, the range will include all entries. + pub fn prefixed_entries_range(&self, prefix: &BStr) -> Option> { + if prefix.is_empty() { + return Some(0..self.entries.len()); + } + let prefix_len = prefix.len(); + let mut low = self.entries.partition_point(|e| { + e.path(self) + .get(..prefix_len) + .map_or_else(|| e.path(self) <= &prefix[..e.path.len()], |p| p < prefix) + }); + let mut high = + low + self.entries[low..].partition_point(|e| e.path(self).get(..prefix_len).is_some_and(|p| p <= prefix)); + + let low_entry = &self.entries.get(low)?; + if low_entry.stage_raw() != 0 { + low = self + .walk_entry_stages(low_entry.path(self), low, Ordering::Less) + .unwrap_or(low); + } + if let Some(high_entry) = self.entries.get(high) + && high_entry.stage_raw() != 0 + { + high = self + .walk_entry_stages(high_entry.path(self), high, Ordering::Less) + .unwrap_or(high); + } + (low != high).then_some(low..high) + } + + /// Return the entry at `idx` or _panic_ if the index is out of bounds. + /// + /// The `idx` is typically returned by [`entry_by_path_and_stage()`][State::entry_by_path_and_stage()]. + pub fn entry(&self, idx: usize) -> &Entry { + &self.entries[idx] + } + + /// Returns a boolean value indicating whether the index is sparse or not. + /// + /// An index is sparse if it contains at least one [`Mode::DIR`][entry::Mode::DIR] entry. + pub fn is_sparse(&self) -> bool { + self.is_sparse + } + + /// Return the range of entries that exactly match the given `path`, in all available stages, or `None` if no entry with such + /// path exists. + /// + /// The `path` must use the repository-relative, slash-separated [`State`] path format. + /// + /// The range can be used to access the respective entries via [`entries()`](Self::entries()) or [`entries_mut()](Self::entries_mut()). + pub fn entry_range(&self, path: &BStr) -> Option> { + let mut stage_at_index = 0; + let idx = self + .entries + .binary_search_by(|e| { + let res = e.path(self).cmp(path); + if res.is_eq() { + stage_at_index = e.stage_raw(); + } + res + }) + .ok()?; + + let (start, end) = ( + self.walk_entry_stages(path, idx, Ordering::Less).unwrap_or(idx), + self.walk_entry_stages(path, idx, Ordering::Greater).unwrap_or(idx) + 1, + ); + Some(start..end) + } +} + +impl AccelerateLookup<'_> { + fn with_capacity(cap: usize) -> Self { + let ratio_of_entries_to_dirs_in_webkit = 20; // 400k entries and 20k dirs + Self { + icase_entries: hashbrown::HashTable::with_capacity(cap), + icase_dirs: hashbrown::HashTable::with_capacity(cap / ratio_of_entries_to_dirs_in_webkit), + } + } + fn icase_hash(data: &BStr) -> u64 { + use std::hash::Hasher; + let mut hasher = fnv::FnvHasher::default(); + for b in data.as_bytes() { + hasher.write_u8(b.to_ascii_lowercase()); + } + hasher.finish() + } +} + +/// Mutation +impl State { + /// After usage of the storage obtained by [`take_path_backing()`][Self::take_path_backing()], return it here. + /// Note that it must not be empty. + pub fn return_path_backing(&mut self, backing: PathStorage) { + debug_assert!( + self.path_backing.is_empty(), + "BUG: return path backing only after taking it, once" + ); + self.path_backing = backing; + } + + /// Return mutable entries in a slice. + pub fn entries_mut(&mut self) -> &mut [Entry] { + &mut self.entries + } + + /// Return a writable slice to entries and read-access to their path storage at the same time. + pub fn entries_mut_and_pathbacking(&mut self) -> (&mut [Entry], &PathStorageRef) { + (&mut self.entries, &self.path_backing) + } + + /// Return mutable entries along with their paths in an iterator. + pub fn entries_mut_with_paths(&mut self) -> impl Iterator { + let paths = &self.path_backing; + self.entries.iter_mut().map(move |e| { + let path = paths[e.path.clone()].as_bstr(); + (e, path) + }) + } + + /// Return all parts that relate to entries, which includes path storage. + /// + /// This can be useful for obtaining a standalone, boxable iterator + pub fn into_entries(self) -> (Vec, PathStorage) { + (self.entries, self.path_backing) + } + + /// Sometimes it's needed to remove the path backing to allow certain mutation to happen in the state while supporting reading the entry's + /// path. + pub fn take_path_backing(&mut self) -> PathStorage { + assert_eq!( + self.entries.is_empty(), + self.path_backing.is_empty(), + "BUG: cannot take out backing multiple times" + ); + std::mem::take(&mut self.path_backing) + } + + /// Like [`entry_index_by_path_and_stage()`][State::entry_index_by_path_and_stage()], + /// but returns the mutable entry instead of the index. + /// + /// The `path` must use the repository-relative, slash-separated [`State`] path format. + pub fn entry_mut_by_path_and_stage(&mut self, path: &BStr, stage: entry::Stage) -> Option<&mut Entry> { + self.entry_index_by_path_and_stage(path, stage) + .map(|idx| &mut self.entries[idx]) + } + + /// Push a new entry containing `stat`, `id`, `flags` and `mode` and `path` to the end of our storage, without performing + /// any sanity checks. This means it's possible to push a new entry to the same path on the same stage and even after sorting + /// the entries lookups may still return the wrong one of them unless the correct binary search criteria is chosen. + /// + /// The `path` must use the repository-relative, slash-separated [`State`] path format. + /// + /// Note that this *is likely* to break invariants that will prevent further lookups by path unless + /// [`entry_index_by_path_and_stage_bounded()`][State::entry_index_by_path_and_stage_bounded()] is used with + /// the `upper_bound` being the amount of entries before the first call to this method. + /// + /// Alternatively, make sure to call [`sort_entries()`][State::sort_entries()] before entry lookup by path to restore + /// the invariant. + pub fn dangerously_push_entry( + &mut self, + stat: entry::Stat, + id: gix_hash::ObjectId, + flags: entry::Flags, + mode: entry::Mode, + path: &BStr, + ) { + let path = { + let path_start = self.path_backing.len(); + self.path_backing.push_str(path); + path_start..self.path_backing.len() + }; + + self.entries.push(Entry { + stat, + id, + flags, + mode, + path, + }); + } + + /// Unconditionally sort entries as needed to perform lookups quickly. + pub fn sort_entries(&mut self) { + let path_backing = &self.path_backing; + self.entries.sort_by(|a, b| { + Entry::cmp_filepaths(a.path_in(path_backing), b.path_in(path_backing)) + .then_with(|| a.stage().cmp(&b.stage())) + }); + } + + /// Similar to [`sort_entries()`][State::sort_entries()], but applies `compare` after comparing + /// by path and stage as a third criteria. + pub fn sort_entries_by(&mut self, mut compare: impl FnMut(&Entry, &Entry) -> Ordering) { + let path_backing = &self.path_backing; + self.entries.sort_by(|a, b| { + Entry::cmp_filepaths(a.path_in(path_backing), b.path_in(path_backing)) + .then_with(|| a.stage().cmp(&b.stage())) + .then_with(|| compare(a, b)) + }); + } + + /// Physically remove all entries for which `should_remove(idx, path, entry)` returns `true`, traversing them from first to last. + /// + /// Note that the memory used for the removed entries paths is not freed, as it's append-only, and + /// that some extensions might refer to paths which are now deleted. + /// + /// ### Performance + /// + /// To implement this operation typically, one would rather add [entry::Flags::REMOVE] to each entry to remove + /// them when [writing the index](Self::write_to()). + pub fn remove_entries(&mut self, mut should_remove: impl FnMut(usize, &BStr, &mut Entry) -> bool) { + let mut index = 0; + let paths = &self.path_backing; + self.entries.retain_mut(|e| { + let path = e.path_in(paths); + let res = !should_remove(index, path, e); + index += 1; + res + }); + } + + /// Physically remove the entry at `index`, or panic if the entry didn't exist. + /// + /// This call is typically made after looking up `index`, so it's clear that it will not panic. + /// + /// Note that the memory used for the removed entries paths is not freed, as it's append-only, and + /// that some extensions might refer to paths which are now deleted. + pub fn remove_entry_at_index(&mut self, index: usize) -> Entry { + self.entries.remove(index) + } +} + +/// Extensions +impl State { + /// Access the `tree` extension. + pub fn tree(&self) -> Option<&extension::Tree> { + self.tree.as_ref() + } + /// Mutably access the `tree` extension. + pub fn tree_mut(&mut self) -> Option<&mut extension::Tree> { + self.tree.as_mut() + } + /// Remove the `tree` extension. + pub fn remove_tree(&mut self) -> Option { + self.tree.take() + } + /// Access the `link` extension. + pub fn link(&self) -> Option<&extension::Link> { + self.link.as_ref() + } + /// Obtain the resolve-undo extension. + pub fn resolve_undo(&self) -> Option<&extension::resolve_undo::Paths> { + self.resolve_undo.as_ref() + } + /// Remove the resolve-undo extension. + pub fn remove_resolve_undo(&mut self) -> Option { + self.resolve_undo.take() + } + /// Obtain the untracked extension. + pub fn untracked(&self) -> Option<&extension::UntrackedCache> { + self.untracked.as_ref() + } + /// Obtain the fsmonitor extension. + pub fn fs_monitor(&self) -> Option<&extension::FsMonitor> { + self.fs_monitor.as_ref() + } + /// Return `true` if the end-of-index extension was present when decoding this index. + pub fn had_end_of_index_marker(&self) -> bool { + self.end_of_index_at_decode_time + } + /// Return `true` if the offset-table extension was present when decoding this index. + pub fn had_offset_table(&self) -> bool { + self.offset_table_at_decode_time + } +} + +fn entry_is_dir(entry: &Entry) -> bool { + entry.mode.is_sparse() || entry.mode.is_submodule() +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + #[test] + fn entry_by_path_with_conflicting_file() { + let file = PathBuf::from("tests") + .join("fixtures") + .join(Path::new("loose_index").join("conflicting-file.git-index")); + let file = crate::File::at(file, gix_hash::Kind::Sha1, false, Default::default()).expect("valid file"); + assert_eq!( + file.entries().len(), + 3, + "we have a set of conflict entries for a single file" + ); + for idx in 0..3 { + for wanted_stage in 1..=3 { + let actual_idx = file + .entry_index_by_idx_and_stage( + "file".into(), + idx, + wanted_stage, + (idx + 1).cmp(&(wanted_stage as usize)), + ) + .expect("found"); + assert_eq!( + actual_idx + 1, + wanted_stage as usize, + "the index and stage have a relation, and that is upheld if we search correctly" + ); + } + } + } +} diff --git a/userland/git/vendor/gix-index/src/access/sparse.rs b/userland/git/vendor/gix-index/src/access/sparse.rs new file mode 100644 index 0000000..27804f8 --- /dev/null +++ b/userland/git/vendor/gix-index/src/access/sparse.rs @@ -0,0 +1,59 @@ +/// Configuration related to sparse indexes. +#[derive(Debug, Default, Clone, Copy)] +pub struct Options { + /// If true, certain entries in the index will be excluded / skipped for certain operations, + /// based on the ignore patterns in the `.git/info/sparse-checkout` file. These entries will + /// carry the [`SKIP_WORKTREE`][crate::entry::Flags::SKIP_WORKTREE] flag. + /// + /// This typically is the value of `core.sparseCheckout` in the git configuration. + pub sparse_checkout: bool, + + /// Interpret the `.git/info/sparse-checkout` file using _cone mode_. + /// + /// If true, _cone mode_ is active and entire directories will be included in the checkout, as well as files in the root + /// of the repository. + /// If false, non-cone mode is active and entries to _include_ will be matched with patterns like those found in `.gitignore` files. + /// + /// This typically is the value of `core.sparseCheckoutCone` in the git configuration. + pub directory_patterns_only: bool, + + /// If true, will attempt to write a sparse index file which only works in cone mode. + /// + /// A sparse index has [`DIR` entries][crate::entry::Mode::DIR] that represent entire directories to be skipped + /// during checkout and other operations due to the added presence of + /// the [`SKIP_WORKTREE`][crate::entry::Flags::SKIP_WORKTREE] flag. + /// + /// This is typically the value of `index.sparse` in the git configuration. + pub write_sparse_index: bool, +} + +impl Options { + /// Derive a valid mode from all parameters that affect the 'sparseness' of the index. + /// + /// Some combinations of them degenerate to one particular mode. + pub fn sparse_mode(&self) -> Mode { + match ( + self.sparse_checkout, + self.directory_patterns_only, + self.write_sparse_index, + ) { + (true, true, true) => Mode::IncludeDirectoriesStoreIncludedEntriesAndExcludedDirs, + (true, true, false) => Mode::IncludeDirectoriesStoreAllEntriesSkipUnmatched, + (true, false, _) => Mode::IncludeByIgnorePatternStoreAllEntriesSkipUnmatched, + (false, _, _) => Mode::Disabled, + } + } +} + +/// Describes the configuration how a sparse index should be written, or if one should be written at all. +#[derive(Debug)] +pub enum Mode { + /// index with DIR entries for exclusion and included entries, directory-only include patterns in `.git/info/sparse-checkout` file. + IncludeDirectoriesStoreIncludedEntriesAndExcludedDirs, + /// index with all file entries and skip worktree flags for exclusion, directory-only include patterns in `.git/info/sparse-checkout` file. + IncludeDirectoriesStoreAllEntriesSkipUnmatched, + /// index with all file entries and skip-worktree flags for exclusion, `ignore` patterns to include entries in `.git/info/sparse-checkout` file. + IncludeByIgnorePatternStoreAllEntriesSkipUnmatched, + /// index with all entries, non is excluded, `.git/info/sparse-checkout` file is not considered, a regular index. + Disabled, +} diff --git a/userland/git/vendor/gix-index/src/decode/entries.rs b/userland/git/vendor/gix-index/src/decode/entries.rs new file mode 100644 index 0000000..38bab1c --- /dev/null +++ b/userland/git/vendor/gix-index/src/decode/entries.rs @@ -0,0 +1,234 @@ +use std::ops::Range; + +use gix_error::{OptionExt, Result}; + +use crate::{ + Entry, Version, + decode::header, + entry, + util::{read_u32, split_at_byte_exclusive, var_int}, +}; + +/// a guess directly from git sources +pub const AVERAGE_V4_DELTA_PATH_LEN_IN_BYTES: usize = 80; + +pub struct Outcome { + pub is_sparse: bool, +} + +fn entries_block_size_in_bytes( + on_disk_size: usize, + offset_to_extensions: Option, + object_hash: gix_hash::Kind, +) -> usize { + offset_to_extensions + .unwrap_or_else(|| on_disk_size.saturating_sub(object_hash.len_in_bytes())) + .saturating_sub(header::SIZE) +} + +const fn on_disk_entry_sans_path(object_hash: gix_hash::Kind) -> usize { + 8 + // ctime + 8 + // mtime + (4 * 6) + // various stat fields + 2 + // flag, ignore extended flag as we'd rather overallocate a bit + object_hash.len_in_bytes() +} + +/// Return a lower bound for the number of on-disk bytes one entry must occupy, +/// based on `object_hash` and `version`. +/// +/// This is used to reject impossible entry counts and to cap estimates derived from untrusted data. +/// For V2/V3, entries are padded to 8-byte boundaries, so the fixed payload is rounded up to the +/// next multiple of 8. For V4, paths are delta-encoded, but each entry still +/// needs at least a one-byte strip-length varint plus the trailing NUL of the path suffix, hence +/// `size + 2`. +const fn min_entry_size_in_bytes(object_hash: gix_hash::Kind, version: Version) -> usize { + let size = on_disk_entry_sans_path(object_hash); + match version { + Version::V2 | Version::V3 => size.next_multiple_of(8), + Version::V4 => size + 2, + } +} + +/// Compute an upper bound for how many entries can physically fit into the on-disk entries block. +/// +/// The entries block is the index payload after the header and before extensions or, if there are no +/// extensions, before the trailing checksum. We divide that byte budget by [`min_entry_size_in_bytes()`] +/// to obtain the largest plausible entry count for the declared index version. This is intentionally a +/// coarse upper bound used to reject corrupt headers that claim more entries than the remaining bytes +/// could possibly encode. +pub fn max_entries_possible( + on_disk_size: usize, + offset_to_extensions: Option, + object_hash: gix_hash::Kind, + version: Version, +) -> usize { + entries_block_size_in_bytes(on_disk_size, offset_to_extensions, object_hash) + / min_entry_size_in_bytes(object_hash, version) +} +pub fn estimate_path_storage_requirements_in_bytes( + num_entries: u32, + on_disk_size: usize, + offset_to_extensions: Option, + object_hash: gix_hash::Kind, + version: Version, +) -> usize { + let size_of_entries_block = entries_block_size_in_bytes(on_disk_size, offset_to_extensions, object_hash); + match version { + Version::V3 | Version::V2 => { + // V2/V3 store full paths in the entries block, so whatever remains after subtracting the fixed + // entry header portion is an upper bound for path bytes we may copy into memory. + size_of_entries_block.saturating_sub(num_entries as usize * on_disk_entry_sans_path(object_hash)) + } + Version::V4 => size_of_entries_block + // V4 stores delta-compressed paths on disk. Subtract the minimum per-entry non-path payload to + // validate how many on-disk bytes could plausibly remain for path suffixes at all. + .saturating_sub(num_entries as usize * (on_disk_entry_sans_path(object_hash) + 2)) + // The in-memory backing stores expanded paths, so the on-disk remainder is not a good estimate by + // itself. Keep the historic per-entry heuristic, but clamp it to what the entries block can plausibly + // contain so corrupt entry counts cannot drive an excessive preallocation. + .min(num_entries as usize * AVERAGE_V4_DELTA_PATH_LEN_IN_BYTES), + } +} + +/// Note that `data` must point to the beginning of the entries, right past the header. +pub fn chunk<'a>( + mut data: &'a [u8], + entries: &mut Vec, + path_backing: &mut Vec, + num_entries: u32, + object_hash: gix_hash::Kind, + version: Version, +) -> Result<(Outcome, &'a [u8])> { + let mut is_sparse = false; + let has_delta_paths = version == Version::V4; + let mut prev_path = None; + let mut delta_buf = Vec::::with_capacity(AVERAGE_V4_DELTA_PATH_LEN_IN_BYTES); + + for idx in 0..num_entries { + let (entry, remaining) = load_one( + data, + path_backing, + object_hash.len_in_bytes(), + has_delta_paths, + prev_path, + ) + .ok_or_raise(|| gix_error::message!("Could not parse entry at index {idx}").corrupted())?; + + data = remaining; + is_sparse |= entry.mode.is_sparse(); + // TODO: entries are actually in an intrusive collection, with path as key. Could be set for us. This affects 'ignore_case' which we + // also don't yet handle but probably could, maybe even smartly with the collection. + // For now it's unclear to me how they access the index, they could iterate quickly, and have fast access by path. + entries.push(entry); + prev_path = entries.last().map(|e| (e.path.clone(), &mut delta_buf)); + } + + Ok((Outcome { is_sparse }, data)) +} + +/// Note that `prev_path` is only useful if the version is V4 +fn load_one<'a>( + data: &'a [u8], + path_backing: &mut Vec, + hash_len: usize, + has_delta_paths: bool, + prev_path_and_buf: Option<(Range, &mut Vec)>, +) -> Option<(Entry, &'a [u8])> { + let first_byte_of_entry = data.as_ptr() as usize; + let (ctime_secs, data) = read_u32(data)?; + let (ctime_nsecs, data) = read_u32(data)?; + let (mtime_secs, data) = read_u32(data)?; + let (mtime_nsecs, data) = read_u32(data)?; + let (dev, data) = read_u32(data)?; + let (ino, data) = read_u32(data)?; + let (mode, data) = read_u32(data)?; + let (uid, data) = read_u32(data)?; + let (gid, data) = read_u32(data)?; + let (size, data) = read_u32(data)?; + let (hash, data) = data.split_at_checked(hash_len)?; + let (flags, data) = read_u16(data)?; + let flags = entry::at_rest::Flags::from_bits_retain(flags); + let (flags, data) = if flags.contains(entry::at_rest::Flags::EXTENDED) { + let (extended_flags, data) = read_u16(data)?; + let extended_flags = entry::at_rest::FlagsExtended::from_bits(extended_flags)?; + let extended_flags = extended_flags.to_flags()?; + (flags.to_memory() | extended_flags, data) + } else { + (flags.to_memory(), data) + }; + + let start = path_backing.len(); + let data = if has_delta_paths { + let (strip_len, data) = var_int(data)?; + if let Some((prev_path, buf)) = prev_path_and_buf { + let end = prev_path.end.checked_sub(strip_len.try_into().ok()?)?; + let copy_len = end.checked_sub(prev_path.start)?; + if copy_len > 0 { + buf.resize(copy_len, 0); + buf.copy_from_slice(&path_backing[prev_path.start..end]); + path_backing.extend_from_slice(buf); + } + } + + let (path, data) = split_at_byte_exclusive(data, 0)?; + path_backing.extend_from_slice(path); + + data + } else { + let (path, data) = if flags.contains(entry::Flags::PATH_LEN) { + split_at_byte_exclusive(data, 0)? + } else { + let path_len = (flags.bits() & entry::Flags::PATH_LEN.bits()) as usize; + let (path, data) = data.split_at_checked(path_len)?; + (path, skip_padding(data, first_byte_of_entry)?) + }; + + // TODO(perf): for some reason, this causes tremendous `memmove` time even though the backing + // has enough capacity most of the time. + path_backing.extend_from_slice(path); + data + }; + let path_range = start..path_backing.len(); + + Some(( + Entry { + stat: entry::Stat { + ctime: entry::stat::Time { + secs: ctime_secs, + nsecs: ctime_nsecs, + }, + mtime: entry::stat::Time { + secs: mtime_secs, + nsecs: mtime_nsecs, + }, + dev, + ino, + uid, + gid, + size, + }, + id: gix_hash::ObjectId::from_bytes_or_panic(hash), + flags: flags & !entry::Flags::PATH_LEN, + // This forces us to add the bits we need before being able to use them. + mode: entry::Mode::from_bits_truncate(mode), + path: path_range, + }, + data, + )) +} + +#[inline] +fn skip_padding(data: &[u8], first_byte_of_entry: usize) -> Option<&[u8]> { + let current_offset = data.as_ptr() as usize; + let c_padding = (current_offset - first_byte_of_entry + 8) & !7; + let skip = (first_byte_of_entry + c_padding) - current_offset; + + data.get(skip..) +} + +#[inline] +fn read_u16(data: &[u8]) -> Option<(u16, &[u8])> { + data.split_at_checked(2) + .map(|(num, data)| (u16::from_be_bytes(num.try_into().unwrap()), data)) +} diff --git a/userland/git/vendor/gix-index/src/decode/header.rs b/userland/git/vendor/gix-index/src/decode/header.rs new file mode 100644 index 0000000..e56e544 --- /dev/null +++ b/userland/git/vendor/gix-index/src/decode/header.rs @@ -0,0 +1,35 @@ +use gix_error::{Result, bail, corruption}; +pub(crate) const SIZE: usize = 4 /*signature*/ + 4 /*version*/ + 4 /* num entries */; + +use crate::{Version, util::from_be_u32}; + +pub(crate) const SIGNATURE: &[u8] = b"DIRC"; + +pub(crate) fn decode(data: &[u8], object_hash: gix_hash::Kind) -> Result<(Version, u32, &[u8])> { + if data.len() < (3 * 4) + object_hash.len_in_bytes() { + bail!(corruption( + "File is too small even for header with zero entries and smallest hash" + )); + } + + let (signature, data) = data.split_at(4); + if signature != SIGNATURE { + bail!(corruption( + "Signature mismatch - this doesn't claim to be a header file" + )); + } + + let (version, data) = data.split_at(4); + let version = match from_be_u32(version) { + 2 => Version::V2, + 3 => Version::V3, + 4 => Version::V4, + unknown => { + bail!("Index version {unknown} is not supported".unsupported()); + } + }; + let (entries, data) = data.split_at(4); + let entries = from_be_u32(entries); + + Ok((version, entries, data)) +} diff --git a/userland/git/vendor/gix-index/src/decode/mod.rs b/userland/git/vendor/gix-index/src/decode/mod.rs new file mode 100644 index 0000000..a6b5dba --- /dev/null +++ b/userland/git/vendor/gix-index/src/decode/mod.rs @@ -0,0 +1,349 @@ +use filetime::FileTime; + +use gix_error::{Error, Result}; + +use crate::{Entry, State, Version, entry, extension}; + +mod entries; +/// +pub mod header; + +use gix_error::{ResourceExhaustionKind, ResultExt, bail, corruption, message}; +use gix_parallel::InOrderIter; + +use crate::util::read_u32; + +/// Options to define how to decode an index state [from bytes][State::from_bytes()]. +#[derive(Debug, Default, Clone, Copy)] +pub struct Options { + /// If Some(_), we are allowed to use more than one thread. If Some(N), use no more than N threads. If Some(0)|None, use as many threads + /// as there are logical cores. + /// + /// This applies to loading extensions in parallel to entries if the common EOIE extension is available. + /// It also allows to use multiple threads for loading entries if the IEOT extension is present. + pub thread_limit: Option, + /// The minimum size in bytes to load extensions in their own thread, assuming there is enough `num_threads` available. + /// If set to 0, for example, extensions will always be read in their own thread if enough threads are available. + pub min_extension_block_in_bytes_for_threading: usize, + /// Set the expected hash of this index if we are read as part of a `link` extension. + /// + /// We will abort reading this file if it doesn't match. + pub expected_checksum: Option, + /// Configure the maximum size of a single allocation caused by untrusted on-disk index data. + /// + /// Use `None` to disable the limit, which is also the default. + pub alloc_limit_bytes: Option, +} + +impl State { + /// Decode an index state from `data` and store `timestamp` in the resulting instance for pass-through, assuming `object_hash` + /// to be used through the file. Also return the stored hash over all bytes in `data` or `None` if none was written due to `index.skipHash`. + pub fn from_bytes( + data: &[u8], + timestamp: FileTime, + object_hash: gix_hash::Kind, + _options @ Options { + thread_limit, + min_extension_block_in_bytes_for_threading, + expected_checksum, + alloc_limit_bytes, + }: Options, + ) -> Result<(Self, Option)> { + let _span = gix_trace::detail!("gix_index::State::from_bytes()", options = ?_options); + let (version, num_entries, post_header_data) = header::decode(data, object_hash)?; + let start_of_extensions = extension::end_of_index_entry::decode(data, object_hash)?; + if num_entries as usize > entries::max_entries_possible(data.len(), start_of_extensions, object_hash, version) { + bail!(corruption( + "Declared entry count exceeds possible entries for file size" + )); + } + + let mut num_threads = gix_parallel::num_threads(thread_limit); + let path_backing_buffer_size = entries::estimate_path_storage_requirements_in_bytes( + num_entries, + data.len(), + start_of_extensions, + object_hash, + version, + ); + ensure_in_alloc_limit( + (num_entries as usize) + .checked_mul(std::mem::size_of::()) + .ok_or_else(|| allocation_error(ResourceExhaustionKind::AllocationFailure))?, + alloc_limit_bytes, + )?; + ensure_in_alloc_limit(path_backing_buffer_size, alloc_limit_bytes)?; + + let (entries, ext, data) = match start_of_extensions { + Some(offset) if num_threads > 1 => { + let extensions_data = &data[offset..]; + let index_offsets_table = extension::index_entry_offset_table::find(extensions_data, object_hash); + let (entries_res, ext_res) = gix_parallel::threads(|scope| { + let extension_loading = + (extensions_data.len() > min_extension_block_in_bytes_for_threading).then({ + num_threads -= 1; + || { + gix_parallel::build_thread() + .name("gix-index.from_bytes.load-extensions".into()) + .spawn_scoped( + scope, + gix_trace::in_thread(|| { + extension::decode::all(extensions_data, object_hash, alloc_limit_bytes) + }), + ) + .expect("valid name") + } + }); + let entries_res = match index_offsets_table { + Some(entry_offsets) => { + let chunk_size = (entry_offsets.len() as f32 / num_threads as f32).ceil() as usize; + let entry_offsets_chunked = entry_offsets.chunks(chunk_size); + let num_chunks = entry_offsets_chunked.len(); + let mut threads = Vec::with_capacity(num_chunks); + for (id, chunks) in entry_offsets_chunked.enumerate() { + let chunks = chunks.to_vec(); + threads.push( + gix_parallel::build_thread() + .name(format!("gix-index.from_bytes.read-entries.{id}")) + .spawn_scoped( + scope, + gix_trace::in_thread(move || { + let num_entries_for_chunks = + chunks.iter().map(|c| c.num_entries).sum::() as usize; + let mut entries = vec_with_capacity(num_entries_for_chunks)?; + let path_backing_buffer_size_for_chunks = + entries::estimate_path_storage_requirements_in_bytes( + num_entries_for_chunks as u32, + data.len() / num_chunks, + start_of_extensions.map(|ofs| ofs / num_chunks), + object_hash, + version, + ); + let mut path_backing = + vec_with_capacity(path_backing_buffer_size_for_chunks)?; + let mut is_sparse = false; + for offset in chunks { + let ( + entries::Outcome { + is_sparse: chunk_is_sparse, + }, + _data, + ) = entries::chunk( + &data[offset.from_beginning_of_file as usize..], + &mut entries, + &mut path_backing, + offset.num_entries, + object_hash, + version, + )?; + is_sparse |= chunk_is_sparse; + } + Ok::<_, Error>(( + id, + EntriesOutcome { + entries, + path_backing, + is_sparse, + }, + )) + }), + ) + .expect("valid name"), + ); + } + let mut results = + InOrderIter::from(threads.into_iter().map(|thread| thread.join().unwrap())); + let mut acc = results.next().expect("have at least two results, one per thread"); + // We explicitly don't adjust the reserve in acc and rather allow for more copying + // to happens as vectors grow to keep the peak memory size low. + // NOTE: one day, we might use a memory pool for paths. We could encode the block of memory + // in some bytes in the path offset. That way there is more indirection/slower access + // to the path, but it would save time here. + // As it stands, `git` is definitely more efficient at this and probably uses less memory too. + // Maybe benchmarks can tell if that is noticeable later at 200/400GB/s memory bandwidth, or maybe just + // 100GB/s on a single core. + while let (Ok(lhs), Some(res)) = (acc.as_mut(), results.next()) { + match res { + Ok(mut rhs) => { + lhs.is_sparse |= rhs.is_sparse; + let ofs = lhs.path_backing.len(); + lhs.path_backing.append(&mut rhs.path_backing); + lhs.entries.extend(rhs.entries.into_iter().map(|mut e| { + e.path.start += ofs; + e.path.end += ofs; + e + })); + } + Err(err) => { + acc = Err(err); + } + } + } + acc.map(|acc| (acc, &data[data.len() - object_hash.len_in_bytes()..])) + } + None => entries( + post_header_data, + path_backing_buffer_size, + num_entries, + object_hash, + version, + ), + }; + let ext_res = extension_loading.map_or_else( + || extension::decode::all(extensions_data, object_hash, alloc_limit_bytes), + |thread| thread.join().unwrap(), + ); + (entries_res, ext_res) + }); + let (ext, data) = ext_res?; + (entries_res?.0, ext, data) + } + None | Some(_) => { + let (entries, data) = entries( + post_header_data, + path_backing_buffer_size, + num_entries, + object_hash, + version, + )?; + let (ext, data) = extension::decode::all(data, object_hash, alloc_limit_bytes)?; + (entries, ext, data) + } + }; + + if data.len() != object_hash.len_in_bytes() { + bail!(corruption(format!( + "Index trailer should have been {} bytes long, but was {}", + object_hash.len_in_bytes(), + data.len() + ))); + } + + let checksum = gix_hash::ObjectId::from_bytes_or_panic(data); + let checksum = (!checksum.is_null()).then_some(checksum); + if let Some((expected_checksum, actual_checksum)) = expected_checksum.zip(checksum) { + actual_checksum + .verify(&expected_checksum) + .or_raise(|| message("Shared index checksum mismatch"))?; + } + let EntriesOutcome { + entries, + path_backing, + mut is_sparse, + } = entries; + let extension::decode::Outcome { + tree, + link, + resolve_undo, + untracked, + fs_monitor, + is_sparse: is_sparse_from_ext, // a marker is needed in case there are no directories + end_of_index, + offset_table, + } = ext; + is_sparse |= is_sparse_from_ext; + + Ok(( + State { + object_hash, + timestamp, + version, + entries, + path_backing, + is_sparse, + + end_of_index_at_decode_time: end_of_index, + offset_table_at_decode_time: offset_table, + tree, + link, + resolve_undo, + untracked, + fs_monitor, + }, + checksum, + )) + } +} + +struct EntriesOutcome { + pub entries: Vec, + pub path_backing: Vec, + pub is_sparse: bool, +} + +fn vec_with_capacity(capacity: usize) -> Result> { + let mut vec = Vec::new(); + vec.try_reserve(capacity) + .or_raise(|| message("Index data would require more memory than can be reserved"))?; + Ok(vec) +} + +fn entries( + post_header_data: &[u8], + path_backing_buffer_size: usize, + num_entries: u32, + object_hash: gix_hash::Kind, + version: Version, +) -> Result<(EntriesOutcome, &[u8])> { + let mut entries = vec_with_capacity(num_entries as usize)?; + let mut path_backing = vec_with_capacity(path_backing_buffer_size)?; + entries::chunk( + post_header_data, + &mut entries, + &mut path_backing, + num_entries, + object_hash, + version, + ) + .map(|(entries::Outcome { is_sparse }, data): (entries::Outcome, &[u8])| { + ( + EntriesOutcome { + entries, + path_backing, + is_sparse, + }, + data, + ) + }) +} + +pub(crate) fn stat(data: &[u8]) -> Option<(entry::Stat, &[u8])> { + let (ctime_secs, data) = read_u32(data)?; + let (ctime_nsecs, data) = read_u32(data)?; + let (mtime_secs, data) = read_u32(data)?; + let (mtime_nsecs, data) = read_u32(data)?; + let (dev, data) = read_u32(data)?; + let (ino, data) = read_u32(data)?; + let (uid, data) = read_u32(data)?; + let (gid, data) = read_u32(data)?; + let (size, data) = read_u32(data)?; + Some(( + entry::Stat { + ctime: entry::stat::Time { + secs: ctime_secs, + nsecs: ctime_nsecs, + }, + mtime: entry::stat::Time { + secs: mtime_secs, + nsecs: mtime_nsecs, + }, + dev, + ino, + uid, + gid, + size, + }, + data, + )) +} + +fn ensure_in_alloc_limit(size: usize, alloc_limit_bytes: Option) -> Result { + if alloc_limit_bytes.is_some_and(|limit| size > limit) { + return Err(allocation_error(ResourceExhaustionKind::AllocationLimit)); + } + Ok(()) +} + +fn allocation_error(kind: ResourceExhaustionKind) -> Error { + message("Index data would require more memory than can be reserved").resource_exhaustion_error(kind) +} diff --git a/userland/git/vendor/gix-index/src/entry/flags.rs b/userland/git/vendor/gix-index/src/entry/flags.rs new file mode 100644 index 0000000..2c4e30c --- /dev/null +++ b/userland/git/vendor/gix-index/src/entry/flags.rs @@ -0,0 +1,175 @@ +use bitflags::bitflags; + +use crate::entry::Stage; + +bitflags! { + /// In-memory flags. + /// + /// Notably, not all of these will be persisted but can be used to aid all kinds of operations. + #[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)] + pub struct Flags: u32 { + // TODO: could we use the pathlen ourselves to save 8 bytes? And how to handle longer paths than that? 0 as sentinel maybe? + /// The mask to obtain the length of the path associated with this entry, up to 4095 characters without extension. + const PATH_LEN = 0x0fff; + /// The mask to apply to obtain the stage number of an entry, encoding three value: 0 = base, 1 = ours, 2 = theirs. + const STAGE_MASK = (1<<12) | (1<<13); + /// If set, additional bits need to be written to storage. + const EXTENDED = 1<<14; + /// If set, the entry be assumed to match with the version on the working tree, as a way to avoid `lstat()` checks. + const ASSUME_VALID = 1 << 15; + /// Indicates that an entry needs to be updated as it's in-memory representation doesn't match what's on disk. + const UPDATE = 1 << 16; + /// Indicates an entry should be removed - this typically happens during writing, by simply skipping over them. + const REMOVE = 1 << 17; + /// Indicates that an entry is known to be up-to-date. + const UPTODATE = 1 << 18; + /// Only temporarily used by unpack_trees() (in C). + const ADDED = 1 << 19; + + /// Whether an up-to-date object hash exists for the entry. + const HASHED = 1 << 20; + /// Set if the filesystem monitor is valid. + const FSMONITOR_VALID = 1 << 21; + /// Remove in work directory + const WORKTREE_REMOVE = 1 << 22; + /// Set to indicate the entry exists in multiple stages at once due to conflicts. + const CONFLICTED = 1 << 23; + + /// Indicates that the entry was already turned into a tree. + const UNPACKED = 1 << 24; + /// Only temporarily used by unpack_trees() (in C) + const NEW_SKIP_WORKTREE = 1 << 25; + + /// temporarily mark paths matched by a path spec + const PATHSPEC_MATCHED = 1 << 26; + + /// When the index is split, this indicates the entry is up-to-date in the shared portion of the index. + const UPDATE_IN_BASE = 1 << 27; + /// Indicates the entry name is present in the base/shared index, and thus doesn't have to be stored in this one. + const STRIP_NAME = 1 << 28; + + /// Created with `git add --intent-to-add` to mark empty entries that have their counter-part in the worktree, but not + /// yet in the object database. + const INTENT_TO_ADD = 1 << 29; + /// Stored at rest + const SKIP_WORKTREE = 1 << 30; + + /// For future extension + const EXTENDED_2 = 1 << 31; + } +} + +impl Flags { + /// Create a new instance whose stage is set to `stage`. + pub fn from_stage(stage: Stage) -> Self { + Flags::from_bits((stage as u32) << 12).expect("stage can only be valid flags") + } + + /// Return the stage as extracted from the bits of this instance. + pub fn stage(&self) -> Stage { + match self.stage_raw() { + 0 => Stage::Unconflicted, + 1 => Stage::Base, + 2 => Stage::Ours, + 3 => Stage::Theirs, + _ => unreachable!("BUG: Flags::STAGE_MASK is two bits, whose 4 possible values we have covered"), + } + } + + /// Return an entry's stage as raw number between 0 and 4. + /// Possible values are: + /// + /// * 0 = no conflict, + /// * 1 = base, + /// * 2 = ours, + /// * 3 = theirs + pub fn stage_raw(&self) -> u32 { + (*self & Flags::STAGE_MASK).bits() >> 12 + } + + /// Transform ourselves to a storage representation to keep all flags which are to be persisted, + /// skipping all extended flags. Note that the caller has to check for the `EXTENDED` bit to be present + /// and write extended flags as well if so. + pub fn to_storage(mut self) -> at_rest::Flags { + at_rest::Flags::from_bits_retain( + { + self.remove(Self::PATH_LEN); + self + } + .bits() as u16, + ) + } +} + +impl From for Flags { + fn from(value: Stage) -> Self { + Flags::from_stage(value) + } +} + +pub(crate) mod at_rest { + use bitflags::bitflags; + + bitflags! { + /// Flags how they are serialized to a storage location + #[derive(Copy, Clone, Debug)] + pub struct Flags: u16 { + /// A portion of a the flags that encodes the length of the path that follows. + const PATH_LEN = 0x0fff; + const STAGE_MASK = 0x3000; + /// If set, there is more extended flags past this one + const EXTENDED = 0x4000; + /// If set, the entry be assumed to match with the version on the working tree, as a way to avoid `lstat()` checks. + const ASSUME_VALID = 0x8000; + } + } + + impl Flags { + pub fn to_memory(self) -> super::Flags { + super::Flags::from_bits_retain(u32::from(self.bits())) + } + } + + bitflags! { + /// Extended flags - add flags for serialization here and offset them down to u16. + #[derive(Copy, Clone, Debug, PartialEq)] + pub struct FlagsExtended: u16 { + const INTENT_TO_ADD = 1 << (29 - 16); + const SKIP_WORKTREE = 1 << (30 - 16); + } + } + + impl FlagsExtended { + pub fn from_flags(flags: super::Flags) -> Self { + Self::from_bits_retain( + ((flags & (super::Flags::INTENT_TO_ADD | super::Flags::SKIP_WORKTREE)).bits() >> 16) as u16, + ) + } + pub fn to_flags(self) -> Option { + super::Flags::from_bits(u32::from(self.bits()) << 16) + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn flags_extended_conversion() { + assert_eq!( + FlagsExtended::all().to_flags(), + Some(super::super::Flags::INTENT_TO_ADD | super::super::Flags::SKIP_WORKTREE) + ); + assert_eq!( + FlagsExtended::from_flags(super::super::Flags::all()), + FlagsExtended::all() + ); + } + + #[test] + fn flags_from_bits_with_conflict() { + let input = 0b1110_0010_1000_1011; + assert_eq!(Flags::from_bits_retain(input).bits(), input); + } + } +} diff --git a/userland/git/vendor/gix-index/src/entry/mod.rs b/userland/git/vendor/gix-index/src/entry/mod.rs new file mode 100644 index 0000000..39e5a39 --- /dev/null +++ b/userland/git/vendor/gix-index/src/entry/mod.rs @@ -0,0 +1,145 @@ +/// The stage of an entry. +#[derive(Default, Copy, Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Hash)] +pub enum Stage { + /// This is the default, and most entries are in this stage. + #[default] + Unconflicted = 0, + /// The entry is the common base between 'our' change and 'their' change, for comparison. + Base = 1, + /// The entry represents our change. + Ours = 2, + /// The entry represents their change. + Theirs = 3, +} + +// The stage of an entry, one of… +/// * 0 = no conflict, +/// * 1 = base, +/// * 2 = ours, +/// * 3 = theirs +pub type StageRaw = u32; + +/// +pub mod mode; + +mod flags; +pub use flags::Flags; +pub(crate) use flags::at_rest; + +/// +pub mod stat; +mod write; + +use bitflags::bitflags; + +bitflags! { + /// The kind of file of an entry. + #[derive(Copy, Clone, Debug, PartialEq, Eq, Ord, PartialOrd)] + pub struct Mode: u32 { + /// directory (only used for sparse checkouts), equivalent to a tree, which is _excluded_ from the index via + /// cone-mode. + const DIR = 0o040000; + /// regular file + const FILE = 0o100644; + /// regular file, executable + const FILE_EXECUTABLE = 0o100755; + /// Symbolic link + const SYMLINK = 0o120000; + /// A git commit for submodules + const COMMIT = 0o160000; + } +} + +/// An entry's filesystem stat information. +#[derive(Debug, Default, PartialEq, Eq, Hash, Ord, PartialOrd, Clone, Copy)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Stat { + /// Modification time + pub mtime: stat::Time, + /// Creation time + pub ctime: stat::Time, + /// Device number + pub dev: u32, + /// Inode number + pub ino: u32, + /// User id of the owner + pub uid: u32, + /// Group id of the owning group + pub gid: u32, + /// The size of bytes on disk. Capped to u32 so files bigger than that will need thorough additional checking + pub size: u32, +} + +mod access { + use bstr::{BStr, ByteSlice}; + + use crate::{Entry, State, entry}; + + impl Entry { + /// Return an entry's path, relative to the repository, which is extracted from its owning `state`. + pub fn path<'a>(&self, state: &'a State) -> &'a BStr { + state.path_backing[self.path.clone()].as_bstr() + } + + /// Return an entry's path using the given `backing`. + pub fn path_in<'backing>(&self, backing: &'backing crate::PathStorageRef) -> &'backing BStr { + backing[self.path.clone()].as_bstr() + } + + /// Return an entry's stage. See [entry::Stage] for possible values. + pub fn stage(&self) -> entry::Stage { + self.flags.stage() + } + + /// Return an entry's stage as raw number between 0 and 4. + /// Possible values are: + /// + /// * 0 = no conflict, + /// * 1 = base, + /// * 2 = ours, + /// * 3 = theirs + pub fn stage_raw(&self) -> u32 { + self.flags.stage_raw() + } + } +} + +mod _impls { + use std::cmp::Ordering; + + use bstr::BStr; + use gix_object::tree::EntryKind; + + use crate::{Entry, State, entry}; + + impl From for entry::Mode { + fn from(value: EntryKind) -> Self { + match value { + EntryKind::Tree => entry::Mode::DIR, + EntryKind::Blob => entry::Mode::FILE, + EntryKind::BlobExecutable => entry::Mode::FILE_EXECUTABLE, + EntryKind::Link => entry::Mode::SYMLINK, + EntryKind::Commit => entry::Mode::COMMIT, + } + } + } + + impl Entry { + /// Compare one entry to another by their path, by comparing only their common path portion byte by byte, then resorting to + /// entry length and stage. + pub fn cmp(&self, other: &Self, state: &State) -> Ordering { + let lhs = self.path(state); + let rhs = other.path(state); + Entry::cmp_filepaths(lhs, rhs).then_with(|| self.stage().cmp(&other.stage())) + } + + /// Compare one entry to another by their path, by comparing only their common path portion byte by byte, then resorting to + /// entry length. + pub fn cmp_filepaths(a: &BStr, b: &BStr) -> Ordering { + let common_len = a.len().min(b.len()); + a[..common_len] + .cmp(&b[..common_len]) + .then_with(|| a.len().cmp(&b.len())) + } + } +} diff --git a/userland/git/vendor/gix-index/src/entry/mode.rs b/userland/git/vendor/gix-index/src/entry/mode.rs new file mode 100644 index 0000000..248df83 --- /dev/null +++ b/userland/git/vendor/gix-index/src/entry/mode.rs @@ -0,0 +1,144 @@ +use crate::entry::Mode; + +impl Mode { + /// Return `true` if this is a sparse entry, as it points to a directory which usually isn't what an 'unsparse' index tracks. + pub fn is_sparse(&self) -> bool { + *self == Self::DIR + } + + /// Return `true` if this is a submodule entry. + pub fn is_submodule(&self) -> bool { + *self == Self::DIR | Self::SYMLINK + } + + /// Return whether this entry is a directory for ignore and sparse-pattern matching. + /// Sparse directories and gitlinks are directories regardless of whether they exist on disk. + pub fn is_dir_for_pattern_matching(&self) -> bool { + self.is_sparse() || self.is_submodule() + } + + /// Convert this instance to a tree's entry mode, or return `None` if for some + /// and unexpected reason the bitflags don't resemble any known entry-mode. + pub fn to_tree_entry_mode(&self) -> Option { + gix_object::tree::EntryMode::try_from(self.bits()).ok() + } + + /// Compares this mode to the file system version ([`std::fs::symlink_metadata`]) + /// and returns the change needed to update this mode to match the file. + /// + /// * if `has_symlinks` is false symlink entries will simply check if there + /// is a normal file on disk + /// * if `executable_bit` is false the executable bit will not be compared + /// `Change::ExecutableBit` will never be generated + /// + /// If there is a type change then we will use whatever information is + /// present on the FS. Specifically if `has_symlinks` is false we will + /// never generate `Change::TypeChange { new_mode: Mode::SYMLINK }`. and + /// if `executable_bit` is false we will never generate `Change::TypeChange + /// { new_mode: Mode::FILE_EXECUTABLE }` (all files are assumed to be not + /// executable). That means that unstaging and staging files can be a lossy + /// operation on such file systems. + /// + /// If a directory replaced a normal file/symlink we assume that the + /// directory is a submodule. Normal (non-submodule) directories would + /// cause a file to be deleted from the index and should be handled before + /// calling this function. + /// + /// If the stat information belongs to something other than a normal file/ + /// directory (like a socket) we just return an identity change (non-files + /// can not be committed to git). + pub fn change_to_match_fs( + self, + stat: &crate::fs::Metadata, + has_symlinks: bool, + executable_bit: bool, + ) -> Option { + self.change_to_match_fs_with_values( + stat.is_file(), + stat.is_dir(), + stat.is_symlink(), + stat.is_executable(), + has_symlinks, + executable_bit, + ) + } + + /// Like [`change_to_match_fs`](Self::change_to_match_fs) but accepts pre-extracted + /// file-type and permission bits, for callers that already have them. + /// + /// Note that some parameters are mutually exclusive, but inconsistency won't be a problem + /// if the source of the data is consistent. + /// + /// * `is_file` is `true` if the file-system entry is a regular file. + /// * `is_dir` is `true` if the file-system entry is a directory. + /// * `is_symlink` is `true` if the file-system entry is a symbolic link. + /// * `is_executable` is `true` if the file-system entry has executable permissions. + /// + /// These parameters match [`change_to_match_fs`](Self::change_to_match_fs). + /// + /// * `has_symlinks` is `true` if the file system represents symbolic links faithfully. + /// * `executable_bit` is `true` if the file system represents executable permissions faithfully. + pub fn change_to_match_fs_with_values( + self, + is_file: bool, + is_dir: bool, + is_symlink: bool, + is_executable: bool, + has_symlinks: bool, + executable_bit: bool, + ) -> Option { + match self { + Mode::FILE if !is_file => (), + Mode::SYMLINK if is_symlink => return None, + Mode::SYMLINK if has_symlinks && !is_symlink => (), + Mode::SYMLINK if !has_symlinks && !is_file => (), + Mode::COMMIT | Mode::DIR if !is_dir => (), + Mode::FILE if executable_bit && is_executable => return Some(Change::ExecutableBit), + Mode::FILE_EXECUTABLE if executable_bit && !is_executable => return Some(Change::ExecutableBit), + _ => return None, + } + let new_mode = if is_dir { + Mode::COMMIT + } else if executable_bit && is_executable { + Mode::FILE_EXECUTABLE + } else if has_symlinks && is_symlink { + Mode::SYMLINK + } else { + Mode::FILE + }; + Some(Change::Type { new_mode }) + } +} + +impl From for Mode { + fn from(value: gix_object::tree::EntryMode) -> Self { + let value: u16 = value.value(); + Self::from_bits_truncate(u32::from(value)) + } +} + +/// A change of a [`Mode`]. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum Change { + /// The type of mode changed, like symlink => file. + Type { + /// The mode representing the new index type. + new_mode: Mode, + }, + /// The executable permission of this file has changed. + ExecutableBit, +} + +impl Change { + /// Applies this change to `mode` and returns the changed one. + pub fn apply(self, mode: Mode) -> Mode { + match self { + Change::Type { new_mode } => new_mode, + Change::ExecutableBit => match mode { + Mode::FILE => Mode::FILE_EXECUTABLE, + Mode::FILE_EXECUTABLE => Mode::FILE, + _ => unreachable!("invalid mode change: can't flip executable bit of {mode:?}"), + }, + } + } +} diff --git a/userland/git/vendor/gix-index/src/entry/stat.rs b/userland/git/vendor/gix-index/src/entry/stat.rs new file mode 100644 index 0000000..72fa6ed --- /dev/null +++ b/userland/git/vendor/gix-index/src/entry/stat.rs @@ -0,0 +1,207 @@ +use std::{ + cmp::Ordering, + time::{SystemTime, SystemTimeError}, +}; + +use filetime::FileTime; + +use crate::entry::Stat; + +impl Stat { + /// Detect whether this stat entry is racy if stored in a file index with `timestamp`. + /// + /// An index entry is considered racy if it's `mtime` is larger or equal to the index `timestamp`. + /// The index `timestamp` marks the point in time before which we definitely resolved the racy git problem + /// for all index entries so any index entries that changed afterwards will need to be examined for + /// changes by actually reading the file from disk at least once. + pub fn is_racy( + &self, + timestamp: FileTime, + Options { + check_stat, use_nsec, .. + }: Options, + ) -> bool { + match timestamp.unix_seconds().cmp(&i64::from(self.mtime.secs)) { + Ordering::Less => true, + Ordering::Equal if use_nsec && check_stat => timestamp.nanoseconds() <= self.mtime.nsecs, + Ordering::Equal => true, + Ordering::Greater => false, + } + } + + /// Compares the stat information of two index entries. + /// + /// Intuitively this is basically equivalent to `self == other`. + /// However there a lot of nobs in git that tweak whether certain stat information is used when checking + /// equality, see [`Options`]. + /// This function respects those options while performing the stat comparison and may therefore ignore some fields. + pub fn matches( + &self, + other: &Self, + Options { + trust_ctime, + check_stat, + use_nsec, + use_stdev, + }: Options, + ) -> bool { + if self.mtime.secs != other.mtime.secs { + return false; + } + if check_stat && use_nsec && self.mtime.nsecs != other.mtime.nsecs { + return false; + } + + if self.size != other.size { + return false; + } + + if trust_ctime { + if self.ctime.secs != other.ctime.secs { + return false; + } + if check_stat && use_nsec && self.ctime.nsecs != other.ctime.nsecs { + return false; + } + } + + if check_stat { + if use_stdev && self.dev != other.dev { + return false; + } + self.ino == other.ino && self.gid == other.gid && self.uid == other.uid + } else { + true + } + } + + /// Creates stat information from file metadata. + /// + /// The information passed to this function should originate from a function like + /// `symlink_metadata`/`lstat` or `File::metadata`/`fstat`. + /// + /// The data are adjusted for use in the index, using default values of fields that are not + /// meaningful on the target operating system or that are unavailable, and truncating data + /// where doing so does not lose essential information for keeping track of file status. + pub fn from_fs(stat: &crate::fs::Metadata) -> Result { + let mtime = stat.modified().unwrap_or(std::time::UNIX_EPOCH); + let ctime = stat.created().unwrap_or(std::time::UNIX_EPOCH); + + #[cfg(windows)] + let res = Stat { + mtime: mtime.try_into()?, + ctime: ctime.try_into()?, + dev: 0, + ino: 0, + uid: 0, + gid: 0, + // Truncation to 32 bits is on purpose (git does the same). + size: stat.len() as u32, + }; + #[cfg(not(windows))] + let res = { + Stat { + mtime: mtime.try_into().unwrap_or_default(), + ctime: ctime.try_into().unwrap_or_default(), + // Truncating the device and inode numbers to 32 bits should be fine even on + // targets where they are represented as 64 bits, since we do not use them + // precisely for tracking changes and we do not map them back to the inode. + dev: stat.dev() as u32, + ino: stat.ino() as u32, + uid: stat.uid(), + gid: stat.gid(), + // Truncation to 32 bits is on purpose (git does the same). + size: stat.len() as u32, + } + }; + + Ok(res) + } +} + +impl TryFrom for Time { + type Error = SystemTimeError; + fn try_from(s: SystemTime) -> Result { + let d = s.duration_since(std::time::UNIX_EPOCH)?; + Ok(Time { + // truncation to 32 bits is on purpose (we only compare the low bits) + secs: d.as_secs() as u32, + nsecs: d.subsec_nanos(), + }) + } +} + +impl From