From d39bbd1801ce05404ada6726acdfa1ba2cf812de Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 23 Feb 2026 08:50:06 -0600 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8feature:=20Add=20remote=20git=20suppor?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 26 +++++ backend/src/main.rs | 6 ++ backend/src/services/git.rs | 193 ++++++++++++++++++++++++++++++++++-- docker-compose.yml | 9 ++ 4 files changed, 224 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 38612b9..5bb9821 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,26 @@ To stop: docker compose down ``` +#### Docker + Private Git Remote Sync + +Ironpad can automatically sync the `data/` git repo with a private remote over SSH. + +1. Put your SSH key files on the host (example: `./deploy/ssh/id_ed25519` and `./deploy/ssh/id_ed25519.pub`). +2. Uncomment the SSH volume mount and git env vars in `docker-compose.yml`. +3. Set: + - `IRONPAD_GIT_REMOTE_URL` (example: `git@github.com:your-org/ironpad-data.git`) + - `IRONPAD_GIT_SSH_PRIVATE_KEY` (path inside container) + - `IRONPAD_GIT_SYNC_INTERVAL_SECS` (example: `300`) +4. Recreate the stack: + +```bash +docker compose up -d --build +``` + +Sync behavior: +- Every cycle: `fetch -> safe fast-forward if possible -> push` +- If local and remote diverge, auto fast-forward is skipped and a warning is logged. + ## Tech Stack ![Tech Stack](docs/graphics/tech-stack.png) @@ -150,6 +170,12 @@ Read about the method: | Fixed port | disabled | Set `IRONPAD_PORT` to force a specific port | | Disable tray mode | `false` | Set `IRONPAD_DISABLE_TRAY=1` to run headless in production static mode | | Auto-commit | Every 60 seconds | Git commits when changes exist | +| Git remote URL | not set | `IRONPAD_GIT_REMOTE_URL` creates/updates `origin` | +| Git sync interval | `0` (disabled) | Set `IRONPAD_GIT_SYNC_INTERVAL_SECS` to enable scheduled sync | +| Git SSH private key | not set | `IRONPAD_GIT_SSH_PRIVATE_KEY` path to private key in container | +| Git SSH public key | not set | Optional `IRONPAD_GIT_SSH_PUBLIC_KEY` path | +| Git SSH username | `git` | Override with `IRONPAD_GIT_SSH_USERNAME` if needed | +| Git SSH passphrase | not set | Optional `IRONPAD_GIT_SSH_PASSPHRASE` | | Auto-save | 1 second debounce | Frontend saves after typing stops | ## Documentation diff --git a/backend/src/main.rs b/backend/src/main.rs index ee549b3..fe43a37 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -102,8 +102,14 @@ async fn run_server(port_tx: Option>) { warn!("Git init skipped: {}", e); } + // Configure git remote from env (if provided) + if let Err(e) = services::git::configure_remote_from_env() { + warn!("Git remote setup skipped: {}", e); + } + // Start auto-commit background task (tries to commit every 60s) services::git::start_auto_commit(); + services::git::start_auto_sync(); // CORS layer (permissive for local-only app) let cors = CorsLayer::permissive(); diff --git a/backend/src/services/git.rs b/backend/src/services/git.rs index 89d5466..50125ed 100644 --- a/backend/src/services/git.rs +++ b/backend/src/services/git.rs @@ -1,3 +1,4 @@ +use std::path::{Path, PathBuf}; use std::time::Duration; use chrono::Utc; @@ -96,6 +97,58 @@ pub struct RemoteInfo { /// The background task simply tries to commit every interval; /// commit_all() already handles "no changes" gracefully. +#[derive(Debug, Clone)] +struct GitAuthConfig { + username: String, + private_key: Option, + public_key: Option, + passphrase: Option, +} + +fn git_auth_config() -> GitAuthConfig { + let username = std::env::var("IRONPAD_GIT_SSH_USERNAME").unwrap_or_else(|_| "git".to_string()); + let private_key = std::env::var("IRONPAD_GIT_SSH_PRIVATE_KEY") + .ok() + .map(PathBuf::from) + .filter(|p| p.exists()); + let public_key = std::env::var("IRONPAD_GIT_SSH_PUBLIC_KEY") + .ok() + .map(PathBuf::from) + .filter(|p| p.exists()); + let passphrase = std::env::var("IRONPAD_GIT_SSH_PASSPHRASE").ok(); + + GitAuthConfig { + username, + private_key, + public_key, + passphrase, + } +} + +fn remote_callbacks() -> git2::RemoteCallbacks<'static> { + let auth = git_auth_config(); + let mut callbacks = git2::RemoteCallbacks::new(); + + callbacks.credentials(move |_url, username_from_url, _allowed_types| { + let username = username_from_url.unwrap_or(auth.username.as_str()); + + if let Some(private_key) = auth.private_key.as_deref() { + let public_key: Option<&Path> = auth.public_key.as_deref(); + let passphrase = auth.passphrase.as_deref(); + + if let Ok(cred) = git2::Cred::ssh_key(username, public_key, private_key, passphrase) { + return Ok(cred); + } + + tracing::warn!("SSH key auth from file failed, falling back to SSH agent"); + } + + git2::Cred::ssh_key_from_agent(username) + }); + + callbacks +} + /// Get repository status pub fn get_status() -> Result { let data_path = config::data_dir(); @@ -317,18 +370,9 @@ pub fn push_to_remote() -> Result<(), String> { return Err("No remote URL configured".to_string()); } - // Create callbacks for authentication - let mut callbacks = git2::RemoteCallbacks::new(); - - // Try to use credential helper from git config - callbacks.credentials(|_url, username_from_url, _allowed_types| { - // Try SSH agent first - git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) - }); - // Set up push options let mut push_options = git2::PushOptions::new(); - push_options.remote_callbacks(callbacks); + push_options.remote_callbacks(remote_callbacks()); // Push the current branch let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name); @@ -351,6 +395,30 @@ pub fn has_remote() -> bool { false } +/// Configure `origin` from IRONPAD_GIT_REMOTE_URL. +/// If `origin` exists, updates its URL. Otherwise creates it. +pub fn configure_remote_from_env() -> Result<(), String> { + let remote_url = match std::env::var("IRONPAD_GIT_REMOTE_URL") { + Ok(url) if !url.trim().is_empty() => url, + _ => return Ok(()), + }; + + let data_path = config::data_dir(); + let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?; + + if repo.find_remote("origin").is_ok() { + repo.remote_set_url("origin", &remote_url) + .map_err(|e| format!("Failed to update origin URL: {}", e))?; + tracing::info!("Updated git remote origin from IRONPAD_GIT_REMOTE_URL"); + } else { + repo.remote("origin", &remote_url) + .map_err(|e| format!("Failed to create origin remote: {}", e))?; + tracing::info!("Configured git remote origin from IRONPAD_GIT_REMOTE_URL"); + } + + Ok(()) +} + /// Start auto-commit background task. /// Tries to commit every 60 seconds; commit_all() already handles "no changes" gracefully. pub fn start_auto_commit() { @@ -374,6 +442,111 @@ pub fn start_auto_commit() { }); } +fn fast_forward_to_upstream(repo: &Repository, branch_name: &str) -> Result<(), String> { + let local_branch = repo + .find_branch(branch_name, git2::BranchType::Local) + .map_err(|e| format!("Local branch not found: {}", e))?; + let upstream_branch = match local_branch.upstream() { + Ok(upstream) => upstream, + Err(_) => return Ok(()), + }; + + let local_oid = match local_branch.get().target() { + Some(oid) => oid, + None => return Ok(()), + }; + let upstream_oid = match upstream_branch.get().target() { + Some(oid) => oid, + None => return Ok(()), + }; + + let (ahead, behind) = repo + .graph_ahead_behind(local_oid, upstream_oid) + .map_err(|e| e.to_string())?; + + if behind == 0 { + return Ok(()); + } + + if ahead > 0 { + tracing::warn!( + "Remote is ahead by {} and local is ahead by {}; skipping auto fast-forward", + behind, + ahead + ); + return Ok(()); + } + + let refname = format!("refs/heads/{}", branch_name); + repo.reference(&refname, upstream_oid, true, "ironpad auto fast-forward") + .map_err(|e| format!("Failed to move local branch ref: {}", e))?; + repo.set_head(&refname) + .map_err(|e| format!("Failed to set HEAD: {}", e))?; + + let mut checkout = git2::build::CheckoutBuilder::new(); + checkout.force(); + repo.checkout_head(Some(&mut checkout)) + .map_err(|e| format!("Failed to checkout fast-forwarded HEAD: {}", e))?; + + tracing::info!("Fast-forwarded local branch {} by {} commit(s)", branch_name, behind); + Ok(()) +} + +fn sync_once() -> Result<(), String> { + configure_remote_from_env()?; + if !has_remote() { + return Ok(()); + } + + fetch_from_remote()?; + + // Try to fast-forward local branch to upstream when safe (no divergence). + let data_path = config::data_dir(); + let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?; + if let Ok(head) = repo.head() { + if let Some(branch_name) = head.shorthand() { + let _ = fast_forward_to_upstream(&repo, branch_name); + } + } + + // Push local commits (if any) after fetching. + if let Err(e) = push_to_remote() { + if !(e.contains("non-fast-forward") || e.contains("rejected")) { + return Err(e); + } + tracing::warn!("Auto-sync push skipped: {}", e); + } + + Ok(()) +} + +/// Start periodic remote sync. +/// Controlled via env vars: +/// - IRONPAD_GIT_SYNC_INTERVAL_SECS: sync interval in seconds (default 0 = disabled) +/// - IRONPAD_GIT_REMOTE_URL: optional remote URL used to create/update `origin` +pub fn start_auto_sync() { + let interval_secs = std::env::var("IRONPAD_GIT_SYNC_INTERVAL_SECS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + + if interval_secs == 0 { + tracing::info!("Git auto-sync disabled (set IRONPAD_GIT_SYNC_INTERVAL_SECS > 0 to enable)"); + return; + } + + tokio::spawn(async move { + let mut ticker = interval(Duration::from_secs(interval_secs)); + + loop { + ticker.tick().await; + if let Err(e) = sync_once() { + tracing::warn!("Git auto-sync failed: {}", e); + } + } + }); +} + /// Get commit history (most recent first) pub fn get_log(limit: Option) -> Result, String> { let data_path = config::data_dir(); diff --git a/docker-compose.yml b/docker-compose.yml index d1fc894..c7dd931 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,9 +13,18 @@ services: IRONPAD_DISABLE_TRAY: "1" IRONPAD_DATA_DIR: "/app/data" RUST_LOG: "info" + # Git sync (optional) + # IRONPAD_GIT_REMOTE_URL: "git@github.com:your-org/your-private-repo.git" + # IRONPAD_GIT_SSH_USERNAME: "git" + # IRONPAD_GIT_SSH_PRIVATE_KEY: "/run/secrets/ironpad_ssh/id_ed25519" + # IRONPAD_GIT_SSH_PUBLIC_KEY: "/run/secrets/ironpad_ssh/id_ed25519.pub" + # IRONPAD_GIT_SSH_PASSPHRASE: "" + # IRONPAD_GIT_SYNC_INTERVAL_SECS: "300" ports: - "3000:3000" volumes: - ./data:/app/data + # Mount SSH key material read-only for private remote auth + # - ./deploy/ssh:/run/secrets/ironpad_ssh:ro