aafe1786b5
- 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>
288 lines
11 KiB
Rust
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);
|
|
}
|
|
}
|