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:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user