use std::{
    backtrace::Backtrace,
    collections::HashSet,
    env::{self, VarError},
    fs,
    path::PathBuf,
    process::Command,
};

use serde::Deserialize;
use tracing::warn;
use turbopath::{
    AbsoluteSystemPath, AbsoluteSystemPathBuf, AnchoredSystemPathBuf, RelativeUnixPath,
};
use turborepo_ci::Vendor;

use crate::{Error, GitRepo, SCM};

#[derive(Debug, PartialEq, Eq)]
pub struct InvalidRange {
    pub from_ref: Option<String>,
    pub to_ref: Option<String>,
}

impl SCM {
    pub fn get_current_branch(&self, path: &AbsoluteSystemPath) -> Result<String, Error> {
        match self {
            Self::Git(git) => git.get_current_branch(),
            Self::Manual => Err(Error::GitRequired(path.to_owned())),
        }
    }

    pub fn get_current_sha(&self, path: &AbsoluteSystemPath) -> Result<String, Error> {
        match self {
            Self::Git(git) => git.get_current_sha(),
            Self::Manual => Err(Error::GitRequired(path.to_owned())),
        }
    }

    pub fn get_current_branch_and_sha(
        &self,
        _path: &AbsoluteSystemPath,
    ) -> (Option<String>, Option<String>) {
        match self {
            Self::Git(git) => (git.get_current_branch().ok(), git.get_current_sha().ok()),
            Self::Manual => (None, None),
        }
    }

    /// Compute a hash that summarizes all uncommitted changes in the working
    /// tree: staged changes, unstaged changes, and untracked files.
    /// Returns `None` for manual SCM mode, when the working tree is clean,
    /// or when a git command fails (errors are logged as warnings).
    pub fn get_dirty_hash(&self) -> Option<String> {
        match self {
            Self::Git(git) => git.get_dirty_hash(),
            Self::Manual => None,
        }
    }

    /// get the actual changed files between two git refs
    pub fn changed_files(
        &self,
        turbo_root: &AbsoluteSystemPath,
        from_commit: Option<&str>,
        to_commit: Option<&str>,
        include_uncommitted: bool,
        allow_unknown_objects: bool,
        merge_base: bool,
    ) -> Result<Result<HashSet<AnchoredSystemPathBuf>, InvalidRange>, Error> {
        fn unable_to_detect_range(
            error: impl std::error::Error,
            from_ref: Option<String>,
            to_ref: Option<String>,
        ) -> Result<Result<HashSet<AnchoredSystemPathBuf>, InvalidRange>, Error> {
            warn!(
                "unable to detect git range, assuming all files have changed: {}",
                error
            );
            Ok(Err(InvalidRange { from_ref, to_ref }))
        }
        match self {
            Self::Git(git) => {
                match git.changed_files(
                    turbo_root,
                    from_commit,
                    to_commit,
                    include_uncommitted,
                    merge_base,
                ) {
                    Ok(files) => Ok(Ok(files)),
                    Err(ref error @ Error::Git(ref message, _))
                        if allow_unknown_objects
                            && (message.contains("no merge base")
                                || message.contains("bad object")) =>
                    {
                        unable_to_detect_range(
                            error,
                            from_commit.map(|c| c.to_string()),
                            to_commit.map(|c| c.to_string()),
                        )
                    }
                    Err(Error::UnableToResolveRef) => unable_to_detect_range(
                        Error::UnableToResolveRef,
                        from_commit.map(|c| c.to_string()),
                        to_commit.map(|c| c.to_string()),
                    ),
                    Err(e) => Err(e),
                }
            }
            Self::Manual => Err(Error::GitRequired(turbo_root.to_owned())),
        }
    }

    pub fn previous_content(
        &self,
        from_commit: Option<&str>,
        file_path: &AbsoluteSystemPath,
    ) -> Result<Vec<u8>, Error> {
        match self {
            Self::Git(git) => git.previous_content(from_commit, file_path),
            Self::Manual => Err(Error::GitRequired(file_path.to_owned())),
        }
    }
}

const UNKNOWN_SHA: &str = "0000000000000000000000000000000000000000";

#[derive(Debug, Deserialize, Clone)]
struct GitHubCommit {
    id: String,
}

#[derive(Debug, Deserialize, Default)]
struct GitHubEvent {
    #[serde(default)]
    before: String,

    #[serde(default)]
    commits: Vec<GitHubCommit>,

    #[serde(default)]
    forced: bool,
}

impl GitHubEvent {
    fn get_parent_ref_of_first_commit(&self) -> Option<String> {
        if self.commits.is_empty() {
            // commits can be empty when you push a branch with no commits
            return None;
        }

        if self.commits.len() >= 2048 {
            // GitHub API limit for number of commits shown in this field
            return None;
        }

        // Extract the base ref from the push event
        let first_commit = self.commits.first()?;
        let id = &first_commit.id;
        Some(format!("{id}^"))
    }
}

#[derive(Debug)]
pub struct CIEnv {
    is_github_actions: bool,
    github_base_ref: Result<String, VarError>,
    github_event_path: Result<String, VarError>,
}

impl Default for CIEnv {
    fn default() -> Self {
        Self::new()
    }
}

impl CIEnv {
    pub fn new() -> Self {
        Self {
            is_github_actions: Vendor::is("GitHub Actions"),
            github_base_ref: env::var("GITHUB_BASE_REF"),
            github_event_path: env::var("GITHUB_EVENT_PATH"),
        }
    }
    pub fn none() -> Self {
        Self {
            is_github_actions: false,
            github_base_ref: Err(VarError::NotPresent),
            github_event_path: Err(VarError::NotPresent),
        }
    }
}

impl GitRepo {
    fn get_current_branch(&self) -> Result<String, Error> {
        let output = self.execute_git_command(&["branch", "--show-current"], "")?;
        let output = String::from_utf8(output)?;
        Ok(output.trim().to_owned())
    }

    fn get_current_sha(&self) -> Result<String, Error> {
        let output = self.execute_git_command(&["rev-parse", "HEAD"], "")?;
        let output = String::from_utf8(output)?;
        Ok(output.trim().to_owned())
    }

    /// Compute a hash summarizing all uncommitted state in the working tree.
    /// Uses `git status --porcelain -z` (which files are dirty/untracked) and
    /// `git diff HEAD` (the actual content changes for tracked files) as inputs
    /// to a SHA-256 hash. Returns `None` if the working tree is clean or if
    /// git commands fail (with a warning logged).
    ///
    /// The diff output is streamed through the hasher to avoid buffering
    /// arbitrarily large diffs into memory. `--no-ext-diff` and `--no-binary`
    /// ensure deterministic, bounded output regardless of user git config.
    ///
    /// Note: content of untracked files (not yet `git add`ed) is not included
    /// in the diff — only their filenames from `git status` contribute.
    fn get_dirty_hash(&self) -> Option<String> {
        use sha2::{Digest, Sha256};

        let status_output = match self.execute_git_command(&["status", "--porcelain", "-z"], "") {
            Ok(output) => output,
            Err(e) => {
                turborepo_log::warn(
                    turborepo_log::Source::turbo(turborepo_log::Subsystem::Scm),
                    format!("failed to get git status for dirty hash: {e}"),
                )
                .emit();
                return None;
            }
        };

        if status_output.is_empty() {
            return None;
        }

        let mut hasher = Sha256::new();
        hasher.update(&status_output);

        // Try `git diff HEAD` first. In a freshly initialized repo with no
        // commits, HEAD doesn't exist and git exits with code 128. Fall back
        // to `git diff --cached` which diffs the index against an empty tree,
        // correctly capturing staged file content without needing HEAD.
        if !self.stream_diff_into_hasher(
            &["diff", "HEAD", "--no-ext-diff", "--no-color"],
            &mut hasher,
        ) && !self.stream_diff_into_hasher(
            &["diff", "--cached", "--no-ext-diff", "--no-color"],
            &mut hasher,
        ) {
            turborepo_log::warn(
                turborepo_log::Source::turbo(turborepo_log::Subsystem::Scm),
                "failed to run git diff for dirty hash",
            )
            .emit();
        }

        Some(hex::encode(hasher.finalize()))
    }

    /// Spawn a git diff subprocess, streaming its stdout into `hasher`.
    /// Returns `true` if the command exited successfully.
    fn stream_diff_into_hasher(&self, args: &[&str], hasher: &mut sha2::Sha256) -> bool {
        use std::{io::Read, process::Stdio};

        use sha2::Digest;

        let mut child = match Command::new(self.bin.as_std_path())
            .args(args)
            .current_dir(&self.root)
            .env("GIT_OPTIONAL_LOCKS", "0")
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()
        {
            Ok(child) => child,
            Err(_) => return false,
        };

        if let Some(stdout) = child.stdout.take() {
            let mut reader = std::io::BufReader::new(stdout);
            let mut buf = [0u8; 65536];
            loop {
                match reader.read(&mut buf) {
                    Ok(0) => break,
                    Ok(n) => hasher.update(&buf[..n]),
                    Err(_) => break,
                }
            }
        }

        child.wait().is_ok_and(|s| s.success())
    }

    /// for GitHub Actions environment variables, see: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables
    pub fn get_github_base_ref(base_ref_env: CIEnv) -> Option<String> {
        // make sure we're running in a CI environment
        if !base_ref_env.is_github_actions {
            return None;
        }

        /*
         * The name of the base ref or target branch of the pull request in a
         * workflow run.
         *
         * This variable only has a value when the event that triggers a workflow run
         * is either `pull_request` or `pull_request_target`.
         * For example, `main`
         *
         * So environment variable is empty in a regular commit
         */
        if let Ok(pr) = base_ref_env.github_base_ref
            && !pr.is_empty()
        {
            return Some(pr);
        }

        // we must be in a push event
        // try reading from the GITHUB_EVENT_PATH file
        if let Ok(event_path) = base_ref_env.github_event_path {
            // Try to open the event file and read the contents
            let data = fs::read_to_string(event_path).ok()?;

            // Parse the JSON data from the file
            let json: GitHubEvent = serde_json::from_str(&data).ok()?;

            // Extract the base ref from the pull request event if available
            let base_ref = &json.before;

            // the base_ref will be UNKNOWN_SHA on first push
            // we also use this behavior in force pushes
            if base_ref == UNKNOWN_SHA || json.forced {
                return json.get_parent_ref_of_first_commit();
            }

            if base_ref.is_empty() {
                return None;
            }

            return Some(base_ref.to_string());
        }
        None
    }

    fn resolve_base(&self, base_override: Option<&str>, env: CIEnv) -> Result<String, Error> {
        if let Some(valid_from) = base_override {
            return Ok(valid_from.to_string());
        }

        if let Some(github_base_ref) = Self::get_github_base_ref(env) {
            // we don't fall through to checking against main or master
            // because at this point we know we're in a GITHUB CI environment
            // and we should really know by now what the base ref is
            // so it's better to just error if something went wrong
            return match self.execute_git_command(&["rev-parse", &github_base_ref], "") {
                Ok(_) => {
                    eprintln!("Resolved base ref from GitHub Actions event: {github_base_ref}");
                    Ok(github_base_ref)
                }
                Err(e) => {
                    eprintln!(
                        "Failed to resolve base ref '{github_base_ref}' from GitHub Actions \
                         event: {e}"
                    );
                    Err(Error::UnableToResolveRef)
                }
            };
        }

        let main_result = self.execute_git_command(&["rev-parse", "main"], "");
        if main_result.is_ok() {
            return Ok("main".to_string());
        }

        let master_result = self.execute_git_command(&["rev-parse", "master"], "");
        if master_result.is_ok() {
            return Ok("master".to_string());
        }
        Err(Error::UnableToResolveRef)
    }

    fn changed_files(
        &self,
        turbo_root: &AbsoluteSystemPath,
        from_commit: Option<&str>,
        to_commit: Option<&str>,
        include_uncommitted: bool,
        merge_base: bool,
    ) -> Result<HashSet<AnchoredSystemPathBuf>, Error> {
        let turbo_root_relative_to_git_root = self.root.anchor(turbo_root)?;
        let pathspec = turbo_root_relative_to_git_root.as_str();

        let mut files = HashSet::new();

        let valid_from = self.resolve_base(from_commit, CIEnv::new())?;

        // If a to commit is not specified for `diff-tree` it will change the comparison
        // to be between the provided commit and it's parent
        let to_commit = to_commit.unwrap_or("HEAD");
        let mut args = vec![
            "diff-tree",
            "-r",
            "--name-only",
            "--no-commit-id",
            "-z",
            &valid_from,
            to_commit,
        ];

        if merge_base {
            args.push("--merge-base");
        }

        let output = self.execute_git_command(&args, pathspec)?;
        self.add_files_from_stdout(&mut files, turbo_root, output)?;

        // We only care about non-tracked files if we haven't specified both ends up the
        // comparison
        if include_uncommitted {
            // Add untracked files or unstaged changes, i.e. files that are not in git at
            // all
            let ls_files_output = self.execute_git_command(
                &[
                    "ls-files",
                    "--others",
                    "--modified",
                    "--exclude-standard",
                    "-z",
                ],
                pathspec,
            )?;
            self.add_files_from_stdout(&mut files, turbo_root, ls_files_output)?;
            // Include any files that have been staged, but not committed
            let diff_output =
                self.execute_git_command(&["diff", "--name-only", "--cached", "-z"], pathspec)?;
            self.add_files_from_stdout(&mut files, turbo_root, diff_output)?;
        }

        Ok(files)
    }

    pub fn execute_git_command(&self, args: &[&str], pathspec: &str) -> Result<Vec<u8>, Error> {
        let mut command = Command::new(self.bin.as_std_path());
        command
            .args(args)
            .current_dir(&self.root)
            .env("GIT_OPTIONAL_LOCKS", "0");

        if !pathspec.is_empty() {
            command.arg("--").arg(pathspec);
        }

        let output = command.output()?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
            Err(Error::Git(stderr, Backtrace::capture()))
        } else {
            Ok(output.stdout)
        }
    }

    fn add_files_from_stdout(
        &self,
        files: &mut HashSet<AnchoredSystemPathBuf>,
        turbo_root: &AbsoluteSystemPath,
        stdout: Vec<u8>,
    ) -> Result<(), Error> {
        let stdout = String::from_utf8_lossy(&stdout);
        for line in stdout.split('\0') {
            if line.is_empty() {
                continue;
            }
            let path = RelativeUnixPath::new(line)?;
            let anchored_to_turbo_root_file_path =
                self.reanchor_path_from_git_root_to_turbo_root(turbo_root, path)?;
            files.insert(anchored_to_turbo_root_file_path);
        }
        Ok(())
    }

    fn reanchor_path_from_git_root_to_turbo_root(
        &self,
        turbo_root: &AbsoluteSystemPath,
        path: &RelativeUnixPath,
    ) -> Result<AnchoredSystemPathBuf, Error> {
        let absolute_file_path = self.root.join_unix_path(path);
        let anchored_to_turbo_root_file_path = turbo_root.anchor(&absolute_file_path)?;
        Ok(anchored_to_turbo_root_file_path)
    }

    fn previous_content(
        &self,
        from_commit: Option<&str>,
        file_path: &AbsoluteSystemPath,
    ) -> Result<Vec<u8>, Error> {
        let anchored_file_path = self.root.anchor(file_path)?;
        let valid_from = self.resolve_base(from_commit, CIEnv::new())?;
        let arg = format!("{}:{}", valid_from, anchored_file_path.as_str());

        self.execute_git_command(&["show", &arg], "")
    }
}

/// Finds the content of a file at a previous commit. Assumes file is in a git
/// repository
///
/// # Arguments
///
/// * `git_root`: The root of the repository
/// * `from_commit`: The commit hash to checkout
/// * `file_path`: The path to the file
///
/// returns: Result<String, Error>
pub fn previous_content(
    git_root: PathBuf,
    from_commit: Option<&str>,
    file_path: String,
) -> Result<Vec<u8>, Error> {
    // If git root is not absolute, we error.
    let git_root = AbsoluteSystemPathBuf::try_from(git_root)?;
    let scm = SCM::new(&git_root);

    // However for file path we handle both absolute and relative paths
    // Note that we assume any relative file path is relative to the git root
    // FIXME: this is probably wrong. We should know the path to the lockfile
    // exactly
    let absolute_file_path = AbsoluteSystemPathBuf::from_unknown(&git_root, file_path);

    scm.previous_content(from_commit, &absolute_file_path)
}

#[cfg(test)]
mod tests {
    use std::{
        assert_matches,
        collections::HashSet,
        env::VarError,
        fs,
        path::{Path, PathBuf},
        process::Command,
    };

    use tempfile::{NamedTempFile, TempDir};
    use test_case::test_case;
    use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf, PathError};
    use which::which;

    use super::{CIEnv, InvalidRange, previous_content};
    use crate::{
        Error, GitRepo, SCM,
        git::{GitHubCommit, GitHubEvent},
    };

    fn run_git(repo_root: &Path, args: &[&str]) -> String {
        let output = Command::new("git")
            .args(args)
            .current_dir(repo_root)
            .env("GIT_AUTHOR_NAME", "test")
            .env("GIT_AUTHOR_EMAIL", "test@test.com")
            .env("GIT_COMMITTER_NAME", "test")
            .env("GIT_COMMITTER_EMAIL", "test@test.com")
            .output()
            .unwrap();
        assert!(
            output.status.success(),
            "git {:?} failed: {}",
            args,
            String::from_utf8_lossy(&output.stderr)
        );
        String::from_utf8(output.stdout).unwrap().trim().to_string()
    }

    fn setup_repository(initial_head: Option<&str>) -> Result<(TempDir, PathBuf), Error> {
        let repo_root = tempfile::tempdir()?;
        run_git(repo_root.path(), &["init"]);
        if let Some(branch) = initial_head {
            run_git(
                repo_root.path(),
                &["symbolic-ref", "HEAD", &format!("refs/heads/{}", branch)],
            );
        }
        run_git(repo_root.path(), &["config", "user.name", "test"]);
        run_git(
            repo_root.path(),
            &["config", "user.email", "test@example.com"],
        );

        let path = repo_root.path().to_path_buf();
        Ok((repo_root, path))
    }

    fn changed_files(
        git_root: PathBuf,
        turbo_root: PathBuf,
        from_commit: Option<&str>,
        to_commit: Option<&str>,
        include_uncommitted: bool,
    ) -> Result<HashSet<String>, Error> {
        let git_root = AbsoluteSystemPath::from_std_path(&git_root)?;
        let scm = SCM::new(git_root);

        let turbo_root = AbsoluteSystemPathBuf::try_from(turbo_root.as_path())?;
        // Replicating the `--filter` behavior where we only do a merge base
        // if both ends of the git range are specified.
        let merge_base = to_commit.is_some();
        let Ok(files) = scm.changed_files(
            &turbo_root,
            from_commit,
            to_commit,
            include_uncommitted,
            false,
            merge_base,
        )?
        else {
            unreachable!("changed_files should always return Some");
        };

        Ok(files
            .into_iter()
            .map(|f| f.to_string())
            .collect::<HashSet<_>>())
    }

    fn commit_file(repo_root: &Path, path: &Path, _previous_commit: Option<&str>) -> String {
        run_git(repo_root, &["add", &path.to_string_lossy()]);
        run_git(repo_root, &["commit", "-m", "Commit"]);
        run_git(repo_root, &["rev-parse", "HEAD"])
    }

    fn commit_delete(repo_root: &Path, path: &Path) -> String {
        run_git(repo_root, &["rm", &path.to_string_lossy()]);
        run_git(repo_root, &["commit", "-m", "Commit"]);
        run_git(repo_root, &["rev-parse", "HEAD"])
    }

    fn commit_rename(repo_root: &Path, source: &Path, dest: &Path) -> String {
        run_git(repo_root, &["rm", &source.to_string_lossy()]);
        run_git(repo_root, &["add", &dest.to_string_lossy()]);
        run_git(repo_root, &["commit", "-m", "Commit"]);
        run_git(repo_root, &["rev-parse", "HEAD"])
    }

    #[test]
    fn test_shallow_clone() -> Result<(), Error> {
        let tmp_dir = tempfile::tempdir()?;

        let git_binary = which("git")?;
        let output = Command::new(git_binary)
            .args([
                "clone",
                "--depth",
                "2",
                "https://github.com/vercel/app-playground.git",
                tmp_dir.path().to_str().unwrap(),
            ])
            .output()?;
        assert!(output.status.success());

        assert!(
            changed_files(
                tmp_dir.path().to_owned(),
                tmp_dir.path().to_owned(),
                Some("HEAD~1"),
                Some("HEAD"),
                false,
            )
            .is_ok()
        );

        assert!(
            changed_files(
                tmp_dir.path().to_owned(),
                tmp_dir.path().to_owned(),
                Some("HEAD"),
                None,
                true,
            )
            .is_ok()
        );

        Ok(())
    }

    #[test]
    fn test_deleted_files() -> Result<(), Error> {
        let (repo_root, repo_path) = setup_repository(None)?;

        let file = repo_root.path().join("foo.js");
        let file_path = Path::new("foo.js");
        fs::write(&file, "let z = 0;")?;

        let first_commit_sha = commit_file(&repo_path, file_path, None);

        fs::remove_file(&file)?;
        let _second_commit_sha = commit_delete(&repo_path, file_path);

        let git_root = repo_root.path().to_owned();
        let turborepo_root = repo_root.path().to_owned();
        let files = changed_files(
            git_root,
            turborepo_root,
            Some(&first_commit_sha),
            Some("HEAD"),
            false,
        )?;

        assert_eq!(files, HashSet::from(["foo.js".to_string()]));
        Ok(())
    }

    #[test]
    fn test_renamed_files() -> Result<(), Error> {
        let (repo_root, repo_path) = setup_repository(None)?;

        let file = repo_root.path().join("foo.js");
        let file_path = Path::new("foo.js");
        fs::write(&file, "let z = 0;")?;

        let first_commit_sha = commit_file(&repo_path, file_path, None);

        fs::rename(file, repo_root.path().join("bar.js")).unwrap();

        let new_file_path = Path::new("bar.js");
        let _second_commit_sha = commit_rename(&repo_path, file_path, new_file_path);

        let git_root = repo_root.path().to_owned();
        let turborepo_root = repo_root.path().to_owned();
        let files = changed_files(
            git_root,
            turborepo_root,
            Some(&first_commit_sha),
            Some("HEAD"),
            false,
        )?;

        assert_eq!(
            files,
            HashSet::from(["foo.js".to_string(), "bar.js".to_string()])
        );
        Ok(())
    }
    #[test]
    fn test_merge_base() -> Result<(), Error> {
        let (repo_root, repo_path) = setup_repository(None)?;
        let first_file = repo_root.path().join("foo.js");
        fs::write(first_file, "let z = 0;")?;
        // Create a base commit. This will *not* be the merge base
        let first_commit_sha = commit_file(&repo_path, Path::new("foo.js"), None);

        let second_file = repo_root.path().join("bar.js");
        fs::write(second_file, "let y = 1;")?;
        // This commit will be the merge base
        let second_commit_sha =
            commit_file(&repo_path, Path::new("bar.js"), Some(&first_commit_sha));

        let third_file = repo_root.path().join("baz.js");
        fs::write(third_file, "let x = 2;")?;
        // Create a first commit off of merge base
        let third_commit_sha =
            commit_file(&repo_path, Path::new("baz.js"), Some(&second_commit_sha));

        // Move HEAD back to merge base without resetting the working tree.
        // `git reset --soft` moves HEAD but keeps the index and working tree intact,
        // matching the old git2 `set_head_detached` behavior.
        run_git(&repo_path, &["reset", "--soft", &second_commit_sha]);
        let fourth_file = repo_root.path().join("qux.js");
        fs::write(fourth_file, "let w = 3;")?;
        // Create a second commit off of merge base
        let fourth_commit_sha =
            commit_file(&repo_path, Path::new("qux.js"), Some(&second_commit_sha));

        run_git(&repo_path, &["checkout", "--detach", &third_commit_sha]);
        let merge_base = run_git(
            &repo_path,
            &["merge-base", &third_commit_sha, &fourth_commit_sha],
        );

        assert_eq!(merge_base, second_commit_sha);

        let files = changed_files(
            repo_root.path().to_path_buf(),
            repo_root.path().to_path_buf(),
            Some(&third_commit_sha),
            Some(&fourth_commit_sha),
            false,
        )?;

        assert_eq!(
            files,
            HashSet::from(["qux.js".to_string(), "baz.js".to_string()])
        );

        Ok(())
    }

    #[test]
    fn test_changed_files() -> Result<(), Error> {
        let (repo_root, repo_path) = setup_repository(None)?;
        let turbo_root = repo_root.path();
        let file = repo_root.path().join("foo.js");
        fs::write(file, "let z = 0;")?;

        // First commit (we need a base commit to compare against)
        let first_commit_sha = commit_file(&repo_path, Path::new("foo.js"), None);

        // Now change another file
        let new_file = repo_root.path().join("bar.js");
        fs::write(new_file, "let y = 1;")?;

        // Test that uncommitted file is marked as changed
        let files = changed_files(
            repo_root.path().to_path_buf(),
            turbo_root.to_path_buf(),
            Some("HEAD"),
            None,
            true,
        )?;
        assert_eq!(files, HashSet::from(["bar.js".to_string()]));

        // Test that uncommitted file in index is not marked as changed when not
        // checking uncommitted
        let files = changed_files(
            repo_root.path().to_path_buf(),
            turbo_root.to_path_buf(),
            Some("HEAD"),
            None,
            false,
        )?;
        assert_eq!(files, HashSet::new());

        // Test that uncommitted file in index is marked as changed when
        // checking uncommitted
        let files = changed_files(
            repo_root.path().to_path_buf(),
            turbo_root.to_path_buf(),
            Some("HEAD"),
            None,
            true,
        )?;
        assert_eq!(files, HashSet::from(["bar.js".to_string()]));

        // Add file to index
        run_git(&repo_path, &["add", "bar.js"]);

        // Test that uncommitted file in index is not marked as changed when not
        // checking uncommitted
        let files = changed_files(
            repo_root.path().to_path_buf(),
            turbo_root.to_path_buf(),
            Some("HEAD"),
            None,
            false,
        )?;
        assert_eq!(files, HashSet::new());

        // Test that uncommitted file in index is still marked as changed
        let files = changed_files(
            repo_root.path().to_path_buf(),
            turbo_root.to_path_buf(),
            Some("HEAD"),
            None,
            true,
        )?;
        assert_eq!(files, HashSet::from(["bar.js".to_string()]));

        // Now commit file
        let second_commit_sha =
            commit_file(&repo_path, Path::new("bar.js"), Some(&first_commit_sha));

        // Test that only second file is marked as changed when we check commit range
        let files = changed_files(
            repo_root.path().to_path_buf(),
            turbo_root.to_path_buf(),
            Some(first_commit_sha.as_str()),
            Some(second_commit_sha.as_str()),
            false,
        )?;
        assert_eq!(files, HashSet::from(["bar.js".to_string()]));

        // Create a file nested in subdir
        fs::create_dir_all(repo_root.path().join("subdir"))?;
        let new_file = repo_root.path().join("subdir").join("baz.js");
        fs::write(new_file, "let x = 2;")?;

        // The new directory and files are not yet committed, they shouldn't show up.
        let files = changed_files(
            repo_root.path().to_path_buf(),
            repo_root.path().to_path_buf(),
            Some(first_commit_sha.as_str()),
            Some(second_commit_sha.as_str()),
            false,
        )?;
        assert_eq!(files, HashSet::from(["bar.js".to_string()]));

        // Since we are only specifying the first commit, the new file should show up
        let files = changed_files(
            repo_root.path().to_path_buf(),
            repo_root.path().to_path_buf(),
            Some(second_commit_sha.as_str()),
            None,
            true,
        )?;
        assert_eq!(
            files,
            HashSet::from([format!("subdir{}baz.js", std::path::MAIN_SEPARATOR)])
        );

        // Commit the new file so it shows up in the changed files
        let third_commit_sha = commit_file(
            &repo_path,
            &Path::new("subdir").join("baz.js"),
            Some(&second_commit_sha),
        );

        // Test that `turbo_root` filters out files not in the specified directory
        let files = changed_files(
            repo_root.path().to_path_buf(),
            repo_root.path().join("subdir"),
            Some(first_commit_sha.as_str()),
            Some(third_commit_sha.as_str()),
            false,
        )?;
        assert_eq!(files, HashSet::from(["baz.js".to_string()]));

        Ok(())
    }

    #[test]
    fn test_changed_files_with_root_as_relative() -> Result<(), Error> {
        let (repo_root, repo_path) = setup_repository(None)?;
        let file = repo_root.path().join("foo.js");
        fs::write(file, "let z = 0;")?;

        // First commit (we need a base commit to compare against)
        commit_file(&repo_path, Path::new("foo.js"), None);

        // Now change another file
        let new_file = repo_root.path().join("bar.js");
        fs::write(new_file, "let y = 1;")?;

        // Test that uncommitted file is marked as changed with the parameters that Go
        // will pass
        let files = changed_files(
            repo_root.path().to_path_buf(),
            repo_root.path().to_path_buf(),
            Some("HEAD"),
            None,
            true,
        )?;
        assert_eq!(files, HashSet::from(["bar.js".to_string()]));

        Ok(())
    }

    // Tests that we can use a subdir as the turbo_root path
    // (occurs when the monorepo is nested inside a subdirectory of git repository)
    #[test]
    fn test_changed_files_with_subdir_as_turbo_root() -> Result<(), Error> {
        let (repo_root, repo_path) = setup_repository(None)?;

        fs::create_dir(repo_root.path().join("subdir"))?;
        // Create additional nested directory to test that we return a system path
        // and not a normalized unix path
        fs::create_dir(repo_root.path().join("subdir").join("src"))?;

        let file = repo_root.path().join("subdir").join("foo.js");
        fs::write(file, "let z = 0;")?;
        let first_commit_sha = commit_file(&repo_path, Path::new("subdir/foo.js"), None);

        let new_file = repo_root.path().join("subdir").join("src").join("bar.js");
        fs::write(new_file, "let y = 1;")?;

        let files = changed_files(
            repo_root.path().to_path_buf(),
            repo_root.path().join("subdir"),
            Some("HEAD"),
            None,
            true,
        )?;

        #[cfg(unix)]
        {
            assert_eq!(files, HashSet::from(["src/bar.js".to_string()]));
        }

        #[cfg(windows)]
        {
            assert_eq!(files, HashSet::from(["src\\bar.js".to_string()]));
        }

        commit_file(
            &repo_path,
            Path::new("subdir/src/bar.js"),
            Some(&first_commit_sha),
        );

        let head_sha = run_git(&repo_path, &["rev-parse", "HEAD"]);

        let files = changed_files(
            repo_root.path().to_path_buf(),
            repo_root.path().join("subdir"),
            Some(first_commit_sha.as_str()),
            Some(head_sha.as_str()),
            false,
        )?;

        #[cfg(unix)]
        {
            assert_eq!(files, HashSet::from(["src/bar.js".to_string()]));
        }

        #[cfg(windows)]
        {
            assert_eq!(files, HashSet::from(["src\\bar.js".to_string()]));
        }

        Ok(())
    }

    #[test]
    fn test_previous_content() -> Result<(), Error> {
        let (repo_root, repo_path) = setup_repository(None)?;

        let root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap();
        let file = root.join_component("foo.js");
        file.create_with_contents("let z = 0;")?;

        let first_commit_sha = commit_file(&repo_path, Path::new("foo.js"), None);
        fs::write(&file, "let z = 1;")?;
        let second_commit_sha =
            commit_file(&repo_path, Path::new("foo.js"), Some(&first_commit_sha));

        let content = previous_content(
            repo_root.path().to_path_buf(),
            Some(first_commit_sha.as_str()),
            file.to_string(),
        )?;

        assert_eq!(content, b"let z = 0;");

        let content = previous_content(
            repo_root.path().to_path_buf(),
            Some(second_commit_sha.as_str()),
            file.to_string(),
        )?;
        assert_eq!(content, b"let z = 1;");

        let content = previous_content(
            repo_root.path().to_path_buf(),
            Some(second_commit_sha.as_str()),
            "foo.js".to_string(),
        )?;
        assert_eq!(content, b"let z = 1;");

        Ok(())
    }

    #[test]
    fn test_revparse() -> Result<(), Error> {
        let (repo_root, repo_path) = setup_repository(None)?;
        let root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap();

        let file = root.join_component("foo.js");
        file.create_with_contents("let z = 0;")?;

        let first_commit_sha = commit_file(&repo_path, Path::new("foo.js"), None);
        fs::write(&file, "let z = 1;")?;
        let second_commit_sha =
            commit_file(&repo_path, Path::new("foo.js"), Some(&first_commit_sha));

        let revparsed_head = run_git(&repo_path, &["rev-parse", "HEAD"]);
        assert_eq!(revparsed_head, second_commit_sha);
        let revparsed_head_minus_1 = run_git(&repo_path, &["rev-parse", "HEAD~1"]);
        assert_eq!(revparsed_head_minus_1, first_commit_sha);

        let files = changed_files(
            repo_root.path().to_path_buf(),
            repo_root.path().to_path_buf(),
            Some("HEAD^"),
            Some("HEAD"),
            false,
        )?;
        assert_eq!(files, HashSet::from(["foo.js".to_string()]));

        let content = previous_content(
            repo_root.path().to_path_buf(),
            Some("HEAD^"),
            file.to_string(),
        )?;
        assert_eq!(content, b"let z = 0;");

        let new_file = repo_root.path().join("bar.js");
        fs::write(new_file, "let y = 0;")?;
        let third_commit_sha =
            commit_file(&repo_path, Path::new("bar.js"), Some(&second_commit_sha));
        run_git(&repo_path, &["branch", "release-1", &third_commit_sha]);

        let files = changed_files(
            repo_root.path().to_path_buf(),
            repo_root.path().to_path_buf(),
            Some("HEAD~1"),
            Some("release-1"),
            false,
        )?;
        assert_eq!(files, HashSet::from(["bar.js".to_string()]));

        Ok(())
    }

    #[test_case(vec!["main"],                      None,            Some("main"))]
    #[test_case(vec!["master"],                    None,            Some("master"))]
    #[test_case(vec!["ziltoid"],                   None,            None)]
    #[test_case(vec!["ziltoid", "main"],           Some("ziltoid"), Some("ziltoid"))]
    #[test_case(vec!["ziltoid", "main"],           Some("main"),    Some("main"))]
    #[test_case(vec!["ziltoid", "main"],           None,            Some("main"))]
    #[test_case(vec!["ziltoid", "master"],         Some("ziltoid"), Some("ziltoid"))]
    #[test_case(vec!["ziltoid", "master"],         Some("master"),  Some("master"))]
    #[test_case(vec!["ziltoid", "master"],         None,            Some("master"))]
    #[test_case(vec!["ziltoid", "master", "main"], Some("ziltoid"), Some("ziltoid"))]
    #[test_case(vec!["ziltoid", "master", "main"], Some("master"),  Some("master"))]
    #[test_case(vec!["ziltoid", "master", "main"], Some("main"),    Some("main"))]
    #[test_case(vec!["ziltoid", "master", "main"], None,            Some("main"))]
    fn test_base_resolution(
        branches_to_create: Vec<&str>,
        target_branch: Option<&str>,
        expected: Option<&str>,
    ) -> Result<(), Error> {
        let (first_branch, remaining_branches) = branches_to_create.split_first().unwrap();

        let (repo_root, repo_path) = setup_repository(Some(first_branch))?;
        let root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap();

        // WARNING:
        // if you do not make a commit, git will show you that you have no branches.
        let file = root.join_component("todo.txt");
        file.create_with_contents("1. make async Rust good")?;
        let first_commit_sha = commit_file(&repo_path, Path::new("todo.txt"), None);

        for branch in remaining_branches {
            run_git(&repo_path, &["branch", branch, &first_commit_sha]);
        }

        let thing = GitRepo::find(&root).unwrap();
        let actual = thing.resolve_base(target_branch, CIEnv::none()).ok();

        assert_eq!(actual.as_deref(), expected);

        Ok(())
    }

    #[test]
    fn test_error_cases() -> Result<(), Error> {
        let repo_dir = tempfile::tempdir()?;
        let repo_does_not_exist = changed_files(
            repo_dir.path().to_path_buf(),
            repo_dir.path().to_path_buf(),
            Some("HEAD"),
            None,
            true,
        );

        assert_matches!(repo_does_not_exist, Err(Error::GitRequired(_)));

        let (repo_root, _repo_path) = setup_repository(None)?;
        let root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap();

        let commit_does_not_exist = changed_files(
            repo_root.path().to_path_buf(),
            repo_root.path().to_path_buf(),
            Some("does-not-exist"),
            None,
            true,
        );

        assert_matches!(commit_does_not_exist, Err(Error::Git(_, _)));

        let file_does_not_exist = previous_content(
            repo_root.path().to_path_buf(),
            Some("HEAD"),
            root.join_component("does-not-exist").to_string(),
        );
        assert_matches!(file_does_not_exist, Err(Error::Git(_, _)));

        let turbo_root = tempfile::tempdir()?;
        let turbo_root_is_not_subdir_of_git_root = changed_files(
            repo_root.path().to_path_buf(),
            turbo_root.path().to_path_buf(),
            Some("HEAD"),
            None,
            true,
        );

        assert_matches!(
            turbo_root_is_not_subdir_of_git_root,
            Err(Error::Path(PathError::NotParent(_, _), _))
        );

        Ok(())
    }

    #[test]
    fn test_changed_files_no_base() -> Result<(), Error> {
        let (repo_root, repo_path) = setup_repository(Some("my-main"))?;
        let root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap();

        // WARNING:
        // if you do not make a commit, git will show you that you have no branches.
        let file = root.join_component("todo.txt");
        file.create_with_contents("1. explain why async Rust is good")?;
        let _first_commit = commit_file(&repo_path, Path::new("todo.txt"), None);

        let scm = SCM::new(&root);
        let actual = scm
            .changed_files(&root, None, Some("HEAD"), true, true, false)
            .unwrap();

        assert_eq!(
            actual,
            Err(InvalidRange {
                from_ref: None,
                to_ref: Some("HEAD".to_string()),
            })
        );

        Ok(())
    }

    #[test]
    fn test_unicode_filenames_in_changed_files() -> Result<(), Error> {
        let (repo_root, repo_path) = setup_repository(None)?;
        let root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap();

        // Test various Unicode filenames that should be properly handled with -z flag
        let test_files = vec![
            "测试文件.txt",      // Chinese
            "テストファイル.js", // Japanese
            "файл.rs",           // Cyrillic
            "file with spaces.txt",
            "emoji_🚀.md",
            "café.ts",  // Latin with diacritics
            "ñoño.jsx", // Spanish with tildes
            "αβγ.py",   // Greek
        ];

        // Create initial commit with a base file
        let base_file = root.join_component("base.txt");
        base_file.create_with_contents("base content")?;
        let first_commit_sha = commit_file(&repo_path, Path::new("base.txt"), None);

        // Create and commit all Unicode files
        for filename in &test_files {
            let file_path = root.join_component(filename);
            file_path.create_with_contents(format!("content for {}", filename))?;
        }

        // Get changed files with uncommitted Unicode files
        let scm = SCM::new(&root);
        let files = scm
            .changed_files(&root, Some("HEAD"), None, true, false, false)?
            .unwrap();

        // Verify all Unicode files are detected in uncommitted changes
        for filename in &test_files {
            assert!(
                files.iter().any(|f| f.to_string().contains(filename)),
                "Failed to detect uncommitted Unicode file: {}",
                filename
            );
        }

        // Commit all Unicode files
        let mut last_commit = first_commit_sha.clone();
        for filename in &test_files {
            last_commit = commit_file(&repo_path, Path::new(filename), Some(&last_commit));
        }

        // Test committed Unicode files in range
        let files = changed_files(
            repo_root.path().to_path_buf(),
            repo_root.path().to_path_buf(),
            Some(first_commit_sha.as_str()),
            Some("HEAD"),
            false,
        )?;

        // Verify all Unicode files are detected in commit range
        for filename in &test_files {
            assert!(
                files.iter().any(|f| f.contains(filename)),
                "Failed to detect committed Unicode file: {}",
                filename
            );
        }

        // Test modification of Unicode files
        let modified_file = "测试文件.txt";
        let file_path = root.join_component(modified_file);
        file_path.create_with_contents("modified content")?;

        let files = scm
            .changed_files(&root, Some("HEAD"), None, true, false, false)?
            .unwrap();

        assert!(
            files.iter().any(|f| f.to_string().contains(modified_file)),
            "Failed to detect modified Unicode file: {}",
            modified_file
        );

        // Test deletion of Unicode files
        let delete_file = "emoji_🚀.md";
        let file_path = root.join_component(delete_file);
        std::fs::remove_file(file_path.as_std_path())?;

        let files = scm
            .changed_files(&root, Some("HEAD"), None, true, false, false)?
            .unwrap();

        assert!(
            files.iter().any(|f| f.to_string().contains(delete_file)),
            "Failed to detect deleted Unicode file: {}",
            delete_file
        );

        Ok(())
    }

    struct TestCase {
        env: CIEnv,
        event_json: &'static str,
    }

    #[test_case(
        TestCase {
            env: CIEnv {
                is_github_actions: true,
                github_base_ref: Err(VarError::NotPresent),
                github_event_path: Err(VarError::NotPresent),
            },
            event_json: r#""#,
        },
        None
        ; "GITHUB_BASE_REF and GITHUB_EVENT_PATH are not set"
    )]
    #[test_case(
        TestCase {
            env: CIEnv {
                is_github_actions: true,
                github_base_ref: Ok("".to_string()),
                github_event_path: Err(VarError::NotPresent),
            },
            event_json: r#""#,
        },
        None
        ; "GITHUB_BASE_REF is set to an empty string"
    )]
    #[test_case(
        TestCase {
            env: CIEnv {
                is_github_actions: true,
                github_base_ref: Ok("The choice is yours, and yours alone".to_string()),
                github_event_path: Err(VarError::NotPresent),
            },
            event_json: r#""#,
        },
        Some("The choice is yours, and yours alone")
        ; "GITHUB_BASE_REF is set to a non-empty string"
    )]
    #[test_case(
        TestCase {
            env: CIEnv {
                is_github_actions: true,
                github_base_ref: Err(VarError::NotPresent),
                github_event_path: Ok("Olmec refused to give up the location of the Shrine of the Silver Monkey".to_string()),
            },
            event_json: r#""#,
        },
        None
        ; "GITHUB_EVENT_PATH is set, but the file fails to open"
    )]
    #[test_case(
        TestCase {
            env: CIEnv {
                is_github_actions: true,
                github_base_ref: Err(VarError::NotPresent),
                github_event_path: Ok("the_room_of_the_three_gargoyles.json".to_string()),
            },
            event_json: r#"first you must pass the temple guards!"#,
        },
        None
        ; "GITHUB_EVENT_PATH is set, is not valid JSON"
    )]
    #[test_case(
        TestCase {
            env: CIEnv {
                is_github_actions: true,
                github_base_ref: Err(VarError::NotPresent),
                github_event_path: Ok("olmecs_temple.json".to_string()),
            },
            event_json: r#"{}"#,
        },
        None
        ; "no 'before' key in the JSON"
    )]
    #[test_case(
        TestCase {
            env: CIEnv {
                is_github_actions: true,
                github_base_ref: Err(VarError::NotPresent),
                github_event_path: Ok("olmecs_temple.json".to_string()),
            },
            event_json: r#"{"forced":true}"#,
        },
        None
        ; "force push"
    )]
    #[test_case(
        TestCase {
            env: CIEnv {
                is_github_actions: true,
                github_base_ref: Err(VarError::NotPresent),
                github_event_path: Ok("shrine_of_the_silver_monkey.json".to_string()),
            },
            event_json: r#"{"before":"e83c5163316f89bfbde7d9ab23ca2e25604af290"}"#,
        },
        Some("e83c5163316f89bfbde7d9ab23ca2e25604af290")
        ; "found a valid 'before' key in the JSON"
    )]
    #[test_case(
        TestCase {
            env: CIEnv {
                is_github_actions: true,
                github_base_ref: Err(VarError::NotPresent),
                github_event_path: Ok("shrine_of_the_silver_monkey.json".to_string()),
            },
            event_json: r#"{"before":"0000000000000000000000000000000000000000"}"#,
        },
        None
        ; "UNKNOWN_SHA but no commits found"
    )]
    #[test_case(
        TestCase {
            env: CIEnv {
                is_github_actions: true,
                github_base_ref: Err(VarError::NotPresent),
                github_event_path: Ok("shrine_of_the_silver_monkey.json".to_string()),
            },
            event_json: r#"{"before":"0000000000000000000000000000000000000000","commits":[]}"#,
        },
        None
        ; "empty commits"
    )]
    #[test_case(
        TestCase {
            env: CIEnv {
                is_github_actions: true,
                github_base_ref: Err(VarError::NotPresent),
                github_event_path: Ok("shrine_of_the_silver_monkey.json".to_string()),
            },
            event_json: r#"{"before":"0000000000000000000000000000000000000000","commits":[{"id":"yep"}]}"#,
        },
        Some("yep^")
        ; "first commit has a parent"
    )]
    fn test_get_github_base_ref(test_case: TestCase, expected: Option<&str>) -> Result<(), Error> {
        // note: we must bind here because otherwise the temporary file will be dropped
        let temp_file = if test_case.env.github_event_path.is_ok() {
            let temp_file = NamedTempFile::new().expect("Failed to create temporary file");
            fs::write(temp_file.path(), test_case.event_json)
                .expect("Failed to write to temporary file");
            Ok(temp_file)
        } else {
            Err(VarError::NotPresent)
        };

        let actual = GitRepo::get_github_base_ref(CIEnv {
            is_github_actions: test_case.env.is_github_actions,
            github_base_ref: test_case.env.github_base_ref,
            github_event_path: temp_file
                .as_ref()
                .map(|p| p.path().to_str().unwrap().to_string())
                .map_err(|e| e.clone()),
        });
        assert_eq!(actual, expected.map(|s| s.to_string()));

        Ok(())
    }

    #[test]
    fn test_thousands_of_commits() {
        let commits = vec![
            GitHubCommit {
                id: "insert-famous-sha-here".to_string(),
            };
            2049 // 2049 is one over the limit
        ];

        let github_event = GitHubEvent {
            before: "".to_string(),
            commits,
            forced: false,
        };
        let actual = github_event.get_parent_ref_of_first_commit();

        assert_eq!(None, actual);
    }

    #[test]
    fn test_dirty_hash_clean_tree_returns_none() {
        let (repo_root, repo_path) = setup_repository(None).unwrap();
        let file = repo_root.path().join("foo.txt");
        fs::write(&file, "initial").unwrap();
        commit_file(&repo_path, Path::new("foo.txt"), None);

        let git_root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap();
        let scm = SCM::new(&git_root);
        assert_eq!(scm.get_dirty_hash(), None);
    }

    #[test]
    fn test_dirty_hash_unstaged_changes() {
        let (repo_root, repo_path) = setup_repository(None).unwrap();
        let file = repo_root.path().join("foo.txt");
        fs::write(&file, "initial").unwrap();
        commit_file(&repo_path, Path::new("foo.txt"), None);

        fs::write(&file, "modified").unwrap();

        let git_root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap();
        let scm = SCM::new(&git_root);
        assert!(scm.get_dirty_hash().is_some());
    }

    #[test]
    fn test_dirty_hash_staged_changes() {
        let (repo_root, repo_path) = setup_repository(None).unwrap();
        let file = repo_root.path().join("foo.txt");
        fs::write(&file, "initial").unwrap();
        commit_file(&repo_path, Path::new("foo.txt"), None);

        fs::write(&file, "staged content").unwrap();
        run_git(repo_root.path(), &["add", "foo.txt"]);

        let git_root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap();
        let scm = SCM::new(&git_root);
        assert!(scm.get_dirty_hash().is_some());
    }

    #[test]
    fn test_dirty_hash_untracked_file() {
        let (repo_root, repo_path) = setup_repository(None).unwrap();
        let file = repo_root.path().join("foo.txt");
        fs::write(&file, "initial").unwrap();
        commit_file(&repo_path, Path::new("foo.txt"), None);

        fs::write(repo_root.path().join("untracked.txt"), "new file").unwrap();

        let git_root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap();
        let scm = SCM::new(&git_root);
        assert!(scm.get_dirty_hash().is_some());
    }

    #[test]
    fn test_dirty_hash_deterministic() {
        let (repo_root, repo_path) = setup_repository(None).unwrap();
        let file = repo_root.path().join("foo.txt");
        fs::write(&file, "initial").unwrap();
        commit_file(&repo_path, Path::new("foo.txt"), None);

        fs::write(&file, "dirty").unwrap();

        let git_root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap();
        let scm = SCM::new(&git_root);
        let hash1 = scm.get_dirty_hash();
        let hash2 = scm.get_dirty_hash();
        assert_eq!(hash1, hash2);
    }

    #[test]
    fn test_dirty_hash_different_content_produces_different_hash() {
        let (repo_root, repo_path) = setup_repository(None).unwrap();
        let file = repo_root.path().join("foo.txt");
        fs::write(&file, "initial").unwrap();
        commit_file(&repo_path, Path::new("foo.txt"), None);

        fs::write(&file, "content A").unwrap();
        let git_root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap();
        let scm = SCM::new(&git_root);
        let hash_a = scm.get_dirty_hash();

        fs::write(&file, "content B").unwrap();
        let hash_b = scm.get_dirty_hash();

        assert_ne!(
            hash_a, hash_b,
            "different content should produce different hashes"
        );
    }

    #[test]
    fn test_dirty_hash_manual_scm_returns_none() {
        assert_eq!(SCM::Manual.get_dirty_hash(), None);
    }

    #[test]
    fn test_dirty_hash_no_commits_untracked_file() {
        let (repo_root, _repo_path) = setup_repository(None).unwrap();
        fs::write(repo_root.path().join("new.txt"), "hello").unwrap();

        let git_root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap();
        let scm = SCM::new(&git_root);
        assert!(
            scm.get_dirty_hash().is_some(),
            "fresh repo with untracked files should produce a dirty hash"
        );
    }

    #[test]
    fn test_dirty_hash_no_commits_staged_content_affects_hash() {
        let (repo_root, _repo_path) = setup_repository(None).unwrap();
        let file = repo_root.path().join("foo.txt");

        fs::write(&file, "content A").unwrap();
        run_git(repo_root.path(), &["add", "foo.txt"]);
        let git_root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap();
        let scm = SCM::new(&git_root);
        let hash_a = scm.get_dirty_hash();

        fs::write(&file, "content B").unwrap();
        run_git(repo_root.path(), &["add", "foo.txt"]);
        let hash_b = scm.get_dirty_hash();

        assert_ne!(
            hash_a, hash_b,
            "different staged content in a fresh repo should produce different hashes"
        );
    }

    fn make_git_repo(root: &AbsoluteSystemPath) -> GitRepo {
        let bin = GitRepo::find_bin().expect("git binary required for tests");
        GitRepo {
            root: root.to_owned(),
            bin,
            attrs: std::sync::OnceLock::new(),
        }
    }

    #[test]
    fn test_add_files_from_stdout_rejects_absolute_paths() {
        let tmp = tempfile::tempdir().unwrap();
        let root = AbsoluteSystemPathBuf::try_from(tmp.path()).unwrap();
        let repo = make_git_repo(&root);

        let stdout = b"/absolute/path\0normal/file\0".to_vec();
        let mut files = HashSet::new();
        let result = repo.add_files_from_stdout(&mut files, &root, stdout);

        assert_matches!(result, Err(Error::Path(PathError::NotRelative(_), _)));
        assert!(
            files.is_empty(),
            "no files should be added before the error"
        );
    }

    #[test]
    fn test_add_files_from_stdout_rejects_unanchorable_paths() {
        let git_root_dir = tempfile::tempdir().unwrap();
        let turbo_root_dir = tempfile::tempdir().unwrap();
        let git_root = AbsoluteSystemPathBuf::try_from(git_root_dir.path()).unwrap();
        let turbo_root = AbsoluteSystemPathBuf::try_from(turbo_root_dir.path()).unwrap();
        let repo = make_git_repo(&git_root);

        // Path is valid and relative, but when joined with git_root it produces an
        // absolute path that isn't under turbo_root — reanchoring fails.
        let stdout = b"some/file.txt\0".to_vec();
        let mut files = HashSet::new();
        let result = repo.add_files_from_stdout(&mut files, &turbo_root, stdout);

        assert_matches!(result, Err(Error::Path(PathError::NotParent(_, _), _)));
    }

    #[test]
    fn test_changed_files_propagates_path_error() -> Result<(), Error> {
        let (repo_root, repo_path) = setup_repository(None)?;
        let root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap();

        let file = root.join_component("foo.txt");
        file.create_with_contents("content")?;
        commit_file(&repo_path, Path::new("foo.txt"), None);

        let new_file = root.join_component("bar.txt");
        new_file.create_with_contents("new content")?;

        // Use a turbo_root that is NOT a subdirectory of the git root.
        // The turbo_root_relative_to_git_root check at the start of changed_files
        // will fail, producing Error::Path.
        let separate_dir = tempfile::tempdir()?;
        let bad_turbo_root = AbsoluteSystemPathBuf::try_from(separate_dir.path()).unwrap();
        let scm = SCM::new(&root);

        let result = scm.changed_files(&bad_turbo_root, Some("HEAD"), None, true, false, false);
        assert_matches!(result, Err(Error::Path(PathError::NotParent(_, _), _)));

        Ok(())
    }
}
