use crate::git;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
fn working_tree_dirty(repo: &Path) -> bool {
git::working_tree_dirty(repo)
}
fn has_unpushed_commits(repo: &Path) -> bool {
git::has_unpushed_commits(repo)
}
pub fn state_path(state_dir: &Path) -> PathBuf {
state_dir.join("sync.json")
}
pub fn read_state(state_dir: &Path) -> Result<Option<SystemTime>> {
let p = state_path(state_dir);
if !p.exists() {
return Ok(None);
}
let s = std::fs::read_to_string(&p)?;
let secs: u64 = s.trim().parse()?;
Ok(Some(UNIX_EPOCH + Duration::from_secs(secs)))
}
pub fn write_state(state_dir: &Path, t: SystemTime) -> Result<()> {
std::fs::create_dir_all(state_dir)?;
let secs = t.duration_since(UNIX_EPOCH)?.as_secs();
crate::paths::write_owner_only_atomic(&state_path(state_dir), secs.to_string().as_bytes())?;
Ok(())
}
pub fn maybe_pull(repo: &Path, state_dir: &Path, interval: Duration) -> Result<()> {
let now = SystemTime::now();
if let Some(last) = read_state(state_dir)?
&& now.duration_since(last).unwrap_or_default() < interval
{
return Ok(());
}
if !repo.join(".git").exists() {
return Err(anyhow::anyhow!(
"config directory is not a git repository: {}",
repo.display()
));
}
if working_tree_dirty(repo) {
eprintln!(
"llmenv: config in {} has uncommitted changes — run `llmenv sync` to commit and push",
repo.display()
);
write_state(state_dir, now)?;
return Ok(());
}
let _ = git::secure_git()
.args(["fetch"])
.current_dir(repo)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
let pull_status = git::secure_git()
.args(["pull", "--ff-only"])
.current_dir(repo)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.context(format!("git pull --ff-only failed in {}", repo.display()))?;
if pull_status.success() {
write_state(state_dir, now)?;
} else if has_unpushed_commits(repo) {
eprintln!(
"llmenv: config in {} has unpushed commits — run `llmenv sync` to push",
repo.display()
);
write_state(state_dir, now)?;
} else {
tracing::debug!("git pull did not complete successfully; will retry on next pull interval");
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
#[test]
fn git_config_flags_protect_against_hooks() {
use crate::git::GIT_CONFIG_FLAGS;
assert_eq!(
GIT_CONFIG_FLAGS,
&[
"-c",
"core.fsmonitor=false",
"-c",
"core.hooksPath=/dev/null"
]
);
}
}