Files
Loki/rust/loki-rs/src/tools/git.rs
T
Claude f11f667a4c Lot 7 : outils git exécutés par la façade
git_status, git_diff et git_clone (rust/loki-rs/src/tools/git.rs) quand la
façade tient la conversation : dépôt deviné comme le Go (sous-dossier
demandé, racine, unique sous-dossier dépôt), sorties mêlées et bornées,
jamais d'invite, clone limité à https:// et git@hôte: et rangé dans le
dossier de la discussion. Toujours sur cette machine, cible distante ou
non, comme le Go.

Parité : TestPariteOutilsGit (deux dépôts, l'un modifié, l'autre sans
commit ; aucun appel réseau) range tests/parity_tools/git.json ;
tests/tools_git.rs le retrouve à l'identique. Le rejeu des journaux des
outils est partagé (tests/common/toolsfx.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 12:19:07 +00:00

354 lines
12 KiB
Rust

//! Outils git du mode Code — port de `code_git.go` : `git_status`,
//! `git_diff`, `git_clone`. Sortie bornée, jamais d'invite interactive, et un
//! clone qui atterrit TOUJOURS dans le dossier de la discussion.
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration;
use regex::Regex;
use serde_json::{Map, Value};
use super::files::arg_str;
use super::policy::clean;
/// Sortie gardée (sa fin).
const GIT_MAX_OUTPUT: usize = 8000;
const STATUS_TIMEOUT: Duration = Duration::from_secs(20);
const DIFF_TIMEOUT: Duration = Duration::from_secs(30);
const CLONE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
/// `str` du Go : chaîne telle quelle, nombre sans décimales, rien → "".
fn go_str(v: Option<&Value>) -> String {
match v {
None | Some(Value::Null) => String::new(),
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => match n.as_f64() {
Some(f) => format!("{f:.0}"),
None => n.to_string(),
},
Some(other) => other.to_string(),
}
}
/// `exec.LookPath` : le programme est-il dans le PATH ?
fn look_path(name: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
let exts: Vec<String> = if cfg!(windows) {
std::env::var("PATHEXT")
.unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".into())
.split(';')
.map(str::to_string)
.collect()
} else {
vec![String::new()]
};
std::env::split_paths(&path).any(|dir| {
exts.iter().any(|e| {
let p = dir.join(format!("{name}{e}"));
p.is_file() && is_executable(&p)
})
})
}
#[cfg(unix)]
fn is_executable(p: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p).is_ok_and(|m| m.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
fn is_executable(_p: &Path) -> bool {
true
}
/// `(*exec.ExitError).Error()` : « exit status N », « signal: killed ».
fn exit_text(st: &std::process::ExitStatus) -> String {
if let Some(c) = st.code() {
return format!("exit status {c}");
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(sig) = st.signal() {
let name = match sig {
2 => "interrupt".to_string(),
9 => "killed".to_string(),
15 => "terminated".to_string(),
n => format!("signal {n}"),
};
return format!("signal: {name}");
}
}
st.to_string()
}
/// La fin d'un texte, `n` caractères au plus (`tailRunes`).
fn tail_chars(s: &str, n: usize) -> &str {
let count = s.chars().count();
if count <= n {
return s;
}
let start = s.char_indices().nth(count - n).map_or(0, |(i, _)| i);
&s[start..]
}
/// `runGitIn` : git dans `dir`, sorties mêlées (comme `CombinedOutput`),
/// délai borné, jamais d'invite (mot de passe, hôte inconnu).
pub async fn run_git_in<S: AsRef<str>>(dir: &Path, timeout: Duration, args: &[S]) -> String {
let args: Vec<&str> = args.iter().map(AsRef::as_ref).collect();
if !look_path("git") {
return "[erreur] git n'est pas installé sur cette machine".into();
}
// Dossier absent : le message de Go (exec, Dir).
match std::fs::metadata(dir) {
Err(e) => {
return format!(
"[erreur] chdir {}: {}",
dir.display(),
super::files::os_err_text(&e)
);
}
Ok(m) if !m.is_dir() => {
return format!("[erreur] chdir {}: not a directory", dir.display());
}
Ok(_) => {}
}
let (reader, writer) = match std::io::pipe() {
Ok(p) => p,
Err(e) => return format!("[erreur] {e}"),
};
let writer2 = match writer.try_clone() {
Ok(w) => w,
Err(e) => return format!("[erreur] {e}"),
};
let mut cmd = tokio::process::Command::new("git");
cmd.args(&args)
.current_dir(dir)
.stdin(Stdio::null())
.stdout(writer)
.stderr(writer2)
.kill_on_drop(true)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_SSH_COMMAND", "ssh -oBatchMode=yes")
.env_remove(crate::auth::INTERNAL_ENV);
let child = cmd.spawn();
// Les extrémités d'écriture gardées par la commande : fermées, sinon la
// lecture n'aurait jamais de fin.
drop(cmd);
let mut child = match child {
Ok(c) => c,
Err(e) => return format!("[erreur] {e}"),
};
let buf = Arc::new(Mutex::new(Vec::new()));
let b = buf.clone();
let read = tokio::task::spawn_blocking(move || {
let mut r = reader;
let mut chunk = [0u8; 8192];
while let Ok(n) = r.read(&mut chunk) {
if n == 0 {
break;
}
b.lock()
.unwrap_or_else(|e| e.into_inner())
.extend_from_slice(&chunk[..n]);
}
});
let status = match tokio::time::timeout(timeout, child.wait()).await {
Ok(st) => st,
Err(_) => {
let _ = child.start_kill();
let _ = child.wait().await;
return format!("[timeout] git {}", args.join(" "));
}
};
// Sortie fermée par git ; un petit-enfant qui la tiendrait encore ne
// bloque pas le tour.
let _ = tokio::time::timeout(Duration::from_secs(2), read).await;
let out = String::from_utf8_lossy(&buf.lock().unwrap_or_else(|e| e.into_inner())).into_owned();
let mut s = tail_chars(out.trim(), GIT_MAX_OUTPUT).to_string();
match status {
Ok(st) if st.success() => {}
Ok(st) => {
if s.is_empty() {
s = exit_text(&st);
}
return format!("[erreur] {s}");
}
Err(e) => return format!("[erreur] {e}"),
}
if s.is_empty() {
s = "(aucune sortie)".into();
}
s
}
/// `gitRepoDir` : le sous-dossier demandé, la racine si c'est un dépôt,
/// sinon l'unique sous-dossier qui en est un (plusieurs : à préciser).
pub fn git_repo_dir(base: &Path, dir: &str) -> Result<PathBuf, String> {
let dir = dir.trim();
if !dir.is_empty() {
if dir.contains("..") || Path::new(dir).is_absolute() {
return Err(format!("dossier invalide : {dir}"));
}
return Ok(clean(&base.join(dir)));
}
if base.join(".git").exists() {
return Ok(base.to_path_buf());
}
let mut repos: Vec<String> = std::fs::read_dir(base)
.map(|rd| {
rd.filter_map(Result::ok)
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.filter(|e| e.path().join(".git").exists())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect()
})
.unwrap_or_default();
repos.sort();
match repos.len() {
0 => Ok(base.to_path_buf()), // git dira « not a git repository »
1 => Ok(base.join(&repos[0])),
_ => Err(format!(
"plusieurs dépôts ici ({}) : précise dir",
repos.join(", ")
)),
}
}
/// `git_status`.
pub async fn tool_git_status(cwd: &Path, args: &Map<String, Value>) -> String {
match git_repo_dir(cwd, &go_str(args.get("dir"))) {
Ok(dir) => {
run_git_in(
&dir,
STATUS_TIMEOUT,
&["status", "--porcelain=v1", "--branch"],
)
.await
}
Err(e) => format!("[erreur] {e}"),
}
}
/// `git_diff` : index et arbre de travail ensemble (`diff HEAD`) ; dépôt
/// sans commit : le diff simple.
pub async fn tool_git_diff(cwd: &Path, args: &Map<String, Value>) -> String {
let dir = match git_repo_dir(cwd, &go_str(args.get("dir"))) {
Ok(d) => d,
Err(e) => return format!("[erreur] {e}"),
};
let file = arg_str(args, "file");
let with_file = |head: &[&str]| -> Vec<String> {
let mut a: Vec<String> = head.iter().map(|s| s.to_string()).collect();
if !file.trim().is_empty() {
a.extend(["--".to_string(), file.to_string()]);
}
a
};
let out = run_git_in(&dir, DIFF_TIMEOUT, &with_file(&["diff", "HEAD"])).await;
if out.starts_with("[erreur]") && out.contains("HEAD") {
return run_git_in(&dir, DIFF_TIMEOUT, &with_file(&["diff"])).await;
}
out
}
/// https:// ou git@hôte: — ni chemin local ni protocole exotique (file://,
/// ext:: qui exécute une commande).
static GIT_URL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(https://[A-Za-z0-9_.\-]+(:[0-9]+)?/|git@[A-Za-z0-9_.\-]+:)[A-Za-z0-9_.\-~/]+$")
.expect("motif d'URL git")
});
/// `filepath.Base`.
fn go_base(p: &str) -> String {
let seps: &[char] = if cfg!(windows) { &['/', '\\'] } else { &['/'] };
let t = p.trim_end_matches(seps);
if t.is_empty() {
return if p.is_empty() { ".".into() } else { "/".into() };
}
t.rsplit(seps).next().unwrap_or(t).to_string()
}
/// `git_clone` : dans le dossier de la discussion, quoi qu'il arrive.
pub async fn tool_git_clone(cwd: &Path, args: &Map<String, Value>) -> String {
let url = arg_str(args, "url").trim();
let url = format!("{}.git", url.strip_suffix(".git").unwrap_or(url).trim());
let bare = url.strip_suffix(".git").unwrap_or(&url);
if !GIT_URL.is_match(bare) && !GIT_URL.is_match(&url) {
return "[erreur] URL refusée — seuls https:// et git@hôte: sont acceptés".into();
}
let mut dir = arg_str(args, "dir").trim().to_string();
if dir.is_empty() {
dir = go_base(bare);
}
if dir.contains("..") || Path::new(&dir).is_absolute() {
return "[erreur] dossier cible invalide".into();
}
let target = clean(&cwd.join(&dir));
if std::fs::metadata(&target).is_ok() {
return format!("[erreur] le dossier {dir} existe déjà");
}
let target_s = target.to_string_lossy().into_owned();
let out = run_git_in(cwd, CLONE_TIMEOUT, &["clone", "--", &url, &target_s]).await;
if out.starts_with("[erreur]") || out.starts_with("[timeout]") {
return out;
}
format!("[ok] cloné dans {dir}/\n{out}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base_et_str_comme_le_go() {
assert_eq!(go_base("https://h/u/depot"), "depot");
assert_eq!(go_base("git@h:u/depot"), "depot");
assert_eq!(go_base("a/"), "a");
assert_eq!(go_base(""), ".");
assert_eq!(go_str(Some(&serde_json::json!(3.0))), "3");
assert_eq!(go_str(None), "");
}
#[test]
fn urls_acceptees() {
for u in [
"https://github.com/a/b",
"https://h:8443/a/b.git",
"git@github.com:a/b.git",
] {
assert!(GIT_URL.is_match(u), "{u}");
}
for u in [
"file:///etc",
"ext::sh -c id",
"/srv/depot",
"https://h/a b",
"https://hé/a",
] {
assert!(!GIT_URL.is_match(u), "{u}");
}
}
#[test]
fn depot_trouve() {
let d = tempfile::tempdir().unwrap();
let b = d.path();
assert_eq!(git_repo_dir(b, "").unwrap(), b);
std::fs::create_dir_all(b.join("un/.git")).unwrap();
assert_eq!(git_repo_dir(b, "").unwrap(), b.join("un"));
std::fs::create_dir_all(b.join("deux/.git")).unwrap();
assert_eq!(
git_repo_dir(b, "").unwrap_err(),
"plusieurs dépôts ici (deux, un) : précise dir"
);
assert_eq!(git_repo_dir(b, "deux").unwrap(), b.join("deux"));
assert!(git_repo_dir(b, "../x").is_err() && git_repo_dir(b, "/x").is_err());
}
}