diff --git a/.gitignore b/.gitignore index 8e9699f..6bcf90f 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ data/notes/assets/* !data/notes/assets/.gitkeep data/projects/*/ !data/projects/.gitkeep +Codex Session.txt # === Stray root lock file (frontend/package-lock.json is kept for CI) === /package-lock.json diff --git a/Dockerfile b/Dockerfile index 3c99c02..b6a71e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ FROM debian:bookworm-slim WORKDIR /app RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates ripgrep libdbus-1-3 \ + && apt-get install -y --no-install-recommends ca-certificates ripgrep libdbus-1-3 git openssh-client \ && rm -rf /var/lib/apt/lists/* COPY --from=backend-builder /src/backend/target/release/ironpad /app/ironpad diff --git a/README.md b/README.md index 5bb9821..638f0ab 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ Ironpad can automatically sync the `data/` git repo with a private remote over S 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_SSH_KNOWN_HOSTS` (optional; defaults to `/root/.ssh/known_hosts`) - `IRONPAD_GIT_SYNC_INTERVAL_SECS` (example: `300`) 4. Recreate the stack: @@ -112,6 +113,7 @@ 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. +- If libgit2 SSH auth fails, Ironpad can fall back to `git` CLI (controlled by `IRONPAD_GIT_USE_CLI_FALLBACK`, default `true`). ## Tech Stack @@ -174,8 +176,10 @@ Read about the method: | 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 known_hosts path | `/root/.ssh/known_hosts` | Override with `IRONPAD_GIT_SSH_KNOWN_HOSTS` | | Git SSH username | `git` | Override with `IRONPAD_GIT_SSH_USERNAME` if needed | | Git SSH passphrase | not set | Optional `IRONPAD_GIT_SSH_PASSPHRASE` | +| Git CLI fallback | `true` | `IRONPAD_GIT_USE_CLI_FALLBACK` for fetch/push auth fallback | | Auto-save | 1 second debounce | Frontend saves after typing stops | ## Documentation diff --git a/backend/src/services/git.rs b/backend/src/services/git.rs index 9abe3bf..a58daa6 100644 --- a/backend/src/services/git.rs +++ b/backend/src/services/git.rs @@ -1,4 +1,5 @@ use std::path::{Path, PathBuf}; +use std::process::Command; use std::time::Duration; use chrono::Utc; @@ -196,6 +197,72 @@ fn remote_callbacks() -> git2::RemoteCallbacks<'static> { callbacks } +fn use_git_cli_fallback() -> bool { + std::env::var("IRONPAD_GIT_USE_CLI_FALLBACK") + .map(|v| !matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off")) + .unwrap_or(true) +} + +fn known_hosts_path() -> Option { + if let Ok(path) = std::env::var("IRONPAD_GIT_SSH_KNOWN_HOSTS") { + let p = PathBuf::from(path); + if p.exists() { + return Some(p); + } + } + + let default = PathBuf::from("/root/.ssh/known_hosts"); + if default.exists() { + Some(default) + } else { + None + } +} + +fn git_ssh_command(auth: &GitAuthConfig) -> Option { + let private_key = auth.private_key.as_ref()?; + + let mut cmd = format!( + "ssh -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes", + private_key.display() + ); + if let Some(known_hosts) = known_hosts_path() { + cmd.push_str(&format!(" -o UserKnownHostsFile={}", known_hosts.display())); + } + Some(cmd) +} + +fn run_git_cli(args: &[&str]) -> Result<(), String> { + let auth = git_auth_config(); + let data_path = config::data_dir(); + let mut cmd = Command::new("git"); + cmd.args(args) + .current_dir(data_path) + .env("GIT_TERMINAL_PROMPT", "0"); + + if let Some(ssh_cmd) = git_ssh_command(&auth) { + cmd.env("GIT_SSH_COMMAND", ssh_cmd); + } + + let output = cmd + .output() + .map_err(|e| format!("Failed to run git CLI: {}", e))?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let msg = if !stderr.is_empty() { stderr } else { stdout }; + Err(format!( + "git {} failed (exit {}): {}", + args.join(" "), + output.status.code().unwrap_or(-1), + msg + )) + } +} + /// Get repository status pub fn get_status() -> Result { let data_path = config::data_dir(); @@ -423,12 +490,27 @@ pub fn push_to_remote() -> Result<(), String> { // Push the current branch let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name); - remote - .push(&[&refspec], Some(&mut push_options)) - .map_err(|e| format!("Push failed: {}. Make sure SSH keys are configured.", e))?; - - tracing::info!("Successfully pushed to origin/{}", branch_name); - Ok(()) + match remote.push(&[&refspec], Some(&mut push_options)) { + Ok(_) => { + tracing::info!("Successfully pushed to origin/{}", branch_name); + Ok(()) + } + Err(e) => { + let err_text = e.to_string(); + if !use_git_cli_fallback() { + return Err(format!( + "Push failed: {}. Make sure SSH keys are configured.", + err_text + )); + } + tracing::warn!("libgit2 push failed, trying git CLI fallback: {}", err_text); + run_git_cli(&["push", "origin", branch_name]).map_err(|cli_err| { + format!("Push failed: {} (libgit2) / {} (git CLI)", err_text, cli_err) + })?; + tracing::info!("Successfully pushed to origin/{} (git CLI fallback)", branch_name); + Ok(()) + } + } } /// Check if remote is configured @@ -850,18 +932,20 @@ pub fn fetch_from_remote() -> Result<(), String> { .find_remote("origin") .map_err(|e| format!("Remote 'origin' not found: {}", e))?; - // Create callbacks for authentication - let mut callbacks = git2::RemoteCallbacks::new(); - callbacks.credentials(|_url, username_from_url, _allowed_types| { - git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) - }); - let mut fetch_options = git2::FetchOptions::new(); - fetch_options.remote_callbacks(callbacks); + fetch_options.remote_callbacks(remote_callbacks()); - remote - .fetch(&[] as &[&str], Some(&mut fetch_options), None) - .map_err(|e| format!("Fetch failed: {}", e))?; - - Ok(()) + match remote.fetch(&[] as &[&str], Some(&mut fetch_options), None) { + Ok(_) => Ok(()), + Err(e) => { + let err_text = e.to_string(); + if !use_git_cli_fallback() { + return Err(format!("Fetch failed: {}", err_text)); + } + tracing::warn!("libgit2 fetch failed, trying git CLI fallback: {}", err_text); + run_git_cli(&["fetch", "origin", "--prune"]).map_err(|cli_err| { + format!("Fetch failed: {} (libgit2) / {} (git CLI)", err_text, cli_err) + }) + } + } } diff --git a/docker-compose.yml b/docker-compose.yml index c7dd931..02e6e03 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,8 +18,10 @@ services: # 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_KNOWN_HOSTS: "/run/secrets/ironpad_ssh/known_hosts" # IRONPAD_GIT_SSH_PASSPHRASE: "" # IRONPAD_GIT_SYNC_INTERVAL_SECS: "300" + # IRONPAD_GIT_USE_CLI_FALLBACK: "true" ports: - "3000:3000"