use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::core::repo::RepoInfo;
use crate::git::{get_current_branch, open_repo, GitError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncSnapshot {
pub timestamp: DateTime<Utc>,
pub repos: Vec<RepoSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoSnapshot {
pub name: String,
pub path: PathBuf,
pub head_commit: String,
pub branch: String,
}
const STATE_FILE: &str = ".gitgrip/sync-state.json";
impl SyncSnapshot {
pub fn capture(_workspace_root: &Path, repos: &[RepoInfo]) -> Result<Self, GitError> {
let mut snapshots = Vec::new();
for repo in repos {
if !repo.absolute_path.exists() {
continue;
}
let git_repo = match open_repo(&repo.absolute_path) {
Ok(r) => r,
Err(_) => continue, };
let branch = get_current_branch(&git_repo).unwrap_or_default();
let head = git_repo
.head()
.ok()
.and_then(|h| h.target())
.map(|oid| oid.to_string())
.unwrap_or_default();
if head.is_empty() {
continue; }
snapshots.push(RepoSnapshot {
name: repo.name.clone(),
path: repo.absolute_path.clone(),
branch,
head_commit: head,
});
}
Ok(Self {
timestamp: Utc::now(),
repos: snapshots,
})
}
pub fn save(&self, workspace_root: &Path) -> Result<(), GitError> {
let path = workspace_root.join(STATE_FILE);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(GitError::Io)?;
}
let json = serde_json::to_string_pretty(self)
.map_err(|e| GitError::OperationFailed(format!("serialize sync state: {}", e)))?;
std::fs::write(&path, json).map_err(GitError::Io)?;
Ok(())
}
pub fn load_latest(workspace_root: &Path) -> Result<Option<Self>, GitError> {
let path = workspace_root.join(STATE_FILE);
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(&path).map_err(GitError::Io)?;
let snapshot: Self = serde_json::from_str(&contents)
.map_err(|e| GitError::OperationFailed(format!("parse sync state: {}", e)))?;
Ok(Some(snapshot))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn setup_repo(dir: &Path) {
Command::new("git")
.args(["init", "-b", "main"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(dir)
.output()
.unwrap();
fs::write(dir.join("README.md"), "# Test").unwrap();
Command::new("git")
.args(["add", "README.md"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(dir)
.output()
.unwrap();
}
fn head_sha(dir: &Path) -> String {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.unwrap();
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
#[test]
fn test_capture_and_save_load() {
let workspace = TempDir::new().unwrap();
let repo_dir = workspace.path().join("repo-a");
fs::create_dir_all(&repo_dir).unwrap();
setup_repo(&repo_dir);
let repos = vec![RepoInfo {
name: "repo-a".to_string(),
url: "https://github.com/test/repo-a.git".to_string(),
path: "./repo-a".to_string(),
absolute_path: repo_dir.clone(),
revision: "main".to_string(),
target: "main".to_string(),
sync_remote: "origin".to_string(),
push_remote: "origin".to_string(),
owner: "test".to_string(),
repo: "repo-a".to_string(),
platform_type: crate::core::manifest::PlatformType::GitHub,
platform_base_url: None,
project: None,
reference: false,
groups: vec![],
agent: None,
clone_strategy: crate::core::manifest::CloneStrategy::Clone,
}];
let snapshot = SyncSnapshot::capture(workspace.path(), &repos).unwrap();
assert_eq!(snapshot.repos.len(), 1);
assert_eq!(snapshot.repos[0].name, "repo-a");
assert_eq!(snapshot.repos[0].branch, "main");
assert_eq!(snapshot.repos[0].head_commit, head_sha(&repo_dir));
snapshot.save(workspace.path()).unwrap();
let loaded = SyncSnapshot::load_latest(workspace.path())
.unwrap()
.unwrap();
assert_eq!(loaded.repos.len(), 1);
assert_eq!(loaded.repos[0].head_commit, snapshot.repos[0].head_commit);
}
#[test]
fn test_load_latest_no_file() {
let workspace = TempDir::new().unwrap();
let result = SyncSnapshot::load_latest(workspace.path()).unwrap();
assert!(result.is_none());
}
#[test]
fn test_capture_skips_missing_repos() {
let workspace = TempDir::new().unwrap();
let repos = vec![RepoInfo {
name: "missing".to_string(),
url: "https://github.com/test/missing.git".to_string(),
path: "./missing".to_string(),
absolute_path: workspace.path().join("missing"),
revision: "main".to_string(),
target: "main".to_string(),
sync_remote: "origin".to_string(),
push_remote: "origin".to_string(),
owner: "test".to_string(),
repo: "missing".to_string(),
platform_type: crate::core::manifest::PlatformType::GitHub,
platform_base_url: None,
project: None,
reference: false,
groups: vec![],
agent: None,
clone_strategy: crate::core::manifest::CloneStrategy::Clone,
}];
let snapshot = SyncSnapshot::capture(workspace.path(), &repos).unwrap();
assert!(snapshot.repos.is_empty());
}
}