fix(git): use macos keychain credentials for remotes

This commit is contained in:
lucaronin
2026-05-06 15:43:02 +02:00
parent 2e7c5cb433
commit d093bd44bc
5 changed files with 287 additions and 8 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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<GitAddRemote
}
let connection = RemoteConnection::new(branch);
run_git(
vault,
&["remote", "add", DEFAULT_REMOTE_NAME, remote_url.trim()],
)?;
let trimmed_url = remote_url.trim();
run_git(vault, &["remote", "add", DEFAULT_REMOTE_NAME, trimmed_url])?;
request_remote_credentials(vault, trimmed_url);
let result = finish_remote_connection(vault, &connection);
if result.status != "connected" {

View File

@@ -0,0 +1,122 @@
use std::path::Path;
#[cfg(target_os = "macos")]
use std::io::Write;
#[cfg(target_os = "macos")]
use std::process::Stdio;
#[cfg(target_os = "macos")]
use super::git_command;
#[cfg(target_os = "macos")]
pub(super) fn request_remote_credentials(vault: &Path, remote_url: &str) {
let Some(input) = credential_fill_input(remote_url) else {
return;
};
let mut child = match git_command()
.args(["credential", "fill"])
.current_dir(vault)
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(child) => 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<String> {
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<CredentialTarget<'_>> {
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());
}
}

View File

@@ -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<OsString>,
}
#[derive(Default)]
struct ShellGitConfig {
git_path: Option<PathBuf>,
path: Option<OsString>,
}
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<GitLaunchConfig> = 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<OsString>,
shell: Option<ShellGitConfig>,
) -> 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<OsString>, program: &OsStr) -> Option<OsString> {
let mut paths = base
.map(|path| std::env::split_paths(&path).collect::<Vec<_>>())
.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<PathBuf>, candidate: PathBuf) {
if paths.iter().any(|path| path == &candidate) {
return;
}
paths.push(candidate);
}
#[cfg(target_os = "macos")]
fn shell_git_config() -> Option<ShellGitConfig> {
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<ShellGitConfig> {
None
}
#[cfg(target_os = "macos")]
fn shell_git_config_from_shell(shell: &Path) -> Option<ShellGitConfig> {
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<PathBuf> {
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<String> {
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();