feat(git): sync with a remote — fetch, pull and push

Add remote sync to the Git panel: set/edit an origin URL, then fetch, pull
(fast-forward) and push, with live ahead/behind counts against the
remote-tracking branch.

Backend:
- git_ops: remote_status (git2 ahead/behind + upstream), set_remote, and
  fetch/pull/push that shell out to the system git CLI so they reuse the
  user's existing credentials (SSH agent, keychain / credential helpers)
  instead of reimplementing libgit2 credential callbacks. Pull is --ff-only
  to avoid leaving a conflicted tree from the GUI; push uses --set-upstream.
- New commands git_remote_status/set_remote/fetch/pull/push, registered.
- Test: push to a bare local remote, then assert upstream + ahead/behind.

Frontend:
- RemoteStatus type, api wrappers, store state (remote, syncing) with
  refreshRemote wired into refreshGit; pull reloads diagrams + open buffer.
- GitPanel remote section: URL, ahead/behind badges, Fetch/Pull/Push, and a
  set-remote dialog.

cargo test (7 passing incl. roundtrip), svelte-check and build all green.
This commit is contained in:
2026-05-22 21:32:48 +03:00
parent 6f23c620c1
commit c35960761b
9 changed files with 484 additions and 2 deletions

View File

@@ -6,7 +6,7 @@ use tauri::State;
use crate::db::{self, ProjectRecord};
use crate::diagram::{self, DiagramInfo};
use crate::error::{AppError, Result};
use crate::git_ops::{self, BranchInfo, CommitInfo, FileStatus};
use crate::git_ops::{self, BranchInfo, CommitInfo, FileStatus, RemoteStatus};
use crate::project::{self, ProjectConfig};
use crate::state::AppState;
@@ -176,6 +176,37 @@ pub fn git_checkout_branch(path: String, name: String) -> Result<()> {
git_ops::checkout_branch(Path::new(&path), &name)
}
// ---- Remote sync ----------------------------------------------------------
#[tauri::command]
pub fn git_remote_status(path: String) -> Result<RemoteStatus> {
git_ops::remote_status(Path::new(&path))
}
#[tauri::command]
pub fn git_set_remote(path: String, url: String) -> Result<()> {
let url = url.trim();
if url.is_empty() {
return Err(AppError::other("remote URL cannot be empty"));
}
git_ops::set_remote(Path::new(&path), url)
}
#[tauri::command]
pub fn git_fetch(path: String) -> Result<String> {
git_ops::fetch(Path::new(&path))
}
#[tauri::command]
pub fn git_pull(path: String) -> Result<String> {
git_ops::pull(Path::new(&path))
}
#[tauri::command]
pub fn git_push(path: String) -> Result<String> {
git_ops::push(Path::new(&path))
}
// ---------------------------------------------------------------------------
// Export helpers & settings
// ---------------------------------------------------------------------------

View File

@@ -1,4 +1,5 @@
use std::path::Path;
use std::process::Command;
use git2::{
BranchType, Commit, IndexAddOption, Repository, RepositoryInitOptions, Signature, StatusOptions,
@@ -30,6 +31,21 @@ pub struct FileStatus {
pub status: String,
}
/// Snapshot of the relationship between the current branch and its remote.
///
/// `ahead`/`behind` are measured against the remote-tracking ref, so they
/// reflect the last fetch — they update after `fetch`/`pull`/`push`.
#[derive(Debug, Serialize)]
pub struct RemoteStatus {
pub has_remote: bool,
pub remote: Option<String>,
pub url: Option<String>,
/// Upstream ref shorthand, e.g. `origin/main`, if the branch tracks one.
pub upstream: Option<String>,
pub ahead: usize,
pub behind: usize,
}
/// Initialize a fresh repository with `main` as the default branch.
pub fn init_repo(dir: &Path) -> Result<Repository> {
let mut opts = RepositoryInitOptions::new();
@@ -188,3 +204,138 @@ pub fn status(dir: &Path) -> Result<Vec<FileStatus>> {
}
Ok(out)
}
// ---------------------------------------------------------------------------
// Remotes & sync
//
// Network operations (fetch/pull/push) shell out to the system `git` CLI so
// they transparently reuse the user's existing credentials — SSH agent keys,
// macOS keychain / credential helpers, and host-key trust — rather than
// reimplementing all of that through libgit2's credential callbacks. Read-only
// introspection (remote URL, ahead/behind) stays on git2 since it needs no
// network and no auth.
// ---------------------------------------------------------------------------
/// Pick the remote to sync with: `origin` if present, otherwise the first one.
fn pick_remote(repo: &Repository) -> Result<Option<String>> {
let remotes = repo.remotes()?;
if remotes.iter().flatten().any(|r| r == "origin") {
return Ok(Some("origin".to_string()));
}
Ok(remotes.get(0).map(|s| s.to_string()))
}
/// Describe how the current branch relates to its remote-tracking branch.
pub fn remote_status(dir: &Path) -> Result<RemoteStatus> {
let repo = Repository::open(dir)?;
let Some(name) = pick_remote(&repo)? else {
return Ok(RemoteStatus {
has_remote: false,
remote: None,
url: None,
upstream: None,
ahead: 0,
behind: 0,
});
};
let url = repo
.find_remote(&name)
.ok()
.and_then(|r| r.url().map(|s| s.to_string()));
let mut upstream = None;
let (mut ahead, mut behind) = (0usize, 0usize);
if let Ok(head) = repo.head() {
if let (Some(local_oid), Some(shorthand)) = (head.target(), head.shorthand()) {
if let Ok(branch) = repo.find_branch(shorthand, BranchType::Local) {
if let Ok(up) = branch.upstream() {
upstream = up.name().ok().flatten().map(|s| s.to_string());
if let Some(up_oid) = up.get().target() {
if let Ok((a, b)) = repo.graph_ahead_behind(local_oid, up_oid) {
ahead = a;
behind = b;
}
}
}
}
}
}
Ok(RemoteStatus {
has_remote: true,
remote: Some(name),
url,
upstream,
ahead,
behind,
})
}
/// Add (or update) the `origin` remote URL.
pub fn set_remote(dir: &Path, url: &str) -> Result<()> {
let repo = Repository::open(dir)?;
if repo.find_remote("origin").is_ok() {
repo.remote_set_url("origin", url)?;
} else {
repo.remote("origin", url)?;
}
Ok(())
}
/// Resolve the remote to operate on, erroring clearly if none is configured.
fn remote_or_err(dir: &Path) -> Result<String> {
let repo = Repository::open(dir)?;
pick_remote(&repo)?.ok_or_else(|| AppError::other("No remote configured. Add one first."))
}
/// Run a `git` subcommand in `dir`, returning trimmed output or a clear error.
fn run_git(dir: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.current_dir(dir)
.args(args)
.output()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
AppError::other("Git is not installed or not on PATH; it's required to sync.")
} else {
AppError::Io(e)
}
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.success() {
// git writes most progress to stderr, so combine both streams.
Ok(format!("{stdout}{stderr}").trim().to_string())
} else {
let msg = if !stderr.trim().is_empty() {
stderr.trim()
} else {
stdout.trim()
};
Err(AppError::other(msg.to_string()))
}
}
/// Fetch refs from the remote (updates remote-tracking branches; no merge).
pub fn fetch(dir: &Path) -> Result<String> {
let remote = remote_or_err(dir)?;
run_git(dir, &["fetch", &remote])
}
/// Pull the current branch, fast-forward only — never leaves a half-merged or
/// conflicted working tree from the GUI. Divergent history errors out so the
/// user can reconcile deliberately.
pub fn pull(dir: &Path) -> Result<String> {
let remote = remote_or_err(dir)?;
let branch = current_branch(dir)?;
run_git(dir, &["pull", "--ff-only", &remote, &branch])
}
/// Push the current branch, setting it as the upstream.
pub fn push(dir: &Path) -> Result<String> {
let remote = remote_or_err(dir)?;
let branch = current_branch(dir)?;
run_git(dir, &["push", "--set-upstream", &remote, &branch])
}

View File

@@ -63,6 +63,11 @@ pub fn run() {
commands::git_current_branch,
commands::git_create_branch,
commands::git_checkout_branch,
commands::git_remote_status,
commands::git_set_remote,
commands::git_fetch,
commands::git_pull,
commands::git_push,
commands::write_text_file,
commands::write_binary_file,
commands::get_setting,

View File

@@ -152,3 +152,51 @@ fn git_commit_and_branching() {
assert_eq!(git_ops::current_branch(&dir).unwrap(), "main");
assert_eq!(git_ops::history(&dir, 10).unwrap().len(), 2);
}
fn git_available() -> bool {
std::process::Command::new("git")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[test]
fn remote_push_and_status_roundtrip() {
// Network ops shell out to git; skip cleanly where the CLI is unavailable.
if !git_available() {
return;
}
let tmp = TempDir::new();
let dir = tmp.project();
project::create_project(&dir, "Proj", "").unwrap();
// No remote yet.
let st = git_ops::remote_status(&dir).unwrap();
assert!(!st.has_remote);
// Point origin at a fresh bare repo acting as the remote.
let remote_path = tmp.0.join("remote.git");
git2::Repository::init_bare(&remote_path).unwrap();
git_ops::set_remote(&dir, remote_path.to_str().unwrap()).unwrap();
let st = git_ops::remote_status(&dir).unwrap();
assert!(st.has_remote);
assert_eq!(st.remote.as_deref(), Some("origin"));
assert_eq!(st.upstream, None, "no upstream before the first push");
// Push main; afterwards the branch tracks origin and is in sync.
git_ops::push(&dir).unwrap();
let st = git_ops::remote_status(&dir).unwrap();
assert!(st.upstream.is_some(), "push --set-upstream sets tracking");
assert_eq!((st.ahead, st.behind), (0, 0));
// A local commit puts us one ahead; a fetch is a clean no-op.
diagram::create_diagram(&dir, "Second").unwrap();
git_ops::commit_all(&dir, "second").unwrap();
let st = git_ops::remote_status(&dir).unwrap();
assert_eq!(st.ahead, 1);
assert_eq!(st.behind, 0);
git_ops::fetch(&dir).unwrap();
}