返回 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 //! diffs and token reads go through [`Git::review_command`] (filters,
6 //! fsmonitor, hooks, 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. Status uses normal Git filters and untracked settings,
11 //! matching the workspace counts and operator writes.
12 //!
13 //! Routes (workspace-scoped, matching the client contract):
14 //! GET /v1/git — branch/head/ahead-behind, per-file porcelain
15 //! entries, local branches and remotes
16 //! GET /v1/changes — the file-change inventory only (status rows)
17 //! GET /v1/diff?path= — unified diff for one workspace-relative file
18 //! GET /v1/workspace/diff — bounded whole-tree diff + per-file numstat
19 //! GET /v1/git/graph — recent commit graph rows (bounded)
20 //! POST /v1/git/stage — `{ "paths": [...] }` or `{ "all": true }`
21 //! POST /v1/git/unstage — `{ "paths": [...] }` or `{ "all": true }`
22 //! POST /v1/git/discard — `{ "paths": [...] }` (tracked only; no `all`)
23 //! POST /v1/git/commit — `{ "message": "…", "all": false }`
24 //! POST /v1/git/push — `{ "remote"?, "set_upstream"?: bool }`
25 //! POST /v1/git/branch — `{ "name": "…", "create"?: bool }`
26 //!
27 //! Mutations answer with the command output tail plus a refreshed workspace
28 //! status and the full `current` detail so the client re-renders in one
29 //! round trip. The caller holds the operator token; the repository's own
30 //! hooks run for `commit` exactly as they would for the user's terminal.
31 //!
32 //! Preconditions (#6647): the detail read carries `head_oid`, `index_token`,
33 //! a whole-tree `revision` and a per-row `files[].rev`. Stage, unstage,
34 //! discard and commit accept an optional `expect` built from those values;
35 //! when the repository no longer matches, the route answers 409
36 //! `git_state_changed` with the current detail and writes nothing. Every
37 //! path in this module — request paths, `files[].path`, `expect.files`
38 //! keys — is workspace-relative; the one translation from git's
39 //! repository-root frame is [`workspace_frame_path`]. Status is best-effort:
40 //! unreadable rows have no token, and incomplete reads have no whole-tree
41 //! revision. These guards do not authorize content the read could not hash.
42
43 use std::collections::{BTreeMap, BTreeSet};
44 use std::path::{Path as FsPath, PathBuf};
45 use std::time::Duration;
46
47 use axum::Json;
48 use axum::extract::{Query, State};
49 use axum::http::StatusCode;
50 use axum::response::{IntoResponse, Response};
51 use serde::{Deserialize, Deserializer, Serialize};
52 use serde_json::{Value, json};
53 use sha2::{Digest, Sha256};
54
55 use crate::dependencies::{ExternalTool as _, Git};
56
57 use super::workspace::{
58 FILE_SERVE_MAX_BYTES, canonical_workspace, collect_workspace_status, content_revision,
59 relative_request_path,
60 };
61 use super::{ApiError, RuntimeApiState};
62
63 /// Generous bound for local git work on very large repositories.
64 const GIT_READ_TIMEOUT: Duration = Duration::from_secs(30);
65 /// Pushes cross the network; still bounded so a dead remote cannot pin a
66 /// handler forever.
67 const GIT_WRITE_TIMEOUT: Duration = Duration::from_secs(120);
68 /// Output tail carried back to the client for mutations.
69 const MAX_OUTPUT_TAIL: usize = 8 * 1024;
70 /// Commit-graph row bounds.
71 const GRAPH_LIMIT_DEFAULT: usize = 100;
72 const GRAPH_LIMIT_MAX: usize = 500;
73 /// Unified-diff response caps: a single file gets a generous window; the
74 /// whole-tree surface defaults smaller and always reports `truncated`.
75 const FILE_DIFF_MAX_BYTES: usize = 512 * 1024;
76 const WORKSPACE_DIFF_DEFAULT_BYTES: usize = 256 * 1024;
77 const WORKSPACE_DIFF_MAX_BYTES: usize = 4 * 1024 * 1024;
78 /// The empty tree — diff base for repositories whose HEAD is unborn.
79 const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
80 /// Branch/commit message caps — generous for real messages, hostile to
81 /// accidental binary paste.
82 const MAX_COMMIT_MESSAGE_BYTES: usize = 64 * 1024;
83 const MAX_BRANCH_NAME_BYTES: usize = 256;
84 const MAX_PATH_ARGS: usize = 512;
85 /// Per-read bound on working-tree bytes hashed for `files[].rev`. Rows past
86 /// the budget get a stat fingerprint (`s-` token) instead of a content digest
87 /// (`c-` token), so a large untracked tree cannot turn a status poll into
88 /// gigabytes of reads. Stat tokens are display-only: guarded writes refuse
89 /// them, and a tree containing one has no whole-tree revision.
90 const REV_CONTENT_BUDGET_BYTES: u64 = 64 * 1024 * 1024;
91 const REV_CONTENT_BUDGET_FILES: usize = 4_096;
92
93 // ---------------------------------------------------------------------------
94 // git invocation
95 // ---------------------------------------------------------------------------
96
97 struct GitRun {
98 status_success: bool,
99 exit_code: Option<i32>,
100 stdout: String,
101 stderr: String,
102 }
103
104 /// Hardened read path: the same primitive review/tooling uses, so fsmonitor,
105 /// content filters, hooks, lazy fetches and replace-objects cannot run inside
106 /// an HTTP read either.
107 async fn git_read(workspace: &FsPath, args: &[&str]) -> Result<GitRun, ApiError> {
108 let workspace = workspace.to_path_buf();
109 let command = tokio::task::spawn_blocking(move || Git::review_command(&workspace))
110 .await
111 .map_err(|_| ApiError::internal("git read setup failed"))?
112 .map_err(|error| ApiError::internal(format!("git is unavailable: {error}")))?;
113 let mut command = tokio::process::Command::from(command);
114 command.args(args);
115 finish_git(
116 crate::process_tree::contained_output(&mut command),
117 GIT_READ_TIMEOUT,
118 )
119 .await
120 }
121
122 /// Write path for operator-driven mutations. Non-interactive by contract:
123 /// [`Git::tokio_command`] carries the shared no-prompt environment
124 /// ([`crate::dependencies::apply_git_noninteractive_env`]) so a credential or
125 /// key prompt can never hang the request. Hooks and filters run exactly as they do for the user's own
126 /// `git` — a Review-sheet commit is the user's commit.
127 async fn git_write(workspace: &FsPath, args: Vec<String>) -> Result<GitRun, ApiError> {
128 let mut command = Git::tokio_command()
129 .ok_or_else(|| ApiError::internal("git is not installed or not in PATH"))?;
130 command.args(&args).current_dir(workspace);
131 // Contained: hooks and filters run here, and a timeout or a dropped
132 // request must end them too, not only the `git` process itself.
133 finish_git(
134 crate::process_tree::contained_output(&mut command),
135 GIT_WRITE_TIMEOUT,
136 )
137 .await
138 }
139
140 async fn finish_git(
141 output: impl std::future::Future<Output = std::io::Result<std::process::Output>>,
142 timeout: Duration,
143 ) -> Result<GitRun, ApiError> {
144 let output = tokio::time::timeout(timeout, output)
145 .await
146 .map_err(|_| ApiError::internal("git operation timed out"))?
147 .map_err(|error| ApiError::internal(format!("failed to run git: {error}")))?;
148 Ok(GitRun {
149 status_success: output.status.success(),
150 exit_code: output.status.code(),
151 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
152 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
153 })
154 }
155
156 fn output_tail(run: &GitRun) -> String {
157 let mut text = String::new();
158 for part in [run.stdout.trim(), run.stderr.trim()] {
159 if !part.is_empty() {
160 if !text.is_empty() {
161 text.push('\n');
162 }
163 text.push_str(part);
164 }
165 }
166 if text.len() > MAX_OUTPUT_TAIL {
167 let mut boundary = text.len() - MAX_OUTPUT_TAIL;
168 while !text.is_char_boundary(boundary) {
169 boundary -= 1;
170 }
171 text = text[boundary..].to_string();
172 }
173 text
174 }
175
176 /// A non-repo workspace is a 404, not a 500: `rev-parse --is-inside-work-tree`
177 /// exits 128 there, so probe directly rather than through the erroring helper.
178 fn require_repo(workspace: &FsPath) -> Result<(), ApiError> {
179 match Git::output(&["rev-parse", "--is-inside-work-tree"], workspace) {
180 Ok(output)
181 if output.status.success()
182 && String::from_utf8_lossy(&output.stdout).trim() == "true" =>
183 {
184 Ok(())
185 }
186 Ok(_) => Err(ApiError::not_found("workspace is not a git repository")),
187 Err(error) => Err(ApiError::internal(format!("failed to run git: {error}"))),
188 }
189 }
190
191 /// Normal Git reads for repository chrome and filter-aware status, matching
192 /// the workspace counts and operator writes.
193 fn run_git_sync(workspace: &FsPath, args: &[&str]) -> Result<String, ApiError> {
194 let output = Git::output(args, workspace)
195 .map_err(|error| ApiError::internal(format!("failed to run git: {error}")))?;
196 if !output.status.success() {
197 return Err(ApiError::internal(format!(
198 "git {} failed: {}",
199 args.first().copied().unwrap_or(""),
200 String::from_utf8_lossy(&output.stderr).trim()
201 )));
202 }
203 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
204 }
205
206 // ---------------------------------------------------------------------------
207 // GET /v1/git — status detail
208 // ---------------------------------------------------------------------------
209
210 #[derive(Clone, Debug, Serialize)]
211 struct GitFileEntry {
212 /// Workspace-relative, like every other path on this surface.
213 path: String,
214 /// Raw porcelain v1 index (X) and worktree (Y) columns.
215 index: String,
216 worktree: String,
217 /// True when the index column records a change.
218 staged: bool,
219 /// Leading human state: modified / added / deleted / renamed /
220 /// typechange / untracked / conflicted / ignored / unknown (unreadable submodule).
221 status: &'static str,
222 /// Rename/copy source, when it lies inside the workspace.
223 #[serde(skip_serializing_if = "Option::is_none")]
224 old_path: Option<String>,
225 /// Opaque per-row precondition token: this row's index entries plus the
226 /// working-tree state of every file it covers (a rename's source and
227 /// every file under a collapsed untracked directory included).
228 rev: Option<String>,
229 }
230
231 #[derive(Debug, Serialize)]
232 pub(super) struct GitStatusDetailResponse {
233 git_repo: bool,
234 workspace: PathBuf,
235 branch: Option<String>,
236 detached: bool,
237 /// Abbreviated HEAD for display. Preconditions use `head_oid`.
238 head: Option<String>,
239 /// Full HEAD commit id; null on an unborn branch.
240 head_oid: Option<String>,
241 /// Opaque token for the whole index (every stage entry, repository-wide).
242 index_token: Option<String>,
243 /// Opaque token for the whole tree: HEAD, the index and every row.
244 revision: Option<String>,
245 ahead: Option<u32>,
246 behind: Option<u32>,
247 staged: usize,
248 unstaged: usize,
249 untracked: usize,
250 files: Vec<GitFileEntry>,
251 branches: Vec<String>,
252 remotes: Vec<String>,
253 }
254
255 pub(super) async fn git_status_detail(
256 State(state): State<RuntimeApiState>,
257 ) -> Result<Json<GitStatusDetailResponse>, ApiError> {
258 let workspace = state.workspace.clone();
259 tokio::task::spawn_blocking(move || collect_git_status_detail(&workspace))
260 .await
261 .map_err(|_| ApiError::internal("git status failed"))?
262 .map(Json)
263 }
264
265 fn collect_git_status_detail(workspace: &FsPath) -> Result<GitStatusDetailResponse, ApiError> {
266 let status = collect_workspace_status(workspace);
267 let mut detail = GitStatusDetailResponse {
268 git_repo: status.git_repo,
269 workspace: workspace.to_path_buf(),
270 branch: status.branch.clone(),
271 detached: false,
272 head: status.head,
273 head_oid: None,
274 index_token: None,
275 revision: None,
276 ahead: status.ahead,
277 behind: status.behind,
278 staged: status.staged,
279 unstaged: status.unstaged,
280 untracked: status.untracked,
281 files: Vec::new(),
282 branches: Vec::new(),
283 remotes: Vec::new(),
284 };
285 if !status.git_repo {
286 return Ok(detail);
287 }
288 detail.detached = status.branch.is_none()
289 || status
290 .branch
291 .as_deref()
292 .is_some_and(|branch| branch.starts_with("detached@"));
293
294 if let Ok(branches) = run_git_sync(workspace, &["branch", "--format=%(refname:short)"]) {
295 detail.branches = branches
296 .lines()
297 .map(str::trim)
298 .filter(|line| !line.is_empty())
299 .map(str::to_string)
300 .collect();
301 }
302 if let Ok(remotes) = run_git_sync(workspace, &["remote"]) {
303 detail.remotes = remotes
304 .lines()
305 .map(str::trim)
306 .filter(|line| !line.is_empty())
307 .map(str::to_string)
308 .collect();
309 }
310 detail.head_oid = head_oid(workspace).ok().flatten();
311 if let Ok(frame) = RepoFrame::read(workspace)
312 && let Ok((rows, complete)) = status_rows(workspace, &frame)
313 {
314 let (inside, outside) = split_by_workspace(rows, &frame.prefix);
315 detail.files = inside;
316 if let Ok(tokens) = compute_tokens(workspace, &frame, &mut detail.files, &outside) {
317 detail.head_oid = tokens.head_oid;
318 detail.index_token = Some(tokens.index_token);
319 detail.revision = tokens.revision.filter(|_| complete);
320 }
321 }
322 Ok(detail)
323 }
324
325 /// Preserve normal filters and untracked mode. Only a collapsed row naming
326 /// the workspace itself needs expansion to give clients addressable paths.
327 fn status_rows(
328 workspace: &FsPath,
329 frame: &RepoFrame,
330 ) -> Result<(Vec<GitFileEntry>, bool), ApiError> {
331 let mut complete = true;
332 let mut rows = match run_git_sync(workspace, &["status", "--porcelain=v1", "-z"]) {
333 Ok(porcelain) => parse_porcelain(&porcelain),
334 Err(_) => {
335 // One broken submodule can abort all of Git's porcelain output.
336 // Recover ordinary rows, then inspect gitlinks individually so
337 // only the unreadable submodule is shown as unknown/unguardable.
338 let porcelain = run_git_sync(
339 workspace,
340 &["status", "--porcelain=v1", "-z", "--ignore-submodules=all"],
341 )?;
342 let mut rows = parse_porcelain(&porcelain);
343 for (path, records) in IndexSnapshot::read(frame)?.by_path {
344 if !records
345 .split(|byte| *byte == 0)
346 .any(|record| record.starts_with(b"160000 "))
347 {
348 continue;
349 }
350 rows.retain(|row| normalized_row_path(&row.path) != path);
351 match run_git_sync(
352 &frame.toplevel,
353 &[
354 "--literal-pathspecs",
355 "status",
356 "--porcelain=v1",
357 "-z",
358 "--",
359 &path,
360 ],
361 ) {
362 Ok(porcelain) => rows.extend(parse_porcelain(&porcelain)),
363 Err(_) => {
364 complete = false;
365 rows.push(GitFileEntry {
366 path,
367 index: " ".to_string(),
368 worktree: "?".to_string(),
369 staged: false,
370 status: "unknown",
371 old_path: None,
372 rev: None,
373 });
374 }
375 }
376 }
377 rows
378 }
379 };
380 if let Some(position) = rows
381 .iter()
382 .position(|row| row.status == "untracked" && row.path == frame.prefix)
383 {
384 match untracked_paths(&frame.toplevel, &[frame.prefix.as_str()]) {
385 Ok(paths) => {
386 rows.remove(position);
387 for path in paths {
388 rows.extend(parse_porcelain(&format!("?? {path}\0")));
389 }
390 }
391 Err(_) => complete = false,
392 }
393 }
394 Ok((rows, complete))
395 }
396
397 fn untracked_paths(workspace: &FsPath, members: &[&str]) -> Result<BTreeSet<String>, ApiError> {
398 if members.is_empty() {
399 return Ok(BTreeSet::new());
400 }
401 let mut args = vec![
402 "--literal-pathspecs",
403 "ls-files",
404 "-z",
405 "--others",
406 "--exclude-standard",
407 "--",
408 ];
409 args.extend_from_slice(members);
410 let others = review_sync_ok(workspace, &args)?;
411 Ok(others
412 .split(|byte| *byte == 0)
413 .filter(|path| !path.is_empty())
414 .map(|path| String::from_utf8_lossy(path).into_owned())
415 .collect())
416 }
417
418 /// Porcelain rows in git's frame: paths are relative to the repository root.
419 fn parse_porcelain(porcelain: &str) -> Vec<GitFileEntry> {
420 let mut entries = Vec::new();
421 let mut records = porcelain.split('\0').peekable();
422 while let Some(record) = records.next() {
423 if record.is_empty() || record.starts_with("## ") {
424 continue;
425 }
426 if record.len() < 4 {
427 continue;
428 }
429 let index = record.as_bytes()[0] as char;
430 let worktree = record.as_bytes()[1] as char;
431 let path = record[3..].to_string();
432 let mut old_path = None;
433 if matches!(index, 'R' | 'C') {
434 old_path = records.next().map(str::to_string);
435 }
436 entries.push(GitFileEntry {
437 path,
438 index: index.to_string(),
439 worktree: worktree.to_string(),
440 staged: !matches!(index, ' ' | '?' | '!'),
441 status: porcelain_status(index, worktree),
442 old_path,
443 rev: None,
444 });
445 }
446 entries
447 }
448
449 /// Move repository-root rows into the workspace frame. Omitted paths still
450 /// feed the whole-tree revision because stage-all and commit reach them.
451 /// A rename leaving the workspace is shown as its source deletion.
452 fn split_by_workspace(
453 rows: Vec<GitFileEntry>,
454 prefix: &str,
455 ) -> (Vec<GitFileEntry>, Vec<GitFileEntry>) {
456 let mut inside = Vec::new();
457 let mut outside = Vec::new();
458 for mut row in rows {
459 let old_path = row
460 .old_path
461 .as_deref()
462 .and_then(|old| workspace_frame_path(prefix, old));
463 match workspace_frame_path(prefix, &row.path) {
464 Some(path) => {
465 if row.old_path.is_some() && old_path.is_none() {
466 outside.push(row.clone());
467 }
468 row.path = path;
469 row.old_path = old_path;
470 inside.push(row);
471 }
472 None => {
473 if row.index == "R"
474 && let Some(path) = old_path
475 {
476 inside.push(GitFileEntry {
477 path,
478 index: "D".to_string(),
479 worktree: " ".to_string(),
480 staged: true,
481 status: "deleted",
482 old_path: None,
483 rev: None,
484 });
485 }
486 outside.push(row);
487 }
488 }
489 }
490 (inside, outside)
491 }
492
493 /// The one translation from git's repository-root frame to the workspace
494 /// frame. `None` means the path lies outside the workspace.
495 fn workspace_frame_path(prefix: &str, root_path: &str) -> Option<String> {
496 let path = root_path.strip_prefix(prefix)?;
497 (!path.is_empty()).then(|| path.to_string())
498 }
499
500 /// A row path as a precondition key: git's collapsed-directory `dir/` and a
501 /// request's `dir` name the same thing.
502 fn normalized_row_path(path: &str) -> &str {
503 path.trim_end_matches('/')
504 }
505
506 // ---------------------------------------------------------------------------
507 // Precondition tokens (#6647)
508 // ---------------------------------------------------------------------------
509
510 /// Hardened one-shot read for token material: the review command, so
511 /// fsmonitor, filters, hooks and replace-objects cannot run (or alter what
512 /// the token sees) during a precondition read.
513 fn review_sync(cwd: &FsPath, args: &[&str]) -> Result<std::process::Output, ApiError> {
514 #[cfg(test)]
515 tests::GIT_READS.with(|count| count.set(count.get() + 1));
516 let mut command = Git::review_command(cwd)
517 .map_err(|error| ApiError::internal(format!("git is unavailable: {error}")))?;
518 command
519 .args(args)
520 .output()
521 .map_err(|error| ApiError::internal(format!("failed to run git: {error}")))
522 }
523
524 fn review_sync_ok(cwd: &FsPath, args: &[&str]) -> Result<Vec<u8>, ApiError> {
525 let output = review_sync(cwd, args)?;
526 if !output.status.success() {
527 return Err(ApiError::internal(format!(
528 "git {} failed: {}",
529 args.iter()
530 .find(|arg| !arg.starts_with("--"))
531 .copied()
532 .unwrap_or(""),
533 String::from_utf8_lossy(&output.stderr).trim()
534 )));
535 }
536 Ok(output.stdout)
537 }
538
539 /// Where the workspace sits inside its repository.
540 struct RepoFrame {
541 toplevel: PathBuf,
542 /// `sub/dir/` for a subdirectory workspace, empty at the root.
543 prefix: String,
544 }
545
546 impl RepoFrame {
547 fn read(workspace: &FsPath) -> Result<Self, ApiError> {
548 let output = review_sync_ok(
549 workspace,
550 &["rev-parse", "--show-toplevel", "--show-prefix"],
551 )?;
552 let text = String::from_utf8_lossy(&output);
553 let mut lines = text.lines();
554 let toplevel = lines
555 .next()
556 .map(str::trim)
557 .filter(|line| !line.is_empty())
558 .ok_or_else(|| ApiError::internal("git rev-parse returned no toplevel"))?;
559 Ok(Self {
560 toplevel: PathBuf::from(toplevel),
561 prefix: lines
562 .next()
563 .unwrap_or("")
564 .trim_end_matches('\n')
565 .to_string(),
566 })
567 }
568 }
569
570 /// Full HEAD commit id, `None` on an unborn branch.
571 fn head_oid(workspace: &FsPath) -> Result<Option<String>, ApiError> {
572 let output = review_sync(workspace, &["rev-parse", "--verify", "-q", "HEAD^{commit}"])?;
573 if output.status.success() {
574 let oid = String::from_utf8_lossy(&output.stdout).trim().to_string();
575 if !oid.is_empty() {
576 return Ok(Some(oid));
577 }
578 } else {
579 let symbolic = review_sync(workspace, &["symbolic-ref", "-q", "HEAD"])?;
580 if symbolic.status.success() {
581 let reference = String::from_utf8_lossy(&symbolic.stdout);
582 let reference = reference.trim();
583 if reference.starts_with("refs/heads/") {
584 // for-each-ref warns when it ignores a broken ref. Only a
585 // clean, empty enumeration establishes an unborn branch.
586 let refs = review_sync(
587 workspace,
588 &["for-each-ref", "--format=%(refname)", reference],
589 )?;
590 if refs.status.success() && refs.stdout.is_empty() && refs.stderr.is_empty() {
591 return Ok(None);
592 }
593 }
594 }
595 }
596 Err(ApiError::internal(
597 "git HEAD does not resolve to a commit or an unborn branch",
598 ))
599 }
600
601 /// The semantic index (mode, blob, stage, path — never the stat cache that
602 /// `git status` rewrites), read once from the repository root with no
603 /// pathspec so it covers every entry, including unmerged stages and entries
604 /// outside a subdirectory workspace.
605 struct IndexSnapshot {
606 token: String,
607 /// Repository-root path → that path's raw `ls-files --stage` records.
608 by_path: BTreeMap<String, Vec<u8>>,
609 /// One scoped inventory, loaded only for directory members. None means
610 /// enumeration failed or was not requested; directory tokens fail closed.
611 untracked: Option<BTreeSet<String>>,
612 }
613
614 impl IndexSnapshot {
615 fn read(frame: &RepoFrame) -> Result<Self, ApiError> {
616 let raw = review_sync_ok(&frame.toplevel, &["ls-files", "--stage", "-z"])?;
617 let mut by_path: BTreeMap<String, Vec<u8>> = BTreeMap::new();
618 for record in raw.split(|byte| *byte == 0).filter(|r| !r.is_empty()) {
619 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
620 continue;
621 };
622 let root_path = String::from_utf8_lossy(&record[tab + 1..]);
623 let slot = by_path.entry(root_path.into_owned()).or_default();
624 slot.extend_from_slice(record);
625 slot.push(0);
626 }
627 Ok(Self {
628 token: content_revision(&raw),
629 by_path,
630 untracked: None,
631 })
632 }
633
634 fn load_untracked(&mut self, workspace: &FsPath, members: &[String]) {
635 let directories: Vec<&str> = members
636 .iter()
637 .filter(|member| {
638 !has_linked_ancestor(workspace, member)
639 && std::fs::symlink_metadata(workspace.join(member))
640 .is_ok_and(|metadata| metadata.is_dir())
641 && !workspace.join(member).join(".git").exists()
642 })
643 .map(String::as_str)
644 .collect();
645 self.untracked = untracked_paths(workspace, &directories).ok();
646 }
647
648 /// Records at `member` or anywhere below it, in path order.
649 fn records_under<'a>(&'a self, member: &'a str) -> impl Iterator<Item = (&'a str, &'a [u8])> {
650 self.by_path
651 .range::<str, _>((
652 std::ops::Bound::Included(member),
653 std::ops::Bound::Unbounded,
654 ))
655 .take_while(move |(path, _)| path.starts_with(member))
656 .filter(move |(path, _)| is_at_or_under(path, member))
657 .map(|(path, records)| (path.as_str(), records.as_slice()))
658 }
659 }
660
661 fn is_at_or_under(path: &str, member: &str) -> bool {
662 path == member
663 || path
664 .strip_prefix(member)
665 .is_some_and(|rest| rest.starts_with('/'))
666 }
667
668 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
669 enum RevMode {
670 /// Working-tree files are identified by a sha256 of their bytes (the
671 /// Files routes' content revision).
672 Content,
673 /// Display-only size + mtime (oversized files or past the read budget).
674 Stat,
675 }
676
677 impl RevMode {
678 fn tag(self) -> &'static str {
679 match self {
680 Self::Content => "c-",
681 Self::Stat => "s-",
682 }
683 }
684 }
685
686 /// One member path of a row (the row itself, or a rename's source) with
687 /// everything its token covers.
688 struct MemberListing {
689 member: String,
690 index_records: Vec<u8>,
691 /// Repository-root files whose working-tree state is part of the token.
692 files: BTreeSet<String>,
693 }
694
695 fn list_member(
696 workspace: &FsPath,
697 index: &IndexSnapshot,
698 member: &str,
699 ) -> Result<MemberListing, ApiError> {
700 let mut listing = MemberListing {
701 member: member.to_string(),
702 index_records: Vec::new(),
703 files: BTreeSet::new(),
704 };
705 for (path, records) in index.records_under(member) {
706 listing.index_records.extend_from_slice(records);
707 listing.files.insert(path.to_string());
708 }
709 // A path beyond a symlinked directory is not part of this repository's
710 // worktree; never read through the link.
711 if has_linked_ancestor(workspace, member) {
712 return Err(ApiError::internal(
713 "cannot fingerprint through a symlinked directory",
714 ));
715 }
716 match std::fs::symlink_metadata(workspace.join(member)) {
717 Ok(metadata) if metadata.is_dir() && workspace.join(member).join(".git").exists() => {
718 listing.files.insert(member.to_string());
719 }
720 Ok(metadata) if metadata.is_dir() => {
721 // Untracked files below a directory (a collapsed `dir/` row, or a
722 // directory named in a request): exactly what `git add dir`
723 // would pick up, with the same ignore rules.
724 let untracked = index
725 .untracked
726 .as_ref()
727 .ok_or_else(|| ApiError::internal("cannot enumerate untracked directory"))?;
728 for path in untracked.range(member.to_string()..) {
729 if !path.starts_with(member) {
730 break;
731 }
732 if is_at_or_under(path, member) {
733 listing.files.insert(normalized_row_path(path).to_string());
734 }
735 }
736 }
737 Ok(_) => {
738 listing.files.insert(member.to_string());
739 }
740 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
741 Err(error) => {
742 return Err(ApiError::internal(format!(
743 "cannot inspect {member}: {error}"
744 )));
745 }
746 }
747 Ok(listing)
748 }
749
750 fn has_linked_ancestor(workspace: &FsPath, member: &str) -> bool {
751 let mut current = workspace.to_path_buf();
752 let mut parts = member.split('/').peekable();
753 while let Some(part) = parts.next() {
754 if parts.peek().is_none() {
755 break;
756 }
757 current.push(part);
758 match std::fs::symlink_metadata(&current) {
759 Ok(metadata) if metadata.file_type().is_symlink() => return true,
760 Ok(_) => {}
761 Err(_) => return false,
762 }
763 }
764 false
765 }
766
767 fn stat_fingerprint(metadata: &std::fs::Metadata) -> String {
768 let mtime_ns = metadata
769 .modified()
770 .ok()
771 .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
772 .map_or(0, |duration| duration.as_nanos());
773 format!("stat:{}:{mtime_ns}", metadata.len())
774 }
775
776 /// A file's working-tree identity. Links are never followed.
777 fn worktree_fingerprint(workspace: &FsPath, path: &str, mode: RevMode) -> Result<String, ApiError> {
778 if has_linked_ancestor(workspace, path) {
779 return Err(ApiError::internal(
780 "cannot fingerprint through a symlinked directory",
781 ));
782 }
783 let full = workspace.join(path);
784 let unreadable = |error| ApiError::internal(format!("cannot fingerprint {path}: {error}"));
785 let metadata = match std::fs::symlink_metadata(&full) {
786 Ok(metadata) => metadata,
787 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok("absent".into()),
788 Err(error) => return Err(unreadable(error)),
789 };
790 let file_type = metadata.file_type();
791 if file_type.is_symlink() {
792 return std::fs::read_link(&full)
793 .map(|target| format!("link:{}", target.to_string_lossy()))
794 .map_err(unreadable);
795 }
796 if file_type.is_dir() {
797 // Gitlinks (including newly added nested repositories) record HEAD.
798 // Dirty submodule contents are summarized by status, not recursively
799 // hashed: ordinary add/checkout do not write those contents.
800 if full.join(".git").exists() {
801 let head = head_oid(&full)?;
802 let status = review_sync_ok(
803 &full,
804 &["status", "--porcelain=v1", "-z", "--ignore-submodules=none"],
805 )?;
806 return Ok(format!(
807 "gitlink:{}:{}",
808 head.as_deref().unwrap_or("unborn"),
809 content_revision(&status)
810 ));
811 }
812 return Ok("dir".into());
813 }
814 if !file_type.is_file() {
815 return Err(ApiError::internal(format!(
816 "cannot fingerprint special file {path}"
817 )));
818 }
819 #[cfg(unix)]
820 let executable = {
821 use std::os::unix::fs::PermissionsExt;
822 metadata.permissions().mode() & 0o100 != 0
823 };
824 #[cfg(not(unix))]
825 let executable = false;
826 let fingerprint = if mode == RevMode::Stat || metadata.len() > FILE_SERVE_MAX_BYTES {
827 stat_fingerprint(&metadata)
828 } else {
829 use std::io::Read as _;
830 let mut bytes = Vec::new();
831 std::fs::File::open(&full)
832 .and_then(|file| file.take(FILE_SERVE_MAX_BYTES + 1).read_to_end(&mut bytes))
833 .map_err(unreadable)?;
834 if bytes.len() as u64 > FILE_SERVE_MAX_BYTES {
835 stat_fingerprint(&metadata)
836 } else {
837 content_revision(&bytes)
838 }
839 };
840 Ok(format!("{fingerprint}:exec:{executable}"))
841 }
842
843 fn members_of(path: &str, old_path: Option<&str>) -> Vec<String> {
844 let mut members = BTreeSet::new();
845 members.insert(normalized_row_path(path).to_string());
846 if let Some(old) = old_path {
847 members.insert(normalized_row_path(old).to_string());
848 }
849 members.into_iter().collect()
850 }
851
852 fn row_rev(
853 workspace: &FsPath,
854 listings: &[MemberListing],
855 mode: RevMode,
856 ) -> Result<String, ApiError> {
857 let mut effective_mode = mode;
858 let mut hasher = Sha256::new();
859 hasher.update(b"cw-git-rev-1\0");
860 for listing in listings {
861 hasher.update(b"member\0");
862 hasher.update(listing.member.as_bytes());
863 hasher.update(b"\0index\0");
864 hasher.update(&listing.index_records);
865 hasher.update(b"\0worktree\0");
866 for file in &listing.files {
867 hasher.update(file.as_bytes());
868 hasher.update(b"\0");
869 let fingerprint = worktree_fingerprint(workspace, file, mode)?;
870 if fingerprint.starts_with("stat:") {
871 effective_mode = RevMode::Stat;
872 }
873 hasher.update(fingerprint.as_bytes());
874 hasher.update(b"\0");
875 }
876 }
877 Ok(format!(
878 "{}{}",
879 effective_mode.tag(),
880 hex(&hasher.finalize())
881 ))
882 }
883
884 fn hex(bytes: &[u8]) -> String {
885 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
886 }
887
888 /// Running content-hash budget for one read.
889 #[derive(Default)]
890 struct RevBudget {
891 bytes: u64,
892 files: usize,
893 }
894
895 impl RevBudget {
896 /// Content mode while the whole row still fits; otherwise stat mode for
897 /// the whole row (a token has exactly one mode).
898 fn choose(&mut self, workspace: &FsPath, listings: &[MemberListing]) -> RevMode {
899 let mut bytes = 0u64;
900 let mut files = 0usize;
901 for file in listings.iter().flat_map(|listing| listing.files.iter()) {
902 if !has_linked_ancestor(workspace, file)
903 && let Ok(metadata) = std::fs::symlink_metadata(workspace.join(file))
904 && metadata.is_file()
905 {
906 if metadata.len() > FILE_SERVE_MAX_BYTES {
907 return RevMode::Stat;
908 }
909 bytes += metadata.len();
910 files += 1;
911 }
912 }
913 if self.bytes + bytes <= REV_CONTENT_BUDGET_BYTES
914 && self.files + files <= REV_CONTENT_BUDGET_FILES
915 {
916 self.bytes += bytes;
917 self.files += files;
918 RevMode::Content
919 } else {
920 RevMode::Stat
921 }
922 }
923 }
924
925 struct GitTokens {
926 head_oid: Option<String>,
927 index_token: String,
928 revision: Option<String>,
929 }
930
931 fn compute_tokens(
932 workspace: &FsPath,
933 frame: &RepoFrame,
934 files: &mut [GitFileEntry],
935 outside: &[GitFileEntry],
936 ) -> Result<GitTokens, ApiError> {
937 let head = head_oid(workspace);
938 let mut index = IndexSnapshot::read(frame)?;
939 let members: Vec<String> = files
940 .iter()
941 .flat_map(|entry| {
942 members_of(&entry.path, entry.old_path.as_deref())
943 .into_iter()
944 .map(|member| format!("{}{member}", frame.prefix))
945 })
946 .chain(
947 outside
948 .iter()
949 .flat_map(|entry| members_of(&entry.path, entry.old_path.as_deref())),
950 )
951 .collect();
952 index.load_untracked(&frame.toplevel, &members);
953 let mut budget = RevBudget::default();
954 // Hidden untracked paths are deliberately not enumerated, so the read
955 // cannot authorize a repository-wide add that would include them.
956 let mut content_safe = head.is_ok()
957 && review_sync_ok(
958 workspace,
959 &[
960 "config",
961 "--default",
962 "normal",
963 "--get",
964 "status.showUntrackedFiles",
965 ],
966 )
967 .is_ok_and(|mode| String::from_utf8_lossy(&mode).trim() != "no");
968 let head_oid = head.ok().flatten();
969 let mut row_token = |entry: &GitFileEntry, prefix: &str| -> Option<String> {
970 let result = (|| {
971 if entry.status == "unknown" {
972 return Err(ApiError::internal("submodule status is unavailable"));
973 }
974 let listings = members_of(&entry.path, entry.old_path.as_deref())
975 .iter()
976 .map(|member| list_member(&frame.toplevel, &index, &format!("{prefix}{member}")))
977 .collect::<Result<Vec<_>, _>>()?;
978 let mode = budget.choose(&frame.toplevel, &listings);
979 row_rev(&frame.toplevel, &listings, mode)
980 })();
981 let rev = result.ok();
982 content_safe &= rev.as_deref().is_some_and(|rev| rev.starts_with("c-"));
983 rev
984 };
985 for entry in files.iter_mut() {
986 entry.rev = row_token(entry, &frame.prefix);
987 }
988 let mut hasher = Sha256::new();
989 hasher.update(b"cw-git-revision-1\0");
990 hasher.update(head_oid.as_deref().unwrap_or("unborn").as_bytes());
991 hasher.update(b"\0");
992 hasher.update(index.token.as_bytes());
993 hasher.update(b"\0");
994 let mut rows: Vec<&GitFileEntry> = files.iter().collect();
995 rows.sort_by(|a, b| a.path.cmp(&b.path));
996 for row in rows {
997 hasher.update(row.path.as_bytes());
998 hasher.update(b"\0");
999 hasher.update(row.rev.as_deref().unwrap_or("").as_bytes());
1000 hasher.update(b"\0");
1001 }
1002 let mut outside: Vec<&GitFileEntry> = outside.iter().collect();
1003 outside.sort_by(|a, b| a.path.cmp(&b.path));
1004 for row in outside {
1005 hasher.update(b"outside\0");
1006 hasher.update(row.path.as_bytes());
1007 hasher.update(b"\0");
1008 hasher.update(row_token(row, "").as_deref().unwrap_or("").as_bytes());
1009 hasher.update(b"\0");
1010 }
1011 Ok(GitTokens {
1012 head_oid,
1013 index_token: index.token,
1014 revision: content_safe.then(|| hex(&hasher.finalize())),
1015 })
1016 }
1017
1018 fn porcelain_status(index: char, worktree: char) -> &'static str {
1019 match (index, worktree) {
1020 ('?', '?') => "untracked",
1021 ('!', '!') => "ignored",
1022 ('U', _) | (_, 'U') | ('D', 'D') | ('A', 'A') => "conflicted",
1023 ('R', _) | (_, 'R') => "renamed",
1024 ('C', _) | (_, 'C') => "copied",
1025 ('A', _) | (_, 'A') => "added",
1026 ('D', _) | (_, 'D') => "deleted",
1027 ('T', _) | (_, 'T') => "typechange",
1028 ('M', _) | (_, 'M') => "modified",
1029 _ => "unchanged",
1030 }
1031 }
1032
1033 // ---------------------------------------------------------------------------
1034 // GET /v1/changes — the file-change inventory only
1035 // ---------------------------------------------------------------------------
1036
1037 /// The Review sheet's change list: the same porcelain projection as
1038 /// `GET /v1/git` minus repo chrome (branches/remotes). One authority — a
1039 /// client that loaded both cannot see them disagree.
1040 pub(super) async fn git_changes(
1041 State(state): State<RuntimeApiState>,
1042 ) -> Result<Json<Value>, ApiError> {
1043 let workspace = state.workspace.clone();
1044 let detail = tokio::task::spawn_blocking(move || collect_git_status_detail(&workspace))
1045 .await
1046 .map_err(|_| ApiError::internal("git status failed"))??;
1047 Ok(Json(json!({
1048 "git_repo": detail.git_repo,
1049 "branch": detail.branch,
1050 "staged": detail.staged,
1051 "unstaged": detail.unstaged,
1052 "untracked": detail.untracked,
1053 "head_oid": detail.head_oid,
1054 "index_token": detail.index_token,
1055 "revision": detail.revision,
1056 "files": detail.files,
1057 })))
1058 }
1059
1060 // ---------------------------------------------------------------------------
1061 // GET /v1/diff + /v1/workspace/diff — unified diffs against HEAD
1062 // ---------------------------------------------------------------------------
1063
1064 /// `git diff <base>` compares the worktree to <base>, covering staged and
1065 /// unstaged changes in one output. On an unborn branch the base is the
1066 /// empty tree, which reads every staged/tracked file as new — the honest
1067 /// "everything changed" picture for a repo with no commits.
1068 async fn diff_base(workspace: &FsPath) -> Result<String, ApiError> {
1069 let head = git_read(workspace, &["rev-parse", "--verify", "HEAD"]).await?;
1070 Ok(if head.status_success {
1071 "HEAD".to_string()
1072 } else {
1073 EMPTY_TREE.to_string()
1074 })
1075 }
1076
1077 /// Byte-bounded cut at a char boundary; reports whether bytes were dropped.
1078 fn bounded_patch(text: &str, limit: usize) -> (String, bool) {
1079 if text.len() <= limit {
1080 return (text.to_string(), false);
1081 }
1082 let mut end = limit;
1083 while !text.is_char_boundary(end) {
1084 end -= 1;
1085 }
1086 (text[..end].to_string(), true)
1087 }
1088
1089 #[derive(Deserialize)]
1090 #[serde(deny_unknown_fields)]
1091 pub(super) struct GitDiffQuery {
1092 /// Workspace-relative file; may name a deleted file (the diff survives).
1093 path: String,
1094 }
1095
1096 /// `GET /v1/diff?path=` — one file's unified diff against HEAD (or the empty
1097 /// tree on an unborn branch). An untracked file has no diff by definition:
1098 /// the response says `untracked: true` with an empty `diff` so the client
1099 /// reads the file itself instead of mistaking it for unchanged.
1100 pub(super) async fn git_diff(
1101 State(state): State<RuntimeApiState>,
1102 Query(query): Query<GitDiffQuery>,
1103 ) -> Result<Json<Value>, ApiError> {
1104 let path = relative_request_path(&query.path, false)?;
1105 let workspace = canonical_workspace(&state.workspace)?;
1106 require_repo(&workspace)?;
1107 let base = diff_base(&workspace).await?;
1108 let path_arg = path.to_string_lossy().into_owned();
1109 let run = git_read(&workspace, &file_diff_args(&base, &path_arg)).await?;
1110 if !run.status_success {
1111 return Err(ApiError::internal(format!(
1112 "git diff failed: {}",
1113 run.stderr.trim()
1114 )));
1115 }
1116 let (diff, truncated) = bounded_patch(&run.stdout, FILE_DIFF_MAX_BYTES);
1117 let untracked = if diff.is_empty() {
1118 let status = git_read(
1119 &workspace,
1120 &[
1121 "--literal-pathspecs",
1122 "status",
1123 "--porcelain=v1",
1124 "-z",
1125 "--ignore-submodules=dirty",
1126 "--",
1127 &path_arg,
1128 ],
1129 )
1130 .await?;
1131 status
1132 .stdout
1133 .split('\0')
1134 .any(|record| record.starts_with("??"))
1135 } else {
1136 false
1137 };
1138 Ok(Json(json!({
1139 "ok": true,
1140 "path": path_arg,
1141 "base": base,
1142 "untracked": untracked,
1143 "diff": diff,
1144 "truncated": truncated,
1145 })))
1146 }
1147
1148 /// One file's diff against `base`. The path is literal, never pathspec magic
1149 /// or a glob, so `:/…`/`:(top)…` cannot reach outside a workspace that is a
1150 /// subdirectory of its repository.
1151 fn file_diff_args<'a>(base: &'a str, path: &'a str) -> Vec<&'a str> {
1152 let mut args = vec!["--literal-pathspecs", "diff", "--no-color"];
1153 args.extend(Git::REVIEW_DIFF_ARGS);
1154 args.extend([base, "--", path]);
1155 args
1156 }
1157
1158 #[derive(Deserialize)]
1159 #[serde(deny_unknown_fields)]
1160 pub(super) struct WorkspaceDiffQuery {
1161 /// Byte cap on the returned patch (default 256 KiB, max 4 MiB).
1162 limit: Option<usize>,
1163 }
1164
1165 /// `GET /v1/workspace/diff?limit=` — the whole tree's diff against HEAD plus
1166 /// a complete `--numstat` inventory, so a client renders every changed file
1167 /// row even when the patch body is truncated.
1168 pub(super) async fn workspace_diff(
1169 State(state): State<RuntimeApiState>,
1170 Query(query): Query<WorkspaceDiffQuery>,
1171 ) -> Result<Json<Value>, ApiError> {
1172 let limit = query.limit.unwrap_or(WORKSPACE_DIFF_DEFAULT_BYTES);
1173 if !(1024..=WORKSPACE_DIFF_MAX_BYTES).contains(&limit) {
1174 return Err(ApiError::bad_request(format!(
1175 "limit must be between 1024 and {WORKSPACE_DIFF_MAX_BYTES} bytes"
1176 )));
1177 }
1178 let workspace = canonical_workspace(&state.workspace)?;
1179 require_repo(&workspace)?;
1180 let base = diff_base(&workspace).await?;
1181
1182 let mut numstat_args = vec!["diff", "--numstat"];
1183 numstat_args.extend(Git::REVIEW_DIFF_ARGS);
1184 numstat_args.push(&base);
1185 let numstat = git_read(&workspace, &numstat_args).await?;
1186 if !numstat.status_success {
1187 return Err(ApiError::internal(format!(
1188 "git diff --numstat failed: {}",
1189 numstat.stderr.trim()
1190 )));
1191 }
1192 let files: Vec<Value> = numstat
1193 .stdout
1194 .lines()
1195 .filter_map(|line| {
1196 let mut fields = line.splitn(3, '\t');
1197 let added = fields.next()?;
1198 let deleted = fields.next()?;
1199 let path = fields.next()?;
1200 Some(json!({
1201 "path": path,
1202 // Binary files report "-" rather than a count.
1203 "added": added.parse::<u64>().ok(),
1204 "deleted": deleted.parse::<u64>().ok(),
1205 }))
1206 })
1207 .collect();
1208
1209 let mut diff_args = vec!["diff", "--no-color"];
1210 diff_args.extend(Git::REVIEW_DIFF_ARGS);
1211 diff_args.push(&base);
1212 let run = git_read(&workspace, &diff_args).await?;
1213 if !run.status_success {
1214 return Err(ApiError::internal(format!(
1215 "git diff failed: {}",
1216 run.stderr.trim()
1217 )));
1218 }
1219 let (diff, truncated) = bounded_patch(&run.stdout, limit);
1220 Ok(Json(json!({
1221 "ok": true,
1222 "git_repo": true,
1223 "base": base,
1224 "files": files,
1225 "diff": diff,
1226 "truncated": truncated,
1227 })))
1228 }
1229
1230 // ---------------------------------------------------------------------------
1231 // GET /v1/git/graph — bounded commit graph rows
1232 // ---------------------------------------------------------------------------
1233
1234 #[derive(Deserialize)]
1235 #[serde(deny_unknown_fields)]
1236 pub(super) struct GitGraphQuery {
1237 limit: Option<usize>,
1238 }
1239
1240 const GRAPH_FIELD: char = '\u{1f}';
1241 const GRAPH_RECORD: char = '\u{1e}';
1242
1243 pub(super) async fn git_graph(
1244 State(state): State<RuntimeApiState>,
1245 Query(query): Query<GitGraphQuery>,
1246 ) -> Result<Json<Value>, ApiError> {
1247 let limit = query.limit.unwrap_or(GRAPH_LIMIT_DEFAULT);
1248 if !(1..=GRAPH_LIMIT_MAX).contains(&limit) {
1249 return Err(ApiError::bad_request(format!(
1250 "limit must be between 1 and {GRAPH_LIMIT_MAX}"
1251 )));
1252 }
1253 let workspace = canonical_workspace(&state.workspace)?;
1254 require_repo(&workspace)?;
1255 let limit_arg = format!("-n{limit}");
1256 let format = format!(
1257 "%H{GRAPH_FIELD}%h{GRAPH_FIELD}%P{GRAPH_FIELD}%an{GRAPH_FIELD}%ae{GRAPH_FIELD}%aI{GRAPH_FIELD}%D{GRAPH_FIELD}%s{GRAPH_RECORD}"
1258 );
1259 let format_arg = format!("--format={format}");
1260 let run = git_read(&workspace, &["log", &limit_arg, &format_arg]).await?;
1261 if !run.status_success {
1262 // `git log` exits non-zero on an unborn branch; that is a valid empty
1263 // graph, not a failure. Distinguish with a HEAD probe instead of
1264 // trusting stderr text.
1265 let head = git_read(&workspace, &["rev-parse", "--verify", "HEAD"]).await?;
1266 if head.status_success {
1267 return Err(ApiError::internal(format!(
1268 "git log failed: {}",
1269 run.stderr.trim()
1270 )));
1271 }
1272 return Ok(Json(json!({ "commits": [], "truncated": false })));
1273 }
1274 let mut commits = Vec::new();
1275 for record in run.stdout.split(GRAPH_RECORD) {
1276 let record = record.trim_matches('\n');
1277 if record.is_empty() {
1278 continue;
1279 }
1280 let mut fields = record.split(GRAPH_FIELD);
1281 let (
1282 Some(id),
1283 Some(short),
1284 Some(parents),
1285 Some(name),
1286 Some(email),
1287 Some(timestamp),
1288 Some(refs),
1289 Some(subject),
1290 ) = (
1291 fields.next(),
1292 fields.next(),
1293 fields.next(),
1294 fields.next(),
1295 fields.next(),
1296 fields.next(),
1297 fields.next(),
1298 fields.next(),
1299 )
1300 else {
1301 continue;
1302 };
1303 commits.push(json!({
1304 "id": id,
1305 "short": short,
1306 "parents": parents.split_whitespace().collect::<Vec<_>>(),
1307 "author": { "name": name, "email": email },
1308 "timestamp": timestamp,
1309 "refs": refs
1310 .split(", ")
1311 .map(str::trim)
1312 .filter(|name| !name.is_empty())
1313 .collect::<Vec<_>>(),
1314 "subject": subject,
1315 }));
1316 }
1317 let truncated = commits.len() >= limit;
1318 Ok(Json(json!({ "commits": commits, "truncated": truncated })))
1319 }
1320
1321 // ---------------------------------------------------------------------------
1322 // Mutations
1323 // ---------------------------------------------------------------------------
1324
1325 #[derive(Deserialize)]
1326 #[serde(deny_unknown_fields)]
1327 pub(super) struct GitPushRequest {
1328 remote: Option<String>,
1329 #[serde(default)]
1330 set_upstream: bool,
1331 }
1332
1333 #[derive(Deserialize)]
1334 #[serde(deny_unknown_fields)]
1335 pub(super) struct GitBranchRequest {
1336 name: String,
1337 /// Create and switch (`git switch -c`); default is switch to existing.
1338 #[serde(default)]
1339 create: bool,
1340 }
1341
1342 #[derive(Deserialize)]
1343 #[serde(deny_unknown_fields)]
1344 pub(super) struct GitPathsRequest {
1345 #[serde(default)]
1346 paths: Vec<String>,
1347 /// Stage/unstage may take the whole tree; discard cannot.
1348 #[serde(default)]
1349 all: bool,
1350 /// Optional preconditions (#6647); absent keeps the unchecked behaviour.
1351 #[serde(default)]
1352 expect: Option<GitExpect>,
1353 }
1354
1355 #[derive(Deserialize)]
1356 #[serde(deny_unknown_fields)]
1357 pub(super) struct GitCommitRequest {
1358 message: String,
1359 /// Also stage tracked modifications (`git commit -a`).
1360 #[serde(default)]
1361 all: bool,
1362 #[serde(default)]
1363 expect: Option<GitExpect>,
1364 }
1365
1366 /// What the client last read. Each present field is checked; an absent
1367 /// field is not. `head: null` means "HEAD must be unborn".
1368 #[derive(Deserialize, Default)]
1369 #[serde(deny_unknown_fields)]
1370 pub(super) struct GitExpect {
1371 #[serde(default, deserialize_with = "present")]
1372 head: Option<Option<String>>,
1373 #[serde(default)]
1374 index: Option<String>,
1375 #[serde(default, deserialize_with = "present")]
1376 revision: Option<Option<String>>,
1377 #[serde(default)]
1378 files: Option<BTreeMap<String, String>>,
1379 }
1380
1381 /// Distinguishes an explicit `null` (`Some(None)`) from an absent field.
1382 fn present<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<Option<String>>, D::Error> {
1383 Option::<String>::deserialize(deserializer).map(Some)
1384 }
1385
1386 /// A validated precondition, normalized (trimmed, lowercase hex, workspace-
1387 /// relative file keys).
1388 #[derive(Debug, Default)]
1389 struct Preconditions {
1390 head: Option<Option<String>>,
1391 index: Option<String>,
1392 revision: Option<String>,
1393 files: Option<BTreeMap<String, String>>,
1394 }
1395
1396 impl Preconditions {
1397 fn is_empty(&self) -> bool {
1398 self.head.is_none()
1399 && self.index.is_none()
1400 && self.revision.is_none()
1401 && self.files.is_none()
1402 }
1403 }
1404
1405 fn normalized_hex(raw: &str, lengths: &[usize], field: &str) -> Result<String, ApiError> {
1406 let value = raw.trim().to_ascii_lowercase();
1407 if lengths.contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1408 Ok(value)
1409 } else {
1410 Err(ApiError::bad_request(format!(
1411 "expect.{field} must be a token read from GET /v1/git"
1412 )))
1413 }
1414 }
1415
1416 /// Where the preconditions are being applied, for the rules that differ.
1417 enum ExpectTarget<'a> {
1418 /// Stage/unstage/discard with explicit, validated paths.
1419 Paths(&'a [String]),
1420 /// Stage/unstage of the whole tree.
1421 AllPaths,
1422 Commit,
1423 }
1424
1425 /// Validate `expect` before any lock or git call; every failure is a 400 and
1426 /// nothing is written.
1427 fn validate_expect(
1428 expect: Option<GitExpect>,
1429 target: ExpectTarget<'_>,
1430 ) -> Result<Preconditions, ApiError> {
1431 let Some(expect) = expect else {
1432 return Ok(Preconditions::default());
1433 };
1434 let head = match expect.head {
1435 None => None,
1436 Some(None) => Some(None),
1437 Some(Some(raw)) => Some(Some(normalized_hex(&raw, &[40, 64], "head")?)),
1438 };
1439 let index = expect
1440 .index
1441 .map(|raw| normalized_hex(&raw, &[64], "index"))
1442 .transpose()?;
1443 let revision = expect
1444 .revision
1445 .map(|raw| {
1446 let raw = raw.ok_or_else(|| ApiError::bad_request(
1447 "expect.revision cannot be null; a content-safe whole-tree revision is required",
1448 ))?;
1449 normalized_hex(&raw, &[64], "revision")
1450 })
1451 .transpose()?;
1452 let files = match (expect.files, target) {
1453 (None, _) => None,
1454 (Some(_), ExpectTarget::Commit) => {
1455 return Err(ApiError::bad_request(
1456 "expect.files applies to path operations; use expect.index or expect.revision for commit",
1457 ));
1458 }
1459 (Some(_), ExpectTarget::AllPaths) => {
1460 return Err(ApiError::bad_request(
1461 "use expect.revision for whole-tree operations",
1462 ));
1463 }
1464 (Some(raw), ExpectTarget::Paths(paths)) => {
1465 let mut files = BTreeMap::new();
1466 for (key, token) in raw {
1467 let path = relative_request_path(&key, false)?
1468 .to_string_lossy()
1469 .into_owned();
1470 let token = token.trim().to_ascii_lowercase();
1471 let valid = token.len() == 66
1472 && token.starts_with("c-")
1473 && token[2..].bytes().all(|byte| byte.is_ascii_hexdigit());
1474 if !valid {
1475 return Err(ApiError::bad_request(format!(
1476 "expect.files[{path}] must be a content-safe c- rev read from GET /v1/git; stat-only revisions cannot guard writes"
1477 )));
1478 }
1479 if files.insert(path.clone(), token).is_some() {
1480 return Err(ApiError::bad_request(format!(
1481 "expect.files names {path} twice"
1482 )));
1483 }
1484 }
1485 let requested: BTreeSet<&str> = paths.iter().map(String::as_str).collect();
1486 let guarded: BTreeSet<&str> = files.keys().map(String::as_str).collect();
1487 if requested != guarded {
1488 let missing: Vec<&str> = requested.difference(&guarded).copied().collect();
1489 let extra: Vec<&str> = guarded.difference(&requested).copied().collect();
1490 return Err(ApiError::bad_request(format!(
1491 "expect.files must name exactly the requested paths (missing: [{}]; extra: [{}])",
1492 missing.join(", "),
1493 extra.join(", ")
1494 )));
1495 }
1496 Some(files)
1497 }
1498 };
1499 Ok(Preconditions {
1500 head,
1501 index,
1502 revision,
1503 files,
1504 })
1505 }
1506
1507 /// A git-route failure. Ordinary failures keep the shared [`ApiError`]
1508 /// envelope; the precondition and busy answers add a machine `code` and
1509 /// fields a client acts on.
1510 pub(super) enum GitWriteError {
1511 Api(ApiError),
1512 Coded {
1513 status: StatusCode,
1514 code: &'static str,
1515 message: String,
1516 extra: serde_json::Map<String, Value>,
1517 },
1518 }
1519
1520 impl From<ApiError> for GitWriteError {
1521 fn from(error: ApiError) -> Self {
1522 Self::Api(error)
1523 }
1524 }
1525
1526 impl IntoResponse for GitWriteError {
1527 fn into_response(self) -> Response {
1528 match self {
1529 Self::Api(error) => error.into_response(),
1530 Self::Coded {
1531 status,
1532 code,
1533 message,
1534 mut extra,
1535 } => {
1536 extra.insert(
1537 "error".to_string(),
1538 json!({ "message": message, "status": status.as_u16(), "code": code }),
1539 );
1540 (status, Json(Value::Object(extra))).into_response()
1541 }
1542 }
1543 }
1544 }
1545
1546 fn busy_error() -> GitWriteError {
1547 GitWriteError::Coded {
1548 status: StatusCode::CONFLICT,
1549 code: "git_busy",
1550 message:
1551 "Another Git write from this runtime is still running. Try again when it finishes."
1552 .to_string(),
1553 extra: serde_json::Map::new(),
1554 }
1555 }
1556
1557 /// Compare the preconditions with the repository as it is now. Runs on a
1558 /// blocking thread under the write lock.
1559 fn check_preconditions(
1560 workspace: &FsPath,
1561 expect: &Preconditions,
1562 ) -> Result<Result<(), GitWriteError>, ApiError> {
1563 let mut stale: Vec<&'static str> = Vec::new();
1564 let mut parts: Vec<String> = Vec::new();
1565 let mut stale_paths: Vec<String> = Vec::new();
1566 let frame = RepoFrame::read(workspace)?;
1567
1568 if let Some(expected) = &expect.head {
1569 let current = head_oid(workspace)?;
1570 if &current != expected {
1571 stale.push("head");
1572 parts.push(match (expected, &current) {
1573 (None, Some(_)) => "HEAD is no longer unborn".to_string(),
1574 (Some(_), None) => "HEAD is now unborn".to_string(),
1575 _ => "HEAD moved".to_string(),
1576 });
1577 }
1578 }
1579 let index = if expect.index.is_some() || expect.files.is_some() {
1580 Some(IndexSnapshot::read(&frame)?)
1581 } else {
1582 None
1583 };
1584 if let (Some(expected), Some(index)) = (&expect.index, &index)
1585 && &index.token != expected
1586 {
1587 stale.push("index");
1588 parts.push("the index changed".to_string());
1589 }
1590 if let (Some(files), Some(mut index)) = (&expect.files, index) {
1591 // A rename row's token also covers its source path; recover that
1592 // pairing from the current status, as the read did.
1593 let (rows, _) = status_rows(workspace, &frame)?;
1594 let (rows, _) = split_by_workspace(rows, &frame.prefix);
1595 let sources: BTreeMap<String, String> = rows
1596 .iter()
1597 .filter_map(|row| {
1598 row.old_path
1599 .as_deref()
1600 .map(|old| (normalized_row_path(&row.path).to_string(), old.to_string()))
1601 })
1602 .collect();
1603 let members: Vec<String> = files
1604 .keys()
1605 .flat_map(|path| members_of(path, sources.get(path).map(String::as_str)))
1606 .map(|member| format!("{}{member}", frame.prefix))
1607 .collect();
1608 index.load_untracked(&frame.toplevel, &members);
1609 for (path, expected) in files {
1610 let current = members_of(path, sources.get(path).map(String::as_str))
1611 .iter()
1612 .map(|member| {
1613 list_member(
1614 &frame.toplevel,
1615 &index,
1616 &format!("{}{member}", frame.prefix),
1617 )
1618 })
1619 .collect::<Result<Vec<_>, _>>()
1620 .and_then(|listings| row_rev(&frame.toplevel, &listings, RevMode::Content));
1621 if current.as_ref().ok() != Some(expected)
1622 || rows
1623 .iter()
1624 .any(|row| row.status == "unknown" && is_at_or_under(&row.path, path))
1625 {
1626 stale_paths.push(path.clone());
1627 }
1628 }
1629 if !stale_paths.is_empty() {
1630 stale.push("files");
1631 parts.push(match stale_paths.as_slice() {
1632 [one] => format!("{one} changed"),
1633 many => {
1634 let shown: Vec<&str> = many.iter().take(3).map(String::as_str).collect();
1635 let more = many.len().saturating_sub(shown.len());
1636 if more == 0 {
1637 format!("{} files changed: {}", many.len(), shown.join(", "))
1638 } else {
1639 format!(
1640 "{} files changed: {} and {more} more",
1641 many.len(),
1642 shown.join(", ")
1643 )
1644 }
1645 }
1646 });
1647 }
1648 }
1649 let mut current = None;
1650 if let Some(expected) = &expect.revision {
1651 let detail = collect_git_status_detail(workspace)?;
1652 if detail.revision.as_deref() != Some(expected.as_str()) {
1653 stale.push("revision");
1654 parts.push("the working tree or index changed".to_string());
1655 }
1656 current = Some(detail);
1657 }
1658 if stale.is_empty() {
1659 return Ok(Ok(()));
1660 }
1661 let current = match current {
1662 Some(detail) => detail,
1663 None => collect_git_status_detail(workspace)?,
1664 };
1665 let mut extra = serde_json::Map::new();
1666 extra.insert("stale".to_string(), json!(stale));
1667 extra.insert("stale_paths".to_string(), json!(stale_paths));
1668 extra.insert(
1669 "current".to_string(),
1670 serde_json::to_value(current)
1671 .map_err(|_| ApiError::internal("git status serialization failed"))?,
1672 );
1673 Ok(Err(GitWriteError::Coded {
1674 status: StatusCode::CONFLICT,
1675 code: "git_state_changed",
1676 message: format!(
1677 "The repository changed since it was read ({}). Nothing was written; refresh and review again.",
1678 parts.join("; ")
1679 ),
1680 extra,
1681 }))
1682 }
1683
1684 async fn mutation_response(workspace: &FsPath, run: GitRun) -> Result<Json<Value>, ApiError> {
1685 if !run.status_success {
1686 let tail = output_tail(&run);
1687 return Err(ApiError::bad_request(if tail.is_empty() {
1688 format!("git exited {}", run.exit_code.unwrap_or(-1))
1689 } else {
1690 tail
1691 }));
1692 }
1693 let output = output_tail(&run);
1694 let workspace = workspace.to_path_buf();
1695 let (status, current) = tokio::task::spawn_blocking(move || {
1696 (
1697 collect_workspace_status(&workspace),
1698 collect_git_status_detail(&workspace),
1699 )
1700 })
1701 .await
1702 .map_err(|_| ApiError::internal("git status failed"))?;
1703 Ok(Json(json!({
1704 "ok": true,
1705 "output": output,
1706 "status": status,
1707 "current": current?,
1708 })))
1709 }
1710
1711 /// The shared check-then-write path. The per-runtime lock makes the check
1712 /// and the write atomic with respect to this server's other git writes (two
1713 /// app windows on one runtime); a second writer gets `git_busy` instead of
1714 /// waiting behind a long hook. External processes are outside the lock —
1715 /// see docs/RUNTIME_API.md for that window.
1716 async fn guarded_write(
1717 state: &RuntimeApiState,
1718 expect: Preconditions,
1719 args: Vec<String>,
1720 ) -> Result<Json<Value>, GitWriteError> {
1721 let _guard = state.git_writes.try_lock().map_err(|_| busy_error())?;
1722 let workspace = canonical_workspace(&state.workspace)?;
1723 require_repo(&workspace)?;
1724 if !expect.is_empty() {
1725 let root = workspace.clone();
1726 tokio::task::spawn_blocking(move || check_preconditions(&root, &expect))
1727 .await
1728 .map_err(|_| ApiError::internal("git precondition check failed"))???;
1729 }
1730 Ok(mutation_response(&workspace, git_write(&workspace, args).await?).await?)
1731 }
1732
1733 /// Validate and collect pathspecs. Every path is workspace-relative with no
1734 /// `.`/`..`/`.git` components and is passed after `--` with
1735 /// `--literal-pathspecs`, so it can never be read as an option or a glob.
1736 fn validated_paths(request: &GitPathsRequest, allow_all: bool) -> Result<Vec<String>, ApiError> {
1737 if request.all && !allow_all {
1738 return Err(ApiError::bad_request(
1739 "discard requires explicit paths; refusing to discard the whole tree",
1740 ));
1741 }
1742 if request.all && !request.paths.is_empty() {
1743 return Err(ApiError::bad_request(
1744 "all and paths are mutually exclusive",
1745 ));
1746 }
1747 if !request.all && request.paths.is_empty() {
1748 return Err(ApiError::bad_request("paths is required (or all: true)"));
1749 }
1750 if request.paths.len() > MAX_PATH_ARGS {
1751 return Err(ApiError::bad_request(format!(
1752 "at most {MAX_PATH_ARGS} paths per request"
1753 )));
1754 }
1755 request
1756 .paths
1757 .iter()
1758 .map(|raw| {
1759 relative_request_path(raw, false).map(|path| path.to_string_lossy().into_owned())
1760 })
1761 .collect()
1762 }
1763
1764 /// Validated paths plus their preconditions.
1765 fn paths_and_expect(
1766 request: GitPathsRequest,
1767 allow_all: bool,
1768 ) -> Result<(bool, Vec<String>, Preconditions), ApiError> {
1769 let paths = validated_paths(&request, allow_all)?;
1770 let target = if request.all {
1771 ExpectTarget::AllPaths
1772 } else {
1773 ExpectTarget::Paths(&paths)
1774 };
1775 let expect = validate_expect(request.expect, target)?;
1776 Ok((request.all, paths, expect))
1777 }
1778
1779 /// `git --literal-pathspecs <subcommand…> -- <paths>`.
1780 fn literal_path_args(subcommand: &[&str], paths: Vec<String>) -> Vec<String> {
1781 let mut args = vec!["--literal-pathspecs".to_string()];
1782 args.extend(subcommand.iter().map(|arg| arg.to_string()));
1783 args.push("--".to_string());
1784 args.extend(paths);
1785 args
1786 }
1787
1788 pub(super) async fn git_stage(
1789 State(state): State<RuntimeApiState>,
1790 Json(request): Json<GitPathsRequest>,
1791 ) -> Result<Json<Value>, GitWriteError> {
1792 let (all, paths, expect) = paths_and_expect(request, true)?;
1793 let args = if all {
1794 vec!["add".to_string(), "--all".to_string()]
1795 } else {
1796 literal_path_args(&["add"], paths)
1797 };
1798 guarded_write(&state, expect, args).await
1799 }
1800
1801 pub(super) async fn git_unstage(
1802 State(state): State<RuntimeApiState>,
1803 Json(request): Json<GitPathsRequest>,
1804 ) -> Result<Json<Value>, GitWriteError> {
1805 let (all, paths, expect) = paths_and_expect(request, true)?;
1806 let args = if all {
1807 // `:/` is pathspec magic for the repository root, so this one form
1808 // cannot run under --literal-pathspecs.
1809 vec![
1810 "restore".to_string(),
1811 "--staged".to_string(),
1812 ":/".to_string(),
1813 ]
1814 } else {
1815 literal_path_args(&["restore", "--staged"], paths)
1816 };
1817 guarded_write(&state, expect, args).await
1818 }
1819
1820 pub(super) async fn git_discard(
1821 State(state): State<RuntimeApiState>,
1822 Json(request): Json<GitPathsRequest>,
1823 ) -> Result<Json<Value>, GitWriteError> {
1824 let (_, paths, expect) = paths_and_expect(request, false)?;
1825 guarded_write(&state, expect, literal_path_args(&["checkout"], paths)).await
1826 }
1827
1828 pub(super) async fn git_commit(
1829 State(state): State<RuntimeApiState>,
1830 Json(request): Json<GitCommitRequest>,
1831 ) -> Result<Json<Value>, GitWriteError> {
1832 let message = request.message.trim();
1833 if message.is_empty() {
1834 return Err(ApiError::bad_request("message is required").into());
1835 }
1836 if message.len() > MAX_COMMIT_MESSAGE_BYTES {
1837 return Err(ApiError::bad_request(format!(
1838 "message must be at most {MAX_COMMIT_MESSAGE_BYTES} bytes"
1839 ))
1840 .into());
1841 }
1842 let expect = validate_expect(request.expect, ExpectTarget::Commit)?;
1843 let mut args = vec!["commit".to_string()];
1844 if request.all {
1845 args.push("--all".to_string());
1846 }
1847 args.push("--message".to_string());
1848 args.push(message.to_string());
1849 guarded_write(&state, expect, args).await
1850 }
1851
1852 /// A push target must be one of the repository's configured remotes: never an
1853 /// option lookalike (`--force`, `--mirror`) and never a path or URL.
1854 fn validate_push_remote(remote: &str, configured: &str) -> Result<(), ApiError> {
1855 let shaped = !remote.starts_with('-')
1856 && remote
1857 .bytes()
1858 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'/'));
1859 if !shaped {
1860 return Err(ApiError::bad_request("remote must be a remote name"));
1861 }
1862 if !configured.lines().any(|name| name.trim() == remote) {
1863 return Err(ApiError::bad_request(format!(
1864 "remote {remote} is not configured in this repository"
1865 )));
1866 }
1867 Ok(())
1868 }
1869
1870 /// A bounded async read that must succeed; its stdout. Discovery for request
1871 /// validation goes through [`git_read`] so it never blocks a runtime worker.
1872 async fn git_read_ok(workspace: &FsPath, args: &[&str]) -> Result<String, ApiError> {
1873 let run = git_read(workspace, args).await?;
1874 if !run.status_success {
1875 return Err(ApiError::internal(format!(
1876 "git {} failed: {}",
1877 args.first().copied().unwrap_or(""),
1878 run.stderr.trim()
1879 )));
1880 }
1881 Ok(run.stdout)
1882 }
1883
1884 /// `git push` arguments for a request. The remote that will be pushed to —
1885 /// the requested one, or `origin` when only `set_upstream` is asked — must be
1886 /// a configured remote, and `--` keeps it from ever parsing as an option.
1887 async fn push_args(
1888 workspace: &FsPath,
1889 remote: Option<&str>,
1890 set_upstream: bool,
1891 ) -> Result<Vec<String>, ApiError> {
1892 let remote = remote.map(str::trim).filter(|remote| !remote.is_empty());
1893 let remote = match (remote, set_upstream) {
1894 (Some(remote), _) => Some(remote),
1895 (None, true) => Some("origin"),
1896 (None, false) => None,
1897 };
1898 let mut args = vec!["push".to_string()];
1899 let Some(remote) = remote else {
1900 return Ok(args);
1901 };
1902 validate_push_remote(remote, &git_read_ok(workspace, &["remote"]).await?)?;
1903 if set_upstream {
1904 let branch = git_read_ok(workspace, &["rev-parse", "--abbrev-ref", "HEAD"]).await?;
1905 let branch = branch.trim();
1906 if branch.is_empty() || branch == "HEAD" {
1907 return Err(ApiError::bad_request(
1908 "cannot set upstream from a detached HEAD",
1909 ));
1910 }
1911 args.push("--set-upstream".to_string());
1912 args.extend(["--".to_string(), remote.to_string(), branch.to_string()]);
1913 } else {
1914 args.extend(["--".to_string(), remote.to_string()]);
1915 }
1916 Ok(args)
1917 }
1918
1919 pub(super) async fn git_push(
1920 State(state): State<RuntimeApiState>,
1921 Json(request): Json<GitPushRequest>,
1922 ) -> Result<Json<Value>, ApiError> {
1923 let workspace = canonical_workspace(&state.workspace)?;
1924 require_repo(&workspace)?;
1925 let args = push_args(&workspace, request.remote.as_deref(), request.set_upstream).await?;
1926 // Push stays outside the write lock: it crosses the network under a
1927 // 120 s bound and only moves a remote ref, never the index or worktree.
1928 mutation_response(&workspace, git_write(&workspace, args).await?).await
1929 }
1930
1931 pub(super) async fn git_branch(
1932 State(state): State<RuntimeApiState>,
1933 Json(request): Json<GitBranchRequest>,
1934 ) -> Result<Json<Value>, GitWriteError> {
1935 let name = request.name.trim();
1936 if name.is_empty() || name.len() > MAX_BRANCH_NAME_BYTES {
1937 return Err(ApiError::bad_request("name is required").into());
1938 }
1939 // Switching rewrites the worktree and index, so it shares the lock with
1940 // stage/discard/commit.
1941 let _guard = state.git_writes.try_lock().map_err(|_| busy_error())?;
1942 let workspace = canonical_workspace(&state.workspace)?;
1943 require_repo(&workspace)?;
1944 // `check-ref-format --branch` is the ref authority — it rejects option
1945 // lookalikes, `..`, `@{`, control bytes, and every other unsafe name.
1946 let check = git_write(
1947 &workspace,
1948 vec![
1949 "check-ref-format".to_string(),
1950 "--branch".to_string(),
1951 name.to_string(),
1952 ],
1953 )
1954 .await?;
1955 if !check.status_success {
1956 return Err(ApiError::bad_request("name is not a valid branch").into());
1957 }
1958 let mut args = vec!["switch".to_string()];
1959 if request.create {
1960 args.push("--create".to_string());
1961 }
1962 args.push(name.to_string());
1963 Ok(mutation_response(&workspace, git_write(&workspace, args).await?).await?)
1964 }
1965
1966 #[cfg(test)]
1967 mod tests {
1968 use super::*;
1969 use std::fs;
1970
1971 thread_local! {
1972 pub(super) static GIT_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1973 }
1974
1975 fn git(dir: &FsPath, args: &[&str]) {
1976 let output = Git::output(args, dir).expect("spawn git");
1977 assert!(
1978 output.status.success(),
1979 "git {args:?} failed: {}",
1980 String::from_utf8_lossy(&output.stderr)
1981 );
1982 }
1983
1984 fn repo() -> tempfile::TempDir {
1985 let tmp = tempfile::tempdir().expect("tempdir");
1986 let dir = tmp.path();
1987 git(dir, &["init", "-q", "-b", "main"]);
1988 git(dir, &["config", "user.email", "git-rev@example.test"]);
1989 git(dir, &["config", "user.name", "Git Rev Test"]);
1990 git(dir, &["config", "core.autocrlf", "false"]);
1991 fs::write(dir.join("a.txt"), "one\n").unwrap();
1992 git(dir, &["add", "a.txt"]);
1993 git(dir, &["commit", "-q", "-m", "initial"]);
1994 tmp
1995 }
1996
1997 fn rev(workspace: &FsPath, path: &str) -> String {
1998 let frame = RepoFrame::read(workspace).unwrap();
1999 let mut index = IndexSnapshot::read(&frame).unwrap();
2000 index.load_untracked(
2001 &frame.toplevel,
2002 &[format!("{}{}", frame.prefix, normalized_row_path(path))],
2003 );
2004 let listings: Vec<_> = members_of(path, None)
2005 .iter()
2006 .map(|member| {
2007 list_member(
2008 &frame.toplevel,
2009 &index,
2010 &format!("{}{member}", frame.prefix),
2011 )
2012 .unwrap()
2013 })
2014 .collect();
2015 row_rev(&frame.toplevel, &listings, RevMode::Content).unwrap()
2016 }
2017
2018 fn index_token(workspace: &FsPath) -> String {
2019 IndexSnapshot::read(&RepoFrame::read(workspace).unwrap())
2020 .unwrap()
2021 .token
2022 }
2023
2024 #[test]
2025 fn row_rev_tracks_content_index_and_deletion_but_not_touch() {
2026 let tmp = repo();
2027 let ws = tmp.path();
2028 fs::write(ws.join("a.txt"), "two\n").unwrap();
2029 let modified = rev(ws, "a.txt");
2030 assert!(
2031 modified.starts_with("c-") && modified.len() == 66,
2032 "{modified}"
2033 );
2034 assert_eq!(rev(ws, "a.txt"), modified, "deterministic");
2035
2036 // Touching without changing bytes keeps the content token.
2037 let file = fs::File::options()
2038 .write(true)
2039 .open(ws.join("a.txt"))
2040 .unwrap();
2041 file.set_modified(std::time::SystemTime::now() + Duration::from_secs(90))
2042 .unwrap();
2043 drop(file);
2044 assert_eq!(rev(ws, "a.txt"), modified, "touch is not a change");
2045
2046 fs::write(ws.join("a.txt"), "three\n").unwrap();
2047 let rewritten = rev(ws, "a.txt");
2048 assert_ne!(rewritten, modified, "content change");
2049
2050 git(ws, &["add", "a.txt"]);
2051 let staged = rev(ws, "a.txt");
2052 assert_ne!(staged, rewritten, "staging changes the index part");
2053
2054 fs::remove_file(ws.join("a.txt")).unwrap();
2055 assert_ne!(rev(ws, "a.txt"), staged, "deletion reads as absent");
2056 }
2057
2058 #[test]
2059 fn directory_rev_covers_untracked_files_inside() {
2060 let tmp = repo();
2061 let ws = tmp.path();
2062 fs::create_dir_all(ws.join("dir/deep")).unwrap();
2063 fs::write(ws.join("dir/deep/x.rs"), "x\n").unwrap();
2064 let before = rev(ws, "dir/");
2065 assert_eq!(rev(ws, "dir"), before, "dir and dir/ are one key");
2066 fs::write(ws.join("dir/deep/x.rs"), "y\n").unwrap();
2067 assert_ne!(rev(ws, "dir"), before);
2068 fs::write(ws.join("dir/deep/x.rs"), "x\n").unwrap();
2069 fs::write(ws.join("dir/new.rs"), "n\n").unwrap();
2070 assert_ne!(rev(ws, "dir"), before, "a new file under the row");
2071 }
2072
2073 #[test]
2074 fn index_token_ignores_stat_refresh_and_tracks_real_changes() {
2075 let tmp = repo();
2076 let ws = tmp.path();
2077 let before = index_token(ws);
2078 let file = fs::File::options()
2079 .write(true)
2080 .open(ws.join("a.txt"))
2081 .unwrap();
2082 file.set_modified(std::time::SystemTime::now() + Duration::from_secs(90))
2083 .unwrap();
2084 drop(file);
2085 git(ws, &["status", "--porcelain"]);
2086 git(ws, &["update-index", "--refresh"]);
2087 assert_eq!(index_token(ws), before, "stat refresh is not a change");
2088 fs::write(ws.join("b.txt"), "b\n").unwrap();
2089 git(ws, &["add", "b.txt"]);
2090 assert_ne!(index_token(ws), before);
2091 }
2092
2093 #[test]
2094 fn rename_row_rev_covers_the_source_path() {
2095 let tmp = repo();
2096 let ws = tmp.path();
2097 git(ws, &["mv", "a.txt", "b.txt"]);
2098 let detail = collect_git_status_detail(ws).unwrap();
2099 let row = detail.files.iter().find(|row| row.path == "b.txt").unwrap();
2100 assert_eq!(row.old_path.as_deref(), Some("a.txt"));
2101 let before = row.rev.clone().unwrap();
2102 // Recreating the source in the worktree changes what a stage of the
2103 // rename would record.
2104 fs::write(ws.join("a.txt"), "resurrected\n").unwrap();
2105 let detail = collect_git_status_detail(ws).unwrap();
2106 let after = detail
2107 .files
2108 .iter()
2109 .find(|row| row.path == "b.txt")
2110 .and_then(|row| row.rev.clone())
2111 .unwrap();
2112 assert_ne!(after, before);
2113 }
2114
2115 #[test]
2116 fn subdirectory_workspace_reports_workspace_relative_rows() {
2117 let tmp = repo();
2118 let root = tmp.path();
2119 fs::create_dir_all(root.join("sub")).unwrap();
2120 fs::write(root.join("sub/in.txt"), "in\n").unwrap();
2121 git(root, &["add", "sub/in.txt"]);
2122 git(root, &["commit", "-q", "-m", "sub"]);
2123 fs::write(root.join("sub/in.txt"), "changed\n").unwrap();
2124 fs::write(root.join("a.txt"), "outside change\n").unwrap();
2125 let ws = root.join("sub").canonicalize().unwrap();
2126 let detail = collect_git_status_detail(&ws).unwrap();
2127 let paths: Vec<&str> = detail.files.iter().map(|row| row.path.as_str()).collect();
2128 assert_eq!(
2129 paths,
2130 vec!["in.txt"],
2131 "workspace frame, outside rows dropped"
2132 );
2133 assert_eq!(
2134 detail.files[0].rev.as_deref(),
2135 Some(rev(&ws, "in.txt").as_str())
2136 );
2137 // The whole-tree revision still sees the change outside the
2138 // workspace, because stage-all and commit reach it.
2139 let before = detail.revision.clone().unwrap();
2140 fs::write(root.join("a.txt"), "outside change again\n").unwrap();
2141 git(root, &["add", "a.txt"]);
2142 let after = collect_git_status_detail(&ws).unwrap().revision.unwrap();
2143 assert_ne!(before, after);
2144 }
2145
2146 fn expect_file(path: &str, token: String) -> Preconditions {
2147 validate_expect(
2148 Some(GitExpect {
2149 files: Some(BTreeMap::from([(path.to_string(), token)])),
2150 ..Default::default()
2151 }),
2152 ExpectTarget::Paths(&[path.to_string()]),
2153 )
2154 .unwrap()
2155 }
2156
2157 fn assert_conflict(workspace: &FsPath, expect: &Preconditions) {
2158 assert!(matches!(
2159 check_preconditions(workspace, expect).unwrap(),
2160 Err(GitWriteError::Coded {
2161 status: StatusCode::CONFLICT,
2162 code: "git_state_changed",
2163 ..
2164 })
2165 ));
2166 }
2167
2168 #[cfg(unix)]
2169 #[test]
2170 fn review_executable_mode_invalidates_row_guard() {
2171 use std::os::unix::fs::PermissionsExt;
2172 let tmp = repo();
2173 let ws = tmp.path();
2174 git(ws, &["config", "core.filemode", "true"]);
2175 fs::write(ws.join("a.txt"), "modified\n").unwrap();
2176 fs::set_permissions(ws.join("a.txt"), fs::Permissions::from_mode(0o644)).unwrap();
2177 let detail = collect_git_status_detail(ws).unwrap();
2178 let expect = expect_file("a.txt", detail.files[0].rev.clone().unwrap());
2179 assert!(check_preconditions(ws, &expect).unwrap().is_ok());
2180 fs::set_permissions(ws.join("a.txt"), fs::Permissions::from_mode(0o755)).unwrap();
2181 assert_conflict(ws, &expect);
2182 assert_ne!(
2183 collect_git_status_detail(ws).unwrap().revision,
2184 detail.revision
2185 );
2186 }
2187
2188 #[test]
2189 fn review_outside_content_invalidates_whole_tree_guard() {
2190 let tmp = repo();
2191 let root = tmp.path();
2192 fs::create_dir(root.join("sub")).unwrap();
2193 let ws = root.join("sub");
2194 fs::write(root.join("a.txt"), "outside one\n").unwrap();
2195 let detail = collect_git_status_detail(&ws).unwrap();
2196 assert!(detail.files.is_empty());
2197 let expect = Preconditions {
2198 head: Some(detail.head_oid),
2199 revision: Some(detail.revision.unwrap()),
2200 ..Default::default()
2201 };
2202 assert!(check_preconditions(&ws, &expect).unwrap().is_ok());
2203 // Keep the porcelain XY and index unchanged.
2204 fs::write(root.join("a.txt"), "outside two\n").unwrap();
2205 assert_conflict(&ws, &expect);
2206 }
2207
2208 #[test]
2209 fn review_submodule_head_invalidates_row_guard() {
2210 let tmp = repo();
2211 let ws = tmp.path();
2212 let sub = ws.join("vendor");
2213 fs::create_dir(&sub).unwrap();
2214 git(&sub, &["init", "-q", "-b", "main"]);
2215 git(&sub, &["config", "user.email", "git-rev@example.test"]);
2216 git(&sub, &["config", "user.name", "Git Rev Test"]);
2217 fs::write(sub.join("lib.txt"), "one\n").unwrap();
2218 git(&sub, &["add", "lib.txt"]);
2219 git(&sub, &["commit", "-q", "-m", "one"]);
2220 git(ws, &["add", "vendor"]);
2221 git(ws, &["commit", "-q", "-m", "gitlink"]);
2222 for contents in ["two\n", "three\n"] {
2223 fs::write(sub.join("lib.txt"), contents).unwrap();
2224 git(&sub, &["commit", "-q", "-am", contents]);
2225 }
2226 git(&sub, &["checkout", "-q", "HEAD~1"]);
2227 let detail = collect_git_status_detail(ws).unwrap();
2228 let row = detail
2229 .files
2230 .iter()
2231 .find(|row| row.path == "vendor")
2232 .unwrap();
2233 let expect = expect_file("vendor", row.rev.clone().unwrap());
2234 assert!(check_preconditions(ws, &expect).unwrap().is_ok());
2235 git(&sub, &["checkout", "-q", "main"]);
2236 assert_conflict(ws, &expect);
2237 }
2238
2239 #[test]
2240 fn review_untracked_workspace_prefix_exposes_children() {
2241 let tmp = repo();
2242 let root = tmp.path();
2243 fs::create_dir_all(root.join("sub/deep")).unwrap();
2244 fs::write(root.join("sub/new.txt"), "new\n").unwrap();
2245 fs::write(root.join("sub/deep/child.txt"), "child\n").unwrap();
2246 let ws = root.join("sub");
2247 let detail = collect_git_status_detail(&ws).unwrap();
2248 assert_eq!(
2249 detail
2250 .files
2251 .iter()
2252 .map(|row| row.path.as_str())
2253 .collect::<Vec<_>>(),
2254 ["deep/child.txt", "new.txt"]
2255 );
2256 for row in detail.files {
2257 assert_eq!(row.status, "untracked");
2258 let expect = expect_file(&row.path, row.rev.unwrap());
2259 assert!(check_preconditions(&ws, &expect).unwrap().is_ok());
2260 git(&ws, &["add", "--", &row.path]);
2261 }
2262 assert_eq!(collect_git_status_detail(&ws).unwrap().staged, 2);
2263 }
2264
2265 #[test]
2266 fn review_outgoing_rename_exposes_source_deletion() {
2267 let tmp = repo();
2268 let root = tmp.path();
2269 fs::create_dir(root.join("sub")).unwrap();
2270 git(root, &["mv", "a.txt", "sub/a.txt"]);
2271 git(root, &["commit", "-q", "-m", "inside"]);
2272 git(root, &["mv", "sub/a.txt", "outside.txt"]);
2273 let ws = root.join("sub");
2274 let detail = collect_git_status_detail(&ws).unwrap();
2275 assert_eq!(detail.files.len(), 1);
2276 let row = &detail.files[0];
2277 assert_eq!(row.path, "a.txt");
2278 assert_eq!(row.index, "D");
2279 assert_eq!(row.status, "deleted");
2280 assert!(row.staged);
2281 assert!(row.old_path.is_none());
2282 let expect = expect_file("a.txt", row.rev.clone().unwrap());
2283 assert!(check_preconditions(&ws, &expect).unwrap().is_ok());
2284 // The projected row can unstage the in-workspace deletion.
2285 git(&ws, &["restore", "--staged", "--", "a.txt"]);
2286 assert_ne!(index_token(&ws), detail.index_token.unwrap());
2287 }
2288
2289 #[test]
2290 fn review_untracked_inventory_has_constant_git_reads() {
2291 let tmp = repo();
2292 let ws = tmp.path();
2293 fs::write(ws.join(".gitignore"), "*.ignored\n").unwrap();
2294 let mut read_counts = Vec::new();
2295 for count in [1, 32] {
2296 for n in 0..count {
2297 let dir = ws.join(format!("dir{n}"));
2298 fs::create_dir_all(&dir).unwrap();
2299 fs::write(dir.join("file.txt"), "file\n").unwrap();
2300 fs::write(dir.join("skip.ignored"), "ignored\n").unwrap();
2301 }
2302 GIT_READS.with(|reads| reads.set(0));
2303 let detail = collect_git_status_detail(ws).unwrap();
2304 read_counts.push(GIT_READS.with(|reads| reads.get()));
2305 assert!(detail.files.iter().all(|row| row.rev.is_some()));
2306 assert!(
2307 !detail
2308 .files
2309 .iter()
2310 .any(|row| row.path.ends_with(".ignored"))
2311 );
2312 }
2313 assert_eq!(
2314 read_counts[0], read_counts[1],
2315 "Git reads must not grow per directory"
2316 );
2317 }
2318
2319 #[test]
2320 fn review_broken_head_is_not_unborn() {
2321 let tmp = tempfile::tempdir().unwrap();
2322 let ws = tmp.path();
2323 git(ws, &["init", "-q", "-b", "main"]);
2324 assert_eq!(head_oid(ws).unwrap(), None);
2325 assert!(collect_git_status_detail(ws).unwrap().index_token.is_some());
2326 let expect = Preconditions {
2327 head: Some(None),
2328 ..Default::default()
2329 };
2330 assert!(check_preconditions(ws, &expect).unwrap().is_ok());
2331 // An existing branch pointing at a missing object is broken, not unborn.
2332 fs::write(
2333 ws.join(".git/refs/heads/main"),
2334 format!("{}\n", "1".repeat(40)),
2335 )
2336 .unwrap();
2337 assert!(head_oid(ws).is_err());
2338 assert!(collect_git_status_detail(ws).unwrap().revision.is_none());
2339 assert!(check_preconditions(ws, &expect).is_err());
2340 fs::write(ws.join(".git/refs/heads/main"), "broken\n").unwrap();
2341 assert!(
2342 head_oid(ws).is_err(),
2343 "a malformed ref is not an absent ref"
2344 );
2345 assert!(collect_git_status_detail(ws).unwrap().revision.is_none());
2346 // The same failure in detached HEAD must also propagate.
2347 fs::write(ws.join(".git/HEAD"), format!("{}\n", "1".repeat(40))).unwrap();
2348 assert!(head_oid(ws).is_err());
2349 }
2350
2351 #[test]
2352 fn review_stat_only_revisions_cannot_guard_writes() {
2353 use std::io::{Seek as _, Write as _};
2354 let tmp = repo();
2355 let ws = tmp.path();
2356 let path = ws.join("a.txt");
2357 let file = fs::File::options().write(true).open(&path).unwrap();
2358 file.set_len(FILE_SERVE_MAX_BYTES + 1).unwrap();
2359 drop(file);
2360 let detail = collect_git_status_detail(ws).unwrap();
2361 let token = detail.files[0].rev.clone().unwrap();
2362 assert!(
2363 token.starts_with("s-"),
2364 "oversized row must not claim content safety"
2365 );
2366 assert!(
2367 detail.revision.is_none(),
2368 "whole-tree token cannot hide stat-only rows"
2369 );
2370 // Same size and mtime, different bytes: the display token can match.
2371 let modified = fs::metadata(&path).unwrap().modified().unwrap();
2372 let mut file = fs::File::options().write(true).open(&path).unwrap();
2373 file.seek(std::io::SeekFrom::Start(0)).unwrap();
2374 file.write_all(b"two\n").unwrap();
2375 file.set_modified(modified).unwrap();
2376 drop(file);
2377 let after = collect_git_status_detail(ws).unwrap();
2378 assert_eq!(after.files[0].rev.as_ref(), Some(&token));
2379 assert!(
2380 validate_expect(
2381 Some(GitExpect {
2382 files: Some(BTreeMap::from([("a.txt".to_string(), token)])),
2383 ..Default::default()
2384 }),
2385 ExpectTarget::Paths(&["a.txt".to_string()]),
2386 )
2387 .is_err()
2388 );
2389 // An old c- token cannot pass after a file grows beyond the bound.
2390 fs::write(&path, "small\n").unwrap();
2391 let expect = expect_file("a.txt", rev(ws, "a.txt"));
2392 fs::File::options()
2393 .write(true)
2394 .open(&path)
2395 .unwrap()
2396 .set_len(FILE_SERVE_MAX_BYTES + 1)
2397 .unwrap();
2398 assert_conflict(ws, &expect);
2399 }
2400
2401 #[test]
2402 fn review_hash_budget_withholds_whole_tree_revision() {
2403 let tmp = repo();
2404 let ws = tmp.path();
2405 for n in 0..=REV_CONTENT_BUDGET_FILES {
2406 fs::write(ws.join(format!("file{n:05}")), "x").unwrap();
2407 }
2408 let detail = collect_git_status_detail(ws).unwrap();
2409 assert!(detail.revision.is_none());
2410 assert!(detail.index_token.is_some());
2411 assert_eq!(
2412 detail
2413 .files
2414 .iter()
2415 .filter(|row| row.rev.as_deref().unwrap().starts_with("c-"))
2416 .count(),
2417 REV_CONTENT_BUDGET_FILES
2418 );
2419 let row = detail.files.last().unwrap();
2420 assert!(row.rev.as_deref().unwrap().starts_with("s-"));
2421 assert!(
2422 validate_expect(
2423 Some(GitExpect {
2424 files: Some(BTreeMap::from([(
2425 row.path.clone(),
2426 row.rev.clone().unwrap()
2427 )])),
2428 ..Default::default()
2429 }),
2430 ExpectTarget::Paths(std::slice::from_ref(&row.path)),
2431 )
2432 .is_err()
2433 );
2434 }
2435
2436 fn assert_unguardable_row(workspace: &FsPath, bad_path: &str) {
2437 let detail = collect_git_status_detail(workspace).unwrap();
2438 assert_eq!(detail.branch.as_deref(), Some("main"));
2439 assert!(detail.head_oid.is_some());
2440 assert!(detail.index_token.is_some());
2441 assert!(detail.revision.is_none());
2442 let bad = detail
2443 .files
2444 .iter()
2445 .find(|row| normalized_row_path(&row.path) == bad_path)
2446 .unwrap();
2447 assert!(bad.rev.is_none());
2448 assert_eq!(
2449 serde_json::to_value(bad).unwrap().get("rev"),
2450 Some(&Value::Null)
2451 );
2452 let healthy = detail
2453 .files
2454 .iter()
2455 .find(|row| row.path == "healthy.txt")
2456 .unwrap();
2457 let expect = expect_file("healthy.txt", healthy.rev.clone().unwrap());
2458 assert!(check_preconditions(workspace, &expect).unwrap().is_ok());
2459 }
2460
2461 #[cfg(unix)]
2462 #[test]
2463 fn symlinked_ancestor_only_withholds_affected_row() {
2464 let tmp = repo();
2465 let ws = tmp.path();
2466 fs::create_dir(ws.join("vendor")).unwrap();
2467 fs::write(ws.join("vendor/a.txt"), "tracked\n").unwrap();
2468 git(ws, &["add", "vendor"]);
2469 git(ws, &["commit", "-q", "-m", "vendor"]);
2470 let expect = expect_file("vendor/a.txt", rev(ws, "vendor/a.txt"));
2471 fs::remove_dir_all(ws.join("vendor")).unwrap();
2472 let shared = tempfile::tempdir().unwrap();
2473 fs::write(shared.path().join("a.txt"), "outside\n").unwrap();
2474 std::os::unix::fs::symlink(shared.path(), ws.join("vendor")).unwrap();
2475 fs::write(ws.join("healthy.txt"), "healthy\n").unwrap();
2476 assert_unguardable_row(ws, "vendor/a.txt");
2477 assert_conflict(ws, &expect);
2478 assert_eq!(
2479 fs::read_to_string(shared.path().join("a.txt")).unwrap(),
2480 "outside\n"
2481 );
2482 }
2483
2484 #[cfg(unix)]
2485 #[test]
2486 fn unreadable_file_only_withholds_affected_row() {
2487 use std::os::unix::fs::PermissionsExt;
2488 let tmp = repo();
2489 let ws = tmp.path();
2490 fs::write(ws.join("private.txt"), "private\n").unwrap();
2491 fs::write(ws.join("healthy.txt"), "healthy\n").unwrap();
2492 let expect = expect_file("private.txt", rev(ws, "private.txt"));
2493 fs::set_permissions(ws.join("private.txt"), fs::Permissions::from_mode(0o000)).unwrap();
2494 assert!(
2495 fs::File::open(ws.join("private.txt")).is_err(),
2496 "fixture must be unreadable"
2497 );
2498 assert_unguardable_row(ws, "private.txt");
2499 assert_conflict(ws, &expect);
2500 }
2501
2502 #[cfg(unix)]
2503 #[test]
2504 fn special_file_only_withholds_affected_row() {
2505 let tmp = repo();
2506 let ws = tmp.path();
2507 let expect = expect_file("a.txt", rev(ws, "a.txt"));
2508 fs::remove_file(ws.join("a.txt")).unwrap();
2509 assert!(
2510 std::process::Command::new("mkfifo")
2511 .arg(ws.join("a.txt"))
2512 .status()
2513 .unwrap()
2514 .success()
2515 );
2516 fs::write(ws.join("healthy.txt"), "healthy\n").unwrap();
2517 assert_unguardable_row(ws, "a.txt");
2518 assert_conflict(ws, &expect);
2519 }
2520
2521 #[test]
2522 fn broken_nested_repo_only_withholds_affected_row() {
2523 for tracked in [false, true] {
2524 let tmp = repo();
2525 let ws = tmp.path();
2526 let nested = ws.join("vendor");
2527 fs::create_dir(&nested).unwrap();
2528 git(&nested, &["init", "-q", "-b", "main"]);
2529 git(&nested, &["config", "user.email", "git-rev@example.test"]);
2530 git(&nested, &["config", "user.name", "Git Rev Test"]);
2531 fs::write(nested.join("lib.txt"), "library\n").unwrap();
2532 git(&nested, &["add", "lib.txt"]);
2533 git(&nested, &["commit", "-q", "-m", "nested"]);
2534 if tracked {
2535 git(ws, &["add", "vendor"]);
2536 git(ws, &["commit", "-q", "-m", "gitlink"]);
2537 }
2538 fs::write(
2539 nested.join(".git/refs/heads/main"),
2540 format!("{}\n", "1".repeat(40)),
2541 )
2542 .unwrap();
2543 fs::write(ws.join("healthy.txt"), "healthy\n").unwrap();
2544 assert_unguardable_row(ws, "vendor");
2545 }
2546 }
2547
2548 #[cfg(unix)]
2549 #[test]
2550 fn status_and_guards_respect_clean_filters() {
2551 let tmp = repo();
2552 let ws = tmp.path();
2553 git(ws, &["config", "filter.up.clean", "tr a-z A-Z"]);
2554 fs::write(ws.join(".gitattributes"), "*.txt filter=up\n").unwrap();
2555 git(ws, &["add", ".gitattributes"]);
2556 // The tracked file is unchanged since the initial commit. Apply the
2557 // new attributes explicitly instead of depending on Git's cached
2558 // stat entry deciding to run the newly configured clean filter.
2559 git(ws, &["add", "--renormalize", "a.txt"]);
2560 assert_eq!(run_git_sync(ws, &["show", ":a.txt"]).unwrap(), "ONE\n");
2561 git(ws, &["commit", "-q", "-m", "filtered"]);
2562 fs::File::options()
2563 .write(true)
2564 .open(ws.join("a.txt"))
2565 .unwrap()
2566 .set_modified(std::time::SystemTime::now() + Duration::from_secs(90))
2567 .unwrap();
2568 let detail = collect_git_status_detail(ws).unwrap();
2569 assert_eq!(detail.unstaged, 0);
2570 assert!(
2571 detail.files.is_empty(),
2572 "clean filtered files must not appear modified"
2573 );
2574 fs::write(ws.join("a.txt"), "two\n").unwrap();
2575 let detail = collect_git_status_detail(ws).unwrap();
2576 let expect = expect_file("a.txt", detail.files[0].rev.clone().unwrap());
2577 assert!(check_preconditions(ws, &expect).unwrap().is_ok());
2578 git(ws, &["add", "a.txt"]);
2579 assert_eq!(run_git_sync(ws, &["show", ":a.txt"]).unwrap(), "TWO\n");
2580 assert_conflict(ws, &expect);
2581 }
2582
2583 #[test]
2584 fn status_respects_collapsed_and_hidden_untracked_modes() {
2585 let tmp = repo();
2586 let ws = tmp.path();
2587 fs::create_dir_all(ws.join("build/deep")).unwrap();
2588 fs::write(ws.join("build/one.txt"), "one\n").unwrap();
2589 fs::write(ws.join("build/deep/two.txt"), "two\n").unwrap();
2590 let detail = collect_git_status_detail(ws).unwrap();
2591 assert_eq!(detail.files.len(), 1);
2592 assert_eq!(detail.files[0].path, "build/");
2593 let expect = expect_file("build", detail.files[0].rev.clone().unwrap());
2594 assert!(check_preconditions(ws, &expect).unwrap().is_ok());
2595 fs::write(ws.join("build/deep/two.txt"), "changed\n").unwrap();
2596 assert_conflict(ws, &expect);
2597 git(ws, &["config", "status.showUntrackedFiles", "no"]);
2598 let detail = collect_git_status_detail(ws).unwrap();
2599 assert!(detail.files.is_empty());
2600 assert_eq!(detail.untracked, 0);
2601 assert!(
2602 detail.revision.is_none(),
2603 "hidden untracked content cannot guard add --all"
2604 );
2605 assert!(
2606 collect_git_status_detail(&ws.join("build"))
2607 .unwrap()
2608 .files
2609 .is_empty()
2610 );
2611 }
2612
2613 #[test]
2614 fn index_token_does_not_enumerate_untracked_tree() {
2615 let tmp = repo();
2616 GIT_READS.with(|reads| reads.set(0));
2617 index_token(tmp.path());
2618 // One rev-parse for the frame and one ls-files --stage for the index.
2619 assert_eq!(GIT_READS.with(|reads| reads.get()), 2);
2620 }
2621
2622 #[test]
2623 fn expect_validation_rules() {
2624 let parse = |value: Value| serde_json::from_value::<GitExpect>(value);
2625 let paths = vec!["a.txt".to_string()];
2626 let rev = format!("c-{}", "a".repeat(64));
2627
2628 // Absent head is unchecked; explicit null means unborn.
2629 let unchecked =
2630 validate_expect(Some(parse(json!({})).unwrap()), ExpectTarget::Commit).unwrap();
2631 assert!(unchecked.is_empty());
2632 let unborn = validate_expect(
2633 Some(parse(json!({ "head": null })).unwrap()),
2634 ExpectTarget::Commit,
2635 )
2636 .unwrap();
2637 assert_eq!(unborn.head, Some(None));
2638 let upper = validate_expect(
2639 Some(parse(json!({ "head": "A".repeat(40) })).unwrap()),
2640 ExpectTarget::Commit,
2641 )
2642 .unwrap();
2643 assert_eq!(upper.head, Some(Some("a".repeat(40))));
2644
2645 for bad in [
2646 json!({ "head": "abc" }),
2647 json!({ "head": "g".repeat(40) }),
2648 json!({ "index": "a".repeat(40) }),
2649 json!({ "revision": "zz" }),
2650 json!({ "revision": null }),
2651 ] {
2652 assert!(
2653 validate_expect(Some(parse(bad.clone()).unwrap()), ExpectTarget::Commit).is_err(),
2654 "{bad}"
2655 );
2656 }
2657 assert!(parse(json!({ "heads": null })).is_err(), "unknown key");
2658
2659 let files = |map: Value| parse(json!({ "files": map })).unwrap();
2660 assert!(
2661 validate_expect(Some(files(json!({ "a.txt": rev }))), ExpectTarget::Commit).is_err()
2662 );
2663 assert!(
2664 validate_expect(Some(files(json!({ "a.txt": rev }))), ExpectTarget::AllPaths).is_err()
2665 );
2666 assert!(
2667 validate_expect(Some(files(json!({}))), ExpectTarget::Paths(&paths)).is_err(),
2668 "missing a requested path"
2669 );
2670 assert!(
2671 validate_expect(
2672 Some(files(json!({ "a.txt": rev, "b.txt": rev }))),
2673 ExpectTarget::Paths(&paths)
2674 )
2675 .is_err(),
2676 "extra path"
2677 );
2678 assert!(
2679 validate_expect(
2680 Some(files(json!({ "a.txt": "a".repeat(64) }))),
2681 ExpectTarget::Paths(&paths)
2682 )
2683 .is_err(),
2684 "rev without a mode"
2685 );
2686 let ok = validate_expect(
2687 Some(files(json!({ "./a.txt/": rev }))),
2688 ExpectTarget::Paths(&paths),
2689 );
2690 // `./` is refused by the shared path confinement, like request paths.
2691 assert!(ok.is_err());
2692 let ok = validate_expect(
2693 Some(files(json!({ "a.txt/": rev }))),
2694 ExpectTarget::Paths(&paths),
2695 )
2696 .unwrap();
2697 assert_eq!(ok.files.unwrap().keys().collect::<Vec<_>>(), vec!["a.txt"]);
2698 }
2699
2700 /// A dropped (or timed-out) write takes down what git started — a hook, a
2701 /// filter, an alias — not just the `git` process.
2702 #[cfg(unix)]
2703 #[tokio::test]
2704 async fn dropped_git_write_kills_what_git_started() {
2705 let tmp = repo();
2706 let pid_file = tmp.path().join("hang.pid");
2707 let args = vec![
2708 "-c".to_string(),
2709 "alias.hang=!sleep 300 & echo $! > hang.pid; wait".to_string(),
2710 "hang".to_string(),
2711 ];
2712 let grandchild =
2713 crate::process_tree::drop_once_pid_written(git_write(tmp.path(), args), &pid_file)
2714 .await;
2715 assert!(
2716 crate::process_tree::wait_for_pid_exit(grandchild, Duration::from_secs(5)),
2717 "a process git started outlived the dropped request"
2718 );
2719 }
2720
2721 #[test]
2722 fn file_diff_reads_the_path_literally_inside_a_subdirectory_workspace() {
2723 let tmp = repo();
2724 let root = tmp.path();
2725 fs::create_dir_all(root.join("apps/web")).unwrap();
2726 fs::create_dir_all(root.join("apps/other")).unwrap();
2727 fs::write(root.join("apps/web/w.txt"), "w\n").unwrap();
2728 fs::write(root.join("apps/other/secret.txt"), "old\n").unwrap();
2729 git(root, &["add", "apps"]);
2730 git(root, &["commit", "-q", "-m", "apps"]);
2731 fs::write(root.join("apps/web/w.txt"), "w2\n").unwrap();
2732 fs::write(root.join("apps/other/secret.txt"), "new\n").unwrap();
2733 let workspace = root.join("apps/web");
2734 let diff = |path: &str| {
2735 let output = Git::review_command(&workspace)
2736 .unwrap()
2737 .args(file_diff_args("HEAD", path))
2738 .output()
2739 .unwrap();
2740 assert!(output.status.success(), "{output:?}");
2741 String::from_utf8_lossy(&output.stdout).into_owned()
2742 };
2743 assert!(diff("w.txt").contains("+w2"), "in-workspace diff works");
2744 for path in [
2745 ":/apps/other/secret.txt",
2746 ":(top)apps/other/secret.txt",
2747 "*",
2748 ] {
2749 let body = diff(path);
2750 assert!(!body.contains("secret"), "{path} escaped: {body}");
2751 }
2752 }
2753
2754 #[tokio::test]
2755 async fn push_args_validate_the_effective_remote_and_end_options() {
2756 let tmp = repo();
2757 let root = tmp.path();
2758 let status = |error: ApiError| error.status;
2759
2760 // No remote configured: plain `push` stays git's call, but an
2761 // upstream push defaults to `origin`, which must exist too.
2762 assert_eq!(push_args(root, None, false).await.unwrap(), ["push"]);
2763 assert_eq!(push_args(root, Some(" "), false).await.unwrap(), ["push"]);
2764 assert_eq!(
2765 push_args(root, None, true)
2766 .await
2767 .map_err(status)
2768 .unwrap_err(),
2769 StatusCode::BAD_REQUEST
2770 );
2771
2772 let bare = tempfile::tempdir().unwrap();
2773 git(bare.path(), &["init", "-q", "--bare"]);
2774 git(
2775 root,
2776 &["remote", "add", "origin", &bare.path().to_string_lossy()],
2777 );
2778 assert_eq!(
2779 push_args(root, Some(" origin "), false).await.unwrap(),
2780 ["push", "--", "origin"]
2781 );
2782 for remote in ["--mirror", "fork", "../other"] {
2783 assert_eq!(
2784 push_args(root, Some(remote), true)
2785 .await
2786 .map_err(status)
2787 .unwrap_err(),
2788 StatusCode::BAD_REQUEST,
2789 "{remote}"
2790 );
2791 }
2792 let args = push_args(root, None, true).await.unwrap();
2793 assert_eq!(args, ["push", "--set-upstream", "--", "origin", "main"]);
2794
2795 // The built arguments are ones git accepts: the push lands in the
2796 // bare remote and records the upstream.
2797 git(root, &args.iter().map(String::as_str).collect::<Vec<_>>());
2798 let upstream = run_git_sync(root, &["rev-parse", "--abbrev-ref", "@{upstream}"]).unwrap();
2799 assert_eq!(upstream.trim(), "origin/main");
2800 }
2801
2802 #[test]
2803 fn push_remote_must_be_a_configured_remote_name() {
2804 let configured = "origin\nupstream\n";
2805 assert!(validate_push_remote("origin", configured).is_ok());
2806 assert!(validate_push_remote("upstream", configured).is_ok());
2807 for remote in [
2808 "--force",
2809 "--mirror",
2810 "-f",
2811 "--delete",
2812 "../other-repo",
2813 "fork",
2814 ] {
2815 let error = validate_push_remote(remote, configured).expect_err(remote);
2816 assert_eq!(error.status, StatusCode::BAD_REQUEST, "{remote}");
2817 }
2818 }
2819 }
2820
2820 lines RUST