diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 35314981..32444e1b 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -496,10 +496,10 @@ 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 "..."`; broken signing helpers trigger one unsigned retry for the same app-managed commit | +| `commit.rs` | Commit | Ensures a local author fallback when needed, then runs `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 | +| `conflict.rs` | Conflict resolution | Detect conflicts, resolve with ours/theirs/manual, and ensure a local author fallback before commit/rebase continuation | | `pulse.rs` | Activity feed | `git log` with `--name-status` for file changes | ### Auto-Sync diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 66b4dfa6..bd07441f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -521,7 +521,7 @@ If the selected vault disappears after startup, `useVaultLoader` re-checks `chec When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action. -When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup and remote-connection commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria ` when the vault has no local Git identity yet. 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. +When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup, remote-connection, manual/automatic, and conflict-resolution commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria ` when the vault has no local Git identity yet. 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, Codex, OpenCode, Pi, and Gemini CLI 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 aa070eb1..870b24e7 100644 --- a/src-tauri/src/git/commit.rs +++ b/src-tauri/src/git/commit.rs @@ -1,4 +1,4 @@ -use super::git_command; +use super::{ensure_author_config, git_command}; use std::path::Path; struct CommitFailure { @@ -22,6 +22,8 @@ pub fn git_commit(vault_path: &str, message: &str) -> Result { return Err(format!("git add failed: {}", stderr)); } + ensure_author_config(vault)?; + match run_commit(vault, message, false) { Ok(stdout) => Ok(stdout), Err(failure) if is_commit_signing_failure(&failure.detail()) => { @@ -89,6 +91,30 @@ mod tests { use super::*; use crate::git::tests::setup_git_repo; use std::fs; + use std::path::Path; + + fn unset_local_author_config(vault: &Path) { + for key in ["user.name", "user.email"] { + let status = git_command() + .args(["config", "--local", "--unset-all", key]) + .current_dir(vault) + .status() + .unwrap(); + assert!(status.success(), "failed to unset {key}"); + } + } + + fn local_config_value(vault: &Path, key: &str) -> Option { + let output = git_command() + .args(["config", "--local", key]) + .current_dir(vault) + .output() + .unwrap(); + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) + } #[test] fn test_git_commit() { @@ -110,6 +136,40 @@ mod tests { assert!(log_str.contains("Test commit")); } + #[test] + fn test_git_commit_sets_missing_local_author_identity() { + let dir = setup_git_repo(); + let vault = dir.path(); + unset_local_author_config(vault); + + fs::write(vault.join("identity-fallback.md"), "# Identity fallback\n").unwrap(); + + let result = git_commit(vault.to_str().unwrap(), "Commit without local identity"); + assert!( + result.is_ok(), + "commit should set local fallback identity: {result:?}" + ); + + assert_eq!( + local_config_value(vault, "user.name").as_deref(), + Some("Tolaria") + ); + assert_eq!( + local_config_value(vault, "user.email").as_deref(), + Some("vault@tolaria.md") + ); + + let author = git_command() + .args(["log", "-1", "--format=%an <%ae>"]) + .current_dir(vault) + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&author.stdout).trim(), + "Tolaria " + ); + } + #[test] fn test_commit_nothing_to_commit_returns_error() { let dir = setup_git_repo(); diff --git a/src-tauri/src/git/conflict.rs b/src-tauri/src/git/conflict.rs index 567756f2..cca865ba 100644 --- a/src-tauri/src/git/conflict.rs +++ b/src-tauri/src/git/conflict.rs @@ -1,6 +1,6 @@ use std::path::Path; -use super::{git_command, run_git}; +use super::{ensure_author_config, git_command, run_git}; /// List files with merge conflicts (unmerged paths). /// @@ -90,6 +90,8 @@ pub fn git_commit_conflict_resolution(vault_path: &str) -> Result git_command() @@ -130,8 +132,32 @@ mod tests { use crate::git::tests::{setup_git_repo, setup_remote_pair}; use crate::git::{git_commit, git_pull, git_push}; use std::fs; + use std::path::Path; use tempfile::TempDir; + fn unset_local_author_config(vault: &Path) { + for key in ["user.name", "user.email"] { + let status = git_command() + .args(["config", "--local", "--unset-all", key]) + .current_dir(vault) + .status() + .unwrap(); + assert!(status.success(), "failed to unset {key}"); + } + } + + fn local_config_value(vault: &Path, key: &str) -> Option { + let output = git_command() + .args(["config", "--local", key]) + .current_dir(vault) + .output() + .unwrap(); + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + #[test] fn test_get_conflict_files_empty_when_clean() { let dir = setup_git_repo(); @@ -282,6 +308,30 @@ mod tests { assert_eq!(get_conflict_mode(vp_b), "none"); } + #[test] + fn test_commit_conflict_resolution_sets_missing_local_author_identity() { + let (_bare, _clone_a, clone_b) = setup_conflict_pair(); + let vault = clone_b.path(); + let vp_b = vault.to_str().unwrap(); + + git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap(); + unset_local_author_config(vault); + + let result = git_commit_conflict_resolution(vp_b); + assert!( + result.is_ok(), + "conflict commit should set local fallback identity: {result:?}" + ); + assert_eq!( + local_config_value(vault, "user.name").as_deref(), + Some("Tolaria") + ); + assert_eq!( + local_config_value(vault, "user.email").as_deref(), + Some("vault@tolaria.md") + ); + } + /// Set up a rebase conflict: clone_b has diverged from origin and /// `git pull --rebase` causes a conflict. fn setup_rebase_conflict_pair() -> (TempDir, TempDir, TempDir) {