返回 CodeWhale
git.rs
根目录 / crates / tui / src / runtime_api / git.rs
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
819 lines RUST