clync 0.4.0

Encrypted sync for Claude Code across machines
use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};

use super::{LocalFs, Store};

pub struct GitStore {
    fs: LocalFs,
}

impl GitStore {
    pub fn new(path: PathBuf) -> Self {
        Self {
            fs: LocalFs::new(path),
        }
    }

    #[allow(dead_code)]
    pub fn root(&self) -> &Path {
        self.fs.root()
    }

    pub fn init_repo(repo_path: &Path) -> Result<Self> {
        std::fs::create_dir_all(repo_path)?;
        if !repo_path.join(".git").exists() {
            let status = std::process::Command::new("git")
                .args(["init", "-b", "main"])
                .current_dir(repo_path)
                .status()
                .context("git init failed")?;
            if !status.success() {
                bail!("git init failed");
            }
            std::fs::write(repo_path.join(".gitignore"), ".clync.lock\n")?;
        }
        Ok(Self::new(repo_path.to_path_buf()))
    }

    pub fn clone_repo(url: &str, dest: &Path) -> Result<Self> {
        let status = std::process::Command::new("git")
            .args(["clone", url, &dest.to_string_lossy()])
            .status()
            .context("git clone failed")?;
        if !status.success() {
            bail!("git clone failed");
        }
        Ok(Self::new(dest.to_path_buf()))
    }

    pub fn has_remote(&self) -> bool {
        std::process::Command::new("git")
            .args(["remote"])
            .current_dir(self.fs.root())
            .output()
            .map(|o| !o.stdout.is_empty())
            .unwrap_or(false)
    }

    pub fn get_remote_url(&self) -> Option<String> {
        std::process::Command::new("git")
            .args(["remote", "get-url", "origin"])
            .current_dir(self.fs.root())
            .output()
            .ok()
            .filter(|o| o.status.success())
            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
    }

    pub fn add_remote(&self, url: &str) -> Result<()> {
        self.run_git(&["remote", "add", "origin", url])
    }

    pub fn checkout_first_branch(&self) -> Result<()> {
        let branches = std::process::Command::new("git")
            .args(["branch", "-r"])
            .current_dir(self.fs.root())
            .output()
            .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
            .unwrap_or_default();
        for line in branches.lines() {
            let branch = line.trim().trim_start_matches("origin/");
            if branch == "HEAD" || branch.contains("->") {
                continue;
            }
            let _ = std::process::Command::new("git")
                .args(["checkout", branch])
                .current_dir(self.fs.root())
                .status();
            break;
        }
        Ok(())
    }

    pub fn commit(&self, message: &str) -> Result<()> {
        self.run_git(&["add", "-A"])?;
        let status = std::process::Command::new("git")
            .args(["diff", "--cached", "--quiet"])
            .current_dir(self.fs.root())
            .status()?;
        if status.success() {
            return Ok(());
        }
        self.run_git(&["commit", "-m", message])
    }

    pub fn push_remote(&self) -> Result<()> {
        if !self.has_remote() {
            return Ok(());
        }
        let result = self.run_git(&["push"]);
        if result.is_err() {
            let branch = std::process::Command::new("git")
                .args(["branch", "--show-current"])
                .current_dir(self.fs.root())
                .output()
                .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
                .unwrap_or_else(|_| "main".into());
            self.run_git(&["push", "--set-upstream", "origin", &branch])?;
        }
        Ok(())
    }

    pub fn pull_remote(&self) -> Result<()> {
        if !self.has_remote() {
            return Ok(());
        }
        let output = std::process::Command::new("git")
            .args(["pull", "--ff-only"])
            .current_dir(self.fs.root())
            .output()
            .context("git pull failed")?;
        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            if stderr.contains("Not possible to fast-forward") || stderr.contains("divergent") {
                bail!(
                    "git pull failed: repos have diverged. \
                     Try: cd {} && git pull --rebase",
                    self.fs.root().display()
                );
            }
            bail!("git pull --ff-only failed: {}", stderr.trim());
        }
        Ok(())
    }

    fn run_git(&self, args: &[&str]) -> Result<()> {
        let status = std::process::Command::new("git")
            .args(args)
            .current_dir(self.fs.root())
            .status()
            .with_context(|| format!("git {} failed", args.join(" ")))?;
        if !status.success() {
            bail!("git {} exited with {}", args.join(" "), status);
        }
        Ok(())
    }
}

impl Store for GitStore {
    fn write_file(&self, rel_path: &str, data: &[u8]) -> Result<()> {
        self.fs.write_file(rel_path, data)
    }

    fn read_file(&self, rel_path: &str) -> Result<Vec<u8>> {
        self.fs.read_file(rel_path)
    }

    fn exists(&self, rel_path: &str) -> bool {
        self.fs.exists(rel_path)
    }

    fn list_files(&self, prefix: &str) -> Result<Vec<String>> {
        self.fs.list_files(prefix)
    }

    fn file_size(&self, rel_path: &str) -> Result<u64> {
        self.fs.file_size(rel_path)
    }

    fn atomic_write(&self, rel_path: &str, data: &[u8]) -> Result<()> {
        self.fs.atomic_write(rel_path, data)
    }

    fn sync_down(&self) -> Result<()> {
        self.pull_remote()
    }

    fn sync_up(&self, message: &str) -> Result<()> {
        self.commit(message)?;
        self.push_remote()
    }

    fn lock(&self) -> Result<Box<dyn std::any::Any>> {
        self.fs.lock()
    }

    fn try_lock(&self) -> Result<Box<dyn std::any::Any>> {
        self.fs.try_lock()
    }

    fn local_path(&self) -> Option<&Path> {
        Some(self.fs.root())
    }
}

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

    fn temp_dir(name: &str) -> PathBuf {
        let dir = std::env::temp_dir()
            .join("clync-git-store-test")
            .join(name)
            .join(format!("{}", std::process::id()));
        if dir.exists() {
            std::fs::remove_dir_all(&dir).ok();
        }
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn init_repo_creates_git_dir() {
        let dir = temp_dir("init_repo");
        let repo_path = dir.join("repo");
        let store = GitStore::init_repo(&repo_path).unwrap();
        assert!(repo_path.join(".git").exists());
        assert!(repo_path.join(".gitignore").exists());
        assert_eq!(store.root(), repo_path);
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn init_repo_idempotent() {
        let dir = temp_dir("init_idem");
        let repo_path = dir.join("repo");
        GitStore::init_repo(&repo_path).unwrap();
        GitStore::init_repo(&repo_path).unwrap();
        assert!(repo_path.join(".git").exists());
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn write_read_exists() {
        let dir = temp_dir("write_read");
        let store = GitStore::init_repo(&dir.join("repo")).unwrap();
        store.write_file("test.txt", b"hello").unwrap();
        assert!(store.exists("test.txt"));
        assert!(!store.exists("missing.txt"));
        let data = store.read_file("test.txt").unwrap();
        assert_eq!(data, b"hello");
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn write_creates_subdirs() {
        let dir = temp_dir("subdirs");
        let store = GitStore::init_repo(&dir.join("repo")).unwrap();
        store.write_file("a/b/c.txt", b"nested").unwrap();
        assert!(store.exists("a/b/c.txt"));
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn list_files_works() {
        let dir = temp_dir("list_files");
        let store = GitStore::init_repo(&dir.join("repo")).unwrap();
        store.write_file("sessions/a.txt", b"a").unwrap();
        store.write_file("sessions/b.txt", b"b").unwrap();
        let files = store.list_files("sessions").unwrap();
        assert_eq!(files.len(), 2);
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn list_files_empty() {
        let dir = temp_dir("list_empty");
        let store = GitStore::init_repo(&dir.join("repo")).unwrap();
        let files = store.list_files("nonexistent").unwrap();
        assert!(files.is_empty());
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn has_remote_false_for_new_repo() {
        let dir = temp_dir("no_remote");
        let store = GitStore::init_repo(&dir.join("repo")).unwrap();
        assert!(!store.has_remote());
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn get_remote_url_none_for_new_repo() {
        let dir = temp_dir("no_remote_url");
        let store = GitStore::init_repo(&dir.join("repo")).unwrap();
        assert!(store.get_remote_url().is_none());
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn add_remote_and_check() {
        let dir = temp_dir("add_remote");
        let bare = dir.join("bare.git");
        std::process::Command::new("git")
            .args(["init", "--bare", "-b", "main"])
            .arg(&bare)
            .output()
            .unwrap();
        let store = GitStore::init_repo(&dir.join("repo")).unwrap();
        store.add_remote(bare.to_str().unwrap()).unwrap();
        assert!(store.has_remote());
        assert!(store.get_remote_url().is_some());
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn lock_and_try_lock() {
        let dir = temp_dir("lock");
        let store = GitStore::init_repo(&dir.join("repo")).unwrap();
        let _lock = store.lock().unwrap();
        std::fs::remove_dir_all(&dir).ok();
    }

    fn set_git_test_author(repo: &Path) {
        for args in [
            &["config", "user.email", "test@clync"][..],
            &["config", "user.name", "test"],
            &["config", "commit.gpgsign", "false"],
        ] {
            std::process::Command::new("git")
                .args(args)
                .current_dir(repo)
                .output()
                .unwrap();
        }
    }

    #[test]
    fn commit_with_changes() {
        let dir = temp_dir("commit");
        let store = GitStore::init_repo(&dir.join("repo")).unwrap();
        set_git_test_author(store.root());
        store.write_file("file.txt", b"content").unwrap();
        store.commit("test commit").unwrap();

        let log = std::process::Command::new("git")
            .args(["log", "--oneline"])
            .current_dir(store.root())
            .output()
            .unwrap();
        let stdout = String::from_utf8_lossy(&log.stdout);
        assert!(stdout.contains("test commit"), "log: {stdout}");
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn commit_no_changes_is_noop() {
        let dir = temp_dir("commit_noop");
        let store = GitStore::init_repo(&dir.join("repo")).unwrap();
        set_git_test_author(store.root());
        store.write_file("file.txt", b"content").unwrap();
        store.commit("first").unwrap();
        store.commit("second should be noop").unwrap();
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn pull_remote_no_remote_is_ok() {
        let dir = temp_dir("pull_no_remote");
        let store = GitStore::init_repo(&dir.join("repo")).unwrap();
        store.pull_remote().unwrap();
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn push_remote_no_remote_is_ok() {
        let dir = temp_dir("push_no_remote");
        let store = GitStore::init_repo(&dir.join("repo")).unwrap();
        store.push_remote().unwrap();
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn file_size_works() {
        let dir = temp_dir("file_size");
        let store = GitStore::init_repo(&dir.join("repo")).unwrap();
        store.write_file("test.txt", b"hello").unwrap();
        assert_eq!(store.file_size("test.txt").unwrap(), 5);
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn atomic_write_works() {
        let dir = temp_dir("atomic_write");
        let store = GitStore::init_repo(&dir.join("repo")).unwrap();
        store.atomic_write("test.txt", b"atomic").unwrap();
        assert_eq!(store.read_file("test.txt").unwrap(), b"atomic");
        assert!(!store.exists("test.txt.tmp"));
        std::fs::remove_dir_all(&dir).ok();
    }
}