use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
pub const INDEX_STATE_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexState {
pub last_indexed_commit: String,
pub last_indexed_at: String,
pub schema_version: u32,
}
fn state_path(root: &Path) -> PathBuf {
root.join(".codescout").join("index-state.json")
}
fn head_commit_full(root: &Path) -> Option<String> {
let repo = git2::Repository::discover(root).ok()?;
let head = repo.head().ok()?;
let commit = head.peel_to_commit().ok()?;
Some(commit.id().to_string())
}
pub fn write_index_state(root: &Path) -> std::io::Result<()> {
let state = IndexState {
last_indexed_commit: head_commit_full(root).unwrap_or_default(),
last_indexed_at: chrono::Utc::now().to_rfc3339(),
schema_version: INDEX_STATE_SCHEMA_VERSION,
};
std::fs::create_dir_all(root.join(".codescout"))?;
let body = serde_json::to_string_pretty(&state).map_err(std::io::Error::other)?;
std::fs::write(state_path(root), body)
}
pub fn read_index_state(root: &Path) -> Option<IndexState> {
let raw = std::fs::read_to_string(state_path(root)).ok()?;
serde_json::from_str(&raw).ok()
}
pub fn git_sync_status(root: &Path) -> Option<Value> {
let state = read_index_state(root)?;
if state.last_indexed_commit.is_empty() {
return None;
}
let head = head_commit_full(root)?;
let short = |s: &str| s.chars().take(8).collect::<String>();
if head == state.last_indexed_commit {
return Some(json!({
"status": "up_to_date",
"behind_commits": 0,
"last_indexed_commit": short(&state.last_indexed_commit),
"head_commit": short(&head),
}));
}
let behind = behind_count(root, &head, &state.last_indexed_commit).unwrap_or(0);
Some(json!({
"status": "behind",
"behind_commits": behind,
"last_indexed_commit": short(&state.last_indexed_commit),
"head_commit": short(&head),
}))
}
fn behind_count(root: &Path, head: &str, indexed: &str) -> Option<u64> {
let repo = git2::Repository::discover(root).ok()?;
let head_oid = git2::Oid::from_str(head).ok()?;
let indexed_oid = git2::Oid::from_str(indexed).ok()?;
let (ahead, _behind) = repo.graph_ahead_behind(head_oid, indexed_oid).ok()?;
Some(ahead as u64)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
fn commit(root: &Path, file: &str, content: &str, msg: &str) -> git2::Oid {
let repo = git2::Repository::open(root)
.or_else(|_| git2::Repository::init(root))
.unwrap();
fs::write(root.join(file), content).unwrap();
let mut index = repo.index().unwrap();
index.add_path(Path::new(file)).unwrap();
index.write().unwrap();
let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
let parents: Vec<&git2::Commit> = parent.iter().collect();
repo.commit(Some("HEAD"), &sig, &sig, msg, &tree, &parents)
.unwrap()
}
#[test]
fn git_sync_tracks_external_head_movement() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
commit(root, "a.txt", "A", "first");
write_index_state(root).unwrap();
let gs = git_sync_status(root).unwrap();
assert_eq!(gs["status"], "up_to_date");
assert_eq!(gs["behind_commits"], 0);
commit(root, "b.txt", "B", "second");
let gs = git_sync_status(root).unwrap();
assert_eq!(gs["status"], "behind");
assert_eq!(gs["behind_commits"], 1);
write_index_state(root).unwrap();
let gs = git_sync_status(root).unwrap();
assert_eq!(gs["status"], "up_to_date");
assert_eq!(gs["behind_commits"], 0);
}
#[test]
fn non_git_root_yields_no_git_sync() {
let tmp = tempfile::tempdir().unwrap();
write_index_state(tmp.path()).unwrap();
let state = read_index_state(tmp.path()).unwrap();
assert_eq!(state.last_indexed_commit, "");
assert!(git_sync_status(tmp.path()).is_none());
}
#[test]
fn missing_sidecar_yields_no_git_sync() {
let tmp = tempfile::tempdir().unwrap();
commit(tmp.path(), "a.txt", "A", "first");
assert!(git_sync_status(tmp.path()).is_none());
}
}