Files
md-manuscript/src/gitsync.rs
T
landon aafe1786b5 feat: Add auto-snapshot feature with git commit every N words
- Add new module src/app/auto_snapshot.rs for auto-snapshot functionality
- Add configuration options: auto_snapshot_enabled (default: false) and
  auto_snapshot_threshold (default: 250 words)
- Automatically create git commits when word count crosses threshold boundaries
- Add 'Edit -> Snapshots' menu option to open snapshots window
- Snapshots window shows: enable/disable toggle, threshold setting, current
  word count, next snapshot target, list of all snapshots with rollback buttons
- Manual snapshot creation via button in snapshots window
- Rollback to any previous snapshot with one click
- Snapshot info includes: word count, timestamp, commit message, commit hash
- Make run_git in gitsync.rs public for use by auto_snapshot module
- Update README.md with Auto-Snapshots documentation

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-16 12:11:35 -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,
}
pub 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);
}
}