1use std::collections::HashMap;
26
27use serde::{Deserialize, Serialize};
28
29use crate::change_id::{change_id, set_change_id, ChangeId};
30use crate::merge_base::{
31 is_ancestor, walk_commits_reachable_excluding_ancestors_of, ReachableWalkLimit,
32};
33use crate::objects::{parse_commit, parse_tree, serialize_commit, ObjectId, ObjectKind};
34use crate::odb::Odb;
35use crate::repo::Repository;
36
37use super::graft::{parents_first, without_signatures};
38use super::graft_log::GraftLog;
39use super::tree::{edit, lookup, TREE_MODE};
40use super::{
41 in_ref, in_view, out_ref, same_branch_name, valid_branch_name, view_tree, BranchInfo,
42 CheckoutInfo, Result, SnapshotRoot,
43};
44
45#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
47#[serde(tag = "kind", rename_all = "snake_case")]
48pub enum ProjectError {
49 #[error("not a valid branch name")]
51 InvalidName,
52 #[error("the branch the checkout follows ({target})")]
55 TargetBranch {
56 target: String,
58 },
59 #[error("no such branch")]
61 NoSuchBranch,
62 #[error("already on the target branch")]
64 OnTarget,
65 #[error("builds on {commit}, from before the checkout's first snapshot")]
68 BeforeFirstSnapshot {
69 commit: String,
71 },
72}
73
74impl ProjectError {
75 #[must_use]
77 pub fn kind(&self) -> &'static str {
78 match self {
79 Self::InvalidName => "invalid_name",
80 Self::TargetBranch { .. } => "target_branch",
81 Self::NoSuchBranch => "no_such_branch",
82 Self::OnTarget => "on_target",
83 Self::BeforeFirstSnapshot { .. } => "before_first_snapshot",
84 }
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub struct ProjectedCommit {
91 pub real: ObjectId,
93 pub projected: ObjectId,
95 pub reused: bool,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct Projection {
105 pub tip: ObjectId,
108 pub commits: Vec<ProjectedCommit>,
111}
112
113impl Projection {
114 #[must_use]
117 pub fn made(&self) -> Vec<(ObjectId, ObjectId)> {
118 self.commits
119 .iter()
120 .filter(|c| !c.reused)
121 .map(|c| (c.projected, c.real))
122 .collect()
123 }
124
125 #[must_use]
127 pub fn reused(&self) -> usize {
128 self.commits.iter().filter(|c| c.reused).count()
129 }
130}
131
132fn root_pairs(roots: &[SnapshotRoot]) -> Vec<(ObjectId, ObjectId)> {
135 roots
136 .iter()
137 .filter_map(|r| {
138 Some((
139 ObjectId::from_hex(&r.commit).ok()?,
140 ObjectId::from_hex(&r.base).ok()?,
141 ))
142 })
143 .collect()
144}
145
146pub fn project(
180 repo: &Repository,
181 roots: &[SnapshotRoot],
182 paths: &[String],
183 known: Option<&GraftLog>,
184 own: &[ObjectId],
185 targets: &[ObjectId],
186 tip: ObjectId,
187) -> Result<std::result::Result<Projection, ProjectError>> {
188 let roots = root_pairs(roots);
189 if let Some((root, _)) = roots.iter().rev().find(|(_, base)| *base == tip) {
190 return Ok(Ok(Projection {
191 tip: *root,
192 commits: Vec::new(),
193 }));
194 }
195 let mut stop = targets.to_vec();
196 stop.extend(roots.iter().map(|(_, base)| *base));
197 let (_, newest_first) = walk_commits_reachable_excluding_ancestors_of(
198 repo,
199 &[tip],
200 &stop,
201 ReachableWalkLimit::Unlimited,
202 )?;
203 if newest_first.is_empty() {
204 return Ok(Err(ProjectError::OnTarget));
205 }
206 let odb = repo.odb();
207 let mut map: HashMap<ObjectId, ObjectId> = HashMap::new();
208 let mut commits = Vec::with_capacity(newest_first.len());
209 for real in parents_first(repo, &newest_first)? {
210 let mut data = odb.read_commit(&real)?;
211 let mut parents: Vec<ObjectId> = Vec::with_capacity(data.parents.len());
212 for parent in &data.parents {
213 let mapped = match map.get(parent) {
214 Some(projected) => *projected,
215 None => match snapshot_under(repo, &roots, *parent)? {
216 Some(root) => root,
217 None => {
218 return Ok(Err(ProjectError::BeforeFirstSnapshot {
219 commit: parent.to_hex()[..12].to_owned(),
220 }))
221 }
222 },
223 };
224 if !parents.contains(&mapped) {
225 parents.push(mapped);
226 }
227 }
228 let tree = projected_tree(odb, paths, data.tree, parents.first().copied())?;
229 let projected = match reusable(repo, known, own, &real, &parents, tree)? {
230 Some(logged) => ProjectedCommit {
231 real,
232 projected: logged,
233 reused: true,
234 },
235 None => {
236 data.tree = tree;
237 data.parents = parents;
238 data.extra_headers = without_signatures(&data.extra_headers);
239 if change_id(&data).is_none() {
240 set_change_id(&mut data, &ChangeId::derived_from_commit_id(&real));
241 }
242 ProjectedCommit {
243 real,
244 projected: odb.write(ObjectKind::Commit, &serialize_commit(&data))?,
245 reused: false,
246 }
247 }
248 };
249 map.insert(real, projected.projected);
250 commits.push(projected);
251 }
252 match map.get(&tip) {
253 Some(tip) => Ok(Ok(Projection { tip: *tip, commits })),
254 None => Ok(Err(ProjectError::OnTarget)),
255 }
256}
257
258fn snapshot_under(
261 repo: &Repository,
262 roots: &[(ObjectId, ObjectId)],
263 commit: ObjectId,
264) -> Result<Option<ObjectId>> {
265 for (root, base) in roots.iter().rev() {
266 if *base == commit || is_ancestor(repo, *base, commit)? {
267 return Ok(Some(*root));
268 }
269 }
270 Ok(None)
271}
272
273fn reusable(
281 repo: &Repository,
282 known: Option<&GraftLog>,
283 own: &[ObjectId],
284 real: &ObjectId,
285 parents: &[ObjectId],
286 tree: ObjectId,
287) -> Result<Option<ObjectId>> {
288 let Some(log) = known else {
289 return Ok(None);
290 };
291 for raw in log.raws(real)? {
292 let Ok(object) = repo.odb().read(&raw) else {
293 continue;
294 };
295 if object.kind != ObjectKind::Commit {
296 continue;
297 }
298 let commit = parse_commit(&object.data)?;
299 if commit.parents != parents {
300 continue;
301 }
302 if commit.tree == tree || reaches(repo, own, raw)? {
303 return Ok(Some(raw));
304 }
305 }
306 Ok(None)
307}
308
309fn reaches(repo: &Repository, tips: &[ObjectId], commit: ObjectId) -> Result<bool> {
311 for tip in tips {
312 if is_ancestor(repo, commit, *tip)? {
313 return Ok(true);
314 }
315 }
316 Ok(false)
317}
318
319fn projected_tree(
323 odb: &Odb,
324 paths: &[String],
325 real_tree: ObjectId,
326 parent: Option<ObjectId>,
327) -> Result<ObjectId> {
328 let tree = view_tree(odb, real_tree, paths)?;
329 let Some(parent) = parent else {
330 return Ok(tree);
331 };
332 let parent_tree = odb.commit_tree(&parent)?;
333 let mut extras = Vec::new();
334 outside_files(odb, parent_tree, paths, &mut Vec::new(), &mut extras)?;
335 let mut edits = Vec::new();
336 for path in extras {
337 if let Some(entry) = lookup(odb, real_tree, &path)?.filter(|e| e.0 != TREE_MODE) {
340 edits.push((path, Some(entry)));
341 }
342 }
343 if edits.is_empty() {
344 return Ok(tree);
345 }
346 Ok(edit(odb, Some(tree), &edits)?)
347}
348
349fn outside_files(
352 odb: &Odb,
353 tree: ObjectId,
354 paths: &[String],
355 prefix: &mut Vec<u8>,
356 out: &mut Vec<Vec<u8>>,
357) -> Result<()> {
358 for entry in parse_tree(&odb.read(&tree)?.data)? {
359 let len = prefix.len();
360 if len > 0 {
361 prefix.push(b'/');
362 }
363 prefix.extend_from_slice(&entry.name);
364 if entry.mode == TREE_MODE {
365 let selected = paths.iter().map(String::as_bytes).any(|p| {
368 prefix.as_slice() == p
369 || (prefix.starts_with(p) && prefix.get(p.len()) == Some(&b'/'))
370 });
371 if !selected {
372 outside_files(odb, entry.oid, paths, prefix, out)?;
373 }
374 } else if !in_view(prefix, paths) {
375 out.push(prefix.clone());
376 }
377 prefix.truncate(len);
378 }
379 Ok(())
380}
381
382pub fn project_branch(
410 repo: &Repository,
411 checkout: &CheckoutInfo,
412 known: &GraftLog,
413 name: &str,
414) -> Result<std::result::Result<BranchInfo, ProjectError>> {
415 if !valid_branch_name(name) {
416 return Ok(Err(ProjectError::InvalidName));
417 }
418 if same_branch_name(name, &checkout.branch) {
419 return Ok(Err(ProjectError::TargetBranch {
420 target: checkout.branch.clone(),
421 }));
422 }
423 let Some((name, tip)) = server_branch(repo, name)? else {
424 return Ok(Err(ProjectError::NoSuchBranch));
425 };
426 let name = name.as_str();
427 let targets: Vec<ObjectId> =
428 crate::refs::resolve_ref(repo.git_dir(), &format!("refs/heads/{}", checkout.branch))
429 .into_iter()
430 .collect();
431 let own: Vec<ObjectId> = crate::refs::list_refs(repo.git_dir(), &out_ref(&checkout.id, ""))?
432 .into_iter()
433 .map(|(_, tip)| tip)
434 .collect();
435 let projection = match project(
436 repo,
437 &checkout.roots,
438 &checkout.paths,
439 Some(known),
440 &own,
441 &targets,
442 tip,
443 )? {
444 Ok(projection) => projection,
445 Err(refusal) => return Ok(Err(refusal)),
446 };
447 known.record(&projection.made())?;
448 crate::refs::update_direct_ref_on_store(
449 repo.refs(),
450 repo.git_dir(),
451 &in_ref(&checkout.id, name),
452 &projection.tip,
453 )?;
454 Ok(Ok(BranchInfo {
455 name: name.to_owned(),
456 tip: tip.to_hex(),
457 projected: projection.tip.to_hex(),
458 commits: projection.commits.len(),
459 made: projection.made().len(),
460 reused: projection.reused(),
461 }))
462}
463
464fn server_branch(repo: &Repository, name: &str) -> Result<Option<(String, ObjectId)>> {
472 let branches: Vec<(String, ObjectId)> = crate::refs::list_refs(repo.git_dir(), "refs/heads/")?
473 .into_iter()
474 .filter_map(|(refname, tip)| {
475 refname
476 .strip_prefix("refs/heads/")
477 .map(|branch| (branch.to_owned(), tip))
478 })
479 .collect();
480 if let Some(exact) = branches.iter().find(|(branch, _)| branch == name) {
481 return Ok(Some(exact.clone()));
482 }
483 if !super::checkin::ignores_case(repo)? {
484 return Ok(None);
485 }
486 Ok(branches
487 .into_iter()
488 .find(|(branch, _)| same_branch_name(branch, name)))
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use crate::objects::CommitData;
495 use crate::repo::InitOptions;
496 use crate::snapshot_checkout::{graft, make_snapshot, Snapshot};
497
498 fn repo() -> (tempfile::TempDir, Repository) {
499 let dir = tempfile::tempdir().expect("tempdir");
500 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
501 (dir, repo)
502 }
503
504 fn blob(repo: &Repository, text: &str) -> (u32, ObjectId) {
505 (
506 0o100644,
507 repo.odb()
508 .write(ObjectKind::Blob, text.as_bytes())
509 .expect("blob"),
510 )
511 }
512
513 fn tree(repo: &Repository, base: Option<ObjectId>, files: &[(&str, Option<&str>)]) -> ObjectId {
516 let edits: Vec<_> = files
517 .iter()
518 .map(|(path, text)| (path.as_bytes().to_vec(), text.map(|t| blob(repo, t))))
519 .collect();
520 edit(repo.odb(), base, &edits).expect("tree")
521 }
522
523 fn commit(
526 repo: &Repository,
527 tree: ObjectId,
528 parents: &[ObjectId],
529 msg: &str,
530 extra: &str,
531 ) -> ObjectId {
532 let mut s = format!("tree {}\n", tree.to_hex());
533 for p in parents {
534 s.push_str(&format!("parent {}\n", p.to_hex()));
535 }
536 s.push_str(&format!(
537 "author A <a@e> 1700000000 +0000\ncommitter C <c@e> 1700000100 +0000\n{extra}\n{msg}\n"
538 ));
539 repo.odb()
540 .write(ObjectKind::Commit, s.as_bytes())
541 .expect("commit")
542 }
543
544 fn read(repo: &Repository, oid: ObjectId) -> CommitData {
545 parse_commit(&repo.odb().read(&oid).expect("read").data).expect("parse")
546 }
547
548 fn change(
550 repo: &Repository,
551 parent: ObjectId,
552 files: &[(&str, Option<&str>)],
553 msg: &str,
554 ) -> ObjectId {
555 let t = tree(repo, Some(read(repo, parent).tree), files);
556 commit(repo, t, &[parent], msg, "")
557 }
558
559 fn file(repo: &Repository, commit: ObjectId, path: &str) -> Option<String> {
560 let entry =
561 lookup(repo.odb(), read(repo, commit).tree, path.as_bytes()).expect("lookup")?;
562 Some(String::from_utf8_lossy(&repo.odb().read(&entry.1).expect("blob").data).into_owned())
563 }
564
565 fn roots(snaps: &[&Snapshot]) -> Vec<SnapshotRoot> {
566 snaps
567 .iter()
568 .map(|s| SnapshotRoot {
569 commit: s.commit.to_hex(),
570 base: s.base.to_hex(),
571 })
572 .collect()
573 }
574
575 fn fixture(repo: &Repository) -> (ObjectId, Snapshot) {
578 let full = tree(
579 repo,
580 None,
581 &[
582 ("README", Some("top")),
583 ("src/a", Some("a")),
584 ("src/lib/x", Some("x")),
585 ("docs/d", Some("d")),
586 ],
587 );
588 let base = commit(repo, full, &[], "base", "");
589 let snap = make_snapshot(repo, base, &["src".to_owned()], None).expect("snapshot");
590 (base, snap)
591 }
592
593 #[cfg_attr(
594 not(feature = "full-tests"),
595 ignore = "starts system git or is heavy; run with --features full-tests"
596 )]
597 #[test]
598 fn branches_are_cut_to_the_paths_and_commits_on_other_paths_come_out_empty() {
599 let (_dir, repo) = repo();
600 let (base, snap) = fixture(&repo);
601 let paths = vec!["src".to_owned()];
602 let t1 = tree(
604 &repo,
605 Some(read(&repo, base).tree),
606 &[("src/a", Some("a by bob")), ("docs/d", Some("d by bob"))],
607 );
608 let r1 = commit(
609 &repo,
610 t1,
611 &[base],
612 "Change a and the docs",
613 "gpgsig -----BEGIN PGP SIGNATURE-----\n abc\n -----END PGP SIGNATURE-----\n",
614 );
615 let r2 = change(&repo, r1, &[("docs/d", Some("more docs"))], "Docs only");
616
617 let projection = project(&repo, &roots(&[&snap]), &paths, None, &[], &[base], r2)
618 .expect("project")
619 .expect("projected");
620 assert_eq!(projection.commits.len(), 2);
621 assert_eq!(projection.reused(), 0);
622 let [first, second] = [projection.commits[0], projection.commits[1]];
623 assert_eq!((first.real, second.real), (r1, r2));
624 assert_eq!(projection.tip, second.projected);
625 assert_eq!(
626 projection.made(),
627 vec![(first.projected, r1), (second.projected, r2)]
628 );
629
630 let p1 = read(&repo, first.projected);
631 assert_eq!(p1.parents, vec![snap.commit], "on the snapshot of its base");
632 assert_eq!(
633 file(&repo, first.projected, "src/a").as_deref(),
634 Some("a by bob")
635 );
636 assert_eq!(
637 file(&repo, first.projected, "README").as_deref(),
638 Some("top")
639 );
640 assert_eq!(
641 file(&repo, first.projected, "docs/d"),
642 None,
643 "outside the paths"
644 );
645 assert_eq!(p1.message, "Change a and the docs\n");
646 assert_eq!(p1.author, "A <a@e> 1700000000 +0000");
647 assert_eq!(p1.committer, "C <c@e> 1700000100 +0000");
648 let text = String::from_utf8_lossy(&repo.odb().read(&first.projected).expect("read").data)
649 .into_owned();
650 assert!(!text.contains("gpgsig"), "{text}");
651 assert_eq!(
652 change_id(&p1),
653 Some(ChangeId::derived_from_commit_id(&r1)),
654 "the id the real commit already reports"
655 );
656 let p2 = read(&repo, second.projected);
658 assert_eq!(p2.parents, vec![first.projected]);
659 assert_eq!(p2.tree, p1.tree);
660 assert_eq!(p2.message, "Docs only\n");
661
662 assert_eq!(
664 project(&repo, &roots(&[&snap]), &paths, None, &[], &[base], r2).expect("project"),
665 Ok(projection)
666 );
667 }
668
669 #[cfg_attr(
670 not(feature = "full-tests"),
671 ignore = "starts system git or is heavy; run with --features full-tests"
672 )]
673 #[test]
674 fn a_checkouts_own_commits_show_as_they_are_with_their_files_outside_the_paths() {
675 let (dir, repo) = repo();
676 let (base, snap) = fixture(&repo);
677 let paths = vec!["src".to_owned()];
678 let log = GraftLog::at(dir.path().join("grafts"));
679 let roots = roots(&[&snap]);
680 let own = change(
683 &repo,
684 snap.commit,
685 &[("src/a", Some("mine")), ("docs/new", Some("new"))],
686 "Mine",
687 );
688 let grafted = graft(&repo, &roots, &paths, Some(&log), &[base], own)
689 .expect("graft")
690 .expect("grafted");
691 log.record(&grafted.commits).expect("record");
692 let elsewhere = project(&repo, &roots, &paths, Some(&log), &[], &[base], grafted.tip)
695 .expect("project")
696 .expect("projected");
697 assert_eq!(elsewhere.reused(), 0);
698 assert_ne!(elsewhere.tip, own);
699 assert_eq!(file(&repo, elsewhere.tip, "docs/new"), None);
700 assert_eq!(file(&repo, elsewhere.tip, "src/a").as_deref(), Some("mine"));
701 let theirs = change(&repo, grafted.tip, &[("src/lib/x", Some("x2"))], "Theirs");
703
704 let projection = project(&repo, &roots, &paths, Some(&log), &[own], &[base], theirs)
705 .expect("project")
706 .expect("projected");
707 assert_eq!(projection.reused(), 1);
708 assert_eq!(
709 projection.commits[0],
710 ProjectedCommit {
711 real: grafted.tip,
712 projected: own,
713 reused: true,
714 },
715 "the checkout's own commit"
716 );
717 let shown = projection.tip;
718 assert_eq!(read(&repo, shown).parents, vec![own]);
719 assert_eq!(file(&repo, shown, "src/lib/x").as_deref(), Some("x2"));
720 assert_eq!(
721 file(&repo, shown, "docs/new").as_deref(),
722 Some("new"),
723 "a file outside the paths its parent carries is kept"
724 );
725 assert_eq!(file(&repo, shown, "docs/d"), None);
726 assert_eq!(projection.made(), vec![(shown, theirs)]);
727
728 log.record(&projection.made()).expect("record");
731 let gone = change(&repo, theirs, &[("docs/new", None)], "Drop new");
732 let again = project(&repo, &roots, &paths, Some(&log), &[own], &[base], gone)
733 .expect("project")
734 .expect("projected");
735 assert_eq!(again.reused(), 2);
736 assert_eq!(again.commits[1].projected, shown);
737 assert_eq!(file(&repo, again.tip, "docs/new"), None);
738 assert_eq!(read(&repo, again.tip).parents, vec![shown]);
739 }
740
741 #[cfg_attr(
742 not(feature = "full-tests"),
743 ignore = "starts system git or is heavy; run with --features full-tests"
744 )]
745 #[test]
746 fn an_outside_file_that_became_a_directory_is_not_carried() {
747 let (dir, repo) = repo();
748 let (base, snap) = fixture(&repo);
749 let paths = vec!["src".to_owned()];
750 let log = GraftLog::at(dir.path().join("grafts"));
751 let roots = roots(&[&snap]);
752 let own = change(&repo, snap.commit, &[("other/x", Some("file"))], "Mine");
753 let grafted = graft(&repo, &roots, &paths, Some(&log), &[base], own)
754 .expect("graft")
755 .expect("grafted");
756 log.record(&grafted.commits).expect("record");
757 let theirs = change(
759 &repo,
760 grafted.tip,
761 &[
762 ("other/x", None),
763 ("other/x/s1", Some("s1")),
764 ("src/a", Some("theirs")),
765 ],
766 "Theirs",
767 );
768
769 let shown = project(&repo, &roots, &paths, Some(&log), &[own], &[base], theirs)
770 .expect("project")
771 .expect("projected");
772 assert_eq!(read(&repo, shown.tip).parents, vec![own]);
773 assert_eq!(file(&repo, shown.tip, "src/a").as_deref(), Some("theirs"));
774 assert_eq!(
775 lookup(repo.odb(), read(&repo, shown.tip).tree, b"other").expect("lookup"),
776 None,
777 "neither the file nor the directory that replaced it"
778 );
779 }
780
781 #[test]
782 fn another_checkouts_commit_on_the_same_snapshot_is_cut_to_these_paths() {
783 let (dir, repo) = repo();
784 let full = tree(
785 &repo,
786 None,
787 &[("README", Some("top")), ("src/deep/y", Some("y"))],
788 );
789 let base = commit(&repo, full, &[], "base", "");
790 let alice_paths = vec!["src".to_owned()];
791 let bob_paths = vec!["src/deep".to_owned()];
792 let alice = make_snapshot(&repo, base, &alice_paths, None).expect("snapshot");
793 let bob = make_snapshot(&repo, base, &bob_paths, None).expect("snapshot");
794 assert_eq!(alice.commit, bob.commit, "src holds only src/deep");
795 let log = GraftLog::at(dir.path().join("grafts"));
796 let a = change(&repo, alice.commit, &[("src/other/x", Some("x"))], "Alice");
797 let ga = graft(
798 &repo,
799 &roots(&[&alice]),
800 &alice_paths,
801 Some(&log),
802 &[base],
803 a,
804 )
805 .expect("graft")
806 .expect("grafted");
807 log.record(&ga.commits).expect("record");
808
809 let to_bob = project(
810 &repo,
811 &roots(&[&bob]),
812 &bob_paths,
813 Some(&log),
814 &[],
815 &[base],
816 ga.tip,
817 )
818 .expect("project")
819 .expect("projected");
820 assert_eq!((to_bob.made().len(), to_bob.reused()), (1, 0));
821 assert_eq!(file(&repo, to_bob.tip, "src/other/x"), None);
822 assert_eq!(read(&repo, to_bob.tip).parents, vec![bob.commit]);
823 let to_alice = project(
825 &repo,
826 &roots(&[&alice]),
827 &alice_paths,
828 Some(&log),
829 &[],
830 &[base],
831 ga.tip,
832 )
833 .expect("project")
834 .expect("projected");
835 assert_eq!(to_alice.tip, a);
836 assert_eq!(to_alice.reused(), 1);
837 }
838
839 #[test]
840 fn two_checkouts_of_different_paths_extend_one_branch() {
841 let (dir, repo) = repo();
842 let full = tree(
843 &repo,
844 None,
845 &[
846 ("README", Some("top")),
847 ("builtin/blame.c", Some("blame")),
848 ("Documentation/blame.txt", Some("docs")),
849 ("t/blame.sh", Some("test")),
850 ],
851 );
852 let base = commit(&repo, full, &[], "base", "");
853 let alice_paths = vec!["Documentation".to_owned(), "builtin".to_owned()];
854 let bob_paths = vec!["Documentation".to_owned(), "t".to_owned()];
855 let alice = make_snapshot(&repo, base, &alice_paths, None).expect("snapshot");
856 let bob = make_snapshot(&repo, base, &bob_paths, None).expect("snapshot");
857 let log = GraftLog::at(dir.path().join("grafts"));
858
859 let a = change(
861 &repo,
862 alice.commit,
863 &[
864 ("builtin/blame.c", Some("blame by alice")),
865 ("Documentation/blame.txt", Some("docs by alice")),
866 ],
867 "Alice",
868 );
869 let ga = graft(
870 &repo,
871 &roots(&[&alice]),
872 &alice_paths,
873 Some(&log),
874 &[base],
875 a,
876 )
877 .expect("graft")
878 .expect("grafted");
879 log.record(&ga.commits).expect("record");
880
881 let shown = project(
883 &repo,
884 &roots(&[&bob]),
885 &bob_paths,
886 Some(&log),
887 &[],
888 &[base],
889 ga.tip,
890 )
891 .expect("project")
892 .expect("projected");
893 log.record(&shown.made()).expect("record");
894 assert_eq!(
895 file(&repo, shown.tip, "Documentation/blame.txt").as_deref(),
896 Some("docs by alice")
897 );
898 assert_eq!(file(&repo, shown.tip, "builtin/blame.c"), None);
899
900 let b = change(
903 &repo,
904 shown.tip,
905 &[
906 ("Documentation/blame.txt", Some("docs by bob")),
907 ("t/blame.sh", Some("test by bob")),
908 ],
909 "Bob",
910 );
911 let gb = graft(&repo, &roots(&[&bob]), &bob_paths, Some(&log), &[base], b)
912 .expect("graft")
913 .expect("grafted");
914 assert_eq!(gb.commits.len(), 1, "only Bob's commit is replayed");
915 assert_eq!(read(&repo, gb.tip).parents, vec![ga.tip]);
916 assert_eq!(
917 file(&repo, gb.tip, "builtin/blame.c").as_deref(),
918 Some("blame by alice")
919 );
920 assert_eq!(
921 file(&repo, gb.tip, "Documentation/blame.txt").as_deref(),
922 Some("docs by bob")
923 );
924 assert_eq!(
925 file(&repo, gb.tip, "t/blame.sh").as_deref(),
926 Some("test by bob")
927 );
928 log.record(&gb.commits).expect("record");
929
930 let back = project(
932 &repo,
933 &roots(&[&alice]),
934 &alice_paths,
935 Some(&log),
936 &[],
937 &[base],
938 gb.tip,
939 )
940 .expect("project")
941 .expect("projected");
942 assert_eq!(back.commits[0].projected, a, "her own commit, as it is");
943 assert_eq!(read(&repo, back.tip).parents, vec![a]);
944 assert_eq!(
945 file(&repo, back.tip, "Documentation/blame.txt").as_deref(),
946 Some("docs by bob")
947 );
948 assert_eq!(file(&repo, back.tip, "t/blame.sh"), None);
949 log.record(&back.made()).expect("record");
950 assert_eq!(
952 graft(
953 &repo,
954 &roots(&[&alice]),
955 &alice_paths,
956 Some(&log),
957 &[base],
958 back.tip
959 )
960 .expect("graft"),
961 Ok(crate::snapshot_checkout::Grafted {
962 tip: gb.tip,
963 commits: Vec::new(),
964 })
965 );
966 }
967
968 #[cfg_attr(
969 not(feature = "full-tests"),
970 ignore = "starts system git or is heavy; run with --features full-tests"
971 )]
972 #[test]
973 fn branches_the_checkout_cant_be_shown_are_refused() {
974 let (dir, repo) = repo();
975 let (base, snap) = fixture(&repo);
976 let paths = vec!["src".to_owned()];
977 let roots = roots(&[&snap]);
978 assert_eq!(
980 project(&repo, &roots, &paths, None, &[], &[base], base).expect("project"),
981 Ok(Projection {
982 tip: snap.commit,
983 commits: Vec::new(),
984 })
985 );
986 let moved = change(&repo, base, &[("README", Some("top2"))], "main moves");
988 assert_eq!(
989 project(&repo, &roots, &paths, None, &[], &[moved], moved).expect("project"),
990 Err(ProjectError::OnTarget)
991 );
992 let newer = make_snapshot(&repo, moved, &paths, Some(snap.commit)).expect("snapshot");
994 let old = change(&repo, base, &[("src/a", Some("old work"))], "Old work");
995 let newer_only = super::super::SnapshotRoot {
996 commit: newer.commit.to_hex(),
997 base: moved.to_hex(),
998 };
999 assert_eq!(
1000 project(
1001 &repo,
1002 std::slice::from_ref(&newer_only),
1003 &paths,
1004 None,
1005 &[],
1006 &[moved],
1007 old
1008 )
1009 .expect("project"),
1010 Err(ProjectError::BeforeFirstSnapshot {
1011 commit: base.to_hex()[..12].to_owned()
1012 })
1013 );
1014 let both = vec![roots[0].clone(), newer_only];
1016 let on_moved = change(&repo, moved, &[("src/a", Some("new work"))], "New work");
1017 let shown = project(&repo, &both, &paths, None, &[], &[moved], on_moved)
1018 .expect("project")
1019 .expect("projected");
1020 assert_eq!(read(&repo, shown.tip).parents, vec![newer.commit]);
1021 let shown_old = project(&repo, &both, &paths, None, &[], &[moved], old)
1022 .expect("project")
1023 .expect("projected");
1024 assert_eq!(read(&repo, shown_old.tip).parents, vec![snap.commit]);
1025
1026 crate::refs::write_ref(repo.git_dir(), "refs/heads/main", &moved).expect("main");
1028 crate::refs::write_ref(repo.git_dir(), "refs/heads/feature", &on_moved).expect("ref");
1029 let checkout = CheckoutInfo {
1030 id: "0123456789abcdef0123".to_owned(),
1031 name: "demo".to_owned(),
1032 branch: "main".to_owned(),
1033 paths,
1034 base: moved.to_hex(),
1035 root: newer.commit.to_hex(),
1036 root_object: String::new(),
1037 tree: newer.tree.to_hex(),
1038 roots: both,
1039 files: 0,
1040 bytes: 0,
1041 bundle_bytes: 0,
1042 work: None,
1043 };
1044 let log = GraftLog::at(dir.path().join("grafts"));
1045 let shown_to = |name: &str| project_branch(&repo, &checkout, &log, name).expect("project");
1046 assert_eq!(shown_to("a..b"), Err(ProjectError::InvalidName));
1047 assert_eq!(
1048 shown_to("MAIN"),
1049 Err(ProjectError::TargetBranch {
1050 target: "main".to_owned()
1051 })
1052 );
1053 assert_eq!(shown_to("nope"), Err(ProjectError::NoSuchBranch));
1054 let info = shown_to("feature").expect("shown");
1055 assert_eq!(
1056 info,
1057 BranchInfo {
1058 name: "feature".to_owned(),
1059 tip: on_moved.to_hex(),
1060 projected: shown.tip.to_hex(),
1061 commits: 1,
1062 made: 1,
1063 reused: 0,
1064 }
1065 );
1066 assert_eq!(
1067 crate::refs::resolve_ref(repo.git_dir(), &in_ref(&checkout.id, "feature")).ok(),
1068 Some(shown.tip)
1069 );
1070 assert_eq!(log.grafted(&shown.tip).expect("lookup"), Some(on_moved));
1071 assert_eq!(shown_to("feature").map(|i| (i.made, i.reused)), Ok((0, 1)));
1073 let ignore_case = |value: &str| {
1077 let config = repo.git_dir().join("config");
1078 let text = std::fs::read_to_string(&config).expect("config");
1079 let mut file =
1080 crate::config::ConfigFile::parse(&config, &text, crate::config::ConfigScope::Local)
1081 .expect("parse");
1082 file.set("core.ignorecase", value).expect("set");
1083 file.write().expect("write");
1084 repo.reload_config().expect("reload");
1085 };
1086 ignore_case("false");
1087 assert_eq!(shown_to("FEATURE"), Err(ProjectError::NoSuchBranch));
1088 ignore_case("true");
1089 let named = |name: &str| shown_to(name).map(|i| (i.name, i.projected));
1090 let feature = Ok(("feature".to_owned(), shown.tip.to_hex()));
1091 assert_eq!(named("FEATURE"), feature);
1092 grit_test_support::git(dir.path(), &["pack-refs", "--all"]);
1093 assert!(
1094 !repo.git_dir().join("refs/heads/feature").exists(),
1095 "packed"
1096 );
1097 assert_eq!(named("Feature"), feature);
1098 assert_eq!(named("feature"), feature);
1099 assert_eq!(shown_to("nope"), Err(ProjectError::NoSuchBranch));
1100 for (refusal, kind) in [
1101 (ProjectError::InvalidName, "invalid_name"),
1102 (ProjectError::NoSuchBranch, "no_such_branch"),
1103 (ProjectError::OnTarget, "on_target"),
1104 ] {
1105 assert_eq!(refusal.kind(), kind);
1106 assert_eq!(serde_json::to_value(&refusal).expect("json")["kind"], kind);
1107 }
1108 }
1109}