优化终端里的git操作

This commit is contained in:
xintaofei
2026-03-21 16:29:36 +08:00
parent 6e2ae6fb36
commit 51e9d15c8e
4 changed files with 137 additions and 168 deletions

View File

@@ -3,51 +3,36 @@ use std::collections::HashMap;
use tauri::Manager;
use tauri::State;
use crate::db::AppDatabase;
use crate::git_credential;
use crate::terminal::error::TerminalError;
use crate::terminal::manager::{SpawnOptions, TerminalManager};
use crate::terminal::types::TerminalInfo;
/// Temp files created for a terminal credential session.
struct TerminalCredFiles {
cred_file: std::path::PathBuf,
helper_script: std::path::PathBuf,
}
/// Build extra env vars and temp credential files for the terminal session.
/// Build extra env vars for the terminal session.
///
/// Uses `credential.helper` with a custom credential helper script that speaks
/// git's structured credential protocol (host/protocol on stdin, username/password
/// on stdout). This is added via `GIT_CONFIG_COUNT` which APPENDS to the user's
/// existing credential helpers (e.g. macOS Keychain) for multi-valued keys.
/// Our helper is tried first; if it has no match, git falls through to existing helpers.
async fn prepare_credential_env(
db: &AppDatabase,
/// Uses `credential.helper` with a script that calls the app binary with
/// `--credential-helper`. The binary opens the DB, looks up the matching
/// account, and outputs credentials. No credentials are written to disk.
fn prepare_credential_env(
app_data_dir: &std::path::Path,
terminal_id: &str,
) -> (Option<HashMap<String, String>>, Option<TerminalCredFiles>) {
let accounts = match git_credential::load_github_accounts(&db.conn).await {
Some(s) if !s.accounts.is_empty() => s.accounts,
_ => return (None, None),
) -> Option<HashMap<String, String>> {
// Get the path to the current running binary
let app_binary = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
eprintln!("[TERM] failed to get current exe path: {}", e);
return None;
}
};
let cred_file = app_data_dir.join(format!("git-creds-{}.tmp", terminal_id));
if let Err(e) = git_credential::write_credential_store_file(&accounts, &cred_file) {
eprintln!("[TERM] failed to write credential store file: {}", e);
return (None, None);
}
let helper_script = match git_credential::create_credential_helper_script(
app_data_dir,
&cred_file,
terminal_id,
&app_binary,
) {
Ok(p) => p,
Err(e) => {
eprintln!("[TERM] failed to create credential helper script: {}", e);
let _ = std::fs::remove_file(&cred_file);
return (None, None);
return None;
}
};
@@ -62,19 +47,14 @@ async fn prepare_credential_env(
"GIT_CONFIG_KEY_0".to_string(),
"credential.helper".to_string(),
);
// credential.helper values without '!' prefix get "git-credential-" prepended.
// The '!' prefix tells git to run it as a raw shell command.
// The '!' prefix tells git to run as a raw shell command (not git-credential-<name>).
// Paths with spaces (e.g. "Application Support") must be quoted.
env.insert(
"GIT_CONFIG_VALUE_0".to_string(),
format!("!\"{}\"", helper_path_str),
);
let files = TerminalCredFiles {
cred_file,
helper_script,
};
(Some(env), Some(files))
Some(env)
}
#[tauri::command]
@@ -82,11 +62,9 @@ pub async fn terminal_spawn(
working_dir: String,
initial_command: Option<String>,
manager: State<'_, TerminalManager>,
db: State<'_, AppDatabase>,
app_handle: tauri::AppHandle,
window: tauri::WebviewWindow,
) -> Result<String, TerminalError> {
// Generate terminal ID early so we can use it for the credential file name
let terminal_id = uuid::Uuid::new_v4().to_string();
let app_data_dir = app_handle
@@ -94,12 +72,7 @@ pub async fn terminal_spawn(
.app_data_dir()
.map_err(|e| TerminalError::SpawnFailed(e.to_string()))?;
let (extra_env, cred_files) =
prepare_credential_env(&db, &app_data_dir, &terminal_id).await;
let temp_files = cred_files
.map(|f| vec![f.cred_file, f.helper_script])
.unwrap_or_default();
let extra_env = prepare_credential_env(&app_data_dir);
manager.spawn_with_id(
SpawnOptions {
@@ -108,7 +81,7 @@ pub async fn terminal_spawn(
owner_window_label: window.label().to_string(),
initial_command,
extra_env,
temp_files,
temp_files: vec![],
},
app_handle,
)

View File

@@ -7,103 +7,32 @@ use crate::models::system::{GitHubAccount, GitHubAccountsSettings};
const GITHUB_ACCOUNTS_KEY: &str = "github_accounts";
/// Write a git credential-store file containing all stored accounts.
/// Create a credential helper that calls the app binary directly with
/// `--credential-helper` flag. The app binary opens the DB, looks up
/// the matching account, and outputs credentials to stdout.
///
/// The credential-store format is one URL per line:
/// `https://username:token@hostname`
///
/// Returns the path to the written file.
pub fn write_credential_store_file(
accounts: &[GitHubAccount],
file_path: &Path,
) -> std::io::Result<()> {
use std::io::Write;
let mut content = String::new();
for account in accounts {
let host = extract_host(&account.server_url).unwrap_or_default();
if host.is_empty() {
continue;
}
// URL-encode username and token to handle special characters
let username = urlencoding::encode(&account.username);
let token = urlencoding::encode(&account.token);
content.push_str(&format!("https://{}:{}@{}\n", username, token, host));
}
let mut file = std::fs::File::create(file_path)?;
file.write_all(content.as_bytes())?;
// Restrict permissions on Unix (owner read/write only)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(file_path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
/// Create a credential helper script that reads from a credential-store file
/// using git's structured credential protocol.
///
/// Git's credential helper protocol passes structured key=value pairs on stdin:
/// protocol=https
/// host=github.com
///
/// And expects the response on stdout:
/// username=xxx
/// password=xxx
///
/// This is far more reliable than GIT_ASKPASS (which requires parsing English prompts).
/// The script is per-terminal (named with terminal_id) to avoid race conditions.
/// This is the simplest and most reliable approach:
/// - No HTTP server, no temp credential files
/// - Always reads latest accounts from DB
/// - No special-character / encoding issues (Rust handles strings natively)
/// - Single shared script across all terminals
pub fn create_credential_helper_script(
app_data_dir: &Path,
cred_store_path: &Path,
terminal_id: &str,
app_binary_path: &Path,
) -> std::io::Result<PathBuf> {
let cred_store_str = cred_store_path.to_string_lossy();
let binary_str = app_binary_path.to_string_lossy();
#[cfg(unix)]
{
let script_path = app_data_dir.join(format!("git-credential-codeg-{}.sh", &terminal_id[..8]));
let script_path = app_data_dir.join("git-credential-codeg.sh");
let content = format!(
r#"#!/bin/sh
# Codeg credential helper: reads from credential store file.
# Codeg credential helper — calls the app binary to look up credentials.
# Only responds to "get" action; ignores "store" and "erase".
[ "$1" != "get" ] && exit 0
CRED_FILE="{cred_file}"
[ ! -f "$CRED_FILE" ] && exit 0
# Read protocol and host from stdin
HOST=""
PROTO=""
while IFS='=' read -r key value; do
[ -z "$key" ] && break
case "$key" in
host) HOST="$value" ;;
protocol) PROTO="$value" ;;
esac
done
[ -z "$HOST" ] && exit 0
# Find matching line: https://user:pass@host
LINE=$(grep -i "@$HOST" "$CRED_FILE" | head -1)
[ -z "$LINE" ] && exit 0
# Parse username and password from https://user:pass@host
USERPASS=$(echo "$LINE" | sed 's|https*://||' | sed 's|@.*||')
USER=$(echo "$USERPASS" | cut -d: -f1)
PASS=$(echo "$USERPASS" | cut -d: -f2-)
[ -z "$USER" ] && exit 0
echo "username=$USER"
echo "password=$PASS"
exec "{binary}" --credential-helper < /dev/stdin
"#,
cred_file = cred_store_str
binary = binary_str
);
std::fs::write(&script_path, content)?;
use std::os::unix::fs::PermissionsExt;
@@ -113,52 +42,112 @@ echo "password=$PASS"
#[cfg(windows)]
{
let script_path = app_data_dir.join(format!("git-credential-codeg-{}.bat", &terminal_id[..8]));
let script_path = app_data_dir.join("git-credential-codeg.bat");
let content = format!(
r#"@echo off
setlocal enabledelayedexpansion
if not "%~1"=="get" exit /b 0
set "CRED_FILE={cred_file}"
if not exist "!CRED_FILE!" exit /b 0
set "HOST="
set "PROTO="
:readloop
set /p "LINE=" || goto :match
for /f "tokens=1,* delims==" %%a in ("!LINE!") do (
if "%%a"=="host" set "HOST=%%b"
if "%%a"=="protocol" set "PROTO=%%b"
)
if defined LINE goto :readloop
:match
if not defined HOST exit /b 0
for /f "usebackq delims=" %%L in ("!CRED_FILE!") do (
echo %%L | findstr /i "!HOST!" >nul
if !errorlevel! equ 0 (
set "FOUND=%%L"
goto :parse
)
)
exit /b 0
:parse
set "FOUND=!FOUND:https://=!"
for /f "tokens=1 delims=@" %%a in ("!FOUND!") do set "USERPASS=%%a"
for /f "tokens=1,2 delims=:" %%a in ("!USERPASS!") do (
echo username=%%a
echo password=%%b
)
"{binary}" --credential-helper
"#,
cred_file = cred_store_str
binary = binary_str
);
std::fs::write(&script_path, content)?;
Ok(script_path)
}
}
/// Run the credential helper mode (called from main when `--credential-helper` is detected).
///
/// Reads git's credential protocol from stdin (host=xxx, protocol=xxx),
/// opens the DB, finds the matching account, and outputs username/password
/// to stdout. Exits immediately — does NOT start the Tauri GUI.
pub fn run_credential_helper() {
use std::io::BufRead;
// Read host from stdin (git credential protocol)
let mut host = String::new();
let stdin = std::io::stdin();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if line.is_empty() {
break;
}
if let Some(value) = line.strip_prefix("host=") {
host = value.to_string();
}
}
if host.is_empty() {
return;
}
// Resolve app data dir the same way Tauri does: ~/Library/Application Support/<identifier>
let app_data_dir = match resolve_app_data_dir() {
Some(d) => d,
None => return,
};
let db_path = app_data_dir.join("codeg.db");
if !db_path.exists() {
return;
}
// Open DB with a lightweight synchronous connection (no async runtime needed)
let db_url = format!(
"sqlite:{}?mode=ro",
urlencoding::encode(&db_path.to_string_lossy())
);
// Use a minimal tokio runtime just for the DB query
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return,
};
rt.block_on(async {
let opts = sea_orm::ConnectOptions::new(db_url);
let conn = match sea_orm::Database::connect(opts).await {
Ok(c) => c,
Err(_) => return,
};
let settings = match load_github_accounts(&conn).await {
Some(s) => s,
None => return,
};
let remote_url = format!("https://{}", host);
if let Some(account) = find_matching_account(&settings.accounts, &remote_url) {
println!("username={}", account.username);
println!("password={}", account.token);
}
});
}
/// Resolve the app data directory (same path Tauri uses).
fn resolve_app_data_dir() -> Option<std::path::PathBuf> {
// On macOS: ~/Library/Application Support/app.codeg
// On Linux: ~/.local/share/app.codeg
// On Windows: %APPDATA%/app.codeg
#[cfg(target_os = "macos")]
{
dirs::data_dir().map(|d| d.join("app.codeg"))
}
#[cfg(target_os = "linux")]
{
dirs::data_dir().map(|d| d.join("app.codeg"))
}
#[cfg(target_os = "windows")]
{
dirs::data_dir().map(|d| d.join("app.codeg"))
}
}
/// Ensure the GIT_ASKPASS helper script exists in the app data directory.
/// Returns the path to the script.
pub fn ensure_askpass_script(app_data_dir: &Path) -> std::io::Result<PathBuf> {

View File

@@ -2,7 +2,7 @@ mod acp;
mod app_error;
mod commands;
mod db;
mod git_credential;
pub mod git_credential;
mod models;
mod network;
mod parsers;

View File

@@ -2,5 +2,12 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
// When called as a git credential helper, handle it immediately and exit.
// This avoids starting the full Tauri GUI runtime.
if std::env::args().any(|a| a == "--credential-helper") {
codeg_lib::git_credential::run_credential_helper();
return;
}
codeg_lib::run()
}