Files
md-manuscript/src/gitsync.rs
T
landon d6ccf12f51 Don't report a first sync as failed
A repository being pushed for the first time has no upstream, so the
pull leg failed with "There is no tracking information for the current
branch" and dragged the whole sync's status down — the app said "Sync
finished with errors" even though the push had gone through and set the
upstream itself.

The upstream is now checked up front. Without one there is nothing to
rebase onto, so the pull is skipped with a note in the log and the push
sets the upstream directly, rather than being run once to fail and then
retried. The argument choice moves to push_args so it can be asserted
without a repository on disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ
2026-09-09 19:41:21 -05:00

288 lines
11 KiB
Rust

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<PathBuf> {
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<String> {
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<Vec<&str>> {
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<String, String> {
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);
}
}