Skip to main content

grit_lib/snapshot_checkout/
project.rs

1//! Projection: a branch of the server's real history as a checkout sees it,
2//! the reverse of a graft.
3//!
4//! Each commit of the branch that the checkout's target branch lacks is
5//! written again with its tree cut down to the checkout's paths
6//! ([`super::filtered_tree`]), on the projection of its parents. A parent on
7//! the target becomes the checkout's newest snapshot under it. Author,
8//! committer and message are kept; a signature or merged tag no longer holds
9//! and is dropped, as in a graft, and a commit without a change id gets the
10//! one derived from the real commit ([`ChangeId::derived_from_commit_id`]),
11//! so a rebased copy of it can be traced back. A commit that only touched
12//! paths the checkout doesn't have becomes an empty commit, so the history
13//! still lines up with the server's.
14//!
15//! Every pair is recorded in the repository's [`GraftLog`], so work on top of
16//! a projected commit grafts onto the real one. A real commit that is the
17//! graft of one of the checkout's own commits (or that was projected before
18//! on the same parents, to the same tree) projects to that commit as it is,
19//! and files outside the paths that such a commit carries stay in the
20//! projections above it while the real commits still have them as files, so
21//! they don't look deleted. Another checkout's commit, on the same snapshot
22//! but with other paths, is only reused when it has the tree this
23//! checkout's projection would make.
24
25use 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/// Why a branch could not be projected for a checkout.
46#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
47#[serde(tag = "kind", rename_all = "snake_case")]
48pub enum ProjectError {
49    /// The name is not a valid branch name.
50    #[error("not a valid branch name")]
51    InvalidName,
52    /// The name is (in any case) the branch the checkout follows, which a
53    /// refresh brings, as snapshots.
54    #[error("the branch the checkout follows ({target})")]
55    TargetBranch {
56        /// The checkout's branch, as the server has it.
57        target: String,
58    },
59    /// The server has no such branch.
60    #[error("no such branch")]
61    NoSuchBranch,
62    /// The target branch already has every commit of the branch.
63    #[error("already on the target branch")]
64    OnTarget,
65    /// The branch builds on a commit of the target from before the
66    /// checkout's first snapshot, which no snapshot stands for.
67    #[error("builds on {commit}, from before the checkout's first snapshot")]
68    BeforeFirstSnapshot {
69        /// That commit (12 hex digits).
70        commit: String,
71    },
72}
73
74impl ProjectError {
75    /// A short, stable name for the kind of refusal (the serialized `kind`).
76    #[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/// One commit of a branch [`project`] showed the checkout.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub struct ProjectedCommit {
91    /// The real commit.
92    pub real: ObjectId,
93    /// What the checkout sees of it.
94    pub projected: ObjectId,
95    /// Whether `projected` was there already: a checkout commit on the same
96    /// snapshots that the real one is the graft of (one of this checkout's
97    /// own, or of a checkout with the same snapshots), or an earlier
98    /// projection.
99    pub reused: bool,
100}
101
102/// A branch as [`project`] showed it to a checkout.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct Projection {
105    /// The projection of the branch's tip: a snapshot commit when the branch
106    /// is at a commit a snapshot stands for.
107    pub tip: ObjectId,
108    /// The branch's commits the target lacks, oldest first (each after its
109    /// parents among them).
110    pub commits: Vec<ProjectedCommit>,
111}
112
113impl Projection {
114    /// The pairs the projection made, as (projected commit, real commit), to
115    /// record in the graft log.
116    #[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    /// How many commits were shown as commits that were there already.
126    #[must_use]
127    pub fn reused(&self) -> usize {
128        self.commits.iter().filter(|c| c.reused).count()
129    }
130}
131
132/// The snapshot commits of `roots` with the real commits they stand for,
133/// oldest first, skipping any that are not object ids.
134fn 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
146/// Show the branch at `tip` to a checkout: the reverse of
147/// [`super::graft`] (see the module docs).
148///
149/// The commits projected are those of `tip` that neither `targets` nor any
150/// snapshot's real commit has; a branch at the real commit a snapshot stands
151/// for projects to that snapshot.
152///
153/// # Parameters
154///
155/// - `repo`: the served repository.
156/// - `roots`: the checkout's snapshot commits and the real commits they
157///   stand for, oldest first.
158/// - `paths`: the checkout's paths (empty: the whole tree).
159/// - `known`: the repository's graft log, whose checkout commits for a real
160///   commit are reused when their parents are the projected parents and
161///   their tree is the one the projection would make, or they are the
162///   checkout's own; `None` to make every commit afresh. Nothing is recorded
163///   in it here.
164/// - `own`: the tips the checkout pushed its branches to (its
165///   [`super::out_ref`]s). A logged commit they reach is the checkout's own
166///   and is shown as it is, with the files outside `paths` it carries.
167/// - `targets`: the tips of the target branch the checkout follows.
168/// - `tip`: the branch's tip.
169///
170/// # Returns
171///
172/// The projection, whose new pairs ([`Projection::made`]) the caller
173/// records; `Ok(Err(..))` with [`ProjectError::OnTarget`] or
174/// [`ProjectError::BeforeFirstSnapshot`].
175///
176/// # Errors
177///
178/// Walk, object and graft log failures.
179pub 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
258/// The newest snapshot of `roots` whose real commit is `commit` or one of its
259/// ancestors.
260fn 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
273/// A commit `known` records for `real` that the checkout can be shown as it
274/// is: one on `parents` whose tree is `tree` (what a fresh projection would
275/// have), or the checkout's own (reached from `own`).
276///
277/// Snapshots of different paths can be the same commit (when one path holds
278/// only another), so another checkout's commit on the same parents can carry
279/// files outside these paths, and is not reused.
280fn 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
309/// Whether one of `tips` is `commit` or has it in its history.
310fn 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
319/// `real_tree` as the checkout sees it: cut down to `paths`, plus the files
320/// outside them that `parent` (a projected commit) carries and `real_tree`
321/// still has as files, as `real_tree` has them.
322fn 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        // A file the real commit turned into a directory is gone; the
338        // directory's files are outside the paths too.
339        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
349/// The files under `tree` (at `prefix`) outside `paths`, without going into
350/// the directories `paths` select.
351fn 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            // A directory above a path is walked: files directly in it are
366            // in the view, but its other directories are not.
367            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
382/// Project the server's branch `name` for `checkout` (see [`project`]),
383/// record the new pairs in `known`, and point [`in_ref`] at the projection
384/// for the checkout to fetch.
385///
386/// The commits hidden are those of the branch the checkout follows
387/// ([`CheckoutInfo::branch`]), which refreshes bring as snapshots.
388///
389/// # Parameters
390///
391/// - `repo`: the served repository.
392/// - `checkout`: the checkout the branch is shown to.
393/// - `known`: the repository's graft log
394///   ([`super::CheckoutStore::graft_log`]).
395/// - `name`: the branch, without `refs/heads/`. Where the repository ignores
396///   case (`core.ignorecase`), a name that differs from one of its branches
397///   only in case is that branch.
398///
399/// # Returns
400///
401/// What the checkout fetches, named as the server spells the branch
402/// ([`BranchInfo::name`]), or `Ok(Err(..))` with why the branch can't be
403/// shown: an invalid name, the branch the checkout follows (in any case), a
404/// branch the server doesn't have, or the refusals of [`project`].
405///
406/// # Errors
407///
408/// Ref, walk, object and graft log failures.
409pub 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
464/// The server's branch `name` and its tip, under the server's spelling.
465///
466/// Where the repository ignores case ([`super::checkin::ignores_case`]), a
467/// name that differs from one of its branches only in case is that branch:
468/// a loose ref would resolve under either spelling, while a packed one would
469/// not, and work checked in under the other spelling would be refused as a
470/// case conflict.
471fn 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    /// `base` (or an empty tree) with each `(path, text)` set, or removed for
514    /// `None`.
515    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    /// A commit of `tree` on `parents`, with `extra` headers after the
524    /// committer.
525    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    /// `parent` with `files` changed, committed as `msg`.
549    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    /// A target with `README`, `src/a`, `src/lib/x` and `docs/d`, and a
576    /// snapshot of `src` of it.
577    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        // A signed real commit changing src and docs, then one changing docs only.
603        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        // The docs-only commit shows as an empty one.
657        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        // Deterministic.
663        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        // The checkout changes src/a and adds docs/new, outside its paths, and
681        // checks in.
682        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        // Another checkout of the same snapshot isn't shown docs/new: the
693        // commit is not its own, so it is cut to the paths afresh.
694        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        // A teammate builds on it with a full clone.
702        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        // Once recorded, the projection is reused too; a commit removing the
729        // outside file drops it.
730        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        // On the server, other/x becomes a directory.
758        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        // Alice is still shown hers: its tree is what her projection makes.
824        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        // 1. Alice changes builtin/ and Documentation/ and checks in.
860        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        // 2. Bob is shown her commit: her Documentation/ change, not builtin/.
882        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        // 3. Bob builds on it and checks in: his commit lands on her real one,
901        // which keeps her builtin/ change.
902        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        // 4. Alice is shown his commit on top of her own one, without t/.
931        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        // Grafting what she is shown answers from the log without a walk.
951        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        // At the commit a snapshot stands for: that snapshot.
979        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        // Every commit already on the target.
987        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        // A branch from before the first snapshot.
993        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        // A parent on the target maps to the newest snapshot under it.
1015        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        // project_branch checks the name, and puts the projection at in_ref.
1027        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        // Shown again, the projection is reused.
1072        assert_eq!(shown_to("feature").map(|i| (i.made, i.reused)), Ok((0, 1)));
1073        // Another spelling is another branch where case matters, and the
1074        // server's branch, under its own name, where it doesn't, whether the
1075        // ref is loose or packed.
1076        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}