diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7aab24d..7b36488 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: with: components: clippy, rustfmt - - name: Validate release workflow + - name: Validate workflows shell: bash run: | set -euo pipefail @@ -65,6 +65,7 @@ jobs: echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 ${tool_dir}/${archive}" | sha256sum -c - tar -C "${tool_dir}" -xzf "${tool_dir}/${archive}" actionlint "${tool_dir}/actionlint" + bash scripts/test-ci-workflow.sh bash scripts/test-release-workflow.sh - name: Validate Linux installation documentation @@ -83,6 +84,29 @@ jobs: - name: Build run: cargo build --locked --workspace + recovery-platforms: + name: Recovery safety (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - macos-latest + - windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Test end-to-end recovery support + run: cargo test --locked -p bitbygit-git recovery_platform_ + + - name: Build workspace + run: cargo build --locked --workspace + installation-docs-macos: name: macOS installation docs runs-on: macos-15 diff --git a/Cargo.lock b/Cargo.lock index 155a9df..8831168 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,6 +36,14 @@ dependencies = [ [[package]] name = "bitbygit-git" version = "0.1.0" +dependencies = [ + "command-group", + "fs2", + "rustix", + "same-file", + "sha2", + "windows-sys 0.61.2", +] [[package]] name = "bitbygit-store" @@ -63,6 +71,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[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 = "cassowary" version = "0.3.0" @@ -84,6 +101,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "command-group" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68fa787550392a9d58f44c21a3022cfb3ea3e2458b7f85d3b399d0ceeccf409" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "compact_str" version = "0.8.2" @@ -98,6 +125,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -123,6 +159,16 @@ dependencies = [ "winapi", ] +[[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 = "darling" version = "0.20.11" @@ -158,6 +204,16 @@ dependencies = [ "syn", ] +[[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 = "either" version = "1.16.0" @@ -192,6 +248,26 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[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 = "hashbrown" version = "0.15.5" @@ -322,6 +398,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -424,6 +511,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[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" @@ -482,6 +578,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "signal-hook" version = "0.3.18" @@ -605,6 +712,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -640,6 +753,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -662,6 +781,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/README.md b/README.md index 4e2eab9..4731938 100644 --- a/README.md +++ b/README.md @@ -53,12 +53,13 @@ Package-manager distribution is not currently available. Prerequisites: - Rust 1.85 or newer. -- `git` available on `PATH`. +- Git 2.42 or newer available on `PATH` (required for guarded conflict recovery). - `gh` is optional and required only for GitHub-specific workflows. Run local checks: ```sh +bash scripts/test-ci-workflow.sh bash scripts/test-release-workflow.sh bash scripts/test-installation-docs.sh cargo fmt --all --check diff --git a/crates/bitbygit-core/src/lib.rs b/crates/bitbygit-core/src/lib.rs index dd04513..3fc4e93 100644 --- a/crates/bitbygit-core/src/lib.rs +++ b/crates/bitbygit-core/src/lib.rs @@ -41,6 +41,7 @@ pub enum OperationRequest { Rebase { base: String, }, + Recover(RecoveryRequest), OpenPullRequest { base: Option, }, @@ -49,6 +50,32 @@ pub enum OperationRequest { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecoveryRequest { + MergeContinue, + MergeAbort, + RebaseContinue, + RebaseAbort, + RebaseSkip, +} + +impl RecoveryRequest { + pub const fn operation_label(self) -> &'static str { + match self { + Self::MergeContinue | Self::MergeAbort => "merge", + Self::RebaseContinue | Self::RebaseAbort | Self::RebaseSkip => "rebase", + } + } + + pub const fn action_label(self) -> &'static str { + match self { + Self::MergeContinue | Self::RebaseContinue => "continue", + Self::MergeAbort | Self::RebaseAbort => "abort", + Self::RebaseSkip => "skip", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OperationKind { RefreshStatus, @@ -68,6 +95,11 @@ pub enum OperationKind { CreateBranch, MergeFastForward, Rebase, + MergeContinue, + MergeAbort, + RebaseContinue, + RebaseAbort, + RebaseSkip, OpenPullRequest, } @@ -90,6 +122,11 @@ impl OperationKind { Self::CreateBranch => "create branch", Self::MergeFastForward => "merge", Self::Rebase => "rebase", + Self::MergeContinue => "merge continue", + Self::MergeAbort => "merge abort", + Self::RebaseContinue => "rebase continue", + Self::RebaseAbort => "rebase abort", + Self::RebaseSkip => "rebase skip", Self::OpenPullRequest => "open pull request", } } @@ -113,6 +150,11 @@ impl OperationKind { Self::CreateBranch => "create_branch", Self::MergeFastForward => "merge_ff_only", Self::Rebase => "rebase", + Self::MergeContinue => "merge_continue", + Self::MergeAbort => "merge_abort", + Self::RebaseContinue => "rebase_continue", + Self::RebaseAbort => "rebase_abort", + Self::RebaseSkip => "rebase_skip", Self::OpenPullRequest => "open_pull_request", } } diff --git a/crates/bitbygit-core/src/policy.rs b/crates/bitbygit-core/src/policy.rs index 06e349c..d6e059c 100644 --- a/crates/bitbygit-core/src/policy.rs +++ b/crates/bitbygit-core/src/policy.rs @@ -139,8 +139,13 @@ pub const fn operation_family(operation: OperationKind) -> OperationFamily { OperationKind::Branches => OperationFamily::Branches, OperationKind::CheckoutBranch => OperationFamily::Checkout, OperationKind::CreateBranch => OperationFamily::CreateBranch, - OperationKind::MergeFastForward => OperationFamily::Merge, - OperationKind::Rebase => OperationFamily::Rebase, + OperationKind::MergeFastForward + | OperationKind::MergeContinue + | OperationKind::MergeAbort => OperationFamily::Merge, + OperationKind::Rebase + | OperationKind::RebaseContinue + | OperationKind::RebaseAbort + | OperationKind::RebaseSkip => OperationFamily::Rebase, OperationKind::OpenPullRequest => OperationFamily::OpenPullRequest, } } @@ -159,9 +164,13 @@ const fn protected_branch_requirement(operation: OperationKind) -> Option Some(ConfirmationRequirement::VisiblePlan), - OperationKind::PullRebase | OperationKind::Rebase => { - Some(ConfirmationRequirement::ExplicitConfirmation) - } + OperationKind::PullRebase + | OperationKind::Rebase + | OperationKind::MergeContinue + | OperationKind::MergeAbort + | OperationKind::RebaseContinue + | OperationKind::RebaseAbort + | OperationKind::RebaseSkip => Some(ConfirmationRequirement::ExplicitConfirmation), OperationKind::RefreshStatus | OperationKind::ViewDiff | OperationKind::Fetch @@ -358,7 +367,12 @@ mod tests { (OperationKind::CheckoutBranch, OperationFamily::Checkout), (OperationKind::CreateBranch, OperationFamily::CreateBranch), (OperationKind::MergeFastForward, OperationFamily::Merge), + (OperationKind::MergeContinue, OperationFamily::Merge), + (OperationKind::MergeAbort, OperationFamily::Merge), (OperationKind::Rebase, OperationFamily::Rebase), + (OperationKind::RebaseContinue, OperationFamily::Rebase), + (OperationKind::RebaseAbort, OperationFamily::Rebase), + (OperationKind::RebaseSkip, OperationFamily::Rebase), ( OperationKind::OpenPullRequest, OperationFamily::OpenPullRequest, diff --git a/crates/bitbygit-core/src/prompt_parser.rs b/crates/bitbygit-core/src/prompt_parser.rs index 5a17a43..03420a5 100644 --- a/crates/bitbygit-core/src/prompt_parser.rs +++ b/crates/bitbygit-core/src/prompt_parser.rs @@ -1,10 +1,10 @@ use std::error::Error; use std::fmt; -use crate::OperationRequest; +use crate::{OperationRequest, RecoveryRequest}; -const PROMPT_EXAMPLES: &str = "branches, checkout , branch , branch from , merge , rebase , open pr, commit -m \"message\", fetch, push, pull, or pull --rebase"; -pub const UNSUPPORTED_PROMPT_MESSAGE: &str = "Unsupported prompt. Try: branches, checkout , branch , branch from , merge , rebase , open pr, commit -m \"message\", fetch, push, pull, or pull --rebase"; +const PROMPT_EXAMPLES: &str = "branches, checkout , branch , branch from , merge , merge --continue, merge --abort, rebase , rebase --continue, rebase --abort, rebase --skip, open pr, commit -m \"message\", fetch, push, pull, or pull --rebase"; +pub const UNSUPPORTED_PROMPT_MESSAGE: &str = "Unsupported prompt. Try: branches, checkout , branch , branch from , merge , merge --continue, merge --abort, rebase , rebase --continue, rebase --abort, rebase --skip, open pr, commit -m \"message\", fetch, push, pull, or pull --rebase"; const SHELL_SYNTAX_MESSAGE: &str = "Shell-style prompt syntax is not supported. Use one guarded prompt at a time."; const RAW_GIT_MESSAGE: &str = @@ -171,6 +171,14 @@ fn parse_single_prompt(input: &str) -> Result Ok(OperationRequest::OpenPullRequest { base: None }), "pull" => Ok(OperationRequest::Pull { rebase: false }), "pull --rebase" | "pull rebase" => Ok(OperationRequest::Pull { rebase: true }), + "merge --continue" => Ok(OperationRequest::Recover(RecoveryRequest::MergeContinue)), + "merge --abort" => Ok(OperationRequest::Recover(RecoveryRequest::MergeAbort)), + "rebase --continue" => Ok(OperationRequest::Recover(RecoveryRequest::RebaseContinue)), + "rebase --abort" => Ok(OperationRequest::Recover(RecoveryRequest::RebaseAbort)), + "rebase --skip" => Ok(OperationRequest::Recover(RecoveryRequest::RebaseSkip)), + "merge --skip" => Err(parse_error( + "Merge skip is not supported by Git. Use merge --continue or merge --abort.", + )), _ if lower.starts_with("checkout ") => parse_one_arg_prompt(trimmed, "checkout") .map(|branch| OperationRequest::Checkout { branch }), _ if lower.starts_with("merge ") => { @@ -353,6 +361,26 @@ mod tests { ("pull", OperationRequest::Pull { rebase: false }), ("pull --rebase", OperationRequest::Pull { rebase: true }), ("pull rebase", OperationRequest::Pull { rebase: true }), + ( + "merge --continue", + OperationRequest::Recover(RecoveryRequest::MergeContinue), + ), + ( + "MERGE --ABORT", + OperationRequest::Recover(RecoveryRequest::MergeAbort), + ), + ( + "rebase --continue", + OperationRequest::Recover(RecoveryRequest::RebaseContinue), + ), + ( + "rebase --abort", + OperationRequest::Recover(RecoveryRequest::RebaseAbort), + ), + ( + "rebase --skip", + OperationRequest::Recover(RecoveryRequest::RebaseSkip), + ), ( "checkout feature/auth", OperationRequest::Checkout { @@ -462,6 +490,11 @@ mod tests { ), ("push --force", UNSUPPORTED_PROMPT_MESSAGE.to_owned()), ("pull --ff-only", UNSUPPORTED_PROMPT_MESSAGE.to_owned()), + ( + "merge --skip", + "Merge skip is not supported by Git. Use merge --continue or merge --abort." + .to_owned(), + ), ("git push", RAW_GIT_MESSAGE.to_owned()), ("git commit -m \"message\"", RAW_GIT_MESSAGE.to_owned()), ( diff --git a/crates/bitbygit-git/Cargo.toml b/crates/bitbygit-git/Cargo.toml index 9867516..127073c 100644 --- a/crates/bitbygit-git/Cargo.toml +++ b/crates/bitbygit-git/Cargo.toml @@ -8,3 +8,15 @@ rust-version.workspace = true [lints] workspace = true + +[dependencies] +fs2 = "0.4" +sha2 = "0.10" + +[target.'cfg(unix)'.dependencies] +rustix = { version = "0.38", features = ["fs", "process"] } + +[target.'cfg(windows)'.dependencies] +command-group = "5.0" +same-file = "1.0" +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 6065013..b50b8a5 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -1,16 +1,46 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::error::Error; use std::ffi::OsString; use std::fmt::{self, Display, Formatter}; use std::fs; +use std::io::{self, Read}; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitStatus}; +use std::process::{ChildStderr, ChildStdout, Command, ExitStatus, Stdio}; use std::string::FromUtf8Error; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use fs2::FileExt; +use sha2::{Digest, Sha256}; + +#[cfg(windows)] +use command_group::{CommandGroup, GroupChild}; +#[cfg(windows)] +use std::os::windows::fs::OpenOptionsExt; +#[cfg(unix)] +use std::process::Child; +#[cfg(windows)] +use windows_sys::Win32::Storage::FileSystem::{ + FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, +}; #[cfg(unix)] use std::os::unix::ffi::OsStringExt; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; +#[cfg(unix)] +use std::os::unix::process::CommandExt; + +#[cfg(unix)] +use rustix::process::{Pid, Signal, kill_process_group}; +#[cfg(unix)] +use rustix::{ + fs::{Mode, OFlags, open}, + io::Errno, +}; const ZERO_OID: &str = "0000000000000000000000000000000000000000"; const EMPTY_TREE_OID: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; @@ -21,12 +51,84 @@ const COMMIT_HOOKS: &[&str] = &[ "commit-msg", "post-commit", ]; +const RECOVERY_PLAN_TIMEOUT: Duration = Duration::from_secs(10); +const RECOVERY_START_TIMEOUT: Duration = Duration::from_secs(60); +const RECOVERY_OUTPUT_LIMIT: usize = 256 * 1024; +const RECOVERY_DIAGNOSTIC_LIMIT: usize = 64 * 1024; +const RECOVERY_DIAGNOSTIC_CAPTURE_LIMIT: usize = 64 * 1024 * 1024; +const RECOVERY_STATE_ENTRY_LIMIT: usize = 100_000; +const RECOVERY_UNTRACKED_DATA_LIMIT: u64 = 64 * 1024 * 1024; +const RECOVERY_REBASE_TODO_LIMIT: u64 = 1024 * 1024; +const RECOVERY_MINIMUM_GIT_VERSION: (u64, u64) = (2, 42); +const RECOVERY_LOCK_DIRECTORY: &str = "recovery-locks"; +const RECOVERY_HOOKS: &[&str] = &[ + "applypatch-msg", + "commit-msg", + "fsmonitor-watchman", + "p4-changelist", + "p4-post-changelist", + "p4-pre-submit", + "p4-prepare-changelist", + "post-applypatch", + "post-checkout", + "post-commit", + "post-index-change", + "post-merge", + "post-receive", + "post-rewrite", + "post-update", + "pre-applypatch", + "pre-auto-gc", + "pre-commit", + "pre-merge-commit", + "pre-push", + "pre-rebase", + "pre-receive", + "prepare-commit-msg", + "proc-receive", + "push-to-checkout", + "reference-transaction", + "sendemail-validate", + "update", +]; +const RECOVERY_CONTROL_PATHS: &[&str] = &[ + "HEAD", + "index", + "ORIG_HEAD", + "MERGE_HEAD", + "MERGE_MSG", + "MERGE_MODE", + "MERGE_AUTOSTASH", + "MERGE_RR", + "AUTO_MERGE", + "SQUASH_MSG", + "REBASE_HEAD", + "CHERRY_PICK_HEAD", + "REVERT_HEAD", + "BISECT_HEAD", + "rebase-apply", + "rebase-merge", + "sequencer", + "rr-cache", + "info/attributes", + "info/sparse-checkout", +]; static NEXT_STAGED_FETCH_ID: AtomicU64 = AtomicU64::new(1); #[derive(Debug, Clone)] pub struct Git { cwd: PathBuf, ssh_executable: Option, + recovery_plan_timeout: Duration, + recovery_start_timeout: Duration, + recovery_output_limit: usize, + isolated_test_config: bool, + #[cfg(test)] + test_global_config: Option, + #[cfg(all(test, unix))] + test_git_exec_path: Option, + #[cfg(test)] + recovery_data_dir: Option, } impl Git { @@ -34,6 +136,16 @@ impl Git { Self { cwd: cwd.into(), ssh_executable: None, + recovery_plan_timeout: RECOVERY_PLAN_TIMEOUT, + recovery_start_timeout: RECOVERY_START_TIMEOUT, + recovery_output_limit: RECOVERY_OUTPUT_LIMIT, + isolated_test_config: cfg!(test), + #[cfg(test)] + test_global_config: None, + #[cfg(all(test, unix))] + test_git_exec_path: None, + #[cfg(test)] + recovery_data_dir: None, } } @@ -44,9 +156,65 @@ impl Git { Self { cwd: cwd.into(), ssh_executable: Some(ssh_executable.into()), + recovery_plan_timeout: RECOVERY_PLAN_TIMEOUT, + recovery_start_timeout: RECOVERY_START_TIMEOUT, + recovery_output_limit: RECOVERY_OUTPUT_LIMIT, + isolated_test_config: cfg!(test), + #[cfg(test)] + test_global_config: None, + #[cfg(all(test, unix))] + test_git_exec_path: None, + #[cfg(test)] + recovery_data_dir: None, + } + } + + #[cfg(test)] + fn with_recovery_limits( + cwd: impl Into, + plan_timeout: Duration, + start_timeout: Duration, + output_limit: usize, + ) -> Self { + Self { + cwd: cwd.into(), + ssh_executable: None, + recovery_plan_timeout: plan_timeout, + recovery_start_timeout: start_timeout, + recovery_output_limit: output_limit, + isolated_test_config: true, + test_global_config: None, + #[cfg(unix)] + test_git_exec_path: None, + recovery_data_dir: None, } } + #[doc(hidden)] + pub fn with_isolated_test_config(mut self) -> Self { + self.isolated_test_config = true; + self + } + + #[cfg(test)] + fn with_test_global_config(mut self, path: impl Into) -> Self { + self.isolated_test_config = true; + self.test_global_config = Some(path.into()); + self + } + + #[cfg(all(test, unix))] + fn with_test_git_exec_path(mut self, path: impl Into) -> Self { + self.test_git_exec_path = Some(path.into()); + self + } + + #[cfg(test)] + fn with_recovery_data_dir(mut self, path: impl Into) -> Self { + self.recovery_data_dir = Some(path.into()); + self + } + pub fn repository(&self) -> Result { let root = self.repo_root()?; let remotes = self.remotes()?; @@ -215,14 +383,131 @@ impl Git { operation: RepositoryOperation, action: RecoveryAction, ) -> Result { - if operation == RepositoryOperation::Merge && action == RecoveryAction::Skip { - return Err(GitError::Blocked { - message: "merge skip is blocked because Git does not support it".to_owned(), - }); + ensure_recovery_execution_supported(operation, action)?; + let deadline = Instant::now() + self.recovery_start_timeout; + self.ensure_recovery_git_version_until(deadline)?; + self.validate_recovery_until(operation, action, deadline)?; + self.ensure_recovery_process_configuration_safe_until(deadline)?; + let recovery_lock = self.acquire_recovery_lock_until(operation, action, deadline)?; + self.run_recovery_args_until_with(operation, action, deadline, || { + let repository_root = self.canonical_repository_root_until(deadline)?; + recovery_lock.ensure_identity(&repository_root, operation, action)?; + self.validate_recovery_until(operation, action, deadline)?; + self.recovery_state_fenced_for_until(operation, action, deadline, || { + let repository_root = self.canonical_repository_root_until(deadline)?; + recovery_lock.ensure_identity(&repository_root, operation, action) + })?; + Ok(()) + }) + } + + pub fn recovery_state(&self) -> Result { + ensure_recovery_planning_supported()?; + let deadline = Instant::now() + self.recovery_plan_timeout; + self.ensure_recovery_git_version_until(deadline)?; + self.ensure_recovery_process_configuration_safe_until(deadline)?; + self.recovery_state_until(deadline) + } + + pub fn prepare_recovery( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + ) -> Result { + ensure_recovery_planning_supported()?; + let deadline = Instant::now() + self.recovery_plan_timeout; + self.ensure_recovery_git_version_until(deadline)?; + self.ensure_recovery_process_configuration_safe_until(deadline)?; + self.validate_recovery_until(operation, action, deadline)?; + self.recovery_state_fenced_for_until(operation, action, deadline, || Ok(())) + } + + pub fn validate_recovery( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + ) -> Result<(), GitError> { + ensure_recovery_planning_supported()?; + let deadline = Instant::now() + self.recovery_plan_timeout; + self.ensure_recovery_git_version_until(deadline)?; + self.ensure_recovery_process_configuration_safe_until(deadline)?; + self.validate_recovery_until(operation, action, deadline) + } + + pub fn recover_exact( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + expected_state: &RecoveryState, + ) -> Result { + self.recover_exact_with(operation, action, expected_state, || Ok(())) + } + + fn recover_exact_with( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + expected_state: &RecoveryState, + before_spawn_check: F, + ) -> Result + where + F: FnOnce() -> Result<(), GitError>, + { + ensure_recovery_execution_supported(operation, action)?; + let deadline = Instant::now() + self.recovery_start_timeout; + self.ensure_recovery_git_version_until(deadline)?; + self.validate_recovery_action(operation, action)?; + self.ensure_recovery_process_configuration_safe_until(deadline)?; + let recovery_lock = self.acquire_recovery_lock_until(operation, action, deadline)?; + self.run_recovery_args_until_with(operation, action, deadline, || { + let repository_root = self.canonical_repository_root_until(deadline)?; + recovery_lock.ensure_identity(&repository_root, operation, action)?; + let current_state = + match self.recovery_state_fenced_for_until(operation, action, deadline, || { + before_spawn_check()?; + let repository_root = self.canonical_repository_root_until(deadline)?; + recovery_lock.ensure_identity(&repository_root, operation, action) + }) { + Ok(state) => state, + Err(GitError::Blocked { message }) + if message.contains("changed while it was fingerprinted") => + { + return Err(recovery_state_changed(operation, action)); + } + Err(error) => return Err(error), + }; + if current_state != *expected_state { + return Err(recovery_state_changed(operation, action)); + } + Ok(()) + }) + } + + #[cfg(test)] + fn ensure_recovery_state( + &self, + expected_state: &RecoveryState, + operation: RepositoryOperation, + action: RecoveryAction, + deadline: Instant, + ) -> Result<(), GitError> { + if self.recovery_state_fenced_for_until(operation, action, deadline, || Ok(()))? + == *expected_state + { + return Ok(()); } + Err(recovery_state_changed(operation, action)) + } - let status = self.status()?; - match status.operation { + fn validate_recovery_until( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + deadline: Instant, + ) -> Result<(), GitError> { + self.validate_recovery_action(operation, action)?; + let active = self.repository_operation_until(deadline)?; + match active { Some(active) if active == operation => {} Some(active) => { return Err(GitError::Blocked { @@ -245,7 +530,7 @@ impl Git { }); } } - if action == RecoveryAction::Continue && !status.conflicted_files().is_empty() { + if action == RecoveryAction::Continue && self.has_unresolved_conflicts_until(deadline)? { return Err(GitError::Blocked { message: format!( "{} continue is blocked while unresolved conflicts are present", @@ -253,11 +538,20 @@ impl Git { ), }); } + Ok(()) + } - self.run_recovery_args(vec![ - operation.label().to_owned(), - format!("--{}", action.label()), - ]) + fn validate_recovery_action( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + ) -> Result<(), GitError> { + if operation == RepositoryOperation::Merge && action == RecoveryAction::Skip { + return Err(GitError::Blocked { + message: "merge skip is blocked because Git does not support it".to_owned(), + }); + } + Ok(()) } pub fn push_current_branch( @@ -1044,1565 +1338,6117 @@ impl Git { }) } - fn run_args(&self, args: Vec) -> Result { - self.run_args_with_editor(args, false) + fn recovery_state_until(&self, deadline: Instant) -> Result { + self.recovery_state_fenced_until(deadline, || Ok(())) + } + + fn recovery_state_fenced_until( + &self, + deadline: Instant, + after_index: F, + ) -> Result + where + F: FnOnce() -> Result<(), GitError>, + { + self.recovery_state_fenced_until_with(None, deadline, after_index) } - fn run_recovery_args(&self, args: Vec) -> Result { - self.run_args_with_editor(args, true) + fn recovery_state_fenced_for_until( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + deadline: Instant, + after_index: F, + ) -> Result + where + F: FnOnce() -> Result<(), GitError>, + { + self.recovery_state_fenced_until_with(Some((operation, action)), deadline, after_index) } - fn run_args_with_editor( + fn recovery_state_fenced_until_with( &self, - args: Vec, - disable_editor: bool, - ) -> Result { - let mut command = Command::new("git"); - command - .current_dir(&self.cwd) - .env("GIT_TERMINAL_PROMPT", "0") - .env("GIT_ASKPASS", "") - .env("SSH_ASKPASS", "") - .env("SSH_ASKPASS_REQUIRE", "never"); - if disable_editor { - command - .env("GIT_EDITOR", "true") - .env("GIT_SEQUENCE_EDITOR", "true"); + recovery: Option<(RepositoryOperation, RecoveryAction)>, + deadline: Instant, + after_index: F, + ) -> Result + where + F: FnOnce() -> Result<(), GitError>, + { + let mut fence = RecoveryInputFence::default(); + let mut after_index = Some(after_index); + self.recovery_state_until_with(recovery, deadline, Some(&mut fence), &mut after_index) + } + + fn recovery_state_until_with( + &self, + recovery: Option<(RepositoryOperation, RecoveryAction)>, + deadline: Instant, + mut fence: Option<&mut RecoveryInputFence>, + after_index: &mut Option, + ) -> Result + where + F: FnOnce() -> Result<(), GitError>, + { + let mut hasher = Sha256::new(); + self.hash_recovery_process_configuration_until( + deadline, + &mut hasher, + fence.as_deref_mut(), + )?; + for args in [ + vec![ + "status".to_owned(), + "--porcelain=v2".to_owned(), + "--branch".to_owned(), + "--untracked-files=all".to_owned(), + "-z".to_owned(), + ], + vec![ + "diff".to_owned(), + "--binary".to_owned(), + "--no-ext-diff".to_owned(), + "--no-textconv".to_owned(), + "--full-index".to_owned(), + "--".to_owned(), + ], + vec![ + "diff".to_owned(), + "--cached".to_owned(), + "--binary".to_owned(), + "--no-ext-diff".to_owned(), + "--no-textconv".to_owned(), + "--full-index".to_owned(), + "--".to_owned(), + ], + vec![ + "for-each-ref".to_owned(), + "--sort=refname".to_owned(), + "--format=%(refname)%00%(objectname)%00%(symref)%00".to_owned(), + ], + vec![ + "config".to_owned(), + "--null".to_owned(), + "--list".to_owned(), + "--show-origin".to_owned(), + "--show-scope".to_owned(), + ], + ] { + let output = self.run_bounded_git_digest(args.clone(), deadline)?; + if !output.status.success() { + return Err(output.git_error(args)); + } + hash_field(&mut hasher, &output.stdout.digest); + if let Some(fence) = fence.as_deref_mut() { + fence.probes.push(RecoveryProbe { + args, + digest: output.stdout.digest, + status: output.status.code(), + safety: RecoveryProbeSafety::None, + }); + } } - if self.ssh_executable.is_some() || env::var_os("GIT_SSH_COMMAND").is_none() { - let ssh_executable = self - .ssh_executable - .as_ref() - .map(|path| shell_quote(&path.to_string_lossy())) - .unwrap_or_else(|| "ssh".to_owned()); - command.env("GIT_SSH_COMMAND", format!("{ssh_executable} {SSH_OPTIONS}")); + + let mut entries = 0; + for relative in RECOVERY_CONTROL_PATHS { + let path = self.git_path_until(relative, deadline)?; + hash_recovery_path_fenced( + &mut hasher, + relative.as_bytes(), + &path, + deadline, + &mut entries, + fence.as_deref_mut(), + )?; } - let output = command - .args(&args) - .output() - .map_err(|source| GitError::Io { - args: args.clone(), - source, + let hooks = + self.recovery_hooks_path_fenced_until(deadline, &mut hasher, fence.as_deref_mut())?; + hash_recovery_path_fenced( + &mut hasher, + b"hooks", + &hooks, + deadline, + &mut entries, + fence.as_deref_mut(), + )?; + for variable in ["GIT_ATTR_SYSTEM", "GIT_ATTR_GLOBAL"] { + if let Some(path) = self.git_var_path_until(variable, deadline)? { + hash_recovery_path_fenced( + &mut hasher, + variable.as_bytes(), + &path, + deadline, + &mut entries, + fence.as_deref_mut(), + )?; + } + } + let root = self.repo_root_until(deadline)?; + let mut untracked_bytes_remaining = Some(RECOVERY_UNTRACKED_DATA_LIMIT); + for relative in self.untracked_worktree_paths_until(deadline)? { + let path = root.join(&relative); + let mut label = b"untracked-worktree/".to_vec(); + label.extend_from_slice(relative.as_os_str().as_encoded_bytes()); + let mut context = RecoveryHashContext { + deadline, + entries: &mut entries, + follow_symlinks: false, + byte_budget: &mut untracked_bytes_remaining, + byte_budget_error: "recovery planning is blocked because untracked worktree data exceeds the 64 MiB fingerprint limit", + fence: fence.as_deref_mut(), + }; + hash_recovery_path_inner(&mut hasher, &label, &path, 0, &mut context, &mut |_, _| { + Ok(()) })?; + } + if let Some((operation, action)) = recovery { + hash_field(&mut hasher, operation.label().as_bytes()); + hash_field(&mut hasher, action.label().as_bytes()); + self.ensure_no_ignored_recovery_collisions_until( + operation, + action, + deadline, + &mut hasher, + fence.as_deref_mut(), + )?; + } + for relative in self.worktree_attributes_until(deadline)? { + let mut label = b"worktree-attributes/".to_vec(); + label.extend_from_slice(relative.as_os_str().as_encoded_bytes()); + hash_recovery_path_fenced( + &mut hasher, + &label, + &root.join(relative), + deadline, + &mut entries, + fence.as_deref_mut(), + )?; + } - let stdout = if disable_editor { - String::from_utf8_lossy(&output.stdout).into_owned() - } else { - String::from_utf8(output.stdout).map_err(|source| GitError::Utf8 { - args: args.clone(), - stream: OutputStream::Stdout, - source, - })? - }; - let stderr = if disable_editor { - String::from_utf8_lossy(&output.stderr).into_owned() - } else { - String::from_utf8(output.stderr).map_err(|source| GitError::Utf8 { - args: args.clone(), - stream: OutputStream::Stderr, - source, - })? - }; - - if !output.status.success() { - return Err(GitError::GitFailed { - args, - status: output.status, - stdout, - stderr, - }); + if let Some(fence) = fence { + fence.validate(self, deadline)?; + if let Some(after_index) = after_index.take() { + after_index()?; + } + fence.validate(self, deadline)?; } - Ok(GitOutput { - status: output.status, - stdout, - stderr, + Ok(RecoveryState { + fingerprint: hasher.finalize().into(), }) } - fn run_path_args( + fn hash_recovery_process_configuration_until( &self, - args: [&str; N], - path: Option<&Path>, - literal_pathspecs: bool, - ) -> Result { - let mut os_args = args.iter().map(OsString::from).collect::>(); - if let Some(path) = path { - os_args.push(OsString::from("--")); - os_args.push(path.as_os_str().to_owned()); + deadline: Instant, + hasher: &mut Sha256, + mut fence: Option<&mut RecoveryInputFence>, + ) -> Result<(), GitError> { + for key in ["core.fsmonitor", "commit.gpgsign", "tag.gpgsign"] { + let args = vec!["config".to_owned(), "--get".to_owned(), key.to_owned()]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + let safety = RecoveryProbeSafety::DisabledConfig(key); + ensure_recovery_probe_observation_safe(safety, &args, &output)?; + hash_recovery_probe(hasher, &output); + if let Some(fence) = fence.as_deref_mut() { + fence.probes.push(RecoveryProbe { + args, + digest: output.stdout.digest, + status: output.status.code(), + safety, + }); + } } - let output = self.run_os_args(os_args, None, literal_pathspecs)?; - let stdout = String::from_utf8(output.stdout).map_err(|source| GitError::Utf8 { - args: output.args.clone(), - stream: OutputStream::Stdout, - source, - })?; - let stderr = String::from_utf8(output.stderr).map_err(|source| GitError::Utf8 { - args: output.args.clone(), - stream: OutputStream::Stderr, - source, - })?; - Ok(GitOutput { - status: output.status, - stdout, - stderr, - }) - } - fn run_paths_args( - &self, - args: [&str; N], - paths: &[PathBuf], - literal_pathspecs: bool, - ) -> Result { - let mut os_args = args.iter().map(OsString::from).collect::>(); - os_args.push(OsString::from("--")); - os_args.extend(paths.iter().map(|path| path.as_os_str().to_owned())); - let output = self.run_os_args(os_args, None, literal_pathspecs)?; - let stdout = String::from_utf8(output.stdout).map_err(|source| GitError::Utf8 { - args: output.args.clone(), - stream: OutputStream::Stdout, - source, - })?; - let stderr = String::from_utf8(output.stderr).map_err(|source| GitError::Utf8 { - args: output.args.clone(), - stream: OutputStream::Stderr, - source, - })?; - Ok(GitOutput { - status: output.status, - stdout, - stderr, - }) + let args = vec![ + "config".to_owned(), + "--name-only".to_owned(), + "--null".to_owned(), + "--list".to_owned(), + ]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + let safety = RecoveryProbeSafety::ExternalDrivers; + self.ensure_recovery_external_drivers_safe_until(&args, &output, deadline)?; + hash_recovery_probe(hasher, &output); + if let Some(fence) = fence { + fence.probes.push(RecoveryProbe { + args, + digest: output.stdout.digest, + status: output.status.code(), + safety, + }); + } + Ok(()) } - fn run_os_paths( + fn ensure_recovery_process_configuration_safe_until( &self, - mut args: Vec, - paths: &[PathBuf], - literal_pathspecs: bool, - ) -> Result { - args.extend(paths.iter().map(|path| path.as_os_str().to_owned())); - self.run_os_args(args, None, literal_pathspecs) - } - - fn run_os_args( - &self, - mut args: Vec, - path: Option<&Path>, - literal_pathspecs: bool, - ) -> Result { - if let Some(path) = path { - args.push(path.as_os_str().to_owned()); + deadline: Instant, + ) -> Result<(), GitError> { + if self.bounded_config_is_enabled("core.fsmonitor", deadline)? { + return Err(GitError::Blocked { + message: "recovery is blocked because core.fsmonitor may start an uncontained process; disable it and preview recovery again, or run Git manually" + .to_owned(), + }); } - let display_args = args + for key in ["commit.gpgsign", "tag.gpgsign"] { + if self.bounded_config_is_enabled(key, deadline)? { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because {key} may start an uncontained signing process; disable it and preview recovery again, or run Git manually" + ), + }); + } + } + for relative in ["rebase-merge/gpg_sign_opt", "rebase-apply/gpg_sign_opt"] { + let path = self.git_path_until(relative, deadline)?; + if read_bounded_optional_file(&path, RECOVERY_REBASE_TODO_LIMIT, deadline)? + .is_some_and(|contents| !contents.trim().is_empty()) + { + return Err(GitError::Blocked { + message: "recovery is blocked because the active rebase requests commit signing with --gpg-sign, which may start an uncontained signer; abort and restart the rebase without signing, or run Git manually" + .to_owned(), + }); + } + } + for relative in [ + "rebase-merge/strategy", + "rebase-apply/strategy", + "rebase-merge/strategy_opts", + "rebase-apply/strategy_opts", + ] { + let path = self.git_path_until(relative, deadline)?; + let Some(value) = + read_bounded_optional_file(&path, RECOVERY_REBASE_TODO_LIMIT, deadline)? + else { + continue; + }; + if !value.trim().is_empty() { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because the active rebase persists an explicit merge strategy or strategy option in {relative}, which may start an uncontained executable; abort and restart without custom strategy settings, or run Git manually" + ), + }); + } + } + let args = vec![ + "config".to_owned(), + "--name-only".to_owned(), + "--null".to_owned(), + "--list".to_owned(), + ]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + self.ensure_recovery_external_drivers_safe_until(&args, &output, deadline)?; + + let hooks = self.recovery_hooks_path_until(deadline)?; + let enabled_hooks = RECOVERY_HOOKS .iter() - .map(|arg| arg.to_string_lossy().into_owned()) + .filter(|hook| hook_is_enabled(&hooks.join(hook))) + .copied() .collect::>(); - let mut command = Command::new("git"); - command.current_dir(&self.cwd).args(&args); - command.env("GIT_TERMINAL_PROMPT", "0"); - command.env("GIT_ASKPASS", ""); - command.env("SSH_ASKPASS", ""); - command.env("SSH_ASKPASS_REQUIRE", "never"); - if literal_pathspecs { - command.env("GIT_LITERAL_PATHSPECS", "1"); + if !enabled_hooks.is_empty() { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because executable Git hooks may start uncontained processes: {}; disable them and preview recovery again, or run Git manually", + enabled_hooks.join(", ") + ), + }); } - let output = command.output().map_err(|source| GitError::Io { - args: display_args.clone(), - source, - })?; + for relative in [ + "rebase-merge/git-rebase-todo", + "rebase-apply/git-rebase-todo", + ] { + let path = self.git_path_until(relative, deadline)?; + let Some(contents) = + read_bounded_optional_file(&path, RECOVERY_REBASE_TODO_LIMIT, deadline)? + else { + continue; + }; + if contents + .lines() + .any(|line| matches!(line.split_ascii_whitespace().next(), Some("exec" | "x"))) + { + return Err(GitError::Blocked { + message: "recovery is blocked because the remaining rebase plan contains an exec command that may start uncontained processes; remove it and preview recovery again, or run Git manually" + .to_owned(), + }); + } + } + Ok(()) + } + + fn ensure_recovery_external_drivers_safe_until( + &self, + args: &[String], + output: &BoundedCommandOutput, + deadline: Instant, + ) -> Result<(), GitError> { if !output.status.success() { - return Err(GitError::GitFailed { - args: display_args, - status: output.status, - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + return Err(output.clone().git_error(args.to_vec())); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery is blocked because Git configuration exceeds the bounded diagnostic limit, so external drivers cannot be ruled out" + .to_owned(), }); } - - Ok(RawProcessOutput { - args: display_args, - status: output.status, - stdout: output.stdout, - stderr: output.stderr, - }) + let mut names = Vec::new(); + let mut filter_keys = BTreeMap::>::new(); + for name in output.stdout.bytes.split(|byte| *byte == 0) { + if !is_recovery_external_driver_config(name) { + continue; + } + let display = String::from_utf8_lossy(name).into_owned(); + if let Some(driver) = recovery_filter_driver(name) { + filter_keys.entry(driver).or_default().push(display); + } else { + names.push(display); + } + } + if !filter_keys.is_empty() { + let configured = filter_keys.keys().cloned().collect(); + for driver in self.active_recovery_filters_until(&configured, deadline)? { + if let Some(keys) = filter_keys.get(&driver) { + names.extend(keys.iter().cloned()); + } + } + } + if !names.is_empty() { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because configured external Git drivers are active or may be activated by a recovery target tree and start uncontained processes: {}; disable the applicable attributes or remove the drivers from every Git config scope, preview recovery again, or run Git manually", + names.join(", ") + ), + }); + } + Ok(()) } - fn run_raw(&self, args: [&str; N]) -> Result { - let args = args.iter().map(|arg| (*arg).to_owned()).collect::>(); - let output = Command::new("git") - .current_dir(&self.cwd) - .env("GIT_TERMINAL_PROMPT", "0") - .env("GIT_ASKPASS", "") - .env("SSH_ASKPASS", "") - .env("SSH_ASKPASS_REQUIRE", "never") - .args(&args) - .output() - .map_err(|source| GitError::Io { - args: args.clone(), - source, - })?; + fn active_recovery_filters_until( + &self, + configured: &BTreeSet, + deadline: Instant, + ) -> Result, GitError> { + let mut sources = BTreeSet::from([None]); + match self.repository_operation_until(deadline)? { + Some(RepositoryOperation::Merge) => { + sources.insert(Some("ORIG_HEAD".to_owned())); + } + Some(RepositoryOperation::Rebase) => { + sources.insert(self.recovery_overwrite_target_until( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + deadline, + )?); + sources.extend( + self.remaining_rebase_commit_oids_until(deadline)? + .into_iter() + .map(Some), + ); + } + None => {} + } + + let mut active = BTreeSet::new(); + for source in sources { + let list_args = match source.as_deref() { + Some(source) => vec![ + "ls-tree".to_owned(), + "-r".to_owned(), + "--name-only".to_owned(), + "-z".to_owned(), + source.to_owned(), + "--".to_owned(), + ], + None => vec![ + "ls-files".to_owned(), + "--cached".to_owned(), + "-z".to_owned(), + "--".to_owned(), + ], + }; + let paths = self.run_bounded_git(list_args.clone(), deadline, true)?; + if !paths.status.success() { + return Err(paths.git_error(list_args)); + } + if paths.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery is blocked because paths requiring attribute inspection exceed the bounded output limit" + .to_owned(), + }); + } + let paths = paths + .stdout + .bytes + .split(|byte| *byte == 0) + .filter(|path| !path.is_empty()) + .collect::>(); + for chunk in paths.chunks(128) { + let mut args = vec!["check-attr".to_owned(), "-z".to_owned()]; + if let Some(source) = source.as_deref() { + args.push(format!("--source={source}")); + } + args.extend(["filter".to_owned(), "--".to_owned()]); + for path in chunk { + let path = std::str::from_utf8(path).map_err(|_| GitError::Blocked { + message: "recovery is blocked because an attribute path is not valid UTF-8" + .to_owned(), + })?; + args.push(path.to_owned()); + } + let attributes = self.run_bounded_git(args.clone(), deadline, true)?; + if !attributes.status.success() { + return Err(attributes.git_error(args)); + } + if attributes.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery is blocked because effective attributes exceed the bounded output limit" + .to_owned(), + }); + } + let fields = attributes + .stdout + .bytes + .split(|byte| *byte == 0) + .filter(|field| !field.is_empty()) + .collect::>(); + let mut triples = fields.chunks_exact(3); + for triple in &mut triples { + let driver = String::from_utf8_lossy(triple[2]).to_ascii_lowercase(); + if configured.contains(&driver) { + active.insert(driver); + } + } + if !triples.remainder().is_empty() { + return Err(GitError::Blocked { + message: + "recovery is blocked because effective attributes could not be parsed" + .to_owned(), + }); + } + } + } + Ok(active) + } + fn bounded_config_is_enabled(&self, key: &str, deadline: Instant) -> Result { + let args = vec!["config".to_owned(), "--get".to_owned(), key.to_owned()]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if output.status.code() == Some(1) { + return Ok(false); + } if !output.status.success() { - return Err(GitError::GitFailed { - args, - status: output.status, - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + return Err(output.git_error(args)); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: format!("recovery is blocked because {key} is too long"), }); } + let value = String::from_utf8_lossy(strip_byte_line_ending(&output.stdout.bytes)); + Ok(!matches!(value.trim(), "" | "0" | "false" | "no" | "off")) + } - Ok(RawGitOutput { - stdout: output.stdout, - }) + fn untracked_worktree_paths_until(&self, deadline: Instant) -> Result, GitError> { + self.other_worktree_paths_until(false, deadline) } -} -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Repository { - pub root: PathBuf, - pub branch: BranchState, - pub remotes: Vec, - pub status: WorktreeStatus, -} + fn other_worktree_paths_until( + &self, + ignored: bool, + deadline: Instant, + ) -> Result, GitError> { + let kind = if ignored { "ignored" } else { "untracked" }; + let args = self.other_worktree_paths_args(ignored); + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if !output.status.success() { + return Err(output.git_error(args)); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because {kind} worktree paths exceed the bounded output limit" + ), + }); + } + let mut paths = BTreeSet::new(); + for path in output.stdout.bytes.split(|byte| *byte == 0) { + if path.is_empty() { + continue; + } + let path = path_from_bytes(path); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked by an invalid {kind} worktree path" + ), + }); + } + paths.insert(path); + } + Ok(paths.into_iter().collect()) + } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct GitOutput { - pub status: ExitStatus, - pub stdout: String, - pub stderr: String, -} + fn other_worktree_paths_args(&self, ignored: bool) -> Vec { + let mut args = vec!["ls-files".to_owned(), "--others".to_owned()]; + if ignored { + args.push("--ignored".to_owned()); + } + args.extend([ + "--exclude-standard".to_owned(), + "--full-name".to_owned(), + "-z".to_owned(), + "--".to_owned(), + ]); + args + } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HeadTarget { - pub oid: Option, - pub reference: Option, -} + fn ensure_no_ignored_recovery_collisions_until( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + deadline: Instant, + hasher: &mut Sha256, + mut fence: Option<&mut RecoveryInputFence>, + ) -> Result<(), GitError> { + let Some(target) = self.recovery_overwrite_target_until(operation, action, deadline)? + else { + return Ok(()); + }; + let changed_args = if action == RecoveryAction::Abort { + vec![ + "ls-tree".to_owned(), + "-r".to_owned(), + "--name-only".to_owned(), + "-z".to_owned(), + target, + "--".to_owned(), + ] + } else { + vec![ + "diff".to_owned(), + "--name-only".to_owned(), + "--no-renames".to_owned(), + "--no-ext-diff".to_owned(), + "--no-textconv".to_owned(), + "-z".to_owned(), + "HEAD".to_owned(), + target, + "--".to_owned(), + ] + }; + let changed = self.run_bounded_git(changed_args.clone(), deadline, true)?; + if !changed.status.success() { + return Err(changed.git_error(changed_args)); + } + if changed.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery planning is blocked because recovery target paths exceed the bounded output limit" + .to_owned(), + }); + } + hash_field(hasher, &changed.stdout.digest); + if let Some(fence) = fence.as_deref_mut() { + fence.probes.push(RecoveryProbe { + args: changed_args, + digest: changed.stdout.digest, + status: changed.status.code(), + safety: RecoveryProbeSafety::None, + }); + } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BranchInfo { - pub name: String, - pub reference: String, - pub oid: String, - pub upstream: Option, - pub current: bool, - pub kind: BranchKind, -} + let mut candidates = BTreeSet::new(); + for path in changed.stdout.bytes.split(|byte| *byte == 0) { + if path.is_empty() { + continue; + } + let path = path_from_bytes(path); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(GitError::Blocked { + message: "recovery planning is blocked by an invalid recovery target path" + .to_owned(), + }); + } + candidates.insert(path); + } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BranchTarget { - pub name: String, - pub reference: String, - pub oid: String, - pub kind: BranchKind, -} + if operation == RepositoryOperation::Rebase && action != RecoveryAction::Abort { + let remaining_oids = match fence.as_deref() { + Some(fence) => fence.remaining_rebase_commit_oids.clone(), + None => self.remaining_rebase_commit_oids_until(deadline)?, + }; + for oid in remaining_oids { + let args = vec![ + "show".to_owned(), + "--format=".to_owned(), + "--name-only".to_owned(), + "--no-renames".to_owned(), + "--no-ext-diff".to_owned(), + "--no-textconv".to_owned(), + "-z".to_owned(), + oid, + "--".to_owned(), + ]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if !output.status.success() { + return Err(output.git_error(args)); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery planning is blocked because an intermediate rebase commit touches too many paths" + .to_owned(), + }); + } + hash_recovery_probe(hasher, &output); + if let Some(fence) = fence.as_deref_mut() { + fence.probes.push(RecoveryProbe { + args, + digest: output.stdout.digest, + status: output.status.code(), + safety: RecoveryProbeSafety::None, + }); + } + for path in output.stdout.bytes.split(|byte| *byte == 0) { + if !path.is_empty() { + candidates.insert(checked_recovery_target_path(path)?); + } + } + } + } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BranchKind { - Local, - Remote, -} + let mut collisions = BTreeSet::new(); + for chunk in candidates.into_iter().collect::>().chunks(128) { + let mut args = vec![ + "ls-files".to_owned(), + "--others".to_owned(), + "--ignored".to_owned(), + "--exclude-standard".to_owned(), + "--full-name".to_owned(), + "-z".to_owned(), + "--".to_owned(), + ]; + for path in chunk { + let Some(path) = path.to_str() else { + return Err(GitError::Blocked { + message: "recovery planning is blocked because a recovery target path is not valid UTF-8" + .to_owned(), + }); + }; + args.push(format!(":(top,literal){path}")); + } + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if !output.status.success() { + return Err(output.git_error(args)); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery planning is blocked because relevant ignored paths exceed the bounded output limit" + .to_owned(), + }); + } + hash_field(hasher, &output.stdout.digest); + if let Some(fence) = fence.as_deref_mut() { + fence.probes.push(RecoveryProbe { + args, + digest: output.stdout.digest, + status: output.status.code(), + safety: RecoveryProbeSafety::None, + }); + } + for path in output.stdout.bytes.split(|byte| *byte == 0) { + if !path.is_empty() { + collisions.insert(path_from_bytes(path)); + } + } + } -#[derive(Debug, Clone, PartialEq, Eq)] -struct RawGitOutput { - stdout: Vec, -} + if let Some(path) = collisions.first() { + return Err(GitError::Blocked { + message: format!( + "{} {} is blocked because ignored worktree path {} would be overwritten; move or remove the path, preview recovery again, or run Git manually", + operation.label(), + action.label(), + path.display() + ), + }); + } + Ok(()) + } -#[derive(Debug, Clone, PartialEq, Eq)] -struct RawProcessOutput { - args: Vec, - status: ExitStatus, - stdout: Vec, - stderr: Vec, -} - -#[derive(Debug)] -pub enum GitError { - Io { - args: Vec, - source: std::io::Error, - }, - Utf8 { - args: Vec, - stream: OutputStream, - source: FromUtf8Error, - }, - GitFailed { - args: Vec, - status: ExitStatus, - stdout: String, - stderr: String, - }, - Blocked { - message: String, - }, - Parse { - message: String, - }, -} + fn remaining_rebase_commit_oids_until( + &self, + deadline: Instant, + ) -> Result, GitError> { + for relative in [ + "rebase-merge/git-rebase-todo", + "rebase-apply/git-rebase-todo", + ] { + let path = self.git_path_until(relative, deadline)?; + let Some(contents) = + read_bounded_optional_file(&path, RECOVERY_REBASE_TODO_LIMIT, deadline)? + else { + continue; + }; + return parse_remaining_rebase_commit_oids(relative, &contents); + } + Ok(BTreeSet::new()) + } -impl Display for GitError { - fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { - match self { - Self::Io { args, source } => { - write!(formatter, "failed to run git {}: {source}", args.join(" ")) - } - Self::Utf8 { - args, - stream, - source, - } => write!( - formatter, - "git {} returned non-UTF-8 {stream}: {source}", - args.join(" ") - ), - Self::GitFailed { - args, - status, - stdout, - stderr, - .. - } => { - let detail = if !stderr.trim().is_empty() { - stderr.trim() - } else if !stdout.trim().is_empty() { - stdout.trim() - } else { - "no output" - }; - write!( - formatter, - "git {} failed with status {status}: {detail}", - args.join(" ") - ) + fn recovery_overwrite_target_until( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + deadline: Instant, + ) -> Result, GitError> { + match (operation, action) { + (RepositoryOperation::Merge, RecoveryAction::Continue) => Ok(None), + (RepositoryOperation::Merge, RecoveryAction::Abort) => Ok(Some("ORIG_HEAD".to_owned())), + (RepositoryOperation::Merge, RecoveryAction::Skip) => Ok(None), + (RepositoryOperation::Rebase, _) => { + for relative in ["rebase-merge/orig-head", "rebase-apply/orig-head"] { + let path = self.git_path_until(relative, deadline)?; + let Some(value) = + read_bounded_optional_file(&path, RECOVERY_REBASE_TODO_LIMIT, deadline)? + else { + continue; + }; + let oid = value.trim(); + if matches!(oid.len(), 40 | 64) + && oid.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Ok(Some(oid.to_owned())); + } + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because {relative} does not contain a valid object ID" + ), + }); + } + Err(GitError::Blocked { + message: "recovery planning is blocked because the original rebase target is unavailable" + .to_owned(), + }) } - Self::Blocked { message } => formatter.write_str(message), - Self::Parse { message } => write!(formatter, "failed to parse git output: {message}"), } } -} -impl Error for GitError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Io { source, .. } => Some(source), - Self::Utf8 { source, .. } => Some(source), - Self::GitFailed { .. } | Self::Blocked { .. } | Self::Parse { .. } => None, + fn ensure_recovery_git_version_until(&self, deadline: Instant) -> Result<(), GitError> { + let args = vec!["version".to_owned()]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if !output.status.success() { + return Err(output.git_error(args)); } + ensure_recovery_git_version(strip_byte_line_ending(&output.stdout.bytes)) } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OutputStream { - Stdout, - Stderr, -} -impl Display for OutputStream { - fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { - match self { - Self::Stdout => formatter.write_str("stdout"), - Self::Stderr => formatter.write_str("stderr"), + fn repository_operation_until( + &self, + deadline: Instant, + ) -> Result, GitError> { + let rebase_apply = self.git_path_until("rebase-apply", deadline)?; + if self.git_path_until("rebase-merge", deadline)?.exists() + || (rebase_apply.exists() && !rebase_apply.join("applying").exists()) + { + return Ok(Some(RepositoryOperation::Rebase)); } + if self.git_path_until("MERGE_HEAD", deadline)?.exists() { + return Ok(Some(RepositoryOperation::Merge)); + } + Ok(None) } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BranchState { - pub head: Head, - pub upstream: Option, - pub ahead: u32, - pub behind: u32, - pub unborn: bool, -} -impl Default for BranchState { - fn default() -> Self { - Self { - head: Head::Unborn, - upstream: None, - ahead: 0, - behind: 0, - unborn: false, + fn has_unresolved_conflicts_until(&self, deadline: Instant) -> Result { + let args = vec![ + "diff".to_owned(), + "--quiet".to_owned(), + "--diff-filter=U".to_owned(), + "--".to_owned(), + ]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + match output.status.code() { + Some(0) => Ok(false), + Some(1) => Ok(true), + _ => Err(output.git_error(args)), } } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Head { - Branch(String), - Detached(String), - Unborn, -} -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RepositoryOperation { - Merge, - Rebase, -} + fn git_path_until(&self, path: &str, deadline: Instant) -> Result { + let args = vec![ + "rev-parse".to_owned(), + "--path-format=absolute".to_owned(), + "--git-path".to_owned(), + path.to_owned(), + ]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if !output.status.success() { + return Err(output.git_error(args)); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because Git path {path} is too long" + ), + }); + } + Ok(path_from_bytes(strip_byte_line_ending( + &output.stdout.bytes, + ))) + } -impl RepositoryOperation { - fn label(self) -> &'static str { - match self { - Self::Merge => "merge", - Self::Rebase => "rebase", + fn recovery_hooks_path_until(&self, deadline: Instant) -> Result { + let args = vec![ + "config".to_owned(), + "--path".to_owned(), + "--get".to_owned(), + "core.hooksPath".to_owned(), + ]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if output.status.success() { + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery planning is blocked because core.hooksPath is too long" + .to_owned(), + }); + } + let path = path_from_bytes(strip_byte_line_ending(&output.stdout.bytes)); + return Ok(if path.is_absolute() { + path + } else { + let root = self.repo_root_until(deadline)?; + root.join(path) + }); + } + if output.status.code() == Some(1) { + return self.git_path_until("hooks", deadline); } + Err(output.git_error(args)) } -} -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RecoveryAction { - Continue, - Abort, - Skip, -} + fn recovery_hooks_path_fenced_until( + &self, + deadline: Instant, + hasher: &mut Sha256, + fence: Option<&mut RecoveryInputFence>, + ) -> Result { + let args = vec![ + "config".to_owned(), + "--path".to_owned(), + "--get".to_owned(), + "core.hooksPath".to_owned(), + ]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery planning is blocked because core.hooksPath is too long" + .to_owned(), + }); + } + if !output.status.success() && output.status.code() != Some(1) { + return Err(output.git_error(args)); + } + hash_recovery_probe(hasher, &output); + if let Some(fence) = fence { + fence.probes.push(RecoveryProbe { + args, + digest: output.stdout.digest, + status: output.status.code(), + safety: RecoveryProbeSafety::None, + }); + } + if output.status.success() { + let path = path_from_bytes(strip_byte_line_ending(&output.stdout.bytes)); + return Ok(if path.is_absolute() { + path + } else { + self.repo_root_until(deadline)?.join(path) + }); + } + self.git_path_until("hooks", deadline) + } -impl RecoveryAction { - fn label(self) -> &'static str { - match self { - Self::Continue => "continue", - Self::Abort => "abort", - Self::Skip => "skip", + fn git_var_path_until( + &self, + variable: &str, + deadline: Instant, + ) -> Result, GitError> { + let args = vec!["var".to_owned(), variable.to_owned()]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if output.status.code() == Some(1) { + return Ok(None); } + if !output.status.success() { + return Err(output.git_error(args)); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: format!("recovery planning is blocked because {variable} is too long"), + }); + } + let path = path_from_bytes(strip_byte_line_ending(&output.stdout.bytes)); + if !path.is_absolute() { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because {variable} is not an absolute path" + ), + }); + } + Ok(Some(path)) } -} -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Remote { - pub name: String, - pub fetch_url: Option, - pub push_url: Option, -} + fn worktree_attributes_until(&self, deadline: Instant) -> Result, GitError> { + let mut paths = BTreeSet::new(); + for selectors in [ + &["--cached", "--others", "--exclude-standard"][..], + &["--others", "--ignored", "--exclude-standard"][..], + ] { + let mut args = vec!["ls-files".to_owned()]; + args.extend(selectors.iter().map(|selector| (*selector).to_owned())); + args.extend([ + "-z".to_owned(), + "--".to_owned(), + ":(glob)**/.gitattributes".to_owned(), + ]); + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if !output.status.success() { + return Err(output.git_error(args)); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery planning is blocked because attribute paths exceed the bounded output limit" + .to_owned(), + }); + } + for path in output.stdout.bytes.split(|byte| *byte == 0) { + if path.is_empty() { + continue; + } + let path = path_from_bytes(path); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(GitError::Blocked { + message: "recovery planning is blocked by an invalid attribute path" + .to_owned(), + }); + } + paths.insert(path); + } + } + Ok(paths.into_iter().collect()) + } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorktreeStatus { - pub branch: BranchState, - pub entries: Vec, - pub operation: Option, -} + fn repo_root_until(&self, deadline: Instant) -> Result { + let args = vec!["rev-parse".to_owned(), "--show-toplevel".to_owned()]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if !output.status.success() { + return Err(output.git_error(args)); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery planning is blocked because the repository path is too long" + .to_owned(), + }); + } + Ok(path_from_bytes(strip_byte_line_ending( + &output.stdout.bytes, + ))) + } -impl WorktreeStatus { - pub fn is_clean(&self) -> bool { - self.entries.is_empty() + fn acquire_recovery_lock_until( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + deadline: Instant, + ) -> Result { + let repository_root = self.canonical_repository_root_until(deadline)?; + let path = self.recovery_lock_path(operation, action, &repository_root)?; + let directory_path = path + .parent() + .ok_or_else(|| GitError::Blocked { + message: "recovery is blocked because the BitByGit recovery lock directory is unavailable" + .to_owned(), + })? + .to_owned(); + let directory = open_recovery_lock_directory(&directory_path, operation, action)?; + let guard_path = path.with_extension("guard"); + let guard = open_recovery_lock_file(&guard_path, operation, action)?; + guard + .try_lock_exclusive() + .map_err(|source| recovery_lock_error(operation, action, source))?; + let file = open_recovery_lock_file(&path, operation, action)?; + file.try_lock_exclusive() + .map_err(|source| recovery_lock_error(operation, action, source))?; + let lock = RecoveryLock { + path, + file, + guard_path, + guard, + directory_path, + directory, + repository_root, + }; + lock.ensure_identity(&lock.repository_root, operation, action)?; + Ok(lock) } - pub fn staged_files(&self) -> Vec<&StatusEntry> { - self.entries - .iter() - .filter(|entry| { - matches!( - entry.entry_type, - StatusEntryType::Ordinary | StatusEntryType::Renamed | StatusEntryType::Copied - ) && entry.index != ChangeKind::Unmodified - }) - .collect() + fn canonical_repository_root_until(&self, deadline: Instant) -> Result { + let root = self.repo_root_until(deadline)?; + fs::canonicalize(&root).map_err(|source| GitError::Io { + args: vec!["canonicalize recovery repository identity".to_owned()], + source, + }) } - pub fn unstaged_files(&self) -> Vec<&StatusEntry> { - self.entries + fn recovery_lock_path( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + repository_root: &Path, + ) -> Result { + #[cfg(test)] + let data_dir = self + .recovery_data_dir + .clone() + .or_else(|| Some(self.cwd.join(".git").join("bitbygit-test-data"))); + #[cfg(not(test))] + let data_dir = resolve_recovery_data_dir(|name| env::var_os(name).map(PathBuf::from)); + let data_dir = data_dir.ok_or_else(|| GitError::Blocked { + message: + "recovery is blocked because BitByGit's application data directory is unavailable" + .to_owned(), + })?; + let lock_dir = data_dir.join(RECOVERY_LOCK_DIRECTORY); + fs::create_dir_all(&lock_dir) + .map_err(|source| recovery_lock_io(operation, action, source))?; + let lock_dir = fs::canonicalize(&lock_dir) + .map_err(|source| recovery_lock_io(operation, action, source))?; + if !lock_dir.is_dir() { + return Err(GitError::Blocked { + message: + "recovery is blocked because BitByGit's recovery lock path is not a directory" + .to_owned(), + }); + } + let key = recovery_repository_key(repository_root); + debug_assert!(key.bytes().all(|byte| byte.is_ascii_hexdigit())); + Ok(lock_dir.join(format!("{key}.lock"))) + } + + fn run_args(&self, args: Vec) -> Result { + self.run_args_with_editor(args, false) + } + + fn run_recovery_args_until_with( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + deadline: Instant, + before_spawn: F, + ) -> Result + where + F: FnOnce() -> Result<(), GitError>, + { + let args = vec![ + operation.label().to_owned(), + format!("--{}", action.label()), + ]; + let output = self.run_bounded_git_with(args.clone(), deadline, false, before_spawn)?; + let stdout = output.stdout.lossy_text(); + let stderr = output.stderr.lossy_text(); + if !output.status.success() { + return Err(GitError::GitFailed { + args, + status: output.status, + stdout, + stderr, + }); + } + Ok(GitOutput { + status: output.status, + stdout, + stderr, + }) + } + + fn run_bounded_git( + &self, + args: Vec, + deadline: Instant, + optional_locks: bool, + ) -> Result { + self.run_bounded_git_with(args, deadline, optional_locks, || Ok(())) + } + + fn run_bounded_git_digest( + &self, + args: Vec, + deadline: Instant, + ) -> Result { + self.run_bounded_git_with_policy(args, deadline, true, BoundedCommandPolicy::Digest, || { + Ok(()) + }) + } + + fn run_bounded_git_with( + &self, + args: Vec, + deadline: Instant, + optional_locks: bool, + before_spawn: F, + ) -> Result + where + F: FnOnce() -> Result<(), GitError>, + { + let policy = if optional_locks { + BoundedCommandPolicy::Diagnostic + } else { + BoundedCommandPolicy::RecoveryExecution + }; + self.run_bounded_git_with_policy(args, deadline, optional_locks, policy, before_spawn) + } + + fn run_bounded_git_with_policy( + &self, + args: Vec, + deadline: Instant, + optional_locks: bool, + policy: BoundedCommandPolicy, + before_spawn: F, + ) -> Result + where + F: FnOnce() -> Result<(), GitError>, + { + let mut command = Command::new("git"); + command + .current_dir(&self.cwd) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_ASKPASS", "") + .env("SSH_ASKPASS", "") + .env("SSH_ASKPASS_REQUIRE", "never") + .env("GIT_EDITOR", "true") + .env("GIT_SEQUENCE_EDITOR", "true"); + if self.isolated_test_config { + let global_config = self.cwd.join(".bitbygit-test-global-config"); + #[cfg(test)] + let global_config = self.test_global_config.clone().unwrap_or(global_config); + command + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", global_config); + } + #[cfg(all(test, unix))] + if let Some(path) = &self.test_git_exec_path { + command.env("GIT_EXEC_PATH", path); + } + if optional_locks { + command.env("GIT_OPTIONAL_LOCKS", "0"); + if args.first().is_none_or(|arg| arg != "config") { + command.args(["-c", "core.fsmonitor=false"]); + } + } else { + command.args(["-c", "maintenance.auto=false", "-c", "gc.auto=0"]); + } + command.args(&args); + if self.ssh_executable.is_some() || env::var_os("GIT_SSH_COMMAND").is_none() { + let ssh_executable = self + .ssh_executable + .as_ref() + .map(|path| shell_quote(&path.to_string_lossy())) + .unwrap_or_else(|| "ssh".to_owned()); + command.env("GIT_SSH_COMMAND", format!("{ssh_executable} {SSH_OPTIONS}")); + } + configure_process_group(&mut command); + let output_limit = if optional_locks { + RECOVERY_DIAGNOSTIC_LIMIT + } else { + self.recovery_output_limit + }; + run_bounded_command(command, args, deadline, output_limit, policy, before_spawn) + } + + fn run_args_with_editor( + &self, + args: Vec, + disable_editor: bool, + ) -> Result { + let mut command = Command::new("git"); + command + .current_dir(&self.cwd) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_ASKPASS", "") + .env("SSH_ASKPASS", "") + .env("SSH_ASKPASS_REQUIRE", "never"); + if disable_editor { + command + .env("GIT_EDITOR", "true") + .env("GIT_SEQUENCE_EDITOR", "true"); + } + if self.ssh_executable.is_some() || env::var_os("GIT_SSH_COMMAND").is_none() { + let ssh_executable = self + .ssh_executable + .as_ref() + .map(|path| shell_quote(&path.to_string_lossy())) + .unwrap_or_else(|| "ssh".to_owned()); + command.env("GIT_SSH_COMMAND", format!("{ssh_executable} {SSH_OPTIONS}")); + } + let output = command + .args(&args) + .output() + .map_err(|source| GitError::Io { + args: args.clone(), + source, + })?; + + let stdout = if disable_editor { + String::from_utf8_lossy(&output.stdout).into_owned() + } else { + String::from_utf8(output.stdout).map_err(|source| GitError::Utf8 { + args: args.clone(), + stream: OutputStream::Stdout, + source, + })? + }; + let stderr = if disable_editor { + String::from_utf8_lossy(&output.stderr).into_owned() + } else { + String::from_utf8(output.stderr).map_err(|source| GitError::Utf8 { + args: args.clone(), + stream: OutputStream::Stderr, + source, + })? + }; + + if !output.status.success() { + return Err(GitError::GitFailed { + args, + status: output.status, + stdout, + stderr, + }); + } + + Ok(GitOutput { + status: output.status, + stdout, + stderr, + }) + } + + fn run_path_args( + &self, + args: [&str; N], + path: Option<&Path>, + literal_pathspecs: bool, + ) -> Result { + let mut os_args = args.iter().map(OsString::from).collect::>(); + if let Some(path) = path { + os_args.push(OsString::from("--")); + os_args.push(path.as_os_str().to_owned()); + } + let output = self.run_os_args(os_args, None, literal_pathspecs)?; + let stdout = String::from_utf8(output.stdout).map_err(|source| GitError::Utf8 { + args: output.args.clone(), + stream: OutputStream::Stdout, + source, + })?; + let stderr = String::from_utf8(output.stderr).map_err(|source| GitError::Utf8 { + args: output.args.clone(), + stream: OutputStream::Stderr, + source, + })?; + Ok(GitOutput { + status: output.status, + stdout, + stderr, + }) + } + + fn run_paths_args( + &self, + args: [&str; N], + paths: &[PathBuf], + literal_pathspecs: bool, + ) -> Result { + let mut os_args = args.iter().map(OsString::from).collect::>(); + os_args.push(OsString::from("--")); + os_args.extend(paths.iter().map(|path| path.as_os_str().to_owned())); + let output = self.run_os_args(os_args, None, literal_pathspecs)?; + let stdout = String::from_utf8(output.stdout).map_err(|source| GitError::Utf8 { + args: output.args.clone(), + stream: OutputStream::Stdout, + source, + })?; + let stderr = String::from_utf8(output.stderr).map_err(|source| GitError::Utf8 { + args: output.args.clone(), + stream: OutputStream::Stderr, + source, + })?; + Ok(GitOutput { + status: output.status, + stdout, + stderr, + }) + } + + fn run_os_paths( + &self, + mut args: Vec, + paths: &[PathBuf], + literal_pathspecs: bool, + ) -> Result { + args.extend(paths.iter().map(|path| path.as_os_str().to_owned())); + self.run_os_args(args, None, literal_pathspecs) + } + + fn run_os_args( + &self, + mut args: Vec, + path: Option<&Path>, + literal_pathspecs: bool, + ) -> Result { + if let Some(path) = path { + args.push(path.as_os_str().to_owned()); + } + let display_args = args .iter() - .filter(|entry| { - matches!( - entry.entry_type, - StatusEntryType::Ordinary | StatusEntryType::Renamed | StatusEntryType::Copied - ) && entry.worktree != ChangeKind::Unmodified - }) - .collect() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + let mut command = Command::new("git"); + command.current_dir(&self.cwd).args(&args); + command.env("GIT_TERMINAL_PROMPT", "0"); + command.env("GIT_ASKPASS", ""); + command.env("SSH_ASKPASS", ""); + command.env("SSH_ASKPASS_REQUIRE", "never"); + if literal_pathspecs { + command.env("GIT_LITERAL_PATHSPECS", "1"); + } + let output = command.output().map_err(|source| GitError::Io { + args: display_args.clone(), + source, + })?; + + if !output.status.success() { + return Err(GitError::GitFailed { + args: display_args, + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + + Ok(RawProcessOutput { + args: display_args, + status: output.status, + stdout: output.stdout, + stderr: output.stderr, + }) + } + + fn run_raw(&self, args: [&str; N]) -> Result { + let args = args.iter().map(|arg| (*arg).to_owned()).collect::>(); + let output = Command::new("git") + .current_dir(&self.cwd) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_ASKPASS", "") + .env("SSH_ASKPASS", "") + .env("SSH_ASKPASS_REQUIRE", "never") + .args(&args) + .output() + .map_err(|source| GitError::Io { + args: args.clone(), + source, + })?; + + if !output.status.success() { + return Err(GitError::GitFailed { + args, + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + + Ok(RawGitOutput { + stdout: output.stdout, + }) + } +} + +fn is_recovery_external_driver_config(name: &[u8]) -> bool { + let name = String::from_utf8_lossy(name).to_ascii_lowercase(); + name == "diff.external" + || (name.starts_with("filter.") + && [".clean", ".smudge", ".process"] + .iter() + .any(|suffix| name.ends_with(suffix))) + || (name.starts_with("diff.") + && [".command", ".textconv"] + .iter() + .any(|suffix| name.ends_with(suffix))) + || (name.starts_with("merge.") && name.ends_with(".driver")) +} + +fn recovery_filter_driver(name: &[u8]) -> Option { + let name = String::from_utf8_lossy(name).to_ascii_lowercase(); + let name = name.strip_prefix("filter.")?; + let driver = [".clean", ".smudge", ".process"] + .into_iter() + .find_map(|suffix| name.strip_suffix(suffix))?; + (!driver.is_empty()).then(|| driver.to_owned()) +} + +fn hash_recovery_probe(hasher: &mut Sha256, output: &BoundedCommandOutput) { + hash_field( + hasher, + &output.status.code().unwrap_or(i32::MIN).to_le_bytes(), + ); + hash_field(hasher, &output.stdout.digest); +} + +fn ensure_recovery_probe_observation_safe( + safety: RecoveryProbeSafety, + args: &[String], + output: &BoundedCommandOutput, +) -> Result<(), GitError> { + match safety { + RecoveryProbeSafety::None => { + if !output.status.success() && output.status.code() != Some(1) { + return Err(output.clone().git_error(args.to_vec())); + } + } + RecoveryProbeSafety::DisabledConfig(key) => { + if output.stdout.truncated { + return Err(GitError::Blocked { + message: format!("recovery is blocked because {key} is too long"), + }); + } + if output.status.code() == Some(1) { + return Ok(()); + } + if !output.status.success() { + return Err(output.clone().git_error(args.to_vec())); + } + let value = String::from_utf8_lossy(strip_byte_line_ending(&output.stdout.bytes)); + if !matches!(value.trim(), "" | "0" | "false" | "no" | "off") { + let risk = if key == "core.fsmonitor" { + "an uncontained process" + } else { + "an uncontained signing process" + }; + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because {key} may start {risk}; disable it and preview recovery again, or run Git manually" + ), + }); + } + } + RecoveryProbeSafety::ExternalDrivers => { + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery is blocked because Git configuration exceeds the bounded diagnostic limit, so external drivers cannot be ruled out" + .to_owned(), + }); + } + if !output.status.success() { + return Err(output.clone().git_error(args.to_vec())); + } + let names = output + .stdout + .bytes + .split(|byte| *byte == 0) + .filter(|name| { + is_recovery_external_driver_config(name) + && recovery_filter_driver(name).is_none() + }) + .map(|name| String::from_utf8_lossy(name).into_owned()) + .collect::>(); + if !names.is_empty() { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because configured external Git drivers may be activated by a recovery target tree and start uncontained processes: {}; remove them from every Git config scope, preview recovery again, or run Git manually", + names.join(", ") + ), + }); + } + } + } + Ok(()) +} + +fn checked_recovery_target_path(path: &[u8]) -> Result { + let path = path_from_bytes(path); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(GitError::Blocked { + message: "recovery planning is blocked by an invalid recovery target path".to_owned(), + }); + } + Ok(path) +} + +fn parse_remaining_rebase_commit_oids( + relative: &str, + contents: &str, +) -> Result, GitError> { + let mut oids = BTreeSet::new(); + for line in contents.lines() { + let mut fields = line.split_ascii_whitespace(); + let Some(command) = fields.next() else { + continue; + }; + let oid = match command { + "pick" | "p" | "reword" | "r" | "edit" | "e" | "squash" | "s" => fields.next(), + "fixup" | "f" => fields.find(|field| !matches!(*field, "-C" | "-c")), + "merge" | "m" => { + let fields = fields.collect::>(); + fields + .windows(2) + .find_map(|pair| matches!(pair[0], "-C" | "-c").then_some(pair[1])) + } + _ => None, + }; + if let Some(oid) = oid { + if oid.len() < 4 || !oid.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because {relative} contains an invalid commit object ID" + ), + }); + } + oids.insert(oid.to_owned()); + } + } + Ok(oids) +} + +fn run_bounded_command( + mut command: Command, + args: Vec, + deadline: Instant, + output_limit: usize, + policy: BoundedCommandPolicy, + before_spawn: F, +) -> Result +where + F: FnOnce() -> Result<(), GitError>, +{ + if Instant::now() >= deadline { + return Err(GitError::TimedOut { args }); + } + let capture_limit = if policy == BoundedCommandPolicy::Diagnostic { + RECOVERY_DIAGNOSTIC_CAPTURE_LIMIT + } else { + usize::MAX + }; + let digest_all_output = policy != BoundedCommandPolicy::RecoveryExecution; + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if Instant::now() >= deadline { + return Err(GitError::TimedOut { args }); + } + before_spawn()?; + if Instant::now() >= deadline { + return Err(GitError::TimedOut { args }); + } + let mut child = spawn_contained(command).map_err(|source| GitError::Io { + args: args.clone(), + source, + })?; + let (stdout, stderr) = take_contained_output(&mut child).map_err(|source| GitError::Io { + args: args.clone(), + source, + })?; + let capture_exceeded = Arc::new(AtomicBool::new(false)); + let stdout_exceeded = Arc::clone(&capture_exceeded); + let stderr_exceeded = Arc::clone(&capture_exceeded); + let stdout_reader = thread::spawn(move || { + drain_bounded_output( + stdout, + output_limit, + capture_limit, + digest_all_output, + stdout_exceeded, + ) + }); + let stderr_reader = thread::spawn(move || { + drain_bounded_output( + stderr, + output_limit, + capture_limit, + digest_all_output, + stderr_exceeded, + ) + }); + + let mut output_limit_hit = false; + let status = if policy == BoundedCommandPolicy::RecoveryExecution { + // The deadline protects the checks before spawn. Killing Git after a + // mutating recovery starts can leave a partial worktree or stale lock. + match child.wait() { + Ok(status) => status, + Err(source) => { + kill_process_tree(&mut child); + let _result = child.wait(); + kill_process_tree(&mut child); + let _result = join_capture_reader(stdout_reader, &args); + let _result = join_capture_reader(stderr_reader, &args); + return Err(GitError::Io { args, source }); + } + } + } else { + loop { + if policy == BoundedCommandPolicy::Diagnostic + && capture_exceeded.load(Ordering::Acquire) + { + output_limit_hit = true; + kill_process_tree(&mut child); + break child.wait().map_err(|source| GitError::Io { + args: args.clone(), + source, + })?; + } + if let Some(status) = child.try_wait().map_err(|source| GitError::Io { + args: args.clone(), + source, + })? { + break status; + } + let now = Instant::now(); + if now >= deadline { + kill_process_tree(&mut child); + let _result = child.wait(); + kill_process_tree(&mut child); + join_capture_reader(stdout_reader, &args)?; + join_capture_reader(stderr_reader, &args)?; + return Err(GitError::TimedOut { args }); + } + let delay = Duration::from_millis(5).min(deadline.saturating_duration_since(now)); + thread::sleep(delay); + } + }; + // End the contained process tree before joining the drains so descendants + // cannot keep inherited pipe writers open. + kill_process_tree(&mut child); + let stdout = join_capture_reader(stdout_reader, &args)?; + let stderr = join_capture_reader(stderr_reader, &args)?; + if policy == BoundedCommandPolicy::Diagnostic + && (output_limit_hit || capture_exceeded.load(Ordering::Acquire)) + { + return Err(GitError::Blocked { + message: format!( + "git {} output exceeded the bounded capture limit", + args.join(" ") + ), + }); + } + + Ok(BoundedCommandOutput { + status, + stdout, + stderr, + }) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum BoundedCommandPolicy { + Diagnostic, + Digest, + RecoveryExecution, +} + +fn drain_bounded_output( + mut reader: impl Read, + output_limit: usize, + capture_limit: usize, + digest_all_output: bool, + exceeded: Arc, +) -> io::Result { + let mut hasher = Sha256::new(); + let mut bytes = Vec::with_capacity(output_limit.min(capture_limit).min(8192)); + let mut total = 0usize; + let mut buffer = [0; 8192]; + loop { + let count = reader.read(&mut buffer)?; + if count == 0 { + break; + } + let within_limit = capture_limit.saturating_sub(total).min(count); + if digest_all_output { + hasher.update(&buffer[..within_limit]); + } + let retained = output_limit.saturating_sub(bytes.len()).min(within_limit); + bytes.extend_from_slice(&buffer[..retained]); + total = total.saturating_add(count); + if total > capture_limit { + exceeded.store(true, Ordering::Release); + } + } + if !digest_all_output { + hasher.update(&bytes); + } + Ok(CapturedStream { + bytes, + digest: hasher.finalize().into(), + truncated: total > output_limit, + }) +} + +fn join_capture_reader( + reader: thread::JoinHandle>, + args: &[String], +) -> Result { + reader + .join() + .map_err(|_| recovery_output_io(args, io::Error::other("output drainer panicked")))? + .map_err(|source| recovery_output_io(args, source)) +} + +fn recovery_output_io(args: &[String], source: io::Error) -> GitError { + GitError::Io { + args: args.to_vec(), + source, + } +} + +#[cfg(unix)] +fn configure_process_group(command: &mut Command) { + command.process_group(0); +} + +#[cfg(not(unix))] +fn configure_process_group(_command: &mut Command) {} + +#[cfg(unix)] +fn spawn_contained(mut command: Command) -> io::Result { + command.spawn() +} + +#[cfg(unix)] +fn take_contained_output(child: &mut Child) -> io::Result<(ChildStdout, ChildStderr)> { + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("contained command has no stdout pipe"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::other("contained command has no stderr pipe"))?; + Ok((stdout, stderr)) +} + +#[cfg(windows)] +fn spawn_contained(mut command: Command) -> io::Result { + command.group().kill_on_drop(true).spawn() +} + +#[cfg(windows)] +fn take_contained_output(child: &mut GroupChild) -> io::Result<(ChildStdout, ChildStderr)> { + let child = child.inner(); + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("contained command has no stdout pipe"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::other("contained command has no stderr pipe"))?; + Ok((stdout, stderr)) +} + +#[cfg(not(any(unix, windows)))] +fn spawn_contained(mut command: Command) -> io::Result { + command.spawn() +} + +#[cfg(not(any(unix, windows)))] +fn take_contained_output( + child: &mut std::process::Child, +) -> io::Result<(ChildStdout, ChildStderr)> { + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("contained command has no stdout pipe"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::other("contained command has no stderr pipe"))?; + Ok((stdout, stderr)) +} + +#[cfg(unix)] +fn kill_process_tree(child: &mut Child) { + if let Some(pid) = Pid::from_raw(child.id() as i32) { + let _result = kill_process_group(pid, Signal::Kill); + } + let _result = child.kill(); +} + +#[cfg(windows)] +fn kill_process_tree(child: &mut GroupChild) { + let _result = child.kill(); +} + +#[cfg(not(any(unix, windows)))] +fn kill_process_tree(child: &mut std::process::Child) { + let _result = child.kill(); +} + +#[cfg(any(unix, windows))] +fn ensure_recovery_execution_supported( + _operation: RepositoryOperation, + _action: RecoveryAction, +) -> Result<(), GitError> { + Ok(()) +} + +#[cfg(any(unix, windows))] +fn ensure_recovery_planning_supported() -> Result<(), GitError> { + Ok(()) +} + +fn ensure_recovery_git_version(version: &[u8]) -> Result<(), GitError> { + let version = String::from_utf8_lossy(version); + let parsed = version.strip_prefix("git version ").and_then(|version| { + let mut components = version.split('.'); + Some(( + components.next()?.parse().ok()?, + components.next()?.parse().ok()?, + )) + }); + if parsed.is_some_and(|version| version >= RECOVERY_MINIMUM_GIT_VERSION) { + return Ok(()); + } + Err(GitError::Blocked { + message: format!( + "recovery requires Git {}.{} or newer because exact attribute-state fingerprinting is unavailable in older Git versions; upgrade Git or run recovery manually", + RECOVERY_MINIMUM_GIT_VERSION.0, RECOVERY_MINIMUM_GIT_VERSION.1 + ), + }) +} + +#[cfg(not(any(unix, windows)))] +fn ensure_recovery_planning_supported() -> Result<(), GitError> { + Err(GitError::Blocked { + message: format!( + "recovery planning and execution are blocked on {} because BitByGit cannot provide hard output and descendant-process bounds; inspect the repository and run the corresponding Git recovery command manually, or use BitByGit on Linux", + env::consts::OS + ), + }) +} + +#[cfg(not(any(unix, windows)))] +fn ensure_recovery_execution_supported( + operation: RepositoryOperation, + action: RecoveryAction, +) -> Result<(), GitError> { + Err(GitError::Blocked { + message: format!( + "{} {} is blocked on {} because BitByGit cannot provide hard output and descendant-process bounds on this platform; inspect the repository state and run `git {} --{}` manually, or use BitByGit on Linux", + operation.label(), + action.label(), + env::consts::OS, + operation.label(), + action.label() + ), + }) +} + +struct RecoveryLock { + path: PathBuf, + file: fs::File, + guard_path: PathBuf, + guard: fs::File, + directory_path: PathBuf, + directory: RecoveryFile, + repository_root: PathBuf, +} + +impl RecoveryLock { + fn ensure_identity( + &self, + current_repository_root: &Path, + operation: RepositoryOperation, + action: RecoveryAction, + ) -> Result<(), GitError> { + let opened = self + .file + .metadata() + .map_err(|source| recovery_lock_io(operation, action, source))?; + let current = fs::symlink_metadata(&self.path) + .map_err(|source| recovery_lock_io(operation, action, source))?; + let opened_guard = self + .guard + .metadata() + .map_err(|source| recovery_lock_io(operation, action, source))?; + let current_guard = fs::symlink_metadata(&self.guard_path) + .map_err(|source| recovery_lock_io(operation, action, source))?; + let current_directory = + open_recovery_lock_directory(&self.directory_path, operation, action)?; + let same_lock = same_recovery_lock_identity(&self.file, &self.path, &opened, ¤t) + .map_err(|source| recovery_lock_io(operation, action, source))?; + let same_guard = same_recovery_lock_identity( + &self.guard, + &self.guard_path, + &opened_guard, + ¤t_guard, + ) + .map_err(|source| recovery_lock_io(operation, action, source))?; + let same_directory = self + .directory + .same_path_identity(¤t_directory) + .map_err(|source| recovery_lock_io(operation, action, source))?; + if current_repository_root != self.repository_root + || !self.directory.metadata.is_dir() + || !current_directory.metadata.is_dir() + || !same_directory + || !opened.is_file() + || !current.is_file() + || current.file_type().is_symlink() + || !same_lock + || !opened_guard.is_file() + || !current_guard.is_file() + || current_guard.file_type().is_symlink() + || !same_guard + || recovery_repository_key(current_repository_root) + != recovery_lock_key_from_path(&self.path).unwrap_or_default() + { + return Err(GitError::Blocked { + message: format!( + "{} {} is blocked because the BitByGit recovery lock identity changed", + operation.label(), + action.label() + ), + }); + } + #[cfg(unix)] + if opened.nlink() != 1 || opened_guard.nlink() != 1 { + return Err(GitError::Blocked { + message: format!( + "{} {} is blocked because the BitByGit recovery lock identity changed", + operation.label(), + action.label() + ), + }); + } + Ok(()) + } +} + +fn open_recovery_lock_directory( + path: &Path, + operation: RepositoryOperation, + action: RecoveryAction, +) -> Result { + match open_recovery_file(path) { + Ok(opened) if opened.metadata.is_dir() => Ok(opened), + Ok(_) | Err(RecoveryOpenError::Missing | RecoveryOpenError::Symlink) => { + Err(GitError::Blocked { + message: format!( + "{} {} is blocked because the BitByGit recovery lock identity changed", + operation.label(), + action.label() + ), + }) + } + Err(RecoveryOpenError::Io(source)) => Err(recovery_lock_io(operation, action, source)), + } +} + +#[cfg(windows)] +fn same_recovery_lock_identity( + opened: &fs::File, + path: &Path, + _opened_metadata: &fs::Metadata, + _path_metadata: &fs::Metadata, +) -> io::Result { + let opened = same_file::Handle::from_file(opened.try_clone()?)?; + let current = same_file::Handle::from_path(path)?; + Ok(opened == current) +} + +#[cfg(not(windows))] +fn same_recovery_lock_identity( + _opened: &fs::File, + _path: &Path, + opened_metadata: &fs::Metadata, + path_metadata: &fs::Metadata, +) -> io::Result { + Ok(same_recovery_file(opened_metadata, path_metadata)) +} + +#[cfg(unix)] +fn open_recovery_lock_file( + path: &Path, + operation: RepositoryOperation, + action: RecoveryAction, +) -> Result { + open( + path, + OFlags::CREATE | OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOFOLLOW, + Mode::from_bits_truncate(0o600), + ) + .map(fs::File::from) + .map_err(|source| recovery_lock_io(operation, action, source.into())) +} + +#[cfg(not(unix))] +fn open_recovery_lock_file( + path: &Path, + operation: RepositoryOperation, + action: RecoveryAction, +) -> Result { + if fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) { + return Err(GitError::Blocked { + message: format!( + "{} {} is blocked because a BitByGit recovery lock path is a symlink", + operation.label(), + action.label() + ), + }); + } + let mut options = fs::OpenOptions::new(); + options.create(true).truncate(false).read(true).write(true); + #[cfg(windows)] + options.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE); + options + .open(path) + .map_err(|source| recovery_lock_io(operation, action, source)) +} + +fn recovery_lock_error( + operation: RepositoryOperation, + action: RecoveryAction, + source: io::Error, +) -> GitError { + if source.raw_os_error() == fs2::lock_contended_error().raw_os_error() { + GitError::Blocked { + message: format!( + "{} {} is blocked because another BitByGit recovery is active for this repository", + operation.label(), + action.label() + ), + } + } else { + recovery_lock_io(operation, action, source) + } +} + +fn recovery_lock_io( + operation: RepositoryOperation, + action: RecoveryAction, + source: io::Error, +) -> GitError { + GitError::Io { + args: vec![format!( + "{} {} BitByGit recovery lock", + operation.label(), + action.label() + )], + source, + } +} + +fn resolve_recovery_data_dir(mut env: impl FnMut(&str) -> Option) -> Option { + env("BITBYGIT_DATA_DIR") + .or_else(|| env("XDG_DATA_HOME").map(|path| path.join("bitbygit"))) + .or_else(|| env("APPDATA").map(|path| path.join("bitbygit").join("data"))) + .or_else(|| env("HOME").map(|path| path.join(".local").join("share").join("bitbygit"))) +} + +fn recovery_repository_key(repository_root: &Path) -> String { + let digest = Sha256::digest(repository_root.as_os_str().as_encoded_bytes()); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn recovery_lock_key_from_path(path: &Path) -> Option<&str> { + let name = path.file_name()?.to_str()?; + let key = name.strip_suffix(".lock")?; + (key.len() == 64 && key.bytes().all(|byte| byte.is_ascii_hexdigit())).then_some(key) +} + +fn read_bounded_optional_file( + path: &Path, + limit: u64, + deadline: Instant, +) -> Result, GitError> { + let opened = match open_recovery_file(path) { + Ok(opened) => opened, + Err(RecoveryOpenError::Missing) => return Ok(None), + Err(RecoveryOpenError::Symlink) => { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because {} is a symlink instead of a regular file; replace it with a regular file and preview recovery again, or run Git manually", + path.display() + ), + }); + } + Err(RecoveryOpenError::Io(source)) => return Err(recovery_state_io(path, source)), + }; + if !opened.metadata.is_file() { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because {} is not a regular file; replace it with a regular file and preview recovery again, or run Git manually", + path.display() + ), + }); + } + let mut opened = opened; + let Some(file) = opened.file.as_mut() else { + return Err(recovery_state_io( + path, + io::Error::other("regular recovery input has no open descriptor"), + )); + }; + let mut contents = Vec::new(); + let mut buffer = [0; 8192]; + while contents.len() as u64 <= limit { + if Instant::now() >= deadline { + return Err(GitError::TimedOut { + args: vec!["recovery-process-configuration".to_owned()], + }); + } + let requested = buffer.len().min( + limit + .saturating_add(1) + .saturating_sub(contents.len() as u64) as usize, + ); + if requested == 0 { + break; + } + let count = file + .read(&mut buffer[..requested]) + .map_err(|source| recovery_state_io(path, source))?; + if count == 0 { + break; + } + contents.extend_from_slice(&buffer[..count]); + } + if contents.len() as u64 > limit { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because {} exceeds the bounded inspection limit", + path.display() + ), + }); + } + let final_metadata = file + .metadata() + .map_err(|source| recovery_state_io(path, source))?; + let reopened = match open_recovery_file(path) { + Ok(reopened) => reopened, + Err(RecoveryOpenError::Io(source)) => return Err(recovery_state_io(path, source)), + Err(RecoveryOpenError::Missing | RecoveryOpenError::Symlink) => { + return Err(recovery_path_changed(path)); + } + }; + if !same_recovery_file(&opened.metadata, &final_metadata) + || !opened + .same_path_identity(&reopened) + .map_err(|source| recovery_state_io(path, source))? + { + return Err(recovery_path_changed(path)); + } + Ok(Some(String::from_utf8_lossy(&contents).into_owned())) +} + +#[cfg(all(test, unix))] +fn hash_recovery_path( + hasher: &mut Sha256, + label: &[u8], + path: &Path, + deadline: Instant, + entries: &mut usize, +) -> Result<(), GitError> { + hash_recovery_path_fenced(hasher, label, path, deadline, entries, None) +} + +fn hash_recovery_path_fenced( + hasher: &mut Sha256, + label: &[u8], + path: &Path, + deadline: Instant, + entries: &mut usize, + fence: Option<&mut RecoveryInputFence>, +) -> Result<(), GitError> { + hash_recovery_path_with_fence( + hasher, + label, + path, + deadline, + entries, + fence, + &mut |_, _| Ok(()), + ) +} + +#[cfg(test)] +fn hash_recovery_path_with( + hasher: &mut Sha256, + label: &[u8], + path: &Path, + deadline: Instant, + entries: &mut usize, + after_contents: &mut F, +) -> Result<(), GitError> +where + F: FnMut(&Path, RecoveryPathKind) -> Result<(), GitError>, +{ + hash_recovery_path_with_fence(hasher, label, path, deadline, entries, None, after_contents) +} + +fn hash_recovery_path_with_fence( + hasher: &mut Sha256, + label: &[u8], + path: &Path, + deadline: Instant, + entries: &mut usize, + fence: Option<&mut RecoveryInputFence>, + after_contents: &mut F, +) -> Result<(), GitError> +where + F: FnMut(&Path, RecoveryPathKind) -> Result<(), GitError>, +{ + let mut byte_budget = None; + let mut context = RecoveryHashContext { + deadline, + entries, + follow_symlinks: true, + byte_budget: &mut byte_budget, + byte_budget_error: "recovery planning is blocked because recovery input data exceeds its fingerprint limit", + fence, + }; + hash_recovery_path_inner(hasher, label, path, 0, &mut context, after_contents) +} + +#[derive(Default)] +struct RecoveryInputFence { + inputs: Vec, + probes: Vec, + remaining_rebase_commit_oids: BTreeSet, + spawn_boundary_input: Option, +} + +struct RecoveryProbe { + args: Vec, + digest: [u8; 32], + status: Option, + safety: RecoveryProbeSafety, +} + +#[derive(Clone, Copy)] +enum RecoveryProbeSafety { + None, + DisabledConfig(&'static str), + ExternalDrivers, +} + +enum RecoveryInput { + Missing(PathBuf), + Symlink { + path: PathBuf, + target: PathBuf, + metadata: fs::Metadata, + }, + Opened { + path: PathBuf, + opened: RecoveryFile, + }, +} + +impl RecoveryInputFence { + fn retain_input(&mut self, label: &[u8], input: RecoveryInput) { + if label == b"index" { + self.spawn_boundary_input = Some(self.inputs.len()); + } + self.inputs.push(input); + } + + fn validate(&self, git: &Git, deadline: Instant) -> Result<(), GitError> { + for probe in &self.probes { + let output = match probe.safety { + RecoveryProbeSafety::None => { + git.run_bounded_git_digest(probe.args.clone(), deadline)? + } + _ => git.run_bounded_git(probe.args.clone(), deadline, true)?, + }; + match probe.safety { + RecoveryProbeSafety::ExternalDrivers => { + git.ensure_recovery_external_drivers_safe_until(&probe.args, &output, deadline)? + } + _ => ensure_recovery_probe_observation_safe(probe.safety, &probe.args, &output)?, + } + if output.status.code() != probe.status { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because Git {} status changed while it was fingerprinted", + probe.args.join(" ") + ), + }); + } + if !output.status.success() && output.status.code() != Some(1) { + return Err(output.git_error(probe.args.clone())); + } + if output.stdout.digest != probe.digest { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because Git {} output changed while it was fingerprinted", + probe.args.join(" ") + ), + }); + } + } + for (index, input) in self.inputs.iter().enumerate() { + if Some(index) != self.spawn_boundary_input { + Self::validate_input(input, deadline)?; + } + } + // Git consumes the index immediately on startup, so recheck it after + // every other retained input at the spawn boundary. + if let Some(index) = self.spawn_boundary_input { + Self::validate_input(&self.inputs[index], deadline)?; + } + Ok(()) + } + + fn validate_input(input: &RecoveryInput, deadline: Instant) -> Result<(), GitError> { + if Instant::now() >= deadline { + return Err(GitError::TimedOut { + args: vec!["recovery-state".to_owned()], + }); + } + match input { + RecoveryInput::Missing(path) => match fs::symlink_metadata(path) { + Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()), + Ok(_) => Err(recovery_path_changed(path)), + Err(source) => Err(recovery_state_io(path, source)), + }, + RecoveryInput::Symlink { + path, + target, + metadata, + } => { + let current_target = + fs::read_link(path).map_err(|source| recovery_state_io(path, source))?; + let current_metadata = recovery_symlink_metadata(path)?; + if current_target != *target || !same_recovery_file(metadata, ¤t_metadata) { + return Err(recovery_path_changed(path)); + } + Ok(()) + } + RecoveryInput::Opened { path, opened } => { + let final_metadata = opened + .file + .as_ref() + .map_or_else(|| fs::symlink_metadata(path), fs::File::metadata) + .map_err(|source| recovery_state_io(path, source))?; + let current = match open_recovery_file(path) { + Ok(current) => current, + Err(RecoveryOpenError::Io(source)) => { + return Err(recovery_state_io(path, source)); + } + Err(RecoveryOpenError::Missing | RecoveryOpenError::Symlink) => { + return Err(recovery_path_changed(path)); + } + }; + if !same_recovery_file(&opened.metadata, &final_metadata) + || !opened + .same_path_identity(¤t) + .map_err(|source| recovery_state_io(path, source))? + { + return Err(recovery_path_changed(path)); + } + Ok(()) + } + } + } +} + +struct RecoveryHashContext<'a> { + deadline: Instant, + entries: &'a mut usize, + follow_symlinks: bool, + byte_budget: &'a mut Option, + byte_budget_error: &'static str, + fence: Option<&'a mut RecoveryInputFence>, +} + +fn hash_recovery_path_inner( + hasher: &mut Sha256, + label: &[u8], + path: &Path, + symlink_depth: usize, + context: &mut RecoveryHashContext<'_>, + after_contents: &mut F, +) -> Result<(), GitError> +where + F: FnMut(&Path, RecoveryPathKind) -> Result<(), GitError>, +{ + ensure_recovery_fingerprint_capacity(context.deadline, context.entries)?; + hash_field(hasher, label); + let opened = match open_recovery_file(path) { + Ok(opened) => opened, + Err(RecoveryOpenError::Missing) => { + hash_field(hasher, b"missing"); + if let Some(fence) = context.fence.as_deref_mut() { + fence.retain_input(label, RecoveryInput::Missing(path.to_owned())); + } + return Ok(()); + } + Err(RecoveryOpenError::Symlink) => { + if symlink_depth >= 16 { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because {} has too many symlink levels", + path.display() + ), + }); + } + *context.entries += 1; + hash_field(hasher, b"symlink"); + let metadata = recovery_symlink_metadata(path)?; + let target = fs::read_link(path).map_err(|source| recovery_state_io(path, source))?; + let read_metadata = recovery_symlink_metadata(path)?; + if !same_recovery_file(&metadata, &read_metadata) { + return Err(recovery_path_changed(path)); + } + hash_field(hasher, target.as_os_str().as_encoded_bytes()); + ensure_recovery_fingerprint_capacity(context.deadline, context.entries)?; + if context.follow_symlinks { + let resolved_target = if target.is_absolute() { + target.clone() + } else { + path.parent() + .unwrap_or_else(|| Path::new(".")) + .join(&target) + }; + hash_recovery_path_inner( + hasher, + label, + &resolved_target, + symlink_depth + 1, + context, + after_contents, + )?; + } + after_contents(path, RecoveryPathKind::Symlink)?; + let final_target = + fs::read_link(path).map_err(|source| recovery_state_io(path, source))?; + let final_metadata = recovery_symlink_metadata(path)?; + if final_target != target || !same_recovery_file(&metadata, &final_metadata) { + return Err(recovery_path_changed(path)); + } + if let Some(fence) = context.fence.as_deref_mut() { + fence.retain_input( + label, + RecoveryInput::Symlink { + path: path.to_owned(), + target, + metadata, + }, + ); + } + return Ok(()); + } + Err(RecoveryOpenError::Io(source)) => return Err(recovery_state_io(path, source)), + }; + let mut opened = opened; + let metadata = opened.metadata.clone(); + *context.entries += 1; + ensure_recovery_hook_observation_safe(label, &metadata)?; + hash_field(hasher, &metadata.len().to_le_bytes()); + #[cfg(unix)] + hash_field(hasher, &metadata.mode().to_le_bytes()); + #[cfg(not(unix))] + hash_field(hasher, &[u8::from(metadata.permissions().readonly())]); + + if metadata.is_file() { + hash_field(hasher, b"file"); + let Some(file) = opened.file.as_mut() else { + return Err(recovery_state_io( + path, + io::Error::other("regular recovery input has no open descriptor"), + )); + }; + let mut buffer = [0; 64 * 1024]; + let inspect_process_safety = recovery_control_input_requires_safety_inspection(label); + let mut process_safety_contents = Vec::new(); + loop { + if Instant::now() >= context.deadline { + return Err(GitError::TimedOut { + args: vec!["recovery-state".to_owned()], + }); + } + let count = file + .read(&mut buffer) + .map_err(|source| recovery_state_io(path, source))?; + if count == 0 { + break; + } + if let Some(remaining) = context.byte_budget.as_mut() { + let Some(updated) = remaining.checked_sub(count as u64) else { + return Err(GitError::Blocked { + message: context.byte_budget_error.to_owned(), + }); + }; + *remaining = updated; + } + hasher.update(&buffer[..count]); + if inspect_process_safety { + if process_safety_contents.len().saturating_add(count) + > RECOVERY_REBASE_TODO_LIMIT as usize + { + return Err(GitError::Blocked { + message: "recovery is blocked because a process-controlling rebase input exceeds the bounded inspection limit" + .to_owned(), + }); + } + process_safety_contents.extend_from_slice(&buffer[..count]); + } + } + ensure_recovery_control_observation_safe(label, &process_safety_contents)?; + if matches!( + label, + b"rebase-merge/git-rebase-todo" | b"rebase-apply/git-rebase-todo" + ) { + let relative = String::from_utf8_lossy(label); + let contents = String::from_utf8_lossy(&process_safety_contents); + let oids = parse_remaining_rebase_commit_oids(&relative, &contents)?; + if let Some(fence) = context.fence.as_deref_mut() { + fence.remaining_rebase_commit_oids.extend(oids); + } + } + let final_metadata = file + .metadata() + .map_err(|source| recovery_state_io(path, source))?; + after_contents(path, RecoveryPathKind::File)?; + let reopened = match open_recovery_file(path) { + Ok(reopened) => reopened, + Err(RecoveryOpenError::Io(source)) => return Err(recovery_state_io(path, source)), + Err(RecoveryOpenError::Missing | RecoveryOpenError::Symlink) => { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because {} changed while it was fingerprinted", + path.display() + ), + }); + } + }; + if !same_recovery_file(&metadata, &final_metadata) + || !opened + .same_path_identity(&reopened) + .map_err(|source| recovery_state_io(path, source))? + { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because {} changed while it was fingerprinted", + path.display() + ), + }); + } + } else if metadata.is_dir() { + hash_field(hasher, b"directory"); + let mut children = recovery_directory_children( + opened.file.as_ref(), + path, + context.deadline, + context.entries, + )?; + children.sort(); + for child in children { + ensure_recovery_fingerprint_capacity(context.deadline, context.entries)?; + let mut child_label = label.to_vec(); + child_label.push(b'/'); + child_label.extend_from_slice(child.as_encoded_bytes()); + hash_recovery_path_inner( + hasher, + &child_label, + &path.join(child), + symlink_depth, + context, + after_contents, + )?; + } + after_contents(path, RecoveryPathKind::Directory)?; + let final_metadata = match opened.file.as_ref() { + Some(file) => file + .metadata() + .map_err(|source| recovery_state_io(path, source))?, + None => fs::symlink_metadata(path).map_err(|source| recovery_state_io(path, source))?, + }; + let reopened = match open_recovery_file(path) { + Ok(reopened) => reopened, + Err(RecoveryOpenError::Io(source)) => return Err(recovery_state_io(path, source)), + Err(RecoveryOpenError::Missing | RecoveryOpenError::Symlink) => { + return Err(recovery_path_changed(path)); + } + }; + if !final_metadata.is_dir() + || !same_recovery_file(&metadata, &final_metadata) + || !opened + .same_path_identity(&reopened) + .map_err(|source| recovery_state_io(path, source))? + { + return Err(recovery_path_changed(path)); + } + } else { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because {} is not a regular file or directory", + path.display() + ), + }); + } + if let Some(fence) = context.fence.as_deref_mut() { + fence.retain_input( + label, + RecoveryInput::Opened { + path: path.to_owned(), + opened, + }, + ); + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RecoveryPathKind { + File, + Symlink, + Directory, +} + +fn recovery_symlink_metadata(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|source| recovery_state_io(path, source))?; + if !metadata.file_type().is_symlink() { + return Err(recovery_path_changed(path)); + } + Ok(metadata) +} + +fn recovery_path_changed(path: &Path) -> GitError { + GitError::Blocked { + message: format!( + "recovery planning is blocked because {} changed while it was fingerprinted", + path.display() + ), + } +} + +fn recovery_state_changed(operation: RepositoryOperation, action: RecoveryAction) -> GitError { + GitError::Blocked { + message: format!( + "{} {} is blocked because repository state changed after preview", + operation.label(), + action.label() + ), + } +} + +struct RecoveryFile { + file: Option, + metadata: fs::Metadata, + #[cfg(windows)] + identity: same_file::Handle, +} + +impl RecoveryFile { + #[cfg(windows)] + fn same_path_identity(&self, current: &Self) -> io::Result { + Ok(self.identity == current.identity) + } + + #[cfg(unix)] + fn same_path_identity(&self, current: &Self) -> io::Result { + Ok(self.metadata.dev() == current.metadata.dev() + && self.metadata.ino() == current.metadata.ino()) + } + + #[cfg(not(any(unix, windows)))] + fn same_path_identity(&self, current: &Self) -> io::Result { + Ok(same_recovery_file(&self.metadata, ¤t.metadata)) + } +} + +enum RecoveryOpenError { + Missing, + Symlink, + Io(io::Error), +} + +#[cfg(unix)] +fn recovery_directory_children( + file: Option<&fs::File>, + path: &Path, + deadline: Instant, + entries: &usize, +) -> Result, GitError> { + use rustix::fs::Dir; + + let Some(file) = file else { + return Err(recovery_state_io( + path, + io::Error::other("recovery directory has no open descriptor"), + )); + }; + let mut children = Vec::new(); + let directory = + Dir::read_from(file).map_err(|source| recovery_state_io(path, io::Error::from(source)))?; + for child in directory { + ensure_recovery_fingerprint_capacity(deadline, entries)?; + if children.len() + *entries >= RECOVERY_STATE_ENTRY_LIMIT { + return Err(GitError::Blocked { + message: "recovery planning is blocked because recovery state has too many entries" + .to_owned(), + }); + } + let child = child.map_err(|source| recovery_state_io(path, io::Error::from(source)))?; + let name = child.file_name().to_bytes(); + if name != b"." && name != b".." { + children.push(OsString::from_vec(name.to_vec())); + } + } + Ok(children) +} + +#[cfg(not(unix))] +fn recovery_directory_children( + _file: Option<&fs::File>, + path: &Path, + deadline: Instant, + entries: &usize, +) -> Result, GitError> { + let mut children = Vec::new(); + for child in fs::read_dir(path).map_err(|source| recovery_state_io(path, source))? { + ensure_recovery_fingerprint_capacity(deadline, entries)?; + if children.len() + *entries >= RECOVERY_STATE_ENTRY_LIMIT { + return Err(GitError::Blocked { + message: "recovery planning is blocked because recovery state has too many entries" + .to_owned(), + }); + } + children.push( + child + .map_err(|source| recovery_state_io(path, source))? + .file_name(), + ); + } + Ok(children) +} + +#[cfg(unix)] +fn open_recovery_file(path: &Path) -> Result { + let descriptor = match open( + path, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + Mode::empty(), + ) { + Ok(descriptor) => descriptor, + Err(Errno::NOENT) => return Err(RecoveryOpenError::Missing), + Err(Errno::LOOP) => return Err(RecoveryOpenError::Symlink), + Err(source) => return Err(RecoveryOpenError::Io(source.into())), + }; + let file = fs::File::from(descriptor); + let metadata = file.metadata().map_err(RecoveryOpenError::Io)?; + Ok(RecoveryFile { + file: Some(file), + metadata, + }) +} + +#[cfg(windows)] +fn open_recovery_file(path: &Path) -> Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(source) if source.kind() == io::ErrorKind::NotFound => { + return Err(RecoveryOpenError::Missing); + } + Err(source) => return Err(RecoveryOpenError::Io(source)), + }; + if metadata.file_type().is_symlink() { + return Err(RecoveryOpenError::Symlink); + } + let file = fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(path) + .map_err(RecoveryOpenError::Io)?; + let metadata = file.metadata().map_err(RecoveryOpenError::Io)?; + let identity = same_file::Handle::from_file(file.try_clone().map_err(RecoveryOpenError::Io)?) + .map_err(RecoveryOpenError::Io)?; + Ok(RecoveryFile { + file: Some(file), + metadata, + identity, + }) +} + +#[cfg(not(any(unix, windows)))] +fn open_recovery_file(path: &Path) -> Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(source) if source.kind() == io::ErrorKind::NotFound => { + return Err(RecoveryOpenError::Missing); + } + Err(source) => return Err(RecoveryOpenError::Io(source)), + }; + if metadata.file_type().is_symlink() { + return Err(RecoveryOpenError::Symlink); + } + let file = if metadata.is_file() { + Some(fs::File::open(path).map_err(RecoveryOpenError::Io)?) + } else { + None + }; + Ok(RecoveryFile { file, metadata }) +} + +#[cfg(unix)] +fn same_recovery_file(left: &fs::Metadata, right: &fs::Metadata) -> bool { + left.dev() == right.dev() + && left.ino() == right.ino() + && left.mode() == right.mode() + && left.len() == right.len() + && left.mtime() == right.mtime() + && left.mtime_nsec() == right.mtime_nsec() + && left.ctime() == right.ctime() + && left.ctime_nsec() == right.ctime_nsec() +} + +#[cfg(windows)] +fn same_recovery_file(left: &fs::Metadata, right: &fs::Metadata) -> bool { + left.is_file() == right.is_file() + && left.len() == right.len() + && left.permissions().readonly() == right.permissions().readonly() + && left.modified().ok() == right.modified().ok() +} + +#[cfg(not(any(unix, windows)))] +fn same_recovery_file(left: &fs::Metadata, right: &fs::Metadata) -> bool { + left.is_file() == right.is_file() + && left.len() == right.len() + && left.permissions().readonly() == right.permissions().readonly() + && left.modified().ok() == right.modified().ok() +} + +fn ensure_recovery_fingerprint_capacity( + deadline: Instant, + entries: &usize, +) -> Result<(), GitError> { + if Instant::now() >= deadline { + return Err(GitError::TimedOut { + args: vec!["recovery-state".to_owned()], + }); + } + if *entries >= RECOVERY_STATE_ENTRY_LIMIT { + return Err(GitError::Blocked { + message: "recovery planning is blocked because recovery state has too many entries" + .to_owned(), + }); + } + Ok(()) +} + +fn hash_field(hasher: &mut Sha256, value: &[u8]) { + hasher.update((value.len() as u64).to_le_bytes()); + hasher.update(value); +} + +fn recovery_state_io(path: &Path, source: io::Error) -> GitError { + GitError::Io { + args: vec![format!("recovery-state {}", path.display())], + source, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Repository { + pub root: PathBuf, + pub branch: BranchState, + pub remotes: Vec, + pub status: WorktreeStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitOutput { + pub status: ExitStatus, + pub stdout: String, + pub stderr: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HeadTarget { + pub oid: Option, + pub reference: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BranchInfo { + pub name: String, + pub reference: String, + pub oid: String, + pub upstream: Option, + pub current: bool, + pub kind: BranchKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BranchTarget { + pub name: String, + pub reference: String, + pub oid: String, + pub kind: BranchKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BranchKind { + Local, + Remote, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RawGitOutput { + stdout: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RawProcessOutput { + args: Vec, + status: ExitStatus, + stdout: Vec, + stderr: Vec, +} + +#[derive(Debug, Clone)] +struct BoundedCommandOutput { + status: ExitStatus, + stdout: CapturedStream, + stderr: CapturedStream, +} + +impl BoundedCommandOutput { + fn git_error(self, args: Vec) -> GitError { + GitError::GitFailed { + args, + status: self.status, + stdout: self.stdout.lossy_text(), + stderr: self.stderr.lossy_text(), + } + } +} + +#[derive(Debug, Clone)] +struct CapturedStream { + bytes: Vec, + digest: [u8; 32], + truncated: bool, +} + +impl CapturedStream { + fn lossy_text(self) -> String { + let mut text = String::from_utf8_lossy(&self.bytes).into_owned(); + if self.truncated { + if !text.ends_with('\n') && !text.is_empty() { + text.push('\n'); + } + text.push_str("[output truncated by bitbygit]\n"); + } + text + } +} + +#[derive(Debug)] +pub enum GitError { + Io { + args: Vec, + source: std::io::Error, + }, + Utf8 { + args: Vec, + stream: OutputStream, + source: FromUtf8Error, + }, + GitFailed { + args: Vec, + status: ExitStatus, + stdout: String, + stderr: String, + }, + TimedOut { + args: Vec, + }, + Blocked { + message: String, + }, + Parse { + message: String, + }, +} + +impl Display for GitError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Io { args, source } => { + write!(formatter, "failed to run git {}: {source}", args.join(" ")) + } + Self::Utf8 { + args, + stream, + source, + } => write!( + formatter, + "git {} returned non-UTF-8 {stream}: {source}", + args.join(" ") + ), + Self::GitFailed { + args, + status, + stdout, + stderr, + .. + } => { + let detail = if !stderr.trim().is_empty() { + stderr.trim() + } else if !stdout.trim().is_empty() { + stdout.trim() + } else { + "no output" + }; + write!( + formatter, + "git {} failed with status {status}: {detail}", + args.join(" ") + ) + } + Self::TimedOut { args } => { + write!(formatter, "git {} timed out", args.join(" ")) + } + Self::Blocked { message } => formatter.write_str(message), + Self::Parse { message } => write!(formatter, "failed to parse git output: {message}"), + } + } +} + +impl Error for GitError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Io { source, .. } => Some(source), + Self::Utf8 { source, .. } => Some(source), + Self::GitFailed { .. } + | Self::TimedOut { .. } + | Self::Blocked { .. } + | Self::Parse { .. } => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputStream { + Stdout, + Stderr, +} + +impl Display for OutputStream { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Stdout => formatter.write_str("stdout"), + Self::Stderr => formatter.write_str("stderr"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BranchState { + pub head: Head, + pub upstream: Option, + pub ahead: u32, + pub behind: u32, + pub unborn: bool, +} + +impl Default for BranchState { + fn default() -> Self { + Self { + head: Head::Unborn, + upstream: None, + ahead: 0, + behind: 0, + unborn: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Head { + Branch(String), + Detached(String), + Unborn, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RepositoryOperation { + Merge, + Rebase, +} + +impl RepositoryOperation { + fn label(self) -> &'static str { + match self { + Self::Merge => "merge", + Self::Rebase => "rebase", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecoveryAction { + Continue, + Abort, + Skip, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecoveryState { + fingerprint: [u8; 32], +} + +impl RecoveryAction { + fn label(self) -> &'static str { + match self { + Self::Continue => "continue", + Self::Abort => "abort", + Self::Skip => "skip", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Remote { + pub name: String, + pub fetch_url: Option, + pub push_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeStatus { + pub branch: BranchState, + pub entries: Vec, + pub operation: Option, +} + +impl WorktreeStatus { + pub fn is_clean(&self) -> bool { + self.entries.is_empty() + } + + pub fn staged_files(&self) -> Vec<&StatusEntry> { + self.entries + .iter() + .filter(|entry| { + matches!( + entry.entry_type, + StatusEntryType::Ordinary | StatusEntryType::Renamed | StatusEntryType::Copied + ) && entry.index != ChangeKind::Unmodified + }) + .collect() + } + + pub fn unstaged_files(&self) -> Vec<&StatusEntry> { + self.entries + .iter() + .filter(|entry| { + matches!( + entry.entry_type, + StatusEntryType::Ordinary | StatusEntryType::Renamed | StatusEntryType::Copied + ) && entry.worktree != ChangeKind::Unmodified + }) + .collect() + } + + pub fn untracked_files(&self) -> Vec<&StatusEntry> { + self.entries + .iter() + .filter(|entry| entry.entry_type == StatusEntryType::Untracked) + .collect() + } + + pub fn conflicted_files(&self) -> Vec<&StatusEntry> { + self.entries + .iter() + .filter(|entry| entry.entry_type == StatusEntryType::Conflict) + .collect() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusEntry { + pub path: PathBuf, + pub original_path: Option, + pub index: ChangeKind, + pub worktree: ChangeKind, + pub entry_type: StatusEntryType, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusEntryType { + Ordinary, + Renamed, + /// Porcelain v2 reserves the `2` record family for renames and copies. Git + /// status does not normally emit copies, but the parser keeps the model + /// explicit for forward-compatible callers. + Copied, + Untracked, + Ignored, + Conflict, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChangeKind { + Unmodified, + Modified, + Added, + Deleted, + Renamed, + Copied, + Unmerged, + Untracked, + Ignored, + Unknown(char), +} + +#[cfg(test)] +fn parse_status(input: &str) -> Result { + parse_status_bytes(input.as_bytes()) +} + +fn parse_status_bytes(input: &[u8]) -> Result { + let mut branch = BranchState::default(); + let mut oid = None; + let mut entries = Vec::new(); + let nul_delimited = input.contains(&0); + let mut records: Box + '_> = if nul_delimited { + Box::new( + input + .split(|byte| *byte == 0) + .filter(|record| !record.is_empty()), + ) + } else { + Box::new( + input + .split(|byte| *byte == b'\n') + .filter(|record| !record.is_empty()), + ) + }; + + while let Some(line) = records.next() { + if let Some(value) = strip_bytes_prefix(line, b"# branch.oid ") { + let value = parse_utf8(value, "branch oid")?; + oid = Some(value.to_owned()); + if value == "(initial)" { + branch.unborn = true; + } + continue; + } + + if let Some(value) = strip_bytes_prefix(line, b"# branch.head ") { + let value = parse_utf8(value, "branch head")?; + branch.head = if value == "(detached)" { + Head::Detached(oid.clone().unwrap_or_default()) + } else { + Head::Branch(value.to_owned()) + }; + continue; + } + + if let Some(value) = strip_bytes_prefix(line, b"# branch.upstream ") { + let value = parse_utf8(value, "branch upstream")?; + branch.upstream = Some(value.to_owned()); + continue; + } + + if let Some(value) = strip_bytes_prefix(line, b"# branch.ab ") { + let value = parse_utf8(value, "branch ahead/behind")?; + let (ahead, behind) = parse_ahead_behind(value)?; + branch.ahead = ahead; + branch.behind = behind; + continue; + } + + if let Some(path) = strip_bytes_prefix(line, b"? ") { + entries.push(StatusEntry { + path: path_from_bytes(path), + original_path: None, + index: ChangeKind::Unmodified, + worktree: ChangeKind::Untracked, + entry_type: StatusEntryType::Untracked, + }); + continue; + } + + if let Some(path) = strip_bytes_prefix(line, b"! ") { + entries.push(StatusEntry { + path: path_from_bytes(path), + original_path: None, + index: ChangeKind::Unmodified, + worktree: ChangeKind::Ignored, + entry_type: StatusEntryType::Ignored, + }); + continue; + } + + if line.starts_with(b"1 ") { + entries.push(parse_ordinary_entry(line)?); + continue; + } + + if line.starts_with(b"2 ") { + let original_path = if nul_delimited { + Some( + records + .next() + .ok_or_else(|| parse_error("renamed entry missing original path"))?, + ) + } else { + None + }; + entries.push(parse_renamed_entry(line, original_path)?); + continue; + } + + if line.starts_with(b"u ") { + entries.push(parse_conflict_entry(line)?); + continue; + } + + if !line.starts_with(b"# ") { + return Err(parse_error(&format!( + "unknown porcelain v2 record: {}", + String::from_utf8_lossy(line) + ))); + } + } + + Ok(WorktreeStatus { + branch, + entries, + operation: None, + }) +} + +fn parse_ahead_behind(value: &str) -> Result<(u32, u32), GitError> { + let mut parts = value.split_whitespace(); + let ahead = parts + .next() + .ok_or_else(|| parse_error("missing ahead count"))? + .strip_prefix('+') + .ok_or_else(|| parse_error("ahead count must start with +"))? + .parse::() + .map_err(|_| parse_error("ahead count is not a number"))?; + let behind = parts + .next() + .ok_or_else(|| parse_error("missing behind count"))? + .strip_prefix('-') + .ok_or_else(|| parse_error("behind count must start with -"))? + .parse::() + .map_err(|_| parse_error("behind count is not a number"))?; + Ok((ahead, behind)) +} + +fn parse_ordinary_entry(line: &[u8]) -> Result { + let parts = splitn_bytes(line, b' ', 9); + let xy = parts + .get(1) + .copied() + .ok_or_else(|| parse_error("ordinary entry missing status"))?; + let path = parts + .get(8) + .copied() + .ok_or_else(|| parse_error("ordinary entry missing path"))?; + let (index, worktree) = parse_xy(xy)?; + + Ok(StatusEntry { + path: path_from_bytes(path), + original_path: None, + index, + worktree, + entry_type: StatusEntryType::Ordinary, + }) +} + +fn parse_renamed_entry(line: &[u8], original_path: Option<&[u8]>) -> Result { + let parts = splitn_bytes(line, b' ', 10); + let xy = parts + .get(1) + .copied() + .ok_or_else(|| parse_error("renamed entry missing status"))?; + let path = parts + .get(9) + .copied() + .ok_or_else(|| parse_error("renamed entry missing path"))?; + let (path, original_path) = match original_path { + Some(original_path) => (path, original_path), + None => split_once_byte(path, b'\t') + .ok_or_else(|| parse_error("renamed entry missing original path"))?, + }; + let (index, worktree) = parse_xy(xy)?; + + Ok(StatusEntry { + path: path_from_bytes(path), + original_path: Some(path_from_bytes(original_path)), + index, + worktree, + entry_type: if index == ChangeKind::Copied || worktree == ChangeKind::Copied { + StatusEntryType::Copied + } else { + StatusEntryType::Renamed + }, + }) +} + +fn parse_conflict_entry(line: &[u8]) -> Result { + let parts = splitn_bytes(line, b' ', 11); + let path = parts + .get(10) + .copied() + .ok_or_else(|| parse_error("conflict entry missing path"))?; + + Ok(StatusEntry { + path: path_from_bytes(path), + original_path: None, + index: ChangeKind::Unmerged, + worktree: ChangeKind::Unmerged, + entry_type: StatusEntryType::Conflict, + }) +} + +fn parse_xy(value: &[u8]) -> Result<(ChangeKind, ChangeKind), GitError> { + let index = value + .first() + .copied() + .ok_or_else(|| parse_error("missing index status"))?; + let worktree = value + .get(1) + .copied() + .ok_or_else(|| parse_error("missing worktree status"))?; + Ok((change_kind(index), change_kind(worktree))) +} + +fn change_kind(value: u8) -> ChangeKind { + match value { + b'.' => ChangeKind::Unmodified, + b'M' => ChangeKind::Modified, + b'A' => ChangeKind::Added, + b'D' => ChangeKind::Deleted, + b'R' => ChangeKind::Renamed, + b'C' => ChangeKind::Copied, + b'U' => ChangeKind::Unmerged, + b'?' => ChangeKind::Untracked, + b'!' => ChangeKind::Ignored, + other => ChangeKind::Unknown(char::from(other)), + } +} + +fn parse_remotes(input: &str) -> Vec { + let mut remotes = BTreeMap::::new(); + + for line in input.lines() { + let Some((name, rest)) = line.split_once('\t') else { + continue; + }; + let Some((url, kind)) = rest.rsplit_once(' ') else { + continue; + }; + + let remote = remotes.entry(name.to_owned()).or_insert_with(|| Remote { + name: name.to_owned(), + fetch_url: None, + push_url: None, + }); + + match kind { + "(fetch)" => remote.fetch_url = Some(url.to_owned()), + "(push)" => remote.push_url = Some(url.to_owned()), + _ => {} + } + } + + remotes.into_values().collect() +} + +fn parse_branches(input: &str) -> Result, GitError> { + let mut branches = Vec::new(); + for line in input.lines().filter(|line| !line.trim().is_empty()) { + let branch = parse_branch_line(line)?; + if branch.kind == BranchKind::Remote && branch.name.ends_with("/HEAD") { + continue; + } + branches.push(branch); + } + Ok(branches) +} + +fn parse_branch_line(line: &str) -> Result { + let fields = line.split('\0').collect::>(); + let [reference, oid, upstream, head] = fields.as_slice() else { + return Err(GitError::Parse { + message: "git branch list output has unexpected fields".to_owned(), + }); + }; + let (kind, name) = if let Some(name) = reference.strip_prefix("refs/heads/") { + (BranchKind::Local, name) + } else if let Some(name) = reference.strip_prefix("refs/remotes/") { + (BranchKind::Remote, name) + } else { + return Err(GitError::Parse { + message: format!("unsupported branch reference: {reference}"), + }); + }; + Ok(BranchInfo { + name: name.to_owned(), + reference: (*reference).to_owned(), + oid: (*oid).to_owned(), + upstream: (!upstream.is_empty()).then(|| (*upstream).to_owned()), + current: *head == "*", + kind, + }) +} + +fn local_name_for_remote_branch(name: &str) -> Option<&str> { + name.split_once('/') + .map(|(_remote, branch)| branch) + .filter(|branch| !branch.is_empty()) +} + +fn strip_byte_line_ending(value: &[u8]) -> &[u8] { + value.strip_suffix(b"\n").unwrap_or(value) +} + +fn strip_bytes_prefix<'a>(value: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> { + value.strip_prefix(prefix) +} + +fn parse_utf8<'a>(value: &'a [u8], description: &str) -> Result<&'a str, GitError> { + std::str::from_utf8(value).map_err(|_| parse_error(&format!("{description} is not UTF-8"))) +} + +fn splitn_bytes(value: &[u8], delimiter: u8, count: usize) -> Vec<&[u8]> { + value.splitn(count, |byte| *byte == delimiter).collect() +} + +fn split_once_byte(value: &[u8], delimiter: u8) -> Option<(&[u8], &[u8])> { + let index = value.iter().position(|byte| *byte == delimiter)?; + Some((&value[..index], &value[index + 1..])) +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +#[cfg(unix)] +fn path_from_bytes(value: &[u8]) -> PathBuf { + PathBuf::from(OsString::from_vec(value.to_vec())) +} + +#[cfg(not(unix))] +fn path_from_bytes(value: &[u8]) -> PathBuf { + PathBuf::from(String::from_utf8_lossy(value).into_owned()) +} + +fn parse_error(message: &str) -> GitError { + GitError::Parse { + message: message.to_owned(), + } +} + +fn remote_tracking_ref(remote: &str, branch: &str) -> String { + format!("refs/remotes/{remote}/{branch}") +} + +fn force_with_lease_arg(branch: &str, expected_remote_oid: Option<&str>) -> String { + format!( + "--force-with-lease=refs/heads/{branch}:{}", + expected_remote_oid.unwrap_or("") + ) +} + +fn combine_outputs(first: GitOutput, second: GitOutput) -> GitOutput { + let stdout = [first.stdout.trim(), second.stdout.trim()] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n"); + let stderr = [first.stderr.trim(), second.stderr.trim()] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n"); + GitOutput { + status: second.status, + stdout, + stderr, + } +} + +#[cfg(unix)] +fn hook_is_enabled(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + + fs::metadata(path) + .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(not(unix))] +fn hook_is_enabled(path: &Path) -> bool { + fs::metadata(path) + .map(|metadata| metadata.is_file()) + .unwrap_or(false) +} + +fn ensure_recovery_hook_observation_safe( + label: &[u8], + metadata: &fs::Metadata, +) -> Result<(), GitError> { + let Some(hook) = RECOVERY_HOOKS + .iter() + .find(|hook| label.strip_prefix(b"hooks/") == Some(hook.as_bytes())) + else { + return Ok(()); + }; + #[cfg(unix)] + let enabled = metadata.is_file() && metadata.mode() & 0o111 != 0; + #[cfg(not(unix))] + let enabled = metadata.is_file(); + if enabled { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because executable Git hooks may start uncontained processes: {hook}; disable them and preview recovery again, or run Git manually" + ), + }); + } + Ok(()) +} + +fn recovery_control_input_requires_safety_inspection(label: &[u8]) -> bool { + matches!( + label, + b"rebase-merge/gpg_sign_opt" + | b"rebase-apply/gpg_sign_opt" + | b"rebase-merge/strategy" + | b"rebase-apply/strategy" + | b"rebase-merge/strategy_opts" + | b"rebase-apply/strategy_opts" + | b"rebase-merge/git-rebase-todo" + | b"rebase-apply/git-rebase-todo" + ) +} + +fn ensure_recovery_control_observation_safe(label: &[u8], contents: &[u8]) -> Result<(), GitError> { + if !recovery_control_input_requires_safety_inspection(label) { + return Ok(()); + } + let nonempty = contents.iter().any(|byte| !byte.is_ascii_whitespace()); + if matches!( + label, + b"rebase-merge/gpg_sign_opt" | b"rebase-apply/gpg_sign_opt" + ) && nonempty + { + return Err(GitError::Blocked { + message: "recovery is blocked because the active rebase requests commit signing with --gpg-sign, which may start an uncontained signer; abort and restart the rebase without signing, or run Git manually" + .to_owned(), + }); + } + if matches!(label, b"rebase-merge/strategy" | b"rebase-apply/strategy") && nonempty { + return Err(GitError::Blocked { + message: "recovery is blocked because the active rebase persists an explicit merge strategy, which may start an uncontained executable; abort and restart without custom strategy settings, or run Git manually" + .to_owned(), + }); + } + if matches!( + label, + b"rebase-merge/strategy_opts" | b"rebase-apply/strategy_opts" + ) && nonempty + { + return Err(GitError::Blocked { + message: "recovery is blocked because the active rebase persists an explicit merge strategy option, which may start an uncontained executable; abort and restart without custom strategy settings, or run Git manually" + .to_owned(), + }); + } + if matches!( + label, + b"rebase-merge/git-rebase-todo" | b"rebase-apply/git-rebase-todo" + ) && contents.split(|byte| *byte == b'\n').any(|line| { + matches!( + line.split(|byte| byte.is_ascii_whitespace()) + .find(|field| !field.is_empty()), + Some(b"exec" | b"x") + ) + }) { + return Err(GitError::Blocked { + message: "recovery is blocked because the remaining rebase plan contains an exec command that may start uncontained processes; remove it and preview recovery again, or run Git manually" + .to_owned(), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::Path; + use std::process::Command; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[cfg(unix)] + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + static NEXT_REPO_ID: AtomicUsize = AtomicUsize::new(0); + + #[test] + fn parses_clean_status() -> Result<(), Box> { + let status = parse_status( + "# branch.oid 1234567\n# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n", + )?; + + assert!(status.is_clean()); + assert_eq!(status.branch.head, Head::Branch("main".to_owned())); + assert_eq!(status.branch.upstream, Some("origin/main".to_owned())); + assert_eq!(status.branch.ahead, 0); + assert_eq!(status.branch.behind, 0); + assert_eq!(status.operation, None); + Ok(()) + } + + #[test] + fn parses_dirty_unstaged_file() -> Result<(), Box> { + let status = parse_status( + "# branch.oid 1234567\n# branch.head main\n1 .M N... 100644 100644 100644 abc abc README.md\n", + )?; + + assert_eq!(status.unstaged_files().len(), 1); + assert_eq!(status.entries[0].path, PathBuf::from("README.md")); + assert_eq!(status.entries[0].worktree, ChangeKind::Modified); + Ok(()) + } + + #[test] + fn parses_staged_file() -> Result<(), Box> { + let status = parse_status( + "# branch.oid 1234567\n# branch.head main\n1 A. N... 000000 100644 100644 zero abc src/main.rs\n", + )?; + + assert_eq!(status.staged_files().len(), 1); + assert_eq!(status.entries[0].index, ChangeKind::Added); + Ok(()) + } + + #[test] + fn parses_untracked_file() -> Result<(), Box> { + let status = parse_status("# branch.head main\n? notes.txt\n")?; + + assert_eq!(status.untracked_files().len(), 1); + assert_eq!(status.entries[0].entry_type, StatusEntryType::Untracked); + Ok(()) + } + + #[test] + fn parses_renamed_file() -> Result<(), Box> { + let status = parse_status( + "# branch.head main\n2 R. N... 100644 100644 100644 abc def R100 new.txt\told.txt\n", + )?; + + assert_eq!(status.entries[0].entry_type, StatusEntryType::Renamed); + assert_eq!(status.entries[0].path, PathBuf::from("new.txt")); + assert_eq!( + status.entries[0].original_path, + Some(PathBuf::from("old.txt")) + ); + Ok(()) + } + + #[test] + fn parses_conflicted_file() -> Result<(), Box> { + let status = parse_status( + "# branch.head main\nu UU N... 100644 100644 100644 100644 one two three conflict.txt\n", + )?; + + assert_eq!(status.conflicted_files().len(), 1); + assert_eq!(status.staged_files().len(), 0); + assert_eq!(status.entries[0].entry_type, StatusEntryType::Conflict); + Ok(()) + } + + #[test] + fn parses_detached_head() -> Result<(), Box> { + let status = parse_status("# branch.oid abc123\n# branch.head (detached)\n")?; + + assert_eq!(status.branch.head, Head::Detached("abc123".to_owned())); + Ok(()) + } + + #[test] + fn parses_unborn_branch() -> Result<(), Box> { + let status = parse_status("# branch.oid (initial)\n# branch.head main\n")?; + + assert_eq!(status.branch.head, Head::Branch("main".to_owned())); + assert!(status.branch.unborn); + Ok(()) + } + + #[test] + fn parses_nul_delimited_raw_paths() -> Result<(), Box> { + let status = parse_status("# branch.head main\0? café.txt\0? tab\tname.txt\0")?; + + assert_eq!(status.untracked_files().len(), 2); + assert_eq!(status.entries[0].path, PathBuf::from("café.txt")); + assert_eq!(status.entries[1].path, PathBuf::from("tab\tname.txt")); + Ok(()) + } + + #[test] + fn parses_nul_delimited_rename() -> Result<(), Box> { + let status = parse_status(concat!( + "# branch.head main\0", + "2 R. N... 100644 100644 100644 abc def R100 new\tname.txt\0", + "old name.txt\0" + ))?; + + assert_eq!(status.entries[0].entry_type, StatusEntryType::Renamed); + assert_eq!(status.entries[0].path, PathBuf::from("new\tname.txt")); + assert_eq!( + status.entries[0].original_path, + Some(PathBuf::from("old name.txt")) + ); + Ok(()) + } + + #[test] + fn parses_copied_file() -> Result<(), Box> { + let status = parse_status(concat!( + "# branch.head main\0", + "2 C. N... 100644 100644 100644 abc def C100 copy.txt\0", + "source.txt\0" + ))?; + + assert_eq!(status.entries[0].entry_type, StatusEntryType::Copied); + assert_eq!(status.staged_files().len(), 1); + Ok(()) + } + + #[test] + fn parses_remotes() { + let remotes = parse_remotes( + "origin\thttps://github.com/cosentinode/bitbygit.git (fetch)\norigin\tgit@github.com:cosentinode/bitbygit.git (push)\n", + ); + + assert_eq!(remotes.len(), 1); + assert_eq!(remotes[0].name, "origin"); + assert_eq!( + remotes[0].fetch_url, + Some("https://github.com/cosentinode/bitbygit.git".to_owned()) + ); + assert_eq!( + remotes[0].push_url, + Some("git@github.com:cosentinode/bitbygit.git".to_owned()) + ); + } + + #[test] + fn parses_branches_and_skips_remote_head() -> Result<(), Box> { + let branches = parse_branches( + "refs/heads/main\x001111111111111111111111111111111111111111\x00origin/main\x00*\nrefs/remotes/origin/main\x002222222222222222222222222222222222222222\x00\x00\nrefs/remotes/origin/HEAD\x002222222222222222222222222222222222222222\x00\x00\n", + )?; + + assert_eq!(branches.len(), 2); + assert_eq!(branches[0].name, "main"); + assert_eq!(branches[0].kind, BranchKind::Local); + assert!(branches[0].current); + assert_eq!(branches[0].upstream.as_deref(), Some("origin/main")); + assert_eq!(branches[1].name, "origin/main"); + assert_eq!(branches[1].kind, BranchKind::Remote); + Ok(()) + } + + #[test] + fn branch_target_blocks_ambiguous_local_and_remote_names() -> Result<(), Box> { + let repo = TempRepo::new()?; + repo.run(["init", "-b", "main"])?; + repo.run(["config", "user.email", "bitbygit@example.invalid"])?; + repo.run(["config", "user.name", "bitbygit test"])?; + repo.write("README.md", "initial\n")?; + repo.run(["add", "README.md"])?; + repo.run(["commit", "-m", "initial"])?; + repo.run(["branch", "origin/main"])?; + repo.run(["update-ref", "refs/remotes/origin/main", "HEAD"])?; + let git = Git::new(repo.path()); + + let result = git.branch_target("origin/main"); + + let Err(error) = result else { + return Err("expected ambiguous branch guardrail".into()); + }; + assert!(error.to_string().contains("ambiguous")); + Ok(()) + } + + #[test] + fn reads_repository_state_from_temp_repo() -> Result<(), Box> { + let repo = TempRepo::new()?; + repo.run(["init", "-b", "main"])?; + repo.run(["config", "user.email", "bitbygit@example.invalid"])?; + repo.run(["config", "user.name", "bitbygit test"])?; + repo.write("README.md", "initial\n")?; + repo.run(["add", "README.md"])?; + repo.run(["commit", "-m", "initial"])?; + repo.write("README.md", "changed\n")?; + repo.write("staged.txt", "staged\n")?; + repo.run(["add", "staged.txt"])?; + repo.write("untracked.txt", "untracked\n")?; + + let git = Git::new(repo.path()); + let state = git.repository()?; + + assert_eq!(state.root, repo.path()); + assert_eq!(state.branch.head, Head::Branch("main".to_owned())); + assert_eq!(state.status.staged_files().len(), 1); + assert_eq!(state.status.unstaged_files().len(), 1); + assert_eq!(state.status.untracked_files().len(), 1); + Ok(()) + } + + #[test] + fn branch_workflows_create_checkout_merge_and_rebase() -> Result<(), Box> { + let repo = TempRepo::new()?; + repo.run(["init", "-b", "main"])?; + repo.run(["config", "user.email", "bitbygit@example.invalid"])?; + repo.run(["config", "user.name", "bitbygit test"])?; + repo.write("README.md", "initial\n")?; + repo.run(["add", "README.md"])?; + repo.run(["commit", "-m", "initial"])?; + let git = Git::new(repo.path()); + + git.create_branch("feature", None, &git.head_target()?)?; + repo.write("feature.txt", "feature\n")?; + repo.run(["add", "feature.txt"])?; + repo.run(["commit", "-m", "feature"])?; + let feature = git.branch_target("feature")?.ok_or("missing feature")?; + let main = git.branch_target("main")?.ok_or("missing main")?; + git.checkout_branch(&main, &git.head_target()?)?; + git.merge_ff_only(&feature, &git.head_target()?)?; + assert!(repo.path().join("feature.txt").exists()); + + git.create_branch("topic", None, &git.head_target()?)?; + repo.write("topic.txt", "topic\n")?; + repo.run(["add", "topic.txt"])?; + repo.run(["commit", "-m", "topic"])?; + let main = git.branch_target("main")?.ok_or("missing main")?; + git.checkout_branch(&main, &git.head_target()?)?; + repo.write("base.txt", "base\n")?; + repo.run(["add", "base.txt"])?; + repo.run(["commit", "-m", "base"])?; + let topic = git.branch_target("topic")?.ok_or("missing topic")?; + git.checkout_branch(&topic, &git.head_target()?)?; + let main = git.branch_target("main")?.ok_or("missing main")?; + git.rebase_onto(&main, &git.head_target()?)?; + + repo.run(["merge-base", "--is-ancestor", "main", "HEAD"])?; + Ok(()) + } + + #[test] + fn branch_workflows_block_dirty_tree() -> Result<(), Box> { + let repo = TempRepo::new()?; + repo.run(["init", "-b", "main"])?; + repo.run(["config", "user.email", "bitbygit@example.invalid"])?; + repo.run(["config", "user.name", "bitbygit test"])?; + repo.write("README.md", "initial\n")?; + repo.run(["add", "README.md"])?; + repo.run(["commit", "-m", "initial"])?; + repo.write("README.md", "dirty\n")?; + let git = Git::new(repo.path()); + + let result = git.create_branch("feature", None, &git.head_target()?); + + let Err(error) = result else { + return Err("expected dirty tree guardrail".into()); + }; + assert!(error.to_string().contains("working tree is not clean")); + Ok(()) + } + + #[test] + fn remote_checkout_blocks_when_local_branch_exists() -> Result<(), Box> { + let repo = TempRepo::new()?; + repo.run(["init", "-b", "main"])?; + repo.run(["config", "user.email", "bitbygit@example.invalid"])?; + repo.run(["config", "user.name", "bitbygit test"])?; + repo.write("README.md", "initial\n")?; + repo.run(["add", "README.md"])?; + repo.run(["commit", "-m", "initial"])?; + repo.run(["update-ref", "refs/remotes/origin/main", "HEAD"])?; + let git = Git::new(repo.path()); + let remote = git.branch_target("origin/main")?.ok_or("missing remote")?; + + let result = git.checkout_branch(&remote, &git.head_target()?); + + let Err(error) = result else { + return Err("expected remote checkout guardrail".into()); + }; + assert!( + error + .to_string() + .contains("local branch main already exists") + ); + Ok(()) + } + + #[test] + fn branch_workflows_block_in_progress_rebase() -> Result<(), Box> { + let repo = TempRepo::new()?; + repo.run(["init", "-b", "main"])?; + repo.run(["config", "user.email", "bitbygit@example.invalid"])?; + repo.run(["config", "user.name", "bitbygit test"])?; + repo.write("README.md", "initial\n")?; + repo.run(["add", "README.md"])?; + repo.run(["commit", "-m", "initial"])?; + repo.run(["switch", "-c", "topic"])?; + repo.write("topic.txt", "topic\n")?; + repo.run(["add", "topic.txt"])?; + repo.run(["commit", "-m", "topic"])?; + repo.run(["switch", "main"])?; + repo.write("base.txt", "base\n")?; + repo.run(["add", "base.txt"])?; + repo.run(["commit", "-m", "base"])?; + repo.run(["switch", "topic"])?; + repo.run_allow_failure(["rebase", "--exec", "false", "main"])?; + let git = Git::new(repo.path()); + + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + + let result = git.create_branch("new-topic", None, &git.head_target()?); + + let Err(error) = result else { + return Err("expected in-progress rebase guardrail".into()); + }; + assert!( + error + .to_string() + .contains("rebase operation is in progress") + ); + Ok(()) + } + + #[test] + fn detects_conflicts_in_temp_repo() -> Result<(), Box> { + let repo = TempRepo::new()?; + repo.run(["init", "-b", "main"])?; + repo.run(["config", "user.email", "bitbygit@example.invalid"])?; + repo.run(["config", "user.name", "bitbygit test"])?; + repo.write("conflict.txt", "base\n")?; + repo.run(["add", "conflict.txt"])?; + repo.run(["commit", "-m", "base"])?; + repo.run(["checkout", "-b", "other"])?; + repo.write("conflict.txt", "other\n")?; + repo.run(["commit", "-am", "other"])?; + repo.run(["checkout", "main"])?; + repo.write("conflict.txt", "main\n")?; + repo.run(["commit", "-am", "main"])?; + repo.run_allow_failure(["merge", "other"])?; + + let status = Git::new(repo.path()).status()?; + + assert_eq!(status.operation, Some(RepositoryOperation::Merge)); + assert_eq!(status.conflicted_files().len(), 1); + assert_eq!( + status.conflicted_files()[0].path, + PathBuf::from("conflict.txt") + ); + + repo.run(["add", "conflict.txt"])?; + let status = Git::new(repo.path()).status()?; + + assert_eq!(status.operation, Some(RepositoryOperation::Merge)); + assert!(status.conflicted_files().is_empty()); + Ok(()) + } + + #[test] + fn merge_continue_requires_resolution_and_finishes_without_an_editor() + -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + repo.run(["config", "core.editor", "false"])?; + let git = Git::new(repo.path()); + + let Err(error) = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue) else { + return Err("expected unresolved merge to block continue".into()); + }; + assert!(error.to_string().contains("unresolved conflicts")); + + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + git.recover(RepositoryOperation::Merge, RecoveryAction::Continue)?; + + assert_eq!(git.status()?.operation, None); + repo.run(["rev-parse", "--verify", "HEAD^2"])?; + Ok(()) } - pub fn untracked_files(&self) -> Vec<&StatusEntry> { - self.entries - .iter() - .filter(|entry| entry.entry_type == StatusEntryType::Untracked) - .collect() + #[test] + fn merge_abort_clears_operation_and_restores_head() -> Result<(), Box> { + let (repo, original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + + git.recover(RepositoryOperation::Merge, RecoveryAction::Abort)?; + + assert_eq!(git.status()?.operation, None); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + assert_eq!( + fs::read_to_string(repo.path().join("conflict.txt"))?, + "main\n" + ); + Ok(()) } - pub fn conflicted_files(&self) -> Vec<&StatusEntry> { - self.entries - .iter() - .filter(|entry| entry.entry_type == StatusEntryType::Conflict) - .collect() + #[test] + fn rebase_continue_reports_the_next_conflict_then_finishes() -> Result<(), Box> { + let repo = prepare_two_conflict_rebase()?; + repo.run(["config", "core.editor", "false"])?; + let git = Git::new(repo.path()); + + repo.write("first.txt", "topic first\n")?; + repo.run(["add", "first.txt"])?; + let Err(error) = git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue) else { + return Err("expected rebase to stop at the next conflict".into()); + }; + let GitError::GitFailed { + args, + stdout, + stderr, + .. + } = &error + else { + return Err(format!("expected git failure for next conflict, got {error}").into()); + }; + assert_eq!(args, &["rebase".to_owned(), "--continue".to_owned()]); + assert!(format!("{stdout}\n{stderr}").contains("second.txt")); + let status = git.status()?; + assert_eq!(status.operation, Some(RepositoryOperation::Rebase)); + assert_eq!(status.conflicted_files().len(), 1); + assert_eq!( + status.conflicted_files()[0].path, + PathBuf::from("second.txt") + ); + + repo.write("second.txt", "topic second\n")?; + repo.run(["add", "second.txt"])?; + git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue)?; + + assert_eq!(git.status()?.operation, None); + repo.run(["merge-base", "--is-ancestor", "main", "HEAD"])?; + Ok(()) } -} -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct StatusEntry { - pub path: PathBuf, - pub original_path: Option, - pub index: ChangeKind, - pub worktree: ChangeKind, - pub entry_type: StatusEntryType, -} + #[test] + fn rebase_abort_clears_operation_and_restores_head() -> Result<(), Box> { + let (repo, original_head) = prepare_rebase_conflict()?; + let git = Git::new(repo.path()); -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StatusEntryType { - Ordinary, - Renamed, - /// Porcelain v2 reserves the `2` record family for renames and copies. Git - /// status does not normally emit copies, but the parser keeps the model - /// explicit for forward-compatible callers. - Copied, - Untracked, - Ignored, - Conflict, -} + git.recover(RepositoryOperation::Rebase, RecoveryAction::Abort)?; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChangeKind { - Unmodified, - Modified, - Added, - Deleted, - Renamed, - Copied, - Unmerged, - Untracked, - Ignored, - Unknown(char), -} + assert_eq!(git.status()?.operation, None); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + assert_eq!( + fs::read_to_string(repo.path().join("conflict.txt"))?, + "topic\n" + ); + Ok(()) + } -#[cfg(test)] -fn parse_status(input: &str) -> Result { - parse_status_bytes(input.as_bytes()) -} + #[test] + fn rebase_skip_drops_the_conflicting_commit() -> Result<(), Box> { + let (repo, _original_head) = prepare_rebase_conflict()?; + let git = Git::new(repo.path()); -fn parse_status_bytes(input: &[u8]) -> Result { - let mut branch = BranchState::default(); - let mut oid = None; - let mut entries = Vec::new(); - let nul_delimited = input.contains(&0); - let mut records: Box + '_> = if nul_delimited { - Box::new( - input - .split(|byte| *byte == 0) - .filter(|record| !record.is_empty()), - ) - } else { - Box::new( - input - .split(|byte| *byte == b'\n') - .filter(|record| !record.is_empty()), - ) - }; + git.recover(RepositoryOperation::Rebase, RecoveryAction::Skip)?; + + assert_eq!(git.status()?.operation, None); + assert_eq!( + fs::read_to_string(repo.path().join("conflict.txt"))?, + "main\n" + ); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + repo.git_stdout(["rev-parse", "main"])?.trim() + ); + Ok(()) + } + + #[test] + fn rebase_merge_step_recovery_preserves_rebase_identity() -> Result<(), Box> { + let (abort_repo, original_head) = prepare_rebase_merge_conflict()?; + let abort_git = Git::new(abort_repo.path()); + + let Err(error) = abort_git.recover(RepositoryOperation::Merge, RecoveryAction::Abort) + else { + return Err("expected nested merge recovery to be rejected".into()); + }; + assert!(error.to_string().contains("rebase operation is active")); + assert!(abort_git.git_path("rebase-merge")?.exists()); + assert!(abort_git.git_path("MERGE_HEAD")?.exists()); + + abort_git.recover(RepositoryOperation::Rebase, RecoveryAction::Abort)?; + assert_eq!(abort_git.status()?.operation, None); + assert_eq!( + abort_repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + + let (continue_repo, _original_head) = prepare_rebase_merge_conflict()?; + let continue_git = Git::new(continue_repo.path()); + let Err(error) = + continue_git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue) + else { + return Err("expected unresolved nested merge to block continue".into()); + }; + assert!(error.to_string().contains("unresolved conflicts")); + continue_repo.write("conflict.txt", "resolved again\n")?; + continue_repo.run(["add", "conflict.txt"])?; + continue_git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue)?; + assert_eq!(continue_git.status()?.operation, None); + + let (skip_repo, _original_head) = prepare_rebase_merge_conflict()?; + let skip_git = Git::new(skip_repo.path()); + skip_git.recover(RepositoryOperation::Rebase, RecoveryAction::Skip)?; + assert_eq!(skip_git.status()?.operation, None); + Ok(()) + } + + #[test] + fn recovery_rejects_merge_skip_absent_state_and_mismatched_state() -> Result<(), Box> + { + let clean = initialized_repo()?; + let clean_git = Git::new(clean.path()); + for (operation, action) in [ + (RepositoryOperation::Merge, RecoveryAction::Continue), + (RepositoryOperation::Merge, RecoveryAction::Abort), + (RepositoryOperation::Rebase, RecoveryAction::Continue), + (RepositoryOperation::Rebase, RecoveryAction::Abort), + (RepositoryOperation::Rebase, RecoveryAction::Skip), + ] { + let Err(error) = clean_git.recover(operation, action) else { + return Err( + format!("expected {operation:?} {action:?} to require active state").into(), + ); + }; + assert!(error.to_string().contains("no")); + assert!(error.to_string().contains("operation is active")); + } + + let (merge_repo, _original_head) = prepare_merge_conflict()?; + let merge_git = Git::new(merge_repo.path()); + let Err(error) = merge_git.recover(RepositoryOperation::Merge, RecoveryAction::Skip) else { + return Err("expected merge skip to be rejected".into()); + }; + assert!(error.to_string().contains("does not support")); + assert_eq!( + merge_git.status()?.operation, + Some(RepositoryOperation::Merge) + ); + + let Err(error) = merge_git.recover(RepositoryOperation::Rebase, RecoveryAction::Abort) + else { + return Err("expected mismatched rebase recovery to be rejected".into()); + }; + assert!(error.to_string().contains("merge operation is active")); + assert_eq!( + merge_git.status()?.operation, + Some(RepositoryOperation::Merge) + ); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn recovery_rejects_executable_hooks_without_starting_them() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; - while let Some(line) = records.next() { - if let Some(value) = strip_bytes_prefix(line, b"# branch.oid ") { - let value = parse_utf8(value, "branch oid")?; - oid = Some(value.to_owned()); - if value == "(initial)" { - branch.unborn = true; - } - continue; - } + let (repo, _original_head) = prepare_merge_conflict()?; + let hooks = repo.path().join("hooks"); + fs::create_dir(&hooks)?; + let commit_msg_hook = hooks.join("commit-msg"); + fs::write( + &commit_msg_hook, + "#!/bin/sh\ntouch hook-ran\nprintf '\\377'\n", + )?; + let mut permissions = fs::metadata(&commit_msg_hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&commit_msg_hook, permissions)?; + repo.run(["config", "core.hooksPath", "hooks"])?; - if let Some(value) = strip_bytes_prefix(line, b"# branch.head ") { - let value = parse_utf8(value, "branch head")?; - branch.head = if value == "(detached)" { - Head::Detached(oid.clone().unwrap_or_default()) - } else { - Head::Branch(value.to_owned()) - }; - continue; - } + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let git = Git::new(repo.path()); - if let Some(value) = strip_bytes_prefix(line, b"# branch.upstream ") { - let value = parse_utf8(value, "branch upstream")?; - branch.upstream = Some(value.to_owned()); - continue; - } + let Err(error) = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue) else { + return Err("expected executable hook configuration to fail closed".into()); + }; + assert!(error.to_string().contains("executable Git hooks")); + assert!(!repo.path().join("hook-ran").exists()); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) + } - if let Some(value) = strip_bytes_prefix(line, b"# branch.ab ") { - let value = parse_utf8(value, "branch ahead/behind")?; - let (ahead, behind) = parse_ahead_behind(value)?; - branch.ahead = ahead; - branch.behind = behind; - continue; - } + #[test] + fn recovery_fingerprint_tracks_refs_index_config_hooks_and_progress() + -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let baseline = git.recovery_state()?; - if let Some(path) = strip_bytes_prefix(line, b"? ") { - entries.push(StatusEntry { - path: path_from_bytes(path), - original_path: None, - index: ChangeKind::Unmodified, - worktree: ChangeKind::Untracked, - entry_type: StatusEntryType::Untracked, - }); - continue; - } + repo.run(["update-ref", "refs/heads/preview-race", "HEAD"])?; + assert_ne!(git.recovery_state()?, baseline); + repo.run(["update-ref", "-d", "refs/heads/preview-race"])?; + assert_eq!(git.recovery_state()?, baseline); - if let Some(path) = strip_bytes_prefix(line, b"! ") { - entries.push(StatusEntry { - path: path_from_bytes(path), - original_path: None, - index: ChangeKind::Unmodified, - worktree: ChangeKind::Ignored, - entry_type: StatusEntryType::Ignored, - }); - continue; - } + repo.run(["config", "user.name", "changed after preview"])?; + assert_ne!(git.recovery_state()?, baseline); + repo.run(["config", "user.name", "bitbygit test"])?; + assert_eq!(git.recovery_state()?, baseline); + + let hook = repo.path().join(".git/hooks/commit-msg"); + fs::write(&hook, "#!/bin/sh\nexit 0\n")?; + assert_ne!(git.recovery_state()?, baseline); + fs::remove_file(hook)?; + assert_eq!(git.recovery_state()?, baseline); + + repo.write("staged-after-preview.txt", "unexpected\n")?; + repo.run(["add", "staged-after-preview.txt"])?; + assert_ne!(git.recovery_state()?, baseline); + + let (rebase_repo, _original_head) = prepare_rebase_conflict()?; + let rebase_git = Git::new(rebase_repo.path()); + let rebase_baseline = rebase_git.recovery_state()?; + let orig_head = rebase_git.git_path("rebase-merge/orig-head")?; + fs::write(orig_head, format!("{ZERO_OID}\n"))?; + assert_ne!(rebase_git.recovery_state()?, rebase_baseline); + Ok(()) + } - if line.starts_with(b"1 ") { - entries.push(parse_ordinary_entry(line)?); - continue; - } + #[test] + fn recovery_fingerprint_tracks_autostash_and_rerere_state() -> Result<(), Box> { + let (repo, original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let baseline = git.recovery_state()?; - if line.starts_with(b"2 ") { - let original_path = if nul_delimited { - Some( - records - .next() - .ok_or_else(|| parse_error("renamed entry missing original path"))?, - ) - } else { - None - }; - entries.push(parse_renamed_entry(line, original_path)?); - continue; + for relative in ["MERGE_AUTOSTASH", "MERGE_RR"] { + let path = git.git_path(relative)?; + fs::write(&path, format!("{original_head}\n"))?; + assert_ne!(git.recovery_state()?, baseline, "{relative}"); + fs::remove_file(path)?; + assert_eq!(git.recovery_state()?, baseline, "{relative}"); } - if line.starts_with(b"u ") { - entries.push(parse_conflict_entry(line)?); - continue; + let rr_cache = git.git_path("rr-cache")?; + let rr_cache_existed = rr_cache.exists(); + fs::create_dir_all(&rr_cache)?; + fs::write(rr_cache.join("review-regression"), "changed rerere state\n")?; + assert_ne!(git.recovery_state()?, baseline, "rr-cache"); + fs::remove_file(rr_cache.join("review-regression"))?; + if !rr_cache_existed { + fs::remove_dir(rr_cache)?; } + assert_eq!(git.recovery_state()?, baseline, "rr-cache"); + Ok(()) + } - if !line.starts_with(b"# ") { - return Err(parse_error(&format!( - "unknown porcelain v2 record: {}", - String::from_utf8_lossy(line) - ))); + #[test] + fn recovery_fingerprint_tracks_attributes_and_sparse_checkout_inputs() + -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let external = TempRepo::new()?; + let external_attributes = external.path().join("attributes"); + fs::write(&external_attributes, "*.txt text\n")?; + repo.run_args(&[ + "config", + "core.attributesFile", + &external_attributes.to_string_lossy(), + ])?; + repo.write(".gitattributes", "*.txt text\n")?; + let git = Git::new(repo.path()); + let info_attributes = git.git_path("info/attributes")?; + let sparse_checkout = git.git_path("info/sparse-checkout")?; + fs::write(&info_attributes, "*.md text\n")?; + fs::write(&sparse_checkout, "/*\n")?; + let baseline = git.recovery_state()?; + + for (path, changed, original) in [ + ( + repo.path().join(".gitattributes"), + "*.txt binary\n", + "*.txt text\n", + ), + (info_attributes.clone(), "*.md binary\n", "*.md text\n"), + (sparse_checkout.clone(), "/src/\n", "/*\n"), + ( + external_attributes.clone(), + "*.txt binary\n", + "*.txt text\n", + ), + ] { + fs::write(&path, changed)?; + assert_ne!(git.recovery_state()?, baseline, "{}", path.display()); + fs::write(path, original)?; + assert_eq!(git.recovery_state()?, baseline); } + Ok(()) } - Ok(WorktreeStatus { - branch, - entries, - operation: None, - }) -} - -fn parse_ahead_behind(value: &str) -> Result<(u32, u32), GitError> { - let mut parts = value.split_whitespace(); - let ahead = parts - .next() - .ok_or_else(|| parse_error("missing ahead count"))? - .strip_prefix('+') - .ok_or_else(|| parse_error("ahead count must start with +"))? - .parse::() - .map_err(|_| parse_error("ahead count is not a number"))?; - let behind = parts - .next() - .ok_or_else(|| parse_error("missing behind count"))? - .strip_prefix('-') - .ok_or_else(|| parse_error("behind count must start with -"))? - .parse::() - .map_err(|_| parse_error("behind count is not a number"))?; - Ok((ahead, behind)) -} - -fn parse_ordinary_entry(line: &[u8]) -> Result { - let parts = splitn_bytes(line, b' ', 9); - let xy = parts - .get(1) - .copied() - .ok_or_else(|| parse_error("ordinary entry missing status"))?; - let path = parts - .get(8) - .copied() - .ok_or_else(|| parse_error("ordinary entry missing path"))?; - let (index, worktree) = parse_xy(xy)?; - - Ok(StatusEntry { - path: path_from_bytes(path), - original_path: None, - index, - worktree, - entry_type: StatusEntryType::Ordinary, - }) -} + #[cfg(unix)] + #[test] + fn recovery_fingerprint_tracks_symlinked_hook_target_contents() -> Result<(), Box> { + use std::os::unix::fs::symlink; -fn parse_renamed_entry(line: &[u8], original_path: Option<&[u8]>) -> Result { - let parts = splitn_bytes(line, b' ', 10); - let xy = parts - .get(1) - .copied() - .ok_or_else(|| parse_error("renamed entry missing status"))?; - let path = parts - .get(9) - .copied() - .ok_or_else(|| parse_error("renamed entry missing path"))?; - let (path, original_path) = match original_path { - Some(original_path) => (path, original_path), - None => split_once_byte(path, b'\t') - .ok_or_else(|| parse_error("renamed entry missing original path"))?, - }; - let (index, worktree) = parse_xy(xy)?; + let (repo, _original_head) = prepare_merge_conflict()?; + let external = TempRepo::new()?; + let target = external.path().join("commit-msg-target"); + fs::write(&target, "#!/bin/sh\nexit 0\n")?; + symlink(&target, repo.path().join(".git/hooks/commit-msg"))?; + let git = Git::new(repo.path()); + let baseline = git.recovery_state()?; - Ok(StatusEntry { - path: path_from_bytes(path), - original_path: Some(path_from_bytes(original_path)), - index, - worktree, - entry_type: if index == ChangeKind::Copied || worktree == ChangeKind::Copied { - StatusEntryType::Copied - } else { - StatusEntryType::Renamed - }, - }) -} + fs::write(&target, "#!/bin/sh\nexit 1\n")?; -fn parse_conflict_entry(line: &[u8]) -> Result { - let parts = splitn_bytes(line, b' ', 11); - let path = parts - .get(10) - .copied() - .ok_or_else(|| parse_error("conflict entry missing path"))?; + assert_ne!(git.recovery_state()?, baseline); + Ok(()) + } - Ok(StatusEntry { - path: path_from_bytes(path), - original_path: None, - index: ChangeKind::Unmerged, - worktree: ChangeKind::Unmerged, - entry_type: StatusEntryType::Conflict, - }) -} + #[cfg(unix)] + #[test] + fn recovery_fingerprint_rejects_synchronized_symlink_retarget() -> Result<(), Box> { + use std::os::unix::fs::symlink; -fn parse_xy(value: &[u8]) -> Result<(ChangeKind, ChangeKind), GitError> { - let index = value - .first() - .copied() - .ok_or_else(|| parse_error("missing index status"))?; - let worktree = value - .get(1) - .copied() - .ok_or_else(|| parse_error("missing worktree status"))?; - Ok((change_kind(index), change_kind(worktree))) -} + let repo = TempRepo::new()?; + let first = repo.path().join("first-hook"); + let second = repo.path().join("second-hook"); + let hook = repo.path().join("commit-msg"); + let replacement = repo.path().join("replacement-link"); + fs::write(&first, "same hook contents\n")?; + fs::write(&second, "same hook contents\n")?; + symlink(&first, &hook)?; + symlink(&second, &replacement)?; + let mut hasher = Sha256::new(); + let mut entries = 0; + let mut replaced = false; + + let Err(error) = hash_recovery_path_with( + &mut hasher, + b"hook", + &hook, + Instant::now() + Duration::from_secs(2), + &mut entries, + &mut |path, kind| { + if path == hook && kind == RecoveryPathKind::Symlink { + fs::rename(&replacement, &hook) + .map_err(|source| recovery_state_io(path, source))?; + replaced = true; + } + Ok(()) + }, + ) else { + return Err("expected a retargeted symlink to be rejected".into()); + }; -fn change_kind(value: u8) -> ChangeKind { - match value { - b'.' => ChangeKind::Unmodified, - b'M' => ChangeKind::Modified, - b'A' => ChangeKind::Added, - b'D' => ChangeKind::Deleted, - b'R' => ChangeKind::Renamed, - b'C' => ChangeKind::Copied, - b'U' => ChangeKind::Unmerged, - b'?' => ChangeKind::Untracked, - b'!' => ChangeKind::Ignored, - other => ChangeKind::Unknown(char::from(other)), + assert!(replaced); + assert!( + error + .to_string() + .contains("changed while it was fingerprinted") + ); + Ok(()) } -} -fn parse_remotes(input: &str) -> Vec { - let mut remotes = BTreeMap::::new(); + #[cfg(unix)] + #[test] + fn recovery_fingerprint_rejects_synchronized_directory_replacement() + -> Result<(), Box> { + let repo = TempRepo::new()?; + let hooks = repo.path().join("hooks"); + let original = repo.path().join("original-hooks"); + let replacement = repo.path().join("replacement-hooks"); + fs::create_dir(&hooks)?; + fs::write(hooks.join("commit-msg"), "same hook contents\n")?; + fs::create_dir(&replacement)?; + fs::write(replacement.join("commit-msg"), "same hook contents\n")?; + let mut hasher = Sha256::new(); + let mut entries = 0; + let mut replaced = false; + + let Err(error) = hash_recovery_path_with( + &mut hasher, + b"hooks", + &hooks, + Instant::now() + Duration::from_secs(2), + &mut entries, + &mut |path, kind| { + if path == hooks && kind == RecoveryPathKind::Directory { + fs::rename(&hooks, &original) + .map_err(|source| recovery_state_io(path, source))?; + fs::rename(&replacement, &hooks) + .map_err(|source| recovery_state_io(path, source))?; + replaced = true; + } + Ok(()) + }, + ) else { + return Err("expected a replaced directory to be rejected".into()); + }; + + assert!(replaced); + assert!( + error + .to_string() + .contains("changed while it was fingerprinted") + ); + Ok(()) + } - for line in input.lines() { - let Some((name, rest)) = line.split_once('\t') else { - continue; - }; - let Some((url, kind)) = rest.rsplit_once(' ') else { - continue; + #[cfg(unix)] + #[test] + fn recovery_fingerprint_rejects_fifo_without_blocking() -> Result<(), Box> { + let repo = TempRepo::new()?; + let fifo = repo.path().join("attributes.fifo"); + let status = Command::new("mkfifo").arg(&fifo).status()?; + assert!(status.success()); + let mut hasher = Sha256::new(); + let mut entries = 0; + let started = Instant::now(); + + let Err(error) = hash_recovery_path( + &mut hasher, + b"fifo", + &fifo, + Instant::now() + Duration::from_millis(200), + &mut entries, + ) else { + return Err("expected FIFO recovery input to be rejected".into()); }; - let remote = remotes.entry(name.to_owned()).or_insert_with(|| Remote { - name: name.to_owned(), - fetch_url: None, - push_url: None, - }); + assert!( + error + .to_string() + .contains("not a regular file or directory") + ); + assert!(started.elapsed() < Duration::from_secs(2)); + Ok(()) + } - match kind { - "(fetch)" => remote.fetch_url = Some(url.to_owned()), - "(push)" => remote.push_url = Some(url.to_owned()), - _ => {} - } + #[test] + fn recovery_output_drainer_retains_only_hard_bounded_memory() -> Result<(), Box> { + let exceeded = Arc::new(AtomicBool::new(false)); + let capture = drain_bounded_output( + io::Cursor::new(vec![b'x'; 8192]), + 1024, + 4096, + true, + Arc::clone(&exceeded), + )?; + + assert_eq!(capture.bytes.len(), 1024); + assert!(capture.truncated); + assert!(exceeded.load(Ordering::Acquire)); + Ok(()) } - remotes.into_values().collect() -} + #[test] + fn recovery_reports_clear_minimum_git_version() -> Result<(), Box> { + let Err(error) = ensure_recovery_git_version(b"git version 2.41.3") else { + return Err("expected old Git to be rejected".into()); + }; -fn parse_branches(input: &str) -> Result, GitError> { - let mut branches = Vec::new(); - for line in input.lines().filter(|line| !line.trim().is_empty()) { - let branch = parse_branch_line(line)?; - if branch.kind == BranchKind::Remote && branch.name.ends_with("/HEAD") { - continue; - } - branches.push(branch); + assert!(error.to_string().contains("requires Git 2.42 or newer")); + ensure_recovery_git_version(b"git version 2.42.0")?; + ensure_recovery_git_version(b"git version 2.55.0.windows.1")?; + Ok(()) } - Ok(branches) -} -fn parse_branch_line(line: &str) -> Result { - let fields = line.split('\0').collect::>(); - let [reference, oid, upstream, head] = fields.as_slice() else { - return Err(GitError::Parse { - message: "git branch list output has unexpected fields".to_owned(), - }); - }; - let (kind, name) = if let Some(name) = reference.strip_prefix("refs/heads/") { - (BranchKind::Local, name) - } else if let Some(name) = reference.strip_prefix("refs/remotes/") { - (BranchKind::Remote, name) - } else { - return Err(GitError::Parse { - message: format!("unsupported branch reference: {reference}"), - }); - }; - Ok(BranchInfo { - name: name.to_owned(), - reference: (*reference).to_owned(), - oid: (*oid).to_owned(), - upstream: (!upstream.is_empty()).then(|| (*upstream).to_owned()), - current: *head == "*", - kind, - }) -} + #[cfg(any(unix, windows))] + #[test] + fn recovery_platform_end_to_end_deadline_does_not_interrupt_merge_abort() + -> Result<(), Box> { + let repo = initialized_repo()?; + repo.run(["config", "core.autocrlf", "false"])?; + repo.write("conflict.txt", "base\n")?; + repo.run(["add", "conflict.txt"])?; + repo.run(["commit", "-m", "base"])?; + repo.run(["switch", "-c", "other"])?; + repo.write("conflict.txt", "other\n")?; + for index in 0..2_000 { + repo.write(&format!("generated-{index:04}.txt"), "other\n")?; + } + repo.run(["add", "."])?; + repo.run(["commit", "-m", "other"])?; + repo.run(["switch", "main"])?; + repo.write("conflict.txt", "main\n")?; + repo.run(["commit", "-am", "main"])?; + let original_head = repo.git_stdout(["rev-parse", "HEAD"])?.trim().to_owned(); + repo.run_allow_failure(["merge", "other"])?; + let git = Git::new(repo.path()); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); -fn local_name_for_remote_branch(name: &str) -> Option<&str> { - name.split_once('/') - .map(|(_remote, branch)| branch) - .filter(|branch| !branch.is_empty()) -} + let mut command = Command::new("git"); + command + .current_dir(repo.path()) + .args([ + "-c", + "maintenance.auto=false", + "-c", + "gc.auto=0", + "merge", + "--abort", + ]) + .env("GIT_TERMINAL_PROMPT", "0"); + configure_process_group(&mut command); + let start_timeout = Duration::from_millis(10); + let started = Instant::now(); + + let output = run_bounded_command( + command, + vec!["merge".to_owned(), "--abort".to_owned()], + started + start_timeout, + 4096, + BoundedCommandPolicy::RecoveryExecution, + || Ok(()), + )?; -fn strip_byte_line_ending(value: &[u8]) -> &[u8] { - value.strip_suffix(b"\n").unwrap_or(value) -} + assert!(output.status.success()); + assert!(started.elapsed() >= start_timeout); + assert_eq!(git.status()?.operation, None); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + assert_eq!( + fs::read_to_string(repo.path().join("conflict.txt"))?, + "main\n" + ); + for index in 0..2_000 { + assert!( + !repo + .path() + .join(format!("generated-{index:04}.txt")) + .exists() + ); + } + assert!(!git.git_path("index.lock")?.exists()); + assert!(repo.git_stdout(["status", "--porcelain"])?.is_empty()); + Ok(()) + } -fn strip_bytes_prefix<'a>(value: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> { - value.strip_prefix(prefix) -} + #[cfg(any(target_os = "macos", windows))] + #[test] + fn recovery_platform_command_output_is_bounded() -> Result<(), Box> { + #[cfg(unix)] + let mut command = { + let mut command = Command::new("sh"); + command.args(["-c", "while :; do printf '0123456789'; done"]); + command + }; + #[cfg(windows)] + let mut command = { + let mut command = Command::new("powershell"); + command.args([ + "-NoProfile", + "-Command", + "[Console]::Out.Write('x' * 70000000)", + ]); + command + }; + configure_process_group(&mut command); + + let result = run_bounded_command( + command, + vec!["recovery-output-bound-test".to_owned()], + Instant::now() + Duration::from_secs(5), + 1024, + BoundedCommandPolicy::Diagnostic, + || Ok(()), + ); -fn parse_utf8<'a>(value: &'a [u8], description: &str) -> Result<&'a str, GitError> { - std::str::from_utf8(value).map_err(|_| parse_error(&format!("{description} is not UTF-8"))) -} + assert!( + matches!(result, Err(GitError::TimedOut { .. })) + || result + .as_ref() + .is_err_and(|error| error.to_string().contains("bounded capture limit")), + "unexpected result: {result:?}" + ); + Ok(()) + } -fn splitn_bytes(value: &[u8], delimiter: u8, count: usize) -> Vec<&[u8]> { - value.splitn(count, |byte| *byte == delimiter).collect() -} + #[test] + fn exact_recovery_rejects_changed_merge_control_input() -> Result<(), Box> { + let (repo, original_head) = prepare_merge_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let git = Git::new(repo.path()); + let expected = + git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Continue)?; + fs::write(git.git_path("MERGE_MSG")?, "substituted message\n")?; + + let Err(error) = git.recover_exact( + RepositoryOperation::Merge, + RecoveryAction::Continue, + &expected, + ) else { + return Err("expected changed merge metadata to block recovery".into()); + }; -fn split_once_byte(value: &[u8], delimiter: u8) -> Option<(&[u8], &[u8])> { - let index = value.iter().position(|byte| *byte == delimiter)?; - Some((&value[..index], &value[index + 1..])) -} + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + Ok(()) + } -fn shell_quote(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\\''")) -} + #[test] + fn exact_recovery_rejects_changed_merge_autostash() -> Result<(), Box> { + let (repo, original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let autostash = git.git_path("MERGE_AUTOSTASH")?; + fs::write(&autostash, format!("{original_head}\n"))?; + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + let merge_head = fs::read_to_string(git.git_path("MERGE_HEAD")?)?; + fs::write(&autostash, merge_head)?; -#[cfg(unix)] -fn path_from_bytes(value: &[u8]) -> PathBuf { - PathBuf::from(OsString::from_vec(value.to_vec())) -} + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected changed merge autostash to block recovery".into()); + }; -#[cfg(not(unix))] -fn path_from_bytes(value: &[u8]) -> PathBuf { - PathBuf::from(String::from_utf8_lossy(value).into_owned()) -} + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + Ok(()) + } -fn parse_error(message: &str) -> GitError { - GitError::Parse { - message: message.to_owned(), + #[test] + fn rebase_abort_blocks_ignored_collision_path() -> Result<(), Box> { + let repo = initialized_repo()?; + repo.write(".gitignore", "target/\n")?; + repo.run(["add", ".gitignore"])?; + repo.run(["commit", "-m", "ignore target"])?; + repo.run(["switch", "-c", "topic"])?; + repo.write("first.txt", "first\n")?; + repo.run(["add", "first.txt"])?; + repo.run(["commit", "-m", "first"])?; + repo.write(".gitignore", "")?; + fs::create_dir(repo.path().join("target"))?; + repo.write("target/victim.bin", "committed\n")?; + repo.run(["add", ".gitignore", "target/victim.bin"])?; + repo.run(["commit", "-m", "track victim"])?; + repo.run(["switch", "main"])?; + repo.write("upstream.txt", "upstream\n")?; + repo.run(["add", "upstream.txt"])?; + repo.run(["commit", "-m", "upstream"])?; + repo.run(["switch", "topic"])?; + repo.run_allow_failure(["rebase", "--exec", "false", "main"])?; + let todo = Git::new(repo.path()).git_path("rebase-merge/git-rebase-todo")?; + let remaining = fs::read_to_string(&todo)? + .lines() + .filter(|line| { + let line = line.trim_start(); + !line.starts_with("exec ") && !line.starts_with("x ") + }) + .collect::>() + .join("\n"); + fs::write(todo, remaining)?; + fs::create_dir(repo.path().join("target"))?; + repo.write("target/victim.bin", "at preview\n")?; + let git = Git::new(repo.path()); + let Err(error) = git.prepare_recovery(RepositoryOperation::Rebase, RecoveryAction::Abort) + else { + return Err("expected ignored collision to block abort planning".into()); + }; + + assert!(error.to_string().contains("would be overwritten")); + assert_eq!( + fs::read_to_string(repo.path().join("target/victim.bin"))?, + "at preview\n" + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + Ok(()) } -} -fn remote_tracking_ref(remote: &str, branch: &str) -> String { - format!("refs/remotes/{remote}/{branch}") -} + #[test] + fn merge_abort_blocks_ignored_collision_omitted_from_endpoint_diff() + -> Result<(), Box> { + let repo = initialized_repo()?; + repo.write("conflict.txt", "base\n")?; + repo.write("stable.txt", "tracked stable contents\n")?; + repo.run(["add", "conflict.txt", "stable.txt"])?; + repo.run(["commit", "-m", "base"])?; + repo.run(["switch", "-c", "other"])?; + repo.write("conflict.txt", "other\n")?; + repo.run(["commit", "-am", "other"])?; + repo.run(["switch", "main"])?; + repo.write("conflict.txt", "main\n")?; + repo.run(["commit", "-am", "main"])?; + repo.run_allow_failure(["merge", "other"])?; + repo.run(["rm", "--cached", "stable.txt"])?; + repo.write(".gitignore", "stable.txt\n")?; + repo.write("stable.txt", "local ignored data\n")?; + let git = Git::new(repo.path()); -fn force_with_lease_arg(branch: &str, expected_remote_oid: Option<&str>) -> String { - format!( - "--force-with-lease=refs/heads/{branch}:{}", - expected_remote_oid.unwrap_or("") - ) -} + let Err(error) = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort) + else { + return Err("expected ignored stable path to block merge abort".into()); + }; -fn combine_outputs(first: GitOutput, second: GitOutput) -> GitOutput { - let stdout = [first.stdout.trim(), second.stdout.trim()] - .into_iter() - .filter(|part| !part.is_empty()) - .collect::>() - .join("\n"); - let stderr = [first.stderr.trim(), second.stderr.trim()] - .into_iter() - .filter(|part| !part.is_empty()) - .collect::>() - .join("\n"); - GitOutput { - status: second.status, - stdout, - stderr, + assert!(error.to_string().contains("would be overwritten")); + assert_eq!( + fs::read_to_string(repo.path().join("stable.txt"))?, + "local ignored data\n" + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) } -} -#[cfg(unix)] -fn hook_is_enabled(path: &Path) -> bool { - use std::os::unix::fs::PermissionsExt; + #[test] + fn rebase_continue_blocks_ignored_path_touched_only_by_intermediate_commits() + -> Result<(), Box> { + let repo = initialized_repo()?; + repo.write(".gitignore", "victim.txt\n")?; + repo.write("conflict.txt", "base\n")?; + repo.run(["add", ".gitignore", "conflict.txt"])?; + repo.run(["commit", "-m", "rebase base"])?; + repo.run(["switch", "-c", "topic"])?; + repo.write("conflict.txt", "topic\n")?; + repo.run(["commit", "-am", "conflicting change"])?; + repo.write("victim.txt", "committed intermediate contents\n")?; + repo.run(["add", "-f", "victim.txt"])?; + repo.run(["commit", "-m", "add ignored victim"])?; + fs::remove_file(repo.path().join("victim.txt"))?; + repo.run(["add", "-u"])?; + repo.run(["commit", "-m", "remove ignored victim"])?; + repo.run(["switch", "main"])?; + repo.write("conflict.txt", "main\n")?; + repo.write("upstream.txt", "upstream\n")?; + repo.run(["add", "conflict.txt", "upstream.txt"])?; + repo.run(["commit", "-m", "upstream change"])?; + repo.run(["switch", "topic"])?; + repo.run_allow_failure(["rebase", "main"])?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + repo.write("victim.txt", "local ignored data\n")?; + let git = Git::new(repo.path()); - fs::metadata(path) - .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) - .unwrap_or(false) -} + let Err(error) = + git.prepare_recovery(RepositoryOperation::Rebase, RecoveryAction::Continue) + else { + return Err("expected an intermediate rebase collision to block continue".into()); + }; -#[cfg(not(unix))] -fn hook_is_enabled(path: &Path) -> bool { - fs::metadata(path) - .map(|metadata| metadata.is_file()) - .unwrap_or(false) -} + assert!(error.to_string().contains("would be overwritten")); + assert_eq!( + fs::read_to_string(repo.path().join("victim.txt"))?, + "local ignored data\n" + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + Ok(()) + } -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use std::path::Path; - use std::process::Command; - use std::sync::atomic::{AtomicUsize, Ordering}; + #[test] + fn exact_recovery_rejects_changed_untracked_contents() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + repo.write("untracked.txt", "at preview\n")?; + let git = Git::new(repo.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + repo.write("untracked.txt", "changed after preview\n")?; - #[cfg(unix)] - use std::os::unix::ffi::{OsStrExt, OsStringExt}; + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected changed untracked content to block abort".into()); + }; - static NEXT_REPO_ID: AtomicUsize = AtomicUsize::new(0); + assert!(error.to_string().contains("state changed after preview")); + assert_eq!( + fs::read_to_string(repo.path().join("untracked.txt"))?, + "changed after preview\n" + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) + } #[test] - fn parses_clean_status() -> Result<(), Box> { - let status = parse_status( - "# branch.oid 1234567\n# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n", - )?; + fn recovery_fingerprint_bounds_untracked_data() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let untracked = fs::File::create(repo.path().join("large.untracked"))?; + untracked.set_len(RECOVERY_UNTRACKED_DATA_LIMIT + 1)?; - assert!(status.is_clean()); - assert_eq!(status.branch.head, Head::Branch("main".to_owned())); - assert_eq!(status.branch.upstream, Some("origin/main".to_owned())); - assert_eq!(status.branch.ahead, 0); - assert_eq!(status.branch.behind, 0); - assert_eq!(status.operation, None); + let Err(error) = Git::new(repo.path()).recovery_state() else { + return Err("expected oversized untracked data to block planning".into()); + }; + + assert!( + error + .to_string() + .contains("untracked worktree data exceeds") + ); Ok(()) } #[test] - fn parses_dirty_unstaged_file() -> Result<(), Box> { - let status = parse_status( - "# branch.oid 1234567\n# branch.head main\n1 .M N... 100644 100644 100644 abc abc README.md\n", - )?; + fn exact_merge_abort_allows_large_unrelated_ignored_build_tree() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + repo.write(".gitignore", "target/\n")?; + fs::create_dir(repo.path().join("target"))?; + let artifact = fs::File::create(repo.path().join("target/large-artifact.bin"))?; + artifact.set_len(RECOVERY_UNTRACKED_DATA_LIMIT * 2)?; + for index in 0..4_000 { + repo.write(&format!("target/generated-artifact-{index:04}.bin"), "")?; + } + let git = Git::new(repo.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; - assert_eq!(status.unstaged_files().len(), 1); - assert_eq!(status.entries[0].path, PathBuf::from("README.md")); - assert_eq!(status.entries[0].worktree, ChangeKind::Modified); + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; + + assert_eq!(git.status()?.operation, None); + assert_eq!( + artifact.metadata()?.len(), + RECOVERY_UNTRACKED_DATA_LIMIT * 2 + ); Ok(()) } + #[cfg(unix)] #[test] - fn parses_staged_file() -> Result<(), Box> { - let status = parse_status( - "# branch.oid 1234567\n# branch.head main\n1 A. N... 000000 100644 100644 zero abc src/main.rs\n", - )?; + fn recovery_preview_rechecks_process_safety_inside_state_fence() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; - assert_eq!(status.staged_files().len(), 1); - assert_eq!(status.entries[0].index, ChangeKind::Added); + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let hook = repo.path().join(".git/hooks/commit-msg"); + + let Err(error) = + git.recovery_state_fenced_until(Instant::now() + Duration::from_secs(10), || { + fs::write(&hook, "#!/bin/sh\nexit 0\n").map_err(|source| GitError::Io { + args: vec!["install synchronized hook".to_owned()], + source, + })?; + let mut permissions = fs::metadata(&hook) + .map_err(|source| GitError::Io { + args: vec!["inspect synchronized hook".to_owned()], + source, + })? + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions).map_err(|source| GitError::Io { + args: vec!["enable synchronized hook".to_owned()], + source, + }) + }) + else { + return Err("expected a hook enabled during fingerprinting to be rejected".into()); + }; + + assert!( + error + .to_string() + .contains("changed while it was fingerprinted") + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); Ok(()) } + #[cfg(unix)] #[test] - fn parses_untracked_file() -> Result<(), Box> { - let status = parse_status("# branch.head main\n? notes.txt\n")?; + fn recovery_fingerprint_rejects_swapped_executable_hook_observation() + -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; - assert_eq!(status.untracked_files().len(), 1); - assert_eq!(status.entries[0].entry_type, StatusEntryType::Untracked); + let (repo, _original_head) = prepare_merge_conflict()?; + let active = repo.path().join("active-hooks"); + let safe = repo.path().join("safe-hooks"); + let unsafe_hooks = repo.path().join("unsafe-hooks"); + fs::create_dir(&active)?; + fs::create_dir(&unsafe_hooks)?; + let marker = repo.path().join("swapped-hook-ran"); + let hook = unsafe_hooks.join("commit-msg"); + fs::write(&hook, format!("#!/bin/sh\ntouch '{}'\n", marker.display()))?; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions)?; + repo.run(["config", "core.hooksPath", "active-hooks"])?; + let git = Git::new(repo.path()); + let deadline = Instant::now() + Duration::from_secs(10); + git.ensure_recovery_process_configuration_safe_until(deadline)?; + + let Err(error) = git.recovery_state_fenced_until(deadline, || { + fs::rename(&active, &safe).map_err(|source| recovery_state_io(&active, source))?; + fs::rename(&unsafe_hooks, &active) + .map_err(|source| recovery_state_io(&unsafe_hooks, source)) + }) else { + return Err("expected swapped executable hook directory to fail closed".into()); + }; + + assert!( + error + .to_string() + .contains("changed while it was fingerprinted") + ); + assert!(!marker.exists(), "swapped hook process started"); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); Ok(()) } #[test] - fn parses_renamed_file() -> Result<(), Box> { - let status = parse_status( - "# branch.head main\n2 R. N... 100644 100644 100644 abc def R100 new.txt\told.txt\n", - )?; + fn exact_recovery_rechecks_index_after_first_final_fence_pass() -> Result<(), Box> { + let (repo, original_head) = prepare_merge_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let git = Git::new(repo.path()); + let expected = + git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Continue)?; + + let Err(error) = git.recover_exact_with( + RepositoryOperation::Merge, + RecoveryAction::Continue, + &expected, + || { + fs::write(repo.path().join("late.txt"), "late staged content\n").map_err( + |source| GitError::Io { + args: vec!["write synchronized mutation".to_owned()], + source, + }, + )?; + let output = Command::new("git") + .current_dir(repo.path()) + .args(["add", "late.txt"]) + .output() + .map_err(|source| GitError::Io { + args: vec!["add synchronized mutation".to_owned()], + source, + })?; + if !output.status.success() { + return Err(GitError::Blocked { + message: "synchronized mutation failed".to_owned(), + }); + } + Ok(()) + }, + ) else { + return Err("expected final fingerprint fence to block recovery".into()); + }; - assert_eq!(status.entries[0].entry_type, StatusEntryType::Renamed); - assert_eq!(status.entries[0].path, PathBuf::from("new.txt")); + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); assert_eq!( - status.entries[0].original_path, - Some(PathBuf::from("old.txt")) + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head ); Ok(()) } #[test] - fn parses_conflicted_file() -> Result<(), Box> { - let status = parse_status( - "# branch.head main\nu UU N... 100644 100644 100644 100644 one two three conflict.txt\n", - )?; + fn exact_recovery_rechecks_process_config_after_first_final_fence_pass() + -> Result<(), Box> { + let (repo, original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + + let Err(error) = git.recover_exact_with( + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + || { + let output = Command::new("git") + .current_dir(repo.path()) + .args(["config", "core.fsmonitor", "true"]) + .output() + .map_err(|source| GitError::Io { + args: vec!["install synchronized fsmonitor config".to_owned()], + source, + })?; + if !output.status.success() { + return Err(GitError::Blocked { + message: "synchronized config mutation failed".to_owned(), + }); + } + Ok(()) + }, + ) else { + return Err("expected final process-config fence to block recovery".into()); + }; - assert_eq!(status.conflicted_files().len(), 1); - assert_eq!(status.staged_files().len(), 0); - assert_eq!(status.entries[0].entry_type, StatusEntryType::Conflict); + assert!(error.to_string().contains("core.fsmonitor")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); Ok(()) } #[test] - fn parses_detached_head() -> Result<(), Box> { - let status = parse_status("# branch.oid abc123\n# branch.head (detached)\n")?; + fn exact_recovery_rejects_filter_activated_at_spawn_boundary() -> Result<(), Box> { + let (repo, original_head) = prepare_merge_conflict()?; + repo.write(".gitattributes", "*.txt filter=unsafe\n")?; + let git = Git::new(repo.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + + let Err(error) = git.recover_exact_with( + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + || { + let output = Command::new("git") + .current_dir(repo.path()) + .args(["config", "filter.unsafe.smudge", "cat"]) + .output() + .map_err(|source| GitError::Io { + args: vec!["install synchronized filter config".to_owned()], + source, + })?; + if !output.status.success() { + return Err(GitError::Blocked { + message: "synchronized filter mutation failed".to_owned(), + }); + } + Ok(()) + }, + ) else { + return Err("expected activated filter at spawn boundary to block recovery".into()); + }; - assert_eq!(status.branch.head, Head::Detached("abc123".to_owned())); + assert!(error.to_string().contains("filter.unsafe.smudge")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); Ok(()) } #[test] - fn parses_unborn_branch() -> Result<(), Box> { - let status = parse_status("# branch.oid (initial)\n# branch.head main\n")?; + fn exact_recovery_rechecks_process_control_after_first_final_fence_pass() + -> Result<(), Box> { + for (relative, contents) in [ + ("rebase-merge/git-rebase-todo", "exec false\n"), + ("rebase-merge/strategy", "resolve\n"), + ] { + let (repo, original_head) = prepare_rebase_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let git = Git::new(repo.path()); + let expected = + git.prepare_recovery(RepositoryOperation::Rebase, RecoveryAction::Continue)?; + let control = git.git_path(relative)?; + + let Err(error) = git.recover_exact_with( + RepositoryOperation::Rebase, + RecoveryAction::Continue, + &expected, + || { + fs::write(&control, contents) + .map_err(|source| recovery_state_io(&control, source)) + }, + ) else { + return Err(format!("expected synchronized {relative} mutation to block").into()); + }; - assert_eq!(status.branch.head, Head::Branch("main".to_owned())); - assert!(status.branch.unborn); + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + assert_eq!( + repo.git_stdout(["rev-parse", "rebase-merge/orig-head"])? + .trim(), + original_head + ); + } Ok(()) } + #[cfg(windows)] #[test] - fn parses_nul_delimited_raw_paths() -> Result<(), Box> { - let status = parse_status("# branch.head main\0? café.txt\0? tab\tname.txt\0")?; + fn recovery_platform_rejects_same_metadata_file_replacement() -> Result<(), Box> { + let repo = TempRepo::new()?; + let input = repo.path().join("index"); + let retained = repo.path().join("index-retained"); + fs::write(&input, b"original")?; + let original_metadata = fs::metadata(&input)?; + let modified = original_metadata.modified()?; + let mut hasher = Sha256::new(); + let mut entries = 0; + let mut replaced = false; + + let Err(error) = hash_recovery_path_with( + &mut hasher, + b"index", + &input, + Instant::now() + Duration::from_secs(2), + &mut entries, + &mut |path, kind| { + if path == input && kind == RecoveryPathKind::File { + fs::rename(&input, &retained) + .map_err(|source| recovery_state_io(path, source))?; + fs::write(&input, b"replaced") + .map_err(|source| recovery_state_io(path, source))?; + fs::OpenOptions::new() + .write(true) + .open(&input) + .and_then(|file| { + file.set_times(fs::FileTimes::new().set_modified(modified)) + }) + .map_err(|source| recovery_state_io(path, source))?; + replaced = true; + } + Ok(()) + }, + ) else { + return Err("expected same-metadata replacement to be rejected".into()); + }; - assert_eq!(status.untracked_files().len(), 2); - assert_eq!(status.entries[0].path, PathBuf::from("café.txt")); - assert_eq!(status.entries[1].path, PathBuf::from("tab\tname.txt")); + assert!(replaced, "unexpected error: {error}"); + assert!(same_recovery_file( + &original_metadata, + &fs::metadata(&input)? + )); + assert!( + error + .to_string() + .contains("changed while it was fingerprinted") + ); Ok(()) } #[test] - fn parses_nul_delimited_rename() -> Result<(), Box> { - let status = parse_status(concat!( - "# branch.head main\0", - "2 R. N... 100644 100644 100644 abc def R100 new\tname.txt\0", - "old name.txt\0" - ))?; - - assert_eq!(status.entries[0].entry_type, StatusEntryType::Renamed); - assert_eq!(status.entries[0].path, PathBuf::from("new\tname.txt")); - assert_eq!( - status.entries[0].original_path, - Some(PathBuf::from("old name.txt")) + fn recovery_fingerprint_streams_large_binary_diffs_into_fixed_state() + -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + fs::write( + repo.path().join("conflict.txt"), + vec![0xa5; 8 * 1024 * 1024], + )?; + let git = Git::with_recovery_limits( + repo.path(), + Duration::from_secs(10), + Duration::from_secs(5), + 4096, ); + + let state = git.recovery_state()?; + + assert_eq!(std::mem::size_of_val(&state), 32); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); Ok(()) } + #[cfg(unix)] #[test] - fn parses_copied_file() -> Result<(), Box> { - let status = parse_status(concat!( - "# branch.head main\0", - "2 C. N... 100644 100644 100644 abc def C100 copy.txt\0", - "source.txt\0" - ))?; + fn recovery_planning_rejects_fsmonitor_before_it_runs() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; - assert_eq!(status.entries[0].entry_type, StatusEntryType::Copied); - assert_eq!(status.staged_files().len(), 1); + let (repo, _original_head) = prepare_merge_conflict()?; + let hook = repo.path().join("fsmonitor"); + fs::write(&hook, "#!/bin/sh\nsleep 30\n")?; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions)?; + repo.run_args(&["config", "core.fsmonitor", &hook.to_string_lossy()])?; + let git = Git::with_recovery_limits( + repo.path(), + Duration::from_millis(200), + Duration::from_secs(5), + 4096, + ); + let started = Instant::now(); + + let marker = repo.path().join("fsmonitor-ran"); + fs::write(&hook, format!("#!/bin/sh\ntouch '{}'\n", marker.display()))?; + let Err(error) = git.recovery_state() else { + return Err("expected fsmonitor recovery planning to fail closed".into()); + }; + + assert!(error.to_string().contains("core.fsmonitor")); + assert!(!marker.exists()); + assert!(started.elapsed() < Duration::from_secs(2)); Ok(()) } + #[cfg(target_os = "linux")] #[test] - fn parses_remotes() { - let remotes = parse_remotes( - "origin\thttps://github.com/cosentinode/bitbygit.git (fetch)\norigin\tgit@github.com:cosentinode/bitbygit.git (push)\n", + fn recovery_rejects_fifo_rebase_todo_without_blocking() -> Result<(), Box> { + let (repo, _original_head) = prepare_rebase_conflict()?; + let todo = Git::new(repo.path()).git_path("rebase-merge/git-rebase-todo")?; + fs::remove_file(&todo)?; + assert!(Command::new("mkfifo").arg(&todo).status()?.success()); + let git = Git::with_recovery_limits( + repo.path(), + Duration::from_millis(200), + Duration::from_secs(5), + 4096, ); + let started = Instant::now(); - assert_eq!(remotes.len(), 1); - assert_eq!(remotes[0].name, "origin"); - assert_eq!( - remotes[0].fetch_url, - Some("https://github.com/cosentinode/bitbygit.git".to_owned()) - ); - assert_eq!( - remotes[0].push_url, - Some("git@github.com:cosentinode/bitbygit.git".to_owned()) + let Err(error) = git.recovery_state() else { + return Err("expected FIFO rebase todo to fail closed".into()); + }; + + assert!( + error.to_string().contains("not a regular file"), + "unexpected error: {error}" ); + assert!(started.elapsed() < Duration::from_secs(2)); + Ok(()) } + #[cfg(target_os = "linux")] #[test] - fn parses_branches_and_skips_remote_head() -> Result<(), Box> { - let branches = parse_branches( - "refs/heads/main\x001111111111111111111111111111111111111111\x00origin/main\x00*\nrefs/remotes/origin/main\x002222222222222222222222222222222222222222\x00\x00\nrefs/remotes/origin/HEAD\x002222222222222222222222222222222222222222\x00\x00\n", - )?; - - assert_eq!(branches.len(), 2); - assert_eq!(branches[0].name, "main"); - assert_eq!(branches[0].kind, BranchKind::Local); - assert!(branches[0].current); - assert_eq!(branches[0].upstream.as_deref(), Some("origin/main")); - assert_eq!(branches[1].name, "origin/main"); - assert_eq!(branches[1].kind, BranchKind::Remote); + fn recovery_rejects_fifo_attributes_without_blocking() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let attributes = Git::new(repo.path()).git_path("info/attributes")?; + assert!(Command::new("mkfifo").arg(&attributes).status()?.success()); + let git = Git::with_recovery_limits( + repo.path(), + Duration::from_millis(200), + Duration::from_secs(5), + 4096, + ); + let started = Instant::now(); + + let Err(error) = git.recovery_state() else { + return Err("expected FIFO attributes to fail closed".into()); + }; + + let error = error.to_string(); + assert!( + error.contains("not a regular file") || error.contains("timed out"), + "unexpected error: {error}" + ); + assert!(started.elapsed() < Duration::from_secs(2)); Ok(()) } #[test] - fn branch_target_blocks_ambiguous_local_and_remote_names() -> Result<(), Box> { - let repo = TempRepo::new()?; - repo.run(["init", "-b", "main"])?; - repo.run(["config", "user.email", "bitbygit@example.invalid"])?; - repo.run(["config", "user.name", "bitbygit test"])?; - repo.write("README.md", "initial\n")?; - repo.run(["add", "README.md"])?; - repo.run(["commit", "-m", "initial"])?; - repo.run(["branch", "origin/main"])?; - repo.run(["update-ref", "refs/remotes/origin/main", "HEAD"])?; + fn recovery_rejects_all_configured_external_driver_kinds() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + repo.write(".gitattributes", "*.txt filter=unsafe\n")?; let git = Git::new(repo.path()); + for key in [ + "filter.unsafe.clean", + "filter.unsafe.smudge", + "filter.unsafe.process", + "diff.external", + "diff.unsafe.command", + "diff.unsafe.textconv", + "merge.unsafe.driver", + ] { + repo.run(["config", key, "cat"])?; + let Err(error) = git.recovery_state() else { + return Err(format!("expected {key} to fail closed").into()); + }; + assert!(error.to_string().contains("external Git drivers")); + assert!(error.to_string().contains(key)); + repo.run(["config", "--unset", key])?; + } + Ok(()) + } - let result = git.branch_target("origin/main"); + #[test] + fn recovery_rejects_external_driver_from_global_scope() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let config_dir = TempRepo::new()?; + let global_config = config_dir.path().join("global-config"); + fs::write(&global_config, "[diff]\n\texternal = global-diff-command\n")?; + let git = Git::new(repo.path()).with_test_global_config(global_config); - let Err(error) = result else { - return Err("expected ambiguous branch guardrail".into()); + let Err(error) = git.recovery_state() else { + return Err("expected a global external diff driver to fail closed".into()); }; - assert!(error.to_string().contains("ambiguous")); + + assert!(error.to_string().contains("diff.external")); + assert!(error.to_string().contains("every Git config scope")); Ok(()) } #[test] - fn reads_repository_state_from_temp_repo() -> Result<(), Box> { - let repo = TempRepo::new()?; - repo.run(["init", "-b", "main"])?; - repo.run(["config", "user.email", "bitbygit@example.invalid"])?; - repo.run(["config", "user.name", "bitbygit test"])?; - repo.write("README.md", "initial\n")?; - repo.run(["add", "README.md"])?; - repo.run(["commit", "-m", "initial"])?; - repo.write("README.md", "changed\n")?; - repo.write("staged.txt", "staged\n")?; - repo.run(["add", "staged.txt"])?; - repo.write("untracked.txt", "untracked\n")?; + fn recovery_allows_inactive_global_lfs_configuration() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let config_dir = TempRepo::new()?; + let global_config = config_dir.path().join("global-config"); + fs::write( + &global_config, + "[filter \"lfs\"]\n\tclean = git-lfs clean -- %f\n\tsmudge = git-lfs smudge -- %f\n\tprocess = git-lfs filter-process\n\trequired = true\n", + )?; + let git = Git::new(repo.path()).with_test_global_config(global_config); - let git = Git::new(repo.path()); - let state = git.repository()?; + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; - assert_eq!(state.root, repo.path()); - assert_eq!(state.branch.head, Head::Branch("main".to_owned())); - assert_eq!(state.status.staged_files().len(), 1); - assert_eq!(state.status.unstaged_files().len(), 1); - assert_eq!(state.status.untracked_files().len(), 1); + assert_eq!(git.status()?.operation, None); Ok(()) } #[test] - fn branch_workflows_create_checkout_merge_and_rebase() -> Result<(), Box> { - let repo = TempRepo::new()?; - repo.run(["init", "-b", "main"])?; - repo.run(["config", "user.email", "bitbygit@example.invalid"])?; - repo.run(["config", "user.name", "bitbygit test"])?; - repo.write("README.md", "initial\n")?; - repo.run(["add", "README.md"])?; - repo.run(["commit", "-m", "initial"])?; - let git = Git::new(repo.path()); - - git.create_branch("feature", None, &git.head_target()?)?; - repo.write("feature.txt", "feature\n")?; - repo.run(["add", "feature.txt"])?; - repo.run(["commit", "-m", "feature"])?; - let feature = git.branch_target("feature")?.ok_or("missing feature")?; - let main = git.branch_target("main")?.ok_or("missing main")?; - git.checkout_branch(&main, &git.head_target()?)?; - git.merge_ff_only(&feature, &git.head_target()?)?; - assert!(repo.path().join("feature.txt").exists()); - - git.create_branch("topic", None, &git.head_target()?)?; - repo.write("topic.txt", "topic\n")?; - repo.run(["add", "topic.txt"])?; - repo.run(["commit", "-m", "topic"])?; - let main = git.branch_target("main")?.ok_or("missing main")?; - git.checkout_branch(&main, &git.head_target()?)?; - repo.write("base.txt", "base\n")?; - repo.run(["add", "base.txt"])?; + fn rebase_abort_rejects_filter_selected_only_by_target_tree() -> Result<(), Box> { + let repo = initialized_repo()?; + repo.write(".gitattributes", "*.txt text\n")?; + repo.write("conflict.txt", "base\n")?; + repo.run(["add", ".gitattributes", "conflict.txt"])?; repo.run(["commit", "-m", "base"])?; - let topic = git.branch_target("topic")?.ok_or("missing topic")?; - git.checkout_branch(&topic, &git.head_target()?)?; - let main = git.branch_target("main")?.ok_or("missing main")?; - git.rebase_onto(&main, &git.head_target()?)?; + repo.run(["switch", "-c", "topic"])?; + repo.write(".gitattributes", "*.txt filter=unsafe\n")?; + repo.write("conflict.txt", "topic\n")?; + repo.run(["commit", "-am", "topic"])?; + let target = repo.git_stdout(["rev-parse", "HEAD"])?.trim().to_owned(); + repo.run(["switch", "main"])?; + repo.write("conflict.txt", "main\n")?; + repo.run(["commit", "-am", "main"])?; + repo.run(["switch", "topic"])?; + repo.run_allow_failure(["rebase", "main"])?; + repo.write(".gitattributes", "*.txt text\n")?; + assert_eq!( + repo.git_stdout_args(&["show", &format!("{target}:.gitattributes")])?, + "*.txt filter=unsafe\n" + ); - repo.run(["merge-base", "--is-ancestor", "main", "HEAD"])?; + let marker = repo.path().join("filter-ran"); + repo.run_args(&[ + "config", + "filter.unsafe.smudge", + &format!("touch '{}'", marker.display()), + ])?; + let git = Git::new(repo.path()); + let Err(error) = git.prepare_recovery(RepositoryOperation::Rebase, RecoveryAction::Abort) + else { + return Err("expected target-tree filter configuration to fail closed".into()); + }; + + assert!(error.to_string().contains("recovery target tree")); + assert!(!marker.exists()); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); Ok(()) } + #[cfg(target_os = "linux")] #[test] - fn branch_workflows_block_dirty_tree() -> Result<(), Box> { - let repo = TempRepo::new()?; - repo.run(["init", "-b", "main"])?; - repo.run(["config", "user.email", "bitbygit@example.invalid"])?; - repo.run(["config", "user.name", "bitbygit test"])?; - repo.write("README.md", "initial\n")?; - repo.run(["add", "README.md"])?; - repo.run(["commit", "-m", "initial"])?; - repo.write("README.md", "dirty\n")?; + fn recovery_rejects_signing_configuration() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; let git = Git::new(repo.path()); - let result = git.create_branch("feature", None, &git.head_target()?); - - let Err(error) = result else { - return Err("expected dirty tree guardrail".into()); - }; - assert!(error.to_string().contains("working tree is not clean")); + for key in ["commit.gpgsign", "tag.gpgsign"] { + repo.run_args(&["config", key, "true"])?; + let Err(error) = git.recovery_state() else { + return Err(format!("expected {key} to fail closed").into()); + }; + assert!(error.to_string().contains(key)); + repo.run_args(&["config", "--unset", key])?; + } Ok(()) } + #[cfg(target_os = "linux")] #[test] - fn remote_checkout_blocks_when_local_branch_exists() -> Result<(), Box> { - let repo = TempRepo::new()?; - repo.run(["init", "-b", "main"])?; - repo.run(["config", "user.email", "bitbygit@example.invalid"])?; - repo.run(["config", "user.name", "bitbygit test"])?; - repo.write("README.md", "initial\n")?; - repo.run(["add", "README.md"])?; - repo.run(["commit", "-m", "initial"])?; - repo.run(["update-ref", "refs/remotes/origin/main", "HEAD"])?; - let git = Git::new(repo.path()); - let remote = git.branch_target("origin/main")?.ok_or("missing remote")?; + fn recovery_rejects_active_rebase_gpg_sign_before_starting_signer() -> Result<(), Box> + { + use std::os::unix::fs::PermissionsExt; - let result = git.checkout_branch(&remote, &git.head_target()?); + let repo = initialized_repo()?; + repo.write("conflict.txt", "base\n")?; + repo.run(["add", "conflict.txt"])?; + repo.run(["commit", "-m", "base"])?; + repo.run(["switch", "-c", "topic"])?; + repo.write("conflict.txt", "topic\n")?; + repo.run(["commit", "-am", "topic"])?; + repo.run(["switch", "main"])?; + repo.write("conflict.txt", "main\n")?; + repo.run(["commit", "-am", "main"])?; + repo.run(["switch", "topic"])?; + let marker = repo.path().join("signer-ran"); + let signer = repo.path().join("fake-signer"); + fs::write( + &signer, + format!("#!/bin/sh\ntouch '{}'\nexit 1\n", marker.display()), + )?; + let mut permissions = fs::metadata(&signer)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&signer, permissions)?; + repo.run_args(&["config", "gpg.program", &signer.to_string_lossy()])?; + repo.run_allow_failure(["rebase", "--gpg-sign", "main"])?; + let git = Git::new(repo.path()); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; - let Err(error) = result else { - return Err("expected remote checkout guardrail".into()); + let Err(error) = git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue) else { + return Err("expected active signed rebase to fail closed".into()); }; + assert!( error .to_string() - .contains("local branch main already exists") + .contains("active rebase requests commit signing") ); + assert!(!marker.exists()); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); Ok(()) } + #[cfg(target_os = "linux")] #[test] - fn branch_workflows_block_in_progress_rebase() -> Result<(), Box> { - let repo = TempRepo::new()?; - repo.run(["init", "-b", "main"])?; - repo.run(["config", "user.email", "bitbygit@example.invalid"])?; - repo.run(["config", "user.name", "bitbygit test"])?; - repo.write("README.md", "initial\n")?; - repo.run(["add", "README.md"])?; - repo.run(["commit", "-m", "initial"])?; - repo.run(["switch", "-c", "topic"])?; - repo.write("topic.txt", "topic\n")?; - repo.run(["add", "topic.txt"])?; - repo.run(["commit", "-m", "topic"])?; - repo.run(["switch", "main"])?; - repo.write("base.txt", "base\n")?; - repo.run(["add", "base.txt"])?; - repo.run(["commit", "-m", "base"])?; - repo.run(["switch", "topic"])?; - repo.run_allow_failure(["rebase", "--exec", "false", "main"])?; + fn recovery_git_invocation_disables_automatic_maintenance() -> Result<(), Box> { + let repo = initialized_repo()?; let git = Git::new(repo.path()); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + for (key, expected) in [ + ("maintenance.auto", &b"false\n"[..]), + ("gc.auto", &b"0\n"[..]), + ] { + let args = vec!["config".to_owned(), "--get".to_owned(), key.to_owned()]; + let output = + git.run_bounded_git(args.clone(), Instant::now() + Duration::from_secs(2), false)?; + assert!(output.status.success(), "{key}"); + assert_eq!(output.stdout.bytes, expected, "{key}"); + } + Ok(()) + } - let result = git.create_branch("new-topic", None, &git.head_target()?); + #[cfg(unix)] + #[test] + fn recovery_platform_execution_cleans_descendants_after_parent_exit() + -> Result<(), Box> { + let repo = TempRepo::new()?; + let marker = repo.path().join("descendant-ran"); + let mut command = Command::new("sh"); + command + .arg("-c") + .arg("(sleep 1; touch \"$1\") &") + .arg("sh") + .arg(&marker); + configure_process_group(&mut command); + + let output = run_bounded_command( + command, + vec!["descendant-cleanup-test".to_owned()], + Instant::now() + Duration::from_secs(5), + 4096, + BoundedCommandPolicy::RecoveryExecution, + || Ok(()), + )?; + assert!(output.status.success()); + thread::sleep(Duration::from_millis(1100)); + assert!(!marker.exists()); + Ok(()) + } - let Err(error) = result else { - return Err("expected in-progress rebase guardrail".into()); - }; - assert!( - error - .to_string() - .contains("rebase operation is in progress") + #[cfg(windows)] + #[test] + fn recovery_platform_execution_cleans_windows_job_descendants_after_parent_exit() + -> Result<(), Box> { + let repo = TempRepo::new()?; + let marker = repo.path().join("windows-descendant-ran"); + let script = format!( + "Start-Process powershell -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 1; Set-Content -Path ''{}'' -Value ran' | Out-Null", + marker.display() ); + let mut command = Command::new("powershell"); + command.args(["-NoProfile", "-Command", &script]); + + let output = run_bounded_command( + command, + vec!["windows-job-cleanup-test".to_owned()], + Instant::now() + Duration::from_secs(5), + 4096, + BoundedCommandPolicy::RecoveryExecution, + || Ok(()), + )?; + assert!(output.status.success()); + thread::sleep(Duration::from_millis(1200)); + assert!(!marker.exists()); Ok(()) } #[test] - fn detects_conflicts_in_temp_repo() -> Result<(), Box> { + fn recovery_rejects_remaining_rebase_exec_command() -> Result<(), Box> { + let (repo, _original_head) = prepare_rebase_conflict()?; + let todo = Git::new(repo.path()).git_path("rebase-merge/git-rebase-todo")?; + fs::write(&todo, "exec touch must-not-run\n")?; + + let Err(error) = Git::new(repo.path()).recovery_state() else { + return Err("expected rebase exec command to fail closed".into()); + }; + + assert!(error.to_string().contains("rebase plan contains an exec")); + assert!(!repo.path().join("must-not-run").exists()); + Ok(()) + } + + #[test] + fn recovery_fingerprint_applies_process_policy_to_exact_control_observation() + -> Result<(), Box> { let repo = TempRepo::new()?; - repo.run(["init", "-b", "main"])?; - repo.run(["config", "user.email", "bitbygit@example.invalid"])?; - repo.run(["config", "user.name", "bitbygit test"])?; - repo.write("conflict.txt", "base\n")?; - repo.run(["add", "conflict.txt"])?; - repo.run(["commit", "-m", "base"])?; - repo.run(["checkout", "-b", "other"])?; - repo.write("conflict.txt", "other\n")?; - repo.run(["commit", "-am", "other"])?; - repo.run(["checkout", "main"])?; - repo.write("conflict.txt", "main\n")?; - repo.run(["commit", "-am", "main"])?; - repo.run_allow_failure(["merge", "other"])?; + for (label, contents, expected) in [ + ( + b"rebase-merge/git-rebase-todo".as_slice(), + "exec touch must-not-run\n", + "rebase plan contains an exec", + ), + ( + b"rebase-merge/strategy".as_slice(), + "resolve\n", + "explicit merge strategy", + ), + ] { + let input = repo.path().join("control-input"); + fs::write(&input, contents)?; + let mut hasher = Sha256::new(); + let mut entries = 0; + let mut fence = RecoveryInputFence::default(); + + let Err(error) = hash_recovery_path_fenced( + &mut hasher, + label, + &input, + Instant::now() + Duration::from_secs(2), + &mut entries, + Some(&mut fence), + ) else { + return Err(format!("expected exact {expected} observation to be rejected").into()); + }; - let status = Git::new(repo.path()).status()?; + assert!(error.to_string().contains(expected), "{error}"); + } + Ok(()) + } - assert_eq!(status.operation, Some(RepositoryOperation::Merge)); - assert_eq!(status.conflicted_files().len(), 1); - assert_eq!( - status.conflicted_files()[0].path, - PathBuf::from("conflict.txt") - ); + #[cfg(unix)] + #[test] + fn recovery_rejects_persisted_explicit_strategy_before_spawn() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_rebase_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let exec_dir = TempRepo::new()?; + let marker = repo.path().join("explicit-strategy-ran"); + let strategy = exec_dir.path().join("git-merge-resolve"); + fs::write( + &strategy, + format!("#!/bin/sh\ntouch '{}'\nexit 1\n", marker.display()), + )?; + let mut permissions = fs::metadata(&strategy)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&strategy, permissions)?; + let git = Git::new(repo.path()).with_test_git_exec_path(exec_dir.path()); + fs::write(git.git_path("rebase-merge/strategy")?, "resolve\n")?; - repo.run(["add", "conflict.txt"])?; - let status = Git::new(repo.path()).status()?; + let Err(error) = git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue) else { + return Err("expected a persisted explicit strategy to be rejected".into()); + }; - assert_eq!(status.operation, Some(RepositoryOperation::Merge)); - assert!(status.conflicted_files().is_empty()); + assert!(error.to_string().contains("explicit merge strategy")); + assert!(!marker.exists(), "explicit merge strategy was started"); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); Ok(()) } #[test] - fn merge_continue_requires_resolution_and_finishes_without_an_editor() - -> Result<(), Box> { - let (repo, _original_head) = prepare_merge_conflict()?; - repo.run(["config", "core.editor", "false"])?; + fn recovery_rejects_persisted_strategy_options_before_spawn() -> Result<(), Box> { + let (repo, _original_head) = prepare_rebase_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; let git = Git::new(repo.path()); + fs::write( + git.git_path("rebase-merge/strategy_opts")?, + "--evil-option\n", + )?; - let Err(error) = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue) else { - return Err("expected unresolved merge to block continue".into()); + let Err(error) = git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue) else { + return Err("expected persisted strategy options to be rejected".into()); }; - assert!(error.to_string().contains("unresolved conflicts")); - repo.write("conflict.txt", "resolved\n")?; - repo.run(["add", "conflict.txt"])?; - git.recover(RepositoryOperation::Merge, RecoveryAction::Continue)?; + assert!(error.to_string().contains("strategy option")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + Ok(()) + } - assert_eq!(git.status()?.operation, None); - repo.run(["rev-parse", "--verify", "HEAD^2"])?; + #[cfg(target_os = "linux")] + #[test] + fn recovery_rejects_tab_delimited_rebase_exec_commands_before_spawn() + -> Result<(), Box> { + for (index, command) in ["exec", "x"].into_iter().enumerate() { + let (repo, _original_head) = prepare_rebase_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let git = Git::new(repo.path()); + let todo = git.git_path("rebase-merge/git-rebase-todo")?; + let marker = repo.path().join(format!("tab-exec-{index}-ran")); + fs::write(&todo, format!("{command}\ttouch '{}'\n", marker.display()))?; + + let Err(error) = git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue) + else { + return Err(format!("expected tab-delimited {command} to fail closed").into()); + }; + + assert!(error.to_string().contains("rebase plan contains an exec")); + assert!(!marker.exists(), "{command} process started"); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + } Ok(()) } + #[cfg(any(unix, windows))] #[test] - fn merge_abort_clears_operation_and_restores_head() -> Result<(), Box> { + fn recovery_platform_lock_serializes_bitbygit_execution() -> Result<(), Box> { let (repo, original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - - git.recover(RepositoryOperation::Merge, RecoveryAction::Abort)?; + let storage = TempRepo::new()?; + let git = Git::new(repo.path()).with_recovery_data_dir(storage.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + let held = git.acquire_recovery_lock_until( + RepositoryOperation::Merge, + RecoveryAction::Abort, + Instant::now() + Duration::from_secs(2), + )?; - assert_eq!(git.status()?.operation, None); + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected concurrent BitByGit recovery to be blocked".into()); + }; + assert!(error.to_string().contains("another BitByGit recovery")); assert_eq!( repo.git_stdout(["rev-parse", "HEAD"])?.trim(), original_head ); - assert_eq!( - fs::read_to_string(repo.path().join("conflict.txt"))?, - "main\n" - ); + + drop(held); + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; + assert_eq!(git.status()?.operation, None); Ok(()) } + #[cfg(any(unix, windows))] #[test] - fn rebase_continue_reports_the_next_conflict_then_finishes() -> Result<(), Box> { - let repo = prepare_two_conflict_rebase()?; - repo.run(["config", "core.editor", "false"])?; - let git = Git::new(repo.path()); + fn recovery_lock_replacement_cannot_enable_concurrent_recovery() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let storage = TempRepo::new()?; + let git = Git::new(repo.path()).with_recovery_data_dir(storage.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + let lock = git.acquire_recovery_lock_until( + RepositoryOperation::Merge, + RecoveryAction::Abort, + Instant::now() + Duration::from_secs(2), + )?; + git.ensure_recovery_state( + &expected, + RepositoryOperation::Merge, + RecoveryAction::Abort, + Instant::now() + Duration::from_secs(2), + )?; + let replaced = lock.path.with_extension("replaced"); + fs::rename(&lock.path, &replaced)?; + fs::File::create(&lock.path)?; - repo.write("first.txt", "topic first\n")?; - repo.run(["add", "first.txt"])?; - let Err(error) = git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue) else { - return Err("expected rebase to stop at the next conflict".into()); - }; - let GitError::GitFailed { - args, - stdout, - stderr, - .. - } = &error + let Err(concurrent_error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) else { - return Err(format!("expected git failure for next conflict, got {error}").into()); + return Err("expected stable repository lock to block concurrent recovery".into()); }; - assert_eq!(args, &["rebase".to_owned(), "--continue".to_owned()]); - assert!(format!("{stdout}\n{stderr}").contains("second.txt")); - let status = git.status()?; - assert_eq!(status.operation, Some(RepositoryOperation::Rebase)); - assert_eq!(status.conflicted_files().len(), 1); - assert_eq!( - status.conflicted_files()[0].path, - PathBuf::from("second.txt") + assert!( + concurrent_error + .to_string() + .contains("another BitByGit recovery") ); - repo.write("second.txt", "topic second\n")?; - repo.run(["add", "second.txt"])?; - git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue)?; + let Err(error) = lock.ensure_identity( + &lock.repository_root, + RepositoryOperation::Merge, + RecoveryAction::Abort, + ) else { + return Err("expected replaced recovery lock to fail identity check".into()); + }; - assert_eq!(git.status()?.operation, None); - repo.run(["merge-base", "--is-ancestor", "main", "HEAD"])?; + assert!(error.to_string().contains("lock identity changed")); + drop(lock); + fs::remove_file(replaced)?; Ok(()) } + #[cfg(unix)] #[test] - fn rebase_abort_clears_operation_and_restores_head() -> Result<(), Box> { - let (repo, original_head) = prepare_rebase_conflict()?; - let git = Git::new(repo.path()); - - git.recover(RepositoryOperation::Rebase, RecoveryAction::Abort)?; + fn recovery_platform_lock_directory_replacement_is_rejected_after_final_validation() + -> Result<(), Box> { + let (repo, original_head) = prepare_merge_conflict()?; + let storage = TempRepo::new()?; + let git = Git::new(repo.path()).with_recovery_data_dir(storage.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + let lock_directory = storage.path().join(RECOVERY_LOCK_DIRECTORY); + let replaced_directory = storage.path().join("recovery-locks-replaced"); + let concurrent = std::cell::RefCell::new(None); + + let Err(error) = git.recover_exact_with( + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + || { + fs::rename(&lock_directory, &replaced_directory) + .map_err(|source| recovery_state_io(&lock_directory, source))?; + fs::create_dir(&lock_directory) + .map_err(|source| recovery_state_io(&lock_directory, source))?; + let lock = git.acquire_recovery_lock_until( + RepositoryOperation::Merge, + RecoveryAction::Abort, + Instant::now() + Duration::from_secs(2), + )?; + *concurrent.borrow_mut() = Some(lock); + Ok(()) + }, + ) else { + return Err("expected replaced lock directory to block recovery".into()); + }; - assert_eq!(git.status()?.operation, None); + assert!(concurrent.borrow().is_some()); + assert!(error.to_string().contains("lock identity changed")); assert_eq!( repo.git_stdout(["rev-parse", "HEAD"])?.trim(), original_head ); - assert_eq!( - fs::read_to_string(repo.path().join("conflict.txt"))?, - "topic\n" - ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + drop(concurrent.into_inner()); + fs::remove_dir_all(replaced_directory)?; Ok(()) } + #[cfg(unix)] #[test] - fn rebase_skip_drops_the_conflicting_commit() -> Result<(), Box> { - let (repo, _original_head) = prepare_rebase_conflict()?; - let git = Git::new(repo.path()); - - git.recover(RepositoryOperation::Rebase, RecoveryAction::Skip)?; + fn recovery_lock_survives_git_directory_replacement() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let storage = TempRepo::new()?; + let git = Git::new(repo.path()).with_recovery_data_dir(storage.path()); + let lock = git.acquire_recovery_lock_until( + RepositoryOperation::Merge, + RecoveryAction::Abort, + Instant::now() + Duration::from_secs(2), + )?; + let original_lock_path = lock.path.clone(); + fs::rename(repo.path().join(".git"), repo.path().join(".git-original"))?; + repo.run(["init", "-b", "main"])?; + let replacement = Git::new(repo.path()).with_recovery_data_dir(storage.path()); - assert_eq!(git.status()?.operation, None); - assert_eq!( - fs::read_to_string(repo.path().join("conflict.txt"))?, - "main\n" - ); assert_eq!( - repo.git_stdout(["rev-parse", "HEAD"])?.trim(), - repo.git_stdout(["rev-parse", "main"])?.trim() + replacement.recovery_lock_path( + RepositoryOperation::Merge, + RecoveryAction::Abort, + &replacement + .canonical_repository_root_until(Instant::now() + Duration::from_secs(2))?, + )?, + original_lock_path ); + let Err(error) = replacement.acquire_recovery_lock_until( + RepositoryOperation::Merge, + RecoveryAction::Abort, + Instant::now() + Duration::from_secs(2), + ) else { + return Err("expected replacement Git directory to use the held app lock".into()); + }; + assert!(error.to_string().contains("another BitByGit recovery")); Ok(()) } #[test] - fn rebase_merge_step_recovery_preserves_rebase_identity() -> Result<(), Box> { - let (abort_repo, original_head) = prepare_rebase_merge_conflict()?; - let abort_git = Git::new(abort_repo.path()); + fn recovery_platform_lock_key_and_path_are_safe() -> Result<(), Box> { + let repo = initialized_repo()?; + let storage = TempRepo::new()?; + let git = Git::new(repo.path()).with_recovery_data_dir(storage.path()); + let root = git.canonical_repository_root_until(Instant::now() + Duration::from_secs(2))?; + let path = + git.recovery_lock_path(RepositoryOperation::Merge, RecoveryAction::Abort, &root)?; + let key = recovery_lock_key_from_path(&path).ok_or("unsafe recovery lock name")?; + let expected_parent = fs::canonicalize(storage.path().join(RECOVERY_LOCK_DIRECTORY))?; + + assert_eq!(key.len(), 64); + assert!(key.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert_eq!(path.parent(), Some(expected_parent.as_path())); + assert!(!repo.path().join(".git/bitbygit-recovery.lock").exists()); + Ok(()) + } - let Err(error) = abort_git.recover(RepositoryOperation::Merge, RecoveryAction::Abort) - else { - return Err("expected nested merge recovery to be rejected".into()); - }; - assert!(error.to_string().contains("rebase operation is active")); - assert!(abort_git.git_path("rebase-merge")?.exists()); - assert!(abort_git.git_path("MERGE_HEAD")?.exists()); + #[test] + fn recovery_storage_uses_bitbygit_data_root_precedence() { + let resolved = resolve_recovery_data_dir(|name| match name { + "BITBYGIT_DATA_DIR" => Some(PathBuf::from("/custom/data")), + "XDG_DATA_HOME" => Some(PathBuf::from("/xdg/data")), + "APPDATA" => Some(PathBuf::from("/appdata")), + "HOME" => Some(PathBuf::from("/home/test")), + _ => None, + }); + assert_eq!(resolved, Some(PathBuf::from("/custom/data"))); - abort_git.recover(RepositoryOperation::Rebase, RecoveryAction::Abort)?; - assert_eq!(abort_git.status()?.operation, None); - assert_eq!( - abort_repo.git_stdout(["rev-parse", "HEAD"])?.trim(), - original_head - ); + let resolved = resolve_recovery_data_dir(|name| match name { + "XDG_DATA_HOME" => Some(PathBuf::from("/xdg/data")), + "HOME" => Some(PathBuf::from("/home/test")), + _ => None, + }); + assert_eq!(resolved, Some(PathBuf::from("/xdg/data/bitbygit"))); + } + + #[cfg(target_os = "linux")] + #[test] + fn exact_recovery_surfaces_standard_git_lock_failure() -> Result<(), Box> { + let (repo, original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + let index_lock = git.git_path("index.lock")?; + fs::write(&index_lock, "external Git writer\n")?; - let (continue_repo, _original_head) = prepare_rebase_merge_conflict()?; - let continue_git = Git::new(continue_repo.path()); let Err(error) = - continue_git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue) + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) else { - return Err("expected unresolved nested merge to block continue".into()); + return Err("expected Git index lock failure".into()); }; - assert!(error.to_string().contains("unresolved conflicts")); - continue_repo.write("conflict.txt", "resolved again\n")?; - continue_repo.run(["add", "conflict.txt"])?; - continue_git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue)?; - assert_eq!(continue_git.status()?.operation, None); - let (skip_repo, _original_head) = prepare_rebase_merge_conflict()?; - let skip_git = Git::new(skip_repo.path()); - skip_git.recover(RepositoryOperation::Rebase, RecoveryAction::Skip)?; - assert_eq!(skip_git.status()?.operation, None); + let GitError::GitFailed { args, stderr, .. } = error else { + return Err("expected standard Git failure to be surfaced".into()); + }; + assert_eq!(args, ["merge".to_owned(), "--abort".to_owned()]); + assert!(stderr.contains("index.lock")); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + fs::remove_file(index_lock)?; Ok(()) } + #[cfg(any(unix, windows))] #[test] - fn recovery_rejects_merge_skip_absent_state_and_mismatched_state() -> Result<(), Box> - { - let clean = initialized_repo()?; - let clean_git = Git::new(clean.path()); - for (operation, action) in [ - (RepositoryOperation::Merge, RecoveryAction::Continue), - (RepositoryOperation::Merge, RecoveryAction::Abort), - (RepositoryOperation::Rebase, RecoveryAction::Continue), - (RepositoryOperation::Rebase, RecoveryAction::Abort), - (RepositoryOperation::Rebase, RecoveryAction::Skip), - ] { - let Err(error) = clean_git.recover(operation, action) else { - return Err( - format!("expected {operation:?} {action:?} to require active state").into(), - ); - }; - assert!(error.to_string().contains("no")); - assert!(error.to_string().contains("operation is active")); - } - - let (merge_repo, _original_head) = prepare_merge_conflict()?; - let merge_git = Git::new(merge_repo.path()); - let Err(error) = merge_git.recover(RepositoryOperation::Merge, RecoveryAction::Skip) else { - return Err("expected merge skip to be rejected".into()); - }; - assert!(error.to_string().contains("does not support")); + fn recovery_platform_end_to_end_exact_merge_and_rebase() -> Result<(), Box> { + ensure_recovery_execution_supported(RepositoryOperation::Merge, RecoveryAction::Abort)?; + let (repo, original_head) = prepare_merge_conflict()?; + let storage = TempRepo::new()?; + let git = Git::new(repo.path()).with_recovery_data_dir(storage.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; + assert_eq!(git.status()?.operation, None); assert_eq!( - merge_git.status()?.operation, - Some(RepositoryOperation::Merge) + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head ); + assert!(!git.git_path("index.lock")?.exists()); + drop(git.acquire_recovery_lock_until( + RepositoryOperation::Merge, + RecoveryAction::Abort, + Instant::now() + Duration::from_secs(2), + )?); - let Err(error) = merge_git.recover(RepositoryOperation::Rebase, RecoveryAction::Abort) - else { - return Err("expected mismatched rebase recovery to be rejected".into()); - }; - assert!(error.to_string().contains("merge operation is active")); + let (repo, original_head) = prepare_rebase_conflict()?; + let git = Git::new(repo.path()).with_recovery_data_dir(storage.path()); + let expected = git.prepare_recovery(RepositoryOperation::Rebase, RecoveryAction::Abort)?; + git.recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &expected, + )?; + assert_eq!(git.status()?.operation, None); assert_eq!( - merge_git.status()?.operation, - Some(RepositoryOperation::Merge) + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + assert_eq!( + fs::read_to_string(repo.path().join("conflict.txt"))?, + "topic\n" ); + assert!(!git.git_path("index.lock")?.exists()); + drop(git.acquire_recovery_lock_until( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + Instant::now() + Duration::from_secs(2), + )?); Ok(()) } - #[cfg(unix)] + #[cfg(not(any(unix, windows)))] #[test] - fn merge_continue_runs_hooks_and_does_not_bypass_signing() -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; - + fn recovery_platform_capability_fails_closed_before_planning() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; - let hooks = repo.path().join("hooks"); - fs::create_dir(&hooks)?; - let commit_msg_hook = hooks.join("commit-msg"); - fs::write( - &commit_msg_hook, - "#!/bin/sh\ntouch hook-ran\nprintf '\\377'\n", - )?; - let mut permissions = fs::metadata(&commit_msg_hook)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&commit_msg_hook, permissions)?; - repo.run(["config", "core.hooksPath", "hooks"])?; - - let signing_program = repo.path().join("signing-program"); - fs::write(&signing_program, "#!/bin/sh\ntouch signing-ran\nexit 1\n")?; - let mut permissions = fs::metadata(&signing_program)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&signing_program, permissions)?; - repo.run_args(&["config", "gpg.program", &signing_program.to_string_lossy()])?; - repo.run(["config", "commit.gpgsign", "true"])?; - repo.write("conflict.txt", "resolved\n")?; - repo.run(["add", "conflict.txt"])?; let git = Git::new(repo.path()); - let Err(error) = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue) else { - return Err("expected configured signing failure".into()); - }; - let GitError::GitFailed { - args, - stdout, - stderr, - .. - } = error + let Err(error) = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort) else { - return Err("expected standard git merge failure".into()); + return Err("expected recovery planning to fail closed".into()); }; - assert_eq!(args, ["merge".to_owned(), "--continue".to_owned()]); - assert!(format!("{stdout}{stderr}").contains('\u{fffd}')); - assert!(repo.path().join("hook-ran").exists()); - assert!(repo.path().join("signing-ran").exists()); + + let message = error.to_string(); + assert!(message.contains("recovery planning and execution are blocked")); + assert!(message.contains("hard output and descendant-process bounds")); assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); Ok(()) } #[cfg(unix)] #[test] - fn successful_recovery_preserves_non_utf8_hook_output() -> Result<(), Box> { + fn recovery_rejects_hooks_that_would_detach_descendants() -> Result<(), Box> { use std::os::unix::fs::PermissionsExt; let (repo, _original_head) = prepare_merge_conflict()?; - let hook = repo.path().join(".git").join("hooks").join("commit-msg"); - fs::write(&hook, "#!/bin/sh\nprintf '\\377'\n")?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let background_pid = repo.path().join("background.pid"); + let detached_pid = repo.path().join("detached.pid"); + let hook = repo.path().join(".git/hooks/commit-msg"); + fs::write( + &hook, + format!( + "#!/bin/sh\nsleep 30 &\nprintf '%s' $! > '{}'\nsetsid sleep 30 &\nprintf '%s' $! > '{}'\n", + background_pid.display(), + detached_pid.display() + ), + )?; let mut permissions = fs::metadata(&hook)?.permissions(); permissions.set_mode(0o755); fs::set_permissions(&hook, permissions)?; - repo.write("conflict.txt", "resolved\n")?; - repo.run(["add", "conflict.txt"])?; - let git = Git::new(repo.path()); - - let output = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue)?; + let git = Git::with_recovery_limits( + repo.path(), + Duration::from_secs(10), + Duration::from_secs(5), + 4096, + ); + let Err(error) = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue) else { + return Err("expected detached hook to fail closed".into()); + }; - assert!(format!("{}{}", output.stdout, output.stderr).contains('\u{fffd}')); - assert_eq!(git.status()?.operation, None); - repo.run(["rev-parse", "--verify", "HEAD^2"])?; + assert!(error.to_string().contains("executable Git hooks")); + assert!(!background_pid.exists()); + assert!(!detached_pid.exists()); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); Ok(()) } @@ -3593,6 +8439,7 @@ mod tests { Ok(()) } + #[cfg(unix)] fn write_path( &self, relative_path: &PathBuf, diff --git a/crates/bitbygit-tui/src/lib.rs b/crates/bitbygit-tui/src/lib.rs index f684a60..02d7c33 100644 --- a/crates/bitbygit-tui/src/lib.rs +++ b/crates/bitbygit-tui/src/lib.rs @@ -18,14 +18,15 @@ use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}; use bitbygit_core::{ ConfirmationRequirement, OperationKind, OperationPlan, OperationRequest, OperationStep, - RiskLevel, + RecoveryRequest, RiskLevel, policy::{EffectivePolicy, PolicyEvaluation}, prompt_parser::{ParsedPrompt, parse_prompt}, }; use bitbygit_gh::{CreatePullRequest, GhError, GitHub}; use bitbygit_git::{ BranchInfo, BranchKind, BranchState, BranchTarget, ChangeKind, Git, GitError, GitOutput, Head, - HeadTarget, RepositoryOperation, StatusEntry, StatusEntryType, + HeadTarget, RecoveryAction as GitRecoveryAction, RecoveryState, RepositoryOperation, + StatusEntry, StatusEntryType, }; use bitbygit_store::{AuditEntry, LocalStore, RepoId, StorePaths}; @@ -821,6 +822,9 @@ enum PendingPayload { base: BranchTarget, target: HeadTarget, }, + Recovery { + state: RecoveryState, + }, OpenPullRequest { branch: String, upstream: String, @@ -1358,6 +1362,44 @@ fn rebase_plan(current: &str, base: &BranchTarget) -> OperationPlan { ) } +fn recovery_plan(request: RecoveryRequest) -> OperationPlan { + let operation = request.operation_label(); + let action = request.action_label(); + OperationPlan::new( + OperationRequest::Recover(request), + format!("{} {} plan", capitalize(operation), action), + vec![ + OperationStep::new( + recovery_operation_kind(request), + RiskLevel::High, + format!("{action} active {operation}"), + ) + .with_detail("block if repository state changes after preview"), + ], + format!( + "Explicit confirmation required: press uppercase Y to {action} the {operation} or n to cancel." + ), + ) +} + +fn recovery_operation_kind(request: RecoveryRequest) -> OperationKind { + match request { + RecoveryRequest::MergeContinue => OperationKind::MergeContinue, + RecoveryRequest::MergeAbort => OperationKind::MergeAbort, + RecoveryRequest::RebaseContinue => OperationKind::RebaseContinue, + RecoveryRequest::RebaseAbort => OperationKind::RebaseAbort, + RecoveryRequest::RebaseSkip => OperationKind::RebaseSkip, + } +} + +fn capitalize(value: &str) -> String { + let mut characters = value.chars(); + characters + .next() + .map(|first| first.to_ascii_uppercase().to_string() + characters.as_str()) + .unwrap_or_default() +} + fn open_pull_request_plan( request: OperationRequest, remote: &str, @@ -1464,6 +1506,7 @@ fn operation_request_kind(request: &OperationRequest) -> Option { OperationRequest::CreateBranch { .. } => Some(OperationKind::CreateBranch), OperationRequest::Merge { .. } => Some(OperationKind::MergeFastForward), OperationRequest::Rebase { .. } => Some(OperationKind::Rebase), + OperationRequest::Recover(request) => Some(recovery_operation_kind(*request)), OperationRequest::OpenPullRequest { .. } => Some(OperationKind::OpenPullRequest), OperationRequest::PromptSequence { .. } => None, } @@ -1873,6 +1916,15 @@ fn prompt_sequence_request_preview( RiskLevel::High, format!("rebase current branch onto {base}"), )), + OperationRequest::Recover(request) => Ok(PromptSequenceStepPreview::new( + recovery_operation_kind(*request), + RiskLevel::High, + format!( + "{} active {}", + request.action_label(), + request.operation_label() + ), + )), OperationRequest::OpenPullRequest { base } => { let preview = PromptSequenceStepPreview::new( OperationKind::OpenPullRequest, @@ -2062,6 +2114,9 @@ impl OperationPlanner { if let Some(operation) = operation_request_kind(&request) { self.ensure_operation_enabled(operation)?; } + if !matches!(request, OperationRequest::Recover(_)) { + self.ensure_conflict_mode_allowed(std::slice::from_ref(&request))?; + } if let Some(operation) = self.preflight_blocked_request(&request)? { return Ok(operation); } @@ -2107,6 +2162,7 @@ impl OperationPlanner { } OperationRequest::Merge { branch } => self.plan_merge(branch)?, OperationRequest::Rebase { base } => self.plan_rebase(base)?, + OperationRequest::Recover(request) => self.plan_recovery(request)?, OperationRequest::OpenPullRequest { base } => self.plan_open_pull_request(base)?, OperationRequest::PromptSequence { .. } => { return Err("Prompt sequences are handled by prompt submission.".to_owned()); @@ -2153,6 +2209,17 @@ impl OperationPlanner { if requests.len() < 2 { return Err("Prompt sequence requires at least two steps.".to_owned()); } + if requests + .iter() + .skip(1) + .any(|request| matches!(request, OperationRequest::Recover(_))) + { + return Err( + "Recovery sequence blocked: recovery must be the first step so its confirmed state is captured by the sequence preview." + .to_owned(), + ); + } + self.ensure_conflict_mode_allowed(&requests)?; for (index, request) in requests.iter().enumerate() { let preview = prompt_sequence_request_preview(request).map_err(|error| { format!("Prompt sequence step {} is blocked: {error}", index + 1) @@ -2724,6 +2791,31 @@ impl OperationPlanner { )) } + fn plan_recovery(&self, request: RecoveryRequest) -> Result { + let git = self.git(); + let operation = recovery_git_operation(request); + let state = git + .prepare_recovery(operation, recovery_git_action(request)) + .map_err(|error| { + if matches!( + request, + RecoveryRequest::MergeContinue | RecoveryRequest::RebaseContinue + ) && error.to_string().contains("unresolved conflicts") + { + format!( + "{} continue blocked: resolve and stage all conflicts first.", + capitalize(request.operation_label()) + ) + } else { + format!("Unable to snapshot recovery state: {error}") + } + })?; + Ok(PreparedOperation::new( + recovery_plan(request), + ExecutionContext::from_payload(PendingPayload::Recovery { state }), + )) + } + fn plan_open_pull_request( &self, requested_base: Option, @@ -2891,6 +2983,10 @@ impl OperationPlanner { branch_name(&status.branch).map_err(|error| error.replace("Operation", action)) } + fn ensure_conflict_mode_allowed(&self, requests: &[OperationRequest]) -> Result<(), String> { + validate_conflict_mode(&self.git(), requests) + } + fn typed_push_target( &self, git: &Git, @@ -2925,11 +3021,19 @@ impl OperationPlanner { } fn git(&self) -> Git { + let git = { + #[cfg(test)] + if let Some(executable) = &self.ssh_executable { + Git::with_ssh_executable(self.repo_root.clone(), executable) + } else { + Git::new(self.repo_root.clone()) + } + #[cfg(not(test))] + Git::new(self.repo_root.clone()) + }; #[cfg(test)] - if let Some(executable) = &self.ssh_executable { - return Git::with_ssh_executable(self.repo_root.clone(), executable); - } - Git::new(self.repo_root.clone()) + let git = git.with_isolated_test_config(); + git } fn github(&self, repository: &GitHubRepository) -> GitHub { @@ -2950,6 +3054,147 @@ impl OperationPlanner { } } +fn validate_conflict_mode(git: &Git, requests: &[OperationRequest]) -> Result<(), String> { + if requests.iter().all(|request| { + conflict_mode_allows(request) && !matches!(request, OperationRequest::Recover(_)) + }) { + return Ok(()); + } + let recoveries = requests + .iter() + .filter_map(|request| match request { + OperationRequest::Recover(recovery) => Some(*recovery), + _ => None, + }) + .collect::>(); + if recoveries.len() > 1 { + return Err( + "Recovery sequence blocked: only one recovery action can be preflighted because it changes the active operation state." + .to_owned(), + ); + } + let operation = if let Some(recovery) = recoveries.first().copied() { + let operation = recovery_git_operation(recovery); + git.validate_recovery(operation, recovery_git_action(recovery)) + .map_err(|error| { + if matches!( + recovery, + RecoveryRequest::MergeContinue | RecoveryRequest::RebaseContinue + ) && error.to_string().contains("unresolved conflicts") + { + format!( + "{} continue blocked: resolve and stage all conflicts first.", + capitalize(recovery.operation_label()) + ) + } else { + format!("Unable to validate conflict-mode policy: {error}") + } + })?; + Some(operation) + } else { + git.status() + .map_err(|error| format!("Unable to validate conflict-mode policy: {error}"))? + .operation + }; + + for request in requests { + if let OperationRequest::Recover(recovery) = request { + if operation != Some(recovery_git_operation(*recovery)) { + return Err(recovery_state_error(*recovery, operation)); + } + } + if let Some(active) = operation { + if !conflict_mode_allows(request) { + return Err(format!( + "{} blocked: an active {} must be resolved first.", + request_action_label(request), + operation_label(active).to_ascii_lowercase() + )); + } + } + } + Ok(()) +} + +fn conflict_mode_allows(request: &OperationRequest) -> bool { + matches!( + request, + OperationRequest::RefreshStatus + | OperationRequest::ViewDiff { .. } + | OperationRequest::Fetch + | OperationRequest::StagePaths { .. } + | OperationRequest::UnstagePaths { .. } + | OperationRequest::StageAll + | OperationRequest::UnstageAll + | OperationRequest::Branches + | OperationRequest::Recover(_) + ) +} + +fn request_action_label(request: &OperationRequest) -> String { + match request { + OperationRequest::RefreshStatus => "Refresh status", + OperationRequest::ViewDiff { .. } => "View diff", + OperationRequest::Fetch => "Fetch", + OperationRequest::StagePaths { .. } => "Stage", + OperationRequest::UnstagePaths { .. } => "Unstage", + OperationRequest::StageAll => "Stage all", + OperationRequest::UnstageAll => "Unstage all", + OperationRequest::Commit { .. } => "Commit", + OperationRequest::Push => "Push", + OperationRequest::Pull { rebase: false } => "Pull", + OperationRequest::Pull { rebase: true } => "Pull rebase", + OperationRequest::Branches => "Branches", + OperationRequest::Checkout { .. } => "Checkout", + OperationRequest::CreateBranch { .. } => "Create branch", + OperationRequest::Merge { .. } => "Merge", + OperationRequest::Rebase { .. } => "Rebase", + OperationRequest::Recover(request) => { + return format!( + "{} {}", + capitalize(request.operation_label()), + request.action_label() + ); + } + OperationRequest::OpenPullRequest { .. } => "Open pull request", + OperationRequest::PromptSequence { .. } => "Prompt sequence", + } + .to_owned() +} + +fn recovery_git_operation(request: RecoveryRequest) -> RepositoryOperation { + match request { + RecoveryRequest::MergeContinue | RecoveryRequest::MergeAbort => RepositoryOperation::Merge, + RecoveryRequest::RebaseContinue + | RecoveryRequest::RebaseAbort + | RecoveryRequest::RebaseSkip => RepositoryOperation::Rebase, + } +} + +fn recovery_git_action(request: RecoveryRequest) -> GitRecoveryAction { + match request { + RecoveryRequest::MergeContinue | RecoveryRequest::RebaseContinue => { + GitRecoveryAction::Continue + } + RecoveryRequest::MergeAbort | RecoveryRequest::RebaseAbort => GitRecoveryAction::Abort, + RecoveryRequest::RebaseSkip => GitRecoveryAction::Skip, + } +} + +fn recovery_state_error(request: RecoveryRequest, active: Option) -> String { + let action = request_action_label(&OperationRequest::Recover(request)); + match active { + Some(active) => format!( + "{action} blocked: an active {} does not match the requested recovery.", + operation_label(active).to_ascii_lowercase() + ), + None => format!( + "{action} blocked: no active {} exists.", + request.operation_label() + ), + } +} + fn default_remote_name(git: &Git) -> Option { let remotes = git.remotes().ok()?; remotes @@ -3711,6 +3956,7 @@ fn policy_branch(context: &ExecutionContext) -> Option<&str> { | PendingPayload::UnstageAll | PendingPayload::Checkout { .. } | PendingPayload::CreateBranch { .. } + | PendingPayload::Recovery { .. } | PendingPayload::OpenPullRequest { .. } => None, } } @@ -3794,6 +4040,14 @@ impl PlanExecutor { plan_error: Some("operation plan has no steps".to_owned()), }; } + if let Err(error) = validate_conflict_mode(&self.git(), std::slice::from_ref(&plan.request)) + { + return PlanExecutionResult { + planned_step_count: plan.steps.len(), + step_results: Vec::new(), + plan_error: Some(error), + }; + } if let Err(error) = self.validate_policy(plan, &context) { return PlanExecutionResult { @@ -3919,6 +4173,11 @@ impl PlanExecutor { OperationKind::CreateBranch => self.run_create_branch_step(plan, context, &git), OperationKind::MergeFastForward => self.run_merge_step(plan, context, &git), OperationKind::Rebase => self.run_rebase_step(plan, context, &git), + OperationKind::MergeContinue + | OperationKind::MergeAbort + | OperationKind::RebaseContinue + | OperationKind::RebaseAbort + | OperationKind::RebaseSkip => self.run_recovery_step(plan, step, context, &git), OperationKind::OpenPullRequest => self.run_open_pull_request_step(plan, context, &git), OperationKind::RefreshStatus | OperationKind::ViewDiff => { Err(StepExecutionError::Unsupported(format!( @@ -3930,11 +4189,19 @@ impl PlanExecutor { } fn git(&self) -> Git { + let git = { + #[cfg(test)] + if let Some(executable) = &self.ssh_executable { + Git::with_ssh_executable(self.repo_root.clone(), executable) + } else { + Git::new(self.repo_root.clone()) + } + #[cfg(not(test))] + Git::new(self.repo_root.clone()) + }; #[cfg(test)] - if let Some(executable) = &self.ssh_executable { - return Git::with_ssh_executable(self.repo_root.clone(), executable); - } - Git::new(self.repo_root.clone()) + let git = git.with_isolated_test_config(); + git } fn audit_repo_id(&self) -> Option { @@ -4225,6 +4492,33 @@ impl PlanExecutor { git_output(git.rebase_onto(target, head)) } + fn run_recovery_step( + &self, + plan: &OperationPlan, + step: &OperationStep, + context: &ExecutionContext, + git: &Git, + ) -> StepRunResult { + let OperationRequest::Recover(request) = &plan.request else { + return Err(StepExecutionError::Unsupported( + "recovery step requires a typed recovery request".to_owned(), + )); + }; + if step.kind != recovery_operation_kind(*request) { + return Err(StepExecutionError::Unsupported( + "recovery step request does not match its operation kind".to_owned(), + )); + } + let PendingPayload::Recovery { state } = typed_payload(context, step.kind)? else { + return Err(mismatched_context(step.kind)); + }; + git_output(git.recover_exact( + recovery_git_operation(*request), + recovery_git_action(*request), + state, + )) + } + fn run_open_pull_request_step( &self, plan: &OperationPlan, @@ -4483,6 +4777,8 @@ impl PromptSequenceExecutor { let mut current_policy = self.load_policy(&self.policy); let git = Git::new(self.repo_root.clone()); + #[cfg(test)] + let git = git.with_isolated_test_config(); let requests = std::iter::once(first.plan.request.clone()) .chain(remaining_requests.iter().cloned()) .collect::>(); @@ -4584,6 +4880,15 @@ impl PromptSequenceExecutor { #[cfg(test)] ssh_executable: self.ssh_executable.clone(), }; + if let Err(error) = validate_conflict_mode(&executor.git(), &requests) { + step_results.push(PromptSequenceStepResult::planning_failed( + 1, + prompt_sequence_request_title(&first.plan.request), + error, + )); + return PromptSequenceExecutionResult::new(total_steps, step_results, current_policy); + } + let first_result = Self::execute_prepared_step(&executor, 1, first); let first_succeeded = first_result.succeeded; step_results.push(first_result); @@ -5269,6 +5574,11 @@ fn should_show_git_output(kind: OperationKind) -> bool { | OperationKind::CreateBranch | OperationKind::MergeFastForward | OperationKind::Rebase + | OperationKind::MergeContinue + | OperationKind::MergeAbort + | OperationKind::RebaseContinue + | OperationKind::RebaseAbort + | OperationKind::RebaseSkip ) } @@ -5428,6 +5738,7 @@ fn truncate_audit_message(message: &str) -> String { fn sanitized_git_error(error: &GitError) -> String { match error { GitError::GitFailed { status, .. } => format!("git failed with status {status}"), + GitError::TimedOut { .. } => "git timed out".to_owned(), GitError::Io { .. } => "git failed before execution".to_owned(), GitError::Utf8 { stream, .. } => format!("git returned non-UTF-8 {stream}"), GitError::Blocked { message } => format!("operation blocked: {message}"), @@ -6863,6 +7174,437 @@ mod tests { Ok(()) } + #[test] + fn recovery_plans_are_typed_high_risk_and_use_stable_audit_names() { + let cases = [ + ( + RecoveryRequest::MergeContinue, + OperationKind::MergeContinue, + "merge_continue", + ), + ( + RecoveryRequest::MergeAbort, + OperationKind::MergeAbort, + "merge_abort", + ), + ( + RecoveryRequest::RebaseContinue, + OperationKind::RebaseContinue, + "rebase_continue", + ), + ( + RecoveryRequest::RebaseAbort, + OperationKind::RebaseAbort, + "rebase_abort", + ), + ( + RecoveryRequest::RebaseSkip, + OperationKind::RebaseSkip, + "rebase_skip", + ), + ]; + + for (request, kind, audit_name) in cases { + let plan = recovery_plan(request); + assert_eq!(plan.request, OperationRequest::Recover(request)); + assert_eq!(plan.steps[0].kind, kind); + assert_eq!(plan.confirmation.risk_level, RiskLevel::High); + assert_eq!( + plan.confirmation.requirement, + ConfirmationRequirement::ExplicitConfirmation + ); + assert!(plan.confirmation.prompt.contains("uppercase Y")); + assert_eq!(kind.audit_operation(), audit_name); + } + } + + #[test] + fn recovery_rejects_lowercase_confirmation_without_side_effects() -> Result<(), Box> + { + let repo = merge_conflict_repo("recovery-uppercase-confirmation")?; + let operation = OperationPlanner::new(&repo) + .plan_request(OperationRequest::Recover(RecoveryRequest::MergeAbort)) + .map_err(std::io::Error::other)?; + let mut app = App::new(); + app.operation_queue + .enqueue(QueuedOperation::new(operation.plan, operation.context)); + + app.handle_key(key(KeyCode::Char('y'))); + + assert!(app.operation_queue.has_pending()); + assert!(app.details.contains("uppercase Y")); + assert_eq!( + Git::new(repo).status()?.operation, + Some(RepositoryOperation::Merge) + ); + Ok(()) + } + + #[test] + fn conflict_mode_blocks_risky_requests_before_sequence_side_effects() + -> Result<(), Box> { + let repo = merge_conflict_repo("conflict-policy")?; + std::fs::write(repo.join("unrelated.txt"), "do not stage\n")?; + let planner = OperationPlanner::new(&repo); + + assert!(planner.plan_request(OperationRequest::StageAll).is_ok()); + assert!(planner.plan_request(OperationRequest::Branches).is_ok()); + assert!( + planner + .plan_request(OperationRequest::RefreshStatus) + .is_err_and(|error| error.contains("handled directly by the UI")) + ); + assert!( + planner + .plan_request(OperationRequest::ViewDiff { + path: "conflict.txt".to_owned(), + }) + .is_err_and(|error| error.contains("handled directly by the UI")) + ); + assert!( + planner + .plan_request(OperationRequest::Push) + .is_err_and(|error| error.contains("active merge")) + ); + for request in [ + OperationRequest::Commit { + message: "blocked".to_owned(), + }, + OperationRequest::Pull { rebase: false }, + OperationRequest::Pull { rebase: true }, + OperationRequest::Checkout { + branch: "conflicting".to_owned(), + }, + OperationRequest::CreateBranch { + branch: "blocked".to_owned(), + base: None, + }, + OperationRequest::Merge { + branch: "conflicting".to_owned(), + }, + OperationRequest::Rebase { + base: "conflicting".to_owned(), + }, + OperationRequest::OpenPullRequest { base: None }, + ] { + assert!( + planner + .plan_request(request) + .is_err_and(|error| error.contains("active merge")) + ); + } + + let Err(error) = + planner.plan_prompt_sequence(vec![OperationRequest::StageAll, OperationRequest::Push]) + else { + return Err("risky deferred step should block the whole sequence".into()); + }; + assert!(error.contains("active merge")); + assert!( + git_stdout( + &repo, + &["diff", "--cached", "--name-only", "--", "unrelated.txt"] + )? + .trim() + .is_empty() + ); + Ok(()) + } + + #[test] + fn queued_sequence_revalidates_conflict_mode_before_first_side_effect() + -> Result<(), Box> { + let repo = isolated_git_repo("queued-conflict-policy")?; + let remote = isolated_bare_git_repo("queued-conflict-policy-remote")?; + configure_git_identity(&repo)?; + std::fs::write(repo.join("conflict.txt"), "base\n")?; + git_stdout(&repo, &["add", "conflict.txt"])?; + git_stdout(&repo, &["commit", "-m", "base"])?; + let branch = git_stdout(&repo, &["branch", "--show-current"])? + .trim() + .to_owned(); + git_stdout(&repo, &["checkout", "-b", "conflicting"])?; + std::fs::write(repo.join("conflict.txt"), "other\n")?; + git_stdout(&repo, &["commit", "-am", "other"])?; + git_stdout(&repo, &["checkout", branch.as_str()])?; + std::fs::write(repo.join("conflict.txt"), "current\n")?; + git_stdout(&repo, &["commit", "-am", "current"])?; + git_stdout( + &repo, + &["remote", "add", "origin", &remote.to_string_lossy()], + )?; + git_stdout(&repo, &["push", "-u", "origin", branch.as_str()])?; + let remote_ref = format!("refs/heads/{branch}"); + let remote_before = git_stdout(&remote, &["rev-parse", remote_ref.as_str()])?; + std::fs::write(repo.join("ahead.txt"), "local only\n")?; + git_stdout(&repo, &["add", "ahead.txt"])?; + git_stdout(&repo, &["commit", "-m", "ahead"])?; + let sequence = OperationPlanner::new(&repo) + .plan_prompt_sequence(vec![OperationRequest::Push, OperationRequest::Branches]) + .map_err(std::io::Error::other)?; + + let merge = std::process::Command::new("git") + .current_dir(&repo) + .args(["merge", "conflicting"]) + .output()?; + assert!(!merge.status.success()); + let paths = isolated_store_paths("queued-conflict-policy-audit")?; + + let result = PromptSequenceExecutor::with_audit_paths(&repo, paths.clone()) + .execute(sequence.sequence); + + assert!(result.message().contains("active merge")); + assert_eq!( + git_stdout(&remote, &["rev-parse", remote_ref.as_str()])?, + remote_before + ); + assert!(LocalStore::open(paths)?.list_audit_entries()?.is_empty()); + Ok(()) + } + + #[test] + fn queued_sequence_revalidates_deferred_requests_before_first_side_effect() + -> Result<(), Box> { + let repo = isolated_git_repo("queued-deferred-conflict-policy")?; + let remote = isolated_bare_git_repo("queued-deferred-conflict-policy-remote")?; + configure_git_identity(&repo)?; + configure_git_identity(&remote)?; + std::fs::write(repo.join("conflict.txt"), "base\n")?; + git_stdout(&repo, &["add", "conflict.txt"])?; + git_stdout(&repo, &["commit", "-m", "base"])?; + let branch = git_stdout(&repo, &["branch", "--show-current"])? + .trim() + .to_owned(); + git_stdout( + &repo, + &["remote", "add", "origin", &remote.to_string_lossy()], + )?; + git_stdout(&repo, &["push", "-u", "origin", branch.as_str()])?; + let remote_ref = format!("refs/heads/{branch}"); + let tracking_ref = format!("refs/remotes/origin/{branch}"); + let tracking_before = git_stdout(&repo, &["rev-parse", tracking_ref.as_str()])?; + + git_stdout(&repo, &["checkout", "-b", "conflicting"])?; + std::fs::write(repo.join("conflict.txt"), "other\n")?; + git_stdout(&repo, &["commit", "-am", "other"])?; + git_stdout(&repo, &["checkout", branch.as_str()])?; + std::fs::write(repo.join("conflict.txt"), "current\n")?; + git_stdout(&repo, &["commit", "-am", "current"])?; + let sequence = OperationPlanner::new(&repo) + .plan_prompt_sequence(vec![OperationRequest::Fetch, OperationRequest::Push]) + .map_err(std::io::Error::other)?; + + let remote_tree = git_stdout(&remote, &["rev-parse", "HEAD^{tree}"])?; + let remote_head = git_stdout(&remote, &["rev-parse", remote_ref.as_str()])?; + let remote_update = git_stdout( + &remote, + &[ + "commit-tree", + remote_tree.trim(), + "-p", + remote_head.trim(), + "-m", + "remote update", + ], + )?; + git_stdout( + &remote, + &[ + "update-ref", + remote_ref.as_str(), + remote_update.trim(), + remote_head.trim(), + ], + )?; + let merge = std::process::Command::new("git") + .current_dir(&repo) + .args(["merge", "conflicting"]) + .output()?; + assert!(!merge.status.success()); + let paths = isolated_store_paths("queued-deferred-conflict-policy-audit")?; + + let result = PromptSequenceExecutor::with_audit_paths(&repo, paths.clone()) + .execute(sequence.sequence); + + assert!(result.message().contains("active merge")); + assert_eq!( + git_stdout(&repo, &["rev-parse", tracking_ref.as_str()])?, + tracking_before + ); + assert!(LocalStore::open(paths)?.list_audit_entries()?.is_empty()); + Ok(()) + } + + #[test] + fn recovery_planner_matches_active_state_and_continue_prerequisites() + -> Result<(), Box> { + let repo = merge_conflict_repo("recovery-planner")?; + let planner = OperationPlanner::new(&repo); + + let Err(error) = planner.plan_prompt_sequence(vec![ + OperationRequest::Fetch, + OperationRequest::Recover(RecoveryRequest::MergeContinue), + ]) else { + return Err("deferred recovery should fail sequence preflight".into()); + }; + assert!(error.contains("recovery must be the first step")); + + let operation = planner + .plan_request(OperationRequest::Recover(RecoveryRequest::MergeAbort)) + .map_err(std::io::Error::other)?; + assert_eq!( + operation.plan.confirmation.requirement, + ConfirmationRequirement::ExplicitConfirmation + ); + assert!(matches!( + operation.context.payload, + Some(PendingPayload::Recovery { .. }) + )); + assert!( + planner + .plan_request(OperationRequest::Recover(RecoveryRequest::MergeContinue)) + .is_err_and(|error| error.contains("resolve and stage all conflicts")) + ); + assert!( + planner + .plan_request(OperationRequest::Recover(RecoveryRequest::RebaseAbort)) + .is_err_and(|error| error.contains("merge operation is active")) + ); + + let head_before = git_stdout(&repo, &["rev-parse", "HEAD"])?; + let Err(error) = planner.plan_prompt_sequence(vec![ + OperationRequest::Recover(RecoveryRequest::MergeAbort), + OperationRequest::Recover(RecoveryRequest::MergeAbort), + ]) else { + return Err("dependent recovery sequence should fail full preflight".into()); + }; + assert!(error.contains("recovery must be the first step")); + assert_eq!(git_stdout(&repo, &["rev-parse", "HEAD"])?, head_before); + assert_eq!( + Git::new(repo).status()?.operation, + Some(RepositoryOperation::Merge) + ); + Ok(()) + } + + #[test] + fn recovery_first_sequence_executes_the_state_captured_at_preview() -> Result<(), Box> + { + let repo = merge_conflict_repo("recovery-sequence-preview-state")?; + let sequence = OperationPlanner::new(&repo) + .plan_prompt_sequence(vec![ + OperationRequest::Recover(RecoveryRequest::MergeAbort), + OperationRequest::Branches, + ]) + .map_err(std::io::Error::other)?; + std::fs::write( + repo.join("conflict.txt"), + "changed after sequence preview\n", + )?; + let paths = isolated_store_paths("recovery-sequence-preview-state-audit")?; + + let result = PromptSequenceExecutor::with_audit_paths(&repo, paths.clone()) + .execute(sequence.sequence); + + assert!(result.message().contains("state changed after preview")); + assert!(!result.message().contains("Prompt sequence completed")); + assert_eq!( + Git::new(&repo).status()?.operation, + Some(RepositoryOperation::Merge) + ); + let entries = LocalStore::open(paths)?.list_audit_entries()?; + assert_eq!(entries.len(), 2); + assert!(entries.iter().all(|entry| entry.operation == "merge_abort")); + Ok(()) + } + + #[test] + fn recovery_execution_revalidates_state_and_audits_safely() -> Result<(), Box> { + let repo = merge_conflict_repo("recovery-revalidation")?; + let operation = OperationPlanner::new(&repo) + .plan_request(OperationRequest::Recover(RecoveryRequest::MergeAbort)) + .map_err(std::io::Error::other)?; + std::fs::write(repo.join("conflict.txt"), "changed after preview\n")?; + let paths = isolated_store_paths("recovery-revalidation-audit")?; + + let execution = PlanExecutor::with_audit_paths(&repo, paths.clone()) + .execute(&operation.plan, operation.context); + + assert!(!execution.succeeded()); + assert!(execution.message().contains("state changed after preview")); + assert_eq!( + Git::new(&repo).status()?.operation, + Some(RepositoryOperation::Merge) + ); + let entries = LocalStore::open(paths)?.list_audit_entries()?; + assert_eq!(entries.len(), 2); + assert!(entries.iter().all(|entry| entry.operation == "merge_abort")); + assert_eq!(entries[1].result, "error"); + assert!( + !entries[1] + .message + .contains(&repo.to_string_lossy().to_string()) + ); + Ok(()) + } + + #[test] + fn confirmed_recovery_succeeds_and_records_stable_audit_entries() -> Result<(), Box> + { + let repo = merge_conflict_repo("recovery-audit")?; + let operation = OperationPlanner::new(&repo) + .plan_request(OperationRequest::Recover(RecoveryRequest::MergeAbort)) + .map_err(std::io::Error::other)?; + let paths = isolated_store_paths("recovery-audit")?; + let execution = PlanExecutor::with_audit_paths(&repo, paths.clone()) + .execute(&operation.plan, operation.context); + + assert!(execution.succeeded(), "{}", execution.message()); + assert_eq!(Git::new(&repo).status()?.operation, None); + let entries = LocalStore::open(paths)?.list_audit_entries()?; + assert_eq!(entries.len(), 2); + assert!(entries.iter().all(|entry| entry.operation == "merge_abort")); + assert_eq!(entries[0].message, "pending"); + assert_eq!(entries[1].result, "ok"); + Ok(()) + } + + #[test] + fn confirmed_recovery_executes_all_supported_requests() -> Result<(), Box> { + let merge_continue = merge_conflict_repo("recovery-merge-continue")?; + std::fs::write(merge_continue.join("conflict.txt"), "resolved\n")?; + git_stdout(&merge_continue, &["add", "conflict.txt"])?; + let merge_abort = merge_conflict_repo("recovery-merge-abort")?; + + let rebase_continue = rebase_conflict_repo("recovery-rebase-continue")?; + std::fs::write(rebase_continue.join("conflict.txt"), "resolved\n")?; + git_stdout(&rebase_continue, &["add", "conflict.txt"])?; + let rebase_abort = rebase_conflict_repo("recovery-rebase-abort")?; + let rebase_skip = rebase_conflict_repo("recovery-rebase-skip")?; + + for (repo, request) in [ + (merge_continue, RecoveryRequest::MergeContinue), + (merge_abort, RecoveryRequest::MergeAbort), + (rebase_continue, RecoveryRequest::RebaseContinue), + (rebase_abort, RecoveryRequest::RebaseAbort), + (rebase_skip, RecoveryRequest::RebaseSkip), + ] { + let operation = OperationPlanner::new(&repo) + .plan_request(OperationRequest::Recover(request)) + .map_err(std::io::Error::other)?; + let paths = isolated_store_paths(&format!("recovery-{request:?}"))?; + let execution = PlanExecutor::with_audit_paths(&repo, paths) + .execute(&operation.plan, operation.context); + assert!( + execution.succeeded(), + "{request:?}: {}", + execution.message() + ); + assert_eq!(Git::new(repo).status()?.operation, None); + } + Ok(()) + } + #[test] fn open_pull_request_plan_previews_target_and_creates_pull_request() -> Result<(), Box> { @@ -10234,6 +10976,65 @@ mod tests { Ok(root) } + fn merge_conflict_repo(name: &str) -> Result> { + let repo = isolated_git_repo(name)?; + configure_git_identity(&repo)?; + std::fs::write(repo.join("conflict.txt"), "base\n")?; + git_stdout(&repo, &["add", "conflict.txt"])?; + git_stdout(&repo, &["commit", "-m", "base"])?; + let base = git_stdout(&repo, &["branch", "--show-current"])?; + git_stdout(&repo, &["checkout", "-b", "conflicting"])?; + std::fs::write(repo.join("conflict.txt"), "other\n")?; + git_stdout(&repo, &["commit", "-am", "other"])?; + git_stdout(&repo, &["checkout", base.trim()])?; + std::fs::write(repo.join("conflict.txt"), "current\n")?; + git_stdout(&repo, &["commit", "-am", "current"])?; + + let output = std::process::Command::new("git") + .arg("-C") + .arg(&repo) + .args(["merge", "conflicting"]) + .output()?; + if output.status.success() { + return Err(std::io::Error::other("expected merge conflict").into()); + } + assert_eq!( + Git::new(&repo).status()?.operation, + Some(RepositoryOperation::Merge) + ); + Ok(repo) + } + + fn rebase_conflict_repo(name: &str) -> Result> { + let repo = isolated_git_repo(name)?; + configure_git_identity(&repo)?; + std::fs::write(repo.join("conflict.txt"), "base\n")?; + git_stdout(&repo, &["add", "conflict.txt"])?; + git_stdout(&repo, &["commit", "-m", "base"])?; + let base = git_stdout(&repo, &["branch", "--show-current"])?; + git_stdout(&repo, &["checkout", "-b", "topic"])?; + std::fs::write(repo.join("conflict.txt"), "topic\n")?; + git_stdout(&repo, &["commit", "-am", "topic"])?; + git_stdout(&repo, &["checkout", base.trim()])?; + std::fs::write(repo.join("conflict.txt"), "upstream\n")?; + git_stdout(&repo, &["commit", "-am", "upstream"])?; + git_stdout(&repo, &["checkout", "topic"])?; + + let output = std::process::Command::new("git") + .arg("-C") + .arg(&repo) + .args(["rebase", base.trim()]) + .output()?; + if output.status.success() { + return Err(std::io::Error::other("expected rebase conflict").into()); + } + assert_eq!( + Git::new(&repo).status()?.operation, + Some(RepositoryOperation::Rebase) + ); + Ok(repo) + } + fn isolated_bare_git_repo(name: &str) -> Result> { let root = isolated_temp_root(name)?; let output = std::process::Command::new("git") diff --git a/docs/guardrails.md b/docs/guardrails.md index 87fdaca..b840e01 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -123,6 +123,29 @@ Conflict mode should: - audit conflict recovery actions Conflict recovery actions are high risk because they move repository state. +BitByGit fingerprints bounded tracked and untracked data plus the complete +ignored path set, control, +configuration, attribute, hook, and ref inputs at preview. It takes an +identity-checked lock in app-owned data storage keyed by the canonical repository +root, checks that fingerprint as the last operation before spawning Git, and +holds the lock through execution. Replacing a repository's Git directory does +not change this lock identity. A recovery action in a prompt sequence must be +first so this state is captured by the displayed sequence preview. + +The lock serializes BitByGit recovery processes for the repository. Other Git +clients and filesystem writers do not honor an application lock; their normal +concurrency boundary still applies, and BitByGit surfaces resulting Git failures +rather than claiming to exclude those writers. + +Recovery requires Git 2.42 or newer for exact system and global attribute-path +discovery. It rejects executable hooks, active external filters or merge drivers, +fsmonitor and signing processes, non-built-in persisted rebase strategies, and +remaining rebase `exec` commands. This prevents those configured processes from +detaching outside bounded cleanup. Planning also fails closed when its path, +entry, untracked-data, or diagnostic output limits are exceeded. Linux uses +sealed capture files, macOS uses bounded temporary captures, Unix places Git in +a process group, and Windows uses bounded temporary captures plus a kill-on-close +Job Object. ## Confirmation Copy diff --git a/docs/installation.md b/docs/installation.md index 72b5315..ba87a01 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -8,6 +8,8 @@ instructions once the version you want appears on the ## Runtime prerequisites - `git` must be installed and available on `PATH`. + Guarded conflict recovery requires Git 2.42 or newer; other operations remain + available with older Git versions. - [GitHub CLI (`gh`)](https://cli.github.com/) is optional. It is required only for GitHub-specific operations such as opening a pull request; run `gh auth login` before using those operations. diff --git a/scripts/setup-dev.sh b/scripts/setup-dev.sh index 8697e52..a477add 100755 --- a/scripts/setup-dev.sh +++ b/scripts/setup-dev.sh @@ -1,6 +1,7 @@ #!/usr/bin/env sh set -eu +bash scripts/test-ci-workflow.sh bash scripts/test-release-workflow.sh bash scripts/test-installation-docs.sh cargo fmt --all --check diff --git a/scripts/test-ci-workflow.sh b/scripts/test-ci-workflow.sh new file mode 100755 index 0000000..362b276 --- /dev/null +++ b/scripts/test-ci-workflow.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +workflow="${root}/.github/workflows/ci.yml" +test_source="$(<"${root}/crates/bitbygit-git/src/lib.rs")" +recovery_job="$(perl -0777 -ne ' + print $1 if /\n recovery-platforms:\n(.*?)(?=\n [a-z][a-z0-9-]*:\n|\z)/s +' "${workflow}")" + +expected='cargo test --locked -p bitbygit-git recovery_platform_' +if [[ -z "${recovery_job}" || "${recovery_job}" != *"${expected}"* ]]; then + printf 'CI workflow does not run end-to-end recovery on supported platforms\n' >&2 + exit 1 +fi +if [[ "${recovery_job}" != *'macos-latest'* || "${recovery_job}" != *'windows-latest'* ]]; then + printf 'CI workflow does not cover macOS and Windows recovery\n' >&2 + exit 1 +fi +for test_name in \ + recovery_platform_command_output_is_bounded \ + recovery_platform_end_to_end_deadline_does_not_interrupt_merge_abort \ + recovery_platform_end_to_end_exact_merge_and_rebase \ + recovery_platform_execution_cleans_descendants_after_parent_exit \ + recovery_platform_execution_cleans_windows_job_descendants_after_parent_exit; do + if [[ "${test_source}" != *"fn ${test_name}"* ]]; then + printf 'CI recovery selector is missing test %s\n' "${test_name}" >&2 + exit 1 + fi +done + +printf 'CI workflow validation passed\n' diff --git a/scripts/test-installation-docs.ps1 b/scripts/test-installation-docs.ps1 index 7f576b0..ec3f711 100644 --- a/scripts/test-installation-docs.ps1 +++ b/scripts/test-installation-docs.ps1 @@ -56,6 +56,9 @@ if ($SelectedVersion -eq $WorkspaceVersion -or $SelectedVersion -eq "0.1.0") { if (-not $DocsText.Contains("`$Version = `"$WorkspaceVersion`"")) { Fail "PowerShell examples do not use workspace version $WorkspaceVersion" } +if (-not $DocsText.Contains("Guarded conflict recovery requires Git 2.42 or newer")) { + Fail "runtime prerequisites do not document the guarded recovery Git version" +} $UserPathOrder = '$NewUserPath = (@($InstallDir) + $UserPathEntries) -join ";"' if ([regex]::Matches($DocsText, [regex]::Escape($UserPathOrder)).Count -ne 2) { Fail "Windows examples do not prioritize the install directory in user PATH" diff --git a/scripts/test-installation-docs.sh b/scripts/test-installation-docs.sh index ab47f2d..268e0bf 100755 --- a/scripts/test-installation-docs.sh +++ b/scripts/test-installation-docs.sh @@ -339,6 +339,7 @@ extract_selected_block "${failure_heading}" bash > "${cleanup_failure_dir}/snipp [[ "${docs_text}" == *'git -C "${SOURCE_DIR}" checkout --detach "${tag_commit}"'* ]] || fail "Bash source build does not detach at the selected tag" [[ "${docs_text}" == *'git -C $SourceDir fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${Tag}:${Tag}"'* ]] || fail "PowerShell source build does not fetch the exact selected tag" [[ "${docs_text}" == *'git -C $SourceDir checkout --detach $TagCommit'* ]] || fail "PowerShell source build does not detach at the selected tag" +[[ "${docs_text}" == *'Guarded conflict recovery requires Git 2.42 or newer'* ]] || fail "runtime prerequisites do not document the guarded recovery Git version" [[ "${docs_text}" == *'"${HOME}/.local/bin/bitbygit" --version'* ]] || fail "Unix installation does not validate the installed path" [[ "${docs_text}" == *'& $InstalledBinary --version'* ]] || fail "Windows installation does not validate the installed path" [[ "$(grep -c 'resolved_binary="$(type -P bitbygit || true)"' "${docs}")" -eq 3 ]] || fail "Unix installations do not bypass aliases and functions during PATH resolution"