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

@@ -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();