feature: Add remote git support
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m56s

This commit is contained in:
Keith Solomon
2026-02-23 08:50:06 -06:00
parent 0b5816621b
commit d39bbd1801
4 changed files with 224 additions and 10 deletions

View File

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

View File

@@ -102,8 +102,14 @@ async fn run_server(port_tx: Option<std::sync::mpsc::Sender<u16>>) {
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();

View File

@@ -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<PathBuf>,
public_key: Option<PathBuf>,
passphrase: Option<String>,
}
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<RepoStatus, String> {
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::<u64>().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<usize>) -> Result<Vec<CommitDetail>, String> {
let data_path = config::data_dir();

View File

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