Файли
arookieofcтаa-rookie-of-C-language e85ffa2ae8 feat(git): remember HTTPS credentials in the app secret store (#321)
Git no longer asks for the HTTPS user name and password on every fetch, pull and push. The password prompt offers "Remember for this host"; the credential is saved in the app's secret store (next to the saved SSH passwords, never in git's global configuration) only after the git command that used it succeeds, and a stored credential the server rejects is forgotten so the next command asks again. Settings › Version Control › Git lists saved credentials and forgets them.

Each git child gets its own id, passed to the askpass helper, so concurrent commands never save or forget each other's credentials. ide-vcs reports command completion through an observer installed at start-up instead of depending on ide-ssh. Credentials are keyed by scheme, host and port, and only git's authentication errors (Authentication failed, HTTP 401/403 responses, invalid username or password) forget a stored credential.

Fixes #272

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 10:48:29 +08:00

503 рядки
17 KiB
Rust
Неформатований Постійне посилання Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
//! Thin wrapper around the `git` executable used for every mutating operation.
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::OnceLock;
use std::sync::mpsc;
use std::time::Duration;
/// Explicit git executable from Settings › Version Control › Git (`None` = auto-detect).
static CONFIGURED: std::sync::RwLock<Option<PathBuf>> = std::sync::RwLock::new(None);
/// Sets (or clears, with `None` / an empty path) the git executable to use.
pub fn set_git_executable(path: Option<&Path>) {
let path = path.filter(|p| !p.as_os_str().is_empty()).map(Path::to_path_buf);
if *CONFIGURED.read().unwrap_or_else(|e| e.into_inner()) != path {
*CONFIGURED.write().unwrap_or_else(|e| e.into_inner()) = path;
}
}
/// Environment that routes credential prompts (`ssh` passphrases and host keys,
/// HTTPS user names and passwords) to the UI; empty = prompts fail.
static ASKPASS: std::sync::RwLock<Vec<(String, String)>> = std::sync::RwLock::new(Vec::new());
pub fn set_askpass_env(env: Vec<(String, String)>) {
*ASKPASS.write().unwrap_or_else(|e| e.into_inner()) = env;
}
/// Called once per git child with its id, success flag, and stderr.
pub type GitCommandObserver = std::sync::Arc<dyn Fn(u64, bool, &str) + Send + Sync>;
static COMMAND_OBSERVER: std::sync::RwLock<Option<(&'static str, GitCommandObserver)>> = std::sync::RwLock::new(None);
/// Installs the completion observer and the environment variable carrying each
/// child's unique id to its credential helper.
pub fn set_git_command_observer(env_name: &'static str, observer: GitCommandObserver) {
*COMMAND_OBSERVER.write().unwrap_or_else(|e| e.into_inner()) = Some((env_name, observer));
}
struct CommandSession {
id: u64,
observer: Option<GitCommandObserver>,
}
impl CommandSession {
fn start(cmd: &mut Command) -> Self {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let hook = COMMAND_OBSERVER.read().unwrap_or_else(|e| e.into_inner()).clone();
let observer = hook.map(|(env_name, observer)| {
cmd.env(env_name, id.to_string());
observer
});
Self { id, observer }
}
fn finish(&mut self, success: bool, stderr: &str) {
if let Some(observer) = self.observer.take() {
observer(self.id, success, stderr);
}
}
}
impl Drop for CommandSession {
fn drop(&mut self) {
// Cancellation or an I/O error discards only this child's pending answers.
self.finish(false, "");
}
}
/// Locates the auto-detected git executable once per process.
///
/// GUI apps on macOS start with a minimal `PATH`, so a few well-known install
/// locations are probed after the `PATH` lookup.
fn detected_git() -> Option<&'static Path> {
static GIT: OnceLock<Option<PathBuf>> = OnceLock::new();
GIT.get_or_init(|| {
if let Some(explicit) = std::env::var_os("OPENIDEA_GIT").map(PathBuf::from).filter(|p| p.is_file()) {
return Some(explicit);
}
if let Ok(found) = which::which("git") {
return Some(found);
}
let candidates: &[&str] = if cfg!(windows) {
&[r"C:\Program Files\Git\cmd\git.exe", r"C:\Program Files (x86)\Git\cmd\git.exe"]
} else {
&["/opt/homebrew/bin/git", "/usr/local/bin/git", "/usr/bin/git", "/bin/git"]
};
candidates.iter().map(PathBuf::from).find(|p| p.is_file())
})
.as_deref()
}
/// The auto-detected git executable, if any.
pub fn detected_git_executable() -> Option<PathBuf> {
detected_git().map(Path::to_path_buf)
}
pub(crate) fn git_binary() -> IdeResult<PathBuf> {
if let Some(configured) = CONFIGURED.read().unwrap_or_else(|e| e.into_inner()).clone() {
return Ok(configured);
}
detected_git()
.map(Path::to_path_buf)
.ok_or_else(|| IdeError::unavailable("git executable not found; install git and make sure it is on PATH"))
}
/// `git --version` of `executable` (or the configured / detected one), e.g. `git version 2.44.0`.
pub fn git_version(executable: Option<&Path>) -> IdeResult<String> {
let exe = match executable.filter(|p| !p.as_os_str().is_empty()) {
Some(p) => p.to_path_buf(),
None => git_binary()?,
};
let mut cmd = Command::new(&exe);
cmd.arg("--version").stdin(Stdio::null()).env("LC_ALL", "C");
ide_core::process::prepare(&mut cmd);
let out = cmd.output().map_err(|e| IdeError::unavailable(format!("cannot run {}: {e}", exe.display())))?;
let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !out.status.success() || !text.starts_with("git version") {
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(IdeError::invalid(format!(
"{} is not a git executable{}",
exe.display(),
if err.is_empty() { String::new() } else { format!(": {err}") }
)));
}
Ok(text)
}
/// Builds a `git` command with the environment every invocation needs.
pub(crate) fn command(root: &Path) -> IdeResult<Command> {
let mut cmd = Command::new(git_binary()?);
cmd.current_dir(root)
.arg("-c")
.arg("core.quotepath=off")
.arg("-c")
.arg("color.ui=false")
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C")
.env("LANG", "C")
.env("GIT_PAGER", "cat")
.env("GIT_EDITOR", "true")
.env("GIT_SEQUENCE_EDITOR", "true")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE");
for (k, v) in ASKPASS.read().unwrap_or_else(|e| e.into_inner()).iter() {
cmd.env(k, v);
}
ide_core::process::prepare(&mut cmd);
Ok(cmd)
}
pub(crate) struct Output {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub code: Option<i32>,
}
impl Output {
pub fn stdout_str(&self) -> String {
String::from_utf8_lossy(&self.stdout).into_owned()
}
}
/// Runs git to completion, optionally feeding `stdin`. Never fails on a non-zero exit.
pub(crate) fn run_raw(root: &Path, args: &[&str], stdin: Option<&[u8]>) -> IdeResult<Output> {
run_raw_env(root, args, stdin, &[])
}
/// [`run_raw`] with extra environment variables (e.g. a temporary `GIT_INDEX_FILE`).
pub(crate) fn run_raw_env(
root: &Path,
args: &[&str],
stdin: Option<&[u8]>,
env: &[(&str, &std::ffi::OsStr)],
) -> IdeResult<Output> {
let mut cmd = command(root)?;
for (k, v) in env {
cmd.env(k, v);
}
cmd.args(args)
.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
.stdout(Stdio::piped())
.stderr(Stdio::piped());
tracing::debug!(?args, "git");
let mut session = CommandSession::start(&mut cmd);
let mut child = cmd.spawn().map_err(spawn_error)?;
let writer = match (stdin, child.stdin.take()) {
(Some(data), Some(mut pipe)) => {
let data = data.to_vec();
Some(std::thread::spawn(move || {
// A broken pipe just means git stopped reading early; its exit status tells the story.
let _ = pipe.write_all(&data);
}))
}
_ => None,
};
let out = child.wait_with_output()?;
if let Some(w) = writer {
let _ = w.join();
}
session.finish(out.status.success(), &String::from_utf8_lossy(&out.stderr));
Ok(Output { stdout: out.stdout, stderr: out.stderr, code: out.status.code() })
}
/// Runs git and maps a non-zero exit status to an error carrying git's message.
pub(crate) fn run(root: &Path, args: &[&str], stdin: Option<&[u8]>) -> IdeResult<Output> {
let out = run_raw(root, args, stdin)?;
if out.code == Some(0) { Ok(out) } else { Err(failure(args, &out.stderr, &out.stdout, out.code)) }
}
/// [`run`] with extra environment variables.
pub(crate) fn run_env(
root: &Path,
args: &[&str],
stdin: Option<&[u8]>,
env: &[(&str, &std::ffi::OsStr)],
) -> IdeResult<Output> {
let out = run_raw_env(root, args, stdin, env)?;
if out.code == Some(0) { Ok(out) } else { Err(failure(args, &out.stderr, &out.stdout, out.code)) }
}
fn spawn_error(e: std::io::Error) -> IdeError {
if e.kind() == std::io::ErrorKind::NotFound {
IdeError::unavailable(format!("could not run git: {e}"))
} else {
e.into()
}
}
pub(crate) fn failure(args: &[&str], stderr: &[u8], stdout: &[u8], code: Option<i32>) -> IdeError {
let stderr = String::from_utf8_lossy(stderr).trim().to_string();
let stdout = String::from_utf8_lossy(stdout).trim().to_string();
let mut msg = if stderr.is_empty() {
stdout.clone()
} else {
// Merge/rebase/stash report conflicts on stdout; keep them next to the error.
let conflicts: Vec<&str> = stdout.lines().filter(|l| l.starts_with("CONFLICT")).collect();
if conflicts.is_empty() { stderr } else { format!("{}\n{stderr}", conflicts.join("\n")) }
};
if msg.is_empty() {
let sub = subcommand(args);
msg = match code {
Some(c) => format!("git {sub} failed with exit code {c}"),
None => format!("git {sub} was terminated"),
};
}
IdeError::new(classify(&msg), msg)
}
fn subcommand<'a>(args: &[&'a str]) -> &'a str {
let mut skip_next = false;
for a in args {
if skip_next {
skip_next = false;
} else if *a == "-c" {
skip_next = true;
} else if !a.starts_with('-') {
return a;
}
}
"git"
}
fn classify(msg: &str) -> ide_core::error::ErrorKind {
use ide_core::error::ErrorKind;
let lower = msg.to_ascii_lowercase();
if lower.contains("did not match any file")
|| lower.contains("unknown revision")
|| lower.contains("not found")
|| lower.contains("does not exist")
{
ErrorKind::NotFound
} else if lower.contains("already exists") {
ErrorKind::AlreadyExists
} else if lower.contains("permission denied") {
ErrorKind::PermissionDenied
} else {
ErrorKind::Internal
}
}
/// Runs a (network) command, streaming every progress line to `on_output`.
///
/// Lines are split on both `\r` and `\n` so git's in-place progress counters arrive
/// as separate updates. The child (and, on Unix, its whole process group — ssh,
/// remote helpers) is killed promptly once `cancel` fires.
pub(crate) fn run_streaming(
root: &Path,
args: &[&str],
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
run_streaming_env(root, args, &[], cancel, on_output)
}
/// [`run_streaming`] with extra environment variables (e.g. a sequence editor).
pub(crate) fn run_streaming_env(
root: &Path,
args: &[&str],
env: &[(&str, &std::ffi::OsStr)],
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut cmd = command(root)?;
for (k, v) in env {
cmd.env(k, v);
}
cmd.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
tracing::debug!(?args, "git (streaming)");
let mut session = CommandSession::start(&mut cmd);
let mut child = cmd.spawn().map_err(spawn_error)?;
let (tx, rx) = mpsc::channel::<Event>();
let mut readers = Vec::new();
if let Some(out) = child.stdout.take() {
readers.push(spawn_reader(out, tx.clone(), Stream::Stdout));
}
if let Some(err) = child.stderr.take() {
readers.push(spawn_reader(err, tx.clone(), Stream::Stderr));
}
drop(tx);
// Keep the tail of stderr (and stdout conflict reports) for the error message.
let mut stderr_tail: Vec<String> = Vec::new();
let mut conflicts: Vec<String> = Vec::new();
let mut open = readers.len();
while open > 0 {
if cancel.is_cancelled() {
kill(&mut child);
let _ = child.wait();
for r in readers {
let _ = r.join();
}
return Err(IdeError::cancelled());
}
match rx.recv_timeout(Duration::from_millis(40)) {
Ok(Event::Line(stream, line)) => {
if stream == Stream::Stderr {
stderr_tail.push(line.clone());
if stderr_tail.len() > 40 {
stderr_tail.remove(0);
}
} else if line.starts_with("CONFLICT") && conflicts.len() < 40 {
conflicts.push(line.clone());
}
on_output(line);
}
Ok(Event::Closed) => open -= 1,
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
// Pipes are closed; the process is exiting. Still honour cancellation while waiting.
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
if cancel.is_cancelled() {
kill(&mut child);
let _ = child.wait();
return Err(IdeError::cancelled());
}
std::thread::sleep(Duration::from_millis(10));
};
for r in readers {
let _ = r.join();
}
if status.success() {
session.finish(true, &stderr_tail.join("\n"));
return Ok(());
}
let relevant: Vec<&str> =
conflicts.iter().chain(stderr_tail.iter()).map(String::as_str).filter(|l| !is_progress_line(l)).collect();
let msg = relevant.join("\n");
session.finish(false, &stderr_tail.join("\n"));
Err(failure(args, msg.as_bytes(), b"", status.code()))
}
fn is_progress_line(line: &str) -> bool {
let progress_prefixes = [
"remote: Counting",
"remote: Compressing",
"remote: Enumerating",
"remote: Total",
"Counting objects",
"Compressing objects",
"Enumerating objects",
"Receiving objects",
"Resolving deltas",
"Writing objects",
"Delta compression",
"Unpacking objects",
];
progress_prefixes.iter().any(|p| line.starts_with(p))
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Stream {
Stdout,
Stderr,
}
enum Event {
Line(Stream, String),
Closed,
}
fn spawn_reader(
mut pipe: impl Read + Send + 'static,
tx: mpsc::Sender<Event>,
stream: Stream,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
let mut pending: Vec<u8> = Vec::new();
let flush = |pending: &mut Vec<u8>| {
if !pending.is_empty() {
let line = String::from_utf8_lossy(pending).trim_end().to_string();
pending.clear();
if !line.is_empty() {
let _ = tx.send(Event::Line(stream, line));
}
}
};
loop {
match pipe.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
for &b in &buf[..n] {
if b == b'\n' || b == b'\r' {
flush(&mut pending);
} else {
pending.push(b);
}
}
}
}
}
flush(&mut pending);
let _ = tx.send(Event::Closed);
})
}
fn kill(child: &mut Child) {
#[cfg(unix)]
{
let pid = child.id() as libc::pid_t;
// SAFETY: plain syscall; the child was spawned as leader of its own process group.
unsafe {
libc::kill(-pid, libc::SIGTERM);
}
std::thread::sleep(Duration::from_millis(20));
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
}
let _ = child.kill();
}
#[cfg(test)]
mod command_session_tests {
use super::*;
#[test]
fn command_ids_reach_children_and_completion_is_once_per_session() {
let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let seen = events.clone();
set_git_command_observer(
"OPENIDEA_TEST_COMMAND_ID",
std::sync::Arc::new(move |id, success, stderr| {
seen.lock().unwrap().push((id, success, stderr.to_owned()));
}),
);
let mut first_cmd = Command::new("git");
let mut second_cmd = Command::new("git");
let mut first = CommandSession::start(&mut first_cmd);
let second = CommandSession::start(&mut second_cmd);
let (first_id, second_id) = (first.id, second.id);
assert_ne!(first_id, second_id);
for (cmd, id) in [(&first_cmd, first_id), (&second_cmd, second_id)] {
let value = cmd.get_envs().find(|(key, _)| *key == "OPENIDEA_TEST_COMMAND_ID").unwrap().1.unwrap();
assert_eq!(value, std::ffi::OsStr::new(&id.to_string()));
}
drop(second); // cancellation discards this command's state
first.finish(true, "done");
drop(first);
let events = events.lock().unwrap();
let ours: Vec<_> = events.iter().filter(|(id, _, _)| *id == first_id || *id == second_id).cloned().collect();
assert_eq!(ours, vec![(second_id, false, String::new()), (first_id, true, "done".into())]);
}
}