优化终端里的git操作
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user