forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
35.9 KiB
1//! Pull request plumbing: PR head refs, merge checks, merge commits and compare-and-swap ref
2//! updates. Every object id is validated as a full hex id and every ref name against a strict
3//! allowlist before it reaches git. User values follow `--end-of-options` where git allows it.
4
5use std::path::Path;
6use std::process::{Output, Stdio};
7
8use tokio::io::AsyncWriteExt;
9
10use crate::browse::{CommitInfo, validate_repo_path};
11use crate::command::{GitCommand, GitEnv, ObjectSource};
12use crate::error::{Error, Result};
13use crate::history::{
14 CommitDiff, DiffLimits, diff_between, empty_tree_for, git, new_commits_with, validate_oid,
15};
16
17/// Outcome of a merge check between a base tip and a head commit.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum MergeCheck {
20 /// The merge would be clean; `tree` is the id of the resulting tree.
21 Clean { tree: String },
22 /// The merge would conflict; `files` are the conflicted paths, deduplicated.
23 Conflict { files: Vec<String> },
24 /// The head is already contained in the base.
25 UpToDate,
26}
27
28/// Author or committer identity for [`commit_tree`]. `time_unix` is written as `<unix> +0000`.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct Signature {
31 pub name: String,
32 pub email: String,
33 pub time_unix: i64,
34}
35
36/// Runs a command without turning a non-zero exit into an error, optionally feeding stdin.
37async fn run_raw(cmd: GitCommand, stdin: Option<&[u8]>) -> Result<Output> {
38 let mut command = cmd.into_tokio();
39 command.stdout(Stdio::piped()).stderr(Stdio::piped());
40 if stdin.is_some() {
41 command.stdin(Stdio::piped());
42 }
43 let mut child = command.spawn()?;
44 if let (Some(data), Some(mut pipe)) = (stdin, child.stdin.take()) {
45 pipe.write_all(data).await?;
46 // `pipe` is dropped here, which closes git's stdin.
47 }
48 Ok(child.wait_with_output().await?)
49}
50
51fn failed(subcommand: &str, out: &Output) -> Error {
52 Error::Failed(format!(
53 "git {subcommand} exited with {}: {}",
54 out.status,
55 String::from_utf8_lossy(&out.stderr).trim()
56 ))
57}
58
59fn stdout_trimmed(out: &Output) -> String {
60 String::from_utf8_lossy(&out.stdout).trim().to_owned()
61}
62
63/// Accepts `refs/heads/<name>` and `refs/pull/<N>/head`, with a conservative character set.
64fn validate_ref(refname: &str) -> Result<()> {
65 let heads_ok = refname
66 .strip_prefix("refs/heads/")
67 .is_some_and(valid_branch_rest);
68 let pull_ok = refname
69 .strip_prefix("refs/pull/")
70 .and_then(|r| r.strip_suffix("/head"))
71 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()));
72 if heads_ok || pull_ok {
73 Ok(())
74 } else {
75 Err(Error::InvalidInput(format!(
76 "invalid ref name: {refname:?}"
77 )))
78 }
79}
80
81fn valid_branch_rest(rest: &str) -> bool {
82 !rest.is_empty()
83 && !rest.contains("..")
84 && !rest.contains("@{")
85 && !rest.contains("//")
86 && !rest.ends_with(['/', '.'])
87 && !rest.ends_with(".lock")
88 && rest
89 .split('/')
90 .all(|c| !c.is_empty() && !c.starts_with(['.', '-']))
91 && rest
92 .bytes()
93 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'.' | b'_' | b'-' | b'+'))
94}
95
96/// Rejects identity fields git would refuse or misparse.
97fn validate_signature(sig: &Signature) -> Result<()> {
98 let bad = |s: &str| s.is_empty() || s.contains(['<', '>', '\n', '\0']);
99 if bad(&sig.name) || bad(&sig.email) {
100 return Err(Error::InvalidInput("invalid identity name or email".into()));
101 }
102 Ok(())
103}
104
105/// True when both paths name the same repository (equal, or equal after canonicalization).
106fn same_repo(a: &Path, b: &Path) -> bool {
107 if a == b {
108 return true;
109 }
110 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
111 (Ok(x), Ok(y)) => x == y,
112 _ => false,
113 }
114}
115
116/// Fetches `oid` from `head_repo` into `base_repo`'s object store. The first attempt uses a plain
117/// fetch of the raw id. If git refuses it, the one call is retried with
118/// `uploadpack.allowAnySHA1InWant=true` for the local upload-pack process.
119async fn fetch_object(env: &GitEnv, base_repo: &Path, head_repo: &Path, oid: &str) -> Result<()> {
120 let head_path = head_repo
121 .to_str()
122 .ok_or_else(|| Error::InvalidInput("repo path is not valid UTF-8".into()))?;
123 let build = |allow_any: bool| -> Result<GitCommand> {
124 // `head_repo` is a fork repo from repo_path(), so the local transport is allowed here only.
125 let cmd = git(env, "fetch")
126 .allow_local_transport()
127 .git_dir(base_repo)
128 .arg("--no-tags")
129 .arg("--no-write-fetch-head");
130 let cmd = if allow_any {
131 cmd.env("GIT_CONFIG_COUNT", "2")
132 .env("GIT_CONFIG_KEY_1", "uploadpack.allowAnySHA1InWant")
133 .env("GIT_CONFIG_VALUE_1", "true")
134 } else {
135 cmd
136 };
137 cmd.user_revs(&[head_path, oid])
138 };
139 match build(false)?.status().await {
140 Ok(()) => Ok(()),
141 Err(Error::Failed(_)) => build(true)?.status().await,
142 Err(e) => Err(e),
143 }
144}
145
146/// Points `refs/pull/<number>/head` at `head_oid` in `base_repo`. When the head lives in another
147/// repository, the object is fetched into `base_repo` first.
148pub async fn fetch_into_pull_ref(
149 env: &GitEnv,
150 base_repo: &Path,
151 head_repo: &Path,
152 head_oid: &str,
153 number: i64,
154) -> Result<()> {
155 validate_oid(head_oid)?;
156 if number < 1 {
157 return Err(Error::InvalidInput(format!(
158 "invalid pull number: {number}"
159 )));
160 }
161 let refname = format!("refs/pull/{number}/head");
162 validate_ref(&refname)?;
163 if !same_repo(base_repo, head_repo) {
164 fetch_object(env, base_repo, head_repo, head_oid).await?;
165 }
166 git(env, "update-ref")
167 .git_dir(base_repo)
168 .user_arg(&refname)?
169 .user_arg(head_oid)?
170 .status()
171 .await
172}
173
174/// Best common ancestor of `a` and `b`, or `None` when they share no history.
175pub async fn merge_base(env: &GitEnv, repo: &Path, a: &str, b: &str) -> Result<Option<String>> {
176 merge_base_with(env, repo, ObjectSource::default(), a, b).await
177}
178
179/// [`merge_base`], reading objects missing from `repo` (such as a fork's head commit) from `src`.
180/// Nothing is written to `repo`.
181pub async fn merge_base_with(
182 env: &GitEnv,
183 repo: &Path,
184 src: ObjectSource<'_>,
185 a: &str,
186 b: &str,
187) -> Result<Option<String>> {
188 validate_oid(a)?;
189 validate_oid(b)?;
190 let cmd = git(env, "merge-base")
191 .git_dir(repo)
192 .objects(src)?
193 .user_revs(&[a, b])?;
194 let out = run_raw(cmd, None).await?;
195 match out.status.code() {
196 Some(0) => {
197 let oid = stdout_trimmed(&out);
198 validate_oid(&oid)?;
199 Ok(Some(oid))
200 }
201 Some(1) => Ok(None),
202 _ => Err(failed("merge-base", &out)),
203 }
204}
205
206/// Blob id of `path` in `commit`, or `None` when the path is absent or is not a blob (a tree or a
207/// submodule). `commit` must be a full object id. `path` is a relative `/`-separated repo path.
208pub async fn blob_at(
209 env: &GitEnv,
210 repo: &Path,
211 commit: &str,
212 path: &str,
213) -> Result<Option<String>> {
214 blob_at_with(env, repo, ObjectSource::default(), commit, path).await
215}
216
217/// [`blob_at`], reading objects missing from `repo` (such as a fork's commit) from `src`.
218/// Nothing is written to `repo`.
219pub async fn blob_at_with(
220 env: &GitEnv,
221 repo: &Path,
222 src: ObjectSource<'_>,
223 commit: &str,
224 path: &str,
225) -> Result<Option<String>> {
226 validate_oid(commit)?;
227 validate_blob_path(path)?;
228 // `ls-tree` rather than `rev-parse <commit>:<path>`: the path is a pathspec after `--`, so it is
229 // never read as a revision. `:(literal)` stops globs and magic such as `:x` from matching
230 // other entries. Output is NUL-terminated and unquoted, so any name is safe to parse.
231 let spec = format!(":(literal){path}");
232 let out = git(env, "ls-tree")
233 .git_dir(repo)
234 .objects(src)?
235 .arg("-z")
236 .arg("--full-tree")
237 .user_arg(commit)?
238 .paths(&[spec.as_str()])
239 .output()
240 .await?;
241 // Pathspecs can match more than one entry, so keep only the exact path.
242 let entry = out.split(|&b| b == 0).find_map(|rec| {
243 let text = std::str::from_utf8(rec).ok()?;
244 let (meta, name) = text.split_once('\t')?;
245 if name != path {
246 return None;
247 }
248 let mut fields = meta.split(' ');
249 let _mode = fields.next()?;
250 let kind = fields.next()?;
251 let oid = fields.next()?;
252 Some((kind.to_owned(), oid.to_owned()))
253 });
254 match entry {
255 Some((kind, oid)) if kind == "blob" => {
256 validate_oid(&oid)?;
257 Ok(Some(oid))
258 }
259 _ => Ok(None),
260 }
261}
262
263/// Rules for the path argument of [`blob_at`]: non-empty, relative, no newline, and the segment
264/// rules of [`validate_repo_path`].
265fn validate_blob_path(path: &str) -> Result<()> {
266 if path.is_empty() {
267 return Err(Error::InvalidInput("empty path".into()));
268 }
269 if path.starts_with('/') {
270 return Err(Error::InvalidInput(format!("absolute path: {path:?}")));
271 }
272 if path.contains('\n') {
273 return Err(Error::InvalidInput("path contains newline".into()));
274 }
275 validate_repo_path(path)
276}
277
278/// True when `a` is an ancestor of `b` (or equal to it).
279pub async fn is_ancestor(env: &GitEnv, repo: &Path, a: &str, b: &str) -> Result<bool> {
280 validate_oid(a)?;
281 validate_oid(b)?;
282 let cmd = git(env, "merge-base")
283 .git_dir(repo)
284 .arg("--is-ancestor")
285 .user_revs(&[a, b])?;
286 let out = run_raw(cmd, None).await?;
287 match out.status.code() {
288 Some(0) => Ok(true),
289 Some(1) => Ok(false),
290 _ => Err(failed("merge-base --is-ancestor", &out)),
291 }
292}
293
294/// Checks whether `head` merges cleanly into `base`, without touching any ref.
295pub async fn check_merge(
296 env: &GitEnv,
297 repo: &Path,
298 base_oid: &str,
299 head_oid: &str,
300) -> Result<MergeCheck> {
301 validate_oid(base_oid)?;
302 validate_oid(head_oid)?;
303 if is_ancestor(env, repo, head_oid, base_oid).await? {
304 return Ok(MergeCheck::UpToDate);
305 }
306 let cmd = git(env, "merge-tree")
307 .git_dir(repo)
308 .arg("--write-tree")
309 .arg("--name-only")
310 .arg("--no-messages")
311 .user_revs(&[base_oid, head_oid])?;
312 let out = run_raw(cmd, None).await?;
313 let text = String::from_utf8_lossy(&out.stdout).into_owned();
314 match out.status.code() {
315 Some(0) => {
316 let tree = text.lines().next().unwrap_or_default().trim().to_owned();
317 validate_oid(&tree)?;
318 Ok(MergeCheck::Clean { tree })
319 }
320 Some(1) => {
321 let mut lines = text.lines();
322 let tree = lines.next().unwrap_or_default().trim().to_owned();
323 validate_oid(&tree)?;
324 let mut files: Vec<String> = Vec::new();
325 for line in lines.map(str::trim).filter(|l| !l.is_empty()) {
326 if !files.iter().any(|f| f == line) {
327 files.push(line.to_owned());
328 }
329 }
330 Ok(MergeCheck::Conflict { files })
331 }
332 _ => Err(failed("merge-tree", &out)),
333 }
334}
335
336/// Creates a commit for `tree` with the given parents and returns its id. The message is written
337/// to stdin. Author and committer come from `GIT_AUTHOR_*` and `GIT_COMMITTER_*`.
338pub async fn commit_tree(
339 env: &GitEnv,
340 repo: &Path,
341 tree: &str,
342 parents: &[&str],
343 message: &str,
344 author: &Signature,
345 committer: &Signature,
346) -> Result<String> {
347 validate_oid(tree)?;
348 for p in parents {
349 validate_oid(p)?;
350 }
351 validate_signature(author)?;
352 validate_signature(committer)?;
353 if message.contains('\0') {
354 return Err(Error::InvalidInput("commit message contains NUL".into()));
355 }
356 let mut cmd = git(env, "commit-tree").git_dir(repo);
357 for p in parents {
358 cmd = cmd.arg("-p").user_arg(p)?;
359 }
360 let cmd = cmd
361 .user_revs(&[tree])?
362 .env("GIT_AUTHOR_NAME", author.name.as_str())
363 .env("GIT_AUTHOR_EMAIL", author.email.as_str())
364 .env("GIT_AUTHOR_DATE", format!("{} +0000", author.time_unix))
365 .env("GIT_COMMITTER_NAME", committer.name.as_str())
366 .env("GIT_COMMITTER_EMAIL", committer.email.as_str())
367 .env(
368 "GIT_COMMITTER_DATE",
369 format!("{} +0000", committer.time_unix),
370 );
371 let out = run_raw(cmd, Some(message.as_bytes())).await?;
372 if !out.status.success() {
373 return Err(failed("commit-tree", &out));
374 }
375 let oid = stdout_trimmed(&out);
376 validate_oid(&oid)?;
377 Ok(oid)
378}
379
380/// Moves `refname` from `old` to `new` only if it still points at `old`. A stale `old` gives
381/// `Error::RefMoved`. Use the zero id as `old` to require that the ref does not exist yet.
382pub async fn update_ref_cas(
383 env: &GitEnv,
384 repo: &Path,
385 refname: &str,
386 new: &str,
387 old: &str,
388) -> Result<()> {
389 validate_ref(refname)?;
390 validate_oid(new)?;
391 validate_oid(old)?;
392 let cmd = git(env, "update-ref")
393 .git_dir(repo)
394 .user_arg(refname)?
395 .user_arg(new)?
396 .user_arg(old)?;
397 let out = run_raw(cmd, None).await?;
398 if out.status.success() {
399 return Ok(());
400 }
401 let stderr = String::from_utf8_lossy(&out.stderr);
402 if stderr.contains("but expected") || stderr.contains("unable to resolve reference") {
403 return Err(Error::RefMoved(refname.to_owned()));
404 }
405 Err(failed("update-ref", &out))
406}
407
408/// Deletes `refname` only if it still points at `old`. `git update-ref -d <ref> <old>` checks the
409/// old value itself, so a ref that moved, or one that is already gone, gives `Error::RefMoved`
410/// and is left alone. The zero id is not accepted as `old`: a deletion needs a real value to
411/// compare against.
412pub async fn delete_ref_cas(env: &GitEnv, repo: &Path, refname: &str, old: &str) -> Result<()> {
413 validate_ref(refname)?;
414 validate_oid(old)?;
415 let cmd = git(env, "update-ref")
416 .git_dir(repo)
417 .arg("-d")
418 .user_arg(refname)?
419 .user_arg(old)?;
420 let out = run_raw(cmd, None).await?;
421 if out.status.success() {
422 return Ok(());
423 }
424 let stderr = String::from_utf8_lossy(&out.stderr);
425 if stderr.contains("but expected") || stderr.contains("unable to resolve reference") {
426 return Err(Error::RefMoved(refname.to_owned()));
427 }
428 Err(failed("update-ref", &out))
429}
430
431/// The diff of the head side of a three-dot range: `merge-base(base, head)..head`. Parsed the same
432/// way as [`crate::history::commit_diff`]. With no common ancestor it is the diff from the empty tree.
433pub async fn range_diff_files(
434 env: &GitEnv,
435 repo: &Path,
436 base_oid: &str,
437 head_oid: &str,
438 limits: DiffLimits,
439) -> Result<CommitDiff> {
440 range_diff_files_with(
441 env,
442 repo,
443 ObjectSource::default(),
444 base_oid,
445 head_oid,
446 limits,
447 )
448 .await
449}
450
451/// [`range_diff_files`], reading objects missing from `repo` from `src`. Nothing is written.
452pub async fn range_diff_files_with(
453 env: &GitEnv,
454 repo: &Path,
455 src: ObjectSource<'_>,
456 base_oid: &str,
457 head_oid: &str,
458 limits: DiffLimits,
459) -> Result<CommitDiff> {
460 validate_oid(base_oid)?;
461 validate_oid(head_oid)?;
462 let from = match merge_base_with(env, repo, src, base_oid, head_oid).await? {
463 Some(b) => b,
464 None => empty_tree_for(head_oid).to_owned(),
465 };
466 diff_between(env, repo, src, &from, head_oid, limits).await
467}
468
469/// Commits reachable from `head` but not from `base` (`log <head> ^<base>`), oldest first.
470/// At most `max` of the newest such commits are returned.
471pub async fn range_log(
472 env: &GitEnv,
473 repo: &Path,
474 base_oid: &str,
475 head_oid: &str,
476 max: usize,
477) -> Result<Vec<CommitInfo>> {
478 range_log_with(env, repo, ObjectSource::default(), base_oid, head_oid, max).await
479}
480
481/// [`range_log`], reading objects missing from `repo` from `src`. Nothing is written.
482pub async fn range_log_with(
483 env: &GitEnv,
484 repo: &Path,
485 src: ObjectSource<'_>,
486 base_oid: &str,
487 head_oid: &str,
488 max: usize,
489) -> Result<Vec<CommitInfo>> {
490 validate_oid(base_oid)?;
491 validate_oid(head_oid)?;
492 new_commits_with(env, repo, src, Some(base_oid), head_oid, max).await
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use crate::history::DiffStatus;
499 use std::path::PathBuf;
500 use std::process::Command;
501
502 const LIMITS: DiffLimits = DiffLimits {
503 max_files: 100,
504 max_lines_per_file: 1000,
505 max_total_lines: 10_000,
506 };
507
508 /// Runs git in `dir` with a cleared environment and fixed identities.
509 fn git_in(dir: &Path, home: &Path, args: &[&str]) -> String {
510 let out = Command::new("/usr/bin/git")
511 .arg("-C")
512 .arg(dir)
513 .args(["-c", "commit.gpgsign=false"])
514 .args(args)
515 .env_clear()
516 .env("PATH", "/usr/bin:/bin")
517 .env("HOME", home)
518 .env("GIT_CONFIG_NOSYSTEM", "1")
519 .env("GIT_CONFIG_GLOBAL", "/dev/null")
520 .env("GIT_AUTHOR_NAME", "Tester")
521 .env("GIT_AUTHOR_EMAIL", "tester@example.com")
522 .env("GIT_COMMITTER_NAME", "Tester")
523 .env("GIT_COMMITTER_EMAIL", "tester@example.com")
524 .output()
525 .expect("git runs");
526 assert!(
527 out.status.success(),
528 "git {args:?} failed: {}",
529 String::from_utf8_lossy(&out.stderr)
530 );
531 String::from_utf8_lossy(&out.stdout).trim().to_owned()
532 }
533
534 struct Sandbox {
535 _dir: tempfile::TempDir,
536 home: PathBuf,
537 }
538
539 impl Sandbox {
540 /// The data dir holds the real managed gitconfig, as written at boot, so these tests see
541 /// the same `protocol.file.allow = never` setting that the server runs with.
542 fn new() -> Self {
543 let dir = tempfile::tempdir().expect("tempdir");
544 let home = dir.path().join("home");
545 std::fs::create_dir(&home).expect("home dir");
546 crate::setup::write_managed_gitconfig(&home).expect("managed gitconfig");
547 Self { _dir: dir, home }
548 }
549
550 fn env(&self) -> GitEnv {
551 GitEnv {
552 git_bin: PathBuf::from("/usr/bin/git"),
553 data_dir: self.home.clone(),
554 }
555 }
556
557 /// A non-bare repo named `name` with `main` checked out.
558 fn repo(&self, name: &str) -> Work {
559 let work = self._dir.path().join(name);
560 std::fs::create_dir(&work).expect("work dir");
561 git_in(&work, &self.home, &["init", "-q", "-b", "main"]);
562 Work {
563 work,
564 home: self.home.clone(),
565 }
566 }
567 }
568
569 struct Work {
570 work: PathBuf,
571 home: PathBuf,
572 }
573
574 impl Work {
575 fn git_dir(&self) -> PathBuf {
576 self.work.join(".git")
577 }
578
579 fn git(&self, args: &[&str]) -> String {
580 git_in(&self.work, &self.home, args)
581 }
582
583 fn write(&self, name: &str, content: &str) {
584 std::fs::write(self.work.join(name), content).expect("write file");
585 }
586
587 /// Stages everything, commits, and returns the new full id.
588 fn commit(&self, msg: &str) -> String {
589 self.git(&["add", "-A"]);
590 self.git(&["commit", "-q", "--allow-empty", "-m", msg]);
591 self.git(&["rev-parse", "HEAD"])
592 }
593 }
594
595 #[tokio::test]
596 async fn clean_merge_builds_merge_commit_with_author() {
597 let sb = Sandbox::new();
598 let env = sb.env();
599 let w = sb.repo("repo");
600 w.write("a.txt", "1\n");
601 let c0 = w.commit("c0");
602 w.git(&["switch", "-q", "-c", "feature"]);
603 w.write("b.txt", "feature\n");
604 let head = w.commit("feature work");
605 w.git(&["switch", "-q", "main"]);
606 w.write("c.txt", "main\n");
607 let base = w.commit("main work");
608 let gd = w.git_dir();
609
610 assert_eq!(
611 merge_base(&env, &gd, &base, &head)
612 .await
613 .expect("merge-base"),
614 Some(c0)
615 );
616 let tree = match check_merge(&env, &gd, &base, &head).await.expect("check") {
617 MergeCheck::Clean { tree } => tree,
618 other => panic!("expected clean merge, got {other:?}"),
619 };
620 assert_eq!(tree.len(), 40);
621
622 let author = Signature {
623 name: "Alice Author".into(),
624 email: "alice@example.com".into(),
625 time_unix: 1_700_000_000,
626 };
627 let committer = Signature {
628 name: "Carol Committer".into(),
629 email: "carol@example.com".into(),
630 time_unix: 1_700_000_100,
631 };
632 let merge = commit_tree(
633 &env,
634 &gd,
635 &tree,
636 &[base.as_str(), head.as_str()],
637 "Merge pull request #1 from owner/feature\n\nFeature",
638 &author,
639 &committer,
640 )
641 .await
642 .expect("commit-tree");
643
644 assert_eq!(
645 w.git(&["rev-list", "--parents", "-n1", merge.as_str()]),
646 format!("{merge} {base} {head}")
647 );
648 assert_eq!(
649 w.git(&[
650 "log",
651 "-1",
652 "--format=%T|%an|%ae|%at|%cn|%ce|%ct|%s",
653 merge.as_str()
654 ]),
655 format!(
656 "{tree}|Alice Author|alice@example.com|1700000000|Carol Committer|carol@example.com|1700000100|Merge pull request #1 from owner/feature"
657 )
658 );
659 assert!(
660 is_ancestor(&env, &gd, &head, &merge)
661 .await
662 .expect("ancestor")
663 );
664 }
665
666 #[tokio::test]
667 async fn conflicting_merge_lists_the_file() {
668 let sb = Sandbox::new();
669 let env = sb.env();
670 let w = sb.repo("repo");
671 w.write("f.txt", "base\n");
672 w.commit("c0");
673 w.git(&["switch", "-q", "-c", "feature"]);
674 w.write("f.txt", "head\n");
675 let head = w.commit("feature edits f");
676 w.git(&["switch", "-q", "main"]);
677 w.write("f.txt", "other\n");
678 let base = w.commit("main edits f");
679
680 let check = check_merge(&env, &w.git_dir(), &base, &head)
681 .await
682 .expect("check");
683 assert_eq!(
684 check,
685 MergeCheck::Conflict {
686 files: vec!["f.txt".to_owned()]
687 }
688 );
689 }
690
691 #[tokio::test]
692 async fn head_contained_in_base_is_up_to_date() {
693 let sb = Sandbox::new();
694 let env = sb.env();
695 let w = sb.repo("repo");
696 w.write("a.txt", "1\n");
697 let c0 = w.commit("c0");
698 w.write("a.txt", "2\n");
699 let c1 = w.commit("c1");
700 let gd = w.git_dir();
701
702 assert_eq!(
703 check_merge(&env, &gd, &c1, &c0).await.expect("check"),
704 MergeCheck::UpToDate
705 );
706 assert!(is_ancestor(&env, &gd, &c0, &c1).await.expect("ancestor"));
707 assert!(!is_ancestor(&env, &gd, &c1, &c0).await.expect("ancestor"));
708 }
709
710 #[tokio::test]
711 async fn update_ref_cas_succeeds_then_refuses_stale_old() {
712 let sb = Sandbox::new();
713 let env = sb.env();
714 let w = sb.repo("repo");
715 w.write("a.txt", "1\n");
716 let c0 = w.commit("c0");
717 w.write("a.txt", "2\n");
718 let c1 = w.commit("c1");
719 w.write("a.txt", "3\n");
720 let c2 = w.commit("c2");
721 let gd = w.git_dir();
722
723 // The base is at c2, so a CAS expecting c0 must fail and leave the ref alone.
724 let stale = update_ref_cas(&env, &gd, "refs/heads/main", &c1, &c0).await;
725 assert!(matches!(stale, Err(Error::RefMoved(_))), "{stale:?}");
726 assert_eq!(w.git(&["rev-parse", "main"]), c2);
727
728 update_ref_cas(&env, &gd, "refs/heads/main", &c1, &c2)
729 .await
730 .expect("cas with current old");
731 assert_eq!(w.git(&["rev-parse", "main"]), c1);
732 }
733
734 #[tokio::test]
735 async fn delete_ref_cas_deletes_only_at_expected_value() {
736 let sb = Sandbox::new();
737 let env = sb.env();
738 let w = sb.repo("repo");
739 w.write("a.txt", "1\n");
740 let c0 = w.commit("c0");
741 w.git(&["branch", "topic"]);
742 w.write("a.txt", "2\n");
743 let c1 = w.commit("c1");
744 let gd = w.git_dir();
745
746 // topic is at c0, so a deletion expecting c1 must fail and leave the ref alone.
747 let stale = delete_ref_cas(&env, &gd, "refs/heads/topic", &c1).await;
748 assert!(matches!(stale, Err(Error::RefMoved(_))), "{stale:?}");
749 assert_eq!(w.git(&["rev-parse", "topic"]), c0);
750
751 delete_ref_cas(&env, &gd, "refs/heads/topic", &c0)
752 .await
753 .expect("delete at current value");
754 let gone = w.git(&["for-each-ref", "refs/heads/topic"]);
755 assert!(gone.is_empty(), "{gone:?}");
756
757 // Deleting a ref that is already gone is a refusal, not a silent success.
758 let missing = delete_ref_cas(&env, &gd, "refs/heads/topic", &c0).await;
759 assert!(matches!(missing, Err(Error::RefMoved(_))), "{missing:?}");
760 }
761
762 #[tokio::test]
763 async fn range_log_returns_head_side_commits_oldest_first() {
764 let sb = Sandbox::new();
765 let env = sb.env();
766 let w = sb.repo("repo");
767 w.write("a.txt", "1\n");
768 w.commit("c0");
769 w.git(&["switch", "-q", "-c", "feature"]);
770 w.write("f1.txt", "1\n");
771 w.commit("f1");
772 w.write("f2.txt", "1\n");
773 let head = w.commit("f2");
774 w.git(&["switch", "-q", "main"]);
775 w.write("m.txt", "1\n");
776 let base = w.commit("m1");
777
778 let log = range_log(&env, &w.git_dir(), &base, &head, 100)
779 .await
780 .expect("range log");
781 let summaries: Vec<&str> = log.iter().map(|c| c.summary.as_str()).collect();
782 assert_eq!(summaries, vec!["f1", "f2"]);
783 }
784
785 #[tokio::test]
786 async fn range_diff_shows_only_head_side_after_base_moved() {
787 let sb = Sandbox::new();
788 let env = sb.env();
789 let w = sb.repo("repo");
790 w.write("a.txt", "1\n");
791 w.commit("c0");
792 w.git(&["switch", "-q", "-c", "feature"]);
793 w.write("feat.txt", "feature\n");
794 let head = w.commit("feature");
795 w.git(&["switch", "-q", "main"]);
796 w.write("base.txt", "base\n");
797 let base = w.commit("base moves");
798
799 let diff = range_diff_files(&env, &w.git_dir(), &base, &head, LIMITS)
800 .await
801 .expect("range diff");
802 let paths: Vec<String> = diff
803 .files
804 .iter()
805 .map(|f| f.new_path.clone().unwrap_or_default())
806 .collect();
807 assert_eq!(paths, vec!["feat.txt".to_owned()]);
808 assert_eq!(diff.files[0].status, DiffStatus::Added);
809 }
810
811 /// True when the repo at `git_dir` has `oid` in its object store.
812 fn has_object(git_dir: &Path, oid: &str) -> bool {
813 Command::new("/usr/bin/git")
814 .arg("--git-dir")
815 .arg(git_dir)
816 .args(["cat-file", "-e", oid])
817 .env_clear()
818 .env("PATH", "/usr/bin:/bin")
819 .env("GIT_CONFIG_NOSYSTEM", "1")
820 .env("GIT_CONFIG_GLOBAL", "/dev/null")
821 .status()
822 .expect("git runs")
823 .success()
824 }
825
826 #[tokio::test]
827 async fn alternate_objects_read_fork_commit_without_copying_it() {
828 let sb = Sandbox::new();
829 let env = sb.env();
830 let base = sb.repo("base");
831 base.write("a.txt", "1\n");
832 let base_tip = base.commit("base");
833 // The fork is a clone, so both histories share `base_tip`.
834 let fork_dir = sb._dir.path().join("fork");
835 git_in(
836 sb._dir.path(),
837 &sb.home,
838 &["clone", "-q", base.work.to_str().expect("utf8"), "fork"],
839 );
840 let fork = Work {
841 work: fork_dir,
842 home: sb.home.clone(),
843 };
844 fork.write("b.txt", "fork\n");
845 let fork_head = fork.commit("fork work");
846 let base_git = base.git_dir();
847 let fork_objects = fork.git_dir().join("objects");
848 assert!(!has_object(&base_git, &fork_head));
849
850 // Without the alternate the base cannot see the fork's commit.
851 assert!(
852 range_log(&env, &base_git, &base_tip, &fork_head, 10)
853 .await
854 .is_err()
855 );
856
857 let src = ObjectSource {
858 extra: Some(fork_objects.as_path()),
859 };
860 let log = range_log_with(&env, &base_git, src, &base_tip, &fork_head, 10)
861 .await
862 .expect("range log through alternate");
863 let summaries: Vec<&str> = log.iter().map(|c| c.summary.as_str()).collect();
864 assert_eq!(summaries, vec!["fork work"]);
865
866 let merge = merge_base_with(&env, &base_git, src, &base_tip, &fork_head)
867 .await
868 .expect("merge base through alternate");
869 assert_eq!(merge.as_deref(), Some(base_tip.as_str()));
870
871 let diff = range_diff_files_with(&env, &base_git, src, &base_tip, &fork_head, LIMITS)
872 .await
873 .expect("range diff through alternate");
874 let paths: Vec<String> = diff
875 .files
876 .iter()
877 .map(|f| f.new_path.clone().unwrap_or_default())
878 .collect();
879 assert_eq!(paths, vec!["b.txt".to_owned()]);
880
881 // Reading through the alternate wrote nothing into the base repo.
882 assert!(!has_object(&base_git, &fork_head));
883 }
884
885 #[tokio::test]
886 async fn fetch_into_pull_ref_from_fork_and_same_repo() {
887 let sb = Sandbox::new();
888 let env = sb.env();
889 let base = sb.repo("base");
890 base.write("a.txt", "1\n");
891 let base_tip = base.commit("base");
892 let fork = sb.repo("fork");
893 fork.write("x.txt", "fork\n");
894 let fork_tip = fork.commit("fork work");
895
896 fetch_into_pull_ref(&env, &base.git_dir(), &fork.git_dir(), &fork_tip, 1)
897 .await
898 .expect("fork fetch");
899 assert_eq!(base.git(&["rev-parse", "refs/pull/1/head"]), fork_tip);
900
901 fetch_into_pull_ref(&env, &base.git_dir(), &base.git_dir(), &base_tip, 2)
902 .await
903 .expect("same-repo update");
904 assert_eq!(base.git(&["rev-parse", "refs/pull/2/head"]), base_tip);
905 }
906
907 #[tokio::test]
908 async fn fetch_into_pull_ref_handles_unadvertised_fork_commit() {
909 let sb = Sandbox::new();
910 let env = sb.env();
911 let base = sb.repo("base");
912 base.write("a.txt", "1\n");
913 base.commit("base");
914 let fork = sb.repo("fork");
915 fork.write("x.txt", "fork\n");
916 fork.commit("main work");
917 fork.git(&["switch", "-q", "-c", "topic"]);
918 fork.write("y.txt", "topic\n");
919 let topic_tip = fork.commit("topic work");
920 fork.git(&["switch", "-q", "main"]);
921 fork.git(&["branch", "-q", "-D", "topic"]);
922
923 fetch_into_pull_ref(&env, &base.git_dir(), &fork.git_dir(), &topic_tip, 3)
924 .await
925 .expect("unadvertised fetch");
926 assert_eq!(base.git(&["rev-parse", "refs/pull/3/head"]), topic_tip);
927 }
928
929 #[tokio::test]
930 async fn blob_at_returns_blob_ids_for_files_only() {
931 let sb = Sandbox::new();
932 let env = sb.env();
933 let w = sb.repo("repo");
934 let gd = w.git_dir();
935 w.write("a.txt", "1\n");
936 std::fs::create_dir(w.work.join("dir")).expect("dir");
937 w.write("dir/x.txt", "x\n");
938 let c0 = w.commit("c0");
939 let a0 = w.git(&["hash-object", "a.txt"]);
940
941 assert_eq!(
942 blob_at(&env, &gd, &c0, "a.txt").await.expect("ok"),
943 Some(a0.clone())
944 );
945 assert_eq!(
946 blob_at(&env, &gd, &c0, "dir/x.txt").await.expect("ok"),
947 Some(w.git(&["hash-object", "dir/x.txt"]))
948 );
949 assert_eq!(
950 blob_at(&env, &gd, &c0, "missing.txt").await.expect("ok"),
951 None
952 );
953 assert_eq!(blob_at(&env, &gd, &c0, "dir").await.expect("ok"), None);
954
955 w.write("a.txt", "2\n");
956 let c1 = w.commit("c1");
957 let a1 = blob_at(&env, &gd, &c1, "a.txt").await.expect("ok");
958 assert_eq!(a1, Some(w.git(&["hash-object", "a.txt"])));
959 assert_ne!(a1, Some(a0));
960 // The older commit still maps to the older blob.
961 let old_spec = format!("{c0}:a.txt");
962 assert_eq!(
963 blob_at(&env, &gd, &c0, "a.txt").await.expect("ok"),
964 Some(w.git(&["rev-parse", old_spec.as_str()]))
965 );
966 }
967
968 #[tokio::test]
969 async fn blob_at_handles_literal_special_and_unicode_names() {
970 let sb = Sandbox::new();
971 let env = sb.env();
972 let w = sb.repo("repo");
973 let gd = w.git_dir();
974 std::fs::create_dir(w.work.join("dir with space")).expect("dir");
975 w.write("dir with space/füß ü.txt", "u\n");
976 w.write("star*", "s\n");
977 w.write(":colon", "c\n");
978 w.write("other", "o\n");
979 let c0 = w.commit("c0");
980
981 assert_eq!(
982 blob_at(&env, &gd, &c0, "dir with space/füß ü.txt")
983 .await
984 .expect("ok"),
985 Some(w.git(&["hash-object", "dir with space/füß ü.txt"]))
986 );
987 assert_eq!(
988 blob_at(&env, &gd, &c0, "star*").await.expect("ok"),
989 Some(w.git(&["hash-object", "star*"]))
990 );
991 assert_eq!(
992 blob_at(&env, &gd, &c0, ":colon").await.expect("ok"),
993 Some(w.git(&["hash-object", ":colon"]))
994 );
995 // A glob must not match other entries.
996 assert_eq!(blob_at(&env, &gd, &c0, "st*").await.expect("ok"), None);
997 assert_eq!(blob_at(&env, &gd, &c0, "*").await.expect("ok"), None);
998 }
999
1000 #[tokio::test]
1001 async fn blob_at_rejects_invalid_paths_and_commits() {
1002 let sb = Sandbox::new();
1003 let env = sb.env();
1004 let w = sb.repo("repo");
1005 w.write("a.txt", "1\n");
1006 let c0 = w.commit("c0");
1007 let gd = w.git_dir();
1008
1009 for bad in [
1010 "", "../x", "a/../x", "/a", "a//b", "a/", "./a", "a/./b", "a\nb", "a\0b",
1011 ] {
1012 let r = blob_at(&env, &gd, &c0, bad).await;
1013 assert!(matches!(r, Err(Error::InvalidInput(_))), "{bad:?}: {r:?}");
1014 }
1015 let r = blob_at(&env, &gd, "not-hex", "a.txt").await;
1016 assert!(matches!(r, Err(Error::InvalidInput(_))), "{r:?}");
1017 }
1018
1019 #[tokio::test]
1020 async fn invalid_inputs_are_rejected_before_git() {
1021 let sb = Sandbox::new();
1022 let env = sb.env();
1023 let w = sb.repo("repo");
1024 w.write("a.txt", "1\n");
1025 let c0 = w.commit("c0");
1026 let gd = w.git_dir();
1027 let short = "a".repeat(39);
1028
1029 let bad_oid = |r: Result<()>| assert!(matches!(r, Err(Error::InvalidInput(_))), "{r:?}");
1030 bad_oid(check_merge(&env, &gd, "not-hex", &c0).await.map(|_| ()));
1031 bad_oid(merge_base(&env, &gd, &short, &c0).await.map(|_| ()));
1032 bad_oid(
1033 commit_tree(
1034 &env,
1035 &gd,
1036 "--output=/tmp/x",
1037 &[],
1038 "m",
1039 &Signature {
1040 name: "a".into(),
1041 email: "a@example.com".into(),
1042 time_unix: 0,
1043 },
1044 &Signature {
1045 name: "a".into(),
1046 email: "a@example.com".into(),
1047 time_unix: 0,
1048 },
1049 )
1050 .await
1051 .map(|_| ()),
1052 );
1053 bad_oid(update_ref_cas(&env, &gd, "refs/heads/main", "HEAD", &c0).await);
1054 bad_oid(update_ref_cas(&env, &gd, "refs/heads/../x", &c0, &c0).await);
1055 bad_oid(update_ref_cas(&env, &gd, "refs/heads/-x", &c0, &c0).await);
1056 bad_oid(fetch_into_pull_ref(&env, &gd, &gd, "--upload-pack=x", 1).await);
1057 bad_oid(fetch_into_pull_ref(&env, &gd, &gd, &c0, 0).await);
1058 bad_oid(range_log(&env, &gd, "main", &c0, 10).await.map(|_| ()));
1059 bad_oid(
1060 range_diff_files(&env, &gd, &c0, &short, LIMITS)
1061 .await
1062 .map(|_| ()),
1063 );
1064 let bad_name = Signature {
1065 name: "Eve <eve@example.com>".into(),
1066 email: "eve@example.com".into(),
1067 time_unix: 0,
1068 };
1069 bad_oid(
1070 commit_tree(&env, &gd, &c0, &[], "m", &bad_name, &bad_name)
1071 .await
1072 .map(|_| ()),
1073 );
1074 }
1075}