| 1 | //! Workspace git surface for native clients (APPS-106). |
| 2 | //! |
| 3 | //! One authority: these routes run `git` against the server's configured |
| 4 | //! workspace through the same hardened primitives the agent tools use — |
| 5 | //! reads go through [`Git::review_command`] (filters, fsmonitor, hooks, |
| 6 | //! lazy fetches and replace-objects neutralized), writes run through |
| 7 | //! [`Git::tokio_command`] with interactive prompts disabled so a credential |
| 8 | //! or host-key prompt can never hang an HTTP request. There is no second |
| 9 | //! index, cache, or diff store here; every response is computed live from |
| 10 | //! the repository. |
| 11 | //! |
| 12 | //! Routes (workspace-scoped, matching the client contract): |
| 13 | //! GET /v1/git — branch/head/ahead-behind, per-file porcelain |
| 14 | //! entries, local branches and remotes |
| 15 | //! GET /v1/changes — the file-change inventory only (status rows) |
| 16 | //! GET /v1/diff?path= — unified diff for one workspace-relative file |
| 17 | //! GET /v1/workspace/diff — bounded whole-tree diff + per-file numstat |
| 18 | //! GET /v1/git/graph — recent commit graph rows (bounded) |
| 19 | //! POST /v1/git/stage — `{ "paths": [...] }` or `{ "all": true }` |
| 20 | //! POST /v1/git/unstage — `{ "paths": [...] }` or `{ "all": true }` |
| 21 | //! POST /v1/git/discard — `{ "paths": [...] }` (tracked only; no `all`) |
| 22 | //! POST /v1/git/commit — `{ "message": "…", "all": false }` |
| 23 | //! POST /v1/git/push — `{ "remote"?, "set_upstream"?: bool }` |
| 24 | //! POST /v1/git/branch — `{ "name": "…", "create"?: bool }` |
| 25 | //! |
| 26 | //! Mutations answer with the command output tail plus a refreshed workspace |
| 27 | //! status so the client re-renders in one round trip. The caller holds the |
| 28 | //! operator token; the repository's own hooks run for `commit` exactly as |
| 29 | //! they would for the user's terminal. |
| 30 | |
| 31 | use std::path::{Path as FsPath, PathBuf}; |
| 32 | use std::process::Stdio; |
| 33 | use std::time::Duration; |
| 34 | |
| 35 | use axum::Json; |
| 36 | use axum::extract::{Query, State}; |
| 37 | use serde::{Deserialize, Serialize}; |
| 38 | use serde_json::{Value, json}; |
| 39 | |
| 40 | use crate::dependencies::{ExternalTool as _, Git}; |
| 41 | |
| 42 | use super::workspace::{canonical_workspace, collect_workspace_status, relative_request_path}; |
| 43 | use super::{ApiError, RuntimeApiState}; |
| 44 | |
| 45 | /// Generous bound for local git work on very large repositories. |
| 46 | const GIT_READ_TIMEOUT: Duration = Duration::from_secs(30); |
| 47 | /// Pushes cross the network; still bounded so a dead remote cannot pin a |
| 48 | /// handler forever. |
| 49 | const GIT_WRITE_TIMEOUT: Duration = Duration::from_secs(120); |
| 50 | /// Output tail carried back to the client for mutations. |
| 51 | const MAX_OUTPUT_TAIL: usize = 8 * 1024; |
| 52 | /// Commit-graph row bounds. |
| 53 | const GRAPH_LIMIT_DEFAULT: usize = 100; |
| 54 | const GRAPH_LIMIT_MAX: usize = 500; |
| 55 | /// Unified-diff response caps: a single file gets a generous window; the |
| 56 | /// whole-tree surface defaults smaller and always reports `truncated`. |
| 57 | const FILE_DIFF_MAX_BYTES: usize = 512 * 1024; |
| 58 | const WORKSPACE_DIFF_DEFAULT_BYTES: usize = 256 * 1024; |
| 59 | const WORKSPACE_DIFF_MAX_BYTES: usize = 4 * 1024 * 1024; |
| 60 | /// The empty tree — diff base for repositories whose HEAD is unborn. |
| 61 | const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; |
| 62 | /// Branch/commit message caps — generous for real messages, hostile to |
| 63 | /// accidental binary paste. |
| 64 | const MAX_COMMIT_MESSAGE_BYTES: usize = 64 * 1024; |
| 65 | const MAX_BRANCH_NAME_BYTES: usize = 256; |
| 66 | const MAX_PATH_ARGS: usize = 512; |
| 67 | |
| 68 | // --------------------------------------------------------------------------- |
| 69 | // git invocation |
| 70 | // --------------------------------------------------------------------------- |
| 71 | |
| 72 | struct GitRun { |
| 73 | status_success: bool, |
| 74 | exit_code: Option<i32>, |
| 75 | stdout: String, |
| 76 | stderr: String, |
| 77 | } |
| 78 | |
| 79 | /// Hardened read path: the same primitive review/tooling uses, so fsmonitor, |
| 80 | /// content filters, hooks, lazy fetches and replace-objects cannot run inside |
| 81 | /// an HTTP read either. |
| 82 | async fn git_read(workspace: &FsPath, args: &[&str]) -> Result<GitRun, ApiError> { |
| 83 | let workspace = workspace.to_path_buf(); |
| 84 | let command = tokio::task::spawn_blocking(move || Git::review_command(&workspace)) |
| 85 | .await |
| 86 | .map_err(|_| ApiError::internal("git read setup failed"))? |
| 87 | .map_err(|error| ApiError::internal(format!("git is unavailable: {error}")))?; |
| 88 | let mut command = tokio::process::Command::from(command); |
| 89 | command.args(args).kill_on_drop(true); |
| 90 | finish_git(command.output(), GIT_READ_TIMEOUT).await |
| 91 | } |
| 92 | |
| 93 | /// Write path for operator-driven mutations. Non-interactive by contract: |
| 94 | /// no terminal prompt, no pager, and BatchMode ssh (unless the user already |
| 95 | /// pins their own `GIT_SSH_COMMAND`) so a key prompt can never hang the |
| 96 | /// request. Hooks and filters run exactly as they do for the user's own |
| 97 | /// `git` — a Review-sheet commit is the user's commit. |
| 98 | async fn git_write(workspace: &FsPath, args: Vec<String>) -> Result<GitRun, ApiError> { |
| 99 | let mut command = Git::tokio_command() |
| 100 | .ok_or_else(|| ApiError::internal("git is not installed or not in PATH"))?; |
| 101 | command |
| 102 | .args(&args) |
| 103 | .current_dir(workspace) |
| 104 | .stdin(Stdio::null()) |
| 105 | .env("GIT_TERMINAL_PROMPT", "0") |
| 106 | .env("GIT_PAGER", "") |
| 107 | .kill_on_drop(true); |
| 108 | if std::env::var_os("GIT_SSH_COMMAND").is_none() { |
| 109 | command.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes"); |
| 110 | } |
| 111 | finish_git(command.output(), GIT_WRITE_TIMEOUT).await |
| 112 | } |
| 113 | |
| 114 | async fn finish_git( |
| 115 | output: impl std::future::Future<Output = std::io::Result<std::process::Output>>, |
| 116 | timeout: Duration, |
| 117 | ) -> Result<GitRun, ApiError> { |
| 118 | let output = tokio::time::timeout(timeout, output) |
| 119 | .await |
| 120 | .map_err(|_| ApiError::internal("git operation timed out"))? |
| 121 | .map_err(|error| ApiError::internal(format!("failed to run git: {error}")))?; |
| 122 | Ok(GitRun { |
| 123 | status_success: output.status.success(), |
| 124 | exit_code: output.status.code(), |
| 125 | stdout: String::from_utf8_lossy(&output.stdout).into_owned(), |
| 126 | stderr: String::from_utf8_lossy(&output.stderr).into_owned(), |
| 127 | }) |
| 128 | } |
| 129 | |
| 130 | fn output_tail(run: &GitRun) -> String { |
| 131 | let mut text = String::new(); |
| 132 | for part in [run.stdout.trim(), run.stderr.trim()] { |
| 133 | if !part.is_empty() { |
| 134 | if !text.is_empty() { |
| 135 | text.push('\n'); |
| 136 | } |
| 137 | text.push_str(part); |
| 138 | } |
| 139 | } |
| 140 | if text.len() > MAX_OUTPUT_TAIL { |
| 141 | let mut boundary = text.len() - MAX_OUTPUT_TAIL; |
| 142 | while !text.is_char_boundary(boundary) { |
| 143 | boundary -= 1; |
| 144 | } |
| 145 | text = text[boundary..].to_string(); |
| 146 | } |
| 147 | text |
| 148 | } |
| 149 | |
| 150 | /// A non-repo workspace is a 404, not a 500: `rev-parse --is-inside-work-tree` |
| 151 | /// exits 128 there, so probe directly rather than through the erroring helper. |
| 152 | fn require_repo(workspace: &FsPath) -> Result<(), ApiError> { |
| 153 | match Git::output(&["rev-parse", "--is-inside-work-tree"], workspace) { |
| 154 | Ok(output) |
| 155 | if output.status.success() |
| 156 | && String::from_utf8_lossy(&output.stdout).trim() == "true" => |
| 157 | { |
| 158 | Ok(()) |
| 159 | } |
| 160 | Ok(_) => Err(ApiError::not_found("workspace is not a git repository")), |
| 161 | Err(error) => Err(ApiError::internal(format!("failed to run git: {error}"))), |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | /// Cheap one-shot read used only for repo probes where the hardened review |
| 166 | /// command's filter dance would be wasted work. |
| 167 | fn run_git_sync(workspace: &FsPath, args: &[&str]) -> Result<String, ApiError> { |
| 168 | let output = Git::output(args, workspace) |
| 169 | .map_err(|error| ApiError::internal(format!("failed to run git: {error}")))?; |
| 170 | if !output.status.success() { |
| 171 | return Err(ApiError::internal(format!( |
| 172 | "git {} failed: {}", |
| 173 | args.first().copied().unwrap_or(""), |
| 174 | String::from_utf8_lossy(&output.stderr).trim() |
| 175 | ))); |
| 176 | } |
| 177 | Ok(String::from_utf8_lossy(&output.stdout).into_owned()) |
| 178 | } |
| 179 | |
| 180 | // --------------------------------------------------------------------------- |
| 181 | // GET /v1/git — status detail |
| 182 | // --------------------------------------------------------------------------- |
| 183 | |
| 184 | #[derive(Debug, Serialize)] |
| 185 | struct GitFileEntry { |
| 186 | path: String, |
| 187 | /// Raw porcelain v1 index (X) and worktree (Y) columns. |
| 188 | index: String, |
| 189 | worktree: String, |
| 190 | /// True when the index column records a change. |
| 191 | staged: bool, |
| 192 | /// Leading human state: modified / added / deleted / renamed / |
| 193 | /// typechange / untracked / conflicted / ignored. |
| 194 | status: &'static str, |
| 195 | #[serde(skip_serializing_if = "Option::is_none")] |
| 196 | old_path: Option<String>, |
| 197 | } |
| 198 | |
| 199 | #[derive(Debug, Serialize)] |
| 200 | pub(super) struct GitStatusDetailResponse { |
| 201 | git_repo: bool, |
| 202 | workspace: PathBuf, |
| 203 | branch: Option<String>, |
| 204 | detached: bool, |
| 205 | head: Option<String>, |
| 206 | ahead: Option<u32>, |
| 207 | behind: Option<u32>, |
| 208 | staged: usize, |
| 209 | unstaged: usize, |
| 210 | untracked: usize, |
| 211 | files: Vec<GitFileEntry>, |
| 212 | branches: Vec<String>, |
| 213 | remotes: Vec<String>, |
| 214 | } |
| 215 | |
| 216 | pub(super) async fn git_status_detail( |
| 217 | State(state): State<RuntimeApiState>, |
| 218 | ) -> Result<Json<GitStatusDetailResponse>, ApiError> { |
| 219 | let workspace = state.workspace.clone(); |
| 220 | tokio::task::spawn_blocking(move || collect_git_status_detail(&workspace)) |
| 221 | .await |
| 222 | .map_err(|_| ApiError::internal("git status failed"))? |
| 223 | .map(Json) |
| 224 | } |
| 225 | |
| 226 | fn collect_git_status_detail(workspace: &FsPath) -> Result<GitStatusDetailResponse, ApiError> { |
| 227 | let status = collect_workspace_status(workspace); |
| 228 | let mut detail = GitStatusDetailResponse { |
| 229 | git_repo: status.git_repo, |
| 230 | workspace: workspace.to_path_buf(), |
| 231 | branch: status.branch.clone(), |
| 232 | detached: false, |
| 233 | head: status.head, |
| 234 | ahead: status.ahead, |
| 235 | behind: status.behind, |
| 236 | staged: status.staged, |
| 237 | unstaged: status.unstaged, |
| 238 | untracked: status.untracked, |
| 239 | files: Vec::new(), |
| 240 | branches: Vec::new(), |
| 241 | remotes: Vec::new(), |
| 242 | }; |
| 243 | if !status.git_repo { |
| 244 | return Ok(detail); |
| 245 | } |
| 246 | detail.detached = status.branch.is_none() |
| 247 | || status |
| 248 | .branch |
| 249 | .as_deref() |
| 250 | .is_some_and(|branch| branch.starts_with("detached@")); |
| 251 | |
| 252 | // `-z` keeps paths verbatim: one NUL-terminated `XY <path>` record each, |
| 253 | // with renames/copies carrying the source path in the following record. |
| 254 | if let Ok(porcelain) = run_git_sync(workspace, &["status", "--porcelain=v1", "-z"]) { |
| 255 | detail.files = parse_porcelain(&porcelain); |
| 256 | } |
| 257 | if let Ok(branches) = run_git_sync(workspace, &["branch", "--format=%(refname:short)"]) { |
| 258 | detail.branches = branches |
| 259 | .lines() |
| 260 | .map(str::trim) |
| 261 | .filter(|line| !line.is_empty()) |
| 262 | .map(str::to_string) |
| 263 | .collect(); |
| 264 | } |
| 265 | if let Ok(remotes) = run_git_sync(workspace, &["remote"]) { |
| 266 | detail.remotes = remotes |
| 267 | .lines() |
| 268 | .map(str::trim) |
| 269 | .filter(|line| !line.is_empty()) |
| 270 | .map(str::to_string) |
| 271 | .collect(); |
| 272 | } |
| 273 | Ok(detail) |
| 274 | } |
| 275 | |
| 276 | fn parse_porcelain(porcelain: &str) -> Vec<GitFileEntry> { |
| 277 | let mut entries = Vec::new(); |
| 278 | let mut records = porcelain.split('\0').peekable(); |
| 279 | while let Some(record) = records.next() { |
| 280 | if record.is_empty() || record.starts_with("## ") { |
| 281 | continue; |
| 282 | } |
| 283 | if record.len() < 4 { |
| 284 | continue; |
| 285 | } |
| 286 | let index = record.as_bytes()[0] as char; |
| 287 | let worktree = record.as_bytes()[1] as char; |
| 288 | let path = record[3..].to_string(); |
| 289 | let mut old_path = None; |
| 290 | if matches!(index, 'R' | 'C') { |
| 291 | old_path = records.next().map(str::to_string); |
| 292 | } |
| 293 | entries.push(GitFileEntry { |
| 294 | path, |
| 295 | index: index.to_string(), |
| 296 | worktree: worktree.to_string(), |
| 297 | staged: !matches!(index, ' ' | '?' | '!'), |
| 298 | status: porcelain_status(index, worktree), |
| 299 | old_path, |
| 300 | }); |
| 301 | } |
| 302 | entries |
| 303 | } |
| 304 | |
| 305 | fn porcelain_status(index: char, worktree: char) -> &'static str { |
| 306 | match (index, worktree) { |
| 307 | ('?', '?') => "untracked", |
| 308 | ('!', '!') => "ignored", |
| 309 | ('U', _) | (_, 'U') | ('D', 'D') | ('A', 'A') => "conflicted", |
| 310 | ('R', _) | (_, 'R') => "renamed", |
| 311 | ('C', _) | (_, 'C') => "copied", |
| 312 | ('A', _) | (_, 'A') => "added", |
| 313 | ('D', _) | (_, 'D') => "deleted", |
| 314 | ('T', _) | (_, 'T') => "typechange", |
| 315 | ('M', _) | (_, 'M') => "modified", |
| 316 | _ => "unchanged", |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | // --------------------------------------------------------------------------- |
| 321 | // GET /v1/changes — the file-change inventory only |
| 322 | // --------------------------------------------------------------------------- |
| 323 | |
| 324 | /// The Review sheet's change list: the same porcelain projection as |
| 325 | /// `GET /v1/git` minus repo chrome (branches/remotes). One authority — a |
| 326 | /// client that loaded both cannot see them disagree. |
| 327 | pub(super) async fn git_changes( |
| 328 | State(state): State<RuntimeApiState>, |
| 329 | ) -> Result<Json<Value>, ApiError> { |
| 330 | let workspace = state.workspace.clone(); |
| 331 | let detail = tokio::task::spawn_blocking(move || collect_git_status_detail(&workspace)) |
| 332 | .await |
| 333 | .map_err(|_| ApiError::internal("git status failed"))??; |
| 334 | Ok(Json(json!({ |
| 335 | "git_repo": detail.git_repo, |
| 336 | "branch": detail.branch, |
| 337 | "staged": detail.staged, |
| 338 | "unstaged": detail.unstaged, |
| 339 | "untracked": detail.untracked, |
| 340 | "files": detail.files, |
| 341 | }))) |
| 342 | } |
| 343 | |
| 344 | // --------------------------------------------------------------------------- |
| 345 | // GET /v1/diff + /v1/workspace/diff — unified diffs against HEAD |
| 346 | // --------------------------------------------------------------------------- |
| 347 | |
| 348 | /// `git diff <base>` compares the worktree to <base>, covering staged and |
| 349 | /// unstaged changes in one output. On an unborn branch the base is the |
| 350 | /// empty tree, which reads every staged/tracked file as new — the honest |
| 351 | /// "everything changed" picture for a repo with no commits. |
| 352 | async fn diff_base(workspace: &FsPath) -> Result<String, ApiError> { |
| 353 | let head = git_read(workspace, &["rev-parse", "--verify", "HEAD"]).await?; |
| 354 | Ok(if head.status_success { |
| 355 | "HEAD".to_string() |
| 356 | } else { |
| 357 | EMPTY_TREE.to_string() |
| 358 | }) |
| 359 | } |
| 360 | |
| 361 | /// Byte-bounded cut at a char boundary; reports whether bytes were dropped. |
| 362 | fn bounded_patch(text: &str, limit: usize) -> (String, bool) { |
| 363 | if text.len() <= limit { |
| 364 | return (text.to_string(), false); |
| 365 | } |
| 366 | let mut end = limit; |
| 367 | while !text.is_char_boundary(end) { |
| 368 | end -= 1; |
| 369 | } |
| 370 | (text[..end].to_string(), true) |
| 371 | } |
| 372 | |
| 373 | #[derive(Deserialize)] |
| 374 | #[serde(deny_unknown_fields)] |
| 375 | pub(super) struct GitDiffQuery { |
| 376 | /// Workspace-relative file; may name a deleted file (the diff survives). |
| 377 | path: String, |
| 378 | } |
| 379 | |
| 380 | /// `GET /v1/diff?path=` — one file's unified diff against HEAD (or the empty |
| 381 | /// tree on an unborn branch). An untracked file has no diff by definition: |
| 382 | /// the response says `untracked: true` with an empty `diff` so the client |
| 383 | /// reads the file itself instead of mistaking it for unchanged. |
| 384 | pub(super) async fn git_diff( |
| 385 | State(state): State<RuntimeApiState>, |
| 386 | Query(query): Query<GitDiffQuery>, |
| 387 | ) -> Result<Json<Value>, ApiError> { |
| 388 | let path = relative_request_path(&query.path, false)?; |
| 389 | let workspace = canonical_workspace(&state.workspace)?; |
| 390 | require_repo(&workspace)?; |
| 391 | let base = diff_base(&workspace).await?; |
| 392 | let path_arg = path.to_string_lossy().into_owned(); |
| 393 | let run = git_read( |
| 394 | &workspace, |
| 395 | &[ |
| 396 | "diff", |
| 397 | "--no-color", |
| 398 | "--no-ext-diff", |
| 399 | &base, |
| 400 | "--", |
| 401 | &path_arg, |
| 402 | ], |
| 403 | ) |
| 404 | .await?; |
| 405 | if !run.status_success { |
| 406 | return Err(ApiError::internal(format!( |
| 407 | "git diff failed: {}", |
| 408 | run.stderr.trim() |
| 409 | ))); |
| 410 | } |
| 411 | let (diff, truncated) = bounded_patch(&run.stdout, FILE_DIFF_MAX_BYTES); |
| 412 | let untracked = if diff.is_empty() { |
| 413 | let status = git_read( |
| 414 | &workspace, |
| 415 | &["status", "--porcelain=v1", "-z", "--", &path_arg], |
| 416 | ) |
| 417 | .await?; |
| 418 | status |
| 419 | .stdout |
| 420 | .split('\0') |
| 421 | .any(|record| record.starts_with("??")) |
| 422 | } else { |
| 423 | false |
| 424 | }; |
| 425 | Ok(Json(json!({ |
| 426 | "ok": true, |
| 427 | "path": path_arg, |
| 428 | "base": base, |
| 429 | "untracked": untracked, |
| 430 | "diff": diff, |
| 431 | "truncated": truncated, |
| 432 | }))) |
| 433 | } |
| 434 | |
| 435 | #[derive(Deserialize)] |
| 436 | #[serde(deny_unknown_fields)] |
| 437 | pub(super) struct WorkspaceDiffQuery { |
| 438 | /// Byte cap on the returned patch (default 256 KiB, max 4 MiB). |
| 439 | limit: Option<usize>, |
| 440 | } |
| 441 | |
| 442 | /// `GET /v1/workspace/diff?limit=` — the whole tree's diff against HEAD plus |
| 443 | /// a complete `--numstat` inventory, so a client renders every changed file |
| 444 | /// row even when the patch body is truncated. |
| 445 | pub(super) async fn workspace_diff( |
| 446 | State(state): State<RuntimeApiState>, |
| 447 | Query(query): Query<WorkspaceDiffQuery>, |
| 448 | ) -> Result<Json<Value>, ApiError> { |
| 449 | let limit = query.limit.unwrap_or(WORKSPACE_DIFF_DEFAULT_BYTES); |
| 450 | if !(1024..=WORKSPACE_DIFF_MAX_BYTES).contains(&limit) { |
| 451 | return Err(ApiError::bad_request(format!( |
| 452 | "limit must be between 1024 and {WORKSPACE_DIFF_MAX_BYTES} bytes" |
| 453 | ))); |
| 454 | } |
| 455 | let workspace = canonical_workspace(&state.workspace)?; |
| 456 | require_repo(&workspace)?; |
| 457 | let base = diff_base(&workspace).await?; |
| 458 | |
| 459 | let numstat = git_read(&workspace, &["diff", "--numstat", &base]).await?; |
| 460 | if !numstat.status_success { |
| 461 | return Err(ApiError::internal(format!( |
| 462 | "git diff --numstat failed: {}", |
| 463 | numstat.stderr.trim() |
| 464 | ))); |
| 465 | } |
| 466 | let files: Vec<Value> = numstat |
| 467 | .stdout |
| 468 | .lines() |
| 469 | .filter_map(|line| { |
| 470 | let mut fields = line.splitn(3, '\t'); |
| 471 | let added = fields.next()?; |
| 472 | let deleted = fields.next()?; |
| 473 | let path = fields.next()?; |
| 474 | Some(json!({ |
| 475 | "path": path, |
| 476 | // Binary files report "-" rather than a count. |
| 477 | "added": added.parse::<u64>().ok(), |
| 478 | "deleted": deleted.parse::<u64>().ok(), |
| 479 | })) |
| 480 | }) |
| 481 | .collect(); |
| 482 | |
| 483 | let run = git_read(&workspace, &["diff", "--no-color", "--no-ext-diff", &base]).await?; |
| 484 | if !run.status_success { |
| 485 | return Err(ApiError::internal(format!( |
| 486 | "git diff failed: {}", |
| 487 | run.stderr.trim() |
| 488 | ))); |
| 489 | } |
| 490 | let (diff, truncated) = bounded_patch(&run.stdout, limit); |
| 491 | Ok(Json(json!({ |
| 492 | "ok": true, |
| 493 | "git_repo": true, |
| 494 | "base": base, |
| 495 | "files": files, |
| 496 | "diff": diff, |
| 497 | "truncated": truncated, |
| 498 | }))) |
| 499 | } |
| 500 | |
| 501 | // --------------------------------------------------------------------------- |
| 502 | // GET /v1/git/graph — bounded commit graph rows |
| 503 | // --------------------------------------------------------------------------- |
| 504 | |
| 505 | #[derive(Deserialize)] |
| 506 | #[serde(deny_unknown_fields)] |
| 507 | pub(super) struct GitGraphQuery { |
| 508 | limit: Option<usize>, |
| 509 | } |
| 510 | |
| 511 | const GRAPH_FIELD: char = '\u{1f}'; |
| 512 | const GRAPH_RECORD: char = '\u{1e}'; |
| 513 | |
| 514 | pub(super) async fn git_graph( |
| 515 | State(state): State<RuntimeApiState>, |
| 516 | Query(query): Query<GitGraphQuery>, |
| 517 | ) -> Result<Json<Value>, ApiError> { |
| 518 | let limit = query.limit.unwrap_or(GRAPH_LIMIT_DEFAULT); |
| 519 | if !(1..=GRAPH_LIMIT_MAX).contains(&limit) { |
| 520 | return Err(ApiError::bad_request(format!( |
| 521 | "limit must be between 1 and {GRAPH_LIMIT_MAX}" |
| 522 | ))); |
| 523 | } |
| 524 | let workspace = canonical_workspace(&state.workspace)?; |
| 525 | require_repo(&workspace)?; |
| 526 | let limit_arg = format!("-n{limit}"); |
| 527 | let format = format!( |
| 528 | "%H{GRAPH_FIELD}%h{GRAPH_FIELD}%P{GRAPH_FIELD}%an{GRAPH_FIELD}%ae{GRAPH_FIELD}%aI{GRAPH_FIELD}%D{GRAPH_FIELD}%s{GRAPH_RECORD}" |
| 529 | ); |
| 530 | let format_arg = format!("--format={format}"); |
| 531 | let run = git_read(&workspace, &["log", &limit_arg, &format_arg]).await?; |
| 532 | if !run.status_success { |
| 533 | // `git log` exits non-zero on an unborn branch; that is a valid empty |
| 534 | // graph, not a failure. Distinguish with a HEAD probe instead of |
| 535 | // trusting stderr text. |
| 536 | let head = git_read(&workspace, &["rev-parse", "--verify", "HEAD"]).await?; |
| 537 | if head.status_success { |
| 538 | return Err(ApiError::internal(format!( |
| 539 | "git log failed: {}", |
| 540 | run.stderr.trim() |
| 541 | ))); |
| 542 | } |
| 543 | return Ok(Json(json!({ "commits": [], "truncated": false }))); |
| 544 | } |
| 545 | let mut commits = Vec::new(); |
| 546 | for record in run.stdout.split(GRAPH_RECORD) { |
| 547 | let record = record.trim_matches('\n'); |
| 548 | if record.is_empty() { |
| 549 | continue; |
| 550 | } |
| 551 | let mut fields = record.split(GRAPH_FIELD); |
| 552 | let ( |
| 553 | Some(id), |
| 554 | Some(short), |
| 555 | Some(parents), |
| 556 | Some(name), |
| 557 | Some(email), |
| 558 | Some(timestamp), |
| 559 | Some(refs), |
| 560 | Some(subject), |
| 561 | ) = ( |
| 562 | fields.next(), |
| 563 | fields.next(), |
| 564 | fields.next(), |
| 565 | fields.next(), |
| 566 | fields.next(), |
| 567 | fields.next(), |
| 568 | fields.next(), |
| 569 | fields.next(), |
| 570 | ) |
| 571 | else { |
| 572 | continue; |
| 573 | }; |
| 574 | commits.push(json!({ |
| 575 | "id": id, |
| 576 | "short": short, |
| 577 | "parents": parents.split_whitespace().collect::<Vec<_>>(), |
| 578 | "author": { "name": name, "email": email }, |
| 579 | "timestamp": timestamp, |
| 580 | "refs": refs |
| 581 | .split(", ") |
| 582 | .map(str::trim) |
| 583 | .filter(|name| !name.is_empty()) |
| 584 | .collect::<Vec<_>>(), |
| 585 | "subject": subject, |
| 586 | })); |
| 587 | } |
| 588 | let truncated = commits.len() >= limit; |
| 589 | Ok(Json(json!({ "commits": commits, "truncated": truncated }))) |
| 590 | } |
| 591 | |
| 592 | // --------------------------------------------------------------------------- |
| 593 | // Mutations |
| 594 | // --------------------------------------------------------------------------- |
| 595 | |
| 596 | #[derive(Deserialize)] |
| 597 | #[serde(deny_unknown_fields)] |
| 598 | pub(super) struct GitPathsRequest { |
| 599 | #[serde(default)] |
| 600 | paths: Vec<String>, |
| 601 | /// Stage/unstage may take the whole tree; discard cannot. |
| 602 | #[serde(default)] |
| 603 | all: bool, |
| 604 | } |
| 605 | |
| 606 | #[derive(Deserialize)] |
| 607 | #[serde(deny_unknown_fields)] |
| 608 | pub(super) struct GitCommitRequest { |
| 609 | message: String, |
| 610 | /// Also stage tracked modifications (`git commit -a`). |
| 611 | #[serde(default)] |
| 612 | all: bool, |
| 613 | } |
| 614 | |
| 615 | #[derive(Deserialize)] |
| 616 | #[serde(deny_unknown_fields)] |
| 617 | pub(super) struct GitPushRequest { |
| 618 | remote: Option<String>, |
| 619 | #[serde(default)] |
| 620 | set_upstream: bool, |
| 621 | } |
| 622 | |
| 623 | #[derive(Deserialize)] |
| 624 | #[serde(deny_unknown_fields)] |
| 625 | pub(super) struct GitBranchRequest { |
| 626 | name: String, |
| 627 | /// Create and switch (`git switch -c`); default is switch to existing. |
| 628 | #[serde(default)] |
| 629 | create: bool, |
| 630 | } |
| 631 | |
| 632 | fn mutation_response(workspace: &FsPath, run: GitRun) -> Result<Json<Value>, ApiError> { |
| 633 | if !run.status_success { |
| 634 | let tail = output_tail(&run); |
| 635 | return Err(ApiError::bad_request(if tail.is_empty() { |
| 636 | format!("git exited {}", run.exit_code.unwrap_or(-1)) |
| 637 | } else { |
| 638 | tail |
| 639 | })); |
| 640 | } |
| 641 | Ok(Json(json!({ |
| 642 | "ok": true, |
| 643 | "output": output_tail(&run), |
| 644 | "status": collect_workspace_status(workspace), |
| 645 | }))) |
| 646 | } |
| 647 | |
| 648 | /// Validate and collect pathspecs. Every path is workspace-relative with no |
| 649 | /// `.`/`..`/`.git` components and is passed after `--`, so it can never be |
| 650 | /// read as an option. |
| 651 | fn validated_paths(request: &GitPathsRequest, allow_all: bool) -> Result<Vec<String>, ApiError> { |
| 652 | if request.all && !allow_all { |
| 653 | return Err(ApiError::bad_request( |
| 654 | "discard requires explicit paths; refusing to discard the whole tree", |
| 655 | )); |
| 656 | } |
| 657 | if request.all && !request.paths.is_empty() { |
| 658 | return Err(ApiError::bad_request( |
| 659 | "all and paths are mutually exclusive", |
| 660 | )); |
| 661 | } |
| 662 | if !request.all && request.paths.is_empty() { |
| 663 | return Err(ApiError::bad_request("paths is required (or all: true)")); |
| 664 | } |
| 665 | if request.paths.len() > MAX_PATH_ARGS { |
| 666 | return Err(ApiError::bad_request(format!( |
| 667 | "at most {MAX_PATH_ARGS} paths per request" |
| 668 | ))); |
| 669 | } |
| 670 | request |
| 671 | .paths |
| 672 | .iter() |
| 673 | .map(|raw| { |
| 674 | relative_request_path(raw, false).map(|path| path.to_string_lossy().into_owned()) |
| 675 | }) |
| 676 | .collect() |
| 677 | } |
| 678 | |
| 679 | pub(super) async fn git_stage( |
| 680 | State(state): State<RuntimeApiState>, |
| 681 | Json(request): Json<GitPathsRequest>, |
| 682 | ) -> Result<Json<Value>, ApiError> { |
| 683 | let workspace = canonical_workspace(&state.workspace)?; |
| 684 | require_repo(&workspace)?; |
| 685 | let paths = validated_paths(&request, true)?; |
| 686 | let mut args = vec!["add".to_string()]; |
| 687 | if request.all { |
| 688 | args.push("--all".to_string()); |
| 689 | } |
| 690 | if !paths.is_empty() { |
| 691 | args.push("--".to_string()); |
| 692 | args.extend(paths); |
| 693 | } |
| 694 | mutation_response(&workspace, git_write(&workspace, args).await?) |
| 695 | } |
| 696 | |
| 697 | pub(super) async fn git_unstage( |
| 698 | State(state): State<RuntimeApiState>, |
| 699 | Json(request): Json<GitPathsRequest>, |
| 700 | ) -> Result<Json<Value>, ApiError> { |
| 701 | let workspace = canonical_workspace(&state.workspace)?; |
| 702 | require_repo(&workspace)?; |
| 703 | let paths = validated_paths(&request, true)?; |
| 704 | let mut args = vec!["restore".to_string(), "--staged".to_string()]; |
| 705 | if request.all { |
| 706 | args.push(":/".to_string()); |
| 707 | } |
| 708 | if !paths.is_empty() { |
| 709 | args.push("--".to_string()); |
| 710 | args.extend(paths); |
| 711 | } |
| 712 | mutation_response(&workspace, git_write(&workspace, args).await?) |
| 713 | } |
| 714 | |
| 715 | pub(super) async fn git_discard( |
| 716 | State(state): State<RuntimeApiState>, |
| 717 | Json(request): Json<GitPathsRequest>, |
| 718 | ) -> Result<Json<Value>, ApiError> { |
| 719 | let workspace = canonical_workspace(&state.workspace)?; |
| 720 | require_repo(&workspace)?; |
| 721 | let paths = validated_paths(&request, false)?; |
| 722 | let mut args = vec!["checkout".to_string(), "--".to_string()]; |
| 723 | args.extend(paths); |
| 724 | mutation_response(&workspace, git_write(&workspace, args).await?) |
| 725 | } |
| 726 | |
| 727 | pub(super) async fn git_commit( |
| 728 | State(state): State<RuntimeApiState>, |
| 729 | Json(request): Json<GitCommitRequest>, |
| 730 | ) -> Result<Json<Value>, ApiError> { |
| 731 | let message = request.message.trim(); |
| 732 | if message.is_empty() { |
| 733 | return Err(ApiError::bad_request("message is required")); |
| 734 | } |
| 735 | if message.len() > MAX_COMMIT_MESSAGE_BYTES { |
| 736 | return Err(ApiError::bad_request(format!( |
| 737 | "message must be at most {MAX_COMMIT_MESSAGE_BYTES} bytes" |
| 738 | ))); |
| 739 | } |
| 740 | let workspace = canonical_workspace(&state.workspace)?; |
| 741 | require_repo(&workspace)?; |
| 742 | let mut args = vec!["commit".to_string()]; |
| 743 | if request.all { |
| 744 | args.push("--all".to_string()); |
| 745 | } |
| 746 | args.push("--message".to_string()); |
| 747 | args.push(message.to_string()); |
| 748 | mutation_response(&workspace, git_write(&workspace, args).await?) |
| 749 | } |
| 750 | |
| 751 | pub(super) async fn git_push( |
| 752 | State(state): State<RuntimeApiState>, |
| 753 | Json(request): Json<GitPushRequest>, |
| 754 | ) -> Result<Json<Value>, ApiError> { |
| 755 | let workspace = canonical_workspace(&state.workspace)?; |
| 756 | require_repo(&workspace)?; |
| 757 | let remote = request |
| 758 | .remote |
| 759 | .as_deref() |
| 760 | .map(str::trim) |
| 761 | .filter(|remote| !remote.is_empty()) |
| 762 | .map(str::to_string); |
| 763 | if let Some(remote) = &remote |
| 764 | && !remote |
| 765 | .bytes() |
| 766 | .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'/')) |
| 767 | { |
| 768 | return Err(ApiError::bad_request("remote must be a remote name")); |
| 769 | } |
| 770 | let mut args = vec!["push".to_string()]; |
| 771 | if request.set_upstream { |
| 772 | let branch = run_git_sync(&workspace, &["rev-parse", "--abbrev-ref", "HEAD"])?; |
| 773 | let branch = branch.trim(); |
| 774 | if branch.is_empty() || branch == "HEAD" { |
| 775 | return Err(ApiError::bad_request( |
| 776 | "cannot set upstream from a detached HEAD", |
| 777 | )); |
| 778 | } |
| 779 | args.push("--set-upstream".to_string()); |
| 780 | args.push(remote.unwrap_or_else(|| "origin".to_string())); |
| 781 | args.push(branch.to_string()); |
| 782 | } else if let Some(remote) = remote { |
| 783 | args.push(remote); |
| 784 | } |
| 785 | mutation_response(&workspace, git_write(&workspace, args).await?) |
| 786 | } |
| 787 | |
| 788 | pub(super) async fn git_branch( |
| 789 | State(state): State<RuntimeApiState>, |
| 790 | Json(request): Json<GitBranchRequest>, |
| 791 | ) -> Result<Json<Value>, ApiError> { |
| 792 | let name = request.name.trim(); |
| 793 | if name.is_empty() || name.len() > MAX_BRANCH_NAME_BYTES { |
| 794 | return Err(ApiError::bad_request("name is required")); |
| 795 | } |
| 796 | let workspace = canonical_workspace(&state.workspace)?; |
| 797 | require_repo(&workspace)?; |
| 798 | // `check-ref-format --branch` is the ref authority — it rejects option |
| 799 | // lookalikes, `..`, `@{`, control bytes, and every other unsafe name. |
| 800 | let check = git_write( |
| 801 | &workspace, |
| 802 | vec![ |
| 803 | "check-ref-format".to_string(), |
| 804 | "--branch".to_string(), |
| 805 | name.to_string(), |
| 806 | ], |
| 807 | ) |
| 808 | .await?; |
| 809 | if !check.status_success { |
| 810 | return Err(ApiError::bad_request("name is not a valid branch")); |
| 811 | } |
| 812 | let mut args = vec!["switch".to_string()]; |
| 813 | if request.create { |
| 814 | args.push("--create".to_string()); |
| 815 | } |
| 816 | args.push(name.to_string()); |
| 817 | mutation_response(&workspace, git_write(&workspace, args).await?) |
| 818 | } |
| 819 |