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

@@ -20,6 +20,11 @@ TypeScript** frontend using **CodeMirror 6** and **Mermaid 11**.
- **Git version control built in** — view the working-tree status, write commit - **Git version control built in** — view the working-tree status, write commit
messages, browse history, create branches and switch between them. Each branch messages, browse history, create branches and switch between them. Each branch
keeps its own set of diagrams, just like normal Git. 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 - **Optimize panel (Diagram Doctor)** — analyse a tangled flowchart and declutter
it in one click: a readability score and metrics (hubs, density, cross-group it in one click: a readability score and metrics (hubs, density, cross-group
edges), plus source rewrites for the **ELK layered layout**, extra 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. for you. Switching branches reloads the diagram list from that branch.
- **History** shows the most recent commits with short SHA, message, author and - **History** shows the most recent commits with short SHA, message, author and
relative time. 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 ## License

View File

@@ -6,7 +6,7 @@ use tauri::State;
use crate::db::{self, ProjectRecord}; use crate::db::{self, ProjectRecord};
use crate::diagram::{self, DiagramInfo}; use crate::diagram::{self, DiagramInfo};
use crate::error::{AppError, Result}; 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::project::{self, ProjectConfig};
use crate::state::AppState; 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) 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 // Export helpers & settings
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -1,4 +1,5 @@
use std::path::Path; use std::path::Path;
use std::process::Command;
use git2::{ use git2::{
BranchType, Commit, IndexAddOption, Repository, RepositoryInitOptions, Signature, StatusOptions, BranchType, Commit, IndexAddOption, Repository, RepositoryInitOptions, Signature, StatusOptions,
@@ -30,6 +31,21 @@ pub struct FileStatus {
pub status: String, 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. /// Initialize a fresh repository with `main` as the default branch.
pub fn init_repo(dir: &Path) -> Result<Repository> { pub fn init_repo(dir: &Path) -> Result<Repository> {
let mut opts = RepositoryInitOptions::new(); let mut opts = RepositoryInitOptions::new();
@@ -188,3 +204,138 @@ pub fn status(dir: &Path) -> Result<Vec<FileStatus>> {
} }
Ok(out) 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_current_branch,
commands::git_create_branch, commands::git_create_branch,
commands::git_checkout_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_text_file,
commands::write_binary_file, commands::write_binary_file,
commands::get_setting, 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::current_branch(&dir).unwrap(), "main");
assert_eq!(git_ops::history(&dir, 10).unwrap().len(), 2); 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();
}

View File

@@ -6,6 +6,7 @@ import type {
FileStatus, FileStatus,
OpenedProject, OpenedProject,
ProjectRecord, ProjectRecord,
RemoteStatus,
} from './types'; } from './types';
// Thin, typed wrappers over the Rust command surface. Argument keys use // Thin, typed wrappers over the Rust command surface. Argument keys use
@@ -60,6 +61,18 @@ export const api = {
gitCheckoutBranch: (path: string, name: string) => gitCheckoutBranch: (path: string, name: string) =>
invoke<void>('git_checkout_branch', { path, name }), invoke<void>('git_checkout_branch', { path, name }),
// ---- Remote sync ----------------------------------------------------
gitRemoteStatus: (path: string) => invoke<RemoteStatus>('git_remote_status', { path }),
gitSetRemote: (path: string, url: string) =>
invoke<void>('git_set_remote', { path, url }),
gitFetch: (path: string) => invoke<string>('git_fetch', { path }),
gitPull: (path: string) => invoke<string>('git_pull', { path }),
gitPush: (path: string) => invoke<string>('git_push', { path }),
// ---- Export & settings --------------------------------------------- // ---- Export & settings ---------------------------------------------
writeTextFile: (path: string, contents: string) => writeTextFile: (path: string, contents: string) =>
invoke<void>('write_text_file', { path, contents }), invoke<void>('write_text_file', { path, contents }),

View File

@@ -4,9 +4,25 @@
let message = $state(''); let message = $state('');
let newBranch = $state<string | null>(null); let newBranch = $state<string | null>(null);
let remoteUrl = $state<string | null>(null);
const hasChanges = $derived(store.changes.length > 0 || store.dirty); 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() { async function commit() {
const msg = message.trim(); const msg = message.trim();
if (!msg) return; if (!msg) return;
@@ -56,6 +72,44 @@
<button class="ghost icon" title="New branch" onclick={() => (newBranch = '')}></button> <button class="ghost icon" title="New branch" onclick={() => (newBranch = '')}></button>
</div> </div>
<div class="remote-row">
{#if store.remote?.has_remote}
<div class="remote-info">
<span class="remote-icon" title="Remote"></span>
<span class="remote-name" title={store.remote.url ?? ''}>
{store.remote.url ? shortUrl(store.remote.url) : store.remote.remote}
</span>
{#if store.remote.ahead}
<span class="badge-count ahead" title="{store.remote.ahead} commit(s) to push">
{store.remote.ahead}
</span>
{/if}
{#if store.remote.behind}
<span class="badge-count behind" title="{store.remote.behind} commit(s) to pull">
{store.remote.behind}
</span>
{/if}
<button
class="ghost icon mini"
title="Edit remote URL"
onclick={() => (remoteUrl = store.remote?.url ?? '')}>✎</button
>
</div>
<div class="sync-btns">
<button onclick={() => store.fetch()} disabled={store.syncing}>Fetch</button>
<button onclick={() => store.pull()} disabled={store.syncing}>
Pull{store.remote.behind ? ` ↓${store.remote.behind}` : ''}
</button>
<button class="primary" onclick={() => store.push()} disabled={store.syncing}>
Push{store.remote.ahead ? ` ${store.remote.ahead}` : ''}
</button>
</div>
{#if store.syncing}<div class="syncing faint">Syncing…</div>{/if}
{:else if store.remote}
<button class="add-remote" onclick={() => (remoteUrl = '')}>☁ Add remote…</button>
{/if}
</div>
<div class="commit-box"> <div class="commit-box">
<div class="changes"> <div class="changes">
{#if hasChanges} {#if hasChanges}
@@ -121,6 +175,29 @@
</Modal> </Modal>
{/if} {/if}
{#if remoteUrl !== null}
<Modal title="Remote repository" onClose={() => (remoteUrl = null)}>
<label class="fld">
<span>Remote URL (origin)</span>
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
bind:value={remoteUrl}
placeholder="git@github.com:user/repo.git"
onkeydown={(e) => e.key === 'Enter' && saveRemote()}
/>
</label>
<p class="hint faint">
Uses your existing Git credentials (SSH keys / keychain). Push and pull
authenticate just like the <code>git</code> command line.
</p>
<div class="actions">
<button onclick={() => (remoteUrl = null)}>Cancel</button>
<button class="primary" onclick={saveRemote} disabled={!remoteUrl.trim()}>Save</button>
</div>
</Modal>
{/if}
<style> <style>
.git { .git {
width: 280px; width: 280px;
@@ -141,6 +218,79 @@
.branch-icon { .branch-icon {
color: var(--accent); color: var(--accent);
} }
.remote-row {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 8px;
}
.remote-info {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.remote-icon {
color: var(--accent);
}
.remote-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--mono);
font-size: 11.5px;
color: var(--text-dim);
}
.badge-count {
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
padding: 0 5px;
border-radius: 999px;
}
.badge-count.ahead {
color: var(--green);
background: rgba(63, 185, 80, 0.14);
}
.badge-count.behind {
color: var(--amber);
background: rgba(210, 153, 34, 0.14);
}
.sync-btns {
display: flex;
gap: 4px;
}
.sync-btns button {
flex: 1;
padding: 6px 4px;
font-size: 12px;
}
.syncing {
font-size: 11px;
text-align: center;
}
.add-remote {
width: 100%;
background: transparent;
border-style: dashed;
color: var(--text-dim);
font-size: 12px;
}
.add-remote:hover {
color: var(--text);
border-color: var(--accent);
}
.hint {
margin: 0;
font-size: 11.5px;
line-height: 1.45;
}
.hint code {
font-family: var(--mono);
color: var(--accent);
}
.commit-box { .commit-box {
padding: 12px; padding: 12px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);

View File

@@ -6,6 +6,7 @@ import type {
FileStatus, FileStatus,
OpenedProject, OpenedProject,
ProjectRecord, ProjectRecord,
RemoteStatus,
} from './types'; } from './types';
export type Toast = { id: number; msg: string; kind: 'info' | 'error' | 'success' }; export type Toast = { id: number; msg: string; kind: 'info' | 'error' | 'success' };
@@ -31,6 +32,9 @@ class Store {
branches = $state<BranchInfo[]>([]); branches = $state<BranchInfo[]>([]);
history = $state<CommitInfo[]>([]); history = $state<CommitInfo[]>([]);
changes = $state<FileStatus[]>([]); changes = $state<FileStatus[]>([]);
remote = $state<RemoteStatus | null>(null);
/** True while a fetch/pull/push is in flight. */
syncing = $state(false);
// UI // UI
theme = $state('default'); theme = $state('default');
@@ -134,6 +138,7 @@ class Store {
this.history = []; this.history = [];
this.branches = []; this.branches = [];
this.changes = []; this.changes = [];
this.remote = null;
this.focusNode = null; this.focusNode = null;
this.view = 'start'; this.view = 'start';
} }
@@ -249,11 +254,12 @@ class Store {
async refreshGit() { async refreshGit() {
if (!this.path) return; if (!this.path) return;
try { try {
[this.branch, this.branches, this.history, this.changes] = await Promise.all([ [this.branch, this.branches, this.history, this.changes, this.remote] = await Promise.all([
api.gitCurrentBranch(this.path), api.gitCurrentBranch(this.path),
api.gitBranches(this.path), api.gitBranches(this.path),
api.gitHistory(this.path, 100), api.gitHistory(this.path, 100),
api.gitStatus(this.path), api.gitStatus(this.path),
api.gitRemoteStatus(this.path),
]); ]);
} catch (e) { } catch (e) {
this.fail(e); this.fail(e);
@@ -304,6 +310,66 @@ class Store {
} }
} }
// ---- Remote sync ----------------------------------------------------
async setRemote(url: string) {
if (!this.path) return;
try {
await api.gitSetRemote(this.path, url);
this.remote = await api.gitRemoteStatus(this.path);
this.notify('Remote saved', 'success');
} catch (e) {
this.fail(e);
}
}
fetch() {
return this.sync('fetch');
}
pull() {
return this.sync('pull');
}
push() {
return this.sync('push');
}
/** Shared fetch/pull/push flow: run, then refresh local + remote state. */
private async sync(op: 'fetch' | 'pull' | 'push') {
if (!this.path || this.syncing) return;
// Don't lose buffered edits before a push or a (fast-forward) pull.
if (op !== 'fetch' && this.dirty) await this.save(true);
this.syncing = true;
try {
if (op === 'fetch') await api.gitFetch(this.path);
else if (op === 'pull') await api.gitPull(this.path);
else await api.gitPush(this.path);
// A pull can change files on disk; reload the diagram list and the open
// diagram's content so the editor reflects what was merged in.
if (op === 'pull') {
this.diagrams = await api.listDiagrams(this.path);
if (this.activeId && this.diagrams.some((d) => d.id === this.activeId)) {
const content = await api.readDiagram(this.path, this.activeId);
this.content = content;
this.savedContent = content;
this.revision++;
} else if (this.diagrams.length > 0) {
await this.selectDiagram(this.diagrams[0].id);
}
}
await this.refreshGit();
const where = this.remote?.remote ?? 'remote';
const verb = op === 'fetch' ? 'Fetched from' : op === 'pull' ? 'Pulled from' : 'Pushed to';
this.notify(`${verb} ${where}`, 'success');
} catch (e) {
this.fail(e);
} finally {
this.syncing = false;
}
}
async setTheme(theme: string) { async setTheme(theme: string) {
this.theme = theme; this.theme = theme;
try { try {

View File

@@ -56,4 +56,13 @@ export interface FileStatus {
status: string; status: string;
} }
export interface RemoteStatus {
has_remote: boolean;
remote: string | null;
url: string | null;
upstream: string | null;
ahead: number;
behind: number;
}
export type MermaidTheme = 'default' | 'neutral' | 'dark' | 'forest' | 'base'; export type MermaidTheme = 'default' | 'neutral' | 'dark' | 'forest' | 'base';