✨feature: Add remote git support
This commit is contained in:
26
README.md
26
README.md
@@ -93,6 +93,26 @@ To stop:
|
|||||||
docker compose down
|
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
|
||||||
|
|
||||||

|

|
||||||
@@ -150,6 +170,12 @@ Read about the method:
|
|||||||
| Fixed port | disabled | Set `IRONPAD_PORT` to force a specific port |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Auto-save | 1 second debounce | Frontend saves after typing stops |
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|||||||
@@ -102,8 +102,14 @@ async fn run_server(port_tx: Option<std::sync::mpsc::Sender<u16>>) {
|
|||||||
warn!("Git init skipped: {}", e);
|
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)
|
// Start auto-commit background task (tries to commit every 60s)
|
||||||
services::git::start_auto_commit();
|
services::git::start_auto_commit();
|
||||||
|
services::git::start_auto_sync();
|
||||||
|
|
||||||
// CORS layer (permissive for local-only app)
|
// CORS layer (permissive for local-only app)
|
||||||
let cors = CorsLayer::permissive();
|
let cors = CorsLayer::permissive();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -96,6 +97,58 @@ pub struct RemoteInfo {
|
|||||||
/// The background task simply tries to commit every interval;
|
/// The background task simply tries to commit every interval;
|
||||||
/// commit_all() already handles "no changes" gracefully.
|
/// 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
|
/// Get repository status
|
||||||
pub fn get_status() -> Result<RepoStatus, String> {
|
pub fn get_status() -> Result<RepoStatus, String> {
|
||||||
let data_path = config::data_dir();
|
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());
|
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
|
// Set up push options
|
||||||
let mut push_options = git2::PushOptions::new();
|
let mut push_options = git2::PushOptions::new();
|
||||||
push_options.remote_callbacks(callbacks);
|
push_options.remote_callbacks(remote_callbacks());
|
||||||
|
|
||||||
// Push the current branch
|
// Push the current branch
|
||||||
let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
|
let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
|
||||||
@@ -351,6 +395,30 @@ pub fn has_remote() -> bool {
|
|||||||
false
|
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.
|
/// Start auto-commit background task.
|
||||||
/// Tries to commit every 60 seconds; commit_all() already handles "no changes" gracefully.
|
/// Tries to commit every 60 seconds; commit_all() already handles "no changes" gracefully.
|
||||||
pub fn start_auto_commit() {
|
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)
|
/// Get commit history (most recent first)
|
||||||
pub fn get_log(limit: Option<usize>) -> Result<Vec<CommitDetail>, String> {
|
pub fn get_log(limit: Option<usize>) -> Result<Vec<CommitDetail>, String> {
|
||||||
let data_path = config::data_dir();
|
let data_path = config::data_dir();
|
||||||
|
|||||||
@@ -13,9 +13,18 @@ services:
|
|||||||
IRONPAD_DISABLE_TRAY: "1"
|
IRONPAD_DISABLE_TRAY: "1"
|
||||||
IRONPAD_DATA_DIR: "/app/data"
|
IRONPAD_DATA_DIR: "/app/data"
|
||||||
RUST_LOG: "info"
|
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:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
# Mount SSH key material read-only for private remote auth
|
||||||
|
# - ./deploy/ssh:/run/secrets/ironpad_ssh:ro
|
||||||
|
|||||||
Reference in New Issue
Block a user