Files
codecraft/server/src/git.rs
T
Richard ZinkandClaude Opus 5.5 0f4bfa0ab0 Git identity from the settings for all agent shells, terminals and the git panel
Set GIT_AUTHOR_*/GIT_COMMITTER_* from Settings → Account → Git (also inside the
sandbox), so a repository's own user.name/user.email no longer wins; tell agents
not to change the git identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:22:25 +01:00

485 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Git client for the file manager. Runs the git CLI without a shell; file
//! names always follow `--`, refs and URLs are validated.
use crate::api::{ApiError, ApiResult};
use crate::app::App;
use crate::files::checked_path;
use crate::store::User;
use axum::extract::State;
use axum::http::StatusCode;
use axum::{Extension, Json};
use serde::Deserialize;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::process::Command;
type AppState = Arc<App>;
/// Nearest directory containing `.git`.
pub fn repo_root(dir: &Path) -> Option<PathBuf> {
let mut d = dir.to_path_buf();
loop {
if d.join(".git").exists() {
return Some(d);
}
if !d.pop() {
return None;
}
}
}
struct Out {
ok: bool,
stdout: String,
stderr: String,
}
/// Options that keep repository content from running code (hooks, fsmonitor).
const SAFE_OPTS: &[&str] = &["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "protocol.ext.allow=never"];
tokio::task_local! {
/// Sandbox of the requesting user for all git calls of this request.
static SANDBOX: Option<crate::sandbox::SandboxSpec>;
/// The user's git config (credentials, identity) for unsandboxed runs.
static GITCONFIG: Option<std::path::PathBuf>;
/// The user's commit identity (overrides a repository's own user.name/email).
static GITENV: Vec<(String, String)>;
}
async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
let mut all: Vec<&str> = SAFE_OPTS.to_vec();
all.extend_from_slice(args);
let spec = SANDBOX.try_with(|s| s.clone()).ok().flatten();
let mut cmd = match &spec {
Some(spec) => {
if !crate::sandbox::available() {
return Err(ApiError(StatusCode::SERVICE_UNAVAILABLE, "git is disabled: the sandbox is not available".into()));
}
crate::sandbox::command(spec, dir, "git", &all)
}
None => {
let mut c = Command::new("git");
c.args(&all).current_dir(dir).env("GIT_TERMINAL_PROMPT", "0").env("GIT_PAGER", "cat").env("LC_ALL", "C");
if let Some(cfg) = GITCONFIG.try_with(|g| g.clone()).ok().flatten() {
c.env("GIT_CONFIG_GLOBAL", cfg);
}
c.envs(GITENV.try_with(|e| e.clone()).unwrap_or_default());
c
}
};
let fut = cmd.kill_on_drop(true).output();
let out = tokio::time::timeout(Duration::from_secs(timeout), fut)
.await
.map_err(|_| ApiError(StatusCode::GATEWAY_TIMEOUT, "git timed out".into()))?
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot run git: {e}")))?;
Ok(Out {
ok: out.status.success(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
})
}
fn fail(o: Out) -> ApiError {
let msg = if o.stderr.trim().is_empty() { o.stdout } else { o.stderr };
ApiError(StatusCode::BAD_REQUEST, msg.trim().to_string())
}
async fn git_ok(dir: &Path, args: &[&str], timeout: u64) -> Result<String, ApiError> {
let o = git(dir, args, timeout).await?;
if o.ok { Ok(o.stdout) } else { Err(fail(o)) }
}
fn valid_ref(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with('-')
&& !name.contains("..")
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.'))
}
#[derive(Deserialize)]
pub struct GitBody {
/// Repository directory, or any path inside it.
path: String,
action: String,
#[serde(default)]
files: Vec<String>,
#[serde(default)]
staged: bool,
message: Option<String>,
#[serde(default)]
amend: bool,
branch: Option<String>,
#[serde(default)]
create: bool,
rev: Option<String>,
url: Option<String>,
limit: Option<u32>,
}
/// Repo-relative paths for the given absolute file paths (all checked).
fn rel_files(app: &App, user: &User, repo: &Path, files: &[String]) -> Result<Vec<String>, ApiError> {
files
.iter()
.map(|f| {
let p = if Path::new(f).is_absolute() { PathBuf::from(f) } else { repo.join(f) };
let p = checked_path(app, user, &p.to_string_lossy(), false)?;
p.strip_prefix(repo)
.map(|r| r.to_string_lossy().into_owned())
.map_err(|_| ApiError(StatusCode::BAD_REQUEST, "file is outside the repository".into()))
})
.collect()
}
fn parse_status(raw: &str) -> Value {
let mut branch = Value::Null;
let mut upstream = Value::Null;
let (mut ahead, mut behind) = (0, 0);
let mut files = Vec::new();
let mut entries = raw.split('\0').filter(|s| !s.is_empty());
while let Some(e) = entries.next() {
if let Some(h) = e.strip_prefix("## ") {
// "main...origin/main [ahead 1, behind 2]" or "No commits yet on main"
let (names, info) = h.split_once(" [").map(|(a, b)| (a, b.trim_end_matches(']'))).unwrap_or((h, ""));
let names = names.trim_start_matches("No commits yet on ");
let (b, u) = names.split_once("...").map(|(a, b)| (a, Some(b))).unwrap_or((names, None));
branch = json!(b);
upstream = json!(u);
for part in info.split(", ") {
if let Some(n) = part.strip_prefix("ahead ") {
ahead = n.parse().unwrap_or(0);
}
if let Some(n) = part.strip_prefix("behind ") {
behind = n.parse().unwrap_or(0);
}
}
continue;
}
if e.len() < 4 {
continue;
}
let (x, y) = (&e[0..1], &e[1..2]);
let path = &e[3..];
let mut item = json!({"path": path, "index": x, "worktree": y});
// Renames/copies carry the original path as the next entry.
if x == "R" || x == "C" {
item["from"] = json!(entries.next());
}
files.push(item);
}
json!({"branch": branch, "upstream": upstream, "ahead": ahead, "behind": behind, "files": files})
}
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
let spec = crate::sandbox::spec_for(&app, Some(&user.id));
let cfg = crate::gitcreds::global_config(&app, &user);
let env = crate::gitcreds::identity_env(&app, &user.id);
SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, handle_inner(user, app, b)))).await
}
async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
let target = checked_path(&app, &user, &b.path, b.action != "clone")?;
// Actions that create a repository.
match b.action.as_str() {
"init" => {
if !target.is_dir() {
return Err(ApiError(StatusCode::BAD_REQUEST, "not a directory".into()));
}
let out = git_ok(&target, &["init", "-q"], 30).await?;
return Ok(Json(json!({"ok": true, "output": out})));
}
"clone" => {
let url = b.url.as_deref().unwrap_or("").trim().to_string();
let allowed = url.starts_with("https://") || url.starts_with("http://") || url.starts_with("ssh://") || url.starts_with("git@");
if !allowed || url.contains(char::is_whitespace) {
return Err(ApiError(StatusCode::BAD_REQUEST, "unsupported repository URL".into()));
}
if target.exists() {
return Err(ApiError(StatusCode::CONFLICT, "target folder already exists".into()));
}
let parent = target.parent().unwrap_or(Path::new("/")).to_path_buf();
let name = target.file_name().and_then(|n| n.to_str()).unwrap_or("repo").to_string();
let o = git(&parent, &["clone", "--", &url, &name], 600).await?;
if !o.ok {
return Err(fail(o));
}
return Ok(Json(json!({"ok": true, "path": target, "output": o.stderr})));
}
_ => {}
}
let start = if target.is_dir() { target.clone() } else { target.parent().unwrap_or(&target).to_path_buf() };
let repo = repo_root(&start).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "not a git repository".into()))?;
// The repository itself must be inside the user's folders.
checked_path(&app, &user, &repo.to_string_lossy(), true)?;
let result = match b.action.as_str() {
"status" => {
let raw = git_ok(&repo, &["status", "--porcelain=v1", "-b", "-z", "--untracked-files=all"], 60).await?;
let mut v = parse_status(&raw);
v["repo"] = json!(repo);
v
}
"diff" => {
let files = rel_files(&app, &user, &repo, &b.files)?;
let mut args = vec!["diff", "--no-color", "--no-ext-diff"];
if b.staged {
args.push("--cached");
}
args.push("--");
args.extend(files.iter().map(String::as_str));
let mut out = git_ok(&repo, &args, 60).await?;
// Untracked files have no diff: show them as new.
if out.is_empty() && !b.staged && files.len() == 1 {
let o = git(&repo, &["diff", "--no-color", "--no-index", "--", "/dev/null", &files[0]], 60).await?;
out = o.stdout;
}
out.truncate(out.char_indices().nth(600_000).map(|(i, _)| i).unwrap_or(out.len()));
json!({"diff": out})
}
"stage" | "unstage" | "discard" => {
let files = rel_files(&app, &user, &repo, &b.files)?;
if files.is_empty() {
return Err(ApiError(StatusCode::BAD_REQUEST, "no files".into()));
}
let mut args: Vec<&str> = match b.action.as_str() {
"stage" => vec!["add", "-A"],
"unstage" => vec!["restore", "--staged"],
_ => vec!["restore", "--worktree"],
};
args.push("--");
args.extend(files.iter().map(String::as_str));
git_ok(&repo, &args, 60).await?;
json!({"ok": true})
}
"commit" => {
let msg = b.message.as_deref().unwrap_or("").trim().to_string();
if msg.is_empty() && !b.amend {
return Err(ApiError(StatusCode::BAD_REQUEST, "commit message is empty".into()));
}
// Use the repository's identity if configured, otherwise the CodeCraft user.
let has_identity = git(&repo, &["config", "user.email"], 10).await.map(|o| o.ok && !o.stdout.trim().is_empty()).unwrap_or(false);
let name = format!("user.name={}", if user.display_name.is_empty() { &user.username } else { &user.display_name });
let email = format!("user.email={}@users.codecraft.local", user.username);
let mut args: Vec<&str> = Vec::new();
if !has_identity {
args.extend(["-c", &name, "-c", &email]);
}
args.push("commit");
if b.amend {
args.push("--amend");
if msg.is_empty() {
args.push("--no-edit");
}
}
if !msg.is_empty() {
args.extend(["-m", &msg]);
}
let out = git_ok(&repo, &args, 60).await?;
json!({"ok": true, "output": out})
}
"log" => {
let n = b.limit.unwrap_or(50).min(500).to_string();
let raw = git(&repo, &["log", "-n", &n, "--pretty=format:%H%x1f%an%x1f%at%x1f%s%x1f%D%x1e"], 60).await?;
let commits: Vec<Value> = raw
.stdout
.split('\u{1e}')
.filter(|s| !s.trim().is_empty())
.map(|c| {
let f: Vec<&str> = c.trim_start_matches('\n').split('\u{1f}').collect();
json!({
"hash": f.first(), "author": f.get(1),
"time": f.get(2).and_then(|t| t.parse::<i64>().ok()).map(|t| t * 1000),
"subject": f.get(3), "refs": f.get(4),
})
})
.collect();
json!({"commits": commits})
}
"show" => {
let rev = b.rev.as_deref().unwrap_or("");
if rev.len() < 4 || !rev.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid commit".into()));
}
let mut out = git_ok(&repo, &["show", "--no-color", "--stat", "--patch", rev], 60).await?;
out.truncate(out.char_indices().nth(600_000).map(|(i, _)| i).unwrap_or(out.len()));
json!({"diff": out})
}
"branches" => {
let raw = git_ok(&repo, &["branch", "-a", "--format=%(refname:short)%1f%(HEAD)%1f%(upstream:short)"], 30).await?;
let list: Vec<Value> = raw
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
let f: Vec<&str> = l.split('\u{1f}').collect();
json!({"name": f.first(), "current": f.get(1) == Some(&"*"), "upstream": f.get(2).filter(|u| !u.is_empty())})
})
.collect();
json!({"branches": list})
}
"checkout" => {
let branch = b.branch.as_deref().unwrap_or("");
if !valid_ref(branch) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid branch name".into()));
}
let args: Vec<&str> = if b.create { vec!["switch", "-c", branch] } else { vec!["switch", branch] };
let o = git(&repo, &args, 60).await?;
if !o.ok {
return Err(fail(o));
}
json!({"ok": true, "output": o.stderr})
}
"fetch" | "pull" | "push" => {
let args: Vec<&str> = match b.action.as_str() {
"fetch" => vec!["fetch", "--prune"],
"pull" => vec!["pull", "--ff-only"],
_ => {
let has_upstream = git(&repo, &["rev-parse", "--abbrev-ref", "@{u}"], 10).await.map(|o| o.ok).unwrap_or(false);
if has_upstream { vec!["push"] } else { vec!["push", "-u", "origin", "HEAD"] }
}
};
let o = git(&repo, &args, 300).await?;
if b.action == "push" {
app.audit(Some(&user), "git.push", format!("{} ({})", repo.display(), if o.ok { "ok" } else { "failed" }), "");
}
if !o.ok {
return Err(fail(o));
}
json!({"ok": true, "output": format!("{}{}", o.stdout, o.stderr)})
}
other => return Err(ApiError(StatusCode::BAD_REQUEST, format!("unknown action '{other}'"))),
};
Ok(Json(result))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_porcelain_status() {
let raw = "## main...origin/main [ahead 2, behind 1]\0 M src/a.rs\0A new.txt\0R b.rs\0old.rs\0?? x y.txt\0";
let v = parse_status(raw);
assert_eq!(v["branch"], "main");
assert_eq!(v["upstream"], "origin/main");
assert_eq!(v["ahead"], 2);
assert_eq!(v["behind"], 1);
let files = v["files"].as_array().unwrap();
assert_eq!(files.len(), 4);
assert_eq!(files[2]["from"], "old.rs");
assert_eq!(files[3]["path"], "x y.txt");
assert_eq!(parse_status("## No commits yet on main\0")["branch"], "main");
}
#[test]
fn refs_are_validated() {
assert!(valid_ref("feature/login-2"));
assert!(!valid_ref("--upload-pack=x"));
assert!(!valid_ref("a..b"));
assert!(!valid_ref("a b"));
}
}
// --- worktrees per session -------------------------------------------------------
fn wt_str<'a>(wt: &'a Value, key: &str) -> &'a str {
wt[key].as_str().unwrap_or("")
}
/// Create, inspect, merge or remove the session's isolated worktree.
pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> {
let spec = crate::sandbox::spec_for(app, Some(&user.id));
let cfg = crate::gitcreds::global_config(app, user);
let env = crate::gitcreds::identity_env(app, &user.id);
let s = session.clone();
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, worktree_inner(s, action)))).await?;
if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
session.cwd = cwd.to_string();
}
if res.get("worktree").is_some() {
session.worktree = res["worktree"].clone().as_object().map(|_| res["worktree"].clone());
}
Ok(res)
}
async fn worktree_inner(session: crate::types::Session, action: &str) -> Result<Value, ApiError> {
let bad = |m: &str| ApiError(StatusCode::BAD_REQUEST, m.to_string());
match action {
"create" => {
if session.worktree.is_some() {
return Err(bad("this session already has a worktree"));
}
let cwd = PathBuf::from(&session.cwd);
let repo = repo_root(&cwd).ok_or_else(|| bad("the session folder is not in a git repository"))?;
let sub = cwd.strip_prefix(&repo).map(|p| p.to_path_buf()).unwrap_or_default();
let short: String = session.id.chars().rev().take(8).collect::<String>().chars().rev().collect();
let branch = format!("codecraft/{short}");
let path = repo.join(".codecraft").join("worktrees").join(&short);
let base = git_ok(&repo, &["rev-parse", "--abbrev-ref", "HEAD"], 30).await?.trim().to_string();
if base == "HEAD" {
return Err(bad("the repository is in detached HEAD state – check out a branch first"));
}
// Keep the worktrees out of the repository's status (local only, not committed).
let common = git_ok(&repo, &["rev-parse", "--git-common-dir"], 30).await?.trim().to_string();
let common = if Path::new(&common).is_absolute() { PathBuf::from(common) } else { repo.join(common) };
let exclude = common.join("info").join("exclude");
let current = std::fs::read_to_string(&exclude).unwrap_or_default();
if !current.lines().any(|l| l.trim() == ".codecraft/") {
let _ = std::fs::create_dir_all(exclude.parent().unwrap());
let _ = std::fs::write(&exclude, format!("{current}{}.codecraft/\n", if current.is_empty() || current.ends_with('\n') { "" } else { "\n" }));
}
std::fs::create_dir_all(path.parent().unwrap()).map_err(|e| bad(&e.to_string()))?;
let p = path.display().to_string();
git_ok(&repo, &["worktree", "add", "-b", &branch, &p, "HEAD"], 120).await?;
let wt = json!({"repo": repo, "path": path, "branch": branch, "base": base, "sub": sub});
let cwd = if sub.as_os_str().is_empty() { path.clone() } else { path.join(&sub) };
Ok(json!({"ok": true, "worktree": wt, "cwd": cwd}))
}
"status" => {
let wt = session.worktree.clone().ok_or_else(|| bad("no worktree"))?;
let path = PathBuf::from(wt_str(&wt, "path"));
let dirty = git_ok(&path, &["status", "--porcelain"], 60).await?.lines().count();
let range = format!("{}..{}", wt_str(&wt, "base"), wt_str(&wt, "branch"));
let commits = git_ok(&path, &["log", "--oneline", &range], 60).await.unwrap_or_default();
let stat = git_ok(&path, &["diff", "--shortstat", wt_str(&wt, "base")], 60).await.unwrap_or_default();
Ok(json!({"worktree": wt, "dirty_files": dirty, "commits": commits.lines().collect::<Vec<_>>(), "diffstat": stat.trim()}))
}
"merge" => {
let wt = session.worktree.clone().ok_or_else(|| bad("no worktree"))?;
let path = PathBuf::from(wt_str(&wt, "path"));
let repo = PathBuf::from(wt_str(&wt, "repo"));
let (branch, base) = (wt_str(&wt, "branch"), wt_str(&wt, "base"));
// Uncommitted work in the worktree is committed first.
if !git_ok(&path, &["status", "--porcelain"], 60).await?.trim().is_empty() {
git_ok(&path, &["add", "-A"], 120).await?;
let msg = format!("CodeCraft: {}", if session.title.is_empty() { "session changes" } else { session.title.as_str() });
git_ok(&path, &["commit", "-q", "-m", &msg], 120).await?;
}
let current = git_ok(&repo, &["rev-parse", "--abbrev-ref", "HEAD"], 30).await?.trim().to_string();
if current != base {
return Err(bad(&format!("the main working copy is on '{current}', not on '{base}' – check out '{base}' first")));
}
let msg = format!("Merge {branch}{}", if session.title.is_empty() { String::new() } else { format!(": {}", session.title) });
let o = git(&repo, &["merge", "--no-ff", "-m", &msg, branch], 300).await?;
if !o.ok {
let _ = git(&repo, &["merge", "--abort"], 60).await;
return Err(ApiError(StatusCode::CONFLICT, format!("merge failed (aborted, nothing changed): {}", if o.stderr.trim().is_empty() { o.stdout.trim() } else { o.stderr.trim() })));
}
Ok(json!({"ok": true, "merged": branch, "into": base, "output": o.stdout.trim()}))
}
"remove" => {
let wt = session.worktree.clone().ok_or_else(|| bad("no worktree"))?;
let repo = PathBuf::from(wt_str(&wt, "repo"));
let p = wt_str(&wt, "path").to_string();
git_ok(&repo, &["worktree", "remove", "--force", &p], 120).await?;
let _ = git(&repo, &["branch", "-D", wt_str(&wt, "branch")], 60).await;
let sub = wt_str(&wt, "sub");
let cwd = if sub.is_empty() { repo.clone() } else { repo.join(sub) };
Ok(json!({"ok": true, "worktree": Value::Null, "cwd": if cwd.is_dir() { cwd } else { repo }}))
}
_ => Err(bad("unknown worktree action")),
}
}