tandem-server 0.6.0

HTTP server for Tandem engine APIs
use std::path::{Path, PathBuf};

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

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ManagedWorktreeRecord {
    pub key: String,
    pub repo_root: String,
    pub path: String,
    pub branch: String,
    pub base: String,
    pub managed: bool,
    pub task_id: Option<String>,
    pub owner_run_id: Option<String>,
    pub lease_id: Option<String>,
    pub cleanup_branch: bool,
    pub created_at_ms: u64,
    pub updated_at_ms: u64,
}

#[derive(Debug, Clone)]
pub struct ManagedWorktreeEnsureInput {
    pub repo_root: String,
    pub task_id: Option<String>,
    pub owner_run_id: Option<String>,
    pub lease_id: Option<String>,
    pub branch_hint: Option<String>,
    pub base: String,
    pub cleanup_branch: bool,
}

#[derive(Debug, Clone)]
pub struct ManagedWorktreeEnsureResult {
    pub record: ManagedWorktreeRecord,
    pub reused: bool,
}

fn slug_part(raw: Option<&str>) -> Option<String> {
    let cleaned = raw
        .unwrap_or_default()
        .trim()
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() {
                ch.to_ascii_lowercase()
            } else {
                '-'
            }
        })
        .collect::<String>();
    let collapsed = cleaned
        .split('-')
        .filter(|part| !part.is_empty())
        .collect::<Vec<_>>()
        .join("-");
    if collapsed.is_empty() {
        None
    } else {
        Some(collapsed)
    }
}

pub fn managed_worktree_slug(
    task_id: Option<&str>,
    owner_run_id: Option<&str>,
    lease_id: Option<&str>,
    branch_hint: Option<&str>,
) -> String {
    let mut parts = Vec::new();
    if let Some(task_id) = slug_part(task_id) {
        parts.push(task_id);
    }
    if let Some(owner_run_id) = slug_part(owner_run_id) {
        parts.push(owner_run_id);
    }
    if let Some(lease_id) = slug_part(lease_id) {
        parts.push(lease_id);
    }
    if parts.is_empty() {
        parts.push(
            slug_part(branch_hint)
                .filter(|value| !value.is_empty())
                .unwrap_or_else(|| "worktree".to_string()),
        );
    }
    parts.join("-")
}

pub fn managed_worktree_key(
    repo_root: &str,
    task_id: Option<&str>,
    owner_run_id: Option<&str>,
    lease_id: Option<&str>,
    path: &str,
    branch: &str,
) -> String {
    let task_id = task_id.unwrap_or("");
    let owner_run_id = owner_run_id.unwrap_or("");
    let lease_id = lease_id.unwrap_or("");
    format!("{repo_root}::{task_id}::{owner_run_id}::{lease_id}::{path}::{branch}")
}

pub fn managed_worktree_root(repo_root: &str) -> PathBuf {
    PathBuf::from(repo_root).join(".tandem").join("worktrees")
}

pub fn managed_worktree_path(repo_root: &str, slug: &str) -> PathBuf {
    managed_worktree_root(repo_root).join(slug)
}

pub fn is_within_managed_worktree_root(repo_root: &str, path: &Path) -> bool {
    path.starts_with(managed_worktree_root(repo_root))
}

pub fn resolve_git_repo_root(candidate: &str) -> Option<String> {
    let output = std::process::Command::new("git")
        .args(["-C", candidate, "rev-parse", "--show-toplevel"])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let resolved = String::from_utf8_lossy(&output.stdout).trim().to_string();
    crate::normalize_absolute_workspace_root(&resolved).ok()
}

pub async fn ensure_managed_worktree(
    state: &crate::AppState,
    input: ManagedWorktreeEnsureInput,
) -> anyhow::Result<ManagedWorktreeEnsureResult> {
    let slug = managed_worktree_slug(
        input.task_id.as_deref(),
        input.owner_run_id.as_deref(),
        input.lease_id.as_deref(),
        input.branch_hint.as_deref(),
    );
    let path = managed_worktree_path(&input.repo_root, &slug);
    let branch = format!("tandem/{slug}");
    let path_string = path.to_string_lossy().to_string();
    let key = managed_worktree_key(
        &input.repo_root,
        input.task_id.as_deref(),
        input.owner_run_id.as_deref(),
        input.lease_id.as_deref(),
        &path_string,
        &branch,
    );
    if let Some(existing) = state.managed_worktrees.read().await.get(&key).cloned() {
        if worktree_is_registered_async(input.repo_root.clone(), existing.path.clone()).await? {
            return Ok(ManagedWorktreeEnsureResult {
                record: existing,
                reused: true,
            });
        }
    }
    if let Some(parent) = path.parent() {
        tokio::fs::create_dir_all(parent).await?;
    }
    if tokio::fs::try_exists(&path).await?
        && !worktree_is_registered_async(input.repo_root.clone(), path_string.clone()).await?
    {
        anyhow::bail!("managed worktree path conflict: {path_string}");
    }
    let now = crate::now_ms();
    if worktree_is_registered_async(input.repo_root.clone(), path_string.clone()).await? {
        let record = ManagedWorktreeRecord {
            key: key.clone(),
            repo_root: input.repo_root.clone(),
            path: path_string,
            branch,
            base: input.base,
            managed: true,
            task_id: input.task_id,
            owner_run_id: input.owner_run_id,
            lease_id: input.lease_id,
            cleanup_branch: input.cleanup_branch,
            created_at_ms: now,
            updated_at_ms: now,
        };
        state
            .managed_worktrees
            .write()
            .await
            .insert(key, record.clone());
        return Ok(ManagedWorktreeEnsureResult {
            record,
            reused: true,
        });
    }
    if input.base.trim_start().starts_with('-') {
        anyhow::bail!("git worktree base ref cannot start with '-'");
    }
    add_git_worktree_async(
        input.repo_root.clone(),
        branch.clone(),
        path.clone(),
        input.base.clone(),
    )
    .await?;
    let record = ManagedWorktreeRecord {
        key: key.clone(),
        repo_root: input.repo_root,
        path: path.to_string_lossy().to_string(),
        branch,
        base: input.base,
        managed: true,
        task_id: input.task_id,
        owner_run_id: input.owner_run_id,
        lease_id: input.lease_id,
        cleanup_branch: input.cleanup_branch,
        created_at_ms: now,
        updated_at_ms: now,
    };
    state
        .managed_worktrees
        .write()
        .await
        .insert(key, record.clone());
    Ok(ManagedWorktreeEnsureResult {
        record,
        reused: false,
    })
}

pub async fn delete_managed_worktree(
    state: &crate::AppState,
    record: &ManagedWorktreeRecord,
) -> anyhow::Result<()> {
    remove_git_worktree_async(record.repo_root.clone(), record.path.clone()).await?;
    if record.cleanup_branch {
        let _ = delete_git_branch_async(record.repo_root.clone(), record.branch.clone()).await;
    }
    state
        .managed_worktrees
        .write()
        .await
        .retain(|_, row| !(row.repo_root == record.repo_root && row.path == record.path));
    Ok(())
}

fn worktree_is_registered(repo_root: &str, path: &str) -> anyhow::Result<bool> {
    let output = std::process::Command::new("git")
        .args(["-C", repo_root, "worktree", "list", "--porcelain"])
        .output()?;
    if !output.status.success() {
        return Ok(false);
    }
    let needle = PathBuf::from(path);
    for line in String::from_utf8_lossy(&output.stdout).lines() {
        if let Some(value) = line.strip_prefix("worktree ") {
            if PathBuf::from(value) == needle {
                return Ok(true);
            }
        }
    }
    Ok(false)
}

async fn worktree_is_registered_async(repo_root: String, path: String) -> anyhow::Result<bool> {
    tokio::task::spawn_blocking(move || worktree_is_registered(&repo_root, &path))
        .await
        .context("git worktree list task failed")?
}

async fn add_git_worktree_async(
    repo_root: String,
    branch: String,
    path: PathBuf,
    base: String,
) -> anyhow::Result<()> {
    tokio::task::spawn_blocking(move || {
        let output = std::process::Command::new("git")
            .args([
                "-C",
                &repo_root,
                "worktree",
                "add",
                "-b",
                &branch,
                &path.to_string_lossy(),
                "--",
                &base,
            ])
            .output()?;
        if !output.status.success() {
            anyhow::bail!(
                "git worktree add failed: {}",
                String::from_utf8_lossy(&output.stderr).trim()
            );
        }
        Ok(())
    })
    .await
    .context("git worktree add task failed")?
}

async fn remove_git_worktree_async(repo_root: String, path: String) -> anyhow::Result<()> {
    tokio::task::spawn_blocking(move || {
        let output = std::process::Command::new("git")
            .args(["-C", &repo_root, "worktree", "remove", "--force", &path])
            .output()?;
        if !output.status.success() {
            anyhow::bail!(
                "git worktree remove failed: {}",
                String::from_utf8_lossy(&output.stderr).trim()
            );
        }
        Ok(())
    })
    .await
    .context("git worktree remove task failed")?
}

async fn delete_git_branch_async(repo_root: String, branch: String) -> anyhow::Result<()> {
    tokio::task::spawn_blocking(move || {
        std::process::Command::new("git")
            .args(["-C", &repo_root, "branch", "-D", &branch])
            .output()
            .map(|_| ())
            .map_err(Into::into)
    })
    .await
    .context("git branch delete task failed")?
}