llmenv 1.0.11

Universal scope-aware environment for AI coding agents
Documentation
use crate::git;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tracing;

/// True if the repo's working tree has staged or unstaged changes.
fn working_tree_dirty(repo: &Path) -> bool {
    git::working_tree_dirty(repo)
}

/// True if the current branch has commits not yet pushed to its upstream.
/// Returns false if there's no upstream or git fails — we only want to nudge
/// the user when we're certain.
fn has_unpushed_commits(repo: &Path) -> bool {
    git::has_unpushed_commits(repo)
}

/// Result of [`commit_and_push`]: whether a commit was actually pushed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncOutcome {
    /// A commit was created and pushed to origin.
    Pushed,
    /// The working tree was clean — nothing to commit or push.
    NothingToCommit,
}

/// Run a git subcommand in `repo`, capturing its output. On a non-zero exit the
/// captured stderr is surfaced in the returned error so failures are loud, and
/// capturing (rather than inheriting) stdout/stderr keeps git's chatter out of
/// a piped `llmenv export` eval context (#307).
///
/// # Errors
/// Returns an error if git cannot be spawned or exits non-zero (stderr included).
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(())
}

/// Stage, commit, and push every change in `repo` to origin.
///
/// "Nothing to commit" is detected up front by inspecting the working tree
/// after staging — not by misreading `git commit`'s exit code — so a commit
/// that fails for a real reason (e.g. missing identity) surfaces as an error
/// instead of being mistaken for a clean tree. A failed `git push` is likewise
/// surfaced rather than silently treated as success (#307).
///
/// # Errors
/// Returns an error if any git step fails to spawn or exits non-zero.
pub fn commit_and_push(repo: &Path, message: &str) -> Result<SyncOutcome> {
    run_git_checked(repo, &["add", "-A"], "stage changes (git add -A)")?;

    // After staging, an empty `status --porcelain` means there is genuinely
    // nothing to commit — distinct from `git commit` failing for another reason.
    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)
}

/// Path to the sync state file within state_dir.
pub fn state_path(state_dir: &Path) -> PathBuf {
    state_dir.join("sync.json")
}

/// Read the last-pull timestamp from state_dir.
/// Returns Ok(None) if the file doesn't exist, Ok(Some(time)) if it does.
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)))
}

/// Write the current pull timestamp to state_dir/sync.json.
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(())
}

/// Throttled pull: check if interval has elapsed since last pull,
/// and if so, run `git fetch` followed by `git pull --ff-only` in repo.
/// Only updates state_dir if pull succeeds (to enable retry on failure).
pub fn maybe_pull(repo: &Path, state_dir: &Path, interval: Duration) -> Result<()> {
    let now = SystemTime::now();

    // Check if we should pull
    if let Some(last) = read_state(state_dir)?
        && now.duration_since(last).unwrap_or_default() < interval
    {
        return Ok(());
    }

    // Validate repo is a git repository
    if !repo.join(".git").exists() {
        return Err(anyhow::anyhow!(
            "config directory is not a git repository: {}",
            repo.display()
        ));
    }

    // Working tree dirty → don't try to pull (git will refuse on rebase, and
    // a fast-forward could clobber uncommitted edits anyway). Surface a
    // one-line nudge and return early; treat this as success so we don't
    // retry every shell prompt.
    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(());
    }

    // Attempt fetch — silent on failure (network issues are transient and
    // we don't want to spam every shell prompt while offline). Log spawn errors
    // at debug level in case git binary is missing or broken.
    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);
    }

    // Attempt fast-forward pull. Suppress git's stderr — we'll print our
    // own one-line warning on failure rather than git's two-line message.
    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 {
        // Some other pull failure (non-fast-forward, diverged, conflict, auth).
        // Don't update state so we retry on next tick — but surface a one-line
        // nudge so a persistently-broken sync isn't silently swallowed across
        // every shell prompt (stderr was suppressed, so point at `llmenv sync`
        // for the detail).
        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"
            ]
        );
    }
}