use std::path::Path; use std::process::Command; /// Result of a git operation: combined human-readable log for display. pub struct GitOutcome { pub ok: bool, pub log: String, } fn run_git(workspace: &Path, args: &[&str]) -> (bool, String) { let output = Command::new("git") .current_dir(workspace) .args(args) .output(); match output { Ok(out) => { let mut text = String::new(); text.push_str(&format!("$ git {}\n", args.join(" "))); text.push_str(&String::from_utf8_lossy(&out.stdout)); let err = String::from_utf8_lossy(&out.stderr); if !err.trim().is_empty() { text.push_str(&err); } (out.status.success(), text) } Err(e) => (false, format!("failed to launch git: {e}\n")), } } /// True if the workspace directory is inside a git working tree. pub fn is_repo(workspace: &Path) -> bool { let (ok, out) = run_git(workspace, &["rev-parse", "--is-inside-work-tree"]); ok && out.contains("true") } /// True if a remote named `origin` is configured. fn has_origin(workspace: &Path) -> bool { let (ok, out) = run_git(workspace, &["remote"]); ok && out.lines().any(|l| l.trim() == "origin") } /// Initialise a new git repository in the workspace with a starter .gitignore. pub fn init(workspace: &Path) -> GitOutcome { let mut log = String::new(); let (ok1, o1) = run_git(workspace, &["init"]); log.push_str(&o1); // Make sure we are on a predictable default branch name. let (_, o2) = run_git(workspace, &["symbolic-ref", "HEAD", "refs/heads/main"]); log.push_str(&o2); GitOutcome { ok: ok1, log } } /// Commit all local changes and, if `origin` exists, pull (rebase) then push. /// This is the one-button "sync" operation. pub fn sync(workspace: &Path, message: &str) -> GitOutcome { let mut log = String::new(); let mut ok = true; let (_, o) = run_git(workspace, &["add", "-A"]); log.push_str(&o); // Commit only if there is something staged; otherwise git returns non-zero, // which is fine and should not abort the sync. let (has_changes, status) = run_git(workspace, &["status", "--porcelain"]); let dirty = has_changes && !status.lines().skip(1).all(|l| l.trim().is_empty()); if dirty { let (c_ok, c_out) = run_git(workspace, &["commit", "-m", message]); log.push_str(&c_out); ok &= c_ok; } else { log.push_str("(nothing to commit)\n"); } if has_origin(workspace) { let (p_ok, p_out) = run_git(workspace, &["pull", "--rebase", "--autostash"]); log.push_str(&p_out); ok &= p_ok; let (push_ok, push_out) = run_git(workspace, &["push"]); log.push_str(&push_out); // A failed push often just means no upstream is set yet; surface it but // do not treat a missing upstream as a hard failure of the whole sync. if !push_ok && push_out.contains("no upstream") { let branch = current_branch(workspace); let (u_ok, u_out) = run_git(workspace, &["push", "--set-upstream", "origin", &branch]); log.push_str(&u_out); ok &= u_ok; } else { ok &= push_ok; } } else { log.push_str("(no 'origin' remote configured — committed locally only)\n"); } GitOutcome { ok, log } } fn current_branch(workspace: &Path) -> String { let (_, out) = run_git(workspace, &["rev-parse", "--abbrev-ref", "HEAD"]); out.lines() .last() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| "main".to_string()) }