fix(git): use macos keychain credentials for remotes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" {
|
||||
|
||||
122
src-tauri/src/git/credentials.rs
Normal file
122
src-tauri/src/git/credentials.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user