#![feature(error_generic_member_access)]
#![feature(io_error_more)]
#![deny(clippy::all)]
#![allow(clippy::result_large_err)]

//! Turborepo's library for interacting with source control management (SCM).
//! Currently we only support git. We use SCM for finding changed files,
//! for getting the previous version of a lockfile, and for hashing files.

use std::{
    backtrace::{self, Backtrace},
    collections::HashMap,
    fmt,
    io::Read,
    process::{Child, Command},
    sync::OnceLock,
};

use bstr::io::BufReadExt;
use thiserror::Error;
use tracing::debug;
use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf, PathError, RelativeUnixPathBuf};

pub(crate) mod crlf;
pub mod git;
mod hash_object;
mod ls_tree;
pub mod manual;
pub mod package_deps;
mod repo_index;
mod status;
pub mod worktree;

#[cfg(test)]
mod git_index_regression_tests;
mod git_path;
#[cfg(test)]
mod test_utils;

pub use repo_index::{RepoGitIndex, walk_candidate_files};
pub use turborepo_hash::OidHash;
pub use worktree::WorktreeInfo;

#[derive(Debug, Error)]
pub enum Error {
    #[error("Git error: {0}")]
    Git(String, #[backtrace] backtrace::Backtrace),
    #[error(
        "{0} is not part of a Git repository. Git is required for operations based on source \
         control"
    )]
    GitRequired(AbsoluteSystemPathBuf),
    #[error("Git command failed due to unsupported version. Upgrade to git 2.18 or newer: {0}")]
    GitVersion(String),
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error, #[backtrace] backtrace::Backtrace),
    #[error("Path error: {0}")]
    Path(#[from] PathError, #[backtrace] backtrace::Backtrace),
    #[error("Could not find git binary")]
    GitBinaryNotFound(#[from] which::Error),
    #[error("encoding error: {0}")]
    Encoding(
        #[from] std::string::FromUtf8Error,
        #[backtrace] backtrace::Backtrace,
    ),
    #[error("Git path contains non-UTF-8 bytes from {origin}: {path}")]
    UnsupportedGitPath {
        origin: &'static str,
        path: String,
        #[backtrace]
        backtrace: backtrace::Backtrace,
    },
    #[error("Package traversal error: {0}")]
    Ignore(#[from] ignore::Error, #[backtrace] backtrace::Backtrace),
    #[error("Invalid glob: {0}")]
    Glob(#[source] Box<wax::BuildError>, backtrace::Backtrace),
    #[error("Invalid globwalk pattern: {0}")]
    Globwalk(#[from] globwalk::GlobError),
    #[error(transparent)]
    Walk(#[from] globwalk::WalkError),
    #[error("Unable to resolve base branch. Please set with `TURBO_SCM_BASE`.")]
    UnableToResolveRef,
}

pub type GitHashes = HashMap<RelativeUnixPathBuf, OidHash>;

fn is_os_resource_error(e: &std::io::Error) -> bool {
    matches!(
        e.raw_os_error(),
        Some(24) // EMFILE: too many open files
        | Some(12) // ENOMEM: out of memory
    )
}

fn walk_error_is_resource_exhaustion(e: &globwalk::WalkError) -> bool {
    // Walk the error source chain looking for an underlying io::Error.
    let mut source: Option<&dyn std::error::Error> = Some(e);
    while let Some(err) = source {
        if let Some(io_err) = err.downcast_ref::<std::io::Error>()
            && is_os_resource_error(io_err)
        {
            return true;
        }
        source = err.source();
    }
    // wax wraps errors in ways that can break the downcast chain.
    // Fall back to checking the Display string.
    let msg = e.to_string();
    msg.contains("Too many open files") || msg.contains("os error 24")
}

impl From<wax::BuildError> for Error {
    fn from(value: wax::BuildError) -> Self {
        Error::Glob(Box::new(value), Backtrace::capture())
    }
}

impl Error {
    pub(crate) fn git_error(s: impl Into<String>) -> Self {
        Error::Git(s.into(), Backtrace::capture())
    }

    /// Returns true if this error indicates OS resource exhaustion (e.g. too
    /// many open files) where a fallback to manual hashing would also fail.
    pub fn is_resource_exhaustion(&self) -> bool {
        match self {
            Error::Io(e, _) => is_os_resource_error(e),
            Error::Walk(e) => walk_error_is_resource_exhaustion(e),
            _ => false,
        }
    }

    pub fn is_unsupported_git_path(&self) -> bool {
        matches!(self, Error::UnsupportedGitPath { .. })
    }
}

fn read_git_error_to_string<R: Read>(stderr: &mut R) -> Option<String> {
    let mut buf = String::new();
    let bytes_read = stderr.read_to_string(&mut buf).ok()?;
    if bytes_read > 0 {
        // something failed with git, report that error
        Some(buf)
    } else {
        None
    }
}

pub(crate) fn wait_for_success<R: Read, T>(
    mut child: Child,
    stderr: &mut R,
    command: &str,
    root_path: impl AsRef<AbsoluteSystemPath>,
    parse_result: Result<T, Error>,
) -> Result<T, Error> {
    if let Err(parse_err) = parse_result {
        // In this case, we don't care about waiting for the child to exit,
        // we need to kill it. It's possible that we didn't read all of the output,
        // or that the child process doesn't know to exit.
        // For good measure close off `stdin`
        child.stdin.take();
        match child.kill() {
            Ok(()) => {
                // If we successfully sent the signal, then we wait for process to exit
                // Do not error if wait fails as that can indicate the pid has already been
                // reaped.
                child.wait().ok();
            }
            Err(e) => {
                debug!("failed to kill git process: {e}");
                // There are exactly 3 reasons why killing a child could fail:
                // - Invalid signal number (Unix only, would indicate Rust stdlib is faulty)
                // - Insufficient permission (Windows + Unix) (Not sure how this could happen
                //   given we're talking about a child process)
                // - Process has already exited (Windows + Unix)
                // The final option is by far the most likely in which case `try_call` is a
                // nonblocking option that will reap the child pid if it has
                // already exited. We use this over `wait` to avoid blocking on a process
                // exiting which possibly did not receive our kill signal.
                child.try_wait().ok();
            }
        };

        if parse_err.is_unsupported_git_path() {
            return Err(parse_err);
        }

        let stderr_output = read_git_error_to_string(stderr);
        let stderr_text = stderr_output
            .map(|stderr| format!(" stderr: {stderr}"))
            .unwrap_or_default();
        let err_text = format!(
            "'{}' in {}{}{}",
            command,
            root_path.as_ref(),
            stderr_text,
            parse_err
        );
        return Err(Error::Git(err_text, Backtrace::capture()));
    }
    // TODO: if we've successfully parsed the output, but the command is hanging for
    // some reason, we will currently block forever.
    let exit_status = child.wait()?;
    if exit_status.success() {
        return parse_result;
    }
    // We successfully parsed, but the command failed.
    let stderr_output = read_git_error_to_string(stderr);
    let stderr_text = stderr_output
        .map(|stderr| format!(" stderr: {stderr}"))
        .unwrap_or_default();
    if matches!(exit_status.code(), Some(129)) {
        return Err(Error::GitVersion(stderr_text));
    }
    let exit_text = {
        let code = exit_status
            .code()
            .map(|code| code.to_string())
            .unwrap_or("unknown".to_string());
        format!(" exited with code {code}")
    };
    let path_text = root_path.as_ref();
    let err_text = format!("'{command}' in {path_text}{exit_text}{stderr_text}");
    Err(Error::Git(err_text, Backtrace::capture()))
}

pub struct GitRepo {
    root: AbsoluteSystemPathBuf,
    bin: AbsoluteSystemPathBuf,
    attrs: OnceLock<Option<crlf::GitAttrs>>,
}

impl GitRepo {
    pub(crate) fn git_attrs(&self) -> Option<&crlf::GitAttrs> {
        self.attrs
            .get_or_init(|| crlf::GitAttrs::load(&self.root))
            .as_ref()
    }
}

impl Clone for GitRepo {
    fn clone(&self) -> Self {
        Self {
            root: self.root.clone(),
            bin: self.bin.clone(),
            attrs: OnceLock::new(),
        }
    }
}

impl fmt::Debug for GitRepo {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("GitRepo")
            .field("root", &self.root)
            .field("bin", &self.bin)
            .finish()
    }
}

#[derive(Debug, Error)]
enum GitError {
    #[error("failed to find git binary: {0}")]
    Binary(#[from] which::Error),
    #[error("failed to find .git folder for path {0}: {1}")]
    Root(AbsoluteSystemPathBuf, Error),
}

impl GitRepo {
    fn find(path_in_repo: &AbsoluteSystemPath) -> Result<Self, GitError> {
        // If which produces an invalid absolute path, it's not an execution error, it's
        // a programming error. We expect it to always give us an absolute path
        // if it gives us any path. If that's not the case, we should crash.
        let bin = Self::find_bin()?;
        let root =
            find_git_root(path_in_repo).map_err(|e| GitError::Root(path_in_repo.to_owned(), e))?;
        Ok(Self {
            root,
            bin,
            attrs: OnceLock::new(),
        })
    }

    pub fn find_bin() -> Result<AbsoluteSystemPathBuf, which::Error> {
        which::which("git").map(|path| {
            AbsoluteSystemPathBuf::try_from(path.as_path()).unwrap_or_else(|_| {
                panic!(
                    "which git produced an invalid absolute path {}",
                    path.display()
                )
            })
        })
    }
}

fn find_git_root(turbo_root: &AbsoluteSystemPath) -> Result<AbsoluteSystemPathBuf, Error> {
    let rev_parse = Command::new("git")
        .args(["rev-parse", "--show-cdup"])
        .current_dir(turbo_root)
        .output()?;
    if !rev_parse.status.success() {
        let stderr = String::from_utf8_lossy(&rev_parse.stderr);
        return Err(Error::git_error(format!(
            "git rev-parse --show-cdup error: {stderr}"
        )));
    }
    let cursor = std::io::Cursor::new(rev_parse.stdout);
    let mut lines = cursor.byte_lines();
    if let Some(line) = lines.next() {
        let line = String::from_utf8(line?)?;
        let tail = RelativeUnixPathBuf::new(line)?;
        Ok(turbo_root.join_unix_path(tail))
    } else {
        let stderr = String::from_utf8_lossy(&rev_parse.stderr);
        Err(Error::git_error(format!(
            "git rev-parse --show-cdup error: no values on stdout. stderr: {stderr}"
        )))
    }
}

#[derive(Debug, Clone)]
pub enum SCM {
    Git(GitRepo),
    Manual,
}

impl SCM {
    #[tracing::instrument]
    pub fn new(path_in_repo: &AbsoluteSystemPath) -> SCM {
        GitRepo::find(path_in_repo)
            .map(SCM::Git)
            .unwrap_or_else(|e| {
                debug!("{}, continuing with manual hashing", e);
                SCM::Manual
            })
    }

    /// Creates an SCM instance using a pre-resolved git root, avoiding the
    /// `git rev-parse --show-cdup` subprocess call that `new` would perform.
    /// Falls back to `new` if the git binary cannot be found.
    #[tracing::instrument]
    pub fn new_with_git_root(
        path_in_repo: &AbsoluteSystemPath,
        git_root: AbsoluteSystemPathBuf,
    ) -> SCM {
        match GitRepo::find_bin() {
            Ok(bin) => SCM::Git(GitRepo {
                root: git_root,
                bin,
                attrs: OnceLock::new(),
            }),
            Err(e) => {
                debug!(
                    "git binary not found: {}, continuing with manual hashing",
                    e
                );
                SCM::Manual
            }
        }
    }

    pub fn is_manual(&self) -> bool {
        matches!(self, SCM::Manual)
    }

    pub fn git_root(&self) -> Option<&AbsoluteSystemPath> {
        match self {
            SCM::Git(git) => Some(&git.root),
            SCM::Manual => None,
        }
    }

    /// Build a repo-wide git index that caches `git ls-tree` and `git status`
    /// results. Returns `None` for manual SCM mode or when the package count
    /// is too small to benefit. Callers should build this once before parallel
    /// file hashing and pass it through to `get_package_file_hashes`.
    pub fn build_repo_index(&self, package_count: usize) -> Option<RepoGitIndex> {
        // The repo index trades 2N subprocess spawns for 2 repo-wide git
        // commands + a BTreeMap build. For small repos, the overhead of
        // scanning the entire repo outweighs the subprocess savings.
        if package_count < 16 {
            debug!(
                "skipping repo index for small repo (package_count={})",
                package_count,
            );
            return None;
        }

        match self {
            SCM::Git(git) => match RepoGitIndex::new(git) {
                Ok(index) => {
                    debug!("repo git index built successfully");
                    Some(index)
                }
                Err(e) => {
                    debug!(
                        "failed to build repo git index: {}. Will hash per-package.",
                        e
                    );
                    None
                }
            },
            SCM::Manual => None,
        }
    }

    /// Build the repo index without a package-count threshold.
    ///
    /// This is intended for early, speculative construction: we spawn it on a
    /// background thread before the package graph is built so the git I/O
    /// overlaps with package discovery. If the repo turns out to be small the
    /// caller can simply ignore the result.
    pub fn build_repo_index_eager(&self) -> Option<RepoGitIndex> {
        match self {
            SCM::Git(git) => match RepoGitIndex::new(git) {
                Ok(index) => {
                    debug!("repo git index built eagerly");
                    Some(index)
                }
                Err(e) => {
                    debug!(
                        "failed to build repo git index eagerly: {}. Will hash per-package.",
                        e,
                    );
                    None
                }
            },
            SCM::Manual => None,
        }
    }

    /// Build only the tracked portion of the repo index.
    ///
    /// This is intended for speculative startup work on the `turbo run` path.
    /// Untracked-file discovery can be layered on later once the selected
    /// package set is known.
    pub fn build_tracked_repo_index_eager(&self) -> Option<RepoGitIndex> {
        match self {
            SCM::Git(git) => match RepoGitIndex::new_tracked(git) {
                Ok(index) => {
                    debug!("tracked repo git index built eagerly");
                    Some(index)
                }
                Err(e) => {
                    debug!(
                        "failed to build tracked repo git index eagerly: {}. Will hash \
                         per-package.",
                        e,
                    );
                    None
                }
            },
            SCM::Manual => None,
        }
    }

    /// Build the full repo index (tracked + untracked) using parallel git
    /// subprocesses for the tracked index, and a race between
    /// `walk_candidate_files` and `git ls-files --others` for untracked
    /// discovery. The race ensures optimal performance on both macOS
    /// (where the walk wins) and Linux (where ls-files wins).
    pub fn build_repo_index_from_subprocesses(
        &self,
        prefixes: &[RelativeUnixPathBuf],
    ) -> Option<RepoGitIndex> {
        match self {
            SCM::Git(git) => match RepoGitIndex::new_from_subprocess_and_walk(git, prefixes) {
                Ok(index) => {
                    debug!("repo git index built from subprocess + walk race");
                    Some(index)
                }
                Err(e) => {
                    debug!(
                        "failed to build repo git index from subprocesses: {}. Will hash \
                         per-package.",
                        e,
                    );
                    None
                }
            },
            SCM::Manual => None,
        }
    }

    pub fn populate_repo_index_untracked(
        &self,
        repo_index: &mut RepoGitIndex,
        prefixes: &[RelativeUnixPathBuf],
    ) -> Result<(), Error> {
        match self {
            SCM::Git(git) => repo_index.populate_untracked_for_prefixes(git, prefixes),
            SCM::Manual => Ok(()),
        }
    }
}

#[cfg(test)]
mod tests {
    use std::{
        assert_matches,
        io::Read,
        process::{Command, Stdio},
    };

    use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf};

    use super::find_git_root;
    use crate::{Error, wait_for_success};

    fn tmp_dir() -> (tempfile::TempDir, AbsoluteSystemPathBuf) {
        let tmp_dir = tempfile::tempdir().unwrap();
        let dir = AbsoluteSystemPathBuf::try_from(tmp_dir.path())
            .unwrap()
            .to_realpath()
            .unwrap();
        (tmp_dir, dir)
    }

    fn require_git_cmd(repo_root: &AbsoluteSystemPath, args: &[&str]) {
        let mut cmd = Command::new("git");
        cmd.args(args).current_dir(repo_root);
        assert!(cmd.output().unwrap().status.success());
    }

    fn setup_repository(repo_root: &AbsoluteSystemPath) {
        let cmds: &[&[&str]] = &[
            &["init", "."],
            &["config", "--local", "user.name", "test"],
            &["config", "--local", "user.email", "test@example.com"],
        ];
        for cmd in cmds {
            require_git_cmd(repo_root, cmd);
        }
    }

    #[test]
    fn test_symlinked_git_root() {
        let (_, tmp_root) = tmp_dir();
        let git_root = tmp_root.join_component("actual_repo");
        git_root.create_dir_all().unwrap();
        setup_repository(&git_root);
        git_root.join_component("inside").create_dir_all().unwrap();
        let link = tmp_root.join_component("link");
        link.symlink_to_dir("actual_repo").unwrap();
        let turbo_root = link.join_component("inside");
        let result = find_git_root(&turbo_root).unwrap();
        assert_eq!(result, link);
    }

    #[test]
    fn test_no_git_root() {
        let (_, tmp_root) = tmp_dir();
        tmp_root.create_dir_all().unwrap();
        let result = find_git_root(&tmp_root);
        assert_matches!(result, Err(Error::Git(_, _)));
    }

    #[test]
    fn test_wait_for_success() {
        let tmp_dir = tempfile::tempdir().unwrap();
        let root = AbsoluteSystemPathBuf::try_from(tmp_dir.path()).unwrap();
        #[cfg(windows)]
        let mut cmd = {
            let batch = r#"
            echo "some error text" 1>&2
            echo "started"
            set /p myvar=Press enter to stop hanging
            "#;

            let script_path = root.join_component("hanging.cmd");
            script_path.create_with_contents(batch).unwrap();
            Command::new(script_path.as_std_path())
        };

        #[cfg(unix)]
        let mut cmd = {
            // Shell script to simulate a command that hangs
            let sh = r#"
            echo "some error text" >&2
            echo "started"
            read -p "Press enter to stop hanging"
            "#;
            let bash = which::which("bash").unwrap();
            let script_path = root.join_component("hanging.sh");
            script_path.create_with_contents(sh).unwrap();
            script_path.set_mode(0x755).unwrap();
            let mut cmd = Command::new(bash);
            cmd.arg(script_path.as_str());
            cmd
        };

        let mut cmd = cmd
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .stdin(Stdio::piped())
            .spawn()
            .unwrap();
        let mut stderr = cmd.stderr.take().unwrap();
        let mut stdout = cmd.stdout.take().unwrap();
        // read from stdout to ensure the process has started
        let mut buf = vec![0; 8];
        stdout.read_exact(&mut buf).unwrap();
        // simulate a parsing error. Any error will work here
        let parse_result: Result<(), super::Error> =
            Err(Error::GitVersion("any error".to_string()));
        // Previously, this would hang forever trying to read from stderr
        let err =
            wait_for_success(cmd, &mut stderr, "hanging.sh", &root, parse_result).unwrap_err();
        // Note that we aren't guaranteed to have captured stderr, notably on windows.
        // We should, however, have our injected error of "any error" in the error
        // message.
        assert!(err.to_string().contains("any error"));
    }

    #[test]
    fn test_wait_for_success_preserves_unsupported_git_path_errors() {
        let tmp_dir = tempfile::tempdir().unwrap();
        let root = AbsoluteSystemPathBuf::try_from(tmp_dir.path()).unwrap();
        let mut cmd = Command::new("git")
            .arg("--version")
            .stderr(Stdio::piped())
            .spawn()
            .unwrap();
        let mut stderr = cmd.stderr.take().unwrap();
        let parse_result: Result<(), super::Error> = Err(Error::UnsupportedGitPath {
            origin: "test git path",
            path: "bad-\\xff".to_string(),
            backtrace: std::backtrace::Backtrace::capture(),
        });

        let err =
            wait_for_success(cmd, &mut stderr, "git --version", &root, parse_result).unwrap_err();
        assert_matches!(err, Error::UnsupportedGitPath { .. });
    }
}
