//! Git operations for resolving and caching images via git notes.

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};

/// Git ref for the notes used to store cached image metadata.
pub const NOTES_REF: &str = "refs/notes/offload-images";

/// A cached image entry stored in a git note.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageEntry {
    pub image_id: String,
}

/// A note is a JSON object keyed by TOML config file path.
pub type NoteContents = HashMap<String, ImageEntry>;

/// Run a git command and return its stdout as a trimmed string.
async fn run_git(repo: &Path, args: &[&str]) -> Result<String> {
    let repo = repo.to_path_buf();
    let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
    tokio::task::spawn_blocking(move || {
        let output = std::process::Command::new("git")
            .args(&args)
            .current_dir(&repo)
            .output()
            .context("failed to run git")?;
        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            bail!(
                "git {} failed (exit {}): {}",
                args.join(" "),
                output.status,
                stderr.trim()
            );
        }
        let stdout = String::from_utf8(output.stdout).context("git output was not valid UTF-8")?;
        Ok(stdout.trim().to_string())
    })
    .await?
}

/// Return the SHA of HEAD.
pub async fn head_sha(repo: &Path) -> Result<String> {
    run_git(repo, &["rev-parse", "HEAD"]).await
}

/// Return the repository root directory.
pub async fn repo_root(repo: &Path) -> Result<PathBuf> {
    let root = run_git(repo, &["rev-parse", "--show-toplevel"]).await?;
    Ok(PathBuf::from(root))
}

/// Read the offload-images note for a given commit.
///
/// Returns `Ok(None)` if the notes ref or the note for this commit doesn't exist.
pub async fn read_note(repo: &Path, commit_sha: &str) -> Result<Option<NoteContents>> {
    let repo = repo.to_path_buf();
    let sha = commit_sha.to_string();
    let args = vec![
        "notes".to_string(),
        "--ref".to_string(),
        NOTES_REF.to_string(),
        "show".to_string(),
        sha,
    ];
    tokio::task::spawn_blocking(move || {
        let output = std::process::Command::new("git")
            .args(&args)
            .current_dir(&repo)
            .output()
            .context("failed to run git notes show")?;
        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            // "no note found" or missing ref -- not an error
            let stderr_lower = stderr.to_lowercase();
            if stderr_lower.contains("no note found") || stderr_lower.contains("not a valid ref") {
                return Ok(None);
            }
            bail!("git notes show failed: {}", stderr.trim());
        }
        let json =
            String::from_utf8(output.stdout).context("git notes output was not valid UTF-8")?;
        let contents: NoteContents =
            serde_json::from_str(&json).context("failed to parse note JSON")?;
        Ok(Some(contents))
    })
    .await?
}

/// Write (or merge) an offload-images note for a given commit.
///
/// Performs a read-modify-write: existing entries for other config keys are
/// preserved. The note is pretty-printed JSON with 4-space indentation.
pub async fn write_note(repo: &Path, commit_sha: &str, contents: &NoteContents) -> Result<()> {
    // Read existing note to merge
    let existing = read_note(repo, commit_sha).await?.unwrap_or_default();
    let mut merged = existing;
    for (key, value) in contents {
        merged.insert(key.clone(), value.clone());
    }

    let json = serde_json::to_string_pretty(&merged).context("failed to serialize note JSON")?;
    let sha = commit_sha.to_string();
    let repo = repo.to_path_buf();

    tokio::task::spawn_blocking(move || {
        // Write JSON to a temp file and use -F to pass it to git notes add
        let mut tmp = tempfile::NamedTempFile::new().context("failed to create temp file")?;
        std::io::Write::write_all(&mut tmp, json.as_bytes())
            .context("failed to write note content to temp file")?;
        std::io::Write::flush(&mut tmp).context("failed to flush temp file")?;

        let output = std::process::Command::new("git")
            .args([
                "notes",
                "--ref",
                NOTES_REF,
                "add",
                "-f",
                "-F",
                &tmp.path().to_string_lossy(),
                &sha,
            ])
            .current_dir(&repo)
            .output()
            .context("failed to run git notes add")?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            bail!("git notes add failed: {}", stderr.trim());
        }
        Ok(())
    })
    .await?
}

/// Force-push notes to a remote.
///
/// Returns `Ok(())` even if the remote ref doesn't exist yet (first push creates it).
pub async fn push_notes(repo: &Path, remote: &str) -> Result<()> {
    let repo = repo.to_path_buf();
    let remote = remote.to_string();
    tokio::task::spawn_blocking(move || {
        let output = std::process::Command::new("git")
            .args(["push", &remote, NOTES_REF, "--force"])
            .current_dir(&repo)
            .output()
            .context("failed to run git push")?;
        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            bail!("git push notes failed: {}", stderr.trim());
        }
        Ok(())
    })
    .await?
}

/// Fetch notes from a remote.
///
/// Returns `Ok(())` even if the remote ref doesn't exist.
pub async fn fetch_notes(repo: &Path, remote: &str) -> Result<()> {
    let repo = repo.to_path_buf();
    let remote = remote.to_string();
    let refspec = format!("{NOTES_REF}:{NOTES_REF}");
    tokio::task::spawn_blocking(move || {
        let output = std::process::Command::new("git")
            .args(["fetch", &remote, &refspec])
            .current_dir(&repo)
            .output()
            .context("failed to run git fetch")?;
        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            // Missing remote ref is not an error
            if stderr.contains("couldn't find remote ref")
                || stderr.contains("no such ref")
                || stderr.contains("does not appear to be a git repository")
            {
                return Ok(());
            }
            bail!("git fetch notes failed: {}", stderr.trim());
        }
        Ok(())
    })
    .await?
}

/// Ensure the notes refspec is in the remote's fetch configuration.
///
/// Adds `+refs/notes/offload-images:refs/notes/offload-images` to
/// `remote.<remote>.fetch` if not already present.
pub async fn configure_notes_fetch(repo: &Path, remote: &str) -> Result<()> {
    let repo = repo.to_path_buf();
    let remote = remote.to_string();
    let refspec = format!("+{NOTES_REF}:{NOTES_REF}");

    tokio::task::spawn_blocking(move || {
        // Check existing fetch refspecs
        let output = std::process::Command::new("git")
            .args(["config", "--get-all", &format!("remote.{remote}.fetch")])
            .current_dir(&repo)
            .output()
            .context("failed to run git config")?;

        let existing = String::from_utf8_lossy(&output.stdout);
        if existing.lines().any(|line| line.trim() == refspec) {
            return Ok(());
        }

        // Add the refspec
        let add_output = std::process::Command::new("git")
            .args([
                "config",
                "--add",
                &format!("remote.{remote}.fetch"),
                &refspec,
            ])
            .current_dir(&repo)
            .output()
            .context("failed to run git config --add")?;

        if !add_output.status.success() {
            let stderr = String::from_utf8_lossy(&add_output.stderr);
            bail!("git config --add failed: {}", stderr.trim());
        }
        Ok(())
    })
    .await?
}

/// Check whether a commit touches any of the given paths.
///
/// Uses `git diff-tree` with `-m` to handle merge commits (checks all parents).
pub async fn commit_touches_paths(repo: &Path, commit_sha: &str, paths: &[String]) -> Result<bool> {
    let sha = commit_sha.to_string();
    let paths = paths.to_vec();

    let output = run_git(
        repo,
        &[
            "diff-tree",
            "--no-commit-id",
            "--name-only",
            "-r",
            "-m",
            &sha,
        ],
    )
    .await?;

    let changed: std::collections::HashSet<&str> = output.lines().collect();
    Ok(paths.iter().any(|p| changed.contains(p.as_str())))
}

/// Export a commit's tree plus untracked files into `dest` as a shallow clone.
///
/// First creates a shallow clone with tracked files and `.git/`, then copies
/// untracked files from the working tree filtered by `.dockerignore` (not
/// `.gitignore`). This includes gitignored files like generated tarballs that
/// the Docker build needs but git doesn't track.
///
/// The thin diff applies with `git apply --3way` to handle overlapping
/// "new file" hunks for files present in both the export and the patch.
pub async fn export_tree(repo: &Path, commit_sha: &str, dest: &Path) -> Result<()> {
    let repo_root = repo_root(repo).await?;
    let sha = commit_sha.to_string();
    let dest = dest.to_path_buf();
    tokio::task::spawn_blocking(move || {
        // file:// protocol is required to fetch arbitrary SHAs from a local repo.
        let repo_url = format!("file://{}", repo_root.display());

        let run = |args: &[&str]| -> Result<()> {
            let output = std::process::Command::new("git")
                .args(args)
                .output()
                .with_context(|| format!("failed to run git {}", args.join(" ")))?;
            if !output.status.success() {
                let stderr = String::from_utf8_lossy(&output.stderr);
                bail!("git {} failed: {}", args.join(" "), stderr.trim());
            }
            Ok(())
        };

        run(&["init", &dest.to_string_lossy()])?;
        run(&[
            "-C",
            &dest.to_string_lossy(),
            "fetch",
            "--depth=1",
            &repo_url,
            &sha,
        ])?;
        run(&["-C", &dest.to_string_lossy(), "checkout", "FETCH_HEAD"])?;
        // Create a branch so refs/heads/ is non-empty.  Some container
        // image builders (e.g. Modal) only upload files, not empty
        // directories, and git refuses to recognise a repo whose
        // refs/heads/ directory is missing.
        run(&["-C", &dest.to_string_lossy(), "checkout", "-b", "main"])?;

        // Copy untracked files from the working tree, using .dockerignore
        // (not .gitignore) as the filter. This ensures gitignored files
        // like generated tarballs are included in the base image.
        copy_untracked_files(&repo_root, &dest)?;

        Ok(())
    })
    .await?
}

/// Bridge the gap between `.gitignore` and `.dockerignore` in the exported tree.
///
/// `export_tree()` builds the Docker context from a shallow git clone that
/// contains only tracked files, but a real `COPY . /app` honours
/// `.dockerignore`, not `.gitignore`. Gitignored build artifacts that the
/// container needs at runtime — pre-built frontend bundles (`frontend-dist/`),
/// generated tarballs, etc. — would be missing without this step.
///
/// `generate_checkpoint_diff()` runs `git add -A`, which respects `.gitignore`,
/// so these files are invisible to the thin diff and must be baked into the
/// base image here instead. Only gitignored files are copied to avoid
/// conflicts with the thin diff's "new file" hunks for non-gitignored
/// untracked files.
fn copy_untracked_files(repo_root: &Path, dest: &Path) -> Result<()> {
    let dockerignore = repo_root.join(".dockerignore");

    // All untracked files (including gitignored), minus dockerignored ones.
    let mut all_args = vec![
        "ls-files".to_string(),
        "--others".to_string(),
        "--exclude=.git".to_string(),
    ];
    if dockerignore.exists() {
        all_args.push(format!("--exclude-from={}", dockerignore.to_string_lossy()));
    }
    let all_output = std::process::Command::new("git")
        .args(&all_args)
        .current_dir(repo_root)
        .output()
        .context("failed to list untracked files")?;
    if !all_output.status.success() {
        return Ok(());
    }

    // Non-gitignored untracked files — these are in the thin diff already.
    let ngi_output = std::process::Command::new("git")
        .args(["ls-files", "--others", "--exclude-standard"])
        .current_dir(repo_root)
        .output()
        .context("failed to list non-gitignored untracked files")?;
    let ngi_stdout = if ngi_output.status.success() {
        String::from_utf8_lossy(&ngi_output.stdout).into_owned()
    } else {
        String::new()
    };
    let non_gitignored: std::collections::HashSet<&str> = ngi_stdout.lines().collect();

    // Copy only the gitignored files (set difference).
    let all_stdout = String::from_utf8_lossy(&all_output.stdout).into_owned();
    for relpath in all_stdout.lines() {
        if relpath.is_empty() || non_gitignored.contains(relpath) {
            continue;
        }
        let src = repo_root.join(relpath);
        let dst = dest.join(relpath);
        if let Some(parent) = dst.parent() {
            std::fs::create_dir_all(parent).ok();
        }
        std::fs::copy(&src, &dst).ok();
    }

    Ok(())
}

/// Generate a binary diff capturing all working-tree changes since a checkpoint commit.
///
/// Uses a temporary git index so that both tracked modifications and untracked
/// (non-ignored) files are included in one unified diff. The real index is
/// never touched.
///
/// Returns `Ok(None)` if there are no changes. Otherwise returns `Ok(Some(path))`
/// where path is a `NamedTempFile` containing the binary patch. The caller
/// owns the temp file and is responsible for its lifetime.
pub async fn generate_checkpoint_diff(
    repo: &Path,
    checkpoint_sha: &str,
) -> Result<Option<tempfile::NamedTempFile>> {
    let repo = repo.to_path_buf();
    let sha = checkpoint_sha.to_string();
    tokio::task::spawn_blocking(move || {
        // Create a temporary index file
        let tmp_index =
            tempfile::NamedTempFile::new().context("failed to create temp index file")?;
        let tmp_index_path = tmp_index.path().to_string_lossy().to_string();

        let run_git_with_index = |args: &[&str]| -> Result<std::process::Output> {
            let output = std::process::Command::new("git")
                .args(args)
                .current_dir(&repo)
                .env("GIT_INDEX_FILE", &tmp_index_path)
                .output()
                .with_context(|| format!("failed to run git {}", args.join(" ")))?;
            if !output.status.success() {
                let stderr = String::from_utf8_lossy(&output.stderr);
                bail!("git {} failed: {}", args.join(" "), stderr.trim());
            }
            Ok(output)
        };

        // Seed the temp index with the checkpoint tree
        run_git_with_index(&["read-tree", &sha])?;

        // Stage the entire current working tree (tracked + untracked) into it
        run_git_with_index(&["add", "-A"])?;

        // Diff the checkpoint against the staged state -> single binary patch
        let output = run_git_with_index(&["diff", "--cached", "--binary", &sha])?;

        // tmp_index is dropped here, cleaning up the temp index file

        let patch = output.stdout;
        if patch.iter().all(|&b| b.is_ascii_whitespace()) || patch.is_empty() {
            return Ok(None);
        }

        // Write the patch to a temp file
        let mut patch_file =
            tempfile::NamedTempFile::new().context("failed to create temp patch file")?;
        std::io::Write::write_all(&mut patch_file, &patch)
            .context("failed to write patch to temp file")?;
        std::io::Write::flush(&mut patch_file).context("failed to flush patch temp file")?;

        Ok(Some(patch_file))
    })
    .await?
}

/// Count the number of files changed between two commits.
pub async fn diff_file_count(repo: &Path, from_sha: &str, to_sha: &str) -> Result<usize> {
    let output = run_git(repo, &["diff", "--name-only", from_sha, to_sha]).await?;
    if output.is_empty() {
        return Ok(0);
    }
    Ok(output.lines().count())
}

/// Return the SHAs of the last `max_depth` ancestors of HEAD (including HEAD).
pub async fn ancestors(repo: &Path, max_depth: usize) -> Result<Vec<String>> {
    let n = max_depth.to_string();
    let output = run_git(repo, &["log", "--format=%H", "-n", &n]).await?;
    Ok(output.lines().map(|s| s.to_string()).collect())
}

/// Find the nearest ancestor of HEAD that touches any of the given paths.
///
/// Relies on git's default history simplification, which follows TREESAME
/// edges through merge commits. This looks through trivial merges to find
/// the actual commit that changed the file content, while still returning
/// conflict-resolution merges where the merge itself determined the result.
/// Returns `None` if no ancestor touches any of the paths.
pub async fn nearest_ancestor_touching(repo: &Path, paths: &[String]) -> Result<Option<String>> {
    if paths.is_empty() {
        return Ok(None);
    }

    let mut args: Vec<String> = vec![
        "log".into(),
        "--format=%H".into(),
        "-n".into(),
        "1".into(),
        "HEAD".into(),
        "--".into(),
    ];
    args.extend(paths.iter().cloned());
    let refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();

    let output = run_git(repo, &refs).await?;
    let sha = output.trim();

    if sha.is_empty() {
        return Ok(None);
    }

    Ok(Some(sha.to_string()))
}

/// Convert a config path to a canonical repo-relative form.
///
/// Strips `./` prefix and makes the path relative to the repo root.
pub fn canonicalize_config_path(config_path: &str, repo_root: &Path) -> Result<String> {
    let stripped = config_path.strip_prefix("./").unwrap_or(config_path);
    let path = Path::new(stripped);

    if path.is_absolute() {
        // Make absolute paths relative to repo root
        let rel = path.strip_prefix(repo_root).with_context(|| {
            format!(
                "config path {} is not under repo root {}",
                config_path,
                repo_root.display()
            )
        })?;
        Ok(rel.to_string_lossy().to_string())
    } else {
        Ok(stripped.to_string())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Helper: create a temp directory with an initialized git repo.
    fn init_temp_repo() -> Result<tempfile::TempDir> {
        let dir = tempfile::tempdir()?;
        let run = |args: &[&str]| -> Result<()> {
            let output = std::process::Command::new("git")
                .args(args)
                .current_dir(dir.path())
                .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()?;
            if !output.status.success() {
                let stderr = String::from_utf8_lossy(&output.stderr);
                bail!("git {} failed: {}", args.join(" "), stderr.trim());
            }
            Ok(())
        };
        run(&["init"])?;
        run(&["config", "user.email", "test@test.com"])?;
        run(&["config", "user.name", "Test"])?;
        // Create an initial commit so HEAD exists
        let readme = dir.path().join("README.md");
        std::fs::write(&readme, "# test repo")?;
        run(&["add", "README.md"])?;
        run(&["commit", "-m", "initial commit"])?;
        Ok(dir)
    }

    /// Helper: run a git command in a directory and return stdout.
    fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
        let output = std::process::Command::new("git")
            .args(args)
            .current_dir(dir)
            .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()?;
        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            bail!("git {} failed: {}", args.join(" "), stderr.trim());
        }
        Ok(String::from_utf8(output.stdout)?.trim().to_string())
    }

    // ---- Unit tests ----

    #[test]
    fn test_image_entry_json_round_trip() -> Result<()> {
        let entry = ImageEntry {
            image_id: "im-abc123".to_string(),
        };
        let json = serde_json::to_string(&entry)?;
        let parsed: ImageEntry = serde_json::from_str(&json)?;
        assert_eq!(parsed.image_id, entry.image_id);
        Ok(())
    }

    #[test]
    fn test_canonicalize_config_path() -> Result<()> {
        let repo = PathBuf::from("/home/user/project");

        // Strips ./
        assert_eq!(
            canonicalize_config_path("./offload.toml", &repo)?,
            "offload.toml"
        );

        // Already clean
        assert_eq!(
            canonicalize_config_path("offload.toml", &repo)?,
            "offload.toml"
        );

        // Nested path
        assert_eq!(
            canonicalize_config_path("./configs/offload.toml", &repo)?,
            "configs/offload.toml"
        );

        // Nested without ./
        assert_eq!(
            canonicalize_config_path("configs/offload.toml", &repo)?,
            "configs/offload.toml"
        );

        // Absolute path under repo root
        assert_eq!(
            canonicalize_config_path("/home/user/project/offload.toml", &repo)?,
            "offload.toml"
        );
        Ok(())
    }

    // ---- generate_checkpoint_diff tests ----

    #[tokio::test]
    async fn test_generate_checkpoint_diff_no_changes() -> Result<()> {
        let dir = init_temp_repo()?;
        let sha = git_in(dir.path(), &["rev-parse", "HEAD"])?;

        let result = generate_checkpoint_diff(dir.path(), &sha).await?;
        assert!(
            result.is_none(),
            "expected None when working tree matches commit"
        );
        Ok(())
    }

    #[tokio::test]
    async fn test_generate_checkpoint_diff_tracked_modification() -> Result<()> {
        let dir = init_temp_repo()?;
        let sha = git_in(dir.path(), &["rev-parse", "HEAD"])?;

        // Modify a tracked file without committing
        std::fs::write(dir.path().join("README.md"), "# modified content")?;

        let result = generate_checkpoint_diff(dir.path(), &sha).await?;

        let patch_file = result.context("expected Some patch for tracked modification")?;
        let patch_content = std::fs::read_to_string(patch_file.path())?;
        assert!(!patch_content.is_empty(), "patch should be non-empty");
        assert!(
            patch_content.contains("README.md"),
            "patch should reference the modified file"
        );
        Ok(())
    }

    #[tokio::test]
    async fn test_generate_checkpoint_diff_untracked_file() -> Result<()> {
        let dir = init_temp_repo()?;
        let sha = git_in(dir.path(), &["rev-parse", "HEAD"])?;

        // Add a new untracked file (not git-added)
        std::fs::write(dir.path().join("untracked.txt"), "new file content")?;

        let result = generate_checkpoint_diff(dir.path(), &sha).await?;

        let patch_file = result.context("expected Some patch for untracked file")?;
        let patch_content = std::fs::read_to_string(patch_file.path())?;
        assert!(!patch_content.is_empty(), "patch should be non-empty");
        assert!(
            patch_content.contains("untracked.txt"),
            "patch should reference the untracked file"
        );
        Ok(())
    }

    #[tokio::test]
    async fn test_generate_checkpoint_diff_invalid_sha() -> Result<()> {
        let dir = init_temp_repo()?;

        let result =
            generate_checkpoint_diff(dir.path(), "0000000000000000000000000000000000000000").await;
        assert!(result.is_err(), "expected error for invalid SHA");
        Ok(())
    }

    // ---- Integration tests (temp git repos) ----

    #[tokio::test]
    async fn test_write_and_read_note() -> Result<()> {
        let dir = init_temp_repo()?;
        let sha = git_in(dir.path(), &["rev-parse", "HEAD"])?;

        let mut contents = NoteContents::new();
        contents.insert(
            "offload.toml".to_string(),
            ImageEntry {
                image_id: "im-test123".to_string(),
            },
        );

        write_note(dir.path(), &sha, &contents).await?;
        let read_back = read_note(dir.path(), &sha).await?;

        let note = read_back.context("expected note to exist")?;
        assert_eq!(
            note.get("offload.toml").context("missing key")?.image_id,
            "im-test123"
        );
        Ok(())
    }

    #[tokio::test]
    async fn test_write_note_merges_configs() -> Result<()> {
        let dir = init_temp_repo()?;
        let sha = git_in(dir.path(), &["rev-parse", "HEAD"])?;

        // Write first config
        let mut contents_a = NoteContents::new();
        contents_a.insert(
            "config-a.toml".to_string(),
            ImageEntry {
                image_id: "im-aaa".to_string(),
            },
        );
        write_note(dir.path(), &sha, &contents_a).await?;

        // Write second config -- should merge, not overwrite
        let mut contents_b = NoteContents::new();
        contents_b.insert(
            "config-b.toml".to_string(),
            ImageEntry {
                image_id: "im-bbb".to_string(),
            },
        );
        write_note(dir.path(), &sha, &contents_b).await?;

        let read_back = read_note(dir.path(), &sha)
            .await?
            .context("expected note")?;
        assert_eq!(read_back.len(), 2);
        assert_eq!(
            read_back
                .get("config-a.toml")
                .context("missing key a")?
                .image_id,
            "im-aaa"
        );
        assert_eq!(
            read_back
                .get("config-b.toml")
                .context("missing key b")?
                .image_id,
            "im-bbb"
        );
        Ok(())
    }

    #[tokio::test]
    async fn test_export_tree() -> Result<()> {
        let dir = init_temp_repo()?;

        // Add a file and commit
        std::fs::write(dir.path().join("hello.txt"), "world")?;
        git_in(dir.path(), &["add", "hello.txt"])?;
        git_in(dir.path(), &["commit", "-m", "add hello"])?;
        let sha = git_in(dir.path(), &["rev-parse", "HEAD"])?;

        // Add another file on top (should NOT appear in export of prior commit)
        std::fs::write(dir.path().join("extra.txt"), "nope")?;
        git_in(dir.path(), &["add", "extra.txt"])?;
        git_in(dir.path(), &["commit", "-m", "add extra"])?;

        // Export the earlier commit's tree
        let dest = tempfile::tempdir()?;
        export_tree(dir.path(), &sha, dest.path()).await?;

        // The exported tree should contain hello.txt and README.md
        assert_eq!(
            std::fs::read_to_string(dest.path().join("hello.txt"))?,
            "world"
        );
        assert!(dest.path().join("README.md").exists());
        // extra.txt should NOT be present
        assert!(!dest.path().join("extra.txt").exists());
        // Should be a git repo with the actual SHA as HEAD
        assert!(dest.path().join(".git").exists(), ".git should exist");
        let exported_head = git_in(dest.path(), &["rev-parse", "HEAD"])?;
        assert_eq!(exported_head, sha, "HEAD should match the exported SHA");
        Ok(())
    }

    #[tokio::test]
    async fn test_export_tree_copies_gitignored_not_dockerignored() -> Result<()> {
        let dir = init_temp_repo()?;

        // Add a file and commit
        std::fs::write(dir.path().join("hello.txt"), "world")?;
        git_in(dir.path(), &["add", "hello.txt"])?;
        git_in(dir.path(), &["commit", "-m", "add hello"])?;

        // Create .gitignore that ignores *.tar.gz files
        std::fs::write(dir.path().join(".gitignore"), "*.tar.gz\n")?;
        git_in(dir.path(), &["add", ".gitignore"])?;
        git_in(dir.path(), &["commit", "-m", "add gitignore"])?;

        // Create .dockerignore that ignores *.log but NOT *.tar.gz
        std::fs::write(dir.path().join(".dockerignore"), "*.log\n")?;
        git_in(dir.path(), &["add", ".dockerignore"])?;
        git_in(dir.path(), &["commit", "-m", "add dockerignore"])?;

        let sha = git_in(dir.path(), &["rev-parse", "HEAD"])?;

        // Create a gitignored file (not dockerignored) in the working tree
        std::fs::write(dir.path().join("base-commit.tar.gz"), "tarball content")?;
        // Create a dockerignored file (should NOT appear in export)
        std::fs::write(dir.path().join("debug.log"), "log content")?;

        // Export the tree
        let dest = tempfile::tempdir()?;
        export_tree(dir.path(), &sha, dest.path()).await?;

        // Tracked file from the commit should exist
        assert!(
            dest.path().join("hello.txt").exists(),
            "tracked file hello.txt should be in the export"
        );
        // Gitignored-but-not-dockerignored file should be copied
        assert!(
            dest.path().join("base-commit.tar.gz").exists(),
            "gitignored-but-not-dockerignored file should be copied"
        );
        assert_eq!(
            std::fs::read_to_string(dest.path().join("base-commit.tar.gz"))?,
            "tarball content"
        );
        // Dockerignored file should NOT be present
        assert!(
            !dest.path().join("debug.log").exists(),
            "dockerignored file should not be in the export"
        );
        // Should be a git repo
        assert!(dest.path().join(".git").exists(), ".git should exist");
        Ok(())
    }

    #[tokio::test]
    async fn test_commit_touches_paths() -> Result<()> {
        let dir = init_temp_repo()?;

        // Create a file and commit
        std::fs::write(dir.path().join("Dockerfile"), "FROM ubuntu")?;
        git_in(dir.path(), &["add", "Dockerfile"])?;
        git_in(dir.path(), &["commit", "-m", "add dockerfile"])?;
        let sha = git_in(dir.path(), &["rev-parse", "HEAD"])?;

        let touches = commit_touches_paths(dir.path(), &sha, &["Dockerfile".to_string()]).await?;
        assert!(touches);

        let no_touch =
            commit_touches_paths(dir.path(), &sha, &["nonexistent.txt".to_string()]).await?;
        assert!(!no_touch);
        Ok(())
    }

    #[tokio::test]
    async fn test_commit_touches_paths_merge_commit() -> Result<()> {
        let dir = init_temp_repo()?;

        // Capture the initial branch name
        let initial_branch = git_in(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;

        // Create branch A with file changes
        git_in(dir.path(), &["checkout", "-b", "branch-a"])?;
        std::fs::write(dir.path().join("file-a.txt"), "branch a content")?;
        git_in(dir.path(), &["add", "file-a.txt"])?;
        git_in(dir.path(), &["commit", "-m", "add file-a"])?;

        // Go back to the initial branch and create branch B
        git_in(dir.path(), &["checkout", &initial_branch])?;
        git_in(dir.path(), &["checkout", "-b", "branch-b"])?;
        std::fs::write(dir.path().join("file-b.txt"), "branch b content")?;
        git_in(dir.path(), &["add", "file-b.txt"])?;
        git_in(dir.path(), &["commit", "-m", "add file-b"])?;

        // Merge branch-a into branch-b
        git_in(dir.path(), &["merge", "branch-a", "-m", "merge"])?;
        let merge_sha = git_in(dir.path(), &["rev-parse", "HEAD"])?;

        // The merge commit should touch files from branch-a (via -m flag)
        let touches_a =
            commit_touches_paths(dir.path(), &merge_sha, &["file-a.txt".to_string()]).await?;
        assert!(touches_a);

        // And files from branch-b
        let touches_b =
            commit_touches_paths(dir.path(), &merge_sha, &["file-b.txt".to_string()]).await?;
        assert!(touches_b);
        Ok(())
    }

    #[tokio::test]
    async fn test_nearest_ancestor_touching() -> Result<()> {
        let dir = init_temp_repo()?;
        // init_temp_repo creates one commit with README.md

        // Commit 2: touches Dockerfile
        std::fs::write(dir.path().join("Dockerfile"), "FROM ubuntu")?;
        git_in(dir.path(), &["add", "Dockerfile"])?;
        git_in(dir.path(), &["commit", "-m", "add dockerfile"])?;
        let dockerfile_sha = git_in(dir.path(), &["rev-parse", "HEAD"])?;

        // Commit 3: touches only app.py
        std::fs::write(dir.path().join("app.py"), "print('hello')")?;
        git_in(dir.path(), &["add", "app.py"])?;
        git_in(dir.path(), &["commit", "-m", "add app"])?;

        // Commit 4: touches only test.py
        std::fs::write(dir.path().join("test.py"), "assert True")?;
        git_in(dir.path(), &["add", "test.py"])?;
        git_in(dir.path(), &["commit", "-m", "add test"])?;

        // Should find the Dockerfile commit (2 commits back)
        let result = nearest_ancestor_touching(dir.path(), &["Dockerfile".to_string()]).await?;
        assert_eq!(result.as_deref(), Some(dockerfile_sha.as_str()));

        // Should return None for a file never committed
        let result =
            nearest_ancestor_touching(dir.path(), &["nonexistent.txt".to_string()]).await?;
        assert!(result.is_none());

        // Should return None for empty paths
        let result = nearest_ancestor_touching(dir.path(), &[]).await?;
        assert!(result.is_none());

        Ok(())
    }

    #[tokio::test]
    async fn test_nearest_ancestor_touching_behind_merge() -> Result<()> {
        let dir = init_temp_repo()?;

        let initial_branch = git_in(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;

        // Branch A: modifies Dockerfile
        git_in(dir.path(), &["checkout", "-b", "branch-a"])?;
        std::fs::write(dir.path().join("Dockerfile"), "FROM ubuntu")?;
        git_in(dir.path(), &["add", "Dockerfile"])?;
        git_in(dir.path(), &["commit", "-m", "add dockerfile on branch-a"])?;
        let dockerfile_sha = git_in(dir.path(), &["rev-parse", "HEAD"])?;

        // Branch B from same base: unrelated change
        git_in(dir.path(), &["checkout", &initial_branch])?;
        git_in(dir.path(), &["checkout", "-b", "branch-b"])?;
        std::fs::write(dir.path().join("app.py"), "print('hello')")?;
        git_in(dir.path(), &["add", "app.py"])?;
        git_in(dir.path(), &["commit", "-m", "add app.py on branch-b"])?;

        // Merge branch-a into branch-b
        git_in(
            dir.path(),
            &["merge", "branch-a", "-m", "merge branch-a into branch-b"],
        )?;

        // One more unrelated commit on top so HEAD is NOT the merge
        std::fs::write(dir.path().join("test.py"), "assert True")?;
        git_in(dir.path(), &["add", "test.py"])?;
        git_in(dir.path(), &["commit", "-m", "add test.py"])?;

        // The Dockerfile change is behind the merge, on branch-a.
        // Git's default history simplification follows the TREESAME edge
        // through the trivial merge to find the actual branch-a commit
        // that introduced the Dockerfile.
        let result = nearest_ancestor_touching(dir.path(), &["Dockerfile".to_string()]).await?;
        assert_eq!(result.as_deref(), Some(dockerfile_sha.as_str()));

        Ok(())
    }

    #[tokio::test]
    async fn test_configure_notes_fetch_idempotent() -> Result<()> {
        let dir = init_temp_repo()?;

        // Create a bare remote repo
        let remote_dir = tempfile::tempdir()?;
        let output = std::process::Command::new("git")
            .args(["init", "--bare"])
            .current_dir(remote_dir.path())
            .output()?;
        if !output.status.success() {
            bail!("git init --bare failed");
        }

        // Add remote
        git_in(
            dir.path(),
            &[
                "remote",
                "add",
                "origin",
                &remote_dir.path().to_string_lossy(),
            ],
        )?;

        let refspec = format!("+{NOTES_REF}:{NOTES_REF}");

        // Call configure twice
        configure_notes_fetch(dir.path(), "origin").await?;
        configure_notes_fetch(dir.path(), "origin").await?;

        // Check that refspec appears exactly once
        let output = git_in(dir.path(), &["config", "--get-all", "remote.origin.fetch"])?;
        let count = output.lines().filter(|l| l.trim() == refspec).count();
        assert_eq!(count, 1, "refspec should appear exactly once");
        Ok(())
    }

    #[tokio::test]
    async fn test_read_note_missing_ref_returns_none() -> Result<()> {
        let dir = init_temp_repo()?;
        let sha = git_in(dir.path(), &["rev-parse", "HEAD"])?;

        let result = read_note(dir.path(), &sha).await?;
        assert!(result.is_none());
        Ok(())
    }

    #[tokio::test]
    async fn test_fetch_notes_missing_ref_returns_ok() -> Result<()> {
        let dir = init_temp_repo()?;

        // Create a bare remote repo (no notes ref)
        let remote_dir = tempfile::tempdir()?;
        let output = std::process::Command::new("git")
            .args(["init", "--bare"])
            .current_dir(remote_dir.path())
            .output()?;
        if !output.status.success() {
            bail!("git init --bare failed");
        }

        git_in(
            dir.path(),
            &[
                "remote",
                "add",
                "origin",
                &remote_dir.path().to_string_lossy(),
            ],
        )?;

        // fetch_notes should return Ok(()) even though remote has no notes ref
        fetch_notes(dir.path(), "origin").await?;
        Ok(())
    }
}
