use crate::git;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tracing;
fn working_tree_dirty(repo: &Path) -> bool {
git::working_tree_dirty(repo)
}
fn has_unpushed_commits(repo: &Path) -> bool {
git::has_unpushed_commits(repo)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncOutcome {
Pushed,
NothingToCommit,
}
fn run_git_checked(repo: &Path, args: &[&str], what: &str) -> Result<()> {
let output = git::secure_git()
.args(args)
.current_dir(repo)
.output()
.with_context(|| format!("failed to spawn git to {what}"))?;
if !output.status.success() {
anyhow::bail!(
"failed to {what}: {}",
git::git_failure_detail(&output.stderr, &output.stdout, output.status)
);
}
Ok(())
}
pub fn commit_and_push(repo: &Path, message: &str) -> Result<SyncOutcome> {
run_git_checked(repo, &["add", "-A"], "stage changes (git add -A)")?;
if !working_tree_dirty(repo) {
return Ok(SyncOutcome::NothingToCommit);
}
run_git_checked(
repo,
&["commit", "-m", message],
"create commit (git commit)",
)?;
run_git_checked(repo, &["push"], "push config (git push)")?;
Ok(SyncOutcome::Pushed)
}
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(());
}
if let Err(e) = git::secure_git()
.args(["fetch"])
.current_dir(repo)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
{
tracing::debug!("git fetch spawn error in {}: {}", repo.display(), e);
}
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 {
eprintln!(
"llmenv: config in {} could not fast-forward (diverged or network error) — \
run `llmenv sync` for details",
repo.display()
);
}
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"
]
);
}
}