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
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

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();
}

View File

@@ -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<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 ---------------------------------------------
writeTextFile: (path: string, contents: string) =>
invoke<void>('write_text_file', { path, contents }),

View File

@@ -4,9 +4,25 @@
let message = $state('');
let newBranch = $state<string | null>(null);
let remoteUrl = $state<string | null>(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 @@
<button class="ghost icon" title="New branch" onclick={() => (newBranch = '')}></button>
</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="changes">
{#if hasChanges}
@@ -121,6 +175,29 @@
</Modal>
{/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>
.git {
width: 280px;
@@ -141,6 +218,79 @@
.branch-icon {
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 {
padding: 12px;
border-bottom: 1px solid var(--border);

View File

@@ -6,6 +6,7 @@ import type {
FileStatus,
OpenedProject,
ProjectRecord,
RemoteStatus,
} from './types';
export type Toast = { id: number; msg: string; kind: 'info' | 'error' | 'success' };
@@ -31,6 +32,9 @@ class Store {
branches = $state<BranchInfo[]>([]);
history = $state<CommitInfo[]>([]);
changes = $state<FileStatus[]>([]);
remote = $state<RemoteStatus | null>(null);
/** True while a fetch/pull/push is in flight. */
syncing = $state(false);
// UI
theme = $state('default');
@@ -134,6 +138,7 @@ class Store {
this.history = [];
this.branches = [];
this.changes = [];
this.remote = null;
this.focusNode = null;
this.view = 'start';
}
@@ -249,11 +254,12 @@ class Store {
async refreshGit() {
if (!this.path) return;
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.gitBranches(this.path),
api.gitHistory(this.path, 100),
api.gitStatus(this.path),
api.gitRemoteStatus(this.path),
]);
} catch (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) {
this.theme = theme;
try {

View File

@@ -56,4 +56,13 @@ export interface FileStatus {
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';