diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 25803a06..4c925f0b 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -394,7 +394,7 @@ interface PulseCommit { | `history.rs` | File history | `git log` — last 20 commits per file | | `status.rs` | Modified files | `git status --porcelain` — filtered to `.md` | | `status.rs` | File diff | `git diff`, fallback to `--cached`, then synthetic for untracked | -| `commit.rs` | Commit | `git add -A && git commit -m "..."` | +| `commit.rs` | Commit | `git add -A && git commit -m "..."`; broken signing helpers trigger one unsigned retry for the same app-managed commit | | `remote.rs` | Pull / Push | `git pull --rebase` / `git push` | | `connect.rs` | Add remote | Adds `origin`, fetches it, validates history compatibility, and only starts tracking when the remote is safe | | `conflict.rs` | Conflict resolution | Detect conflicts, resolve with ours/theirs/manual | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5e0b29a3..85142e90 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -443,7 +443,7 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it - **Open an existing folder** → system file picker - **Get started with a template** → pick a parent folder, then call `create_getting_started_vault()` with the derived `.../Getting Started` child path so the cloned vault opens into the populated repo root immediately -When an opened folder is not yet a git repo, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. +When an opened folder is not yet a git repo, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures. Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code and Codex are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch. diff --git a/src-tauri/src/git/commit.rs b/src-tauri/src/git/commit.rs index a53f620b..658d7996 100644 --- a/src-tauri/src/git/commit.rs +++ b/src-tauri/src/git/commit.rs @@ -1,6 +1,11 @@ use std::path::Path; use std::process::Command; +struct CommitFailure { + stdout: String, + stderr: String, +} + /// Commit all changes with a message. pub fn git_commit(vault_path: &str, message: &str) -> Result { let vault = Path::new(vault_path); @@ -17,26 +22,65 @@ pub fn git_commit(vault_path: &str, message: &str) -> Result { return Err(format!("git add failed: {}", stderr)); } - // Commit - let commit = Command::new("git") + match run_commit(vault, message, false) { + Ok(stdout) => Ok(stdout), + Err(failure) if is_commit_signing_failure(&failure.detail()) => { + run_commit(vault, message, true).map_err(|retry_failure| { + format!( + "git commit signing failed; retried without signing but git commit still failed: {}", + retry_failure.detail() + ) + }) + } + Err(failure) => Err(format!("git commit failed: {}", failure.detail())), + } +} + +fn run_commit(vault: &Path, message: &str, disable_signing: bool) -> Result { + let mut command = Command::new("git"); + if disable_signing { + command.args(["-c", "commit.gpgsign=false"]); + } + + let commit = command .args(["commit", "-m", message]) .current_dir(vault) .output() - .map_err(|e| format!("Failed to run git commit: {}", e))?; + .map_err(|e| CommitFailure { + stdout: String::new(), + stderr: format!("Failed to run git commit: {}", e), + })?; - if !commit.status.success() { - let stderr = String::from_utf8_lossy(&commit.stderr); - let stdout = String::from_utf8_lossy(&commit.stdout); - // git writes "nothing to commit" to stdout, not stderr - let detail = if stderr.trim().is_empty() { - stdout - } else { - stderr - }; - return Err(format!("git commit failed: {}", detail.trim())); + if commit.status.success() { + return Ok(String::from_utf8_lossy(&commit.stdout).to_string()); } - Ok(String::from_utf8_lossy(&commit.stdout).to_string()) + Err(CommitFailure { + stdout: String::from_utf8_lossy(&commit.stdout).to_string(), + stderr: String::from_utf8_lossy(&commit.stderr).to_string(), + }) +} + +impl CommitFailure { + fn detail(&self) -> String { + // git writes "nothing to commit" to stdout, not stderr. + let detail = if self.stderr.trim().is_empty() { + &self.stdout + } else { + &self.stderr + }; + detail.trim().to_string() + } +} + +fn is_commit_signing_failure(detail: &str) -> bool { + let lower = detail.to_ascii_lowercase(); + lower.contains("cannot run gpg") + || lower.contains("gpg failed to sign") + || lower.contains("failed to sign the data") + || lower.contains("gpg.ssh") + || (lower.contains("failed to write commit object") + && (lower.contains("sign") || lower.contains("gpg"))) } #[cfg(test)] @@ -84,4 +128,53 @@ mod tests { "Error should mention 'nothing to commit'" ); } + + #[test] + fn test_git_commit_retries_without_signing_when_gpg_is_missing() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = vault.to_str().unwrap(); + + Command::new("git") + .args(["config", "commit.gpgsign", "true"]) + .current_dir(vault) + .output() + .unwrap(); + Command::new("git") + .args(["config", "gpg.program", "/missing/tolaria-test-gpg"]) + .current_dir(vault) + .output() + .unwrap(); + fs::write(vault.join("signed-config.md"), "# Signed config\n").unwrap(); + + let result = git_commit(vp, "Commit with broken signing config"); + assert!( + result.is_ok(), + "commit should retry unsigned when signing helper is missing: {result:?}" + ); + + let log = Command::new("git") + .args(["log", "--oneline", "-1"]) + .current_dir(vault) + .output() + .unwrap(); + assert!(String::from_utf8_lossy(&log.stdout).contains("Commit with broken signing config")); + + let config = Command::new("git") + .args(["config", "commit.gpgsign"]) + .current_dir(vault) + .output() + .unwrap(); + assert_eq!(String::from_utf8_lossy(&config.stdout).trim(), "true"); + } + + #[test] + fn test_commit_signing_failure_detection_is_specific() { + assert!(is_commit_signing_failure( + "error: cannot run gpg: No such file or directory\nfatal: failed to write commit object" + )); + assert!(!is_commit_signing_failure( + "On branch main\nnothing to commit, working tree clean" + )); + } }