| 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(¤t) { |
| 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 ¤t != expected { |
| 1571 | stale.push("head"); |
| 1572 | parts.push(match (expected, ¤t) { |
| 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 |