atman-cli 1.12.1

atman command-line interface — AI coding agent runtime with a Turing-complete .at flow DSL
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};

use atman_runtime::git::{self, GitCli};

pub const GITIGNORE_TEMPLATE: &str = "todos.jsonl
events.jsonl
*.tmp
";

pub struct SyncEnv {
    pub memory_root: PathBuf,
}

impl SyncEnv {
    pub fn discover() -> Result<Self> {
        let cwd = std::env::current_dir().context("read current working directory")?;
        Ok(Self::from_project_root(cwd))
    }

    pub fn from_project_root(project_root: PathBuf) -> Self {
        Self {
            memory_root: project_root.join(".atman"),
        }
    }
}

pub fn ensure_git_available() -> Result<()> {
    GitCli::ensure_available()
        .map_err(|e| anyhow::anyhow!("`git` not found on PATH — install git first ({e})"))
}

pub fn init(env: &SyncEnv, remote_url: &str, branch: Option<&str>) -> Result<InitReport> {
    ensure_git_available()?;
    std::fs::create_dir_all(&env.memory_root)
        .with_context(|| format!("mkdir memory root {}", env.memory_root.display()))?;
    let cli = GitCli::at(&env.memory_root);
    let already = env.memory_root.join(".git").exists();
    let branch = branch.unwrap_or("main");
    if !already {
        cli.init(branch)
            .with_context(|| format!("git init {}", env.memory_root.display()))?;
    } else {
        cli.run(&["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")])
            .with_context(|| "set HEAD to branch")?;
    }
    let has_remote = cli.remote_exists("origin").context("list remotes")?;
    if has_remote {
        cli.remote_set_url("origin", remote_url)
            .context("reconfigure origin")?;
    } else {
        cli.remote_add("origin", remote_url)
            .context("add remote origin")?;
    }
    let fetched_existing = fetch_and_checkout_if_remote_has_branch(&cli, branch)?;
    let gitignore_path = env.memory_root.join(".gitignore");
    let wrote_gitignore = if fetched_existing || gitignore_path.exists() {
        false
    } else {
        std::fs::write(&gitignore_path, GITIGNORE_TEMPLATE)
            .with_context(|| format!("write {}", gitignore_path.display()))?;
        true
    };
    Ok(InitReport {
        already_initialised: already,
        wrote_gitignore,
        remote_url: remote_url.to_string(),
        branch: branch.to_string(),
    })
}

fn fetch_and_checkout_if_remote_has_branch(cli: &GitCli, branch: &str) -> Result<bool> {
    if cli.fetch("origin", branch).is_err() {
        return Ok(false);
    }
    if !cli
        .ref_exists(&format!("refs/remotes/origin/{branch}"))
        .context("show-ref origin/branch")?
    {
        return Ok(false);
    }
    cli.reset_hard(&format!("origin/{branch}"))
        .context("checkout origin branch")?;
    Ok(true)
}

pub fn push(env: &SyncEnv, message: Option<&str>) -> Result<PushReport> {
    ensure_git_available()?;
    ensure_repo(&env.memory_root)?;
    let cli = GitCli::at(&env.memory_root);
    let has_changes = git::has_changes(&env.memory_root)
        .with_context(|| format!("read status of {}", env.memory_root.display()))?;
    let committed = if has_changes {
        cli.add_all().context("git add")?;
        let default_msg = format!("atman sync {}", epoch_seconds());
        let msg = message.unwrap_or(default_msg.as_str());
        cli.commit(msg).context("git commit")?;
        true
    } else {
        false
    };
    let branch = git::current_branch(&env.memory_root).with_context(|| "read HEAD branch")?;
    let pushed_stderr = cli.push("origin", &branch).context("git push")?;
    Ok(PushReport {
        committed,
        branch,
        stderr_tail: last_line(&pushed_stderr),
    })
}

pub fn pull(env: &SyncEnv) -> Result<String> {
    ensure_git_available()?;
    ensure_repo(&env.memory_root)?;
    let cli = GitCli::at(&env.memory_root);
    let branch = git::current_branch(&env.memory_root).with_context(|| "read HEAD branch")?;
    cli.pull_rebase("origin", &branch).context("git pull")
}

pub fn status(env: &SyncEnv) -> Result<StatusReport> {
    ensure_git_available()?;
    if !env.memory_root.join(".git").exists() {
        return Ok(StatusReport {
            initialised: false,
            porcelain: String::new(),
            branch: None,
        });
    }
    let porcelain = git::status_porcelain(&env.memory_root)
        .with_context(|| format!("read status of {}", env.memory_root.display()))?;
    let branch = git::current_branch(&env.memory_root).ok();
    Ok(StatusReport {
        initialised: true,
        porcelain,
        branch,
    })
}

fn ensure_repo(dir: &Path) -> Result<()> {
    if !dir.join(".git").exists() {
        bail!(
            "{} is not initialised as a memory repo — run `atman sync init <url>` first",
            dir.display()
        );
    }
    Ok(())
}

fn last_line(s: &str) -> String {
    s.lines().last().unwrap_or("").to_string()
}

fn epoch_seconds() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

#[derive(Debug, Clone)]
pub struct InitReport {
    pub already_initialised: bool,
    pub wrote_gitignore: bool,
    pub remote_url: String,
    pub branch: String,
}

#[derive(Debug, Clone)]
pub struct PushReport {
    pub committed: bool,
    pub branch: String,
    pub stderr_tail: String,
}

#[derive(Debug, Clone)]
pub struct StatusReport {
    pub initialised: bool,
    pub porcelain: String,
    pub branch: Option<String>,
}

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

    #[test]
    fn gitignore_template_covers_ephemeral_paths() {
        assert!(GITIGNORE_TEMPLATE.contains("todos.jsonl"));
        assert!(GITIGNORE_TEMPLATE.contains("events.jsonl"));
    }

    #[test]
    fn discover_reads_current_working_dir() {
        let tmp = tempfile::tempdir().unwrap();
        let env = SyncEnv::from_project_root(tmp.path().to_path_buf());
        assert_eq!(env.memory_root, tmp.path().join(".atman"));
    }
}