From c35960761b91852028718a4c75bda39eef78b8b0 Mon Sep 17 00:00:00 2001 From: Aleksey Shakhmatov Date: Fri, 22 May 2026 21:32:48 +0300 Subject: [PATCH] =?UTF-8?q?feat(git):=20sync=20with=20a=20remote=20?= =?UTF-8?q?=E2=80=94=20fetch,=20pull=20and=20push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 9 ++ src-tauri/src/commands.rs | 33 ++++++- src-tauri/src/git_ops.rs | 151 +++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 5 + src-tauri/src/tests.rs | 48 +++++++++ src/lib/api.ts | 13 +++ src/lib/components/GitPanel.svelte | 150 ++++++++++++++++++++++++++++ src/lib/store.svelte.ts | 68 ++++++++++++- src/lib/types.ts | 9 ++ 9 files changed, 484 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d9127ed..116568d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,11 @@ TypeScript** frontend using **CodeMirror 6** and **Mermaid 11**. - **Git version control built in** — view the working-tree status, write commit messages, browse history, create branches and switch between them. Each branch keeps its own set of diagrams, just like normal Git. +- **Remote sync** — point a project at a remote (`origin`), then **fetch**, + **pull** (fast-forward) and **push** from the Git panel, with live ahead/behind + counts. Network operations shell out to your system `git`, so they reuse your + existing credentials (SSH agent, keychain / credential helpers) — no separate + login. - **Optimize panel (Diagram Doctor)** — analyse a tangled flowchart and declutter it in one click: a readability score and metrics (hubs, density, cross-group edges), plus source rewrites for the **ELK layered layout**, extra @@ -132,6 +137,10 @@ cd src-tauri && cargo test for you. Switching branches reloads the diagram list from that branch. - **History** shows the most recent commits with short SHA, message, author and relative time. +- **Remote sync** sets/uses an `origin` remote. Fetch updates remote-tracking + refs; pull is fast-forward only (it never leaves a half-merged tree from the + GUI — reconcile divergent history yourself); push uses `--set-upstream`. These + invoke the system `git` binary, which must be installed and on `PATH`. ## License diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9928cb7..6109a8f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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 { + 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 { + git_ops::fetch(Path::new(&path)) +} + +#[tauri::command] +pub fn git_pull(path: String) -> Result { + git_ops::pull(Path::new(&path)) +} + +#[tauri::command] +pub fn git_push(path: String) -> Result { + git_ops::push(Path::new(&path)) +} + // --------------------------------------------------------------------------- // Export helpers & settings // --------------------------------------------------------------------------- diff --git a/src-tauri/src/git_ops.rs b/src-tauri/src/git_ops.rs index 3779310..e2e46df 100644 --- a/src-tauri/src/git_ops.rs +++ b/src-tauri/src/git_ops.rs @@ -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, + pub url: Option, + /// Upstream ref shorthand, e.g. `origin/main`, if the branch tracks one. + pub upstream: Option, + pub ahead: usize, + pub behind: usize, +} + /// Initialize a fresh repository with `main` as the default branch. pub fn init_repo(dir: &Path) -> Result { let mut opts = RepositoryInitOptions::new(); @@ -188,3 +204,138 @@ pub fn status(dir: &Path) -> Result> { } 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let remote = remote_or_err(dir)?; + let branch = current_branch(dir)?; + run_git(dir, &["push", "--set-upstream", &remote, &branch]) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f42f1e6..ec1895a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/tests.rs b/src-tauri/src/tests.rs index 8533de3..b211012 100644 --- a/src-tauri/src/tests.rs +++ b/src-tauri/src/tests.rs @@ -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(); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 14e8cf1..da9f71e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -6,6 +6,7 @@ import type { FileStatus, OpenedProject, ProjectRecord, + RemoteStatus, } from './types'; // Thin, typed wrappers over the Rust command surface. Argument keys use @@ -60,6 +61,18 @@ export const api = { gitCheckoutBranch: (path: string, name: string) => invoke('git_checkout_branch', { path, name }), + // ---- Remote sync ---------------------------------------------------- + gitRemoteStatus: (path: string) => invoke('git_remote_status', { path }), + + gitSetRemote: (path: string, url: string) => + invoke('git_set_remote', { path, url }), + + gitFetch: (path: string) => invoke('git_fetch', { path }), + + gitPull: (path: string) => invoke('git_pull', { path }), + + gitPush: (path: string) => invoke('git_push', { path }), + // ---- Export & settings --------------------------------------------- writeTextFile: (path: string, contents: string) => invoke('write_text_file', { path, contents }), diff --git a/src/lib/components/GitPanel.svelte b/src/lib/components/GitPanel.svelte index 5557cde..a30cde1 100644 --- a/src/lib/components/GitPanel.svelte +++ b/src/lib/components/GitPanel.svelte @@ -4,9 +4,25 @@ let message = $state(''); let newBranch = $state(null); + let remoteUrl = $state(null); const hasChanges = $derived(store.changes.length > 0 || store.dirty); + async function saveRemote() { + const url = (remoteUrl ?? '').trim(); + if (!url) return; + await store.setRemote(url); + remoteUrl = null; + } + + function shortUrl(url: string): string { + // Strip protocol/host noise to a recognisable "owner/repo". + return url + .replace(/^git@[^:]+:/, '') + .replace(/^[a-z]+:\/\/[^/]+\//, '') + .replace(/\.git$/, ''); + } + async function commit() { const msg = message.trim(); if (!msg) return; @@ -56,6 +72,44 @@ +
+ {#if store.remote?.has_remote} +
+ + + {store.remote.url ? shortUrl(store.remote.url) : store.remote.remote} + + {#if store.remote.ahead} + + ↑{store.remote.ahead} + + {/if} + {#if store.remote.behind} + + ↓{store.remote.behind} + + {/if} + +
+
+ + + +
+ {#if store.syncing}
Syncing…
{/if} + {:else if store.remote} + + {/if} +
+
{#if hasChanges} @@ -121,6 +175,29 @@ {/if} +{#if remoteUrl !== null} + (remoteUrl = null)}> + +

+ Uses your existing Git credentials (SSH keys / keychain). Push and pull + authenticate just like the git command line. +

+
+ + +
+
+{/if} +