agentic-outer-dag-bin 0.1.1

External outer-DAG driver for worktree→PR→CodeRabbit loops
use anyhow::Context;
use anyhow::Result;
use git2::Repository;
use gwt_worktree::worktree::is_worktree_dirty;
use std::process::Command;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FreshnessOutcome {
    UpToDate,
    Rebases { old_head: String, new_head: String },
    Conflict,
    DirtyTree,
}

pub fn run(base_ref: &str, dry_run: bool) -> Result<FreshnessOutcome> {
    if is_dirty()? {
        return Ok(FreshnessOutcome::DirtyTree);
    }

    if dry_run {
        return Ok(FreshnessOutcome::UpToDate);
    }

    git(["fetch", "origin", "--prune"])?;

    if !needs_rebase(base_ref)? {
        return Ok(FreshnessOutcome::UpToDate);
    }

    let old_head = rev_parse("HEAD")?;
    let status = git_status(["rebase", base_ref])?;
    if !status.success() {
        git(["rebase", "--abort"])?;
        return Ok(FreshnessOutcome::Conflict);
    }
    let new_head = rev_parse("HEAD")?;

    Ok(FreshnessOutcome::Rebases { old_head, new_head })
}

fn is_dirty() -> Result<bool> {
    let repo =
        Repository::discover(".").context("failed to discover repository for freshness check")?;
    is_worktree_dirty(&repo).context("failed to inspect worktree dirtiness")
}

fn needs_rebase(base_ref: &str) -> Result<bool> {
    let output = Command::new("git")
        .args(["merge-base", "--is-ancestor", base_ref, "HEAD"])
        .output()
        .with_context(|| format!("failed to run git merge-base --is-ancestor {base_ref} HEAD"))?;

    match output.status.code() {
        Some(0) => Ok(false),
        Some(1) => Ok(true),
        Some(code) => anyhow::bail!(
            "git merge-base --is-ancestor {base_ref} HEAD failed (exit={code}): {}",
            String::from_utf8_lossy(&output.stderr).trim()
        ),
        None => anyhow::bail!(
            "git merge-base --is-ancestor {base_ref} HEAD terminated unexpectedly: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        ),
    }
}

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

    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

fn git<const N: usize>(args: [&str; N]) -> Result<()> {
    let status = git_status(args)?;
    if status.success() {
        Ok(())
    } else {
        anyhow::bail!("git command failed: git {}", args.join(" "))
    }
}

fn git_status<const N: usize>(args: [&str; N]) -> Result<std::process::ExitStatus> {
    Command::new("git")
        .args(args)
        .status()
        .with_context(|| format!("failed to run git {}", args.join(" ")))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::CwdGuard;
    use crate::test_support::process_state_lock;
    use std::fs;
    use std::path::Path;
    use std::path::PathBuf;
    use tempfile::TempDir;

    #[test]
    fn returns_up_to_date_when_branch_is_current() {
        let _guard = process_state_lock().lock().unwrap();
        let fixture = GitFixture::new().unwrap();
        let _cwd = CwdGuard::pushd(&fixture.feature_clone).unwrap();

        let outcome = run("origin/main", false).unwrap();
        assert_eq!(outcome, FreshnessOutcome::UpToDate);
    }

    #[test]
    fn rebases_when_origin_main_moves_forward() {
        let _guard = process_state_lock().lock().unwrap();
        let fixture = GitFixture::new().unwrap();
        fixture.advance_main("main update\n").unwrap();
        let _cwd = CwdGuard::pushd(&fixture.feature_clone).unwrap();

        let outcome = run("origin/main", false).unwrap();
        match outcome {
            FreshnessOutcome::Rebases { old_head, new_head } => assert_ne!(old_head, new_head),
            other => panic!("expected rebase outcome, got {other:?}"),
        }
    }

    #[test]
    fn dry_run_returns_up_to_date_when_origin_main_moves_forward() {
        let _guard = process_state_lock().lock().unwrap();
        let fixture = GitFixture::new().unwrap();
        fixture.advance_main("main update\n").unwrap();
        let _cwd = CwdGuard::pushd(&fixture.feature_clone).unwrap();

        let outcome = run("origin/main", true).unwrap();
        assert_eq!(outcome, FreshnessOutcome::UpToDate);
    }

    #[test]
    fn dirty_tree_blocks_freshness_even_in_dry_run() {
        let _guard = process_state_lock().lock().unwrap();
        let fixture = GitFixture::new().unwrap();
        let _cwd = CwdGuard::pushd(&fixture.feature_clone).unwrap();
        fixture.write_shared_file("uncommitted change\n").unwrap();

        let outcome = run("origin/main", false).unwrap();
        let dry_run_outcome = run("origin/main", true).unwrap();

        assert_eq!(outcome, FreshnessOutcome::DirtyTree);
        assert_eq!(dry_run_outcome, FreshnessOutcome::DirtyTree);
    }

    #[test]
    fn returns_conflict_when_rebase_hits_merge_conflict() {
        let _guard = process_state_lock().lock().unwrap();
        let fixture = GitFixture::new().unwrap();
        fixture.write_shared_file("feature change\n").unwrap();
        fixture.commit_feature("feature change").unwrap();
        fixture.advance_main("main change\n").unwrap();
        let _cwd = CwdGuard::pushd(&fixture.feature_clone).unwrap();

        let outcome = run("origin/main", false).unwrap();

        assert!(!fixture.rebase_in_progress().unwrap());
        let rerun_outcome = run("origin/main", false).unwrap();

        assert_eq!(outcome, FreshnessOutcome::Conflict);
        assert_eq!(rerun_outcome, FreshnessOutcome::Conflict);
    }

    #[test]
    fn invalid_base_ref_returns_err() {
        let _guard = process_state_lock().lock().unwrap();
        let fixture = GitFixture::new().unwrap();
        let _cwd = CwdGuard::pushd(&fixture.feature_clone).unwrap();

        let err = run("origin/does-not-exist", false).expect_err("invalid base ref should fail");
        let text = err.to_string();
        assert!(text.contains("merge-base --is-ancestor origin/does-not-exist HEAD failed"));
        assert!(text.contains("Not a valid object name origin/does-not-exist"));
    }

    struct GitFixture {
        _temp: TempDir,
        main_clone: PathBuf,
        feature_clone: PathBuf,
    }

    impl GitFixture {
        fn new() -> Result<Self> {
            let temp = TempDir::new()?;
            let origin = temp.path().join("origin.git");
            let main_clone = temp.path().join("main");
            let feature_clone = temp.path().join("feature");

            run_git(temp.path(), ["init", "--bare", origin.to_str().unwrap()])?;
            run_git(&origin, ["symbolic-ref", "HEAD", "refs/heads/main"])?;
            run_git(
                temp.path(),
                [
                    "clone",
                    origin.to_str().unwrap(),
                    main_clone.to_str().unwrap(),
                ],
            )?;
            configure_repo(&main_clone)?;
            run_git(&main_clone, ["checkout", "-b", "main"])?;
            fs::write(main_clone.join("shared.txt"), "base\n")?;
            run_git(&main_clone, ["add", "shared.txt"])?;
            run_git(&main_clone, ["commit", "-m", "initial"])?;
            run_git(&main_clone, ["push", "-u", "origin", "main"])?;

            run_git(
                temp.path(),
                [
                    "clone",
                    origin.to_str().unwrap(),
                    feature_clone.to_str().unwrap(),
                ],
            )?;
            configure_repo(&feature_clone)?;
            run_git(&feature_clone, ["checkout", "-b", "feature/test"])?;

            Ok(Self {
                _temp: temp,
                main_clone,
                feature_clone,
            })
        }

        fn advance_main(&self, contents: &str) -> Result<()> {
            fs::write(self.main_clone.join("shared.txt"), contents)?;
            run_git(&self.main_clone, ["add", "shared.txt"])?;
            run_git(&self.main_clone, ["commit", "-m", "main update"])?;
            run_git(&self.main_clone, ["push", "origin", "main"])?;
            Ok(())
        }

        fn write_shared_file(&self, contents: &str) -> Result<()> {
            fs::write(self.feature_clone.join("shared.txt"), contents)?;
            Ok(())
        }

        fn commit_feature(&self, message: &str) -> Result<()> {
            run_git(&self.feature_clone, ["add", "shared.txt"])?;
            run_git(&self.feature_clone, ["commit", "-m", message])?;
            Ok(())
        }

        fn rebase_in_progress(&self) -> Result<bool> {
            let git_dir = git_output(&self.feature_clone, ["rev-parse", "--git-dir"])?;
            let git_dir = self.feature_clone.join(git_dir);
            Ok(git_dir.join("rebase-apply").exists() || git_dir.join("rebase-merge").exists())
        }
    }

    fn configure_repo(path: &Path) -> Result<()> {
        run_git(path, ["config", "user.name", "Test User"])?;
        run_git(path, ["config", "user.email", "test@example.com"])?;
        Ok(())
    }

    fn run_git<const N: usize>(cwd: &Path, args: [&str; N]) -> Result<()> {
        let output = Command::new("git").current_dir(cwd).args(args).output()?;
        if output.status.success() {
            Ok(())
        } else {
            anyhow::bail!(
                "git {} failed: {}",
                args.join(" "),
                String::from_utf8_lossy(&output.stderr).trim()
            )
        }
    }

    fn git_output<const N: usize>(cwd: &Path, args: [&str; N]) -> Result<String> {
        let output = Command::new("git").current_dir(cwd).args(args).output()?;
        if output.status.success() {
            Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
        } else {
            anyhow::bail!(
                "git {} failed: {}",
                args.join(" "),
                String::from_utf8_lossy(&output.stderr).trim()
            )
        }
    }
}