diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index c03d9c49..5f563742 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -775,6 +775,7 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte Tolaria delegates remote auth to the user's system git setup: - `CloneVaultModal` captures a remote URL and local destination - `clone_git_repo` and `create_getting_started_vault` both run system git clone work in blocking Tokio tasks so clone UIs stay responsive +- On macOS, system-git commands prefer the user's login-shell `git` and `PATH`, and `git_add_remote` preflights HTTPS remotes through `git credential fill` so Keychain can prompt/grant access before the first fetch or push - On Linux AppImage launches, every system-git command removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` before spawning `git`, so helpers like `git-remote-https` bind against the host git/library stack instead of Tolaria's bundled WebKit/AppImage libraries - On Linux AppImage Wayland launches, startup environment safeguards also set `GTK_IM_MODULE=fcitx` when common input-method variables already indicate fcitx and the user has not explicitly chosen a GTK IM module, allowing WebKitGTK editor input to reach fcitx5 on compositors such as niri without overriding deliberate `GTK_IM_MODULE` choices. - `git_add_remote` uses the same system git path and refuses remotes whose history is unrelated or ahead of the local vault diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bb4c4ce2..4df903f5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -515,7 +515,7 @@ After the clone completes, Tolaria removes every configured git remote from the ### Remote Clone & Auth Model -Tolaria no longer implements provider-specific OAuth or remote-repository APIs. All remote git work goes through the user's existing system git configuration. +Tolaria no longer implements provider-specific OAuth or remote-repository APIs. All remote git work goes through the user's existing system git configuration. On macOS, git subprocesses prefer the user's login-shell `git` and `PATH` so Homebrew/Xcode Git, Git Credential Manager, and `git-credential-osxkeychain` resolve the same way they do in Terminal. **Flow:** 1. User opens `CloneVaultModal` from onboarding or the vault menu @@ -523,7 +523,8 @@ Tolaria no longer implements provider-specific OAuth or remote-repository APIs. 3. The `clone_git_repo()` Tauri command runs `git clone` inside a blocking Tokio task so the Tauri window stays responsive during slow or failing clones 4. Linux AppImage builds strip AppImage loader variables from system-git subprocesses before spawning `git`, keeping `git-remote-https` on the host git/library stack 5. `git_push()` / `git_pull()` continue to use the same system git path -6. Clone commands disable interactive terminal / askpass prompts and surface the git failure back to the UI instead of freezing the app waiting for input +6. On macOS, `git_add_remote()` asks Git's credential helper for HTTPS credentials before the first fetch so Keychain can grant access to the same saved credential item the shell uses +7. Clone commands disable interactive terminal / askpass prompts and surface the git failure back to the UI instead of freezing the app waiting for input **Auth model:** - SSH keys, Git Credential Manager, macOS Keychain helpers, `gh auth`, and other git helpers all work without app-specific setup diff --git a/src-tauri/src/git/connect.rs b/src-tauri/src/git/connect.rs index d0b3ff0c..0f9f3a8b 100644 --- a/src-tauri/src/git/connect.rs +++ b/src-tauri/src/git/connect.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use std::path::Path; use std::process::Output; +use super::credentials::request_remote_credentials; use super::{ensure_author_config, git_command}; const DEFAULT_REMOTE_NAME: &str = "origin"; @@ -103,10 +104,9 @@ pub fn git_add_remote(vault_path: &str, remote_url: &str) -> Result child, + Err(_) => return, + }; + + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(input.as_bytes()); + } + let _ = child.wait(); +} + +#[cfg(not(target_os = "macos"))] +pub(super) fn request_remote_credentials(_vault: &Path, _remote_url: &str) {} + +#[cfg(any(test, target_os = "macos"))] +struct CredentialTarget<'a> { + protocol: &'a str, + host: &'a str, + username: Option<&'a str>, + path: Option<&'a str>, +} + +#[cfg(any(test, target_os = "macos"))] +fn credential_fill_input(remote_url: &str) -> Option { + let target = credential_target(remote_url)?; + let mut lines = vec![ + format!("protocol={}", target.protocol), + format!("host={}", target.host), + ]; + + if let Some(username) = target.username { + lines.push(format!("username={username}")); + } + + if let Some(path) = target.path { + lines.push(format!("path={path}")); + } + + Some(format!("{}\n\n", lines.join("\n"))) +} + +#[cfg(any(test, target_os = "macos"))] +fn credential_target(remote_url: &str) -> Option> { + let (protocol, rest) = remote_url.trim().split_once("://")?; + if !matches!(protocol, "https" | "http") { + return None; + } + + let rest = rest.split_once('#').map_or(rest, |(value, _)| value); + let rest = rest.split_once('?').map_or(rest, |(value, _)| value); + let (authority, path) = rest.split_once('/').unwrap_or((rest, "")); + if authority.is_empty() { + return None; + } + + let (username, host) = match authority.rsplit_once('@') { + Some((userinfo, host)) => { + let username = userinfo + .split_once(':') + .map_or(userinfo, |(name, _)| name) + .trim(); + let username = (!username.is_empty()).then_some(username); + (username, host) + } + None => (None, authority), + }; + + if host.is_empty() { + return None; + } + + Some(CredentialTarget { + protocol, + host, + username, + path: (!path.is_empty()).then_some(path), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn credential_fill_input_extracts_https_remote_parts() { + let input = credential_fill_input("https://github.com/refactoringhq/tolaria.git").unwrap(); + + assert!(input.contains("protocol=https\n")); + assert!(input.contains("host=github.com\n")); + assert!(input.contains("path=refactoringhq/tolaria.git\n")); + assert!(input.ends_with("\n\n")); + } + + #[test] + fn credential_fill_input_ignores_ssh_remotes() { + assert!(credential_fill_input("git@github.com:refactoringhq/tolaria.git").is_none()); + } +} diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index 7115a8b7..31e0b92c 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -2,14 +2,17 @@ mod clone; mod commit; mod conflict; mod connect; +mod credentials; mod dates; mod history; mod pulse; mod remote; mod status; -use std::path::Path; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::OnceLock; pub use clone::clone_repo; pub use commit::git_commit; @@ -56,13 +59,139 @@ const DEFAULT_GITIGNORE: &str = "# Tolaria app files (machine-specific, never co *.swp\n\ *.swo\n"; +#[derive(Clone)] +struct GitLaunchConfig { + program: OsString, + path: Option, +} + +#[derive(Default)] +struct ShellGitConfig { + git_path: Option, + path: Option, +} + pub(crate) fn git_command() -> Command { - let mut command = crate::hidden_command("git"); + let config = git_launch_config(); + let mut command = crate::hidden_command(&config.program); + if let Some(path) = &config.path { + command.env("PATH", path); + } sanitize_linux_appimage_git_env(&mut command); command.args(["-c", "core.quotePath=false"]); command } +fn git_launch_config() -> &'static GitLaunchConfig { + static CONFIG: OnceLock = OnceLock::new(); + CONFIG.get_or_init(detect_git_launch_config) +} + +fn detect_git_launch_config() -> GitLaunchConfig { + let parent_path = std::env::var_os("PATH"); + git_launch_config_from_parts(parent_path, shell_git_config()) +} + +fn git_launch_config_from_parts( + parent_path: Option, + shell: Option, +) -> GitLaunchConfig { + let shell = shell.unwrap_or_default(); + let program = shell + .git_path + .map(PathBuf::into_os_string) + .unwrap_or_else(|| OsString::from("git")); + let path = path_with_git_parent(shell.path.or(parent_path), &program); + + GitLaunchConfig { program, path } +} + +fn path_with_git_parent(base: Option, program: &OsStr) -> Option { + let mut paths = base + .map(|path| std::env::split_paths(&path).collect::>()) + .unwrap_or_default(); + + let program_path = Path::new(program); + if let Some(parent) = program_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + push_unique_path(&mut paths, parent.to_path_buf()); + } + + if paths.is_empty() { + return None; + } + + std::env::join_paths(paths).ok() +} + +fn push_unique_path(paths: &mut Vec, candidate: PathBuf) { + if paths.iter().any(|path| path == &candidate) { + return; + } + paths.push(candidate); +} + +#[cfg(target_os = "macos")] +fn shell_git_config() -> Option { + user_shell_candidates() + .into_iter() + .filter(|shell| shell.exists()) + .find_map(|shell| shell_git_config_from_shell(&shell)) +} + +#[cfg(not(target_os = "macos"))] +fn shell_git_config() -> Option { + None +} + +#[cfg(target_os = "macos")] +fn shell_git_config_from_shell(shell: &Path) -> Option { + let output = crate::hidden_command(shell) + .arg("-lc") + .arg("printf '%s\\n%s' \"$(command -v git 2>/dev/null || true)\" \"$PATH\"") + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut lines = stdout.lines(); + let git_path = lines + .next() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .filter(|path| path.exists()); + let path = lines + .next() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(OsString::from); + + if git_path.is_none() && path.is_none() { + return None; + } + + Some(ShellGitConfig { git_path, path }) +} + +#[cfg(target_os = "macos")] +fn user_shell_candidates() -> Vec { + let mut shells = Vec::new(); + if let Some(shell) = std::env::var_os("SHELL") { + if !shell.is_empty() { + shells.push(PathBuf::from(shell)); + } + } + shells.push(PathBuf::from("/bin/zsh")); + shells.push(PathBuf::from("/bin/bash")); + shells +} + #[cfg(any(test, all(desktop, target_os = "linux")))] const LINUX_APPIMAGE_GIT_ENV_REMOVALS: [&str; 3] = ["LD_LIBRARY_PATH", "LD_PRELOAD", "GIT_EXEC_PATH"]; @@ -210,6 +339,7 @@ fn parse_github_repo_path(url: &str) -> Option { mod tests { use super::*; use std::collections::HashMap; + use std::ffi::OsString; use std::fs; use tempfile::TempDir; @@ -305,6 +435,31 @@ mod tests { .collect() } + #[test] + fn test_git_launch_config_prefers_login_shell_git_and_path() { + let config = git_launch_config_from_parts( + Some(OsString::from("/usr/bin:/bin")), + Some(ShellGitConfig { + git_path: Some(PathBuf::from("/opt/homebrew/bin/git")), + path: Some(OsString::from("/opt/homebrew/bin:/usr/bin:/bin")), + }), + ); + + assert_eq!(config.program, OsString::from("/opt/homebrew/bin/git")); + assert_eq!( + config.path, + Some(OsString::from("/opt/homebrew/bin:/usr/bin:/bin")) + ); + } + + #[test] + fn test_git_launch_config_keeps_default_git_when_shell_is_unavailable() { + let config = git_launch_config_from_parts(Some(OsString::from("/usr/bin:/bin")), None); + + assert_eq!(config.program, OsString::from("git")); + assert_eq!(config.path, Some(OsString::from("/usr/bin:/bin"))); + } + #[test] fn test_ensure_gitignore_creates_file() { let dir = TempDir::new().unwrap();