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>
485 lines
22 KiB
Rust
485 lines
22 KiB
Rust
//! 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")),
|
||
}
|
||
}
|