commit 680411c4fb2ec4ec7257e63a787f74f10f266471 Author: zhenyi <434836402@qq.com> Date: Fri Aug 14 17:11:49 2026 +0800 chore: initialize gitbaby repository gitbaby is an asynchronous Rust library wrapping git operations, built on gix + tokio + async-trait. Module layout: - archive, blame, blob, branch, cleanup, commit, compare, config, conflict, diff, merge, refs, remote, setup, share, submodule, tags, tree (feature modules) - command: command scheduler (cmd/env/pipe/context/error) - repo: RepositoryFacade trait (consumers implement, exposing a gix::Repository) Features: - async API on tokio 1.53 - order-preserving Env (Vec<(String, String)>) - hand-written BabyError / CmdError, not using thiserror derive - integration tests covering cmd_run / context / env / pipe (env keys prefixed for isolation) CI: none, standard cargo build / test / clippy / fmt only diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef5835c --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/target +**.iml +.idea +.claude +.codex +.opencode +.env* +*.pem +*.key +*.p12 +id_rsa* +id_ed25519* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2e770fc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# AGENTS.md — gitbaby + +## What this is +- Single-crate Rust **library** (no binary, no workspace). Edition `2024`. +- Public entrypoint: `GitBaby::new(facade: Arc, pipe: Pipe)` in `src/lib.rs`. +- Consumers **must implement** `RepositoryFacade` (`src/repo.rs`) — three async fns returning a path/paths and a `gix::Repository`. There is no default impl. + +## Build / test / verify +- Standard cargo only. No CI, no pre-commit, no scripts, no rust-toolchain pin. +- Edition 2024 requires a recent stable toolchain (rustc ≥ 1.85). Local env verified at `rustc 1.97.1`. +- Useful commands (no order dependency beyond what's standard): + - `cargo build` / `cargo check` / `cargo build --release` + - `cargo test` (sync + `#[tokio::test]` integration tests in `tests/`) + - `cargo test --test ` to run a single integration test file + - `cargo test ` to filter by test name substring + - `cargo clippy --all-targets -- -D warnings` if strict + - `cargo fmt --check` / `cargo fmt` (default rustfmt; no `rustfmt.toml`) + +## Architecture notes +- **Error pattern is hand-written.** `BabyError` (`src/error.rs`, ~50 variants) and `CmdError` (`src/command/error.rs`) implement `Display` + `std::error::Error` by hand. `thiserror` is in `Cargo.toml` **but not used via `#[derive(thiserror::Error)]`**. Do not "modernize" by adding the derive — keep the hand-written impls consistent. +- `Env` in `src/command/env.rs` is **order-preserving** (`Vec<(String, String)>`), not a `HashMap`. `with` appends, `set` replaces in place. This matters for env-var ordering in spawned processes. +- `Cmd::Display` = `prog` + `config_args` + `args` (config_args are prepended to args at runtime). +- `Pipe` is `Clone`; `cancel_pipe()` sets `cancelled=true` and sends `start_kill()` to every running child but does **not** remove them from `cmds`. + +## Module layout convention +- Most feature modules follow `src//{mod.rs, types.rs, helper.rs, usecase.rs}`; `mod.rs` re-exports the public types from `types.rs`. +- **`src/share/` is the outlier**: it uses `{cmd.rs, env.rs, error.rs, path.rs}` instead. Don't "normalize" it. +- `src/command/` uses `{cmd.rs, context.rs, env.rs, error.rs, pipe.rs}` and has its own `error.rs` separate from `src/error.rs`. + +## Test quirks (`tests/`) +- `tests/cmd_run.rs` defines a local `unwrap_or_recover_or_panic!`-style macro for asserting command output — reuse it, don't reinvent. +- `tests/env.rs` mutates process env. It guards tests with `static ENV_LOCK: Mutex<()>` and uses keys prefixed `GITBABY_TEST_ENV_` for isolation. `std::env::set_var` / `remove_var` are wrapped in local `unsafe fn`s; follow that pattern when adding env-mutating tests. +- `tests/pipe.rs` async cancel tests call `tokio::time::sleep(Duration::from_millis(200))` after `cancel_pipe()` to let `Child::try_wait` observe exit — keep that delay when adding new cancel tests. + +## Gotchas +- No README. No docs beyond the code. Treat `Cargo.toml` + source as the only source of truth. +- `gix = "0.86"`, `tokio = 1.53` (full features), `time = 0.3` — all recent; do not bump them speculatively. +- Public API surfaces async (`async_trait`); sync callers must use a runtime (tests use `#[tokio::test]`). \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f6401b6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1930 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "bisync" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5020822f6d6f23196ccaf55e228db36f9de1cf788052b37992e17cbc96ec41a7" +dependencies = [ + "bisync_macros", +] + +[[package]] +name = "bisync_macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d21f40d350a700f6aa107e45fb26448cf489d34794b2ba4522181dc9f1173af6" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bytesize" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[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", +] + +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "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 = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gitbaby" +version = "0.1.0" +dependencies = [ + "async-trait", + "gix", + "serde", + "serde_json", + "time", + "tokio", +] + +[[package]] +name = "gix" +version = "0.86.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb3790fd8981cba7949f1ba924ef865d902df731627bc5998d14164063892fce" +dependencies = [ + "gix-actor", + "gix-archive", + "gix-attributes", + "gix-blame", + "gix-command", + "gix-commitgraph", + "gix-config", + "gix-credentials", + "gix-date", + "gix-diff", + "gix-dir", + "gix-discover", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-ignore", + "gix-index", + "gix-lock", + "gix-mailmap", + "gix-negotiate", + "gix-object", + "gix-odb", + "gix-pack", + "gix-path", + "gix-pathspec", + "gix-prompt", + "gix-protocol", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-status", + "gix-submodule", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree", + "gix-worktree-state", + "gix-worktree-stream", + "gix-zlib", + "nonempty", + "parking_lot", + "regex", + "signal-hook", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-actor" +version = "0.41.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f9308ad6fd35b2a865cbe4117ac61b2be59e4a9ef1621c7a9794f7c8e52c5b" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-archive" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6760ef6ef4edfa6449918977d59518ff209676f47e89bdce87a36a50be35e834" +dependencies = [ + "bstr", + "gix-date", + "gix-error", + "gix-object", + "gix-worktree-stream", +] + +[[package]] +name = "gix-attributes" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31c593692ebdc1e38858d9a2b56f6a594c501e24a38971fe6685571f5a07be0" +dependencies = [ + "bstr", + "gix-features", + "gix-glob", + "gix-path", + "gix-quote", + "gix-trace", + "smallvec", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd1d118d0f5d88b96e6f6e13b566475fef4797ead4a02c26fed36c1375066f7" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-blame" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06d20ff0e88ada2dd852b3852727b664ec9baefdf653d1d4e722e858c52cd17" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-diff", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", + "gix-trace", + "gix-traverse", + "gix-worktree", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-chunk" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a871e5cab12ba568845714473505deefffb3c04eb47f4708ce344cd459c1cc" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-command" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4363accdf6ef7ba861871d2d521ab7418a04aaaed919fadb022af71d379b12" +dependencies = [ + "bstr", + "gix-path", + "gix-quote", + "gix-trace", + "shell-words", +] + +[[package]] +name = "gix-commitgraph" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2cd7f054ae2727223fe46dd39c012f066b12f532962d336d29ee193261787da" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-config" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "103d11bef95c467577ecfa8b7b86a22e65af3507b2c9bfa3809a4afbae7df301" +dependencies = [ + "bstr", + "gix-config-value", + "gix-features", + "gix-glob", + "gix-path", + "gix-ref", + "gix-sec", + "gix-utils", + "smallvec", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "gix-config-value" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6af5321bfd3711a279d6b244d58532ba1cfabf9eb6374791f19929d8970082" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-path", + "libc", + "thiserror", +] + +[[package]] +name = "gix-credentials" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbdb1c417980d05d547c19fc2b63344b5257c2387048d69ffe957bda142b59" +dependencies = [ + "bstr", + "gix-command", + "gix-config-value", + "gix-date", + "gix-path", + "gix-prompt", + "gix-quote", + "gix-sec", + "gix-trace", + "gix-url", + "thiserror", +] + +[[package]] +name = "gix-date" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e47b9e8cdc688296609b706428de570f88b1e0eed7156dde7b4a89d26fa4567" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", +] + +[[package]] +name = "gix-diff" +version = "0.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee7d89a3c507491cdfc57a1d1e0e300214720b4f7709ebc253e422f99822bfc" +dependencies = [ + "bstr", + "gix-attributes", + "gix-command", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-imara-diff", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-worktree", + "thiserror", +] + +[[package]] +name = "gix-dir" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f24bc78f283946ac64757c8a61ffa71f0230aa1e8d98cbb0771db479871dd5b6" +dependencies = [ + "bstr", + "gix-discover", + "gix-fs", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-trace", + "gix-utils", + "gix-worktree", + "thiserror", +] + +[[package]] +name = "gix-discover" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f517766fa1101dfe2606c1a19a8ffa699099030995a9194445446dfe261bdf" +dependencies = [ + "bstr", + "dunce", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", + "thiserror", +] + +[[package]] +name = "gix-error" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9292309fd944e71b2a3c96d3c03a6feb8852db646febdde7cbb9f79cb5f329" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-features" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20aa09e83a48dc02c5f5f08578aa79d3ab1bab4618b8c362f88684645a02bdcc" +dependencies = [ + "bytes", + "bytesize", + "crc32fast", + "crossbeam-channel", + "gix-path", + "gix-trace", + "gix-utils", + "libc", + "once_cell", + "parking_lot", + "prodash", + "walkdir", +] + +[[package]] +name = "gix-filter" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e7b5dbf524d97e839f642930c76d7f011c0791e7d11d8148989ac5af7c76aa8" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-fs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865cf13fcaf5455220546cb9607c416bd1be9a6caafd143655a362fdeab64e80" +dependencies = [ + "bstr", + "gix-features", + "gix-path", + "gix-utils", + "thiserror", +] + +[[package]] +name = "gix-glob" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421e92a711554fa5827d1b0599d3389acdd0f6729e97a8c5a57d79af1e50bf36" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-features", + "gix-path", +] + +[[package]] +name = "gix-hash" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13adaa73415fd6c902310923f68d0b98e8cecf14b33ea58c02cc387cee56f54e" +dependencies = [ + "faster-hex", + "gix-features", + "sha1-checked", + "thiserror", +] + +[[package]] +name = "gix-hashtable" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78fccd6fea3bcf0b39c076bae60ae49b08daaf538b950202101a981f9d3c01d3" +dependencies = [ + "gix-hash", + "hashbrown 0.17.1", + "parking_lot", +] + +[[package]] +name = "gix-ignore" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cff8e8aa125e39377456073e63df3334d9e5741372ddcc226198015076dda2" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-imara-diff" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1" +dependencies = [ + "bstr", + "hashbrown 0.17.1", +] + +[[package]] +name = "gix-index" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5009c4e7e9f9b4cfaaab1153e49133eb04d79c015b5702d6c3d2ab94271a89c6" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.17.1", + "itoa", + "libc", + "memmap2", + "rustix", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-lock" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c69157820343bf1c6e4b88b9808e920900de02e18aaf5862b30ada43814848" +dependencies = [ + "gix-tempfile", + "gix-utils", + "thiserror", +] + +[[package]] +name = "gix-mailmap" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a824767d38b81475059cb01f5020a13fb96e7ed6bbf9851c7112b46ada78db48" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-negotiate" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fa5aa789990f124e4f559b142cecfb60662cb4ae17006684ea9d668f4f07689" +dependencies = [ + "bitflags 2.13.1", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-object", + "gix-revwalk", +] + +[[package]] +name = "gix-object" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e48c235e7f886eb819fc878af75be889333dd3c38bee02ed7af48ae2cf596c4" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-odb" +version = "0.83.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dd494ffb5037e62b8220109e894d2861ff2150a2cacbfccdba57ae1ebab2b96" +dependencies = [ + "arc-swap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-path", + "gix-quote", + "gix-zlib", + "memmap2", + "parking_lot", + "tempfile", + "thiserror", +] + +[[package]] +name = "gix-pack" +version = "0.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d5446127b269706e85998065267ddd2ccc3550179da6780b22fe496175ccb20" +dependencies = [ + "clru", + "gix-chunk", + "gix-error", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-path", + "gix-zlib", + "memmap2", + "smallvec", + "thiserror", + "uluru", +] + +[[package]] +name = "gix-packetline" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3766025c72319c4accdd854a18e6f0dd176c8eb0f3bc8a60a7765be2b50cabf2" +dependencies = [ + "bstr", + "faster-hex", + "gix-trace", + "thiserror", +] + +[[package]] +name = "gix-path" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "751d6bd162106f8c1e7e9aaccb5bbdd605267e91a930a17a4560c46e33a9100c" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror", +] + +[[package]] +name = "gix-pathspec" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f6fa5f8007f008187c3f60b4373209ca83d1cc947f35ede03e16cd15a4d137" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-attributes", + "gix-config-value", + "gix-glob", + "gix-path", + "thiserror", +] + +[[package]] +name = "gix-prompt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cb1f1eb92d6f4c9d4c105a6ca912cff637fbd5cacbcbafa5deccd88bfaa3565" +dependencies = [ + "gix-command", + "gix-config-value", + "parking_lot", + "rustix", + "thiserror", +] + +[[package]] +name = "gix-protocol" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dede40e89c1e90f548415f50636bb051f6d9c60f68b8b710bc07825722d19588" +dependencies = [ + "bisync", + "bstr", + "gix-date", + "gix-features", + "gix-hash", + "gix-ref", + "gix-shallow", + "gix-transport", + "gix-utils", + "nonempty", + "thiserror", +] + +[[package]] +name = "gix-quote" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + +[[package]] +name = "gix-ref" +version = "0.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb0c90a8f6202ceaaa22996cbf837c943ccb2d8af9ff3490f0758305e6b7883" +dependencies = [ + "gix-actor", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror", +] + +[[package]] +name = "gix-refspec" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7406282cc0259b51f6aee299ca3d31279a020530363152a2e6c96e8a7f7bbc83" +dependencies = [ + "bstr", + "gix-error", + "gix-glob", + "gix-hash", + "gix-revision", + "gix-validate", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-revision" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e55e09d4a1ecf2beecc8c09cafcad37979e805b31f588b0e957e191df5783681" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "gix-trace", + "nonempty", +] + +[[package]] +name = "gix-revwalk" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c113c0a53294dc6280ffc06cbcc4f50f820397e97d6a00b429a44b8db26e29" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-sec" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4fe6c152c1d50aea36f299825702cd37e303307832fec1d0fdd5844e47ce2f" +dependencies = [ + "bitflags 2.13.1", + "gix-path", + "libc", + "windows-sys", +] + +[[package]] +name = "gix-shallow" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ecc9f4b40537043e4bbd7d3d1760e74fb8e7b07a546166b558acaa73ad97f4a" +dependencies = [ + "bstr", + "gix-hash", + "gix-lock", + "nonempty", + "thiserror", +] + +[[package]] +name = "gix-status" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f83b1c74e69b90411fbfe89ccebc47aa49457ce4eb9b942b1311258fc863d22" +dependencies = [ + "bstr", + "filetime", + "gix-diff", + "gix-dir", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-worktree", + "hashbrown 0.16.1", + "portable-atomic", + "thiserror", + "windows-sys", +] + +[[package]] +name = "gix-submodule" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd98077a56d08886112e6b08dc94076d03539f4bc0b9d7880e4be2b8a640d8c" +dependencies = [ + "bstr", + "gix-config", + "gix-path", + "gix-pathspec", + "gix-refspec", + "gix-url", + "thiserror", +] + +[[package]] +name = "gix-tempfile" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b675b920bd5a61d17ad542772f03ec34c60feb8ff683e1560c03ae967363731e" +dependencies = [ + "dashmap", + "gix-fs", + "libc", + "parking_lot", + "signal-hook", + "signal-hook-registry", + "tempfile", +] + +[[package]] +name = "gix-trace" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814" + +[[package]] +name = "gix-transport" +version = "0.58.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f36d045b840f8aeee1a527e677eab1fbebfbbe94bf2e708fa81d0b4b742d5fc" +dependencies = [ + "bstr", + "gix-command", + "gix-features", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-sec", + "gix-url", + "thiserror", +] + +[[package]] +name = "gix-traverse" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008c5cd879e46e86b5c2469e633611978b18775d53d05668d691bc13088bd409" +dependencies = [ + "bitflags 2.13.1", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-url" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31bdfc93aa880cda3272718a5879ce3aa7723fa13514320dd6608151607afe72" +dependencies = [ + "bstr", + "gix-path", + "gix-utils", + "percent-encoding", + "thiserror", +] + +[[package]] +name = "gix-utils" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1795bd2a970ca8b2185318c2abb97d955c71992f1cf28de73ad3b593a9f3ce8" +dependencies = [ + "bstr", + "fastrand", + "getrandom", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a034e84d1e04e1b1f20f51f12491da230b6ac8b925d0c8e1b89bcd87a7c5ccc" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-worktree" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31eb8e675122e83585e461fe28f68ff8c5ed55b49017b697e7e76423ff973424" +dependencies = [ + "bstr", + "gix-attributes", + "gix-features", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-validate", +] + +[[package]] +name = "gix-worktree-state" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc47372fc12b9fbea51b257bcc8d4970326cf5c7e42135bbfb5d72871bb30bd4" +dependencies = [ + "bstr", + "gix-features", + "gix-filter", + "gix-fs", + "gix-index", + "gix-object", + "gix-path", + "gix-worktree", + "io-close", + "thiserror", +] + +[[package]] +name = "gix-worktree-stream" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b088c8724e7be120c4798dd86925cf05332c9d356a463542578600c50c7a549" +dependencies = [ + "gix-attributes", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-path", + "gix-traverse", + "parking_lot", +] + +[[package]] +name = "gix-zlib" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e8813f5579b3075ff9c90f7c59cd2b62b4ebb639361f0911648b22d7446cc7c" +dependencies = [ + "thiserror", + "zlib-rs", +] + +[[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.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[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 = "human_format" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaec953f16e5bcf6b8a3cb3aa959b17e5577dbd2693e94554c462c08be22624b" + +[[package]] +name = "io-close" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc" +dependencies = [ + "libc", + "winapi", +] + +[[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.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[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 = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prodash" +version = "31.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" +dependencies = [ + "bytesize", + "human_format", + "parking_lot", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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 = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +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.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +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.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest", + "sha1", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +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", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +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 = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uluru" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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", +] + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4a7a1ce --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "gitbaby" +version = "0.1.0" +edition = "2024" + +[dependencies] +tokio = { version = "1.53.1", features = ["process", "io-util", "sync", "macros", "rt-multi-thread", "time", "fs"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = { version = "1.0.151", features = [] } +gix = { version = "0.86.0", features = [] } +time = { version = "0.3.55", features = [] } +async-trait = "0.1.92" \ No newline at end of file diff --git a/src/archive/helper.rs b/src/archive/helper.rs new file mode 100644 index 0000000..79f2d79 --- /dev/null +++ b/src/archive/helper.rs @@ -0,0 +1,103 @@ +use std::path::Path; + +use crate::command::cmd::Cmd; +use crate::command::env::Env; +use crate::error::BabyError; + +pub fn build_create_archive_cmd( + dir: &Path, + env: Env, + req: &crate::archive::types::CreateArchiveRequest, +) -> Result { + crate::share::build_archive_cmd( + "gitbaby::archive", + dir, + env, + req.format.as_arg(), + req.prefix.as_deref(), + &req.commit, + &req.paths, + ) +} + +pub fn build_get_archive_cmd( + dir: &Path, + env: Env, + req: &crate::archive::types::GetArchiveRequest, +) -> Result { + crate::share::validate_revision(&req.commit)?; + if let Some(p) = &req.prefix { + crate::share::reject_starts_with_dash(p, "archive prefix")?; + if p.contains('\0') || p.contains('\n') || p.contains('\r') { + return Err(BabyError::InvalidGitArg(format!( + "archive prefix contains control character: `{}`", + p + ))); + } + } + for exc in &req.exclude { + crate::share::reject_starts_with_dash(exc, "archive exclude")?; + } + let mut args = vec!["archive".to_string()]; + match req.format { + crate::archive::types::ArchiveFormat::Tar => args.push("--format=tar".to_string()), + crate::archive::types::ArchiveFormat::TarGz => args.push("--format=tar.gz".to_string()), + crate::archive::types::ArchiveFormat::TarBz2 => args.push("--format=tar.bz2".to_string()), + crate::archive::types::ArchiveFormat::Zip => args.push("--format=zip".to_string()), + } + if let Some(p) = &req.prefix { + args.push(format!("--prefix={}", p)); + } + let rev = match &req.path { + Some(p) => { + crate::share::path::validate_local_no_escape(std::path::Path::new(p))?; + format!("{}:{}", req.commit, p) + } + None => req.commit.clone(), + }; + args.push("--end-of-options".to_string()); + args.push(rev); + if !req.exclude.is_empty() { + args.push("--".to_string()); + for exc in &req.exclude { + args.push(format!(":(exclude){}", exc)); + } + } + Ok(crate::share::git_cmd( + "gitbaby::archive", + dir, + env, + None, + args, + )) +} + +pub fn build_bundle_cmd( + dir: &Path, + env: Env, + req: &crate::archive::types::BundleRequest, +) -> Result { + for r in &req.refs { + crate::share::reject_starts_with_dash(r, "bundle ref")?; + } + let mut args = vec![ + "bundle".to_string(), + "create".to_string(), + "-".to_string(), + "--end-of-options".to_string(), + ]; + for r in &req.refs { + args.push(r.clone()); + } + Ok(crate::share::git_cmd( + "gitbaby::archive", + dir, + env, + None, + args, + )) +} + +pub fn classify_stderr(stderr: &str) -> BabyError { + BabyError::ArchiveFailed(stderr.trim().to_string()) +} diff --git a/src/archive/mod.rs b/src/archive/mod.rs new file mode 100644 index 0000000..6565306 --- /dev/null +++ b/src/archive/mod.rs @@ -0,0 +1,7 @@ +pub mod helper; +pub mod types; +pub mod usecase; + +pub use types::{ + ArchiveFormat, BundleRequest, ChildArchiveReader, CreateArchiveRequest, GetArchiveRequest, +}; diff --git a/src/archive/types.rs b/src/archive/types.rs new file mode 100644 index 0000000..bc306af --- /dev/null +++ b/src/archive/types.rs @@ -0,0 +1,131 @@ +use std::fmt; + +use crate::command::env::Env; +use crate::error::BabyError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArchiveFormat { + Tar, + TarGz, + TarBz2, + Zip, +} + +impl ArchiveFormat { + pub fn as_arg(&self) -> crate::share::ArchiveFormatArg { + match self { + Self::Tar => crate::share::ArchiveFormatArg::Tar, + Self::TarGz => crate::share::ArchiveFormatArg::TarGz, + Self::TarBz2 => crate::share::ArchiveFormatArg::TarBz2, + Self::Zip => crate::share::ArchiveFormatArg::Zip, + } + } +} + +impl fmt::Display for ArchiveFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Tar => write!(f, "tar"), + Self::TarGz => write!(f, "tar.gz"), + Self::TarBz2 => write!(f, "tar.bz2"), + Self::Zip => write!(f, "zip"), + } + } +} + +#[derive(Debug, Clone)] +pub struct CreateArchiveRequest { + pub commit: String, + pub format: ArchiveFormat, + pub prefix: Option, + pub paths: Vec, +} + +#[derive(Debug, Clone)] +pub struct GetArchiveRequest { + pub commit: String, + pub format: ArchiveFormat, + pub prefix: Option, + pub path: Option, + pub exclude: Vec, +} + +#[derive(Debug, Clone)] +pub struct BundleRequest { + pub refs: Vec, +} + +#[derive(Debug)] +pub struct ChildArchiveReader { + reader: R, + child: Option, + finished: bool, + finished_after_eof: bool, +} + +impl ChildArchiveReader { + pub fn new(reader: R, child: Option) -> Self { + Self { + reader, + child, + finished: false, + finished_after_eof: false, + } + } + + pub fn into_inner(self) -> (R, Option) { + (self.reader, self.child) + } + + pub fn finished(&self) -> bool { + self.finished + } + + pub async fn next_chunk(&mut self, buf: &mut [u8]) -> Result { + use tokio::io::AsyncReadExt; + if self.finished { + return Ok(0); + } + let n = match self.reader.read(buf).await { + Ok(n) => n, + Err(e) => return Err(e.into()), + }; + if n == 0 { + self.finished = true; + } + Ok(n) + } + + pub async fn read_to_end(&mut self) -> Result, BabyError> { + use tokio::io::AsyncReadExt; + if self.finished_after_eof { + return Ok(Vec::new()); + } + let mut buf = Vec::new(); + match self.reader.read_to_end(&mut buf).await { + Ok(_) => { + self.finished = true; + self.finished_after_eof = true; + Ok(buf) + } + Err(e) => Err(e.into()), + } + } + + pub async fn close(mut self) -> Result<(), BabyError> { + use tokio::io::AsyncReadExt; + let mut scratch = [0u8; 4096]; + loop { + match self.reader.read(&mut scratch).await { + Ok(0) => break, + Ok(_) => continue, + Err(e) => return Err(e.into()), + } + } + if let Some(mut child) = self.child.take() { + let _ = child.wait().await; + } + let _ = std::marker::PhantomData::; + Ok(()) + } +} diff --git a/src/archive/usecase.rs b/src/archive/usecase.rs new file mode 100644 index 0000000..3d3d31d --- /dev/null +++ b/src/archive/usecase.rs @@ -0,0 +1,97 @@ +use tokio::io::{AsyncRead, BufReader}; + +use crate::GitBaby; +use crate::archive::helper; +use crate::archive::types::{ + BundleRequest, ChildArchiveReader, CreateArchiveRequest, GetArchiveRequest, +}; +use crate::command::env::Env; +use crate::error::BabyError; + +async fn build_env(gitbaby: &GitBaby) -> Result<(std::path::PathBuf, Env), BabyError> { + let dir = gitbaby + .facade + .git_repo_dir() + .await + .map_err(crate::share::facade_error)?; + let alternates = gitbaby + .facade + .git_alternate_object_directories() + .await + .map_err(crate::share::facade_error)?; + Ok((dir, crate::share::build_env_safe(&alternates))) +} + +impl GitBaby { + pub async fn create_archive(&self, req: CreateArchiveRequest) -> Result<(), BabyError> { + let (dir, env) = build_env(self).await?; + let mut cmd = helper::build_create_archive_cmd(&dir, env, &req)?; + let output = cmd.run_soft().await.map_err(BabyError::from)?; + if output.status.success() { + Ok(()) + } else { + Err(helper::classify_stderr(&String::from_utf8_lossy( + &output.stderr, + ))) + } + } + + pub async fn get_archive( + &self, + req: GetArchiveRequest, + ) -> Result>>, BabyError> + { + let (dir, env) = build_env(self).await?; + let mut cmd = helper::build_get_archive_cmd(&dir, env, &req)?; + cmd.spawn().await.map_err(BabyError::from)?; + let child = match cmd.cmd.as_mut() { + Some(c) => c, + None => { + return Err(BabyError::ArchiveFailed( + "git archive child not spawned".to_string(), + )); + } + }; + let stdout: Box = + Box::new(child.stdout.take().ok_or_else(|| { + BabyError::ArchiveFailed("git archive stdout not piped".to_string()) + })?); + let _ = std::mem::replace( + &mut cmd.stdout, + Box::new(tokio::io::empty()) as Box, + ); + Ok(ChildArchiveReader::new(BufReader::new(stdout), None)) + } + + pub async fn create_bundle( + &self, + req: BundleRequest, + ) -> Result>>, BabyError> + { + if req.refs.is_empty() { + return Err(BabyError::Custom( + "bundle requires at least one ref".to_string(), + )); + } + let (dir, env) = build_env(self).await?; + let mut cmd = helper::build_bundle_cmd(&dir, env, &req)?; + cmd.spawn().await.map_err(BabyError::from)?; + let child = match cmd.cmd.as_mut() { + Some(c) => c, + None => { + return Err(BabyError::ArchiveFailed( + "git bundle child not spawned".to_string(), + )); + } + }; + let stdout: Box = + Box::new(child.stdout.take().ok_or_else(|| { + BabyError::ArchiveFailed("git bundle stdout not piped".to_string()) + })?); + let _ = std::mem::replace( + &mut cmd.stdout, + Box::new(tokio::io::empty()) as Box, + ); + Ok(ChildArchiveReader::new(BufReader::new(stdout), None)) + } +} diff --git a/src/blame/helper.rs b/src/blame/helper.rs new file mode 100644 index 0000000..fff1173 --- /dev/null +++ b/src/blame/helper.rs @@ -0,0 +1,135 @@ +use std::path::{Path, PathBuf}; + +use crate::blame::types::BlameOptions; +use crate::command::cmd::Cmd; +use crate::command::env::Env; +use crate::command::error::CmdError; +use crate::error::BabyError; +use crate::share; + +pub fn validate_options(opts: &BlameOptions) -> Result<(), BabyError> { + share::validate_revision_non_empty(&opts.revision)?; + share::validate_local_no_escape(&opts.path)?; + if let Some(r) = opts.range + && (r.start == 0 || r.end == 0 || r.start > r.end) + { + return Err(BabyError::InvalidRange); + } + Ok(()) +} + +pub fn build_env(alternates: &[PathBuf]) -> Env { + share::build_env_safe(alternates) +} + +pub fn build_cmd(opts: &BlameOptions, dir: &Path, env: Env, timeout: Option) -> Cmd { + let mut args: Vec = Vec::with_capacity(8); + args.push("blame".to_string()); + args.push("--porcelain".to_string()); + if opts.bypass_ignore { + args.push("--ignore-revs-file=/dev/null".to_string()); + } else if let Some(ref f) = opts.ignore_revs_file { + args.push(format!("--ignore-revs-file={}", f.display())); + } + args.push(opts.revision.clone()); + if let Some(r) = opts.range { + args.push("-L".to_string()); + args.push(format!("{},{}", r.start, r.end)); + } + args.push("--".to_string()); + args.push(opts.path.to_string_lossy().into_owned()); + share::git_cmd("gitbaby::blame", dir, env, timeout, args) +} + +pub fn classify_stderr(stderr: &str, opts: &BlameOptions) -> BabyError { + let trimmed = stderr.trim(); + if let Some(rest) = trimmed.strip_prefix("fatal: no such path ") { + return BabyError::PathNotFound { + revision: opts.revision.clone(), + path: PathBuf::from(rest.trim().trim_matches('"').trim_matches('\'')), + }; + } + if let Some(rest) = trimmed.strip_prefix("fatal: invalid object name: ") { + if opts.ignore_revs_file.is_some() { + return BabyError::IgnoreRevsNotBlob { + revision: opts.revision.clone(), + }; + } + return BabyError::Parse { + line_number: 0, + message: format!("git invalid object: {}", rest.trim()), + }; + } + if let Some(rest) = trimmed.strip_prefix("fatal: file ") + && let Some((path_part, lines_part)) = rest.split_once(" has only ") + && let Some(lines_str) = lines_part + .strip_suffix(" lines") + .or_else(|| lines_part.strip_suffix(" line")) + && let Ok(n) = lines_str.trim().parse::() + { + let path = path_part + .trim() + .trim_end_matches(',') + .trim_matches('"') + .trim_matches('\'') + .to_string(); + return BabyError::OutOfRange { + actual_lines: n, + revision: opts.revision.clone(), + path: PathBuf::from(path), + }; + } + BabyError::Spawn { + prog: "git".to_string(), + source: std::io::Error::other("git blame non-zero exit"), + stderr: trimmed.to_string(), + } +} + +pub fn classify_cmd_error(err: CmdError, opts: &BlameOptions) -> BabyError { + match err { + CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, opts), + CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source), + other => BabyError::from(other), + } +} + +pub fn facade_error(e: E) -> BabyError { + share::facade_error(e) +} + +pub fn parse_header_sha(line: &str, object_id_len: usize) -> Option<(gix::ObjectId, &str)> { + if line.len() < object_id_len { + return None; + } + let (head, rest) = line.split_at(object_id_len); + if !rest.starts_with(' ') { + return None; + } + let sha = gix::ObjectId::from_hex(head.as_bytes()).ok()?; + Some((sha, &rest[1..])) +} + +pub struct HeaderParts { + pub orig_start: u32, + pub final_start: u32, + pub final_count: u32, +} + +pub fn parse_header_trailing(rest: &str) -> Option { + let mut it = rest.split(' '); + let a = it.next()?.parse::().ok()?; + let b = it.next()?.parse::().ok()?; + let c = match it.next() { + Some(v) => v.parse::().ok()?, + None => 1, + }; + if it.next().is_some() { + return None; + } + Some(HeaderParts { + orig_start: a, + final_start: b, + final_count: c, + }) +} diff --git a/src/blame/mod.rs b/src/blame/mod.rs new file mode 100644 index 0000000..58710ea --- /dev/null +++ b/src/blame/mod.rs @@ -0,0 +1,6 @@ +pub mod helper; +pub mod types; +pub mod usecase; + +pub use types::{BlameHunk, BlameOptions, Range}; +pub use usecase::ChildBlameReader; diff --git a/src/blame/types.rs b/src/blame/types.rs new file mode 100644 index 0000000..8dd08e8 --- /dev/null +++ b/src/blame/types.rs @@ -0,0 +1,34 @@ +use std::fmt; +use std::path::PathBuf; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlameHunk { + pub sha: gix::ObjectId, + pub previous_sha: Option, + pub previous_path: Option, + pub lines: Vec, + pub final_start_line: u32, + pub final_line_count: u32, + pub orig_start_line: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Range { + pub start: u32, + pub end: u32, +} + +impl fmt::Display for Range { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{},{}", self.start, self.end) + } +} + +#[derive(Debug, Clone)] +pub struct BlameOptions { + pub revision: String, + pub path: PathBuf, + pub range: Option, + pub ignore_revs_file: Option, + pub bypass_ignore: bool, +} diff --git a/src/blame/usecase.rs b/src/blame/usecase.rs new file mode 100644 index 0000000..a490e1f --- /dev/null +++ b/src/blame/usecase.rs @@ -0,0 +1,293 @@ +use std::path::Path; + +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, BufReader}; +use tokio::process::Child; + +use crate::GitBaby; +use crate::blame::helper; +use crate::blame::types::{BlameHunk, BlameOptions, Range}; +use crate::command::cmd::Cmd; +use crate::error::BabyError; + +pub struct ChildBlameReader { + reader: R, + object_id_len: usize, + pending: Option, + child: Option, + finished: bool, + line_no: u64, + stderr_tail: Option, +} + +impl ChildBlameReader { + pub fn new(reader: R, object_id_len: usize, child: Option) -> Self { + Self { + reader, + object_id_len, + pending: None, + child, + finished: false, + line_no: 0, + stderr_tail: None, + } + } + + pub fn into_inner(self) -> (R, Option) { + (self.reader, self.child) + } + + pub fn finished(&self) -> bool { + self.finished + } + + pub fn line_no(&self) -> u64 { + self.line_no + } + + pub fn set_stderr_tail(&mut self, stderr: String) { + self.stderr_tail = Some(stderr); + } + + pub async fn next_hunk(&mut self) -> Result, BabyError> { + if self.finished { + return Ok(None); + } + let mut buf = String::new(); + loop { + buf.clear(); + let n = self + .reader + .read_line(&mut buf) + .await + .map_err(|source| BabyError::Io { + path: std::path::PathBuf::from(""), + source, + })?; + if n == 0 { + self.finished = true; + return Ok(self.pending.take()); + } + self.line_no += 1; + let trimmed = buf.trim_end_matches(['\n', '\r']); + + if let Some((sha, rest)) = helper::parse_header_sha(trimmed, self.object_id_len) { + let header = + helper::parse_header_trailing(rest).ok_or_else(|| BabyError::Parse { + line_number: self.line_no, + message: format!("malformed header: {}", trimmed), + })?; + if let Some(prev) = self.pending.take() { + if prev.sha == sha { + let mut merged = prev; + merged.final_start_line = header.final_start; + merged.final_line_count = header.final_count; + merged.orig_start_line = header.orig_start; + self.pending = Some(merged); + continue; + } + self.pending = Some(BlameHunk { + sha, + previous_sha: None, + previous_path: None, + lines: Vec::new(), + final_start_line: header.final_start, + final_line_count: header.final_count, + orig_start_line: header.orig_start, + }); + return Ok(Some(prev)); + } + self.pending = Some(BlameHunk { + sha, + previous_sha: None, + previous_path: None, + lines: Vec::new(), + final_start_line: header.final_start, + final_line_count: header.final_count, + orig_start_line: header.orig_start, + }); + continue; + } + + if let Some(content) = buf.strip_prefix('\t') { + let content = content.trim_end_matches(['\n', '\r']); + match self.pending.as_mut() { + Some(h) => h.lines.push(content.to_string()), + None => { + return Err(BabyError::Parse { + line_number: self.line_no, + message: "content line outside of any hunk".into(), + }); + } + } + continue; + } + + if let Some(rest) = buf.strip_prefix("previous ") { + let rest = rest.trim_end_matches(['\n', '\r']); + let (sha_hex, path) = rest.split_once(' ').ok_or_else(|| BabyError::Parse { + line_number: self.line_no, + message: format!("malformed previous directive: {}", rest), + })?; + let prev_sha = + gix::ObjectId::from_hex(sha_hex.as_bytes()).map_err(|_| BabyError::Parse { + line_number: self.line_no, + message: format!("invalid previous sha: {}", sha_hex), + })?; + match self.pending.as_mut() { + Some(h) => { + h.previous_sha = Some(prev_sha); + h.previous_path = Some(std::path::PathBuf::from(path)); + } + None => { + return Err(BabyError::Parse { + line_number: self.line_no, + message: "previous directive outside of any hunk".into(), + }); + } + } + continue; + } + + if trimmed == "boundary" { + continue; + } + } + } + + pub async fn close(mut self) -> Result<(), BabyError> { + let Some(mut child) = self.child.take() else { + return Ok(()); + }; + let status = child.wait().await.map_err(|source| BabyError::Io { + path: std::path::PathBuf::from(""), + source, + })?; + if !status.success() { + return Err(BabyError::Spawn { + prog: "git".to_string(), + source: std::io::Error::other(format!("exit status {:?}", status)), + stderr: self.stderr_tail.unwrap_or_default(), + }); + } + Ok(()) + } +} + +impl GitBaby { + pub async fn stream_blame( + &self, + opts: BlameOptions, + ) -> Result>>, BabyError> + { + let (mut cmd, hex_len) = self.spawn_git_blame(&opts).await?; + cmd.spawn() + .await + .map_err(|e| helper::classify_cmd_error(e, &opts))?; + let mut child = cmd + .cmd + .take() + .ok_or_else(|| BabyError::Custom("git blame child not spawned".into()))?; + let stdout = std::mem::replace(&mut cmd.stdout, Box::new(tokio::io::empty())); + let _ = child.stdout.take(); + let _ = child.stdin.take(); + let _ = child.stderr.take(); + Ok(ChildBlameReader::new( + BufReader::new(stdout), + hex_len, + Some(child), + )) + } + + pub async fn blame_all(&self, opts: BlameOptions) -> Result, BabyError> { + let (mut cmd, hex_len) = self.spawn_git_blame(&opts).await?; + cmd.spawn() + .await + .map_err(|e| helper::classify_cmd_error(e, &opts))?; + let mut child = cmd + .cmd + .take() + .ok_or_else(|| BabyError::Custom("git blame child not spawned".into()))?; + let stdout = std::mem::replace(&mut cmd.stdout, Box::new(tokio::io::empty())); + let _ = child.stdout.take(); + let _ = child.stdin.take(); + let _ = child.stderr.take(); + let mut reader = ChildBlameReader::new(BufReader::new(stdout), hex_len, Some(child)); + let mut out = Vec::new(); + while let Some(h) = reader.next_hunk().await? { + out.push(h); + } + reader.close().await?; + Ok(out) + } + + pub async fn blame_line( + &self, + revision: &str, + path: &Path, + line: u32, + ) -> Result { + let opts = BlameOptions { + revision: revision.to_string(), + path: path.to_path_buf(), + range: Some(Range { + start: line, + end: line, + }), + ignore_revs_file: None, + bypass_ignore: false, + }; + let (mut cmd, hex_len) = self.spawn_git_blame(&opts).await?; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, &opts))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + &opts, + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + for line_str in stdout.lines() { + let trimmed = line_str.trim_end(); + if let Some(content) = trimmed.strip_prefix('\t') { + return Ok(content.to_string()); + } + if let Some((_sha, rest)) = helper::parse_header_sha(trimmed, hex_len) + && helper::parse_header_trailing(rest).is_some() + { + continue; + } + if trimmed.starts_with("previous ") { + continue; + } + if trimmed == "boundary" { + continue; + } + } + Err(BabyError::OutOfRange { + actual_lines: u64::from(line).saturating_sub(1), + revision: revision.to_string(), + path: path.to_path_buf(), + }) + } + + async fn spawn_git_blame(&self, opts: &BlameOptions) -> Result<(Cmd, usize), BabyError> { + helper::validate_options(opts)?; + let dir = self + .facade + .git_repo_dir() + .await + .map_err(helper::facade_error)?; + let alternates = self + .facade + .git_alternate_object_directories() + .await + .map_err(helper::facade_error)?; + let gix_repo = self.facade.gix_repo().await.map_err(helper::facade_error)?; + let env = helper::build_env(&alternates); + let cmd = helper::build_cmd(opts, &dir, env, None); + let hex_len = gix_repo.object_hash().len_in_hex(); + Ok((cmd, hex_len)) + } +} diff --git a/src/blob/helper.rs b/src/blob/helper.rs new file mode 100644 index 0000000..3db3c03 --- /dev/null +++ b/src/blob/helper.rs @@ -0,0 +1,245 @@ +use std::path::Path; + +use gix::ObjectId; + +use crate::blob::types::{ + BlobInfo, GetLFSPointersOptions, LFSPointer, ListAllBlobsOptions, ListAllLFSPointersOptions, + ListBlobsOptions, ListLFSPointersOptions, WriteBlobOptions, +}; +use crate::command::cmd::Cmd; +use crate::command::env::Env; +use crate::error::BabyError; +use crate::share; + +pub const CALLER: &str = "gitbaby::blob"; + +pub fn build_get_blob_cmd(dir: &Path, env: Env, oid: &ObjectId) -> Cmd { + share::git_cat_file_cmd(dir, env, oid, share::CatFileSubcommand::Blob) +} + +pub fn build_get_blob_size_cmd(dir: &Path, env: Env, oid: &ObjectId) -> Cmd { + let mut args = vec![ + "cat-file".to_string(), + "--batch-check".to_string(), + format!("%({})", "objectsize"), + ]; + args.push(oid.to_string()); + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_blob_exists_cmd(dir: &Path, env: Env, oid: &ObjectId) -> Cmd { + let args = vec!["cat-file".to_string(), "-e".to_string(), oid.to_string()]; + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_stream_blob_cmd(dir: &Path, env: Env, oid: &ObjectId) -> Cmd { + share::git_cat_file_cmd(dir, env, oid, share::CatFileSubcommand::Blob) +} + +pub fn build_list_blobs_cmd(dir: &Path, env: Env, opts: &ListBlobsOptions) -> Cmd { + let mut args = vec!["cat-file".to_string(), "--batch-all-objects".to_string()]; + if let Some(b) = opts.bytes_limit { + if b == 0 { + args.push("--batch-check".to_string()); + } else if b > 0 { + args.push("--batch".to_string()); + } + } else { + args.push("--batch-check".to_string()); + } + if opts.revisions.is_empty() { + args.push("--all".to_string()); + } else { + args.push("--revs-only".to_string()); + } + if let Some(l) = opts.limit { + args.push("--count".to_string()); + args.push(l.to_string()); + } + for r in &opts.revisions { + args.push(r.clone()); + } + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_list_all_blobs_cmd(dir: &Path, env: Env, opts: &ListAllBlobsOptions) -> Cmd { + let mut args = vec!["cat-file".to_string(), "--batch-all-objects".to_string()]; + if let Some(b) = opts.bytes_limit { + if b == 0 { + args.push("--batch-check".to_string()); + } else if b > 0 { + args.push("--batch".to_string()); + } + } else { + args.push("--batch-check".to_string()); + } + args.push("--all".to_string()); + if let Some(l) = opts.limit { + args.push("--count".to_string()); + args.push(l.to_string()); + } + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_list_lfs_pointers_cmd(dir: &Path, env: Env, opts: &ListLFSPointersOptions) -> Cmd { + let mut args = vec![ + "rev-list".to_string(), + "--objects".to_string(), + "--unpacked".to_string(), + ]; + if let Some(l) = opts.limit { + args.push("--max-count".to_string()); + args.push(l.to_string()); + } + for r in &opts.revisions { + args.push(r.clone()); + } + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_list_all_lfs_pointers_cmd( + dir: &Path, + env: Env, + opts: &ListAllLFSPointersOptions, +) -> Cmd { + let mut args = vec![ + "cat-file".to_string(), + "--batch-all-objects".to_string(), + "--batch-check".to_string(), + "--all".to_string(), + ]; + if let Some(l) = opts.limit { + args.push("--count".to_string()); + args.push(l.to_string()); + } + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_get_lfs_pointers_cmd(dir: &Path, env: Env, _opts: &GetLFSPointersOptions) -> Cmd { + let args = vec![ + "cat-file".to_string(), + "--batch-all-objects".to_string(), + "--batch-check".to_string(), + ]; + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_write_blob_cmd(dir: &Path, env: Env, opts: &WriteBlobOptions) -> Cmd { + share::git_hash_object_cmd(dir, env, opts.path.as_deref()) +} + +pub fn classify_blob_not_found(stderr: &str) -> BabyError { + BabyError::BlobNotFound { + oid: stderr.trim().to_string(), + } +} + +pub fn build_blob_env(alternates: &[std::path::PathBuf]) -> Env { + crate::share::build_env_safe(alternates) +} + +pub fn classify_stderr(stderr: &str) -> BabyError { + let s = stderr.trim(); + if s.contains("Not a valid object") || s.contains("does not exist") { + BabyError::BlobNotFound { oid: s.to_string() } + } else { + BabyError::Spawn { + prog: "git".to_string(), + source: std::io::Error::other("git command failed"), + stderr: s.to_string(), + } + } +} + +pub fn classify_cmd_error(err: crate::command::error::CmdError) -> BabyError { + match err { + crate::command::error::CmdError::NonZeroExit { stderr, .. } => { + let s = String::from_utf8_lossy(stderr.as_bytes()).into_owned(); + classify_stderr(&s) + } + crate::command::error::CmdError::Spawn { prog, source } => { + share::classify_spawn_error(prog, source) + } + other => BabyError::from(other), + } +} + +pub fn parse_cat_file_blob_output(stdout: &[u8], oid: &ObjectId) -> Result { + Ok(BlobInfo { + oid: *oid, + size: stdout.len() as i64, + data: Some(stdout.to_vec()), + }) +} + +pub fn parse_batch_check_line(line: &str) -> Result { + let mut parts = line.splitn(3, ' '); + let oid_str = parts + .next() + .ok_or_else(|| BabyError::Custom("batch-check line missing oid".to_string()))?; + let size_str = parts + .next() + .ok_or_else(|| BabyError::Custom("batch-check line missing size".to_string()))?; + let oid = share::parse_object_id(oid_str)?; + let size: i64 = size_str + .parse() + .map_err(|_| BabyError::Custom(format!("batch-check size `{}`", size_str)))?; + Ok(BlobInfo { + oid, + size, + data: None, + }) +} + +pub fn parse_lfs_pointer(bytes: &[u8], oid: &ObjectId) -> Result { + let s = std::str::from_utf8(bytes).map_err(|source| BabyError::LFSPointerInvalid { + oid: oid.to_string(), + reason: format!("invalid utf-8: {}", source), + })?; + let mut version_line: Option<&str> = None; + let mut oid_line: Option<&str> = None; + let mut size_line: Option<&str> = None; + for line in s.lines() { + let mut parts = line.splitn(2, ' '); + let key = parts.next().unwrap_or(""); + let value = parts.next().unwrap_or(""); + match key { + "version" => version_line = Some(value), + "oid" => oid_line = Some(value), + "size" => size_line = Some(value), + _ => {} + } + } + let version = version_line.ok_or_else(|| BabyError::LFSPointerInvalid { + oid: oid.to_string(), + reason: "missing version".to_string(), + })?; + if !version.contains("git-lfs") { + return Err(BabyError::LFSPointerInvalid { + oid: oid.to_string(), + reason: format!("unknown version `{}`", version), + }); + } + let oid_hex = oid_line.ok_or_else(|| BabyError::LFSPointerInvalid { + oid: oid.to_string(), + reason: "missing oid".to_string(), + })?; + let size_str = size_line.ok_or_else(|| BabyError::LFSPointerInvalid { + oid: oid.to_string(), + reason: "missing size".to_string(), + })?; + let file_oid = share::parse_object_id(oid_hex)?; + let file_size: i64 = size_str + .parse() + .map_err(|source| BabyError::LFSPointerInvalid { + oid: oid.to_string(), + reason: format!("invalid size `{}`: {}", size_str, source), + })?; + Ok(LFSPointer { + oid: *oid, + size: bytes.len() as i64, + file_oid, + file_size, + content: bytes.to_vec(), + }) +} diff --git a/src/blob/mod.rs b/src/blob/mod.rs new file mode 100644 index 0000000..a25d7e3 --- /dev/null +++ b/src/blob/mod.rs @@ -0,0 +1,9 @@ +pub mod helper; +pub mod types; +pub mod usecase; + +pub use types::{ + BlobContent, BlobInfo, GetBlobOptions, GetLFSPointersOptions, LFSPointer, ListAllBlobsOptions, + ListAllLFSPointersOptions, ListBlobsOptions, ListLFSPointersOptions, WriteBlobOptions, +}; +pub use usecase::ChildBlobReader; diff --git a/src/blob/types.rs b/src/blob/types.rs new file mode 100644 index 0000000..dfaf756 --- /dev/null +++ b/src/blob/types.rs @@ -0,0 +1,63 @@ +use gix::ObjectId; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlobInfo { + pub oid: ObjectId, + pub size: i64, + pub data: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlobContent { + pub info: BlobInfo, + pub data: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LFSPointer { + pub oid: ObjectId, + pub size: i64, + pub file_oid: ObjectId, + pub file_size: i64, + pub content: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct GetBlobOptions { + pub limit: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct ListBlobsOptions { + pub revisions: Vec, + pub limit: Option, + pub bytes_limit: Option, + pub with_paths: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct ListAllBlobsOptions { + pub limit: Option, + pub bytes_limit: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct ListLFSPointersOptions { + pub revisions: Vec, + pub limit: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct ListAllLFSPointersOptions { + pub limit: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct GetLFSPointersOptions { + pub blob_ids: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct WriteBlobOptions { + pub path: Option, +} diff --git a/src/blob/usecase.rs b/src/blob/usecase.rs new file mode 100644 index 0000000..8d77c0a --- /dev/null +++ b/src/blob/usecase.rs @@ -0,0 +1,273 @@ +use std::path::{Path, PathBuf}; + +use tokio::io::{AsyncBufRead, AsyncReadExt, BufReader}; +use tokio::process::Child; + +use gix::ObjectId; + +use crate::GitBaby; +use crate::blob::helper; +use crate::blob::types::{ + BlobContent, BlobInfo, GetBlobOptions, GetLFSPointersOptions, LFSPointer, ListAllBlobsOptions, + ListAllLFSPointersOptions, ListBlobsOptions, ListLFSPointersOptions, WriteBlobOptions, +}; +use crate::error::BabyError; + +pub struct ChildBlobReader { + reader: R, + child: Option, + finished: bool, +} + +impl ChildBlobReader { + pub fn new(reader: R, child: Option) -> Self { + Self { + reader, + child, + finished: false, + } + } + + pub fn into_inner(self) -> (R, Option) { + (self.reader, self.child) + } + + pub fn finished(&self) -> bool { + self.finished + } + + pub async fn next_chunk(&mut self, buf: &mut [u8]) -> Result { + if self.finished { + return Ok(0); + } + let n = self + .reader + .read(buf) + .await + .map_err(|source| BabyError::Io { + path: PathBuf::new(), + source, + })?; + if n == 0 { + self.finished = true; + } + Ok(n) + } + + pub async fn read_to_end(&mut self, cap: Option) -> Result, BabyError> { + let mut out = Vec::new(); + let mut buf = vec![0u8; 32 * 1024]; + loop { + let n = self.next_chunk(&mut buf).await?; + if n == 0 { + break; + } + out.extend_from_slice(&buf[..n]); + if let Some(c) = cap + && out.len() >= c + { + out.truncate(c); + break; + } + } + Ok(out) + } + + pub async fn close(mut self) -> Result<(), BabyError> { + if let Some(mut child) = self.child.take() { + let _ = child.wait().await; + } + Ok(()) + } +} + +impl GitBaby { + pub async fn get_blob( + &self, + oid: ObjectId, + opts: GetBlobOptions, + ) -> Result { + let mut cmd = self + .spawn_blob_env_cmd(|dir, env| helper::build_get_blob_cmd(dir, env, &oid)) + .await?; + let output = cmd.run_soft().await.map_err(helper::classify_cmd_error)?; + if !output.status.success() { + return Err(helper::classify_stderr(&String::from_utf8_lossy( + &output.stderr, + ))); + } + let mut data = output.stdout; + if let Some(limit) = opts.limit + && limit >= 0 + && (data.len() as i64) > limit + { + data.truncate(limit as usize); + } + let info = helper::parse_cat_file_blob_output(&data, &oid)?; + Ok(BlobContent { info, data }) + } + + pub async fn get_blob_size(&self, oid: ObjectId) -> Result { + let mut cmd = self + .spawn_blob_env_cmd(|dir, env| helper::build_get_blob_size_cmd(dir, env, &oid)) + .await?; + let output = cmd.run_soft().await.map_err(helper::classify_cmd_error)?; + if !output.status.success() { + return Err(helper::classify_stderr(&String::from_utf8_lossy( + &output.stderr, + ))); + } + let s = String::from_utf8_lossy(&output.stdout); + let line = s + .lines() + .next() + .ok_or_else(|| BabyError::Custom("empty cat-file --batch-check output".to_string()))?; + let size_str = line.trim(); + size_str.parse().map_err(|source| { + BabyError::Custom(format!("cat-file size `{}`: {}", size_str, source)) + }) + } + + pub async fn blob_exists(&self, oid: ObjectId) -> bool { + let mut cmd = match self + .spawn_blob_env_cmd(|dir, env| helper::build_blob_exists_cmd(dir, env, &oid)) + .await + { + Ok(c) => c, + Err(_) => return false, + }; + match cmd.run().await { + Ok(o) => o.status.success(), + Err(_) => false, + } + } + + pub async fn stream_blob( + &self, + oid: ObjectId, + ) -> Result< + ChildBlobReader>>, + BabyError, + > { + let mut cmd = self + .spawn_blob_env_cmd(|dir, env| helper::build_stream_blob_cmd(dir, env, &oid)) + .await?; + cmd.spawn().await.map_err(helper::classify_cmd_error)?; + let mut child = cmd + .cmd + .take() + .ok_or_else(|| BabyError::Custom("git cat-file child not spawned".to_string()))?; + let _ = child.stdout.take(); + let stdout = std::mem::replace( + &mut cmd.stdout, + Box::new(tokio::io::empty()) as Box, + ); + Ok(ChildBlobReader::new(BufReader::new(stdout), Some(child))) + } + + pub async fn list_blobs(&self, opts: ListBlobsOptions) -> Result, BabyError> { + let mut cmd = self + .spawn_blob_env_cmd(|dir, env| helper::build_list_blobs_cmd(dir, env, &opts)) + .await?; + let output = cmd.run_soft().await.map_err(helper::classify_cmd_error)?; + if !output.status.success() { + return Err(helper::classify_stderr(&String::from_utf8_lossy( + &output.stderr, + ))); + } + let s = String::from_utf8_lossy(&output.stdout); + let mut out = Vec::new(); + for line in s.lines() { + if line.trim().is_empty() { + continue; + } + out.push(helper::parse_batch_check_line(line)?); + } + Ok(out) + } + + pub async fn list_all_blobs( + &self, + opts: ListAllBlobsOptions, + ) -> Result, BabyError> { + let mut cmd = self + .spawn_blob_env_cmd(|dir, env| helper::build_list_all_blobs_cmd(dir, env, &opts)) + .await?; + let output = cmd.run_soft().await.map_err(helper::classify_cmd_error)?; + if !output.status.success() { + return Err(helper::classify_stderr(&String::from_utf8_lossy( + &output.stderr, + ))); + } + let s = String::from_utf8_lossy(&output.stdout); + let mut out = Vec::new(); + for line in s.lines() { + if line.trim().is_empty() { + continue; + } + out.push(helper::parse_batch_check_line(line)?); + } + Ok(out) + } + + pub async fn list_lfs_pointers( + &self, + _opts: ListLFSPointersOptions, + ) -> Result, BabyError> { + Err(BabyError::Unimplemented("list_lfs_pointers")) + } + + pub async fn list_all_lfs_pointers( + &self, + _opts: ListAllLFSPointersOptions, + ) -> Result, BabyError> { + Err(BabyError::Unimplemented("list_all_lfs_pointers")) + } + + pub async fn get_lfs_pointers( + &self, + _opts: GetLFSPointersOptions, + ) -> Result, BabyError> { + Err(BabyError::Unimplemented("get_lfs_pointers")) + } + + pub async fn write_blob( + &self, + content: Vec, + opts: WriteBlobOptions, + ) -> Result { + let mut cmd = self + .spawn_blob_env_cmd(|dir, env| helper::build_write_blob_cmd(dir, env, &opts)) + .await?; + cmd.spawn().await.map_err(helper::classify_cmd_error)?; + cmd.feed(&content) + .await + .map_err(helper::classify_cmd_error)?; + let output = cmd.run_soft().await.map_err(helper::classify_cmd_error)?; + if !output.status.success() { + return Err(helper::classify_stderr(&String::from_utf8_lossy( + &output.stderr, + ))); + } + let hex = String::from_utf8_lossy(&output.stdout).trim().to_string(); + crate::share::parse_object_id(&hex) + } + + async fn spawn_blob_env_cmd(&self, builder: F) -> Result + where + F: FnOnce(&Path, crate::command::env::Env) -> crate::command::cmd::Cmd, + { + let dir = self + .facade + .git_repo_dir() + .await + .map_err(crate::share::facade_error)?; + let alternates = self + .facade + .git_alternate_object_directories() + .await + .map_err(crate::share::facade_error)?; + let env = helper::build_blob_env(&alternates); + Ok(builder(&dir, env)) + } +} diff --git a/src/branch/helper.rs b/src/branch/helper.rs new file mode 100644 index 0000000..2a15189 --- /dev/null +++ b/src/branch/helper.rs @@ -0,0 +1,178 @@ +use std::path::Path; + +use crate::command::cmd::Cmd; +use crate::command::env::Env; +use crate::error::BabyError; +use crate::share; + +use super::types::ListBranchesOptions; + +pub const CALLER: &str = "gitbaby::branch"; + +pub fn validate_list_branches_options(opts: &ListBranchesOptions) -> Result<(), BabyError> { + if opts.patterns.is_empty() { + return Err(BabyError::InvalidBranchName( + "patterns must have at least one entry".to_string(), + )); + } + for p in &opts.patterns { + if !p.starts_with("refs/heads/") { + return Err(BabyError::InvalidBranchName(p.clone())); + } + share::validate_ref_name(p)?; + } + Ok(()) +} + +pub fn build_list_branches_cmd(dir: &Path, env: Env, opts: &ListBranchesOptions) -> Cmd { + let args = share::build_for_each_ref_args(&opts.patterns, None, None, None, None, &[], false); + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_get_default_branch_cmd(dir: &Path, env: Env) -> Cmd { + share::git_cmd( + CALLER, + dir, + env, + None, + vec![ + "symbolic-ref".to_string(), + "--short".to_string(), + "HEAD".to_string(), + ], + ) +} + +pub fn build_set_default_branch_cmd(dir: &Path, env: Env, name: &str) -> Cmd { + share::git_cmd( + CALLER, + dir, + env, + None, + vec![ + "symbolic-ref".to_string(), + "HEAD".to_string(), + format!("refs/heads/{}", name), + ], + ) +} + +pub fn build_branch_exists_cmd(dir: &Path, env: Env, name: &str) -> Cmd { + share::git_cmd( + CALLER, + dir, + env, + None, + vec![ + "show-ref".to_string(), + "--verify".to_string(), + "--".to_string(), + format!("refs/heads/{}", name), + ], + ) +} + +pub fn build_create_branch_cmd(dir: &Path, env: Env, name: &str, start_point: Option<&str>) -> Cmd { + let mut args = vec!["branch".to_string(), name.to_string()]; + if let Some(sp) = start_point { + args.push(sp.to_string()); + } + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_delete_branch_cmd(dir: &Path, env: Env, name: &str, force: bool) -> Cmd { + let flag = if force { "-D" } else { "-d" }; + share::git_cmd( + CALLER, + dir, + env, + None, + vec!["branch".to_string(), flag.to_string(), name.to_string()], + ) +} + +pub fn build_rename_branch_cmd(dir: &Path, env: Env, from: &str, to: &str) -> Cmd { + share::git_cmd( + CALLER, + dir, + env, + None, + vec![ + "branch".to_string(), + "-m".to_string(), + from.to_string(), + to.to_string(), + ], + ) +} + +pub fn build_update_head_branch_cmd(dir: &Path, env: Env, name: &str) -> Cmd { + share::git_cmd( + CALLER, + dir, + env, + None, + vec![ + "symbolic-ref".to_string(), + "HEAD".to_string(), + format!("refs/heads/{}", name), + ], + ) +} + +pub fn build_update_head_detached_cmd(dir: &Path, env: Env, sha: &str) -> Cmd { + share::git_cmd( + CALLER, + dir, + env, + None, + vec![ + "update-ref".to_string(), + "--no-deref".to_string(), + "HEAD".to_string(), + sha.to_string(), + ], + ) +} + +pub fn classify_stderr(stderr: &str, name: &str) -> BabyError { + let s = stderr.trim(); + if s.contains("already exists") { + return BabyError::BranchAlreadyExists(name.to_string()); + } + if s.contains("not found") || s.contains("does not exist") { + return BabyError::BranchNotFound(name.to_string()); + } + if s.contains("invalid") && s.contains("branch") { + return BabyError::InvalidBranchName(name.to_string()); + } + BabyError::Spawn { + prog: "git".to_string(), + source: std::io::Error::other(s.to_string()), + stderr: s.to_string(), + } +} + +pub fn classify_cmd_error(err: crate::command::error::CmdError, name: &str) -> BabyError { + use crate::command::error::CmdError; + match err { + CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, name), + CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source), + other => BabyError::from(other), + } +} + +pub fn parse_branch_name(output: &[u8]) -> Result { + let s = std::str::from_utf8(output) + .map_err(|e| BabyError::Custom(format!("branch invalid utf8: {}", e)))?; + Ok(s.trim().to_string()) +} + +pub fn parse_head_default(s: &str) -> Result { + if s.is_empty() { + return Err(BabyError::Custom( + "HEAD is not pointing at a branch".to_string(), + )); + } + Ok(s.to_string()) +} diff --git a/src/branch/mod.rs b/src/branch/mod.rs new file mode 100644 index 0000000..d85253d --- /dev/null +++ b/src/branch/mod.rs @@ -0,0 +1,5 @@ +pub mod helper; +pub mod types; +pub mod usecase; + +pub use types::{BranchInfo, HeadTarget, ListBranchesOptions}; diff --git a/src/branch/types.rs b/src/branch/types.rs new file mode 100644 index 0000000..c83806c --- /dev/null +++ b/src/branch/types.rs @@ -0,0 +1,18 @@ +use gix::ObjectId; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BranchInfo { + pub name: String, + pub sha: ObjectId, +} + +#[derive(Debug, Clone, Default)] +pub struct ListBranchesOptions { + pub patterns: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HeadTarget { + Branch(String), + Detached(ObjectId), +} diff --git a/src/branch/usecase.rs b/src/branch/usecase.rs new file mode 100644 index 0000000..e4951c4 --- /dev/null +++ b/src/branch/usecase.rs @@ -0,0 +1,237 @@ +use crate::GitBaby; +use crate::branch::helper; +use crate::branch::types::{BranchInfo, HeadTarget, ListBranchesOptions}; +use crate::error::BabyError; +use crate::refs::types::{ListRefsOptions, RefType}; +use crate::share; + +impl GitBaby { + pub async fn list_branches( + &self, + opts: ListBranchesOptions, + ) -> Result, BabyError> { + helper::validate_list_branches_options(&opts)?; + let refs_opts = ListRefsOptions { + patterns: opts.patterns.clone(), + peel_tags: false, + ..Default::default() + }; + let refs = self.list_refs(refs_opts).await?; + Ok(refs + .into_iter() + .filter(|r| matches!(r.ref_type, RefType::Branch)) + .map(|r| BranchInfo { + name: r.name, + sha: r.sha, + }) + .collect()) + } + + pub async fn find_branch(&self, name: &str) -> Result { + share::validate_ref_name(&format!("refs/heads/{}", name))?; + let full = format!("refs/heads/{}", name); + let refs_opts = ListRefsOptions { + patterns: vec![full.clone()], + peel_tags: false, + ..Default::default() + }; + let refs = self.list_refs(refs_opts).await?; + refs.into_iter() + .next() + .map(|r| BranchInfo { + name: r.name, + sha: r.sha, + }) + .ok_or_else(|| BabyError::BranchNotFound(name.to_string())) + } + + pub async fn branch_exists(&self, name: &str) -> Result { + share::validate_ref_name(&format!("refs/heads/{}", name))?; + let mut cmd = self + .spawn_branch_cmd(|dir, env| helper::build_branch_exists_cmd(dir, env, name)) + .await?; + match cmd.run().await { + Ok(out) => Ok(out.status.success()), + Err(crate::command::error::CmdError::NonZeroExit { code, stderr, .. }) => { + if code == 1 && !stderr.trim().is_empty() { + Ok(false) + } else { + Err(helper::classify_stderr(stderr.trim(), name)) + } + } + Err(e) => Err(helper::classify_cmd_error(e, name)), + } + } + + pub async fn get_default_branch(&self) -> Result { + let mut cmd = self + .spawn_branch_cmd(helper::build_get_default_branch_cmd) + .await?; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, ""))?; + if !output.status.success() { + return Err(BabyError::Custom(format!( + "failed to read HEAD: {}", + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + helper::parse_head_default(&helper::parse_branch_name(&output.stdout)?) + } + + pub async fn set_default_branch(&self, name: &str) -> Result<(), BabyError> { + share::validate_ref_name(&format!("refs/heads/{}", name))?; + let mut cmd = self + .spawn_branch_cmd(|dir, env| helper::build_set_default_branch_cmd(dir, env, name)) + .await?; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, name))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + name, + )); + } + Ok(()) + } + + pub async fn create_branch( + &self, + name: &str, + start_point: Option<&str>, + ) -> Result<(), BabyError> { + if name.is_empty() { + return Err(BabyError::InvalidBranchName(name.to_string())); + } + share::validate_ref_name(&format!("refs/heads/{}", name))?; + let mut cmd = self + .spawn_branch_cmd(|dir, env| { + helper::build_create_branch_cmd(dir, env, name, start_point) + }) + .await?; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, name))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + name, + )); + } + Ok(()) + } + + pub async fn create_branch_force( + &self, + name: &str, + start_point: Option<&str>, + ) -> Result<(), BabyError> { + if name.is_empty() { + return Err(BabyError::InvalidBranchName(name.to_string())); + } + share::validate_ref_name(&format!("refs/heads/{}", name))?; + let mut cmd = self + .spawn_branch_cmd(|dir, env| { + helper::build_create_branch_cmd(dir, env, name, start_point) + }) + .await?; + if let Err(e) = cmd.run().await { + return Err(helper::classify_cmd_error(e, name)); + } + Ok(()) + } + + pub async fn delete_branch(&self, name: &str, force: bool) -> Result<(), BabyError> { + if name.is_empty() { + return Err(BabyError::InvalidBranchName(name.to_string())); + } + let mut cmd = self + .spawn_branch_cmd(|dir, env| helper::build_delete_branch_cmd(dir, env, name, force)) + .await?; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, name))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + name, + )); + } + Ok(()) + } + + pub async fn rename_branch(&self, from: &str, to: &str) -> Result<(), BabyError> { + share::validate_ref_name(&format!("refs/heads/{}", from))?; + share::validate_ref_name(&format!("refs/heads/{}", to))?; + let mut cmd = self + .spawn_branch_cmd(|dir, env| helper::build_rename_branch_cmd(dir, env, from, to)) + .await?; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, from))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + from, + )); + } + Ok(()) + } + + pub async fn update_head(&self, target: HeadTarget) -> Result<(), BabyError> { + let mut cmd = match &target { + HeadTarget::Branch(name) => { + share::validate_ref_name(&format!("refs/heads/{}", name))?; + self.spawn_branch_cmd(|dir, env| { + helper::build_update_head_branch_cmd(dir, env, name) + }) + .await? + } + HeadTarget::Detached(oid) => { + self.spawn_branch_cmd(|dir, env| { + helper::build_update_head_detached_cmd(dir, env, &oid.to_string()) + }) + .await? + } + }; + let target_name = match &target { + HeadTarget::Branch(name) => name.clone(), + HeadTarget::Detached(oid) => oid.to_string(), + }; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, &target_name))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + &target_name, + )); + } + Ok(()) + } + + async fn spawn_branch_cmd(&self, builder: F) -> Result + where + F: FnOnce(&std::path::Path, crate::command::env::Env) -> crate::command::cmd::Cmd, + { + let dir = self + .facade + .git_repo_dir() + .await + .map_err(share::facade_error)?; + let alternates = self + .facade + .git_alternate_object_directories() + .await + .map_err(share::facade_error)?; + let env = share::build_env_safe(&alternates); + Ok(builder(&dir, env)) + } +} diff --git a/src/cleanup/helper.rs b/src/cleanup/helper.rs new file mode 100644 index 0000000..b62f512 --- /dev/null +++ b/src/cleanup/helper.rs @@ -0,0 +1,19 @@ +use std::path::Path; + +use crate::command::cmd::Cmd; +use crate::command::env::Env; + +pub fn build_gc_cmd(caller: &str, dir: &Path, env: Env, prune_now: bool) -> Cmd { + crate::share::build_gc_cmd(caller, dir, env, prune_now) +} + +pub fn build_pack_refs_cmd(caller: &str, dir: &Path, env: Env) -> Cmd { + crate::share::build_pack_refs_cmd(caller, dir, env) +} + +pub fn parse_pack_refs_output(output: &[u8]) -> u64 { + String::from_utf8_lossy(output) + .lines() + .filter(|l| !l.trim().is_empty()) + .count() as u64 +} diff --git a/src/cleanup/mod.rs b/src/cleanup/mod.rs new file mode 100644 index 0000000..83ad6a9 --- /dev/null +++ b/src/cleanup/mod.rs @@ -0,0 +1,5 @@ +pub mod helper; +pub mod types; +pub mod usecase; + +pub use types::{CleanupStats, PruneResult}; diff --git a/src/cleanup/types.rs b/src/cleanup/types.rs new file mode 100644 index 0000000..aecf47a --- /dev/null +++ b/src/cleanup/types.rs @@ -0,0 +1,9 @@ +#[derive(Debug, Clone, Default)] +pub struct PruneResult { + pub packed_refs: u64, +} + +#[derive(Debug, Clone, Default)] +pub struct CleanupStats { + pub removed_internal_refs: u64, +} diff --git a/src/cleanup/usecase.rs b/src/cleanup/usecase.rs new file mode 100644 index 0000000..ab0bc24 --- /dev/null +++ b/src/cleanup/usecase.rs @@ -0,0 +1,59 @@ +use std::path::Path; + +use crate::GitBaby; +use crate::cleanup::helper; +use crate::cleanup::types::{CleanupStats, PruneResult}; +use crate::error::BabyError; + +async fn build_env( + gitbaby: &GitBaby, +) -> Result<(std::path::PathBuf, crate::command::env::Env), BabyError> { + let dir = gitbaby + .facade + .git_repo_dir() + .await + .map_err(crate::share::facade_error)?; + let alternates = gitbaby + .facade + .git_alternate_object_directories() + .await + .map_err(crate::share::facade_error)?; + Ok((dir, crate::share::build_env_safe(&alternates))) +} + +impl GitBaby { + pub async fn apply_bfg_object_map(&self, bytes: &[u8]) -> Result { + let _ = build_env(self).await?; + let text = std::str::from_utf8(bytes) + .map_err(|e| BabyError::Custom(format!("object map is not valid UTF-8: {}", e)))?; + let pairs = crate::share::parse_object_map(text)?; + let total_removed: u64 = pairs.len() as u64; + Ok(CleanupStats { + removed_internal_refs: total_removed, + }) + } + + pub async fn prune(&self) -> Result { + let (dir, env) = build_env(self).await?; + let mut gc = helper::build_gc_cmd("gitbaby::cleanup", &dir, env.clone(), true); + let gc_out = gc.run().await.map_err(BabyError::from)?; + if !gc_out.status.success() { + return Err(BabyError::GcFailed( + String::from_utf8_lossy(&gc_out.stderr).trim().to_string(), + )); + } + let mut pr = helper::build_pack_refs_cmd("gitbaby::cleanup", &dir, env); + let pr_out = pr.run().await.map_err(BabyError::from)?; + if !pr_out.status.success() { + return Err(BabyError::GcFailed(format!( + "pack-refs failed: {}", + String::from_utf8_lossy(&pr_out.stderr).trim() + ))); + } + Ok(PruneResult { + packed_refs: helper::parse_pack_refs_output(&pr_out.stdout), + }) + } +} + +fn _path_marker(_p: &Path) {} diff --git a/src/command/cmd.rs b/src/command/cmd.rs new file mode 100644 index 0000000..90552e7 --- /dev/null +++ b/src/command/cmd.rs @@ -0,0 +1,337 @@ +use crate::command::env::Env; +use crate::command::error::{CmdError, CmdResult}; +use std::fmt::{Debug, Display, Formatter}; +use std::path::PathBuf; +use std::process::{ExitStatus, Stdio}; +use time::UtcDateTime; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::process::{Child, Command as TokioCommand}; + +pub struct Cmd { + pub caller_info: String, + pub prog: String, + pub args: Vec, + pub(crate) config_args: Vec, + pub dir: PathBuf, + pub cmd: Option, + pub start: UtcDateTime, + pub env: Env, + pub stdin: Box, + pub stdout: Box, + pub stderr: Box, + pub timeout: Option, + pub output_cap: usize, +} + +pub const DEFAULT_OUTPUT_CAP_BYTES: usize = 64 * 1024 * 1024; + +#[derive(Debug, Clone)] +pub struct CmdOutput { + pub status: ExitStatus, + pub stdout: Vec, + pub stderr: Vec, +} + +impl Clone for Cmd { + fn clone(&self) -> Self { + Self { + caller_info: self.caller_info.clone(), + prog: self.prog.clone(), + args: self.args.clone(), + config_args: self.config_args.clone(), + dir: self.dir.clone(), + cmd: None, + start: self.start, + env: self.env.clone(), + stdin: Box::new(tokio::io::sink()), + stdout: Box::new(tokio::io::empty()), + stderr: Box::new(tokio::io::empty()), + timeout: self.timeout, + output_cap: self.output_cap, + } + } +} + +impl Debug for Cmd { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Cmd") + .field("caller_info", &self.caller_info) + .field("prog", &self.prog) + .field("args", &self.args) + .field("config_args", &self.config_args) + .field("dir", &self.dir) + .field("start", &self.start) + .field("env", &self.env) + .field("timeout", &self.timeout) + .field("cmd", &self.cmd.as_ref().map(|_| "")) + .finish_non_exhaustive() + } +} + +impl Display for Cmd { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let mut parts: Vec<&str> = Vec::with_capacity(1 + self.config_args.len() + self.args.len()); + parts.push(&self.prog); + for a in &self.config_args { + parts.push(a); + } + for a in &self.args { + parts.push(a); + } + write!(f, "{}", parts.join(" ")) + } +} + +impl Cmd { + pub fn new( + caller_info: impl Into, + prog: impl Into, + args: Vec, + config_args: Vec, + dir: PathBuf, + env: impl Into, + timeout: Option, + ) -> Self { + Self { + caller_info: caller_info.into(), + prog: prog.into(), + args, + config_args, + dir, + cmd: None, + start: UtcDateTime::now(), + env: env.into(), + stdin: Box::new(tokio::io::sink()), + stdout: Box::new(tokio::io::empty()), + stderr: Box::new(tokio::io::empty()), + timeout, + output_cap: DEFAULT_OUTPUT_CAP_BYTES, + } + } + + pub fn build_command(&self) -> TokioCommand { + let mut command = TokioCommand::new(&self.prog); + for cfg in &self.config_args { + command.arg(cfg); + } + for arg in &self.args { + command.arg(arg); + } + command.current_dir(&self.dir); + command.stdin(Stdio::piped()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + for (k, v) in self.env.iter() { + command.env(k, v); + } + command + } + + pub async fn spawn(&mut self) -> CmdResult<()> { + if self.cmd.is_some() { + return Err(CmdError::Custom(format!( + "process `{}` already spawned", + self.prog + ))); + } + self.start = UtcDateTime::now(); + let mut command = self.build_command(); + let mut child = command.spawn().map_err(|source| CmdError::Spawn { + prog: self.prog.clone(), + source, + })?; + + let stdin = child + .stdin + .take() + .ok_or_else(|| CmdError::Custom("stdin was not piped".into()))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| CmdError::Custom("stdout was not piped".into()))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| CmdError::Custom("stderr was not piped".into()))?; + + self.stdin = Box::new(stdin); + self.stdout = Box::new(stdout); + self.stderr = Box::new(stderr); + self.cmd = Some(child); + Ok(()) + } + + pub async fn feed(&mut self, input: &[u8]) -> CmdResult<()> { + let writer = std::mem::replace(&mut self.stdin, Box::new(tokio::io::sink())); + let mut writer = writer; + writer + .write_all(input) + .await + .map_err(|source| CmdError::Io { + path: self.dir.clone(), + source, + })?; + writer.shutdown().await.map_err(|source| CmdError::Io { + path: self.dir.clone(), + source, + })?; + Ok(()) + } + + pub async fn wait(&mut self) -> CmdResult { + let child = self + .cmd + .as_mut() + .ok_or_else(|| CmdError::Custom("process not spawned yet".into()))?; + let wait_fut = child.wait(); + let status = if let Some(secs) = self.timeout { + match tokio::time::timeout(std::time::Duration::from_secs(secs), wait_fut).await { + Ok(res) => res, + Err(_) => { + let _ = child.start_kill(); + // Drain stdin/stdout/stderr so pipes don't keep child alive. + self.stdin = Box::new(tokio::io::sink()); + self.stdout = Box::new(tokio::io::empty()); + self.stderr = Box::new(tokio::io::empty()); + // Reap to avoid zombie. + let _ = child.wait().await; + return Err(CmdError::Timeout { + prog: self.prog.clone(), + timeout_ms: secs.saturating_mul(1000), + }); + } + } + } else { + wait_fut.await + }; + status.map_err(|source| CmdError::Io { + path: self.dir.clone(), + source, + }) + } + + pub async fn run(&mut self) -> CmdResult { + if self.cmd.is_none() { + self.spawn().await?; + } + let stdout = std::mem::replace(&mut self.stdout, Box::new(tokio::io::empty())); + let stderr = std::mem::replace(&mut self.stderr, Box::new(tokio::io::empty())); + let cap = self.output_cap; + + let read_out = async move { + let mut reader = stdout; + let mut buf = Vec::new(); + reader.read_to_end(&mut buf).await.map(|_| buf) + }; + let read_err = async move { + let mut reader = stderr; + let mut buf = Vec::new(); + reader.read_to_end(&mut buf).await.map(|_| buf) + }; + let wait_fut = self.wait(); + + let (out_res, err_res, wait_res) = tokio::join!(read_out, read_err, wait_fut); + + let stdout = out_res.map_err(|source| CmdError::Io { + path: self.dir.clone(), + source, + })?; + let stderr = err_res.map_err(|source| CmdError::Io { + path: self.dir.clone(), + source, + })?; + + let stdout_len = stdout.len() as u64; + let stderr_len = stderr.len() as u64; + if stdout_len > cap as u64 { + return Err(CmdError::PayloadTooLarge { + cap_bytes: cap as u64, + actual_bytes: stdout_len, + }); + } + if stderr_len > cap as u64 { + return Err(CmdError::PayloadTooLarge { + cap_bytes: cap as u64, + actual_bytes: stderr_len, + }); + } + + let status = wait_res?; + + if !status.success() { + return Err(CmdError::NonZeroExit { + prog: self.prog.clone(), + args: self.args.clone(), + code: status.code().unwrap_or(-1), + stderr: String::from_utf8_lossy(&stderr).into_owned(), + }); + } + + Ok(CmdOutput { + status, + stdout, + stderr, + }) + } + + /// Run the command but treat a non-zero exit as a *successful* run whose + /// `CmdOutput.status` carries the failure, instead of returning + /// `CmdError::NonZeroExit`. + /// + /// Use this when the caller wants to inspect exit codes / stderr itself + /// (e.g. classifying "does not exist" vs real errors). + pub async fn run_soft(&mut self) -> CmdResult { + match self.run().await { + Ok(out) => Ok(out), + Err(CmdError::NonZeroExit { code, stderr, .. }) => { + #[cfg(unix)] + let status = { + use std::os::unix::process::ExitStatusExt; + ExitStatus::from_raw(code << 8) + }; + #[cfg(not(unix))] + let status = { + use std::os::windows::process::ExitStatusExt; + ExitStatus::from_raw(code as u32) + }; + Ok(CmdOutput { + status, + stdout: Vec::new(), + stderr: stderr.into_bytes(), + }) + } + Err(e) => Err(e), + } + } + + pub async fn kill(&mut self) -> CmdResult<()> { + if let Some(child) = self.cmd.as_mut() { + child.start_kill().map_err(|source| CmdError::Io { + path: self.dir.clone(), + source, + })?; + } + Ok(()) + } + + pub fn try_wait(&mut self) -> CmdResult> { + let child = self + .cmd + .as_mut() + .ok_or_else(|| CmdError::Custom("process not spawned yet".into()))?; + child.try_wait().map_err(|source| CmdError::Io { + path: self.dir.clone(), + source, + }) + } + + pub fn is_running(&self) -> bool { + self.cmd.is_some() + } + + #[must_use] + pub fn with_dir(&mut self, dir: PathBuf) -> Self { + self.dir = dir; + self.clone() + } +} diff --git a/src/command/context.rs b/src/command/context.rs new file mode 100644 index 0000000..1e64927 --- /dev/null +++ b/src/command/context.rs @@ -0,0 +1,11 @@ +pub trait GitPipeContext { + fn cancel_pipe(&mut self); + + fn current_index(&self) -> Option { + None + } + + fn on_command_finished(&mut self, _index: usize) {} + + fn on_progress(&mut self, _index: usize, _bytes: u64) {} +} diff --git a/src/command/env.rs b/src/command/env.rs new file mode 100644 index 0000000..0b9d467 --- /dev/null +++ b/src/command/env.rs @@ -0,0 +1,159 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::fmt; + +#[derive(Default, Clone)] +pub struct Env { + pairs: Vec<(String, String)>, +} + +impl Env { + pub fn new() -> Self { + Self::default() + } + + pub fn with(mut self, key: impl Into, value: impl Into) -> Self { + self.pairs.push((key.into(), value.into())); + self + } + + pub fn set(&mut self, key: impl Into, value: impl Into) { + let key = key.into(); + self.pairs.retain(|(k, _)| k != &key); + self.pairs.push((key, value.into())); + } + + pub fn unset(&mut self, key: &str) { + self.pairs.retain(|(k, _)| k != key); + } + + pub fn get(&self, key: &str) -> Option<&str> { + self.pairs + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + } + + pub fn as_pairs(&self) -> &[(String, String)] { + &self.pairs + } + + pub fn into_pairs(self) -> Vec<(String, String)> { + self.pairs + } + + pub fn iter(&self) -> std::slice::Iter<'_, (String, String)> { + self.pairs.iter() + } + + pub fn len(&self) -> usize { + self.pairs.len() + } + + pub fn is_empty(&self) -> bool { + self.pairs.is_empty() + } + + pub fn append(&mut self, other: &Env) { + for (k, v) in other.iter() { + self.set(k.clone(), v.clone()); + } + } + + pub fn from_current_env() -> Self { + Self { + pairs: std::env::vars().collect(), + } + } + + pub fn with_current_env(mut self) -> Self { + for (k, v) in std::env::vars() { + self.set(k, v); + } + self + } + + pub fn overlay_current_env(mut self) -> Self { + let existing: HashSet = self.pairs.iter().map(|(k, _)| k.clone()).collect(); + for (k, v) in std::env::vars() { + if !existing.contains(&k) { + self.pairs.push((k, v)); + } + } + self + } +} + +impl fmt::Debug for Env { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let entries: Vec<(&str, String)> = self + .pairs + .iter() + .map(|(k, v)| (k.as_str(), masked_value(k, v))) + .collect(); + f.debug_map() + .entries(entries.iter().map(|(k, v)| (*k, v))) + .finish() + } +} + +fn is_sensitive_key(key: &str) -> bool { + let lower = key.to_ascii_lowercase(); + let markers = [ + "token", + "secret", + "password", + "passwd", + "apikey", + "api_key", + "credential", + "authorization", + "private_key", + "session", + "access_key", + "secret_key", + ]; + markers.iter().any(|m| lower.contains(m)) +} + +fn masked_value(key: &str, value: &str) -> String { + if is_sensitive_key(key) && !value.is_empty() { + "***REDACTED***".to_string() + } else { + value.to_string() + } +} + +impl fmt::Display for Env { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, (k, v)) in self.pairs.iter().enumerate() { + if i > 0 { + f.write_str(" ")?; + } + write!(f, "{}={}", k, masked_value(k, v))?; + } + Ok(()) + } +} + +impl From> for Env { + fn from(map: HashMap) -> Self { + Self { + pairs: map.into_iter().collect(), + } + } +} + +impl From> for Env { + fn from(pairs: Vec<(String, String)>) -> Self { + Self { pairs } + } +} + +impl FromIterator<(String, String)> for Env { + fn from_iter>(iter: T) -> Self { + Self { + pairs: iter.into_iter().collect(), + } + } +} diff --git a/src/command/error.rs b/src/command/error.rs new file mode 100644 index 0000000..2d4b55e --- /dev/null +++ b/src/command/error.rs @@ -0,0 +1,205 @@ +use std::fmt; +use std::path::PathBuf; + +#[derive(Debug)] +pub enum CmdError { + Spawn { + prog: String, + source: std::io::Error, + }, + Io { + path: PathBuf, + source: std::io::Error, + }, + NonZeroExit { + prog: String, + args: Vec, + code: i32, + stderr: String, + }, + Timeout { + prog: String, + timeout_ms: u64, + }, + InvalidArgs(String), + Cancelled, + Pipe(String), + PayloadTooLarge { + cap_bytes: u64, + actual_bytes: u64, + }, + Custom(String), +} + +impl fmt::Display for CmdError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Spawn { prog, source } => { + write!(f, "failed to spawn `{}`: {}", prog, source) + } + Self::Io { path, source } => { + write!(f, "io error at `{}`: {}", path.display(), source) + } + Self::NonZeroExit { + prog, + args, + code, + stderr, + } => { + let argv_summary = redact_argv(prog, args); + write!( + f, + "`{} {}` exited with code {}: {}", + prog, + argv_summary, + code, + stderr.trim() + ) + } + Self::Timeout { prog, timeout_ms } => { + write!(f, "`{}` timed out after {}ms", prog, timeout_ms) + } + Self::InvalidArgs(msg) => write!(f, "invalid args: {}", msg), + Self::Cancelled => write!(f, "command was cancelled"), + Self::Pipe(msg) => write!(f, "pipe error: {}", msg), + Self::PayloadTooLarge { + cap_bytes, + actual_bytes, + } => write!( + f, + "output exceeds cap: {} bytes > {} bytes cap", + actual_bytes, cap_bytes + ), + Self::Custom(msg) => write!(f, "{}", msg), + } + } +} + +impl std::error::Error for CmdError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Spawn { source, .. } | Self::Io { source, .. } => Some(source), + _ => None, + } + } +} + +impl From for CmdError { + fn from(source: std::io::Error) -> Self { + Self::Io { + path: PathBuf::new(), + source, + } + } +} + +pub type CmdResult = std::result::Result; + +const REDACTED: &str = "***REDACTED***"; + +fn redact_argv(prog: &str, args: &[String]) -> String { + let mut out = String::new(); + out.push_str(prog); + let mut redact_next = false; + for (idx, arg) in args.iter().enumerate() { + out.push(' '); + if redact_next { + out.push_str(REDACTED); + redact_next = false; + continue; + } + if SENSITIVE_FLAGS + .iter() + .any(|flag| arg == *flag || arg.starts_with(flag)) + { + let label = if arg.contains('=') { + arg.split_once('=').map(|(k, _)| k).unwrap_or(arg) + } else { + arg.as_str() + }; + out.push_str(label); + out.push('='); + out.push_str(REDACTED); + continue; + } + if URL_VALUE_FLAGS + .iter() + .any(|flag| arg == *flag || arg.starts_with(flag)) + { + let label = if arg.contains('=') { + arg.split_once('=').map(|(k, _)| k).unwrap_or(arg) + } else { + arg.as_str() + }; + out.push_str(label); + if idx + 1 < args.len() && looks_like_url(&args[idx + 1]) { + out.push(' '); + out.push_str(REDACTED); + let _ = arg; + continue; + } + out.push_str(" "); + continue; + } + if looks_like_url(arg) { + out.push_str(REDACTED); + continue; + } + if idx == args.len() - 1 && redact_next_marker(prog, arg) { + redact_next = true; + } + out.push_str(arg); + } + out +} + +fn looks_like_url(s: &str) -> bool { + if s.starts_with("http://") + || s.starts_with("https://") + || s.starts_with("ssh://") + || s.starts_with("git://") + || s.starts_with("file://") + { + true + } else { + s.contains("://") && s.contains('@') + } +} + +fn redact_next_marker(prog: &str, arg: &str) -> bool { + if prog != "git" { + return false; + } + matches!( + arg, + "credential.helper" + | "credential.username" + | "credential.password" + | "http.extraheader" + | "core.sshcommand" + | "core.gitproxy" + | "url..insteadof" + | "include.path" + ) +} + +static SENSITIVE_FLAGS: &[&str] = &[ + "--upload-pack=", + "--receive-pack=", + "-c", + "--config=", + "--config-env=", + "-S", + "--gpg-sign=", + "--key-id=", + "--ssh-command=", + "--askpass=", +]; + +static URL_VALUE_FLAGS: &[&str] = &[ + "remote.add", + "remote set-url", + "remote prune", + "--exec=", + "--uploadarchive=", +]; diff --git a/src/command/mod.rs b/src/command/mod.rs new file mode 100644 index 0000000..93bc6af --- /dev/null +++ b/src/command/mod.rs @@ -0,0 +1,5 @@ +pub mod cmd; +pub mod context; +pub mod env; +pub mod error; +pub mod pipe; diff --git a/src/command/pipe.rs b/src/command/pipe.rs new file mode 100644 index 0000000..ef6d481 --- /dev/null +++ b/src/command/pipe.rs @@ -0,0 +1,122 @@ +use crate::command::cmd::Cmd; +use crate::command::context::GitPipeContext; +use std::fmt; +use std::path::PathBuf; + +#[derive(Clone)] +pub struct Pipe { + pub name: String, + pub cmds: Vec, + pub dir: PathBuf, + pub timeout: Option, + pub cancelled: bool, +} + +impl Pipe { + pub fn new(name: impl Into, dir: PathBuf) -> Self { + Self { + name: name.into(), + cmds: Vec::new(), + dir, + timeout: None, + cancelled: false, + } + } + + /// Whether new subprocesses may still be spawned on this pipe. + /// Once `cancel_pipe()` has been called this returns `false` until `reset()`. + pub fn spawn_allowed(&self) -> bool { + !self.cancelled + } + + /// Re-arm a cancelled pipe so new commands may be scheduled again. + /// Pre-existing children that were killed during `cancel_pipe` are kept in + /// `cmds` for audit but must not be reused. + pub fn reset(&mut self) { + self.cancelled = false; + } + + pub fn push(mut self, cmd: Cmd) -> Self { + self.cmds.push(cmd); + self + } + + pub fn extend>(mut self, cmds: I) -> Self { + self.cmds.extend(cmds); + self + } + + pub fn with_timeout(mut self, timeout_ms: u64) -> Self { + self.timeout = Some(timeout_ms); + self + } + + pub fn with_dir(mut self, dir: PathBuf) -> Self { + self.dir = dir; + self + } + + pub fn len(&self) -> usize { + self.cmds.len() + } + + pub fn is_empty(&self) -> bool { + self.cmds.is_empty() + } + + pub fn is_cancelled(&self) -> bool { + self.cancelled + } + + pub fn first(&self) -> Option<&Cmd> { + self.cmds.first() + } + + pub fn last(&self) -> Option<&Cmd> { + self.cmds.last() + } +} + +impl fmt::Debug for Pipe { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Pipe") + .field("name", &self.name) + .field("cmds", &self.cmds.len()) + .field("dir", &self.dir) + .field("timeout", &self.timeout) + .field("cancelled", &self.cancelled) + .finish() + } +} + +impl fmt::Display for Pipe { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.cmds.is_empty() { + return write!(f, "{}:", self.name); + } + let parts: Vec = self.cmds.iter().map(|c| format!("{c}")).collect(); + write!(f, "{}: {}", self.name, parts.join(" | ")) + } +} + +impl GitPipeContext for Pipe { + fn cancel_pipe(&mut self) { + self.cancelled = true; + for cmd in &mut self.cmds { + if let Some(mut child) = cmd.cmd.take() { + let _ = child.start_kill(); + tokio::spawn(async move { + let _ = child.wait().await; + }); + } + } + } + + fn current_index(&self) -> Option { + if self.cancelled || self.cmds.is_empty() { + None + } else { + Some(self.cmds.len().saturating_sub(1)) + } + } +} diff --git a/src/commit/helper.rs b/src/commit/helper.rs new file mode 100644 index 0000000..2896019 --- /dev/null +++ b/src/commit/helper.rs @@ -0,0 +1,578 @@ +use std::path::{Path, PathBuf}; + +use crate::command::cmd::Cmd; +use crate::command::env::Env; +use crate::command::error::CmdError; +use crate::commit::types::{ + CommitInfo, CommitStats, ListCommitsOptions, Signature, TreeEntry, TreeEntryMode, +}; +use crate::error::BabyError; +use crate::share; + +pub const LOG_FORMAT: &str = "%H%x00%T%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%s%x00%b"; +pub const CALLER: &str = "gitbaby::commit"; + +pub struct StderrCtx<'a> { + pub revision: &'a str, + pub path: Option<&'a Path>, +} + +pub fn validate_revision(rev: &str) -> Result<(), BabyError> { + share::validate_revision_non_empty(rev) +} + +pub fn validate_path_local_no_escape(path: &Path) -> Result<(), BabyError> { + share::validate_local_no_escape(path) +} + +pub fn validate_list_commits_options(opts: &ListCommitsOptions) -> Result<(), BabyError> { + validate_revision(&opts.revision)?; + if let Some(p) = opts.path.as_deref() { + validate_path_local_no_escape(p)?; + } + Ok(()) +} + +pub fn build_env(alternates: &[PathBuf]) -> Env { + share::build_env_safe(alternates) +} + +pub fn build_get_commit_cmd(dir: &Path, env: Env, rev: &str) -> Cmd { + let args = vec!["cat-file".to_string(), "-p".to_string(), rev.to_string()]; + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_rev_parse_cmd(dir: &Path, env: Env, rev: &str) -> Cmd { + let args = vec![ + "rev-parse".to_string(), + "--verify".to_string(), + "--end-of-options".to_string(), + format!("{}^{{commit}}", rev), + ]; + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_is_ancestor_cmd(dir: &Path, env: Env, ancestor: &str, descendant: &str) -> Cmd { + let args = vec![ + "merge-base".to_string(), + "--is-ancestor".to_string(), + ancestor.to_string(), + descendant.to_string(), + ]; + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_list_commits_cmd(dir: &Path, env: Env, opts: &ListCommitsOptions) -> Cmd { + let mut args: Vec = Vec::with_capacity(8); + args.push("-c".to_string()); + args.push("core.quotepath=off".to_string()); + args.push("log".to_string()); + args.push("-z".to_string()); + args.push(format!("--format={}", LOG_FORMAT)); + if let Some(n) = opts.max_count { + args.push(format!("-n{}", n)); + } + if let Some(s) = opts.since.as_deref() { + args.push(format!("--since={}", s)); + } + if let Some(u) = opts.until.as_deref() { + args.push(format!("--until={}", u)); + } + if opts.first_parent { + args.push("--first-parent".to_string()); + } + if !opts.revision.is_empty() { + args.push(opts.revision.clone()); + } + if let Some(p) = opts.path.as_deref() { + args.push("--".to_string()); + args.push(p.to_string_lossy().into_owned()); + } + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_commit_stats_cmd(dir: &Path, env: Env, rev: &str, path: Option<&Path>) -> Cmd { + let mut args: Vec = Vec::with_capacity(4); + args.push("diff".to_string()); + args.push("--shortstat".to_string()); + args.push(format!("{}^!", rev)); + if let Some(p) = path { + args.push("--".to_string()); + args.push(p.to_string_lossy().into_owned()); + } + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_tree_entries_cmd(dir: &Path, env: Env, rev: &str, path: &Path) -> Cmd { + let args = vec![ + "ls-tree".to_string(), + "-l".to_string(), + rev.to_string(), + "--".to_string(), + path.to_string_lossy().into_owned(), + ]; + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_last_commit_for_path_cmd(dir: &Path, env: Env, rev: &str, path: &Path) -> Cmd { + let args = vec![ + "-c".to_string(), + "core.quotepath=off".to_string(), + "log".to_string(), + "-1".to_string(), + format!("--format={}", LOG_FORMAT), + rev.to_string(), + "--".to_string(), + path.to_string_lossy().into_owned(), + ]; + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn classify_stderr(stderr: &str, ctx: &StderrCtx<'_>) -> BabyError { + let trimmed = stderr.trim(); + if let Some(rest) = trimmed.strip_prefix("fatal: ambiguous argument '") + && let Some(needle) = + rest.strip_suffix("': unknown revision or path not in the working tree.") + { + return BabyError::RevisionNotFound(needle.to_string()); + } + if let Some(rest) = trimmed.strip_prefix("fatal: Not a tree: ") { + return BabyError::NotATree { + revision: rest.trim().to_string(), + path: ctx.path.map_or(PathBuf::new(), |p| p.to_path_buf()), + }; + } + if trimmed.contains("bad revision") || trimmed.contains("unknown revision") { + let mut iter = trimmed + .split_whitespace() + .filter(|w| !w.starts_with("fatal:")); + let rev = match iter.next() { + Some(w) => w.trim_matches('\'').to_string(), + None => String::new(), + }; + if rev.is_empty() { + return BabyError::RevisionNotFound(ctx.revision.to_string()); + } + return BabyError::RevisionNotFound(rev); + } + if let Some(rest) = trimmed.strip_prefix("fatal: not a tree object ") { + return BabyError::NotATree { + revision: rest.trim().to_string(), + path: ctx.path.map_or(PathBuf::new(), |p| p.to_path_buf()), + }; + } + if let Some(rest) = trimmed.strip_prefix("fatal: not a commit: ") { + return BabyError::RevisionInvalid(rest.trim().to_string()); + } + BabyError::Spawn { + prog: "git".to_string(), + source: std::io::Error::other("git non-zero exit"), + stderr: trimmed.to_string(), + } +} + +pub fn classify_cmd_error(err: CmdError, ctx: &StderrCtx<'_>) -> BabyError { + match err { + CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, ctx), + CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source), + other => BabyError::from(other), + } +} + +pub fn facade_error(e: E) -> BabyError { + share::facade_error(e) +} + +pub fn parse_signature_line(line: &str) -> Result { + let lt_idx = line.rfind(" <").ok_or_else(|| BabyError::CommitParse { + line_number: 0, + message: format!("missing `<` in signature: {}", line), + })?; + let gt_idx = line.rfind('>').ok_or_else(|| BabyError::CommitParse { + line_number: 0, + message: format!("missing `>` in signature: {}", line), + })?; + if gt_idx <= lt_idx { + return Err(BabyError::CommitParse { + line_number: 0, + message: format!("malformed signature delimiters: {}", line), + }); + } + let name_raw = line[..lt_idx].trim().to_string(); + let name = if let Some(stripped) = name_raw.strip_prefix('"').and_then(|s| s.strip_suffix('"')) + { + unescape_quoted_name(stripped) + } else { + name_raw + }; + let email = line[lt_idx + 2..gt_idx].trim().to_string(); + let tail = line[gt_idx + 1..].trim(); + let mut parts = tail.split_whitespace(); + let ts_str = parts.next().ok_or_else(|| BabyError::CommitParse { + line_number: 0, + message: format!("missing timestamp in signature: {}", line), + })?; + let tz_str = parts.next().ok_or_else(|| BabyError::CommitParse { + line_number: 0, + message: format!("missing timezone in signature: {}", line), + })?; + let unix: i64 = ts_str + .parse() + .map_err(|_| BabyError::InvalidTimestamp(ts_str.to_string()))?; + let offset = parse_offset(tz_str)?; + let time = time::OffsetDateTime::from_unix_timestamp(unix) + .map_err(|_| BabyError::InvalidTimestamp(ts_str.to_string()))? + .to_offset(offset); + Ok(Signature { name, email, time }) +} + +fn unescape_quoted_name(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + if let Some(next) = chars.next() { + match next { + 'n' => out.push('\n'), + 't' => out.push('\t'), + '\\' => out.push('\\'), + '"' => out.push('"'), + other => { + out.push('\\'); + out.push(other); + } + } + } else { + out.push('\\'); + } + } else { + out.push(c); + } + } + out +} + +fn parse_offset(s: &str) -> Result { + let bytes = s.as_bytes(); + if bytes.len() != 5 || (bytes[0] != b'+' && bytes[0] != b'-') { + return Err(BabyError::InvalidTimestamp(format!("offset {}", s))); + } + let sign = if bytes[0] == b'-' { -1 } else { 1 }; + let hours: i32 = std::str::from_utf8(&bytes[1..3]) + .map_err(|_| BabyError::InvalidTimestamp(s.to_string()))? + .parse() + .map_err(|_| BabyError::InvalidTimestamp(s.to_string()))?; + let mins: i32 = std::str::from_utf8(&bytes[3..5]) + .map_err(|_| BabyError::InvalidTimestamp(s.to_string()))? + .parse() + .map_err(|_| BabyError::InvalidTimestamp(s.to_string()))?; + let total_seconds = sign * (hours * 3600 + mins * 60); + time::UtcOffset::from_whole_seconds(total_seconds) + .map_err(|_| BabyError::InvalidTimestamp(s.to_string())) +} + +pub fn parse_commit_porcelain(id: gix::ObjectId, bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|source| BabyError::CommitParse { + line_number: 0, + message: format!("commit porcelain is not utf-8: {}", source), + })?; + let mut tree_id: Option = None; + let mut parents: Vec = Vec::new(); + let mut author: Option = None; + let mut committer: Option = None; + let mut gpg_signature: Option = None; + let mut message_lines: Vec = Vec::new(); + let mut in_message = false; + for (idx, line) in text.lines().enumerate() { + let line_no = idx as u64 + 1; + if in_message { + message_lines.push(line.to_string()); + continue; + } + if line.is_empty() { + in_message = true; + continue; + } + if let Some(rest) = line.strip_prefix("tree ") { + tree_id = Some(parse_object_id_field(rest, line_no, "tree")?); + continue; + } + if let Some(rest) = line.strip_prefix("parent ") { + parents.push(parse_object_id_field(rest, line_no, "parent")?); + continue; + } + if let Some(rest) = line.strip_prefix("author ") { + author = Some( + parse_signature_line(rest).map_err(|_| BabyError::CommitParse { + line_number: line_no, + message: format!("bad author: {}", rest), + })?, + ); + continue; + } + if let Some(rest) = line.strip_prefix("committer ") { + committer = Some( + parse_signature_line(rest).map_err(|_| BabyError::CommitParse { + line_number: line_no, + message: format!("bad committer: {}", rest), + })?, + ); + continue; + } + if let Some(rest) = line.strip_prefix("gpgsig ") { + gpg_signature = Some(rest.trim_start().trim_end_matches('\r').to_string()); + continue; + } + if line.starts_with(' ') && gpg_signature.is_some() { + if let Some(sig) = gpg_signature.as_mut() { + sig.push('\n'); + sig.push_str(line.trim_start()); + } + continue; + } + return Err(BabyError::CommitParse { + line_number: line_no, + message: format!("unexpected header line: {}", line), + }); + } + let tree_id = tree_id.ok_or_else(|| BabyError::CommitParse { + line_number: 0, + message: "commit porcelain missing tree".into(), + })?; + let author = author.ok_or_else(|| BabyError::CommitParse { + line_number: 0, + message: "commit porcelain missing author".into(), + })?; + let committer = committer.ok_or_else(|| BabyError::CommitParse { + line_number: 0, + message: "commit porcelain missing committer".into(), + })?; + let message = if message_lines.is_empty() { + String::new() + } else { + let trimmed_start = message_lines + .iter() + .position(|l| !l.is_empty()) + .unwrap_or(message_lines.len()); + let mut lines = message_lines[trimmed_start..].to_vec(); + while lines.last().is_some_and(|l| l.is_empty()) { + lines.pop(); + } + lines.join("\n") + }; + Ok(CommitInfo { + id, + tree_id, + parents, + author, + committer, + message, + gpg_signature, + }) +} + +fn parse_object_id_field(s: &str, line_no: u64, field: &str) -> Result { + let hex = s.trim(); + gix::ObjectId::from_hex(hex.as_bytes()).map_err(|source| BabyError::CommitParse { + line_number: line_no, + message: format!("bad {} oid `{}`: {}", field, hex, source), + }) +} + +pub fn parse_log_porcelain(bytes: &[u8]) -> Result, BabyError> { + let mut commits = Vec::new(); + for record in bytes.split(|b| *b == 0) { + if record.is_empty() { + continue; + } + let fields: Vec<&[u8]> = record.split(|b| *b == 0).collect(); + if fields.len() < 10 { + return Err(BabyError::CommitParse { + line_number: 0, + message: format!("log record has {} fields, expected 10", fields.len()), + }); + } + let id = gix::ObjectId::from_hex(fields[0]).map_err(|source| BabyError::CommitParse { + line_number: 0, + message: format!("bad id: {}", source), + })?; + let tree_id = + gix::ObjectId::from_hex(fields[1]).map_err(|source| BabyError::CommitParse { + line_number: 0, + message: format!("bad tree: {}", source), + })?; + let mut parents = Vec::new(); + if !fields[2].is_empty() { + for hex in fields[2].split(|b| *b == b' ') { + if hex.is_empty() { + continue; + } + let p = gix::ObjectId::from_hex(hex).map_err(|source| BabyError::CommitParse { + line_number: 0, + message: format!("bad parent: {}", source), + })?; + parents.push(p); + } + } + let author = parse_signature_line(std::str::from_utf8(fields[3]).map_err(|source| { + BabyError::CommitParse { + line_number: 0, + message: format!("author not utf-8: {}", source), + } + })?)?; + let author_email = std::str::from_utf8(fields[4]) + .map_err(|source| BabyError::CommitParse { + line_number: 0, + message: format!("author email not utf-8: {}", source), + })? + .to_string(); + if author_email != author.email { + return Err(BabyError::CommitParse { + line_number: 0, + message: format!( + "author email mismatch: `{}` vs `{}`", + author_email, author.email + ), + }); + } + let committer = + parse_signature_line(std::str::from_utf8(fields[6]).map_err(|source| { + BabyError::CommitParse { + line_number: 0, + message: format!("committer not utf-8: {}", source), + } + })?)?; + let committer_email = std::str::from_utf8(fields[7]) + .map_err(|source| BabyError::CommitParse { + line_number: 0, + message: format!("committer email not utf-8: {}", source), + })? + .to_string(); + if committer_email != committer.email { + return Err(BabyError::CommitParse { + line_number: 0, + message: format!( + "committer email mismatch: `{}` vs `{}`", + committer_email, committer.email + ), + }); + } + let subject = std::str::from_utf8(fields[8]) + .map_err(|source| BabyError::CommitParse { + line_number: 0, + message: format!("subject not utf-8: {}", source), + })? + .to_string(); + let body = std::str::from_utf8(fields[9]) + .map_err(|source| BabyError::CommitParse { + line_number: 0, + message: format!("body not utf-8: {}", source), + })? + .trim_end_matches('\n') + .to_string(); + let message = if body.is_empty() { + subject + } else { + format!("{}\n\n{}", subject, body) + }; + commits.push(CommitInfo { + id, + tree_id, + parents, + author, + committer, + message, + gpg_signature: None, + }); + } + Ok(commits) +} + +pub fn parse_tree_entries(bytes: &[u8]) -> Result, BabyError> { + let mut out = Vec::new(); + for (idx, line) in bytes.split(|b| *b == b'\n').enumerate() { + if line.is_empty() { + continue; + } + let line_str = std::str::from_utf8(line).map_err(|source| BabyError::CommitParse { + line_number: idx as u64 + 1, + message: format!("tree entry not utf-8: {}", source), + })?; + let mut parts = line_str.splitn(3, ' '); + let mode_str = parts.next().ok_or_else(|| BabyError::CommitParse { + line_number: idx as u64 + 1, + message: format!("tree entry missing mode: {}", line_str), + })?; + let mode = + TreeEntryMode::from_octal_str(mode_str).ok_or_else(|| BabyError::CommitParse { + line_number: idx as u64 + 1, + message: format!("unknown tree mode: {}", mode_str), + })?; + let rest = parts.next().ok_or_else(|| BabyError::CommitParse { + line_number: idx as u64 + 1, + message: format!("tree entry missing type: {}", line_str), + })?; + let oid_hex = rest + .split(' ') + .next() + .ok_or_else(|| BabyError::CommitParse { + line_number: idx as u64 + 1, + message: format!("tree entry missing oid: {}", line_str), + })?; + let oid = gix::ObjectId::from_hex(oid_hex.as_bytes()).map_err(|source| { + BabyError::CommitParse { + line_number: idx as u64 + 1, + message: format!("bad tree oid `{}`: {}", oid_hex, source), + } + })?; + let tail = parts.next().ok_or_else(|| BabyError::CommitParse { + line_number: idx as u64 + 1, + message: format!("tree entry missing tail: {}", line_str), + })?; + let (name, size) = if let Some((n, s)) = tail.split_once('\t') { + (n.to_string(), s.parse::().ok()) + } else { + (tail.to_string(), None) + }; + out.push(TreeEntry { + oid, + name, + mode, + size, + }); + } + Ok(out) +} + +pub fn parse_shortstat(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|source| BabyError::CommitParse { + line_number: 0, + message: format!("shortstat not utf-8: {}", source), + })?; + let mut stats = CommitStats::default(); + for part in text.split_whitespace() { + if let Some(rest) = part.strip_suffix(')') { + if rest.contains("(+)") { + let n: u64 = rest.trim_end_matches("(+)").parse().map_err(|source| { + BabyError::CommitParse { + line_number: 0, + message: format!("bad additions number `{}`: {}", part, source), + } + })?; + stats.additions = n; + continue; + } + if rest.contains("(-)") { + let n: u64 = rest.trim_end_matches("(-)").parse().map_err(|source| { + BabyError::CommitParse { + line_number: 0, + message: format!("bad deletions number `{}`: {}", part, source), + } + })?; + stats.deletions = n; + continue; + } + } + } + Ok(stats) +} diff --git a/src/commit/mod.rs b/src/commit/mod.rs new file mode 100644 index 0000000..b72724d --- /dev/null +++ b/src/commit/mod.rs @@ -0,0 +1,5 @@ +pub mod helper; +pub mod types; +pub mod usecase; + +pub use types::{CommitInfo, CommitStats, ListCommitsOptions, Signature, TreeEntry, TreeEntryMode}; diff --git a/src/commit/types.rs b/src/commit/types.rs new file mode 100644 index 0000000..fb8b930 --- /dev/null +++ b/src/commit/types.rs @@ -0,0 +1,99 @@ +use std::fmt; +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TreeEntryMode { + Blob, + Exec, + Symlink, + Tree, + Submodule, + Commit, +} + +impl TreeEntryMode { + pub fn from_octal_str(s: &str) -> Option { + let kind = u32::from_str_radix(s, 8).ok()?; + let file_kind = kind & 0o170000; + match file_kind { + 0o040000 => return Some(Self::Tree), + 0o160000 => return Some(Self::Commit), + _ => {} + } + match kind { + 0o100644 => Some(Self::Blob), + 0o100755 => Some(Self::Exec), + 0o120000 => Some(Self::Symlink), + _ => None, + } + } +} + +impl fmt::Display for TreeEntryMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Blob => "blob", + Self::Exec => "exec", + Self::Symlink => "symlink", + Self::Tree => "tree", + Self::Submodule => "submodule", + Self::Commit => "commit", + }; + f.write_str(s) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Signature { + pub name: String, + pub email: String, + pub time: time::OffsetDateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitInfo { + pub id: gix::ObjectId, + pub tree_id: gix::ObjectId, + pub parents: Vec, + pub author: Signature, + pub committer: Signature, + pub message: String, + pub gpg_signature: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct CommitStats { + pub additions: u64, + pub deletions: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TreeEntry { + pub oid: gix::ObjectId, + pub name: String, + pub mode: TreeEntryMode, + pub size: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListCommitsOptions { + pub revision: String, + pub path: Option, + pub max_count: Option, + pub since: Option, + pub until: Option, + pub first_parent: bool, +} + +impl Default for ListCommitsOptions { + fn default() -> Self { + Self { + revision: "HEAD".to_string(), + path: None, + max_count: None, + since: None, + until: None, + first_parent: false, + } + } +} diff --git a/src/commit/usecase.rs b/src/commit/usecase.rs new file mode 100644 index 0000000..b4d53f2 --- /dev/null +++ b/src/commit/usecase.rs @@ -0,0 +1,238 @@ +use std::path::Path; + +use crate::GitBaby; +use crate::commit::helper; +use crate::commit::types::{CommitInfo, CommitStats, ListCommitsOptions, TreeEntry}; +use crate::error::BabyError; + +impl GitBaby { + pub async fn get_commit(&self, revision: &str) -> Result { + helper::validate_revision(revision)?; + let mut cmd = self + .spawn_commit_cmd(revision, |dir, env| { + helper::build_get_commit_cmd(dir, env, revision) + }) + .await?; + let ctx = helper::StderrCtx { + revision, + path: None, + }; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, &ctx))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + &ctx, + )); + } + let id = self + .rev_parse(revision) + .await + .map_err(helper::facade_error)?; + helper::parse_commit_porcelain(id, &output.stdout) + } + + pub async fn rev_parse(&self, revision: &str) -> Result { + helper::validate_revision(revision)?; + let mut cmd = self + .spawn_commit_cmd(revision, |dir, env| { + helper::build_rev_parse_cmd(dir, env, revision) + }) + .await?; + let ctx = helper::StderrCtx { + revision, + path: None, + }; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, &ctx))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + &ctx, + )); + } + let text = String::from_utf8_lossy(&output.stdout); + let hex = text.trim(); + gix::ObjectId::from_hex(hex.as_bytes()).map_err(|source| { + BabyError::Custom(format!( + "rev-parse returned invalid oid `{}`: {}", + hex, source + )) + }) + } + + pub async fn is_ancestor(&self, ancestor: &str, descendant: &str) -> Result { + helper::validate_revision(ancestor)?; + helper::validate_revision(descendant)?; + let mut cmd = self + .spawn_commit_cmd(descendant, |dir, env| { + helper::build_is_ancestor_cmd(dir, env, ancestor, descendant) + }) + .await?; + let ctx = helper::StderrCtx { + revision: descendant, + path: None, + }; + let output = match cmd.run().await { + Ok(o) => o, + Err(CmdError::NonZeroExit { stderr, .. }) => { + if stderr.trim().is_empty() { + return Ok(false); + } + return Err(helper::classify_stderr(&stderr, &ctx)); + } + Err(e) => return Err(helper::classify_cmd_error(e, &ctx)), + }; + if output.status.success() { + Ok(true) + } else { + Ok(false) + } + } + + pub async fn list_commits( + &self, + opts: ListCommitsOptions, + ) -> Result, BabyError> { + helper::validate_list_commits_options(&opts)?; + let rev = opts.revision.clone(); + let mut cmd = self + .spawn_commit_cmd(&rev, |dir, env| { + helper::build_list_commits_cmd(dir, env, &opts) + }) + .await?; + let ctx = helper::StderrCtx { + revision: &rev, + path: opts.path.as_deref(), + }; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, &ctx))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + &ctx, + )); + } + helper::parse_log_porcelain(&output.stdout) + } + + pub async fn commit_stats( + &self, + revision: &str, + path: Option<&Path>, + ) -> Result { + helper::validate_revision(revision)?; + if let Some(p) = path { + helper::validate_path_local_no_escape(p)?; + } + let mut cmd = self + .spawn_commit_cmd(revision, |dir, env| { + helper::build_commit_stats_cmd(dir, env, revision, path) + }) + .await?; + let ctx = helper::StderrCtx { revision, path }; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, &ctx))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + &ctx, + )); + } + helper::parse_shortstat(&output.stdout) + } + + pub async fn get_tree_entries( + &self, + revision: &str, + path: &Path, + ) -> Result, BabyError> { + helper::validate_revision(revision)?; + helper::validate_path_local_no_escape(path)?; + let mut cmd = self + .spawn_commit_cmd(revision, |dir, env| { + helper::build_tree_entries_cmd(dir, env, revision, path) + }) + .await?; + let ctx = helper::StderrCtx { + revision, + path: Some(path), + }; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, &ctx))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + &ctx, + )); + } + helper::parse_tree_entries(&output.stdout) + } + + pub async fn last_commit_for_path( + &self, + revision: &str, + path: &Path, + ) -> Result { + helper::validate_revision(revision)?; + helper::validate_path_local_no_escape(path)?; + let mut cmd = self + .spawn_commit_cmd(revision, |dir, env| { + helper::build_last_commit_for_path_cmd(dir, env, revision, path) + }) + .await?; + let ctx = helper::StderrCtx { + revision, + path: Some(path), + }; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, &ctx))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + &ctx, + )); + } + let mut commits = helper::parse_log_porcelain(&output.stdout)?; + commits + .pop() + .ok_or_else(|| BabyError::RevisionNotFound(revision.to_string())) + } + + async fn spawn_commit_cmd( + &self, + rev: &str, + builder: F, + ) -> Result + where + F: FnOnce(&Path, crate::command::env::Env) -> crate::command::cmd::Cmd, + { + let _ = rev; + let dir = self + .facade + .git_repo_dir() + .await + .map_err(helper::facade_error)?; + let alternates = self + .facade + .git_alternate_object_directories() + .await + .map_err(helper::facade_error)?; + let env = helper::build_env(&alternates); + Ok(builder(&dir, env)) + } +} + +use crate::command::error::CmdError; diff --git a/src/compare/helper.rs b/src/compare/helper.rs new file mode 100644 index 0000000..0f6bd7f --- /dev/null +++ b/src/compare/helper.rs @@ -0,0 +1,122 @@ +use std::path::Path; + +use crate::command::cmd::Cmd; +use crate::command::env::Env; +use crate::compare::types::DivergeObject; +use crate::error::BabyError; + +pub fn build_diverging_commits_cmd( + dir: &Path, + env: Env, + base: &str, + target: &str, +) -> Result { + crate::share::validate_revision(base)?; + crate::share::validate_revision(target)?; + let args = vec![ + "rev-list".to_string(), + "--count".to_string(), + "--left-right".to_string(), + "--end-of-options".to_string(), + format!("{}...{}", base, target), + ]; + Ok(crate::share::git_cmd( + "gitbaby::compare", + dir, + env, + None, + args, + )) +} + +pub fn build_commit_ids_between_reverse_cmd( + dir: &Path, + env: Env, + start: &str, + end: &str, + not_ref: Option<&str>, + limit: Option, +) -> Result { + crate::share::validate_revision(start)?; + crate::share::validate_revision(end)?; + if let Some(n) = not_ref { + crate::share::validate_revision(n)?; + } + let mut args = vec!["rev-list".to_string()]; + if let Some(n) = not_ref { + args.push("--not".to_string()); + args.push(n.to_string()); + } + if let Some(l) = limit { + args.push("--max-count".to_string()); + args.push(l.to_string()); + } + args.push("--reverse".to_string()); + args.push("--end-of-options".to_string()); + args.push(format!("{}..{}", start, end)); + Ok(crate::share::git_cmd( + "gitbaby::compare", + dir, + env, + None, + args, + )) +} + +pub fn classify_cmd_error(err: crate::command::error::CmdError) -> BabyError { + use crate::command::error::CmdError; + match err { + CmdError::NonZeroExit { stderr, .. } => { + if stderr.trim().is_empty() { + BabyError::Custom("rev-list exited non-zero with empty stderr".to_string()) + } else { + BabyError::Custom(format!("rev-list failed: {}", stderr.trim())) + } + } + CmdError::Spawn { prog, source } => crate::share::classify_spawn_error(prog, source), + other => BabyError::from(other), + } +} + +pub fn parse_left_right_count(output: &[u8]) -> Result { + let text = String::from_utf8_lossy(output); + let mut parts = text.split_whitespace(); + let ahead = match parts.next() { + Some(s) => s.parse::().map_err(|e| { + BabyError::Custom(format!( + "expected digit before tab in `rev-list --left-right --count`, got `{}`: {}", + text.trim(), + e + )) + })?, + None => { + return Err(BabyError::Custom( + "empty `rev-list --left-right --count` output".to_string(), + )); + } + }; + let behind = match parts.next() { + Some(s) => s.parse::().map_err(|e| { + BabyError::Custom(format!( + "expected digit after tab in `rev-list --left-right --count`, got `{}`: {}", + text.trim(), + e + )) + })?, + None => 0, + }; + Ok(DivergeObject { ahead, behind }) +} + +pub fn parse_rev_list_oids(output: &[u8]) -> Result, BabyError> { + let mut oids = Vec::new(); + for line in String::from_utf8_lossy(output).lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let oid = crate::share::parse_object_id(trimmed)?; + oids.push(oid); + } + Ok(oids) +} diff --git a/src/compare/mod.rs b/src/compare/mod.rs new file mode 100644 index 0000000..6359b4a --- /dev/null +++ b/src/compare/mod.rs @@ -0,0 +1,4 @@ +pub mod helper; +pub mod types; + +pub use types::DivergeObject; diff --git a/src/compare/types.rs b/src/compare/types.rs new file mode 100644 index 0000000..2b252bb --- /dev/null +++ b/src/compare/types.rs @@ -0,0 +1,13 @@ +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DivergeObject { + pub ahead: u32, + pub behind: u32, +} + +impl fmt::Display for DivergeObject { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "ahead={}, behind={}", self.ahead, self.behind) + } +} diff --git a/src/compare/usecase.rs b/src/compare/usecase.rs new file mode 100644 index 0000000..3dfd13b --- /dev/null +++ b/src/compare/usecase.rs @@ -0,0 +1,69 @@ +use crate::compare::helper; +use crate::compare::types::DivergeObject; +use crate::error::BabyError; +use crate::GitBaby; + +impl GitBaby { + pub async fn diverging_commits( + &self, + base: &str, + target: &str, + ) -> Result { + share::validate_revision_non_empty(base)?; + share::validate_revision_non_empty(target)?; + let dir = self.facade.git_repo_dir().await.map_err(share::facade_error)?; + let alternates = self + .facade + .git_alternate_object_directories() + .await + .map_err(share::facade_error)?; + let env = share::build_env_safe(&alternates); + let mut cmd = helper::build_diverging_commits_cmd(&dir, env, base, target)?; + let output = cmd.run_soft().await.map_err(helper::classify_cmd_error)?; + if !output.status.success() { + return Err(BabyError::Custom(format!( + "git rev-list --left-right failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + helper::parse_left_right_count(&output.stdout) + } + + pub async fn commit_ids_between_reverse( + &self, + start: &str, + end: &str, + not_ref: Option<&str>, + limit: Option, + ) -> Result, BabyError> { + share::validate_revision_non_empty(start)?; + share::validate_revision_non_empty(end)?; + let dir = self.facade.git_repo_dir().await.map_err(share::facade_error)?; + let alternates = self + .facade + .git_alternate_object_directories() + .await + .map_err(share::facade_error)?; + let env = share::build_env_safe(&alternates); + let mut cmd = + helper::build_commit_ids_between_reverse_cmd(&dir, env, start, end, not_ref, limit)?; + match cmd.run().await { + Ok(output) => { + if !output.status.success() { + return Err(BabyError::Custom(format!( + "git rev-list failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + helper::parse_rev_list_oids(&output.stdout) + } + Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) + if !stderr.trim().is_empty() && not_ref.is_some() => + { + let _ = stderr; + Ok(Vec::new()) + } + Err(e) => Err(helper::classify_cmd_error(e)), + } + } +} \ No newline at end of file diff --git a/src/config/helper.rs b/src/config/helper.rs new file mode 100644 index 0000000..7b81006 --- /dev/null +++ b/src/config/helper.rs @@ -0,0 +1,152 @@ +use std::path::Path; + +use crate::command::cmd::Cmd; +use crate::command::env::Env; +use crate::config::types::{ConfigEntry, ConfigScope}; +use crate::error::BabyError; + +fn share_scope(scope: &ConfigScope) -> crate::share::ConfigScope { + match scope.0.as_str() { + "global" => crate::share::ConfigScope::Global, + _ => crate::share::ConfigScope::Local, + } +} + +pub fn run_get(dir: &Path, env: Env, scope: &ConfigScope, key: &str) -> Result { + crate::share::build_config_cmd( + "gitbaby::config", + dir, + env, + share_scope(scope), + crate::share::ConfigOp::Get, + key, + None, + ) +} + +pub fn run_get_all(dir: &Path, env: Env, scope: &ConfigScope, key: &str) -> Result { + crate::share::build_config_cmd( + "gitbaby::config", + dir, + env, + share_scope(scope), + crate::share::ConfigOp::GetAll, + key, + None, + ) +} + +pub fn run_get_regexp( + dir: &Path, + env: Env, + scope: &ConfigScope, + pattern: &str, +) -> Result { + crate::share::build_config_cmd( + "gitbaby::config", + dir, + env, + share_scope(scope), + crate::share::ConfigOp::GetRegexp, + pattern, + None, + ) +} + +pub fn run_set( + dir: &Path, + env: Env, + scope: &ConfigScope, + key: &str, + value: &str, +) -> Result { + crate::share::build_config_cmd( + "gitbaby::config", + dir, + env, + share_scope(scope), + crate::share::ConfigOp::Set, + key, + Some(value), + ) +} + +pub fn run_set_if_absent( + dir: &Path, + env: Env, + scope: &ConfigScope, + key: &str, + value: &str, +) -> Result { + crate::share::build_config_cmd( + "gitbaby::config", + dir, + env, + share_scope(scope), + crate::share::ConfigOp::SetIfAbsent, + key, + Some(value), + ) +} + +pub fn run_add( + dir: &Path, + env: Env, + scope: &ConfigScope, + key: &str, + value: &str, +) -> Result { + crate::share::build_config_cmd( + "gitbaby::config", + dir, + env, + share_scope(scope), + crate::share::ConfigOp::Add, + key, + Some(value), + ) +} + +pub fn run_unset_all( + dir: &Path, + env: Env, + scope: &ConfigScope, + key: &str, +) -> Result { + crate::share::build_config_cmd( + "gitbaby::config", + dir, + env, + share_scope(scope), + crate::share::ConfigOp::UnsetAll, + key, + None, + ) +} + +pub fn classify_set_failure(key: &str, stderr: &str) -> BabyError { + BabyError::ConfigSetFailed { + key: key.to_string(), + stderr: stderr.to_string(), + } +} + +pub fn classify_get_failure(key: &str, stderr: &str) -> BabyError { + BabyError::ConfigGetFailed { + key: key.to_string(), + stderr: stderr.to_string(), + } +} + +pub fn parse_kv_lines(output: &[u8]) -> Vec { + String::from_utf8_lossy(output) + .lines() + .filter_map(|line| { + let (k, v) = line.split_once('=')?; + Some(ConfigEntry { + key: k.to_string(), + value: v.to_string(), + }) + }) + .collect() +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..43636e5 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,5 @@ +pub mod helper; +pub mod types; +pub mod usecase; + +pub use types::{ConfigEntry, ConfigScope}; diff --git a/src/config/types.rs b/src/config/types.rs new file mode 100644 index 0000000..2384ae8 --- /dev/null +++ b/src/config/types.rs @@ -0,0 +1,25 @@ +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigEntry { + pub key: String, + pub value: String, +} + +impl fmt::Display for ConfigEntry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}={}", self.key, self.value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigScope(pub String); + +impl ConfigScope { + pub fn global() -> Self { + Self("global".to_string()) + } + pub fn local() -> Self { + Self("local".to_string()) + } +} diff --git a/src/config/usecase.rs b/src/config/usecase.rs new file mode 100644 index 0000000..afbc12a --- /dev/null +++ b/src/config/usecase.rs @@ -0,0 +1,255 @@ +use crate::GitBaby; +use crate::config::helper; +use crate::config::types::{ConfigEntry, ConfigScope}; +use crate::error::BabyError; +use crate::share; + +async fn build_env_for_scope( + gitbaby: &GitBaby, + scope: &ConfigScope, +) -> Result<(std::path::PathBuf, crate::command::env::Env), BabyError> { + let _ = scope; + let dir = gitbaby + .facade + .git_repo_dir() + .await + .map_err(share::facade_error)?; + let alternates = gitbaby + .facade + .git_alternate_object_directories() + .await + .map_err(share::facade_error)?; + let env = share::build_env_safe(&alternates); + Ok((dir, env)) +} + +impl GitBaby { + pub async fn config_get(&self, key: &str) -> Result, BabyError> { + self.config_get_with_scope(key, &ConfigScope::local()).await + } + + pub async fn config_get_global(&self, key: &str) -> Result, BabyError> { + self.config_get_with_scope(key, &ConfigScope::global()) + .await + } + + pub async fn config_set(&self, key: &str, value: &str) -> Result<(), BabyError> { + self.config_set_with_scope(key, value, &ConfigScope::local()) + .await + } + + pub async fn config_set_global(&self, key: &str, value: &str) -> Result<(), BabyError> { + self.config_set_with_scope(key, value, &ConfigScope::global()) + .await + } + + pub async fn config_set_if_absent(&self, key: &str, value: &str) -> Result<(), BabyError> { + self.config_set_if_absent_with_scope(key, value, &ConfigScope::local()) + .await + } + + pub async fn config_set_if_absent_global( + &self, + key: &str, + value: &str, + ) -> Result<(), BabyError> { + self.config_set_if_absent_with_scope(key, value, &ConfigScope::global()) + .await + } + + pub async fn config_add(&self, key: &str, value: &str) -> Result<(), BabyError> { + self.config_add_with_scope(key, value, &ConfigScope::local()) + .await + } + + pub async fn config_add_global(&self, key: &str, value: &str) -> Result<(), BabyError> { + self.config_add_with_scope(key, value, &ConfigScope::global()) + .await + } + + pub async fn config_unset_all(&self, key: &str) -> Result<(), BabyError> { + self.config_unset_all_with_scope(key, &ConfigScope::local()) + .await + } + + pub async fn config_unset_all_global(&self, key: &str) -> Result<(), BabyError> { + self.config_unset_all_with_scope(key, &ConfigScope::global()) + .await + } + + pub async fn config_get_all(&self, key: &str) -> Result, BabyError> { + self.config_get_all_with_scope(key, &ConfigScope::local()) + .await + } + + pub async fn config_get_all_global(&self, key: &str) -> Result, BabyError> { + self.config_get_all_with_scope(key, &ConfigScope::global()) + .await + } + + pub async fn config_get_regexp(&self, pattern: &str) -> Result, BabyError> { + self.config_get_regexp_with_scope(pattern, &ConfigScope::local()) + .await + } + + pub async fn config_get_regexp_global( + &self, + pattern: &str, + ) -> Result, BabyError> { + self.config_get_regexp_with_scope(pattern, &ConfigScope::global()) + .await + } + + pub async fn managed_config_set(&self, key: &str, value: &str) -> Result<(), BabyError> { + self.config_set_with_scope(key, value, &ConfigScope::local()) + .await + } + + pub async fn managed_config_add(&self, key: &str, value: &str) -> Result<(), BabyError> { + self.config_add_with_scope(key, value, &ConfigScope::local()) + .await + } + + async fn config_get_with_scope( + &self, + key: &str, + scope: &ConfigScope, + ) -> Result, BabyError> { + let (dir, env) = build_env_for_scope(self, scope).await?; + let mut cmd = helper::run_get(&dir, env, scope, key)?; + let output = cmd.run_soft().await.map_err(BabyError::from)?; + if output.status.success() { + let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); + return Ok(Some(text)); + } + let stderr = String::from_utf8_lossy(&output.stderr); + if output.status.code() == Some(1) && stderr.trim().is_empty() { + return Ok(None); + } + if output.status.code() == Some(1) && stderr.contains("key does not exist") { + return Ok(None); + } + Err(helper::classify_get_failure( + key, + &String::from_utf8_lossy(&output.stderr), + )) + } + + async fn config_set_with_scope( + &self, + key: &str, + value: &str, + scope: &ConfigScope, + ) -> Result<(), BabyError> { + let (dir, env) = build_env_for_scope(self, scope).await?; + let mut cmd = helper::run_set(&dir, env, scope, key, value)?; + let output = cmd.run_soft().await.map_err(BabyError::from)?; + if output.status.success() { + return Ok(()); + } + Err(helper::classify_set_failure( + key, + &String::from_utf8_lossy(&output.stderr), + )) + } + + async fn config_set_if_absent_with_scope( + &self, + key: &str, + value: &str, + scope: &ConfigScope, + ) -> Result<(), BabyError> { + let (dir, env) = build_env_for_scope(self, scope).await?; + let mut cmd = helper::run_set_if_absent(&dir, env, scope, key, value)?; + let output = cmd.run_soft().await.map_err(BabyError::from)?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + if output.status.code() == Some(1) && stderr.contains("already exists") { + return Ok(()); + } + if output.status.code() == Some(1) && stderr.trim().is_empty() { + return Ok(()); + } + Err(helper::classify_set_failure(key, &stderr)) + } + + async fn config_add_with_scope( + &self, + key: &str, + value: &str, + scope: &ConfigScope, + ) -> Result<(), BabyError> { + let (dir, env) = build_env_for_scope(self, scope).await?; + let mut cmd = helper::run_add(&dir, env, scope, key, value)?; + let output = cmd.run_soft().await.map_err(BabyError::from)?; + if output.status.success() { + return Ok(()); + } + Err(helper::classify_set_failure( + key, + &String::from_utf8_lossy(&output.stderr), + )) + } + + async fn config_unset_all_with_scope( + &self, + key: &str, + scope: &ConfigScope, + ) -> Result<(), BabyError> { + let (dir, env) = build_env_for_scope(self, scope).await?; + let mut cmd = helper::run_unset_all(&dir, env, scope, key)?; + let output = cmd.run_soft().await.map_err(BabyError::from)?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + if output.status.code() == Some(5) { + return Ok(()); + } + if output.status.code() == Some(1) && stderr.trim().is_empty() { + return Ok(()); + } + Err(helper::classify_set_failure(key, &stderr)) + } + + async fn config_get_all_with_scope( + &self, + key: &str, + scope: &ConfigScope, + ) -> Result, BabyError> { + let (dir, env) = build_env_for_scope(self, scope).await?; + let mut cmd = helper::run_get_all(&dir, env, scope, key)?; + let output = cmd.run_soft().await.map_err(BabyError::from)?; + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(|s| s.to_string()) + .collect()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + if output.status.code() == Some(1) && stderr.trim().is_empty() { + return Ok(Vec::new()); + } + Err(helper::classify_get_failure(key, &stderr)) + } + + async fn config_get_regexp_with_scope( + &self, + pattern: &str, + scope: &ConfigScope, + ) -> Result, BabyError> { + let (dir, env) = build_env_for_scope(self, scope).await?; + let mut cmd = helper::run_get_regexp(&dir, env, scope, pattern)?; + let output = cmd.run_soft().await.map_err(BabyError::from)?; + if output.status.success() { + return Ok(helper::parse_kv_lines(&output.stdout)); + } + let stderr = String::from_utf8_lossy(&output.stderr); + if output.status.code() == Some(1) && stderr.trim().is_empty() { + return Ok(Vec::new()); + } + Err(helper::classify_get_failure(pattern, &stderr)) + } +} diff --git a/src/conflict/helper.rs b/src/conflict/helper.rs new file mode 100644 index 0000000..60d8f82 --- /dev/null +++ b/src/conflict/helper.rs @@ -0,0 +1,147 @@ +use std::path::Path; + +use gix::ObjectId; + +use crate::command::cmd::Cmd; +use crate::command::env::Env; +use crate::command::error::CmdError; +use crate::conflict::types::{ConflictFileHeader, ListConflictOptions, MergeTreeResult}; +use crate::error::BabyError; +use crate::share; + +pub const CALLER: &str = "gitbaby::conflict"; + +pub fn build_merge_tree_cmd( + dir: &Path, + env: Env, + ours: &str, + theirs: &str, + merge_base: Option<&str>, + allow_tree_conflicts: bool, +) -> Cmd { + let mut args: Vec = vec![ + "merge-tree".into(), + "--write-tree".into(), + "-z".into(), + "--name-only".into(), + "--no-messages".into(), + ]; + if allow_tree_conflicts { + args.push("--allow-unrelated-histories".into()); + } + if let Some(mb) = merge_base { + args.push(format!("--merge-base={}", mb)); + args.push(ours.into()); + args.push(theirs.into()); + } else { + args.push(ours.into()); + args.push(theirs.into()); + } + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_list_conflict_files_cmd( + dir: &Path, + env: Env, + ours: &str, + theirs: &str, + opts: &ListConflictOptions, +) -> Cmd { + let mut args: Vec = vec!["merge-tree".into(), "-z".into()]; + if opts.allow_tree_conflicts { + args.push("--allow-unrelated-histories".into()); + } + args.push(ours.into()); + args.push(theirs.into()); + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn classify_stderr(stderr: &str, ours: &str, theirs: &str) -> BabyError { + if stderr.contains("fatal: bad revision") { + return BabyError::RevisionNotFound(format!("{}..{}", ours, theirs)); + } + BabyError::Custom(format!("git merge-tree failed: {}", stderr.trim())) +} + +pub fn classify_cmd_error(err: CmdError, ours: &str, theirs: &str) -> BabyError { + match err { + CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, ours, theirs), + CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source), + other => BabyError::from(other), + } +} + +pub fn parse_merge_tree_output( + data: &[u8], + ours_oid: ObjectId, +) -> Result { + let s = String::from_utf8_lossy(data); + let mut iter = s.split('\0'); + let first = iter.next().unwrap_or(""); + let tree_oid = ObjectId::from_hex(first.as_bytes()).map_err(|source| { + BabyError::Custom(format!("invalid merge-tree oid `{}`: {}", first, source)) + })?; + let conflict_files: Vec = iter + .filter(|p| !p.is_empty()) + .map(std::path::PathBuf::from) + .collect(); + let has_conflicts = !conflict_files.is_empty(); + let _ = ours_oid; + Ok(MergeTreeResult { + tree_oid, + has_conflicts, + conflict_files, + }) +} + +pub fn parse_list_conflict_files( + data: &[u8], + ours_oid: ObjectId, +) -> Result, BabyError> { + let s = String::from_utf8_lossy(data); + let mut headers = Vec::new(); + let mut current_mode: Option = None; + let mut their_path: Option = None; + let mut our_path: Option = None; + let mut ancestor_path: Option = None; + let mut mode_count: u8 = 0; + for part in s.split('\0') { + if part.is_empty() { + continue; + } + if mode_count < 3 + && let Ok(m) = share::parse_git_mode(part) + { + current_mode = Some(m); + mode_count += 1; + continue; + } + match mode_count { + 1 => { + our_path = Some(std::path::PathBuf::from(part)); + mode_count = 2; + current_mode = None; + } + 2 => { + their_path = Some(std::path::PathBuf::from(part)); + mode_count = 3; + current_mode = None; + } + _ => { + if ancestor_path.is_none() { + ancestor_path = Some(std::path::PathBuf::from(part)); + headers.push(ConflictFileHeader { + our_commit_oid: ours_oid, + their_path: their_path.take(), + our_path: our_path.take(), + ancestor_path: ancestor_path.take(), + our_mode: current_mode.unwrap_or(0), + }); + mode_count = 0; + current_mode = None; + } + } + } + } + Ok(headers) +} diff --git a/src/conflict/mod.rs b/src/conflict/mod.rs new file mode 100644 index 0000000..a579450 --- /dev/null +++ b/src/conflict/mod.rs @@ -0,0 +1,8 @@ +pub mod helper; +pub mod types; +pub mod usecase; + +pub use types::{ + ConflictFile, ConflictFileHeader, ConflictResolution, ListConflictOptions, MergeStage, + MergeTreeResult, ResolveConflictsInput, +}; diff --git a/src/conflict/types.rs b/src/conflict/types.rs new file mode 100644 index 0000000..347507f --- /dev/null +++ b/src/conflict/types.rs @@ -0,0 +1,57 @@ +use std::path::PathBuf; + +use gix::ObjectId; + +use crate::commit::types::Signature; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MergeStage { + Ancestor, + Ours, + Theirs, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConflictFileHeader { + pub our_commit_oid: ObjectId, + pub their_path: Option, + pub our_path: Option, + pub ancestor_path: Option, + pub our_mode: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConflictFile { + pub header: ConflictFileHeader, + pub content: Option, +} + +#[derive(Debug, Default, Clone)] +pub struct ListConflictOptions { + pub allow_tree_conflicts: bool, + pub skip_content: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MergeTreeResult { + pub tree_oid: ObjectId, + pub has_conflicts: bool, + pub conflict_files: Vec, +} + +#[derive(Debug, Clone)] +pub struct ResolveConflictsInput { + pub our_commit_oid: ObjectId, + pub their_commit_oid: ObjectId, + pub source_branch: String, + pub target_branch: String, + pub commit_message: String, + pub author: Signature, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConflictResolution { + pub old_path: Option, + pub new_path: PathBuf, + pub content: String, +} diff --git a/src/conflict/usecase.rs b/src/conflict/usecase.rs new file mode 100644 index 0000000..948e9a9 --- /dev/null +++ b/src/conflict/usecase.rs @@ -0,0 +1,132 @@ +use std::path::Path; + +use gix::ObjectId; + +use crate::GitBaby; +use crate::command::error::CmdError; +use crate::conflict::helper; +use crate::conflict::types::{ + ConflictFile, ConflictResolution, ListConflictOptions, MergeTreeResult, ResolveConflictsInput, +}; +use crate::error::BabyError; + +impl GitBaby { + pub async fn list_conflict_files( + &self, + our: &str, + their: &str, + opts: &ListConflictOptions, + ) -> Result, BabyError> { + let ours_oid = ObjectId::from_hex(our.as_bytes()).map_err(|source| { + BabyError::Custom(format!("invalid our oid `{}`: {}", our, source)) + })?; + let mut cmd = self.spawn_conflict_cmd(our, their, opts).await?; + let output = match cmd.run().await { + Ok(o) => o, + Err(CmdError::NonZeroExit { stderr, .. }) => { + return Err(helper::classify_stderr(&stderr, our, their)); + } + Err(e) => return Err(helper::classify_cmd_error(e, our, their)), + }; + let headers = helper::parse_list_conflict_files(&output.stdout, ours_oid)?; + let files: Vec = headers + .into_iter() + .map(|header| ConflictFile { + header, + content: None, + }) + .collect(); + Ok(files) + } + + pub async fn merge_tree( + &self, + ours: &str, + theirs: &str, + merge_base: Option<&str>, + allow_tree_conflicts: bool, + ) -> Result { + let ours_oid = ObjectId::from_hex(ours.as_bytes()).map_err(|source| { + BabyError::Custom(format!("invalid ours oid `{}`: {}", ours, source)) + })?; + let mut cmd = self + .spawn_merge_tree_cmd(ours, theirs, merge_base, allow_tree_conflicts) + .await?; + let output = match cmd.run().await { + Ok(o) => o, + Err(CmdError::NonZeroExit { ref stderr, .. }) => { + return Err(helper::classify_stderr(stderr, ours, theirs)); + } + Err(e) => return Err(helper::classify_cmd_error(e, ours, theirs)), + }; + let stdout = output.stdout.clone(); + let _ = output; + helper::parse_merge_tree_output(&stdout, ours_oid) + } + + pub async fn resolve_conflicts( + &self, + _input: &ResolveConflictsInput, + resolutions: I, + ) -> Result + where + I: IntoIterator, + { + let blobs: Vec = resolutions.into_iter().collect(); + let _ = blobs.len(); + Err(BabyError::Unimplemented("resolve_conflicts")) + } + + async fn spawn_conflict_cmd( + &self, + ours: &str, + their: &str, + opts: &ListConflictOptions, + ) -> Result { + let dir = self + .facade + .git_repo_dir() + .await + .map_err(crate::share::facade_error)?; + let alternates = self + .facade + .git_alternate_object_directories() + .await + .map_err(crate::share::facade_error)?; + let env = crate::share::build_env_safe(&alternates); + Ok(helper::build_list_conflict_files_cmd( + &dir, env, ours, their, opts, + )) + } + + async fn spawn_merge_tree_cmd( + &self, + ours: &str, + theirs: &str, + merge_base: Option<&str>, + allow_tree_conflicts: bool, + ) -> Result { + let dir = self + .facade + .git_repo_dir() + .await + .map_err(crate::share::facade_error)?; + let alternates = self + .facade + .git_alternate_object_directories() + .await + .map_err(crate::share::facade_error)?; + let env = crate::share::build_env_safe(&alternates); + Ok(helper::build_merge_tree_cmd( + &dir, + env, + ours, + theirs, + merge_base, + allow_tree_conflicts, + )) + } + + #[allow(dead_code)] + fn _path_marker(_p: &Path) {} +} diff --git a/src/diff/helper.rs b/src/diff/helper.rs new file mode 100644 index 0000000..e010842 --- /dev/null +++ b/src/diff/helper.rs @@ -0,0 +1,471 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use gix::ObjectId; + +use crate::command::cmd::Cmd; +use crate::command::env::Env; +use crate::command::error::CmdError; +use crate::diff::types::{ + ChangedPath, ChangedPathDiff, ChangedPathRequest, DiffOptions, DiffRequest, DiffStatus, + NumStat, RangeDiffSpec, ShortStat, WhitespaceMode, +}; +use crate::error::BabyError; +use crate::share; + +pub const CALLER: &str = "gitbaby::diff"; + +pub fn validate_paths(paths: &[PathBuf]) -> Result<(), BabyError> { + for p in paths { + share::validate_local_no_escape(p)?; + } + Ok(()) +} + +pub fn build_diff_args(req: &DiffRequest, opts: &DiffOptions, hex_len: Option) -> Vec { + let mut args: Vec = vec!["diff".into(), "--full-index".into()]; + let abbrev = hex_len + .map(|n| format!("--abbrev={}", n)) + .unwrap_or_else(|| "--abbrev=40".into()); + args.push(abbrev); + match opts.ignore_whitespace { + Some(WhitespaceMode::IgnoreSpaceChange) => args.push("--ignore-space-change".into()), + Some(WhitespaceMode::IgnoreAllSpace) => args.push("--ignore-all-space".into()), + Some(WhitespaceMode::None) | None => {} + } + if let Some(threshold) = opts.detect_renames { + args.push(format!("--find-renames={}%", threshold)); + } + if opts.word_diff { + args.push("--word-diff=porcelain".into()); + } + args.push(req.left.to_hex().to_string()); + args.push(req.right.to_hex().to_string()); + if let Some(paths) = &opts.paths + && !paths.is_empty() + { + args.push("--".into()); + for p in paths { + args.push(share::to_string_lossy_owned(p)); + } + } + args +} + +pub fn build_raw_diff_cmd( + dir: &Path, + + env: Env, + req: &DiffRequest, + opts: &DiffOptions, + hex_len: Option, +) -> Cmd { + let args = build_diff_args(req, opts, hex_len); + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_patch_diff_cmd( + dir: &Path, + + env: Env, + req: &DiffRequest, + _opts: &DiffOptions, + _hex_len: Option, +) -> Cmd { + let args: Vec = vec![ + "format-patch".into(), + "--stdout".into(), + format!("{}..{}", req.left.to_hex(), req.right.to_hex()), + "--no-signature".into(), + ]; + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_commit_diff_cmd( + dir: &Path, + + env: Env, + req: &DiffRequest, + opts: &DiffOptions, + hex_len: Option, +) -> Cmd { + let mut args: Vec = vec![ + "diff".into(), + "--patch".into(), + "--raw".into(), + "--find-renames=30%".into(), + ]; + let abbrev = hex_len + .map(|n| format!("--abbrev={}", n)) + .unwrap_or_else(|| "--abbrev=40".into()); + args.push(abbrev); + args.push("--full-index".into()); + match opts.ignore_whitespace { + Some(WhitespaceMode::IgnoreSpaceChange) => args.push("--ignore-space-change".into()), + Some(WhitespaceMode::IgnoreAllSpace) => args.push("--ignore-all-space".into()), + Some(WhitespaceMode::None) | None => {} + } + if opts.word_diff { + args.push("--word-diff=porcelain".into()); + } + args.push(req.left.to_hex().to_string()); + args.push(req.right.to_hex().to_string()); + if let Some(paths) = &opts.paths + && !paths.is_empty() + { + args.push("--".into()); + for p in paths { + args.push(share::to_string_lossy_owned(p)); + } + } + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_commit_delta_cmd( + dir: &Path, + + env: Env, + req: &DiffRequest, + opts: &DiffOptions, + hex_len: Option, +) -> Cmd { + let mut args: Vec = vec!["diff".into(), "--raw".into(), "--find-renames=30%".into()]; + let abbrev = hex_len + .map(|n| format!("--abbrev={}", n)) + .unwrap_or_else(|| "--abbrev=40".into()); + args.push(abbrev); + args.push("--full-index".into()); + args.push(req.left.to_hex().to_string()); + args.push(req.right.to_hex().to_string()); + if let Some(paths) = &opts.paths + && !paths.is_empty() + { + args.push("--".into()); + for p in paths { + args.push(share::to_string_lossy_owned(p)); + } + } + let _ = opts; + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_numstat_cmd(dir: &Path, env: Env, req: &DiffRequest, opts: &DiffOptions) -> Cmd { + let mut args: Vec = vec!["diff".into(), "--numstat".into(), "-z".into()]; + args.push(req.left.to_hex().to_string()); + args.push(req.right.to_hex().to_string()); + if let Some(paths) = &opts.paths + && !paths.is_empty() + { + args.push("--".into()); + for p in paths { + args.push(share::to_string_lossy_owned(p)); + } + } + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_shortstat_cmd(dir: &Path, env: Env, req: &DiffRequest, opts: &DiffOptions) -> Cmd { + let mut args: Vec = vec!["diff".into(), "--shortstat".into()]; + args.push(req.left.to_hex().to_string()); + args.push(req.right.to_hex().to_string()); + if let Some(paths) = &opts.paths + && !paths.is_empty() + { + args.push("--".into()); + for p in paths { + args.push(share::to_string_lossy_owned(p)); + } + } + let _ = opts; + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_range_diff_cmd( + dir: &Path, + + env: Env, + spec: &RangeDiffSpec, + _opts: &DiffOptions, + hex_len: Option, +) -> Cmd { + let mut args: Vec = vec!["range-diff".into(), "--no-color".into()]; + let abbrev = hex_len + .map(|n| format!("--abbrev={}", n)) + .unwrap_or_else(|| "--abbrev=40".into()); + args.push(abbrev); + match spec { + RangeDiffSpec::Range(r1, r2) => { + args.push(r1.clone()); + args.push(r2.clone()); + } + RangeDiffSpec::RevisionRange(r1, r2) => { + args.push(format!("{}...{}", r1, r2)); + } + RangeDiffSpec::BaseWithRevisions(base, r1, r2) => { + args.push(base.clone()); + args.push(r1.clone()); + args.push(r2.clone()); + } + } + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_find_changed_paths_cmd( + dir: &Path, + + env: Env, + _requests: &[ChangedPathRequest], + filter_status: Option, + detect_renames: Option, + merge_parents: bool, +) -> Cmd { + let mut args: Vec = vec![ + "diff-tree".into(), + "-z".into(), + "--stdin".into(), + "-r".into(), + "--root".into(), + "--no-commit-id".into(), + ]; + if let Some(ch) = filter_status { + args.push(format!("--diff-filter={}", ch)); + } else { + args.push("--diff-filter=AMDTCR".into()); + } + match detect_renames { + Some(threshold) => args.push(format!("--find-renames={}%", threshold)), + None => args.push("--no-renames".into()), + } + if merge_parents { + args.push("-m".into()); + } + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_diff_tree_stdin(requests: &[ChangedPathRequest]) -> Vec { + let mut buf = Vec::new(); + for r in requests { + if let ChangedPathRequest::Commit(oid) = r { + buf.extend_from_slice(oid.to_string().as_bytes()); + buf.push(b'\n'); + } + } + buf +} + +pub fn classify_stderr(stderr: &str, left: &str, right: &str) -> BabyError { + if stderr.contains("fatal: bad revision") || stderr.contains("bad revision") { + return BabyError::RevisionNotFound(format!("{}..{}", left, right)); + } + BabyError::Custom(format!("git diff failed: {}", stderr.trim())) +} + +pub fn classify_cmd_error(err: CmdError, left: &str, right: &str) -> BabyError { + match err { + CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, left, right), + CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source), + other => BabyError::from(other), + } +} + +pub fn parse_raw_diff_line(line: &str, abbrev: usize) -> Result { + let mut fields = line.splitn(7, ' '); + let old_mode_raw = fields + .next() + .ok_or_else(|| BabyError::Custom(format!("raw diff line too short: `{}`", line)))?; + let new_mode_raw = fields + .next() + .ok_or_else(|| BabyError::Custom(format!("raw diff line too short: `{}`", line)))?; + let old_oid_raw = fields + .next() + .ok_or_else(|| BabyError::Custom(format!("raw diff line too short: `{}`", line)))?; + let new_oid_raw = fields + .next() + .ok_or_else(|| BabyError::Custom(format!("raw diff line too short: `{}`", line)))?; + let status_raw = fields + .next() + .ok_or_else(|| BabyError::Custom(format!("raw diff line too short: `{}`", line)))?; + let scores_or_path = fields + .next() + .ok_or_else(|| BabyError::Custom(format!("raw diff line too short: `{}`", line)))?; + let path = fields + .next() + .ok_or_else(|| BabyError::Custom(format!("raw diff line too short: `{}`", line)))?; + let status_ch = status_raw + .chars() + .next() + .ok_or_else(|| BabyError::Custom(format!("raw diff status missing: `{}`", line)))?; + let status = DiffStatus::from_char(status_ch) + .ok_or_else(|| BabyError::Custom(format!("unknown diff status `{}`", status_ch)))?; + let old_mode = share::parse_git_mode(old_mode_raw)?; + let new_mode = share::parse_git_mode(new_mode_raw)?; + let old_oid = if old_oid_raw == "0000000000000000000000000000000000000000" + || old_oid_raw.len() < abbrev + { + None + } else { + Some(parse_oid_prefix(old_oid_raw)?) + }; + let new_oid = if new_oid_raw == "0000000000000000000000000000000000000000" + || new_oid_raw.len() < abbrev + { + None + } else { + Some(parse_oid_prefix(new_oid_raw)?) + }; + let (old_path, new_path) = match status { + DiffStatus::Renamed | DiffStatus::Copied => { + (Some(PathBuf::from(scores_or_path)), PathBuf::from(path)) + } + _ => (None, PathBuf::from(path)), + }; + Ok(ChangedPath { + old_path, + new_path, + old_mode, + new_mode, + old_oid, + new_oid, + status, + }) +} + +pub fn parse_oid_prefix(hex: &str) -> Result { + ObjectId::from_hex(hex.as_bytes()) + .map_err(|source| BabyError::Custom(format!("invalid oid `{}`: {}", hex, source))) +} + +pub fn parse_numstat_output(data: &[u8]) -> Result, BabyError> { + let mut stats = Vec::new(); + let s = String::from_utf8_lossy(data); + for chunk in s.split('\0') { + if chunk.is_empty() { + continue; + } + let mut parts = chunk.splitn(3, '\t'); + let adds_raw = parts.next().unwrap_or(""); + let dels_raw = parts.next().unwrap_or(""); + let last = parts.next().unwrap_or(""); + let mut iter = last.splitn(2, '\t'); + let first = iter.next().unwrap_or(""); + let second = iter.next(); + let mut stat = NumStat { + additions: parse_numstat_number(adds_raw)?, + deletions: parse_numstat_number(dels_raw)?, + path: PathBuf::new(), + old_path: None, + }; + match second { + Some(p) => { + stat.old_path = Some(PathBuf::from(first)); + stat.path = PathBuf::from(p); + } + None => { + stat.path = PathBuf::from(first); + } + } + stats.push(stat); + } + Ok(stats) +} + +fn parse_numstat_number(s: &str) -> Result { + if s == "-" { + return Ok(0); + } + s.parse::() + .map_err(|e| BabyError::Custom(format!("invalid numstat number `{}`: {}", s, e))) +} + +pub fn parse_shortstat_output(data: &[u8]) -> Result { + let s = String::from_utf8_lossy(data); + let mut stat = ShortStat::default(); + let mut rest = s.trim(); + if let Some(idx) = rest.find("files changed") { + stat.files_changed = parse_u64_before(rest, idx)?; + rest = &rest[idx + "files changed".len()..]; + } + if let Some(idx) = rest.find("insertions") { + stat.additions = parse_u64_before(rest, idx)?; + rest = &rest[idx + "insertions".len()..]; + } + if let Some(idx) = rest.find("deletions") { + stat.deletions = parse_u64_before(rest, idx)?; + } + Ok(stat) +} + +fn parse_u64_before(s: &str, before: usize) -> Result { + let prefix = &s[..before]; + let trimmed = prefix.trim().trim_end_matches(',').trim(); + trimmed + .parse::() + .map_err(|e| BabyError::Custom(format!("invalid number `{}`: {}", trimmed, e))) +} + +pub fn parse_commit_diff_output( + data: &[u8], + abbrev: usize, +) -> Result, BabyError> { + let s = String::from_utf8_lossy(data); + let mut out: Vec = Vec::new(); + let mut current: Option = None; + let mut patch_bytes: Vec = Vec::new(); + let mut in_diff = false; + for line in s.lines() { + if line.starts_with("diff --git ") { + if let Some(c) = current.take() { + let mut finalized = c; + if !patch_bytes.is_empty() { + finalized.patch = Some(std::mem::take(&mut patch_bytes)); + } + out.push(finalized); + } + patch_bytes.clear(); + in_diff = true; + current = None; + } else if line.starts_with(':') { + if let Ok(cp) = parse_raw_diff_line(line, abbrev) { + let _ = cp; + current = Some(ChangedPathDiff { + path: cp, + lines_added: 0, + lines_removed: 0, + binary: false, + patch: None, + }); + } + } else if in_diff { + if line.starts_with("Binary files") { + if let Some(c) = current.as_mut() { + c.binary = true; + } + } else if line.starts_with('+') + && !line.starts_with("+++") + && let Some(c) = current.as_mut() + { + c.lines_added = c.lines_added.saturating_add(1); + } else if line.starts_with('-') + && !line.starts_with("---") + && let Some(c) = current.as_mut() + { + c.lines_removed = c.lines_removed.saturating_add(1); + } + patch_bytes.extend_from_slice(line.as_bytes()); + patch_bytes.push(b'\n'); + } + } + if let Some(c) = current.take() { + let mut finalized = c; + if !patch_bytes.is_empty() { + finalized.patch = Some(patch_bytes); + } + out.push(finalized); + } + let _ = Arc::new(()); + Ok(out) +} + +pub fn parse_diff_tree_status_line(line: &str, abbrev: usize) -> Result { + parse_raw_diff_line(line, abbrev) +} diff --git a/src/diff/mod.rs b/src/diff/mod.rs new file mode 100644 index 0000000..6fcb6d9 --- /dev/null +++ b/src/diff/mod.rs @@ -0,0 +1,8 @@ +pub mod helper; +pub mod types; +pub mod usecase; + +pub use types::{ + ChangedPath, ChangedPathDiff, ChangedPathRequest, DiffOptions, DiffRequest, DiffStatus, + NumStat, RangeDiffSpec, ShortStat, WhitespaceMode, +}; diff --git a/src/diff/types.rs b/src/diff/types.rs new file mode 100644 index 0000000..3d3f909 --- /dev/null +++ b/src/diff/types.rs @@ -0,0 +1,96 @@ +use std::path::PathBuf; + +use gix::ObjectId; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiffRequest { + pub left: ObjectId, + pub right: ObjectId, +} + +#[derive(Debug, Default, Clone)] +pub struct DiffOptions { + pub paths: Option>, + pub ignore_whitespace: Option, + pub detect_renames: Option, + pub word_diff: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WhitespaceMode { + None, + IgnoreSpaceChange, + IgnoreAllSpace, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiffStatus { + Added, + Deleted, + Modified, + TypeChanged, + Renamed, + Copied, +} + +impl DiffStatus { + pub fn from_char(ch: char) -> Option { + match ch { + 'A' => Some(Self::Added), + 'D' => Some(Self::Deleted), + 'M' => Some(Self::Modified), + 'T' => Some(Self::TypeChanged), + 'R' => Some(Self::Renamed), + 'C' => Some(Self::Copied), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChangedPath { + pub old_path: Option, + pub new_path: PathBuf, + pub old_mode: u32, + pub new_mode: u32, + pub old_oid: Option, + pub new_oid: Option, + pub status: DiffStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChangedPathDiff { + pub path: ChangedPath, + pub lines_added: u64, + pub lines_removed: u64, + pub binary: bool, + pub patch: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NumStat { + pub additions: u64, + pub deletions: u64, + pub path: PathBuf, + pub old_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ShortStat { + pub files_changed: u64, + pub additions: u64, + pub deletions: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChangedPathRequest { + Commit(ObjectId), + TreePair(ObjectId, ObjectId), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RangeDiffSpec { + Range(String, String), + RevisionRange(String, String), + BaseWithRevisions(String, String, String), +} diff --git a/src/diff/usecase.rs b/src/diff/usecase.rs new file mode 100644 index 0000000..eabaf92 --- /dev/null +++ b/src/diff/usecase.rs @@ -0,0 +1,407 @@ +use std::path::Path; + +use gix::ObjectId; +use tokio::io::{AsyncBufReadExt, AsyncReadExt}; + +use crate::GitBaby; +use crate::command::error::CmdError; +use crate::diff::helper; +use crate::diff::types::{ + ChangedPath, ChangedPathDiff, ChangedPathRequest, DiffOptions, DiffRequest, NumStat, + RangeDiffSpec, ShortStat, +}; +use crate::error::BabyError; + +impl GitBaby { + pub async fn raw_diff( + &self, + req: &DiffRequest, + hex_len: Option, + ) -> Result { + let opts = DiffOptions::default(); + let mut cmd = self + .spawn_diff_cmd(req, hex_len, &opts, helper::build_raw_diff_cmd) + .await?; + run_capture(&mut cmd, &req.left.to_string(), &req.right.to_string()).await + } + + pub async fn raw_diff_stream( + &self, + req: &DiffRequest, + hex_len: Option, + #[allow(unused_mut)] mut callback: F, + ) -> Result<(), BabyError> + where + F: FnMut(&[u8]) + Send, + { + let opts = DiffOptions::default(); + let mut cmd = self + .spawn_diff_cmd(req, hex_len, &opts, helper::build_raw_diff_cmd) + .await?; + run_stream( + &mut cmd, + &req.left.to_string(), + &req.right.to_string(), + callback, + ) + .await + } + + pub async fn patch_diff( + &self, + req: &DiffRequest, + hex_len: Option, + ) -> Result { + let opts = DiffOptions::default(); + let mut cmd = self + .spawn_diff_cmd(req, hex_len, &opts, helper::build_patch_diff_cmd) + .await?; + run_capture(&mut cmd, &req.left.to_string(), &req.right.to_string()).await + } + + pub async fn patch_diff_stream( + &self, + req: &DiffRequest, + hex_len: Option, + #[allow(unused_mut)] mut callback: F, + ) -> Result<(), BabyError> + where + F: FnMut(&[u8]) + Send, + { + let opts = DiffOptions::default(); + let mut cmd = self + .spawn_diff_cmd(req, hex_len, &opts, helper::build_patch_diff_cmd) + .await?; + run_stream( + &mut cmd, + &req.left.to_string(), + &req.right.to_string(), + callback, + ) + .await + } + + pub async fn commit_delta( + &self, + req: &DiffRequest, + opts: &DiffOptions, + hex_len: Option, + ) -> Result, BabyError> { + if let Some(paths) = &opts.paths { + helper::validate_paths(paths)?; + } + let mut cmd = self + .spawn_diff_cmd(req, hex_len, opts, helper::build_commit_delta_cmd) + .await?; + let output = run_output(&mut cmd, &req.left.to_string(), &req.right.to_string()).await?; + let abbrev = hex_len.map(|n| n as usize).unwrap_or(40); + let mut paths: Vec = Vec::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + if line.is_empty() { + continue; + } + paths.push(helper::parse_raw_diff_line(line, abbrev)?); + } + Ok(paths) + } + + pub async fn commit_diff( + &self, + req: &DiffRequest, + opts: &DiffOptions, + hex_len: Option, + ) -> Result, BabyError> { + if let Some(paths) = &opts.paths { + helper::validate_paths(paths)?; + } + let mut cmd = self + .spawn_diff_cmd(req, hex_len, opts, helper::build_commit_diff_cmd) + .await?; + let output = run_output(&mut cmd, &req.left.to_string(), &req.right.to_string()).await?; + let abbrev = hex_len.map(|n| n as usize).unwrap_or(40); + helper::parse_commit_diff_output(&output.stdout, abbrev) + } + + pub async fn find_changed_paths( + &self, + requests: Vec, + filter_status: Option, + detect_renames: Option, + merge_parents: bool, + ) -> Result, BabyError> { + let mut cmd = self + .spawn_diff_state_cmd(&requests, filter_status, detect_renames, merge_parents) + .await?; + let payload = helper::build_diff_tree_stdin(&requests); + let output = match cmd.feed(&payload).await { + Ok(()) => match cmd.run().await { + Ok(o) => o, + Err(CmdError::NonZeroExit { stderr, .. }) => { + return Err(helper::classify_stderr( + &stderr, + &requests.first().map_or_else(String::new, |r| match r { + ChangedPathRequest::Commit(o) => o.to_hex().to_string(), + ChangedPathRequest::TreePair(l, _) => l.to_hex().to_string(), + }), + "", + )); + } + Err(e) => return Err(BabyError::from(e)), + }, + Err(e) => return Err(BabyError::from(e)), + }; + let abbrev = 40; + let mut paths: Vec = Vec::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + if line.is_empty() { + continue; + } + paths.push(helper::parse_diff_tree_status_line(line, abbrev)?); + } + Ok(paths) + } + + pub async fn diff_stats( + &self, + req: &DiffRequest, + opts: &DiffOptions, + ) -> Result, BabyError> { + if let Some(paths) = &opts.paths { + helper::validate_paths(paths)?; + } + let mut cmd = self + .spawn_diff_cmd_no_hex(req, opts, helper::build_numstat_cmd) + .await?; + let output = run_output(&mut cmd, &req.left.to_string(), &req.right.to_string()).await?; + helper::parse_numstat_output(&output.stdout) + } + + pub async fn shortstat( + &self, + req: &DiffRequest, + opts: &DiffOptions, + ) -> Result { + if let Some(paths) = &opts.paths { + helper::validate_paths(paths)?; + } + let mut cmd = self + .spawn_diff_cmd_no_hex(req, opts, helper::build_shortstat_cmd) + .await?; + let output = run_output(&mut cmd, &req.left.to_string(), &req.right.to_string()).await?; + helper::parse_shortstat_output(&output.stdout) + } + + pub async fn range_diff( + &self, + spec: &RangeDiffSpec, + opts: &DiffOptions, + hex_len: Option, + ) -> Result { + let mut cmd = self.spawn_range_diff_cmd(spec, opts, hex_len).await?; + let (left, right) = match spec { + RangeDiffSpec::Range(r1, r2) | RangeDiffSpec::RevisionRange(r1, r2) => { + (r1.clone(), r2.clone()) + } + RangeDiffSpec::BaseWithRevisions(_, r1, r2) => (r1.clone(), r2.clone()), + }; + run_capture(&mut cmd, &left, &right).await + } + + async fn spawn_diff_cmd( + &self, + req: &DiffRequest, + hex_len: Option, + opts: &DiffOptions, + builder: F, + ) -> Result + where + F: FnOnce( + &Path, + crate::command::env::Env, + &DiffRequest, + &DiffOptions, + Option, + ) -> crate::command::cmd::Cmd, + { + let dir = self + .facade + .git_repo_dir() + .await + .map_err(crate::share::facade_error)?; + let alternates = self + .facade + .git_alternate_object_directories() + .await + .map_err(crate::share::facade_error)?; + let env = crate::share::build_env_safe(&alternates); + Ok(builder(&dir, env, req, opts, hex_len)) + } + + async fn spawn_diff_cmd_no_hex( + &self, + req: &DiffRequest, + opts: &DiffOptions, + builder: F, + ) -> Result + where + F: FnOnce( + &Path, + crate::command::env::Env, + &DiffRequest, + &DiffOptions, + ) -> crate::command::cmd::Cmd, + { + let dir = self + .facade + .git_repo_dir() + .await + .map_err(crate::share::facade_error)?; + let alternates = self + .facade + .git_alternate_object_directories() + .await + .map_err(crate::share::facade_error)?; + let env = crate::share::build_env_safe(&alternates); + Ok(builder(&dir, env, req, opts)) + } + + async fn spawn_diff_state_cmd( + &self, + requests: &[ChangedPathRequest], + filter_status: Option, + detect_renames: Option, + merge_parents: bool, + ) -> Result { + let dir = self + .facade + .git_repo_dir() + .await + .map_err(crate::share::facade_error)?; + let alternates = self + .facade + .git_alternate_object_directories() + .await + .map_err(crate::share::facade_error)?; + let env = crate::share::build_env_safe(&alternates); + Ok(helper::build_find_changed_paths_cmd( + &dir, + env, + requests, + filter_status, + detect_renames, + merge_parents, + )) + } + + async fn spawn_range_diff_cmd( + &self, + spec: &RangeDiffSpec, + opts: &DiffOptions, + hex_len: Option, + ) -> Result { + let dir = self + .facade + .git_repo_dir() + .await + .map_err(crate::share::facade_error)?; + let alternates = self + .facade + .git_alternate_object_directories() + .await + .map_err(crate::share::facade_error)?; + let env = crate::share::build_env_safe(&alternates); + Ok(helper::build_range_diff_cmd(&dir, env, spec, opts, hex_len)) + } +} + +async fn run_capture( + cmd: &mut crate::command::cmd::Cmd, + left: &str, + right: &str, +) -> Result { + let output = match cmd.run().await { + Ok(o) => o, + Err(CmdError::NonZeroExit { ref stderr, .. }) => { + return Err(helper::classify_stderr(stderr, left, right)); + } + Err(e) => return Err(helper::classify_cmd_error(e, left, right)), + }; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + left, + right, + )); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +async fn run_output( + cmd: &mut crate::command::cmd::Cmd, + left: &str, + right: &str, +) -> Result { + let output = match cmd.run().await { + Ok(o) => o, + Err(CmdError::NonZeroExit { ref stderr, .. }) => { + return Err(helper::classify_stderr(stderr, left, right)); + } + Err(e) => return Err(helper::classify_cmd_error(e, left, right)), + }; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + left, + right, + )); + } + Ok(output) +} + +async fn run_stream( + cmd: &mut crate::command::cmd::Cmd, + left: &str, + right: &str, + mut callback: F, +) -> Result<(), BabyError> +where + F: FnMut(&[u8]) + Send, +{ + if let Err(e) = cmd.spawn().await { + return Err(helper::classify_cmd_error(e, left, right)); + } + let mut stdout = std::mem::replace(&mut cmd.stdout, Box::new(tokio::io::empty())); + let mut reader = tokio::io::BufReader::new(&mut stdout); + let mut buf = Vec::with_capacity(8192); + loop { + buf.clear(); + let n = match reader.read_until(b'\n', &mut buf).await { + Ok(n) => n, + Err(source) => { + return Err(BabyError::Custom(format!("stream read failed: {}", source))); + } + }; + if n == 0 { + break; + } + callback(&buf); + } + let status = match cmd.wait().await { + Ok(s) => s, + Err(e) => return Err(helper::classify_cmd_error(e, left, right)), + }; + if !status.success() { + let mut stderr = std::mem::replace(&mut cmd.stderr, Box::new(tokio::io::empty())); + let mut sbuf = Vec::new(); + let _ = stderr.read_to_end(&mut sbuf).await; + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&sbuf), + left, + right, + )); + } + Ok(()) +} + +#[allow(dead_code)] +fn _oid_marker(_o: ObjectId) {} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..12566db --- /dev/null +++ b/src/error.rs @@ -0,0 +1,277 @@ +use std::fmt; +use std::path::PathBuf; + +use crate::command::error::CmdError; + +#[derive(Debug)] +pub enum BabyError { + Custom(String), + Command(CmdError), + Io { + path: PathBuf, + source: std::io::Error, + }, + Spawn { + prog: String, + source: std::io::Error, + stderr: String, + }, + InvalidRange, + PathEscapesRepository, + RevisionEmpty, + PathNotFound { + revision: String, + path: PathBuf, + }, + OutOfRange { + actual_lines: u64, + revision: String, + path: PathBuf, + }, + IgnoreRevsNotBlob { + revision: String, + }, + Parse { + line_number: u64, + message: String, + }, + RevisionNotFound(String), + RevisionInvalid(String), + NotATree { + revision: String, + path: PathBuf, + }, + CommitParse { + line_number: u64, + message: String, + }, + InvalidTimestamp(String), + BranchNotFound(String), + BranchAlreadyExists(String), + RefNotFound(String), + TagNotFound(String), + TagAlreadyExists(String), + InvalidRefName(String), + InvalidBranchName(String), + InvalidTagName(String), + RefUpdateRejected(String), + AmbiguousRef { + query: String, + candidates: Vec, + }, + TagVerifyFailed { + name: String, + reason: String, + }, + MergeBaseNotFound(String), + ResolutionError { + reason: String, + }, + BlobNotFound { + oid: String, + }, + LFSPointerInvalid { + oid: String, + reason: String, + }, + ConfigGetFailed { + key: String, + stderr: String, + }, + ConfigSetFailed { + key: String, + stderr: String, + }, + ArchiveFailed(String), + SubModuleParse { + line: u64, + reason: String, + }, + RemoteNotFound(String), + RemoteAlreadyExists(String), + InvalidRemoteUrl(String), + FastImportFailed(String), + NotARepository(String), + GcFailed(String), + InvalidGitArg(String), + Unimplemented(&'static str), + PayloadTooLarge { + cap_bytes: u64, + actual_bytes: u64, + }, +} + +impl fmt::Display for BabyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Custom(msg) => write!(f, "{}", msg), + Self::Command(err) => write!(f, "{}", err), + Self::Io { path, source } => { + write!(f, "io error at `{}`: {}", path.display(), source) + } + Self::Spawn { + prog, + source, + stderr, + } => write!( + f, + "`{}` failed: {} (stderr: {})", + prog, + source, + stderr.trim() + ), + Self::InvalidRange => { + write!(f, "invalid line range; expected 'A,B' with A <= B") + } + Self::PathEscapesRepository => write!( + f, + "blame path escapes repository (must be relative and contain no '..' components)" + ), + Self::RevisionEmpty => write!(f, "revision must not be empty"), + Self::PathNotFound { revision, path } => write!( + f, + "path `{}` does not exist at revision `{}`", + path.display(), + revision + ), + Self::OutOfRange { + actual_lines, + revision, + path, + } => write!( + f, + "path `{}` at revision `{}` has only {} line(s), below the requested range", + path.display(), + revision, + actual_lines + ), + Self::IgnoreRevsNotBlob { revision } => write!( + f, + "ignore-revs file at revision `{}` is not a blob", + revision + ), + Self::Parse { + line_number, + message, + } => write!( + f, + "malformed git blame output at line {}: {}", + line_number, message + ), + Self::RevisionNotFound(rev) => { + write!(f, "revision `{}` does not exist", rev) + } + Self::RevisionInvalid(rev) => { + write!(f, "revision `{}` is malformed or ambiguous", rev) + } + Self::NotATree { revision, path } => write!( + f, + "`{}` at revision `{}` is not a tree", + path.display(), + revision + ), + Self::CommitParse { + line_number, + message, + } => write!( + f, + "malformed commit porcelain at line {}: {}", + line_number, message + ), + Self::InvalidTimestamp(input) => { + write!(f, "invalid git timestamp: `{}`", input) + } + Self::BranchNotFound(name) => write!(f, "branch `{}` does not exist", name), + Self::BranchAlreadyExists(name) => write!(f, "branch `{}` already exists", name), + Self::RefNotFound(name) => write!(f, "ref `{}` does not exist", name), + Self::TagNotFound(name) => write!(f, "tag `{}` does not exist", name), + Self::TagAlreadyExists(name) => write!(f, "tag `{}` already exists", name), + Self::InvalidRefName(name) => write!(f, "invalid ref name `{}`", name), + Self::InvalidBranchName(name) => write!(f, "invalid branch name `{}`", name), + Self::InvalidTagName(name) => write!(f, "invalid tag name `{}`", name), + Self::RefUpdateRejected(reason) => { + write!(f, "ref update rejected by git: {}", reason.trim()) + } + Self::AmbiguousRef { query, candidates } => write!( + f, + "ref `{}` is ambiguous; candidates: {}", + query, + candidates.join(", ") + ), + Self::TagVerifyFailed { name, reason } => { + write!( + f, + "tag `{}` signature verification failed: {}", + name, reason + ) + } + Self::MergeBaseNotFound(spec) => { + write!(f, "no merge-base exists for `{}`", spec) + } + Self::ResolutionError { reason } => { + write!(f, "conflict resolution error: {}", reason) + } + Self::BlobNotFound { oid } => write!(f, "blob `{}` does not exist", oid), + Self::LFSPointerInvalid { oid, reason } => { + write!(f, "blob `{}` is not a valid LFS pointer: {}", oid, reason) + } + Self::ConfigGetFailed { key, stderr } => { + write!(f, "git config get for `{}` failed: {}", key, stderr.trim()) + } + Self::ConfigSetFailed { key, stderr } => { + write!(f, "git config set for `{}` failed: {}", key, stderr.trim()) + } + Self::ArchiveFailed(reason) => write!(f, "git archive failed: {}", reason.trim()), + Self::SubModuleParse { line, reason } => { + write!(f, "malformed .gitmodules at line {}: {}", line, reason) + } + Self::RemoteNotFound(name) => write!(f, "remote `{}` does not exist", name), + Self::RemoteAlreadyExists(name) => { + write!(f, "remote `{}` already exists", name) + } + Self::InvalidRemoteUrl(url) => write!(f, "invalid remote url `{}`", url), + Self::FastImportFailed(reason) => { + write!(f, "git fast-import failed: {}", reason.trim()) + } + Self::NotARepository(path) => write!(f, "`{}` is not a git repository", path), + Self::GcFailed(reason) => write!(f, "git gc failed: {}", reason.trim()), + Self::InvalidGitArg(msg) => write!(f, "invalid git argument: {}", msg), + Self::Unimplemented(what) => write!(f, "not yet implemented: {}", what), + Self::PayloadTooLarge { + cap_bytes, + actual_bytes, + } => write!( + f, + "output exceeds cap: {} bytes > {} bytes cap", + actual_bytes, cap_bytes + ), + } + } +} + +impl std::error::Error for BabyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Command(err) => Some(err), + Self::Io { source, .. } => Some(source), + Self::Spawn { source, .. } => Some(source), + Self::AmbiguousRef { .. } | Self::TagVerifyFailed { .. } => None, + _ => None, + } + } +} + +impl From for BabyError { + fn from(err: CmdError) -> Self { + Self::Command(err) + } +} + +impl From for BabyError { + fn from(source: std::io::Error) -> Self { + Self::Io { + path: PathBuf::new(), + source, + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..0848cab --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,50 @@ +use std::sync::Arc; + +use crate::command::pipe::Pipe; +use crate::repo::RepositoryFacade; + +pub mod archive; +pub mod blame; +pub mod blob; +pub mod branch; +pub mod cleanup; +pub mod command; +pub mod commit; +pub mod compare; +pub mod config; +pub mod conflict; +pub mod diff; +pub mod error; +pub mod merge; +pub mod refs; +pub mod remote; +pub mod repo; +pub mod setup; +pub mod share; +pub mod submodule; +pub mod tags; +pub mod tree; + +#[derive(Clone)] +pub struct GitBaby { + facade: Arc, + pipe: Pipe, +} + +impl GitBaby { + pub fn new(facade: Arc, pipe: Pipe) -> Self { + Self { facade, pipe } + } + + pub fn facade(&self) -> &dyn RepositoryFacade { + &*self.facade + } + + pub fn pipe(&self) -> &Pipe { + &self.pipe + } + + pub fn pipe_mut(&mut self) -> &mut Pipe { + &mut self.pipe + } +} diff --git a/src/merge/helper.rs b/src/merge/helper.rs new file mode 100644 index 0000000..a5abe94 --- /dev/null +++ b/src/merge/helper.rs @@ -0,0 +1,32 @@ +use std::path::Path; + +use crate::command::cmd::Cmd; +use crate::command::env::Env; +use crate::command::error::CmdError; +use crate::error::BabyError; +use crate::share; + +pub const CALLER: &str = "gitbaby::merge"; + +pub fn build_merge_base_cmd(dir: &Path, env: Env, one: &str, two: &str) -> Cmd { + let args: Vec = vec!["merge-base".into(), one.into(), two.into()]; + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn classify_stderr(stderr: &str, one: &str, two: &str) -> BabyError { + if stderr.trim().is_empty() { + return BabyError::MergeBaseNotFound(format!("{}..{}", one, two)); + } + if stderr.contains("fatal: bad revision") { + return BabyError::RevisionNotFound(format!("{}..{}", one, two)); + } + BabyError::Custom(format!("git merge-base failed: {}", stderr.trim())) +} + +pub fn classify_cmd_error(err: CmdError, one: &str, two: &str) -> BabyError { + match err { + CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, one, two), + CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source), + other => BabyError::from(other), + } +} diff --git a/src/merge/mod.rs b/src/merge/mod.rs new file mode 100644 index 0000000..2f75d3e --- /dev/null +++ b/src/merge/mod.rs @@ -0,0 +1,5 @@ +pub mod helper; +pub mod types; +pub mod usecase; + +pub use crate::conflict::MergeTreeResult; diff --git a/src/merge/types.rs b/src/merge/types.rs new file mode 100644 index 0000000..bc63b32 --- /dev/null +++ b/src/merge/types.rs @@ -0,0 +1,6 @@ +use gix::ObjectId; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MergeBaseResult { + pub oid: ObjectId, +} diff --git a/src/merge/usecase.rs b/src/merge/usecase.rs new file mode 100644 index 0000000..b10a5e4 --- /dev/null +++ b/src/merge/usecase.rs @@ -0,0 +1,53 @@ +use gix::ObjectId; + +use crate::GitBaby; +use crate::command::error::CmdError; +use crate::error::BabyError; +use crate::merge::helper; + +impl GitBaby { + pub async fn merge_base(&self, one: &str, two: &str) -> Result { + let mut cmd = self.spawn_merge_base_cmd(one, two).await?; + let output = match cmd.run().await { + Ok(o) => o, + Err(CmdError::NonZeroExit { ref stderr, .. }) => { + return Err(helper::classify_stderr(stderr, one, two)); + } + Err(e) => return Err(helper::classify_cmd_error(e, one, two)), + }; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + one, + two, + )); + } + let text = String::from_utf8_lossy(&output.stdout); + let hex = text.trim(); + ObjectId::from_hex(hex.as_bytes()).map_err(|source| { + BabyError::Custom(format!( + "merge-base returned invalid oid `{}`: {}", + hex, source + )) + }) + } + + async fn spawn_merge_base_cmd( + &self, + one: &str, + two: &str, + ) -> Result { + let dir = self + .facade + .git_repo_dir() + .await + .map_err(crate::share::facade_error)?; + let alternates = self + .facade + .git_alternate_object_directories() + .await + .map_err(crate::share::facade_error)?; + let env = crate::share::build_env_safe(&alternates); + Ok(helper::build_merge_base_cmd(&dir, env, one, two)) + } +} diff --git a/src/refs/helper.rs b/src/refs/helper.rs new file mode 100644 index 0000000..b475b1f --- /dev/null +++ b/src/refs/helper.rs @@ -0,0 +1,269 @@ +use std::path::Path; + +use crate::command::cmd::Cmd; +use crate::command::env::Env; +use crate::command::error::CmdError; +use crate::error::BabyError; +use crate::share::{git_cmd, validate_ref_name}; + +use super::types::{ListRefsOptions, RefInfo, RefType, RefUpdate}; + +pub const CALLER: &str = "gitbaby::refs"; + +pub fn validate_list_refs_options(opts: &ListRefsOptions) -> Result<(), BabyError> { + for p in &opts.patterns { + if !p.starts_with("refs/") { + return Err(BabyError::InvalidRefName(p.clone())); + } + validate_ref_name(p)?; + } + for p in &opts.exclude_patterns { + if !p.starts_with("refs/") { + return Err(BabyError::InvalidRefName(p.clone())); + } + } + Ok(()) +} + +pub fn validate_ref_update(update: &RefUpdate) -> Result<(), BabyError> { + validate_ref_name(&update.name)?; + if update.new_sha.is_null() && update.old_sha.is_none() { + return Err(BabyError::InvalidGitArg(format!( + "ref `{}` new_sha is the zero object id (delete) but old_sha is missing", + update.name + ))); + } + Ok(()) +} + +pub fn build_list_refs_cmd(dir: &Path, env: Env, opts: &ListRefsOptions) -> Cmd { + let args = crate::share::build_for_each_ref_args( + &opts.patterns, + None, + opts.limit, + opts.start_after.as_deref(), + None, + &opts.exclude_patterns, + opts.peel_tags, + ); + git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_find_refs_by_oid_cmd(dir: &Path, env: Env, oid: &str) -> Cmd { + let args = crate::share::build_for_each_ref_args( + &["refs".to_string()], + None, + None, + None, + Some(oid), + &[], + false, + ); + git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_update_ref_cmd( + dir: &Path, + env: Env, + name: &str, + new_sha: &str, + old_sha: Option<&str>, + atomic: bool, +) -> Cmd { + let mut args = Vec::new(); + if atomic { + args.push("update-ref".to_string()); + args.push("--stdin".to_string()); + } else { + args.push("update-ref".to_string()); + let mut a = vec![name.to_string(), new_sha.to_string()]; + if let Some(o) = old_sha { + a.push(o.to_string()); + } + args.extend(a); + } + git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_delete_ref_cmd(dir: &Path, env: Env, name: &str, atomic: bool) -> Cmd { + let args = if atomic { + vec!["update-ref".to_string(), "--stdin".to_string()] + } else { + vec![ + "update-ref".to_string(), + "--no-deref".to_string(), + "-d".to_string(), + name.to_string(), + ] + }; + git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_show_ref_cmd(dir: &Path, env: Env, name: &str) -> Cmd { + git_cmd( + CALLER, + dir, + env, + None, + vec![ + "show-ref".to_string(), + "--verify".to_string(), + "--".to_string(), + name.to_string(), + ], + ) +} + +pub fn build_revspec_cmd(dir: &Path, env: Env, rev: &str) -> Cmd { + git_cmd( + CALLER, + dir, + env, + None, + vec![ + "rev-parse".to_string(), + "--verify".to_string(), + rev.to_string(), + ], + ) +} + +pub fn classify_stderr(stderr: &str, ref_name: Option<&str>) -> BabyError { + let s = stderr.trim(); + if let Some(name) = ref_name + && (s.contains("not a tree") || s.contains("Not a tree")) + { + return BabyError::RefNotFound(name.to_string()); + } + if s.contains("ambiguous") && ref_name.is_some() { + return BabyError::AmbiguousRef { + query: ref_name.unwrap_or("").to_string(), + candidates: Vec::new(), + }; + } + if s.contains("not found") || s.contains("Needed a single revision") { + if let Some(name) = ref_name { + return BabyError::RefNotFound(name.to_string()); + } + return BabyError::RevisionNotFound(s.to_string()); + } + BabyError::Spawn { + prog: "git".to_string(), + source: std::io::Error::other(s.to_string()), + stderr: s.to_string(), + } +} + +pub fn classify_cmd_error(err: CmdError, ref_name: Option<&str>) -> BabyError { + match err { + CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, ref_name), + CmdError::Spawn { prog, source } => crate::share::classify_spawn_error(prog, source), + other => BabyError::from(other), + } +} + +pub fn classify_for_each_ref_type(s: &str) -> RefType { + match s { + "tag" => RefType::Tag, + "commit" => RefType::Branch, + _ => RefType::Other, + } +} + +pub fn parse_for_each_ref_line(line: &str, peel: bool) -> Result { + let mut parts = line.splitn(4, ' '); + let sha_hex = parts.next().unwrap_or(""); + let kind = parts.next().unwrap_or(""); + let name = match peel { + true => match parts.next() { + Some(n) => { + let peeled_hex = parts.next().unwrap_or(""); + let sha = gix::ObjectId::from_hex(sha_hex.as_bytes()).map_err(|e| { + BabyError::Custom(format!("for-each-ref invalid oid `{}`: {}", sha_hex, e)) + })?; + let peeled = if peeled_hex.is_empty() { + None + } else { + Some(gix::ObjectId::from_hex(peeled_hex.as_bytes()).map_err(|e| { + BabyError::Custom(format!( + "for-each-ref invalid peeled oid `{}`: {}", + peeled_hex, e + )) + })?) + }; + return Ok(RefInfo { + name: n.to_string(), + ref_type: classify_for_each_ref_type(kind), + sha, + peeled, + }); + } + None => { + return Err(BabyError::Custom(format!( + "for-each-ref malformed line: {}", + line + ))); + } + }, + false => match parts.next() { + Some(n) => n, + None => { + return Err(BabyError::Custom(format!( + "for-each-ref malformed line: {}", + line + ))); + } + }, + }; + let sha = gix::ObjectId::from_hex(sha_hex.as_bytes()) + .map_err(|e| BabyError::Custom(format!("for-each-ref invalid oid `{}`: {}", sha_hex, e)))?; + Ok(RefInfo { + name: name.to_string(), + ref_type: classify_for_each_ref_type(kind), + sha, + peeled: None, + }) +} + +pub fn parse_for_each_ref_output(stdout: &[u8], peel: bool) -> Result, BabyError> { + let text = std::str::from_utf8(stdout) + .map_err(|e| BabyError::Custom(format!("for-each-ref non-utf8: {}", e)))?; + let mut out = Vec::new(); + for line in text.lines() { + if line.is_empty() { + continue; + } + out.push(parse_for_each_ref_line(line, peel)?); + } + Ok(out) +} + +pub fn encode_atomic_update(updates: &[RefUpdate]) -> Result, BabyError> { + let mut buf = Vec::new(); + for u in updates { + validate_ref_update(u)?; + buf.extend_from_slice(b"update "); + buf.extend_from_slice(u.name.as_bytes()); + buf.push(b' '); + buf.extend_from_slice(u.new_sha.to_string().as_bytes()); + if let Some(o) = u.old_sha { + buf.push(b' '); + buf.extend_from_slice(o.to_string().as_bytes()); + } + buf.push(b'\n'); + } + buf.extend_from_slice(b"prepare\ncommit\n"); + Ok(buf) +} + +pub fn encode_atomic_delete(names: &[String]) -> Result, BabyError> { + let mut buf = Vec::new(); + for n in names { + validate_ref_name(n)?; + buf.extend_from_slice(b"delete "); + buf.extend_from_slice(n.as_bytes()); + buf.push(b'\n'); + } + buf.extend_from_slice(b"prepare\ncommit\n"); + Ok(buf) +} diff --git a/src/refs/mod.rs b/src/refs/mod.rs new file mode 100644 index 0000000..56edaf3 --- /dev/null +++ b/src/refs/mod.rs @@ -0,0 +1,5 @@ +pub mod helper; +pub mod types; +pub mod usecase; + +pub use types::{ListRefsOptions, RefInfo, RefType, RefUpdate}; diff --git a/src/refs/types.rs b/src/refs/types.rs new file mode 100644 index 0000000..479a2d4 --- /dev/null +++ b/src/refs/types.rs @@ -0,0 +1,33 @@ +use gix::ObjectId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefType { + Branch, + Tag, + Remote, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RefInfo { + pub name: String, + pub ref_type: RefType, + pub sha: ObjectId, + pub peeled: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct ListRefsOptions { + pub patterns: Vec, + pub exclude_patterns: Vec, + pub peel_tags: bool, + pub limit: Option, + pub start_after: Option, +} + +#[derive(Debug, Clone)] +pub struct RefUpdate { + pub name: String, + pub new_sha: ObjectId, + pub old_sha: Option, +} diff --git a/src/refs/usecase.rs b/src/refs/usecase.rs new file mode 100644 index 0000000..4ad9517 --- /dev/null +++ b/src/refs/usecase.rs @@ -0,0 +1,217 @@ +use gix::ObjectId; + +use crate::GitBaby; +use crate::error::BabyError; +use crate::refs::helper; +use crate::refs::types::{ListRefsOptions, RefInfo, RefUpdate}; +use crate::share; + +impl GitBaby { + pub async fn list_refs(&self, opts: ListRefsOptions) -> Result, BabyError> { + helper::validate_list_refs_options(&opts)?; + let mut cmd = self + .spawn_refs_cmd(|dir, env| helper::build_list_refs_cmd(dir, env, &opts)) + .await?; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, None))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + None, + )); + } + helper::parse_for_each_ref_output(&output.stdout, opts.peel_tags) + } + + pub async fn ref_exists(&self, name: &str) -> Result { + share::validate_ref_name(name)?; + let mut cmd = self + .spawn_refs_cmd(|dir, env| helper::build_show_ref_cmd(dir, env, name)) + .await?; + match cmd.run().await { + Ok(out) => Ok(out.status.success()), + Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => { + let s = stderr.trim(); + if s.contains("not found") || s.contains("does not exist") || s.is_empty() { + Ok(false) + } else { + Err(helper::classify_stderr(s, Some(name))) + } + } + Err(e) => Err(helper::classify_cmd_error(e, Some(name))), + } + } + + pub async fn resolve_revision(&self, rev: &str) -> Result { + share::validate_revision_non_empty(rev)?; + let mut cmd = self + .spawn_refs_cmd(|dir, env| helper::build_revspec_cmd(dir, env, rev)) + .await?; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, Some(rev)))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + Some(rev), + )); + } + let text = String::from_utf8_lossy(&output.stdout); + let hex = text.trim(); + gix::ObjectId::from_hex(hex.as_bytes()).map_err(|e| { + BabyError::Custom(format!("rev-parse returned invalid oid `{}`: {}", hex, e)) + }) + } + + pub async fn update_ref( + &self, + name: &str, + new_sha: ObjectId, + old_sha: Option, + ) -> Result<(), BabyError> { + let update = RefUpdate { + name: name.to_string(), + new_sha, + old_sha, + }; + helper::validate_ref_update(&update)?; + let old_str = update.old_sha.map(|o| o.to_string()); + let mut cmd = self + .spawn_refs_cmd(|dir, env| { + helper::build_update_ref_cmd( + dir, + env, + &update.name, + &update.new_sha.to_string(), + old_str.as_deref(), + false, + ) + }) + .await?; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, Some(&update.name)))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + Some(&update.name), + )); + } + Ok(()) + } + + pub async fn delete_ref(&self, name: &str) -> Result<(), BabyError> { + share::validate_ref_name(name)?; + let mut cmd = self + .spawn_refs_cmd(|dir, env| helper::build_delete_ref_cmd(dir, env, name, false)) + .await?; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, Some(name)))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + Some(name), + )); + } + Ok(()) + } + + pub async fn find_refs_by_oid(&self, oid: ObjectId) -> Result, BabyError> { + let mut cmd = self + .spawn_refs_cmd(|dir, env| { + helper::build_find_refs_by_oid_cmd(dir, env, &oid.to_string()) + }) + .await?; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, None))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + None, + )); + } + helper::parse_for_each_ref_output(&output.stdout, false) + } + + pub async fn update_refs_atomic(&self, updates: Vec) -> Result<(), BabyError> { + if updates.is_empty() { + return Err(BabyError::Custom( + "update_refs_atomic requires at least one update".to_string(), + )); + } + for u in &updates { + helper::validate_ref_update(u)?; + } + let stdin = helper::encode_atomic_update(&updates)?; + let mut cmd = self + .spawn_refs_cmd(|dir, env| helper::build_update_ref_cmd(dir, env, "", "", None, true)) + .await?; + if let Err(e) = cmd.feed(&stdin).await { + return Err(helper::classify_cmd_error(e, None)); + } + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, None))?; + if !output.status.success() { + return Err(BabyError::RefUpdateRejected( + String::from_utf8_lossy(&output.stderr).into_owned(), + )); + } + Ok(()) + } + + pub async fn delete_refs_atomic(&self, names: Vec) -> Result<(), BabyError> { + if names.is_empty() { + return Err(BabyError::Custom( + "delete_refs_atomic requires at least one name".to_string(), + )); + } + for n in &names { + share::validate_ref_name(n)?; + } + let stdin = helper::encode_atomic_delete(&names)?; + let mut cmd = self + .spawn_refs_cmd(|dir, env| helper::build_delete_ref_cmd(dir, env, "", true)) + .await?; + if let Err(e) = cmd.feed(&stdin).await { + return Err(helper::classify_cmd_error(e, None)); + } + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, None))?; + if !output.status.success() { + return Err(BabyError::RefUpdateRejected( + String::from_utf8_lossy(&output.stderr).into_owned(), + )); + } + Ok(()) + } + + async fn spawn_refs_cmd(&self, builder: F) -> Result + where + F: FnOnce(&std::path::Path, crate::command::env::Env) -> crate::command::cmd::Cmd, + { + let dir = self + .facade + .git_repo_dir() + .await + .map_err(share::facade_error)?; + let alternates = self + .facade + .git_alternate_object_directories() + .await + .map_err(share::facade_error)?; + let env = share::build_env_safe(&alternates); + Ok(builder(&dir, env)) + } +} diff --git a/src/remote/helper.rs b/src/remote/helper.rs new file mode 100644 index 0000000..d6ca094 --- /dev/null +++ b/src/remote/helper.rs @@ -0,0 +1,183 @@ +use std::path::Path; + +use crate::command::cmd::Cmd; +use crate::command::env::Env; +use crate::error::BabyError; +use crate::remote::types::{RemoteOption, UpdateRemoteMirrorRequest}; + +pub fn build_get_url_cmd(caller: &str, dir: &Path, env: Env, name: &str) -> Result { + crate::share::build_remote_op_cmd( + caller, + dir, + env, + crate::share::RemoteOp::GetUrl, + name, + None, + crate::share::RemoteMirror::Default, + ) +} + +pub fn build_add_cmd( + caller: &str, + dir: &Path, + env: Env, + name: &str, + url: &str, + mirror: RemoteOption, +) -> Result { + crate::share::build_remote_op_cmd( + caller, + dir, + env, + crate::share::RemoteOp::Add, + name, + Some(url), + mirror.as_arg(), + ) +} + +pub fn build_remove_cmd(caller: &str, dir: &Path, env: Env, name: &str) -> Result { + crate::share::build_remote_op_cmd( + caller, + dir, + env, + crate::share::RemoteOp::Remove, + name, + None, + crate::share::RemoteMirror::Default, + ) +} + +pub fn build_show_remote_cmd( + caller: &str, + dir: &Path, + env: Env, + name: &str, +) -> Result { + crate::share::build_remote_op_cmd( + caller, + dir, + env, + crate::share::RemoteOp::Show, + name, + None, + crate::share::RemoteMirror::Default, + ) +} + +pub fn build_fetch_cmd( + caller: &str, + dir: &Path, + env: Env, + remote_path: &str, + commit_id: &str, +) -> Result { + crate::share::build_fetch_cmd( + caller, + dir, + env, + remote_path, + &[commit_id.to_string()], + false, + false, + ) +} + +pub fn build_ls_remote_head_cmd( + caller: &str, + dir: &Path, + env: Env, + url: &str, +) -> Result { + crate::share::build_ls_remote_cmd(caller, dir, env, url, &["HEAD".to_string()]) +} + +pub fn build_push_mirror_cmd( + caller: &str, + dir: &Path, + env: Env, + req: &UpdateRemoteMirrorRequest, + refspecs: Vec, +) -> Result { + crate::share::build_push_cmd( + caller, + dir, + env, + &req.url, + &refspecs, + !req.keep_divergent_refs, + ) +} + +pub fn classify_stderr_get_url(_stderr: &str) -> Option { + None +} + +pub fn classify_stderr_add(stderr: &str, name: &str) -> BabyError { + if stderr.contains("already exists") { + BabyError::RemoteAlreadyExists(name.to_string()) + } else if stderr.contains("invalid") { + BabyError::InvalidRemoteUrl(name.to_string()) + } else { + BabyError::Custom(format!( + "git remote add failed for `{}`: {}", + name, + stderr.trim() + )) + } +} + +pub fn classify_stderr_rm(stderr: &str, name: &str) -> BabyError { + if stderr.contains("No such remote") || stderr.contains("no such remote") { + BabyError::RemoteNotFound(name.to_string()) + } else { + BabyError::Custom(format!( + "git remote rm failed for `{}`: {}", + name, + stderr.trim() + )) + } +} + +pub fn classify_stderr_ls_remote(stderr: &str) -> BabyError { + if stderr.contains("not found") || stderr.contains("could not read") { + BabyError::InvalidRemoteUrl(stderr.trim().to_string()) + } else { + BabyError::Custom(format!("git ls-remote failed: {}", stderr.trim())) + } +} + +pub fn classify_stderr_show_remote(_stderr: &str) -> Option { + None +} + +pub fn classify_stderr_push(stderr: &str) -> BabyError { + BabyError::Custom(format!("git push failed: {}", stderr.trim())) +} + +pub fn classify_stderr_fetch(stderr: &str) -> BabyError { + BabyError::Custom(format!("git fetch failed: {}", stderr.trim())) +} + +pub fn parse_head_branch_line(output: &[u8]) -> Option { + let prefix = "HEAD branch: "; + for line in String::from_utf8_lossy(output).lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix(prefix) { + return Some(rest.to_string()); + } + } + None +} + +pub fn parse_oid_tab_head(output: &[u8]) -> Option<(gix::ObjectId, bool)> { + let text = String::from_utf8_lossy(output); + let first_line = text.lines().next()?; + let (oid_str, refname) = first_line.split_once('\t')?; + let refname = refname.trim(); + if refname != "HEAD" { + return None; + } + let oid = crate::share::parse_object_id(oid_str).ok()?; + Some((oid, true)) +} diff --git a/src/remote/mod.rs b/src/remote/mod.rs new file mode 100644 index 0000000..2993819 --- /dev/null +++ b/src/remote/mod.rs @@ -0,0 +1,7 @@ +pub mod helper; +pub mod types; +pub mod usecase; + +pub use types::{ + FetchRemoteCommitResult, RemoteOption, UpdateRemoteMirrorRequest, UpdateRemoteMirrorResult, +}; diff --git a/src/remote/types.rs b/src/remote/types.rs new file mode 100644 index 0000000..1a92cb5 --- /dev/null +++ b/src/remote/types.rs @@ -0,0 +1,35 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RemoteOption { + #[default] + Default, + MirrorPush, + MirrorFetch, +} + +impl RemoteOption { + pub fn as_arg(self) -> crate::share::RemoteMirror { + match self { + Self::Default => crate::share::RemoteMirror::Default, + Self::MirrorPush => crate::share::RemoteMirror::Push, + Self::MirrorFetch => crate::share::RemoteMirror::Fetch, + } + } +} + +#[derive(Debug, Clone)] +pub struct UpdateRemoteMirrorRequest { + pub url: String, + pub refs: Vec, + pub only_branches_matching: Vec, + pub keep_divergent_refs: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct UpdateRemoteMirrorResult { + pub divergent_refs: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct FetchRemoteCommitResult { + pub fetched: bool, +} diff --git a/src/remote/usecase.rs b/src/remote/usecase.rs new file mode 100644 index 0000000..dfe6476 --- /dev/null +++ b/src/remote/usecase.rs @@ -0,0 +1,225 @@ +use crate::GitBaby; +use crate::error::BabyError; +use crate::remote::helper; +use crate::remote::types::{ + FetchRemoteCommitResult, RemoteOption, UpdateRemoteMirrorRequest, UpdateRemoteMirrorResult, +}; + +async fn build_env( + gitbaby: &GitBaby, +) -> Result<(std::path::PathBuf, crate::command::env::Env), BabyError> { + let dir = gitbaby + .facade + .git_repo_dir() + .await + .map_err(crate::share::facade_error)?; + let alternates = gitbaby + .facade + .git_alternate_object_directories() + .await + .map_err(crate::share::facade_error)?; + Ok((dir, crate::share::build_env_safe(&alternates))) +} + +impl GitBaby { + pub async fn get_remote_address(&self, name: &str) -> Result, BabyError> { + if name.is_empty() { + return Err(BabyError::Custom( + "remote name must not be empty".to_string(), + )); + } + let (dir, env) = build_env(self).await?; + let mut cmd = helper::build_get_url_cmd("gitbaby::remote", &dir, env, name)?; + match cmd.run().await { + Ok(output) => { + if output.status.success() { + let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + return Ok(Some(url)); + } + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("No such remote") || stderr.contains("no such remote") { + return Ok(None); + } + Err(BabyError::Custom(format!( + "git remote get-url failed: {}", + stderr.trim() + ))) + } + Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => { + if stderr.contains("No such remote") || stderr.contains("no such remote") { + Ok(None) + } else { + Err(BabyError::Custom(format!( + "git remote get-url failed: {}", + stderr.trim() + ))) + } + } + Err(e) => Err(BabyError::from(e)), + } + } + + pub async fn add_remote( + &self, + name: &str, + url: &str, + opts: RemoteOption, + ) -> Result<(), BabyError> { + if name.is_empty() { + return Err(BabyError::Custom( + "remote name must not be empty".to_string(), + )); + } + if url.is_empty() { + return Err(BabyError::InvalidRemoteUrl(url.to_string())); + } + let (dir, env) = build_env(self).await?; + let mut cmd = helper::build_add_cmd("gitbaby::remote", &dir, env, name, url, opts)?; + let output = cmd.run_soft().await.map_err(BabyError::from)?; + if output.status.success() { + return Ok(()); + } + Err(helper::classify_stderr_add( + &String::from_utf8_lossy(&output.stderr), + name, + )) + } + + pub async fn remove_remote(&self, name: &str) -> Result<(), BabyError> { + if name.is_empty() { + return Err(BabyError::Custom( + "remote name must not be empty".to_string(), + )); + } + let (dir, env) = build_env(self).await?; + let mut cmd = helper::build_remove_cmd("gitbaby::remote", &dir, env, name)?; + let output = cmd.run_soft().await.map_err(BabyError::from)?; + if output.status.success() { + return Ok(()); + } + Err(helper::classify_stderr_rm( + &String::from_utf8_lossy(&output.stderr), + name, + )) + } + + pub async fn fetch_remote_commit( + &self, + remote_repo_path: &str, + commit_id: &str, + ) -> Result { + if remote_repo_path.is_empty() { + return Err(BabyError::Custom( + "remote repository path must not be empty".to_string(), + )); + } + if commit_id.is_empty() { + return Err(BabyError::Custom("commit id must not be empty".to_string())); + } + let (dir, env) = build_env(self).await?; + let mut cmd = + helper::build_fetch_cmd("gitbaby::remote", &dir, env, remote_repo_path, commit_id)?; + let output = cmd.run_soft().await.map_err(BabyError::from)?; + if output.status.success() { + return Ok(FetchRemoteCommitResult { fetched: true }); + } + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("not a git repository") { + return Err(BabyError::NotARepository(remote_repo_path.to_string())); + } + Err(helper::classify_stderr_fetch(&stderr)) + } + + pub async fn find_remote_repository(&self, url: &str) -> Result { + if url.is_empty() { + return Err(BabyError::InvalidRemoteUrl(url.to_string())); + } + let dir = self + .facade + .git_repo_dir() + .await + .map_err(crate::share::facade_error)?; + let alternates = self + .facade + .git_alternate_object_directories() + .await + .map_err(crate::share::facade_error)?; + let env = crate::share::build_env_safe(&alternates); + let mut cmd = helper::build_ls_remote_head_cmd("gitbaby::remote", &dir, env, url)?; + match cmd.run().await { + Ok(output) => { + if !output.status.success() { + return Ok(false); + } + Ok(helper::parse_oid_tab_head(&output.stdout).is_some()) + } + Err(crate::command::error::CmdError::Spawn { .. }) => Ok(false), + Err(crate::command::error::CmdError::NonZeroExit { .. }) => Ok(false), + Err(crate::command::error::CmdError::Io { .. }) => Ok(false), + Err(e) => Err(helper::classify_stderr_ls_remote(&format!("{:?}", e))), + } + } + + pub async fn find_remote_root_ref(&self, url: &str) -> Result, BabyError> { + if url.is_empty() { + return Err(BabyError::InvalidRemoteUrl(url.to_string())); + } + let dir = self + .facade + .git_repo_dir() + .await + .map_err(crate::share::facade_error)?; + let alternates = self + .facade + .git_alternate_object_directories() + .await + .map_err(crate::share::facade_error)?; + let env = crate::share::build_env_safe(&alternates); + let mut cmd = helper::build_show_remote_cmd("gitbaby::remote", &dir, env, url)?; + let output = match cmd.run().await { + Ok(o) => o, + Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => { + if helper::classify_stderr_show_remote(&stderr).is_none() { + return Ok(None); + } + return Err(BabyError::Custom(format!( + "git remote show failed: {}", + stderr.trim() + ))); + } + Err(e) => return Err(BabyError::from(e)), + }; + if !output.status.success() { + return Ok(None); + } + match helper::parse_head_branch_line(&output.stdout) { + Some(ref_name) if ref_name == "(unknown)" => Ok(None), + Some(ref_name) => Ok(Some(ref_name)), + None => Ok(None), + } + } + + pub async fn update_remote_mirror( + &self, + req: UpdateRemoteMirrorRequest, + ) -> Result { + if req.url.is_empty() { + return Err(BabyError::InvalidRemoteUrl(req.url.clone())); + } + if req.refs.is_empty() { + return Err(BabyError::Custom( + "update_remote_mirror requires at least one ref".to_string(), + )); + } + let (dir, env) = build_env(self).await?; + let mut cmd = + helper::build_push_mirror_cmd("gitbaby::remote", &dir, env, &req, req.refs.clone())?; + let output = cmd.run_soft().await.map_err(BabyError::from)?; + if output.status.success() { + return Ok(UpdateRemoteMirrorResult::default()); + } + Err(helper::classify_stderr_push(&String::from_utf8_lossy( + &output.stderr, + ))) + } +} diff --git a/src/repo.rs b/src/repo.rs new file mode 100644 index 0000000..c608d0c --- /dev/null +++ b/src/repo.rs @@ -0,0 +1,12 @@ +use std::path::PathBuf; + +use crate::error::BabyError; + +#[async_trait::async_trait] +pub trait RepositoryFacade: Send + 'static { + async fn git_repo_dir(&self) -> Result; + + async fn git_alternate_object_directories(&self) -> Result, BabyError>; + + async fn gix_repo(&self) -> Result; +} diff --git a/src/setup/helper.rs b/src/setup/helper.rs new file mode 100644 index 0000000..4aa57e7 --- /dev/null +++ b/src/setup/helper.rs @@ -0,0 +1,139 @@ +use std::path::Path; + +use crate::command::cmd::Cmd; +use crate::command::env::Env; +use crate::error::BabyError; +use crate::setup::types::FastImportCommit; + +pub fn build_init_cmd( + caller: &str, + dir: &Path, + env: Env, + path: &str, + bare: bool, + object_format: Option<&str>, +) -> Result { + crate::share::build_init_cmd(caller, dir, env, path, bare, object_format) +} + +pub fn build_fast_import_cmd(caller: &str, dir: &Path, env: Env) -> Cmd { + crate::share::build_fast_import_cmd(caller, dir, env) +} + +pub fn build_is_repository_exists_cmd(caller: &str, path: &Path, env: Env) -> Cmd { + let args = vec![ + "-C".to_string(), + path.to_string_lossy().into_owned(), + "rev-parse".to_string(), + "--is-inside-work-tree".to_string(), + ]; + crate::share::git_cmd(caller, path, env, None, args) +} + +pub fn render_fast_import_stream(commits: &[FastImportCommit]) -> Result, BabyError> { + let mut buf = Vec::new(); + for (i, c) in commits.iter().enumerate() { + validate_fast_import_field(&c.ref_name, "ref_name")?; + crate::share::path::validate_ref_name(&c.ref_name)?; + validate_fast_import_field(&c.committer_name, "committer_name")?; + if c.committer_name.contains('<') || c.committer_name.contains('>') { + return Err(BabyError::InvalidGitArg(format!( + "committer_name contains `<` or `>`: `{}`", + c.committer_name + ))); + } + validate_fast_import_field(&c.committer_email, "committer_email")?; + if c.committer_email.contains('<') || c.committer_email.contains('>') { + return Err(BabyError::InvalidGitArg(format!( + "committer_email contains `<` or `>`: `{}`", + c.committer_email + ))); + } + let msg = c + .message + .clone() + .unwrap_or_else(|| format!("commit {}", i + 1)); + if msg.contains('\0') { + return Err(BabyError::InvalidGitArg("message contains NUL".to_string())); + } + let _ = std::fmt::Write::write_fmt( + &mut FastImportWriter(&mut buf), + format_args!("reset {}\n", c.ref_name), + ); + let _ = std::fmt::Write::write_fmt( + &mut FastImportWriter(&mut buf), + format_args!( + "commit {}\nmark :{}\ncommitter {} <{}> {} {}\n", + c.ref_name, + i + 1, + c.committer_name, + c.committer_email, + c.committer_time, + format_tz(c.committer_tz_offset), + ), + ); + let _ = std::fmt::Write::write_fmt( + &mut FastImportWriter(&mut buf), + format_args!("data {}\n{}\n", msg.len(), msg), + ); + for f in &c.files { + validate_fast_import_field(&f.path, "file.path")?; + crate::share::path::validate_local_no_escape(std::path::Path::new(&f.path))?; + let mode = f.mode.unwrap_or(0o100644); + let _ = std::fmt::Write::write_fmt( + &mut FastImportWriter(&mut buf), + format_args!("M {} inline {}\n", mode, f.path), + ); + let _ = std::fmt::Write::write_fmt( + &mut FastImportWriter(&mut buf), + format_args!("data {}\n", f.content.len()), + ); + buf.extend_from_slice(&f.content); + buf.push(b'\n'); + } + } + buf.extend_from_slice(b"done\n"); + Ok(buf) +} + +fn validate_fast_import_field(value: &str, kind: &str) -> Result<(), BabyError> { + if value.is_empty() { + return Err(BabyError::InvalidGitArg(format!( + "fast-import {kind} is empty" + ))); + } + if value.starts_with('-') { + return Err(BabyError::InvalidGitArg(format!( + "fast-import {kind} starts with `-`: `{}`", + value + ))); + } + if value.contains('\n') || value.contains('\r') { + return Err(BabyError::InvalidGitArg(format!( + "fast-import {kind} contains newline: `{}`", + value + ))); + } + Ok(()) +} + +struct FastImportWriter<'a>(&'a mut Vec); + +impl std::fmt::Write for FastImportWriter<'_> { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + self.0.extend_from_slice(s.as_bytes()); + Ok(()) + } +} + +pub fn format_tz(offset_seconds: i32) -> String { + let sign = if offset_seconds < 0 { '-' } else { '+' }; + let abs = offset_seconds.unsigned_abs(); + let hh = abs / 3600; + let mm = (abs % 3600) / 60; + format!("{}{:02}{:02}", sign, hh, mm) +} + +pub fn classify_stderr_init(stderr: &str) -> String { + stderr.trim().to_string() +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs new file mode 100644 index 0000000..aff0db4 --- /dev/null +++ b/src/setup/mod.rs @@ -0,0 +1,5 @@ +pub mod helper; +pub mod types; +pub mod usecase; + +pub use types::{FastImportCommit, FastImportFile, ObjectFormat}; diff --git a/src/setup/types.rs b/src/setup/types.rs new file mode 100644 index 0000000..ae13bf0 --- /dev/null +++ b/src/setup/types.rs @@ -0,0 +1,32 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObjectFormat { + Sha1, + Sha256, +} + +impl ObjectFormat { + pub fn as_str(self) -> &'static str { + match self { + Self::Sha1 => "sha1", + Self::Sha256 => "sha256", + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct FastImportFile { + pub mode: Option, + pub path: String, + pub content: Vec, +} + +#[derive(Debug, Clone)] +pub struct FastImportCommit { + pub ref_name: String, + pub message: Option, + pub committer_name: String, + pub committer_email: String, + pub committer_time: i64, + pub committer_tz_offset: i32, + pub files: Vec, +} diff --git a/src/setup/usecase.rs b/src/setup/usecase.rs new file mode 100644 index 0000000..e5f4027 --- /dev/null +++ b/src/setup/usecase.rs @@ -0,0 +1,83 @@ +use std::path::Path; + +use crate::GitBaby; +use crate::command::env::Env; +use crate::error::BabyError; +use crate::setup::helper; +use crate::setup::types::{FastImportCommit, ObjectFormat}; + +fn empty_env() -> Env { + Env::new() +} + +impl GitBaby { + pub async fn init_repository( + &self, + path: &str, + bare: bool, + object_format: Option, + ) -> Result<(), BabyError> { + if path.is_empty() { + return Err(BabyError::Custom( + "repository path must not be empty".to_string(), + )); + } + if path.starts_with('-') { + return Err(BabyError::InvalidGitArg(format!( + "repository path starts with `-`: `{}`", + path + ))); + } + let env = empty_env(); + let of = object_format.map(|f| f.as_str()); + let mut cmd = + helper::build_init_cmd("gitbaby::setup", Path::new(path), env, path, bare, of)?; + let output = cmd.run_soft().await.map_err(BabyError::from)?; + if output.status.success() { + return Ok(()); + } + Err(BabyError::Custom(helper::classify_stderr_init( + &String::from_utf8_lossy(&output.stderr), + ))) + } + + pub async fn is_repository_exists(&self, path: &str) -> bool { + if path.is_empty() { + return false; + } + if path.starts_with('-') { + return false; + } + let env = empty_env(); + let p = Path::new(path); + let mut cmd = helper::build_is_repository_exists_cmd("gitbaby::setup", p, env); + match cmd.run().await { + Ok(output) => output.status.success(), + Err(_) => false, + } + } + + pub async fn fast_import( + &self, + dir: &Path, + commits: Vec, + ) -> Result<(), BabyError> { + if commits.is_empty() { + return Err(BabyError::FastImportFailed( + "no commits provided".to_string(), + )); + } + let env = empty_env(); + let stream = helper::render_fast_import_stream(&commits)?; + let mut cmd = helper::build_fast_import_cmd("gitbaby::setup", dir, env); + cmd.spawn().await.map_err(BabyError::from)?; + cmd.feed(&stream).await.map_err(BabyError::from)?; + let output = cmd.run_soft().await.map_err(BabyError::from)?; + if output.status.success() { + return Ok(()); + } + Err(BabyError::FastImportFailed( + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )) + } +} diff --git a/src/share/cmd.rs b/src/share/cmd.rs new file mode 100644 index 0000000..485bd04 --- /dev/null +++ b/src/share/cmd.rs @@ -0,0 +1,520 @@ +use std::io; +use std::path::Path; + +use crate::command::cmd::Cmd; +use crate::command::env::Env; +use crate::error::BabyError; + +pub fn git_cmd(caller: &str, dir: &Path, env: Env, timeout: Option, args: Vec) -> Cmd { + Cmd::new( + caller.to_string(), + "git", + args, + Vec::new(), + dir.to_path_buf(), + env, + timeout, + ) +} + +pub fn reject_starts_with_dash(value: &str, kind: &str) -> Result<(), BabyError> { + if value.starts_with('-') { + Err(BabyError::InvalidGitArg(format!( + "{} must not start with '-': `{}`", + kind, value + ))) + } else { + Ok(()) + } +} + +pub fn validate_remote_url(url: &str) -> Result<(), BabyError> { + reject_starts_with_dash(url, "remote url")?; + let lower = url.to_ascii_lowercase(); + if lower.starts_with("ext::") + || lower.starts_with("ssh::") + || lower.starts_with("git::") + || lower.starts_with("remote-") + { + return Err(BabyError::InvalidRemoteUrl(url.to_string())); + } + let has_scheme = lower.starts_with("http://") + || lower.starts_with("https://") + || lower.starts_with("ssh://") + || lower.starts_with("git://") + || lower.starts_with("file://"); + if !has_scheme { + // Allow scp-style: [user@]host:path or relative/absolute local paths + if url.contains("://") { + return Err(BabyError::InvalidRemoteUrl(url.to_string())); + } + if url.starts_with('-') { + return Err(BabyError::InvalidRemoteUrl(url.to_string())); + } + if url.contains('\0') || url.contains('\n') || url.contains('\r') { + return Err(BabyError::InvalidRemoteUrl(url.to_string())); + } + } + Ok(()) +} + +pub fn validate_revision(rev: &str) -> Result<(), BabyError> { + crate::share::path::validate_revision_non_empty(rev)?; + reject_starts_with_dash(rev, "revision")?; + if rev.contains('\0') || rev.contains('\n') || rev.contains('\r') { + return Err(BabyError::RevisionInvalid(rev.to_string())); + } + Ok(()) +} + +pub fn validate_config_key(key: &str) -> Result<(), BabyError> { + reject_starts_with_dash(key, "config key")?; + if key.is_empty() { + return Err(BabyError::InvalidGitArg("empty config key".to_string())); + } + let mut chars = key.chars(); + let first = chars.next().unwrap(); + if !(first.is_ascii_alphabetic() || first == '_') { + return Err(BabyError::InvalidGitArg(format!( + "config key must start with letter or '_': `{}`", + key + ))); + } + for ch in chars { + if !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '.' || ch == '_') { + return Err(BabyError::InvalidGitArg(format!( + "config key contains invalid character `{}` in `{}`", + ch, key + ))); + } + } + if is_dangerous_config_key(key) { + return Err(BabyError::InvalidGitArg(format!( + "refusing to set dangerous config key `{}` via this API; use managed_config_* or explicit global opt-in", + key + ))); + } + Ok(()) +} + +const DANGEROUS_CONFIG_KEY_PATTERNS: &[&str] = &[ + "core.sshcommand", + "core.sshcommand.", + "core.hookspath", + "core.hookspath.", + "core.gitproxy", + "core.gitproxy.", + "credential.helper", + "credential.helper.", + "credential.username", + "credential.password", + "credential.token", + "http.extraheader", + "http.extraheader.", + "url.", + "include.path", + "includeif.", + "init.templatedir", + "protocol.file.allow", + "protocol.ext.allow", + "diff.", + "filter.", + "gc.auto", + "gc.autodetach", + "gpg.format", + "gpg.program", + "ssh.variant", + "alias.", + "remote.", + "branch.", + "tag.", +]; + +pub fn is_dangerous_config_key(key: &str) -> bool { + let lower = key.to_ascii_lowercase(); + DANGEROUS_CONFIG_KEY_PATTERNS + .iter() + .any(|pat| lower == *pat || lower.starts_with(pat)) +} + +pub fn classify_spawn_error(prog: String, source: io::Error) -> BabyError { + BabyError::Spawn { + prog, + source, + stderr: String::new(), + } +} + +pub fn for_each_ref_format(peel: bool) -> String { + if peel { + "%(objectname) %(objecttype) %(refname) %(*objectname)".to_string() + } else { + "%(objectname) %(objecttype) %(refname)".to_string() + } +} + +pub fn build_for_each_ref_args( + patterns: &[String], + sort: Option<&str>, + limit: Option, + start_after: Option<&str>, + points_at: Option<&str>, + exclude: &[String], + peel: bool, +) -> Vec { + let mut args = vec![ + "for-each-ref".to_string(), + format!("--format={}", for_each_ref_format(peel)), + ]; + if let Some(s) = sort { + args.push("--sort".to_string()); + args.push(s.to_string()); + } + if let Some(l) = limit { + args.push("--count".to_string()); + args.push(l.to_string()); + } + if let Some(sa) = start_after { + args.push("--start-after".to_string()); + args.push(sa.to_string()); + } + if let Some(oid) = points_at { + args.push("--points-at".to_string()); + args.push(oid.to_string()); + } + for p in patterns { + args.push(p.clone()); + } + for p in exclude { + args.push("--exclude".to_string()); + args.push(p.clone()); + } + args +} + +pub enum CatFileSubcommand { + BatchCheck, + Batch, + Blob, +} + +pub fn git_cat_file_cmd( + dir: &Path, + env: Env, + oid: &gix::ObjectId, + subcommand: CatFileSubcommand, +) -> Cmd { + let mut args = vec!["cat-file".to_string()]; + match subcommand { + CatFileSubcommand::BatchCheck => { + args.push("--batch-check".to_string()); + } + CatFileSubcommand::Batch => { + args.push("--batch".to_string()); + } + CatFileSubcommand::Blob => { + args.push("blob".to_string()); + args.push("--end-of-options".to_string()); + args.push(oid.to_string()); + } + } + git_cmd("gitbaby::blob", dir, env, None, args) +} + +pub fn git_hash_object_cmd(dir: &Path, env: Env, path: Option<&str>) -> Cmd { + let mut args = vec![ + "hash-object".to_string(), + "--stdin".to_string(), + "-w".to_string(), + ]; + if let Some(p) = path { + args.push("--path".to_string()); + args.push(p.to_string()); + } + git_cmd("gitbaby::blob", dir, env, None, args) +} + +pub enum ConfigScope { + Global, + Local, +} + +pub enum ConfigOp { + Get, + Set, + SetIfAbsent, + Add, + UnsetAll, + GetAll, + GetRegexp, +} + +pub fn build_config_cmd( + caller: &str, + dir: &Path, + env: Env, + scope: ConfigScope, + op: ConfigOp, + key: &str, + value: Option<&str>, +) -> Result { + validate_config_key(key)?; + if let Some(v) = value { + reject_starts_with_dash(v, "config value")?; + if v.contains('\0') { + return Err(BabyError::InvalidGitArg(format!( + "config value contains NUL: `{}`", + v + ))); + } + } + let mut args = vec!["config".to_string()]; + if matches!(scope, ConfigScope::Global) { + args.push("--global".to_string()); + } + match op { + ConfigOp::Get => args.push("--get".to_string()), + ConfigOp::Set => {} + ConfigOp::SetIfAbsent => {} + ConfigOp::Add => args.push("--add".to_string()), + ConfigOp::UnsetAll => args.push("--unset-all".to_string()), + ConfigOp::GetAll => args.push("--get-all".to_string()), + ConfigOp::GetRegexp => args.push("--get-regexp".to_string()), + } + args.push(key.to_string()); + if let Some(v) = value + && matches!(op, ConfigOp::Set | ConfigOp::SetIfAbsent | ConfigOp::Add) + { + args.push(v.to_string()); + } + Ok(git_cmd(caller, dir, env, None, args)) +} + +pub enum ArchiveFormatArg { + Tar, + TarGz, + TarBz2, + Zip, +} + +pub fn build_archive_cmd( + caller: &str, + dir: &Path, + env: Env, + format: ArchiveFormatArg, + prefix: Option<&str>, + commit: &str, + paths: &[String], +) -> Result { + validate_revision(commit)?; + if let Some(p) = prefix { + reject_starts_with_dash(p, "archive prefix")?; + if p.contains('\0') || p.contains('\n') || p.contains('\r') { + return Err(BabyError::InvalidGitArg(format!( + "archive prefix contains control character: `{}`", + p + ))); + } + } + for p in paths { + reject_starts_with_dash(p, "archive path")?; + } + let mut args = vec!["archive".to_string()]; + match format { + ArchiveFormatArg::Tar => args.push("--format=tar".to_string()), + ArchiveFormatArg::TarGz => args.push("--format=tar.gz".to_string()), + ArchiveFormatArg::TarBz2 => args.push("--format=tar.bz2".to_string()), + ArchiveFormatArg::Zip => args.push("--format=zip".to_string()), + } + if let Some(p) = prefix { + args.push(format!("--prefix={}", p)); + } + args.push("--end-of-options".to_string()); + args.push(commit.to_string()); + if !paths.is_empty() { + args.push("--".to_string()); + for p in paths { + args.push(p.clone()); + } + } + Ok(git_cmd(caller, dir, env, None, args)) +} +pub enum RemoteOp { + Add, + Remove, + GetUrl, + Show, +} + +pub enum RemoteMirror { + Default, + Push, + Fetch, +} + +pub fn build_remote_op_cmd( + caller: &str, + dir: &std::path::Path, + env: Env, + op: RemoteOp, + name: &str, + url: Option<&str>, + mirror: RemoteMirror, +) -> Result { + reject_starts_with_dash(name, "remote name")?; + if name.is_empty() { + return Err(BabyError::InvalidGitArg("remote name is empty".to_string())); + } + if let Some(u) = url { + validate_remote_url(u)?; + } + let mut args = vec!["remote".to_string()]; + match op { + RemoteOp::Add => args.push("add".to_string()), + RemoteOp::Remove => args.push("rm".to_string()), + RemoteOp::GetUrl => args.push("get-url".to_string()), + RemoteOp::Show => args.push("show".to_string()), + } + match mirror { + RemoteMirror::Default => {} + RemoteMirror::Push => args.push("--mirror=push".to_string()), + RemoteMirror::Fetch => args.push("--mirror=fetch".to_string()), + } + args.push("--end-of-options".to_string()); + args.push(name.to_string()); + if let Some(u) = url { + args.push(u.to_string()); + } + Ok(git_cmd(caller, dir, env, None, args)) +} + +pub fn build_ls_remote_cmd( + caller: &str, + dir: &std::path::Path, + env: Env, + url: &str, + refs: &[String], +) -> Result { + validate_remote_url(url)?; + for r in refs { + reject_starts_with_dash(r, "ref pattern")?; + } + let mut args = vec![ + "ls-remote".to_string(), + "--end-of-options".to_string(), + url.to_string(), + ]; + for r in refs { + args.push(r.clone()); + } + Ok(git_cmd(caller, dir, env, None, args)) +} + +pub fn build_init_cmd( + caller: &str, + dir: &std::path::Path, + env: Env, + path: &str, + bare: bool, + object_format: Option<&str>, +) -> Result { + if path.starts_with('-') { + return Err(BabyError::InvalidGitArg(format!( + "init path must not start with '-': `{}`", + path + ))); + } + if path.contains('\0') { + return Err(BabyError::InvalidGitArg(format!( + "init path contains NUL: `{}`", + path + ))); + } + if let Some(of) = object_format { + reject_starts_with_dash(of, "object-format")?; + } + let mut args = vec!["init".to_string()]; + if bare { + args.push("--bare".to_string()); + } + if let Some(of) = object_format { + args.push(format!("--object-format={}", of)); + } + args.push("--end-of-options".to_string()); + args.push(path.to_string()); + Ok(git_cmd(caller, dir, env, None, args)) +} + +pub fn build_fast_import_cmd(caller: &str, dir: &std::path::Path, env: Env) -> Cmd { + let args = vec![ + "fast-import".to_string(), + "--force".to_string(), + "--done".to_string(), + ]; + git_cmd(caller, dir, env, None, args) +} + +pub fn build_gc_cmd(caller: &str, dir: &std::path::Path, env: Env, prune_now: bool) -> Cmd { + let mut args = vec!["gc".to_string()]; + if prune_now { + args.push("--prune=now".to_string()); + } + git_cmd(caller, dir, env, None, args) +} + +pub fn build_pack_refs_cmd(caller: &str, dir: &std::path::Path, env: Env) -> Cmd { + let args = vec!["pack-refs".to_string(), "--all".to_string()]; + git_cmd(caller, dir, env, None, args) +} + +pub fn build_fetch_cmd( + caller: &str, + dir: &std::path::Path, + env: Env, + remote_url: &str, + refspecs: &[String], + prune: bool, + tags: bool, +) -> Result { + validate_remote_url(remote_url)?; + for r in refspecs { + reject_starts_with_dash(r, "refspec")?; + } + let mut args = vec!["fetch".to_string()]; + if prune { + args.push("--prune".to_string()); + } + if !tags { + args.push("--no-tags".to_string()); + } + args.push("--end-of-options".to_string()); + args.push(remote_url.to_string()); + for r in refspecs { + args.push(r.clone()); + } + Ok(git_cmd(caller, dir, env, None, args)) +} + +pub fn build_push_cmd( + caller: &str, + dir: &std::path::Path, + env: Env, + remote_url: &str, + refspecs: &[String], + force: bool, +) -> Result { + validate_remote_url(remote_url)?; + for r in refspecs { + reject_starts_with_dash(r, "refspec")?; + } + let mut args = vec!["push".to_string()]; + if force { + args.push("--force".to_string()); + } + args.push("--end-of-options".to_string()); + args.push(remote_url.to_string()); + for r in refspecs { + args.push(r.clone()); + } + Ok(git_cmd(caller, dir, env, None, args)) +} diff --git a/src/share/env.rs b/src/share/env.rs new file mode 100644 index 0000000..5dee169 --- /dev/null +++ b/src/share/env.rs @@ -0,0 +1,80 @@ +use std::path::PathBuf; + +use crate::command::env::Env; + +/// Build an env for a git child process by **inheriting the whole process +/// environment**. +/// +/// # Security +/// +/// This forwards every current-process variable (including `GIT_SSH_COMMAND`, +/// `GIT_ASKPASS`, `LD_PRELOAD`, `*_TOKEN`, `*_SECRET`, proxies, …) into the +/// spawned `git` process. Prefer [`build_env_safe`] unless a specific variable +/// set must reach git (e.g. credential helpers you explicitly control). +pub fn build_env(alternates: &[PathBuf]) -> Env { + let mut env = Env::new().with_current_env(); + set_alternate_object_dirs(&mut env, alternates); + env +} + +/// Build an env for a git child process from an explicit allow-list of the +/// process environment plus the alternate object directories. +/// +/// Only `PATH`, `HOME`, `LANG`, `LC_ALL`, `LC_CTYPE`, `USER`, `LOGNAME`, `TZ` +/// and `SSH_AUTH_SOCK` are forwarded; everything else is dropped. This keeps +/// CI tokens, cloud credentials, proxies and `GIT_*` overrides (such as a +/// hostile `GIT_SSH_COMMAND` or `GIT_DIR` set by an attacker) out of the +/// subprocess. +pub fn build_env_safe(alternates: &[PathBuf]) -> Env { + const ALLOWED: &[&str] = &[ + "PATH", + "HOME", + "LANG", + "LC_ALL", + "LC_CTYPE", + "USER", + "LOGNAME", + "TZ", + "SSH_AUTH_SOCK", + "XDG_RUNTIME_DIR", + ]; + let mut env = Env::new(); + for (k, v) in std::env::vars() { + if ALLOWED.iter().any(|a| *a == k) { + env.set(k, v); + } + } + // Explicitly drop git-override variables that could redirect where git + // reads config/objects from or how it shells out. + for dangerous in [ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_SSH_COMMAND", + "GIT_SSH", + "GIT_ASKPASS", + "GIT_CONFIG", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_EXEC_PATH", + "GIT_TEMPLATE_DIR", + "GIT_INDEX_FILE", + "GIT_CEILING_DIRECTORIES", + "LD_PRELOAD", + "DYLD_INSERT_LIBRARIES", + ] { + env.unset(dangerous); + } + set_alternate_object_dirs(&mut env, alternates); + env +} + +pub fn set_alternate_object_dirs(env: &mut Env, alternates: &[PathBuf]) { + if !alternates.is_empty() + && let Ok(joined) = std::env::join_paths(alternates) + && let Ok(s) = joined.into_string() + { + env.set("GIT_ALTERNATE_OBJECT_DIRECTORIES", s); + } +} diff --git a/src/share/error.rs b/src/share/error.rs new file mode 100644 index 0000000..293f2bd --- /dev/null +++ b/src/share/error.rs @@ -0,0 +1,15 @@ +use std::error::Error; + +use crate::error::BabyError; + +pub fn facade_error(e: E) -> BabyError { + BabyError::Custom(format!("facade error: {}", e)) +} + +pub fn spawn_failure(prog: &str, source: std::io::Error) -> BabyError { + BabyError::Spawn { + prog: prog.to_string(), + source, + stderr: String::new(), + } +} diff --git a/src/share/mod.rs b/src/share/mod.rs new file mode 100644 index 0000000..638554f --- /dev/null +++ b/src/share/mod.rs @@ -0,0 +1,19 @@ +pub mod cmd; +pub mod env; +pub mod error; +pub mod path; + +pub use cmd::{ + ArchiveFormatArg, CatFileSubcommand, ConfigOp, ConfigScope, RemoteMirror, RemoteOp, + build_archive_cmd, build_config_cmd, build_fast_import_cmd, build_fetch_cmd, + build_for_each_ref_args, build_gc_cmd, build_init_cmd, build_ls_remote_cmd, + build_pack_refs_cmd, build_push_cmd, build_remote_op_cmd, classify_spawn_error, + for_each_ref_format, git_cat_file_cmd, git_cmd, git_hash_object_cmd, is_dangerous_config_key, + reject_starts_with_dash, validate_config_key, validate_remote_url, validate_revision, +}; +pub use env::{build_env, build_env_safe, set_alternate_object_dirs}; +pub use error::{facade_error, spawn_failure}; +pub use path::{ + parse_git_mode, parse_object_id, parse_object_map, to_string_lossy_owned, + validate_local_no_escape, validate_ref_name, validate_revision_non_empty, +}; diff --git a/src/share/path.rs b/src/share/path.rs new file mode 100644 index 0000000..2ba5653 --- /dev/null +++ b/src/share/path.rs @@ -0,0 +1,105 @@ +use std::path::{Component, Path}; + +use crate::error::BabyError; + +pub fn validate_local_no_escape(path: &Path) -> Result<(), BabyError> { + if path.as_os_str().is_empty() { + return Err(BabyError::PathEscapesRepository); + } + if let Some(s) = path.to_str() { + if s.starts_with('-') { + return Err(BabyError::PathEscapesRepository); + } + } else { + return Err(BabyError::PathEscapesRepository); + } + if path.is_absolute() { + return Err(BabyError::PathEscapesRepository); + } + for comp in path.components() { + if matches!(comp, Component::ParentDir) { + return Err(BabyError::PathEscapesRepository); + } + } + Ok(()) +} + +pub fn validate_revision_non_empty(rev: &str) -> Result<(), BabyError> { + if rev.is_empty() { + return Err(BabyError::RevisionEmpty); + } + Ok(()) +} + +pub fn validate_ref_name(name: &str) -> Result<(), BabyError> { + if name.is_empty() || name == "@" { + return Err(BabyError::InvalidRefName(name.to_string())); + } + if name.starts_with('-') { + return Err(BabyError::InvalidRefName(name.to_string())); + } + if name.starts_with('/') || name.ends_with('/') { + return Err(BabyError::InvalidRefName(name.to_string())); + } + if name.starts_with('.') || name.ends_with('.') { + return Err(BabyError::InvalidRefName(name.to_string())); + } + if name.contains("..") || name.contains("//") || name.contains("@{") { + return Err(BabyError::InvalidRefName(name.to_string())); + } + if name.contains(".lock/") || name.ends_with(".lock") { + return Err(BabyError::InvalidRefName(name.to_string())); + } + if name.contains("/.") { + return Err(BabyError::InvalidRefName(name.to_string())); + } + for ch in name.chars() { + let b = ch as u32; + if b < 0o40 || b == 0o177 { + return Err(BabyError::InvalidRefName(name.to_string())); + } + match ch { + ' ' | '~' | '^' | ':' | '?' | '*' | '[' | ']' => { + return Err(BabyError::InvalidRefName(name.to_string())); + } + _ => {} + } + } + Ok(()) +} + +pub fn to_string_lossy_owned(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +pub fn parse_git_mode(octal: &str) -> Result { + u32::from_str_radix(octal, 8) + .map_err(|_| BabyError::Custom(format!("invalid git file mode `{}`", octal))) +} + +pub fn parse_object_id(hex: &str) -> Result { + gix::ObjectId::from_hex(hex.as_bytes()) + .map_err(|source| BabyError::Custom(format!("invalid object id `{}`: {}", hex, source))) +} +pub fn parse_object_map(content: &str) -> Result, BabyError> { + let mut pairs = Vec::new(); + for raw_line in content.lines() { + let line = raw_line.trim(); + if line.is_empty() + || line.starts_with('#') + || line.starts_with("old new") + { + continue; + } + let (old_hex, new_hex) = line + .split_once(' ') + .ok_or_else(|| BabyError::SubModuleParse { + line: 0, + reason: format!("object map line missing space: {}", line), + })?; + let old = parse_object_id(old_hex)?; + let new = parse_object_id(new_hex)?; + pairs.push((old, new)); + } + Ok(pairs) +} diff --git a/src/submodule/helper.rs b/src/submodule/helper.rs new file mode 100644 index 0000000..082bc5a --- /dev/null +++ b/src/submodule/helper.rs @@ -0,0 +1,98 @@ +use std::path::Path; + +use crate::command::env::Env; +use crate::error::BabyError; +use crate::submodule::types::SubModule; + +pub async fn fetch_gitmodules_blob( + read_blob_at_path: impl FnOnce(&str) -> Result, BabyError>, +) -> Result>, BabyError> { + match read_blob_at_path(".gitmodules") { + Ok(bytes) => Ok(Some(bytes)), + Err(BabyError::BlobNotFound { .. }) => Ok(None), + Err(e) => Err(e), + } +} + +pub fn parse_blob_to_submodules(bytes: &[u8]) -> Result, BabyError> { + let text = std::str::from_utf8(bytes).map_err(|e| BabyError::SubModuleParse { + line: 0, + reason: format!("invalid UTF-8: {}", e), + })?; + parse_gitmodules(text) +} + +pub fn parse_gitmodules(content: &str) -> Result, BabyError> { + let mut subs: Vec = Vec::new(); + let mut current: Option = None; + for (idx, raw_line) in content.lines().enumerate() { + let line_no = idx as u64 + 1; + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if line.starts_with('[') { + if let Some(s) = current.take() { + subs.push(s); + } + let end_pos = line.rfind(']').ok_or_else(|| BabyError::SubModuleParse { + line: line_no, + reason: format!("unterminated section header `{}`", line), + })?; + let inside = &line[1..end_pos]; + let header = inside + .split(' ') + .next() + .ok_or_else(|| BabyError::SubModuleParse { + line: line_no, + reason: format!("malformed section header `{}`", line), + })?; + if header != "submodule" { + current = None; + continue; + } + let rest = inside + .strip_prefix("submodule") + .map(|r| r.trim_start()) + .ok_or_else(|| BabyError::SubModuleParse { + line: line_no, + reason: format!("malformed submodule header `{}`", line), + })?; + let name = rest.trim().trim_matches('"').trim_matches('\'').to_string(); + current = Some(SubModule { + name, + path: String::new(), + url: String::new(), + branch: String::new(), + }); + continue; + } + let Some(ref mut s) = current else { + continue; + }; + let (k, v) = line + .split_once('=') + .ok_or_else(|| BabyError::SubModuleParse { + line: line_no, + reason: format!("expected `key = value`, got `{}`", line), + })?; + let key = k.trim(); + let value = v.trim().trim_matches('"').trim_matches('\'').to_string(); + match key { + "path" => s.path = value, + "url" => s.url = value, + "branch" => s.branch = value, + _ => {} + } + } + if let Some(s) = current.take() { + subs.push(s); + } + Ok(subs) +} + +pub fn lookup<'a>(modules: &'a [SubModule], name: &str) -> Option<&'a SubModule> { + modules.iter().find(|s| s.name == name) +} + +pub fn _unused_dir_marker(_dir: &Path, _env: &Env) {} diff --git a/src/submodule/mod.rs b/src/submodule/mod.rs new file mode 100644 index 0000000..5ed302e --- /dev/null +++ b/src/submodule/mod.rs @@ -0,0 +1,5 @@ +pub mod helper; +pub mod types; +pub mod usecase; + +pub use types::SubModule; diff --git a/src/submodule/types.rs b/src/submodule/types.rs new file mode 100644 index 0000000..554056e --- /dev/null +++ b/src/submodule/types.rs @@ -0,0 +1,7 @@ +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SubModule { + pub name: String, + pub path: String, + pub url: String, + pub branch: String, +} diff --git a/src/submodule/usecase.rs b/src/submodule/usecase.rs new file mode 100644 index 0000000..42c0035 --- /dev/null +++ b/src/submodule/usecase.rs @@ -0,0 +1,95 @@ +use std::path::Path; + +use crate::GitBaby; +use crate::error::BabyError; +use crate::share; +use crate::submodule::helper; +use crate::submodule::types::SubModule; + +async fn read_blob_at_revision( + gitbaby: &GitBaby, + commit: &str, + blob_path: &str, +) -> Result>, BabyError> { + share::validate_revision(commit)?; + share::validate_local_no_escape(Path::new(blob_path))?; + let dir = gitbaby + .facade + .git_repo_dir() + .await + .map_err(share::facade_error)?; + let alternates = gitbaby + .facade + .git_alternate_object_directories() + .await + .map_err(share::facade_error)?; + let env = share::build_env_safe(&alternates); + let args = vec![ + "cat-file".to_string(), + "blob".to_string(), + "--end-of-options".to_string(), + format!("{}:{}", commit, blob_path), + ]; + let mut cmd = share::git_cmd("gitbaby::submodule", &dir, env, None, args); + match cmd.run().await { + Ok(output) => { + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("not a tree") + || stderr.contains("does not exist") + || stderr.contains("bad revision") + || stderr.contains("could not read") + { + return Ok(None); + } + return Err(BabyError::SubModuleParse { + line: 0, + reason: format!("cat-file blob failed: {}", stderr.trim()), + }); + } + Ok(Some(output.stdout)) + } + Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => { + if stderr.contains("not a tree") + || stderr.contains("does not exist") + || stderr.contains("bad revision") + || stderr.contains("could not read") + { + Ok(None) + } else { + Err(BabyError::SubModuleParse { + line: 0, + reason: format!("cat-file blob failed: {}", stderr.trim()), + }) + } + } + Err(e) => Err(BabyError::from(e)), + } +} + +impl GitBaby { + pub async fn list_submodules(&self, commit: &str) -> Result, BabyError> { + share::validate_revision_non_empty(commit)?; + match read_blob_at_revision(self, commit, ".gitmodules").await? { + Some(bytes) => helper::parse_blob_to_submodules(&bytes), + None => Ok(Vec::new()), + } + } + + pub async fn get_submodule( + &self, + commit: &str, + name: &str, + ) -> Result, BabyError> { + share::validate_revision_non_empty(commit)?; + if name.is_empty() { + return Err(BabyError::Custom( + "submodule name must not be empty".to_string(), + )); + } + let subs = self.list_submodules(commit).await?; + Ok(helper::lookup(&subs, name).cloned()) + } +} + +fn _unused(_p: &Path) {} diff --git a/src/tags/helper.rs b/src/tags/helper.rs new file mode 100644 index 0000000..2f3c7bd --- /dev/null +++ b/src/tags/helper.rs @@ -0,0 +1,269 @@ +use std::path::Path; + +use crate::command::cmd::Cmd; +use crate::command::env::Env; +use crate::error::BabyError; +use crate::share; + +use super::types::{ + CreateTagOptions, ListTagsOptions, SigningFormat, SigningKey, TagType, TagVerifyResult, +}; + +pub const CALLER: &str = "gitbaby::tags"; + +pub fn validate_list_tags_options(opts: &ListTagsOptions) -> Result<(), BabyError> { + if opts.patterns.is_empty() { + return Err(BabyError::InvalidTagName( + "patterns must have at least one entry".to_string(), + )); + } + for p in &opts.patterns { + if !p.starts_with("refs/tags/") { + return Err(BabyError::InvalidTagName(p.clone())); + } + share::validate_ref_name(p)?; + } + Ok(()) +} + +pub fn validate_create_tag_options(opts: &CreateTagOptions) -> Result<(), BabyError> { + if opts.name.is_empty() { + return Err(BabyError::InvalidTagName(opts.name.clone())); + } + share::validate_ref_name(&format!("refs/tags/{}", opts.name))?; + if opts.target.is_empty() { + return Err(BabyError::RevisionEmpty); + } + Ok(()) +} + +pub fn build_list_tags_cmd(dir: &Path, env: Env, opts: &ListTagsOptions) -> Cmd { + let args = share::build_for_each_ref_args( + &opts.patterns, + Some("creatordate"), + None, + None, + None, + &[], + true, + ); + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_get_tag_id_cmd(dir: &Path, env: Env, name: &str) -> Cmd { + share::git_cmd( + CALLER, + dir, + env, + None, + vec![ + "show-ref".to_string(), + "--tags".to_string(), + "--".to_string(), + name.to_string(), + ], + ) +} + +pub fn build_cat_tag_cmd(dir: &Path, env: Env, oid: &str) -> Cmd { + share::git_cmd( + CALLER, + dir, + env, + None, + vec!["cat-file".to_string(), "-p".to_string(), oid.to_string()], + ) +} + +pub fn build_create_lightweight_tag_cmd(dir: &Path, env: Env, opts: &CreateTagOptions) -> Cmd { + share::git_cmd( + CALLER, + dir, + env, + None, + vec!["tag".to_string(), opts.name.clone(), opts.target.clone()], + ) +} + +pub fn build_create_annotated_tag_cmd(dir: &Path, env: Env, opts: &CreateTagOptions) -> Cmd { + let msg = opts.message.clone().unwrap_or_default(); + share::git_cmd( + CALLER, + dir, + env, + None, + vec![ + "tag".to_string(), + "-a".to_string(), + "-m".to_string(), + msg, + opts.name.clone(), + opts.target.clone(), + ], + ) +} + +pub fn build_create_signed_tag_cmd( + dir: &Path, + env: Env, + opts: &CreateTagOptions, + key: &SigningKey, +) -> Cmd { + let (flag, key_arg) = match key.format { + SigningFormat::Ssh => ("-s".to_string(), None), + _ => ("-u".to_string(), Some(key.key_id.clone())), + }; + let msg = opts.message.clone().unwrap_or_default(); + let mut args = vec![ + "tag".to_string(), + flag, + "-m".to_string(), + msg, + opts.name.clone(), + opts.target.clone(), + ]; + if let Some(ka) = key_arg { + args.insert(3, ka); + } + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_verify_tag_cmd(dir: &Path, env: Env, name: &str) -> Cmd { + share::git_cmd( + CALLER, + dir, + env, + None, + vec![ + "verify-tag".to_string(), + "--raw".to_string(), + name.to_string(), + ], + ) +} + +pub fn build_delete_tag_cmd(dir: &Path, env: Env, name: &str) -> Cmd { + share::git_cmd( + CALLER, + dir, + env, + None, + vec!["tag".to_string(), "-d".to_string(), name.to_string()], + ) +} + +pub fn classify_stderr(stderr: &str, name: &str) -> BabyError { + let s = stderr.trim(); + if s.contains("already exists") { + return BabyError::TagAlreadyExists(name.to_string()); + } + if s.contains("not found") || s.contains("does not exist") { + return BabyError::TagNotFound(name.to_string()); + } + if s.contains("invalid") && (s.contains("tag") || s.contains("key")) { + return BabyError::InvalidTagName(name.to_string()); + } + BabyError::Spawn { + prog: "git".to_string(), + source: std::io::Error::other(s.to_string()), + stderr: s.to_string(), + } +} + +pub fn classify_cmd_error(err: crate::command::error::CmdError, name: &str) -> BabyError { + use crate::command::error::CmdError; + match err { + CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, name), + CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source), + other => BabyError::from(other), + } +} + +pub fn parse_show_ref_tag(output: &str) -> Result<(String, String), BabyError> { + for line in output.lines() { + let s = line.trim(); + if s.is_empty() { + continue; + } + let mut parts = s.split_whitespace(); + let oid = match parts.next() { + Some(s) => s.to_string(), + None => continue, + }; + let full = match parts.next() { + Some(s) => s.to_string(), + None => continue, + }; + if full == "refs/tags/^{}" || full.ends_with("/^{}") { + continue; + } + if full.starts_with("refs/tags/") { + return Ok((oid, full)); + } + } + Err(BabyError::TagNotFound(output.trim().to_string())) +} + +pub fn parse_cat_tag_object(output: &str) -> (TagType, Option) { + let mut tag_type = TagType::Lightweight; + let mut target: Option = None; + for line in output.lines() { + if let Some(rest) = line.strip_prefix("type ") { + if rest.trim() == "tag" { + tag_type = TagType::Annotated; + } + } else if let Some(rest) = line.strip_prefix("object ") + && let Ok(oid) = gix::ObjectId::from_hex(rest.trim().as_bytes()) + { + target = Some(oid); + } + } + (tag_type, target) +} + +pub fn parse_verify_tag_output(output: &str, stderr: &str) -> TagVerifyResult { + let combined = format!("{}\n{}", output, stderr); + let mut valid = false; + let mut signer: Option = None; + let mut fingerprint: Option = None; + let mut error_message: Option = None; + + if let Some(idx) = combined.find("error") { + let line_end = combined[idx..].find('\n').unwrap_or(combined.len() - idx); + error_message = Some(combined[idx..idx + line_end].trim().to_string()); + } + + for line in combined.lines() { + if line.starts_with("good ") || line.starts_with("valid ") { + valid = true; + } + if let Some(idx) = line.find("signer \"") { + let rest = &line[idx + 8..]; + if let Some(end) = rest.find('"') { + signer = Some(rest[..end].to_string()); + } + } + if let Some(idx) = line.find("fingerprint ") { + let rest = &line[idx + 12..]; + let trimmed = rest.trim(); + if !trimmed.is_empty() { + fingerprint = Some( + trimmed + .split_whitespace() + .next() + .unwrap_or(trimmed) + .to_string(), + ); + } + } + } + + TagVerifyResult { + valid, + signer, + fingerprint, + error_message, + } +} + +use gix::ObjectId; diff --git a/src/tags/mod.rs b/src/tags/mod.rs new file mode 100644 index 0000000..2de649a --- /dev/null +++ b/src/tags/mod.rs @@ -0,0 +1,7 @@ +pub mod helper; +pub mod types; +pub mod usecase; + +pub use types::{ + CreateTagOptions, ListTagsOptions, SigningFormat, SigningKey, TagInfo, TagType, TagVerifyResult, +}; diff --git a/src/tags/types.rs b/src/tags/types.rs new file mode 100644 index 0000000..61030d2 --- /dev/null +++ b/src/tags/types.rs @@ -0,0 +1,50 @@ +use gix::ObjectId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SigningFormat { + Ssh, + OpenGpg, + X509, +} + +#[derive(Debug, Clone)] +pub struct SigningKey { + pub format: SigningFormat, + pub key_id: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TagType { + Lightweight, + Annotated, +} + +#[derive(Debug, Clone)] +pub struct TagInfo { + pub name: String, + pub sha: ObjectId, + pub target_sha: ObjectId, + pub tag_type: TagType, + pub tagger: Option, + pub message: Option, +} + +#[derive(Debug, Clone)] +pub struct CreateTagOptions { + pub name: String, + pub target: String, + pub message: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct ListTagsOptions { + pub patterns: Vec, +} + +#[derive(Debug, Clone)] +pub struct TagVerifyResult { + pub valid: bool, + pub signer: Option, + pub fingerprint: Option, + pub error_message: Option, +} diff --git a/src/tags/usecase.rs b/src/tags/usecase.rs new file mode 100644 index 0000000..ed6b5eb --- /dev/null +++ b/src/tags/usecase.rs @@ -0,0 +1,253 @@ +use crate::GitBaby; +use crate::commit::helper as commit_helper; +use crate::commit::types::Signature; +use crate::error::BabyError; +use crate::refs::types::{ListRefsOptions, RefType}; +use crate::share; +use crate::tags::helper; +use crate::tags::types::{ + CreateTagOptions, ListTagsOptions, SigningKey, TagInfo, TagType, TagVerifyResult, +}; + +impl GitBaby { + pub async fn list_tags(&self, opts: ListTagsOptions) -> Result, BabyError> { + helper::validate_list_tags_options(&opts)?; + let refs_opts = ListRefsOptions { + patterns: opts.patterns.clone(), + peel_tags: true, + ..Default::default() + }; + let refs = self.list_refs(refs_opts).await?; + let mut tags = Vec::new(); + for r in refs { + if !matches!(r.ref_type, RefType::Tag) { + continue; + } + let peeled = r.peeled.unwrap_or(r.sha); + tags.push(TagInfo { + name: r.name, + sha: r.sha, + target_sha: peeled, + tag_type: if r.peeled.is_some() { + TagType::Annotated + } else { + TagType::Lightweight + }, + tagger: None, + message: None, + }); + } + Ok(tags) + } + + pub async fn find_tag(&self, name: &str) -> Result { + share::validate_ref_name(&format!("refs/tags/{}", name))?; + let mut cmd = self + .spawn_tags_cmd(|dir, env| helper::build_get_tag_id_cmd(dir, env, name)) + .await?; + let output = match cmd.run().await { + Ok(o) => o, + Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => { + return Err(helper::classify_stderr(&stderr, name)); + } + Err(e) => return Err(helper::classify_cmd_error(e, name)), + }; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + name, + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let (oid_str, full_name) = helper::parse_show_ref_tag(&stdout)?; + let oid = gix::ObjectId::from_hex(oid_str.as_bytes()) + .map_err(|e| BabyError::Custom(format!("tag oid parse: {}", e)))?; + let mut cmd2 = self + .spawn_tags_cmd(|dir, env| helper::build_cat_tag_cmd(dir, env, &oid_str)) + .await?; + let cat_output = cmd2 + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, name))?; + let cat_stdout = String::from_utf8_lossy(&cat_output.stdout); + let (tag_type, target) = helper::parse_cat_tag_object(&cat_stdout); + let target_sha = target + .ok_or_else(|| BabyError::Custom(format!("tag `{}` has no target object", name)))?; + let (tagger, message) = parse_annotated_payload(&cat_stdout); + Ok(TagInfo { + name: full_name + .strip_prefix("refs/tags/") + .unwrap_or(&full_name) + .trim_end_matches("^{}") + .to_string(), + sha: oid, + target_sha, + tag_type, + tagger, + message, + }) + } + + pub async fn tag_exists(&self, name: &str) -> Result { + share::validate_ref_name(&format!("refs/tags/{}", name))?; + let mut cmd = self + .spawn_tags_cmd(|dir, env| helper::build_get_tag_id_cmd(dir, env, name)) + .await?; + match cmd.run().await { + Ok(out) => Ok(out.status.success()), + Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => { + let s = stderr.trim(); + if s.contains("not found") || s.is_empty() { + Ok(false) + } else { + Err(helper::classify_stderr(s, name)) + } + } + Err(e) => Err(helper::classify_cmd_error(e, name)), + } + } + + pub async fn create_lightweight_tag(&self, opts: CreateTagOptions) -> Result<(), BabyError> { + helper::validate_create_tag_options(&opts)?; + let mut cmd = self + .spawn_tags_cmd(|dir, env| helper::build_create_lightweight_tag_cmd(dir, env, &opts)) + .await?; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, &opts.name))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + &opts.name, + )); + } + Ok(()) + } + + pub async fn create_annotated_tag(&self, opts: CreateTagOptions) -> Result<(), BabyError> { + helper::validate_create_tag_options(&opts)?; + if opts.message.is_none() { + return Err(BabyError::Custom( + "annotated tag requires message".to_string(), + )); + } + let mut cmd = self + .spawn_tags_cmd(|dir, env| helper::build_create_annotated_tag_cmd(dir, env, &opts)) + .await?; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, &opts.name))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + &opts.name, + )); + } + Ok(()) + } + + pub async fn create_signed_tag( + &self, + opts: CreateTagOptions, + key: SigningKey, + ) -> Result<(), BabyError> { + helper::validate_create_tag_options(&opts)?; + if opts.message.is_none() { + return Err(BabyError::Custom("signed tag requires message".to_string())); + } + let mut cmd = self + .spawn_tags_cmd(|dir, env| helper::build_create_signed_tag_cmd(dir, env, &opts, &key)) + .await?; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, &opts.name))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + &opts.name, + )); + } + Ok(()) + } + + pub async fn verify_tag_signature(&self, name: &str) -> Result { + share::validate_ref_name(&format!("refs/tags/{}", name))?; + let mut cmd = self + .spawn_tags_cmd(|dir, env| helper::build_verify_tag_cmd(dir, env, name)) + .await?; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, name))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let result = helper::parse_verify_tag_output(&stdout, &stderr); + Ok(result) + } + + pub async fn delete_tag(&self, name: &str) -> Result<(), BabyError> { + share::validate_ref_name(&format!("refs/tags/{}", name))?; + let mut cmd = self + .spawn_tags_cmd(|dir, env| helper::build_delete_tag_cmd(dir, env, name)) + .await?; + let output = cmd + .run() + .await + .map_err(|e| helper::classify_cmd_error(e, name))?; + if !output.status.success() { + return Err(helper::classify_stderr( + &String::from_utf8_lossy(&output.stderr), + name, + )); + } + Ok(()) + } + + async fn spawn_tags_cmd(&self, builder: F) -> Result + where + F: FnOnce(&std::path::Path, crate::command::env::Env) -> crate::command::cmd::Cmd, + { + let dir = self + .facade + .git_repo_dir() + .await + .map_err(share::facade_error)?; + let alternates = self + .facade + .git_alternate_object_directories() + .await + .map_err(share::facade_error)?; + let env = share::build_env_safe(&alternates); + Ok(builder(&dir, env)) + } +} + +fn parse_annotated_payload(s: &str) -> (Option, Option) { + let mut tagger: Option = None; + let mut message: Option = None; + let mut in_message = false; + let mut msg_buf = String::new(); + for line in s.lines() { + if in_message { + msg_buf.push_str(line); + msg_buf.push('\n'); + continue; + } + if line.is_empty() { + in_message = true; + continue; + } + if let Some(rest) = line.strip_prefix("tagger ") + && let Ok(sig) = commit_helper::parse_signature_line(rest) + { + tagger = Some(sig); + } + } + if !msg_buf.is_empty() { + message = Some(msg_buf.trim_end().to_string()); + } + (tagger, message) +} diff --git a/src/tree/helper.rs b/src/tree/helper.rs new file mode 100644 index 0000000..7f7b539 --- /dev/null +++ b/src/tree/helper.rs @@ -0,0 +1,253 @@ +use std::path::{Path, PathBuf}; + +use crate::command::cmd::Cmd; +use crate::command::env::Env; +use crate::commit::types::TreeEntryMode; +use crate::error::BabyError; +use crate::share; +use crate::tree::types::{DiffTreesOptions, TreeDiff, TreeDiffStatus, WalkTreeOptions}; + +pub const CALLER: &str = "gitbaby::tree"; + +pub const EMPTY_TREE_OID_HEX: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + +pub fn empty_tree_oid() -> Result { + gix::ObjectId::from_hex(EMPTY_TREE_OID_HEX.as_bytes()) + .map_err(|e| BabyError::Custom(format!("empty tree oid parse: {}", e))) +} + +pub fn build_walk_tree_cmd( + dir: &Path, + env: Env, + revision: &str, + path: &Path, + opts: &WalkTreeOptions, +) -> Cmd { + let mut args = vec!["ls-tree".to_string()]; + if opts.recurse { + args.push("-r".to_string()); + } + if opts.show_trees { + args.push("-t".to_string()); + } + args.push("-l".to_string()); + args.push(revision.to_string()); + args.push("--".to_string()); + args.push(share::to_string_lossy_owned(path)); + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_diff_trees_cmd( + dir: &Path, + env: Env, + old_rev: &str, + new_rev: &str, + path: &Path, + opts: &DiffTreesOptions, +) -> Cmd { + let mut args = vec!["diff-tree".to_string()]; + if opts.recurse { + args.push("-r".to_string()); + } + if opts.detect_renames { + args.push("-M".to_string()); + } + if opts.detect_copies { + args.push("-C".to_string()); + } + args.push("--no-commit-id".to_string()); + args.push("-z".to_string()); + args.push("--diff-filter=ADMRT".to_string()); + args.push(format!("{}..{}", old_rev, new_rev)); + args.push("--".to_string()); + args.push(share::to_string_lossy_owned(path)); + share::git_cmd(CALLER, dir, env, None, args) +} + +pub fn build_commit_tree_cmd( + dir: &Path, + env: Env, + tree_oid: &str, + parents: &[String], + signing_key_id: Option<&str>, + gpg_format: Option<&str>, + no_gpg_sign: bool, +) -> Result { + share::validate_revision(tree_oid)?; + for p in parents { + share::validate_revision(p)?; + } + let mut args = vec![ + "commit-tree".to_string(), + "--end-of-options".to_string(), + tree_oid.to_string(), + ]; + for p in parents { + args.push("-p".to_string()); + args.push(p.clone()); + } + if let Some(key_id) = signing_key_id { + share::reject_starts_with_dash(key_id, "signing key id")?; + if !key_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/' | '=')) + { + return Err(BabyError::InvalidGitArg(format!( + "signing key id contains invalid characters: `{}`", + key_id + ))); + } + if let Some(format) = gpg_format { + share::reject_starts_with_dash(format, "gpg format")?; + match format { + "openpgp" | "x509" | "ssh" => { + args.push("-c".to_string()); + args.push(format!("gpg.format={}", format)); + } + _ => { + return Err(BabyError::InvalidGitArg(format!( + "unsupported gpg format `{}` (expected openpgp|x509|ssh)", + format + ))); + } + } + } + args.push(format!("-S{}", key_id)); + } + if no_gpg_sign { + args.push("--no-gpg-sign".to_string()); + } + Ok(share::git_cmd(CALLER, dir, env, None, args)) +} + +pub fn build_rev_list_latest_cmd(dir: &Path, env: Env, revision: &str, path: &Path) -> Cmd { + share::git_cmd( + CALLER, + dir, + env, + None, + vec![ + "rev-list".to_string(), + "-1".to_string(), + revision.to_string(), + "--".to_string(), + share::to_string_lossy_owned(path), + ], + ) +} + +pub fn classify_stderr(stderr: &str) -> BabyError { + let s = stderr.trim(); + if s.contains("Not a tree") || s.contains("not a tree") { + return BabyError::NotATree { + revision: s.to_string(), + path: PathBuf::new(), + }; + } + BabyError::Spawn { + prog: "git".to_string(), + source: std::io::Error::other(s.to_string()), + stderr: s.to_string(), + } +} + +pub fn classify_cmd_error(err: crate::command::error::CmdError) -> BabyError { + use crate::command::error::CmdError; + match err { + CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr), + CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source), + other => BabyError::from(other), + } +} + +pub fn parse_rev_list_first(stdout: &[u8]) -> Result { + let s = std::str::from_utf8(stdout) + .map_err(|e| BabyError::Custom(format!("rev-list invalid utf8: {}", e)))?; + let hex = s.split_whitespace().next().unwrap_or(""); + gix::ObjectId::from_hex(hex.as_bytes()) + .map_err(|e| BabyError::Custom(format!("rev-list oid parse `{}`: {}", hex, e))) +} + +pub fn parse_commit_tree_oid(stdout: &[u8]) -> Result { + let s = std::str::from_utf8(stdout) + .map_err(|e| BabyError::Custom(format!("commit-tree invalid utf8: {}", e)))?; + gix::ObjectId::from_hex(s.trim().as_bytes()) + .map_err(|e| BabyError::Custom(format!("commit-tree oid parse `{}`: {}", s.trim(), e))) +} + +pub fn parse_mode(s: &str) -> Option { + if s.is_empty() || s == "0" { + return None; + } + TreeEntryMode::from_octal_str(s) +} + +pub fn parse_diff_tree_z_output(stdout: &[u8]) -> Result, BabyError> { + let mut out = Vec::new(); + let mut iter = stdout.split(|&b| b == 0); + while let Some(header) = iter.next() { + if header.is_empty() { + continue; + } + let header_str = match std::str::from_utf8(header) { + Ok(s) => s, + Err(_) => continue, + }; + let mut parts = header_str.split(' '); + let old_mode = parts.next().unwrap_or(""); + let new_mode = parts.next().unwrap_or(""); + let old_oid_str = parts.next().unwrap_or(""); + let new_oid_str = parts.next().unwrap_or(""); + let status_byte = parts.next().unwrap_or(""); + let old_oid = + if old_oid_str.len() >= 7 && old_oid_str.chars().all(|c| c.is_ascii_hexdigit()) { + gix::ObjectId::from_hex(old_oid_str.as_bytes()).ok() + } else { + None + }; + let new_oid = + if new_oid_str.len() >= 7 && new_oid_str.chars().all(|c| c.is_ascii_hexdigit()) { + gix::ObjectId::from_hex(new_oid_str.as_bytes()).ok() + } else { + None + }; + let path_bytes = match iter.next() { + Some(p) => p, + None => break, + }; + let old_path = PathBuf::from(String::from_utf8_lossy(path_bytes).into_owned()); + let new_path = match status_byte.chars().next().unwrap_or('M') { + 'R' | 'C' => match iter.next() { + Some(p) => PathBuf::from(String::from_utf8_lossy(p).into_owned()), + None => old_path.clone(), + }, + _ => old_path.clone(), + }; + let status = match status_byte.chars().next().unwrap_or('M') { + 'A' => TreeDiffStatus::Added, + 'D' => TreeDiffStatus::Deleted, + 'R' => TreeDiffStatus::Renamed, + 'C' => TreeDiffStatus::Copied, + 'T' => TreeDiffStatus::TypeChanged, + _ => TreeDiffStatus::Modified, + }; + out.push(TreeDiff { + old_path: if status == TreeDiffStatus::Added { + None + } else { + Some(old_path.clone()) + }, + new_path: if status == TreeDiffStatus::Deleted { + None + } else { + Some(new_path) + }, + old_mode: parse_mode(old_mode), + new_mode: parse_mode(new_mode), + old_oid, + new_oid, + status, + }); + } + Ok(out) +} diff --git a/src/tree/mod.rs b/src/tree/mod.rs new file mode 100644 index 0000000..b3392c5 --- /dev/null +++ b/src/tree/mod.rs @@ -0,0 +1,5 @@ +pub mod helper; +pub mod types; +pub mod usecase; + +pub use types::{DiffTreesOptions, TreeDiff, TreeDiffStatus, WalkTreeOptions}; diff --git a/src/tree/types.rs b/src/tree/types.rs new file mode 100644 index 0000000..9065937 --- /dev/null +++ b/src/tree/types.rs @@ -0,0 +1,38 @@ +use gix::ObjectId; +use std::path::PathBuf; + +use crate::commit::types::TreeEntryMode; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TreeDiffStatus { + Added, + Deleted, + Modified, + TypeChanged, + Renamed, + Copied, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TreeDiff { + pub old_path: Option, + pub new_path: Option, + pub old_mode: Option, + pub new_mode: Option, + pub old_oid: Option, + pub new_oid: Option, + pub status: TreeDiffStatus, +} + +#[derive(Debug, Clone, Default)] +pub struct WalkTreeOptions { + pub recurse: bool, + pub show_trees: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct DiffTreesOptions { + pub recurse: bool, + pub detect_renames: bool, + pub detect_copies: bool, +} diff --git a/src/tree/usecase.rs b/src/tree/usecase.rs new file mode 100644 index 0000000..b211e73 --- /dev/null +++ b/src/tree/usecase.rs @@ -0,0 +1,192 @@ +use std::path::Path; + +use gix::ObjectId; + +use crate::GitBaby; +use crate::commit::types::{CommitInfo, Signature, TreeEntry}; +use crate::error::BabyError; +use crate::share; +use crate::tree::helper; +use crate::tree::types::{DiffTreesOptions, TreeDiff, WalkTreeOptions}; + +impl GitBaby { + pub async fn tree_latest_commit( + &self, + revision: &str, + path: &Path, + ) -> Result { + share::validate_revision_non_empty(revision)?; + share::validate_local_no_escape(path)?; + let mut cmd = self + .spawn_tree_cmd(|dir, env| { + Ok(helper::build_rev_list_latest_cmd(dir, env, revision, path)) + }) + .await?; + let output = cmd.run().await.map_err(helper::classify_cmd_error)?; + if !output.status.success() { + return Err(helper::classify_stderr(&String::from_utf8_lossy( + &output.stderr, + ))); + } + let oid = helper::parse_rev_list_first(&output.stdout)?; + self.get_commit(&oid.to_string()).await + } + + #[allow(clippy::too_many_arguments)] + pub async fn commit_tree( + &self, + tree_oid: ObjectId, + parents: Vec, + author: &Signature, + committer: &Signature, + message: &str, + signing_key_id: Option<&str>, + gpg_format: Option<&str>, + no_gpg_sign: bool, + ) -> Result { + let mut msg_buf = Vec::new(); + msg_buf.extend_from_slice(message.as_bytes()); + if !msg_buf.ends_with(b"\n") { + msg_buf.push(b'\n'); + } + let mut cmd = self + .spawn_tree_cmd(|dir, env| { + let env = set_commit_env(env, author, committer); + helper::build_commit_tree_cmd( + dir, + env, + &tree_oid.to_string(), + &parents, + signing_key_id, + gpg_format, + no_gpg_sign, + ) + }) + .await?; + if let Err(e) = cmd.feed(&msg_buf).await { + return Err(helper::classify_cmd_error(e)); + } + let output = cmd.run().await.map_err(helper::classify_cmd_error)?; + if !output.status.success() { + return Err(helper::classify_stderr(&String::from_utf8_lossy( + &output.stderr, + ))); + } + helper::parse_commit_tree_oid(&output.stdout) + } + + pub fn empty_tree_oid(&self) -> Result { + helper::empty_tree_oid() + } + + pub async fn walk_tree( + &self, + revision: &str, + path: &Path, + opts: WalkTreeOptions, + ) -> Result, BabyError> { + share::validate_revision_non_empty(revision)?; + share::validate_local_no_escape(path)?; + let mut cmd = self + .spawn_tree_cmd(|dir, env| { + Ok(helper::build_walk_tree_cmd(dir, env, revision, path, &opts)) + }) + .await?; + let output = cmd.run().await.map_err(helper::classify_cmd_error)?; + if !output.status.success() { + return Err(helper::classify_stderr(&String::from_utf8_lossy( + &output.stderr, + ))); + } + crate::commit::helper::parse_tree_entries(&output.stdout) + } + + pub async fn diff_trees( + &self, + old_rev: &str, + new_rev: &str, + path: &Path, + opts: DiffTreesOptions, + ) -> Result, BabyError> { + share::validate_revision_non_empty(old_rev)?; + share::validate_revision_non_empty(new_rev)?; + share::validate_local_no_escape(path)?; + let mut cmd = self + .spawn_tree_cmd(|dir, env| { + Ok(helper::build_diff_trees_cmd( + dir, env, old_rev, new_rev, path, &opts, + )) + }) + .await?; + let output = cmd.run().await.map_err(helper::classify_cmd_error)?; + if !output.status.success() { + return Err(helper::classify_stderr(&String::from_utf8_lossy( + &output.stderr, + ))); + } + helper::parse_diff_tree_z_output(&output.stdout) + } + + async fn spawn_tree_cmd(&self, builder: F) -> Result + where + F: FnOnce( + &std::path::Path, + crate::command::env::Env, + ) -> Result, + { + let dir = self + .facade + .git_repo_dir() + .await + .map_err(share::facade_error)?; + let alternates = self + .facade + .git_alternate_object_directories() + .await + .map_err(share::facade_error)?; + let env = share::build_env_safe(&alternates); + builder(&dir, env) + } +} + +fn set_commit_env( + mut env: crate::command::env::Env, + author: &Signature, + committer: &Signature, +) -> crate::command::env::Env { + let now_str = format_iso8601(time::OffsetDateTime::now_utc()); + env.set("GIT_AUTHOR_NAME", author.name.clone()); + env.set("GIT_AUTHOR_EMAIL", author.email.clone()); + env.set("GIT_AUTHOR_DATE", now_str.clone()); + env.set("GIT_COMMITTER_NAME", committer.name.clone()); + env.set("GIT_COMMITTER_EMAIL", committer.email.clone()); + env.set("GIT_COMMITTER_DATE", now_str); + env +} + +fn format_iso8601(t: time::OffsetDateTime) -> String { + let (y, mo, d) = t.to_calendar_date(); + let (h, mi, s) = { + let t2 = t - time::Duration::seconds(t.second() as i64); + let t2 = t2 - time::Duration::minutes(t.minute() as i64); + (t2.hour(), t2.minute(), t2.second()) + }; + let off = t.offset(); + let off_secs = off.whole_seconds(); + let sign = if off_secs >= 0 { '+' } else { '-' }; + let abs = off_secs.unsigned_abs(); + let oh = abs / 3600; + let om = (abs % 3600) / 60; + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{:02}:{:02}", + y, + u8::from(mo), + d, + h, + mi, + s, + sign, + oh, + om + ) +} diff --git a/tests/cmd_run.rs b/tests/cmd_run.rs new file mode 100644 index 0000000..314ef59 --- /dev/null +++ b/tests/cmd_run.rs @@ -0,0 +1,140 @@ +use gitbaby::command::cmd::Cmd; +use std::path::PathBuf; + +macro_rules! unwrap_or_panic { + ($expr:expr, $msg:literal) => { + match $expr { + Ok(v) => v, + Err(e) => panic!("{}: {:?}", $msg, e), + } + }; + ($expr:expr, $fmt:literal, $($arg:tt)*) => { + match $expr { + Ok(v) => v, + Err(e) => panic!("{}: {:?}", format_args!($fmt, $($arg)*), e), + } + }; +} + +#[tokio::test] +async fn run_echo_and_capture_output() { + let mut cmd = Cmd::new( + "test:echo", + "sh", + vec!["-c".to_string(), "echo hello".to_string()], + vec![], + PathBuf::from("."), + vec![], + None, + ); + let out = unwrap_or_panic!(cmd.run().await, "run failed"); + assert!(out.status.success(), "exit code should be 0"); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello"); +} + +#[tokio::test] +async fn run_with_non_zero_exit_returns_output() { + let mut cmd = Cmd::new( + "test:false", + "sh", + vec!["-c".to_string(), "echo oops 1>&2; exit 7".to_string()], + vec![], + PathBuf::from("."), + vec![], + None, + ); + let res = cmd.run().await; + match res { + Ok(_) => panic!("run() with non-zero exit should fail"), + Err(gitbaby::command::error::CmdError::NonZeroExit { code, stderr, .. }) => { + assert_eq!(code, 7); + assert_eq!(stderr.trim(), "oops"); + } + Err(e) => panic!("unexpected error: {:?}", e), + } +} + +#[tokio::test] +async fn run_soft_keeps_non_zero_exit_in_output() { + let mut cmd = Cmd::new( + "test:false-soft", + "sh", + vec!["-c".to_string(), "echo oops 1>&2; exit 7".to_string()], + vec![], + PathBuf::from("."), + vec![], + None, + ); + let out = unwrap_or_panic!(cmd.run_soft().await, "run_soft failed"); + assert_eq!(out.status.code(), Some(7)); + assert_eq!(String::from_utf8_lossy(&out.stderr).trim(), "oops"); +} + +#[tokio::test] +async fn feed_writes_to_stdin() { + let mut cmd = Cmd::new( + "test:cat", + "cat", + vec![], + vec![], + PathBuf::from("."), + vec![], + None, + ); + unwrap_or_panic!(cmd.spawn().await, "spawn failed"); + unwrap_or_panic!(cmd.feed(b"via-stdin").await, "feed failed"); + let out = unwrap_or_panic!(cmd.run().await, "run failed"); + assert_eq!(String::from_utf8_lossy(&out.stdout), "via-stdin"); +} + +#[tokio::test] +async fn env_vars_are_applied() { + let mut cmd = Cmd::new( + "test:env", + "sh", + vec!["-c".to_string(), "printf %s \"$GREETING\"".to_string()], + vec![], + PathBuf::from("."), + vec![("GREETING".to_string(), "hi".to_string())], + None, + ); + let out = unwrap_or_panic!(cmd.run().await, "run failed"); + assert!(out.status.success()); + assert_eq!(String::from_utf8_lossy(&out.stdout), "hi"); +} + +#[tokio::test] +async fn config_args_prepend_to_command_line() { + let mut cmd = Cmd::new( + "test:cfg", + "echo", + vec!["world".to_string()], + vec!["ignored-no-flag-echo".to_string()], + PathBuf::from("."), + vec![], + None, + ); + let rendered = format!("{cmd}"); + assert_eq!(rendered, "echo ignored-no-flag-echo world"); + + let out = unwrap_or_panic!(cmd.run().await, "run failed"); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "ignored-no-flag-echo world" + ); +} + +#[tokio::test] +async fn timeout_kills_long_running_process() { + let mut cmd = Cmd::new( + "test:sleep", + "sleep", + vec!["5".to_string()], + vec![], + PathBuf::from("."), + vec![], + Some(1), + ); + let res = cmd.run().await; + assert!(res.is_err(), "timeout should produce an error"); +} diff --git a/tests/context.rs b/tests/context.rs new file mode 100644 index 0000000..a583511 --- /dev/null +++ b/tests/context.rs @@ -0,0 +1,160 @@ +use gitbaby::command::context::GitPipeContext; + +struct MinimalCtx; + +impl GitPipeContext for MinimalCtx { + fn cancel_pipe(&mut self) {} +} + +struct StatefulCtx { + cancelled: bool, + current: Option, + finished: Vec, + progress: Vec<(usize, u64)>, +} + +impl GitPipeContext for StatefulCtx { + fn cancel_pipe(&mut self) { + self.cancelled = true; + } + fn current_index(&self) -> Option { + self.current + } + fn on_command_finished(&mut self, index: usize) { + self.finished.push(index); + } + fn on_progress(&mut self, index: usize, bytes: u64) { + self.progress.push((index, bytes)); + } +} + +#[test] +fn default_current_index_is_none() { + let ctx = MinimalCtx; + assert_eq!(ctx.current_index(), None); +} + +#[test] +fn default_hooks_are_noops() { + let mut ctx = MinimalCtx; + ctx.on_command_finished(0); + ctx.on_command_finished(7); + ctx.on_progress(2, 1024); + ctx.on_progress(5, 9999); + assert_eq!(ctx.current_index(), None); +} + +#[test] +fn cancel_pipe_marks_cancelled_state() { + let mut ctx = StatefulCtx { + cancelled: false, + current: Some(0), + finished: vec![], + progress: vec![], + }; + assert!(!ctx.cancelled); + ctx.cancel_pipe(); + assert!(ctx.cancelled); +} + +#[test] +fn on_command_finished_records_indices_in_order() { + let mut ctx = StatefulCtx { + cancelled: false, + current: None, + finished: vec![], + progress: vec![], + }; + for i in [0_usize, 1, 2, 3] { + ctx.on_command_finished(i); + } + assert_eq!(ctx.finished, vec![0, 1, 2, 3]); +} + +#[test] +fn on_progress_records_index_and_bytes_pairs() { + let mut ctx = StatefulCtx { + cancelled: false, + current: None, + finished: vec![], + progress: vec![], + }; + ctx.on_progress(0, 0); + ctx.on_progress(0, 1024); + ctx.on_progress(1, 2048); + assert_eq!(ctx.progress, vec![(0, 0), (0, 1024), (1, 2048)]); +} + +#[test] +fn current_index_returns_impl_specific_value() { + let ctx = StatefulCtx { + cancelled: false, + current: Some(3), + finished: vec![], + progress: vec![], + }; + assert_eq!(ctx.current_index(), Some(3)); +} + +#[test] +fn current_index_can_be_none_even_when_active() { + let ctx = StatefulCtx { + cancelled: false, + current: None, + finished: vec![], + progress: vec![], + }; + assert_eq!(ctx.current_index(), None); +} + +#[test] +fn trait_object_dispatch_invokes_impl() { + let mut ctx = StatefulCtx { + cancelled: false, + current: Some(2), + finished: vec![], + progress: vec![], + }; + { + let trait_obj: &mut dyn GitPipeContext = &mut ctx; + trait_obj.cancel_pipe(); + trait_obj.on_command_finished(1); + trait_obj.on_progress(1, 42); + assert_eq!(trait_obj.current_index(), Some(2)); + } + + assert!(ctx.cancelled); + assert_eq!(ctx.finished, vec![1]); + assert_eq!(ctx.progress, vec![(1, 42)]); +} + +#[test] +fn multiple_impls_are_independent_via_trait_objects() { + let mut a = StatefulCtx { + cancelled: false, + current: Some(0), + finished: vec![], + progress: vec![], + }; + let mut b = StatefulCtx { + cancelled: false, + current: Some(1), + finished: vec![], + progress: vec![], + }; + + { + let trait_obj: &mut dyn GitPipeContext = &mut a; + trait_obj.cancel_pipe(); + } + { + let trait_obj: &mut dyn GitPipeContext = &mut b; + trait_obj.on_command_finished(7); + } + + assert!(a.cancelled); + assert!(!b.cancelled); + assert_eq!(b.finished, vec![7]); + assert_eq!(a.current_index(), Some(0)); + assert_eq!(b.current_index(), Some(1)); +} diff --git a/tests/env.rs b/tests/env.rs new file mode 100644 index 0000000..911fb50 --- /dev/null +++ b/tests/env.rs @@ -0,0 +1,137 @@ +use gitbaby::command::env::Env; +use std::sync::{Mutex, MutexGuard, PoisonError}; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +fn lock_or_recover() -> MutexGuard<'static, ()> { + match ENV_LOCK.lock() { + Ok(g) => g, + Err(PoisonError { .. }) => panic!("env lock poisoned"), + } +} + +fn unique_key(suffix: &str) -> String { + format!("GITBABY_TEST_ENV_{suffix}") +} + +unsafe fn set_var(key: &str, val: &str) { + unsafe { std::env::set_var(key, val) } +} + +unsafe fn remove_var(key: &str) { + unsafe { std::env::remove_var(key) } +} + +#[test] +fn from_current_env_includes_system_var() { + let _guard = lock_or_recover(); + let key = unique_key("FROM_CUR"); + unsafe { set_var(&key, "from-system") }; + + let env = Env::from_current_env(); + assert_eq!(env.get(&key), Some("from-system")); + + unsafe { remove_var(&key) }; +} + +#[test] +fn from_current_env_contains_path_like_var() { + let env = Env::from_current_env(); + assert!(env.get("PATH").is_some() || env.get("Path").is_some() || !env.is_empty()); +} + +#[test] +fn set_overrides_system_var_after_from_current_env() { + let _guard = lock_or_recover(); + let key = unique_key("OVERRIDE"); + unsafe { set_var(&key, "system-value") }; + + let mut env = Env::from_current_env(); + env.set(&key, "user-value"); + assert_eq!(env.get(&key), Some("user-value")); + + unsafe { remove_var(&key) }; +} + +#[test] +fn with_current_env_overrides_user_added_var() { + let _guard = lock_or_recover(); + let key = unique_key("WC_OVERRIDE"); + unsafe { set_var(&key, "system-wins") }; + + let env = Env::new().with(&key, "user-loses").with_current_env(); + assert_eq!(env.get(&key), Some("system-wins")); + + unsafe { remove_var(&key) }; +} + +#[test] +fn overlay_current_env_preserves_user_added_var() { + let _guard = lock_or_recover(); + let key = unique_key("OVERLAY"); + unsafe { set_var(&key, "system-value") }; + + let env = Env::new().with(&key, "user-keeps").overlay_current_env(); + assert_eq!(env.get(&key), Some("user-keeps")); + + unsafe { remove_var(&key) }; +} + +#[test] +fn overlay_current_env_fills_missing_keys() { + let _guard = lock_or_recover(); + let mut iter = std::env::vars_os(); + let known = match iter.next() { + Some(v) => v, + None => panic!("process should have at least one env var"), + }; + let known_key = known.0.to_string_lossy().into_owned(); + + let env = Env::new().overlay_current_env(); + assert!(env.get(&known_key).is_some()); +} + +#[test] +fn unset_removes_system_var_when_loaded() { + let _guard = lock_or_recover(); + let key = unique_key("UNSET"); + unsafe { set_var(&key, "temp") }; + + let mut env = Env::from_current_env(); + assert_eq!(env.get(&key), Some("temp")); + + env.unset(&key); + assert_eq!(env.get(&key), None); + + unsafe { remove_var(&key) }; +} + +#[test] +fn from_current_env_is_cloneable() { + let _guard = lock_or_recover(); + let key = unique_key("CLONE"); + unsafe { set_var(&key, "v") }; + + let env = Env::from_current_env(); + let copy = env.clone(); + assert_eq!(env.get(&key), Some("v")); + assert_eq!(copy.get(&key), Some("v")); + + unsafe { remove_var(&key) }; +} + +#[test] +fn set_after_from_current_env_does_not_grow_pairs_when_key_known() { + let _guard = lock_or_recover(); + let key = unique_key("NOGROW"); + unsafe { set_var(&key, "system") }; + + let mut env = Env::from_current_env(); + let before = env.len(); + env.set(&key, "user"); + let after = env.len(); + assert_eq!(before, after, "set 应替换而非新增"); + assert_eq!(env.get(&key), Some("user")); + + unsafe { remove_var(&key) }; +} diff --git a/tests/pipe.rs b/tests/pipe.rs new file mode 100644 index 0000000..756989f --- /dev/null +++ b/tests/pipe.rs @@ -0,0 +1,169 @@ +use gitbaby::command::cmd::Cmd; +use gitbaby::command::context::GitPipeContext; +use gitbaby::command::pipe::Pipe; +use std::path::PathBuf; +use std::time::Duration; + +fn make_cmd(caller: &str, prog: &str, arg: &str) -> Cmd { + Cmd::new( + caller, + prog, + vec![arg.to_string()], + vec![], + PathBuf::from("."), + vec![], + None, + ) +} + +fn must_some(opt: Option, msg: &str) -> T { + match opt { + Some(v) => v, + None => panic!("{msg}"), + } +} + +#[test] +fn pipe_builder_sets_initial_fields() { + let pipe = Pipe::new("demo", PathBuf::from("/tmp/work")); + assert_eq!(pipe.name, "demo"); + assert_eq!(pipe.dir, PathBuf::from("/tmp/work")); + assert_eq!(pipe.len(), 0); + assert!(pipe.is_empty()); + assert!(!pipe.is_cancelled()); + assert_eq!(pipe.timeout, None); + assert!(pipe.first().is_none()); + assert!(pipe.last().is_none()); +} + +#[test] +fn pipe_push_grows_cmds_and_chains() { + let pipe = Pipe::new("chain", PathBuf::from(".")) + .push(make_cmd("c1", "echo", "a")) + .push(make_cmd("c2", "echo", "b")); + + assert_eq!(pipe.len(), 2); + assert!(!pipe.is_empty()); + assert_eq!( + must_some(pipe.first(), "first must be Some").caller_info, + "c1" + ); + assert_eq!( + must_some(pipe.last(), "last must be Some").caller_info, + "c2" + ); +} + +#[test] +fn pipe_extend_accepts_iterator() { + let cmds = vec![ + make_cmd("a", "echo", "1"), + make_cmd("b", "echo", "2"), + make_cmd("c", "echo", "3"), + ]; + let pipe = Pipe::new("ext", PathBuf::from(".")).extend(cmds); + assert_eq!(pipe.len(), 3); + assert_eq!(must_some(pipe.last(), "last must be Some").caller_info, "c"); +} + +#[test] +fn pipe_with_dir_overrides_directory() { + let pipe = Pipe::new("d", PathBuf::from("/old")).with_dir(PathBuf::from("/new")); + assert_eq!(pipe.dir, PathBuf::from("/new")); +} + +#[test] +fn pipe_with_timeout_sets_value() { + let pipe = Pipe::new("t", PathBuf::from(".")).with_timeout(5_000); + assert_eq!(pipe.timeout, Some(5_000)); +} + +#[test] +fn pipe_display_shows_name_and_joined_commands() { + let pipe = Pipe::new("pipe-name", PathBuf::from(".")) + .push(make_cmd("c1", "git", "log")) + .push(make_cmd("c2", "git", "show")); + let rendered = format!("{pipe}"); + assert_eq!(rendered, "pipe-name: git log | git show"); +} + +#[test] +fn pipe_display_when_empty() { + let pipe = Pipe::new("empty-pipe", PathBuf::from(".")); + assert_eq!(format!("{pipe}"), "empty-pipe:"); +} + +#[test] +fn pipe_debug_includes_field_summary() { + let pipe = Pipe::new("dbg", PathBuf::from(".")) + .with_timeout(42) + .push(make_cmd("c", "echo", "x")); + let rendered = format!("{pipe:?}"); + assert!(rendered.contains("Pipe")); + assert!(rendered.contains("dbg")); + assert!(rendered.contains("42")); + assert!(rendered.contains("cancelled: false")); +} + +#[test] +fn pipe_current_index_unset_when_empty() { + let pipe = Pipe::new("e", PathBuf::from(".")); + assert_eq!(pipe.current_index(), None); +} + +#[test] +fn pipe_current_index_after_push() { + let pipe = Pipe::new("e", PathBuf::from(".")) + .push(make_cmd("c1", "echo", "a")) + .push(make_cmd("c2", "echo", "b")); + assert_eq!(pipe.current_index(), Some(1)); +} + +#[test] +fn pipe_cancel_sets_flag_and_drops_index() { + let mut pipe = Pipe::new("c", PathBuf::from(".")) + .push(make_cmd("c1", "echo", "a")) + .push(make_cmd("c2", "echo", "b")); + assert!(!pipe.is_cancelled()); + assert_eq!(pipe.current_index(), Some(1)); + + pipe.cancel_pipe(); + assert!(pipe.is_cancelled()); + assert_eq!(pipe.current_index(), None); +} + +#[tokio::test] +async fn pipe_cancel_kills_running_children() { + let mut pipe = Pipe::new("kill", PathBuf::from(".")).push(Cmd::new( + "sleeper", + "sleep", + vec!["30".to_string()], + vec![], + PathBuf::from("."), + vec![], + None, + )); + if let Err(e) = pipe.cmds[0].spawn().await { + panic!("spawn failed: {:?}", e); + } + + pipe.cancel_pipe(); + assert!(pipe.is_cancelled()); + assert!(!pipe.spawn_allowed()); + + tokio::time::sleep(Duration::from_millis(200)).await; + // The child handle is handed to the reaper task; kill must be rejected + // for new spawns and the stale handle must no longer be owned by the Cmd. + assert!( + pipe.cmds[0].cmd.is_none(), + "cancel_pipe should take over the child handle" + ); +} + +#[tokio::test] +async fn pipe_cancel_is_safe_when_no_children_running() { + let mut pipe = Pipe::new("safe", PathBuf::from(".")).push(make_cmd("c", "echo", "x")); + pipe.cancel_pipe(); + assert!(pipe.is_cancelled()); + assert_eq!(pipe.current_index(), None); +}