car-server-core 0.25.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! Merge-back: deliver the worktree's changes to the repository.
//!
//! Two destinations, by session kind:
//! - **Raw repo** ([`publish_branch`]): the worktree gets one squash commit
//!   (authored as `car-coder` — honest attribution) and the only thing that
//!   touches the user's repository is `git branch car/coder/<id> <commit>`.
//!   Branches are shared across worktrees, so this never disturbs the user's
//!   checkout, index, or any existing ref; reverting is `git branch -D`.
//! - **Managed project** ([`commit_to_main`]): the project is fully CAR-owned,
//!   so there is no separate "user working tree" to protect — approve commits
//!   straight to the project's `main` (fast-forward only). The non-dev sees
//!   "Saved", not a branch to merge; reverting is ordinary git history.

use std::path::Path;

use super::contract::OutcomeContract;

/// Stage all worktree changes and create the CAR-Coder commit. Returns the new
/// commit SHA. Errors when the worktree has nothing to commit. Shared by both
/// delivery paths.
fn commit_worktree(
    worktree: &Path,
    intent: &str,
    contract: &OutcomeContract,
) -> Result<String, String> {
    let status = git(worktree, &["status", "--porcelain"])?;
    if status.trim().is_empty() {
        return Err("no changes to deliver — the worktree is clean".to_string());
    }
    git(worktree, &["add", "-A"])?;

    let subject: String = {
        let s = intent.trim().replace('\n', " ");
        if s.len() > 72 {
            let mut end = 69;
            while !s.is_char_boundary(end) {
                end -= 1;
            }
            format!("{}...", &s[..end])
        } else {
            s
        }
    };
    let body = format!(
        "Authored by CAR Coder.\n\nIntent:\n{}\n\nOutcome contract (all checks passed):\n{}",
        intent.trim(),
        contract.render()
    );
    git(
        worktree,
        &[
            "-c",
            "user.name=car-coder",
            "-c",
            "user.email=coder@parslee.ai",
            "commit",
            "-m",
            &subject,
            "-m",
            &body,
        ],
    )?;
    Ok(git(worktree, &["rev-parse", "HEAD"])?.trim().to_string())
}

/// Result of a publish: the branch name to merge from.
pub fn publish_branch(
    repo: &Path,
    worktree: &Path,
    short_id: &str,
    intent: &str,
    contract: &OutcomeContract,
) -> Result<String, String> {
    let commit = commit_worktree(worktree, intent, contract)?;
    let branch = format!("car/coder/{short_id}");
    // `git branch` (no checkout) in the original repo: refs are shared with
    // the worktree, so this is pure bookkeeping — no working-tree effects.
    git(repo, &["branch", &branch, &commit])?;
    Ok(branch)
}

/// Deliver to a managed project's `main`. The worktree was checked out detached
/// at `main`'s tip, so its commit is a direct descendant — a fast-forward
/// updates both the `main` ref and the project's checkout. **ff-only**: if
/// `main` moved since the session started (something committed underneath us),
/// this errors instead of rebasing or forcing, preserving the guarantee that
/// the diff the user approved is exactly what lands. Returns the commit SHA.
pub fn commit_to_main(
    repo: &Path,
    worktree: &Path,
    intent: &str,
    contract: &OutcomeContract,
) -> Result<String, String> {
    let commit = commit_worktree(worktree, intent, contract)?;
    git(repo, &["merge", "--ff-only", &commit]).map_err(|e| {
        format!("could not fast-forward the project's main branch (it moved since the session started): {e}")
    })?;
    Ok(commit)
}

/// Stage everything and return `(diff --stat, truncated patch)` for the
/// approval UI. Staging is what `publish_branch` would commit anyway, and it
/// makes untracked files visible in the diff.
pub fn stage_and_diff(worktree: &Path) -> Result<(String, String), String> {
    git(worktree, &["add", "-A"])?;
    let stat = git(worktree, &["diff", "--cached", "--stat"])?;
    let patch = git(worktree, &["diff", "--cached"])?;
    Ok((stat, super::shell_tool::tail(&patch, 32 * 1024)))
}

pub(crate) fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
    let out = std::process::Command::new("git")
        .arg("-C")
        .arg(dir)
        .args(args)
        .output()
        .map_err(|e| format!("git {args:?}: {e}"))?;
    if out.status.success() {
        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
    } else {
        Err(format!(
            "git {args:?} failed: {}",
            String::from_utf8_lossy(&out.stderr).trim()
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::coder::contract::ContractCheck;

    fn contract() -> OutcomeContract {
        OutcomeContract {
            description: "x exists".into(),
            checks: vec![ContractCheck {
                name: "exists".into(),
                command: "test -f x.txt".into(),
                expect_exit_zero: true,
                output_contains: None,
                timeout_secs: 10,
            }],
        }
    }

    fn init_repo(dir: &Path) {
        for args in [
            vec!["init", "-q", "-b", "main"],
            vec!["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "init"],
        ] {
            let out = std::process::Command::new("git")
                .arg("-C")
                .arg(dir)
                .args(&args)
                .output()
                .unwrap();
            assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
        }
    }

    #[test]
    fn publishes_branch_without_touching_user_checkout() {
        let repo_dir = tempfile::tempdir().unwrap();
        let repo = repo_dir.path();
        init_repo(repo);

        // Provision a worktree the way a session does.
        let wt_base = tempfile::tempdir().unwrap();
        let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
        let ws = car_multi::AgentWorkspace::provision(&config, "coder-merge-test").unwrap();

        std::fs::write(ws.path().join("x.txt"), "made by coder").unwrap();
        let branch =
            publish_branch(repo, ws.path(), "abc12345", "create x.txt with content", &contract())
                .unwrap();
        assert_eq!(branch, "car/coder/abc12345");

        // The branch exists in the user's repo and contains the file…
        let show = git(repo, &["show", &format!("{branch}:x.txt")]).unwrap();
        assert_eq!(show, "made by coder");
        // …attributed to the coder…
        let author = git(repo, &["log", "-1", "--format=%an", &branch]).unwrap();
        assert_eq!(author.trim(), "car-coder");
        // …and the user's checkout is untouched.
        let status = git(repo, &["status", "--porcelain"]).unwrap();
        assert!(status.is_empty(), "user checkout dirtied: {status}");
        assert!(!repo.join("x.txt").exists());
    }

    #[test]
    fn clean_worktree_refuses_to_publish() {
        let repo_dir = tempfile::tempdir().unwrap();
        init_repo(repo_dir.path());
        let wt_base = tempfile::tempdir().unwrap();
        let config = car_multi::WorkspaceConfig::git_worktree_at(repo_dir.path(), wt_base.path());
        let ws = car_multi::AgentWorkspace::provision(&config, "coder-clean-test").unwrap();

        let err = publish_branch(repo_dir.path(), ws.path(), "def", "noop", &contract()).unwrap_err();
        assert!(err.contains("no changes"), "{err}");
    }

    #[test]
    fn long_intent_is_truncated_in_subject() {
        let repo_dir = tempfile::tempdir().unwrap();
        let repo = repo_dir.path();
        init_repo(repo);
        let wt_base = tempfile::tempdir().unwrap();
        let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
        let ws = car_multi::AgentWorkspace::provision(&config, "coder-long-test").unwrap();
        std::fs::write(ws.path().join("y.txt"), "y").unwrap();

        let long_intent = "a very ".repeat(40) + "long intent";
        let branch = publish_branch(repo, ws.path(), "fff", &long_intent, &contract()).unwrap();
        let subject = git(repo, &["log", "-1", "--format=%s", &branch]).unwrap();
        assert!(subject.trim().len() <= 72);
        assert!(subject.contains("..."));
    }

    #[test]
    fn commit_to_main_fast_forwards_the_checkout() {
        let repo_dir = tempfile::tempdir().unwrap();
        let repo = repo_dir.path();
        init_repo(repo);
        let wt_base = tempfile::tempdir().unwrap();
        let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
        let ws = car_multi::AgentWorkspace::provision(&config, "coder-main-test").unwrap();
        std::fs::write(ws.path().join("z.txt"), "managed").unwrap();

        let commit = commit_to_main(repo, ws.path(), "add z", &contract()).unwrap();
        // main fast-forwarded to the commit; the file is in the repo checkout.
        let head = git(repo, &["rev-parse", "HEAD"]).unwrap();
        assert_eq!(head.trim(), commit);
        assert_eq!(std::fs::read_to_string(repo.join("z.txt")).unwrap(), "managed");
        // No coder branch.
        assert!(git(repo, &["branch", "--list", "car/coder/*"]).unwrap().is_empty());
    }

    #[test]
    fn commit_to_main_errors_when_main_moved() {
        let repo_dir = tempfile::tempdir().unwrap();
        let repo = repo_dir.path();
        init_repo(repo);
        let wt_base = tempfile::tempdir().unwrap();
        let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
        let ws = car_multi::AgentWorkspace::provision(&config, "coder-moved-test").unwrap();
        std::fs::write(ws.path().join("a.txt"), "from session").unwrap();

        // Something commits to main AFTER the worktree was provisioned, so the
        // worktree's commit is no longer a fast-forward of main.
        std::fs::write(repo.join("b.txt"), "concurrent").unwrap();
        for args in [
            vec!["-c", "user.name=t", "-c", "user.email=t@t", "add", "-A"],
            vec!["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "concurrent"],
        ] {
            assert!(std::process::Command::new("git").arg("-C").arg(repo).args(&args).output().unwrap().status.success());
        }

        let err = commit_to_main(repo, ws.path(), "add a", &contract()).unwrap_err();
        assert!(err.contains("fast-forward"), "{err}");
    }
}