use std::path::{Path, PathBuf}; 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")), } } /// The first real output line of a `run_git` result. /// /// `run_git` prefixes a `$ git …` echo for the log window, so a caller after a /// single value — a path, a URL — has to step over it. fn first_output_line(out: &str) -> Option<&str> { out.lines() .find(|l| !l.starts_with("$ git") && !l.trim().is_empty()) .map(str::trim) .filter(|l| !l.is_empty()) } /// 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") } /// The top-level directory of the git work tree containing `workspace`, if any. /// /// This walks up the directory tree the way git itself does, so it finds a /// repository whose `.git` lives in a *parent* of `workspace`, not just one /// rooted at `workspace`. pub fn repo_root(workspace: &Path) -> Option { let (ok, out) = run_git(workspace, &["rev-parse", "--show-toplevel"]); if !ok { return None; } let path = PathBuf::from(first_output_line(&out)?); (!path.as_os_str().is_empty()).then_some(path) } /// 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") } /// True if the current branch has an upstream to pull from. /// /// A repository being pushed for the first time has none, and `rev-parse` /// exits non-zero rather than printing anything. fn has_upstream(workspace: &Path) -> bool { run_git(workspace, &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).0 } /// The push for a branch, which has to set the upstream the first time. /// /// Kept separate from running it so it can be asserted in tests, like /// [`origin_args`]. pub fn push_args(branch: &str, has_upstream: bool) -> Vec<&str> { if has_upstream { vec!["push"] } else { vec!["push", "--set-upstream", "origin", branch] } } /// The URL `origin` points at, if the remote exists. /// /// Read from the repository rather than from this app's settings: a remote /// belongs to the checkout, so the workspace is the source of truth and opening /// a different project shows that project's remote. pub fn origin_url(workspace: &Path) -> Option { let (ok, out) = run_git(workspace, &["remote", "get-url", "origin"]); if !ok { return None; } first_output_line(&out).map(str::to_string) } /// Point `origin` at `url`, adding the remote when it is not there yet. /// /// A blank `url` removes the remote instead, which is how a workspace is /// disconnected from a server without touching its history. pub fn set_origin(workspace: &Path, url: &str) -> GitOutcome { let Some(args) = origin_args(url, has_origin(workspace)) else { return GitOutcome { ok: true, log: "(there was no 'origin' to remove)\n".to_string(), }; }; let (ok, log) = run_git(workspace, &args); GitOutcome { ok, log } } /// The git command that points `origin` at `url`, or `None` when there is /// nothing to do. Kept separate from running it, as with the cookiecutter /// argument builder, so the decision table can be asserted in tests without a /// repository on disk — picking `add` where `set-url` was needed (or the other /// way round) fails at the git level, not here. pub fn origin_args(url: &str, has_origin: bool) -> Option> { let url = url.trim(); match (url.is_empty(), has_origin) { (true, true) => Some(vec!["remote", "remove", "origin"]), (true, false) => None, (false, true) => Some(vec!["remote", "set-url", "origin", url]), (false, false) => Some(vec!["remote", "add", "origin", url]), } } /// 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 upstream = has_upstream(workspace); if upstream { let (p_ok, p_out) = run_git(workspace, &["pull", "--rebase", "--autostash"]); log.push_str(&p_out); ok &= p_ok; } else { // Nothing to rebase onto yet, and pulling anyway fails with "no // tracking information" — which used to make a first sync report // errors even though its push had gone through fine. log.push_str("(no upstream branch yet — nothing to pull)\n"); } let branch = current_branch(workspace); let (push_ok, push_out) = run_git(workspace, &push_args(&branch, upstream)); log.push_str(&push_out); 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()) } /// The unified diff of one file against the last commit, as raw diff text. /// /// Revising is comparing: what changed in this scene since the last time it was /// good? The repository already holds that answer, so the app asks git rather /// than keeping snapshots of its own. /// /// `rel` is relative to the *workspace*, which may sit below the repository /// root, so the path is resolved through the work tree before being handed over. pub fn diff_file(workspace: &Path, rel: &str) -> Result { let (ok, out) = run_git( workspace, &["--no-pager", "diff", "--no-color", "HEAD", "--", rel], ); if !ok { // A repository with no commits yet has no HEAD to compare against. return Err(strip_command_echo(&out)); } Ok(strip_command_echo(&out)) } /// Whether the repository has at least one commit, and so something to diff. pub fn has_commits(workspace: &Path) -> bool { run_git(workspace, &["rev-parse", "--verify", "HEAD"]).0 } /// Drop the `$ git …` line `run_git` prefixes for the log window; a diff view /// wants the diff itself. fn strip_command_echo(text: &str) -> String { text.split_once('\n').map_or(text, |(_, rest)| rest).to_string() } #[cfg(test)] mod tests { use super::*; #[test] fn a_first_push_sets_the_upstream_and_later_ones_do_not() { // Without this the first push fails with "has no upstream branch". assert_eq!( push_args("main", false), vec!["push", "--set-upstream", "origin", "main"] ); // Once it is set, a plain push is what respects the tracking config. assert_eq!(push_args("main", true), vec!["push"]); // Whatever the branch is actually called. assert_eq!( push_args("draft-2", false), vec!["push", "--set-upstream", "origin", "draft-2"] ); } #[test] fn setting_a_remote_adds_it_only_when_it_is_missing() { // Fresh repository: the remote has to be created. assert_eq!( origin_args("ssh://git@host:2228/me/book.git", false), Some(vec!["remote", "add", "origin", "ssh://git@host:2228/me/book.git"]) ); // Already pointed somewhere: `add` would fail, so repoint instead. assert_eq!( origin_args("ssh://git@host:2228/me/book.git", true), Some(vec!["remote", "set-url", "origin", "ssh://git@host:2228/me/book.git"]) ); } #[test] fn clearing_the_box_detaches_the_remote() { assert_eq!(origin_args("", true), Some(vec!["remote", "remove", "origin"])); assert_eq!(origin_args(" ", true), Some(vec!["remote", "remove", "origin"])); // Nothing to remove, so nothing to run — and no error to report. assert_eq!(origin_args("", false), None); } #[test] fn a_pasted_url_is_trimmed_before_it_reaches_git() { // Copying a clone URL out of a web page brings whitespace with it, and // git would take it literally. assert_eq!( origin_args(" ssh://git@host/me/book.git\n", false), Some(vec!["remote", "add", "origin", "ssh://git@host/me/book.git"]) ); } #[test] fn the_command_echo_is_skipped_when_reading_a_single_value() { // What `run_git` actually hands back: its own echo line, then output. let out = "$ git remote get-url origin\nssh://git@host/me/book.git\n"; assert_eq!(first_output_line(out), Some("ssh://git@host/me/book.git")); // No remote configured: git says nothing, so neither do we. assert_eq!(first_output_line("$ git remote get-url origin\n"), None); assert_eq!(first_output_line(""), None); } }