Skip to main content

git_sprout/
sprout.rs

1// ABOUTME: Runs an accelerated `git worktree add`: git creates and finishes the worktree,
2// ABOUTME: and in between the tool clones whatever the source checkout can supply.
3
4use std::collections::HashSet;
5use std::ffi::OsString;
6use std::io;
7use std::path::{Path, PathBuf};
8use std::process::ExitCode;
9
10use filetime::FileTime;
11use gix_index::entry::Stat;
12use gix_index::fs::Metadata;
13
14use crate::argv::AddCommand;
15use crate::attributes::{self, LineEndings};
16use crate::clone::{self, BlockCloner};
17use crate::delegate;
18use crate::git::Git;
19use crate::interrupt;
20use crate::plan::{self, as_path, Planned};
21use crate::scratch_index::{self, Record};
22use crate::source;
23use crate::stats::Stats;
24use crate::tree;
25use crate::verify;
26
27/// The tree modes that need naming rather than a magic number.
28const EXECUTABLE_MODE: u32 = 0o100755;
29const SYMLINK_MODE: u32 = 0o120000;
30
31/// The most paths worth naming on one `git diff-files` command line.
32const RACY_PATH_LIMIT: usize = 1000;
33
34/// The configuration that decides how blob bytes become working-tree bytes. Cloning is
35/// only sound when both worktrees resolve all of it the same way.
36const CONVERSION_KEYS: &[&str] = &["core.autocrlf", "core.eol", "core.symlinks"];
37
38/// Creates the worktree, cloning what it can and letting git finish the job.
39pub fn add(command: &AddCommand, stats: &mut Stats) -> ExitCode {
40    let git = Git::new(command.globals.clone());
41
42    // Git infers `--orphan` for itself when the repository has no commit to branch from,
43    // and then rejects the `--no-checkout` step 2 would add. An unborn HEAD is therefore
44    // the same case as an explicit `--orphan` and goes to git untouched.
45    if git
46        .capture(None, ["rev-parse", "--verify", "--quiet", "HEAD"])
47        .is_err()
48    {
49        stats.fall_back("the repository has no commit on HEAD");
50        stats.emit();
51        return delegate::exec_git(&command.git_args());
52    }
53
54    let before = worktrees(&git);
55    let created = match git.passthrough(None, command.worktree_add_args_no_checkout()) {
56        Ok(status) => status,
57        Err(error) => {
58            eprintln!("git-sprout: could not run git: {error}");
59            return ExitCode::from(1);
60        }
61    };
62    if !created.success() {
63        stats.fall_back("git worktree add failed");
64        stats.emit();
65        return exit_code(created.code());
66    }
67
68    let destination = locate(&git, command, &before);
69
70    // From here on the worktree exists but holds no files, so every remaining step is
71    // best effort and step 7 must run whatever happens. A panic in the clone phase would
72    // or an interrupt would otherwise leave a half-populated worktree behind.
73    interrupt::defer();
74    if let Some(destination) = destination.as_deref() {
75        let reporting = std::panic::take_hook();
76        std::panic::set_hook(Box::new(|_| {}));
77        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
78            populate(&git, destination, &before, stats)
79        }));
80        let _ = std::panic::take_hook();
81        std::panic::set_hook(reporting);
82        if outcome.is_err() {
83            stats.fall_back("the clone phase failed");
84        }
85    } else {
86        stats.fall_back("the new worktree could not be located");
87    }
88
89    stats.emit();
90
91    let Some(destination) = destination else {
92        interrupt::honour();
93        return ExitCode::SUCCESS;
94    };
95
96    let code = finish(&git, &destination, command.quiet);
97    interrupt::honour();
98    code
99}
100
101/// Steps 7 and 8: git writes whatever is missing and the real index, then the checkout
102/// hook fires. `git reset --hard` is what `git worktree add` itself does at this point,
103/// down to the reflog entry, the ORIG_HEAD it leaves and the "HEAD is now at" line; it is
104/// the one invocation that does not also fire `post-checkout`, which is fired here once.
105fn finish(git: &Git, destination: &Path, quiet: bool) -> ExitCode {
106    let mut reset: Vec<&str> = vec!["reset"];
107    if quiet {
108        reset.push("-q");
109    }
110    reset.push("--hard");
111    match git.passthrough(Some(destination), reset) {
112        Ok(status) if !status.success() => return exit_code(status.code()),
113        Err(error) => {
114            eprintln!("git-sprout: could not run git: {error}");
115            return ExitCode::from(1);
116        }
117        Ok(_) => {}
118    }
119
120    let Ok(head) = git.capture_line(Some(destination), ["rev-parse", "HEAD"]) else {
121        return ExitCode::SUCCESS;
122    };
123    let null = null_oid(git, destination);
124    match git.passthrough(
125        Some(destination),
126        [
127            "hook",
128            "run",
129            "--ignore-missing",
130            "post-checkout",
131            "--",
132            &null,
133            &head,
134            "1",
135        ],
136    ) {
137        Ok(status) => exit_code(status.code()),
138        Err(_) => ExitCode::SUCCESS,
139    }
140}
141
142/// The all-zero object id at the repository's hash length.
143fn null_oid(git: &Git, destination: &Path) -> String {
144    let length = match git
145        .capture_line(Some(destination), ["rev-parse", "--show-object-format"])
146        .as_deref()
147    {
148        Ok("sha256") => 64,
149        _ => 40,
150    };
151    "0".repeat(length)
152}
153
154fn exit_code(code: Option<i32>) -> ExitCode {
155    ExitCode::from(u8::try_from(code.unwrap_or(1)).unwrap_or(1))
156}
157
158fn worktrees(git: &Git) -> Vec<source::Worktree> {
159    git.capture(None, ["worktree", "list", "--porcelain"])
160        .map(|output| source::parse_list(&output))
161        .unwrap_or_default()
162}
163
164/// Finds the worktree `git worktree add` just created.
165///
166/// The listing tells us directly which path is new, which needs no guessing about how a
167/// relative path resolved. The path the user asked for is the fallback.
168fn locate(git: &Git, command: &AddCommand, before: &[source::Worktree]) -> Option<PathBuf> {
169    let known: HashSet<&Path> = before
170        .iter()
171        .map(|worktree| worktree.path.as_path())
172        .collect();
173    let added = worktrees(git)
174        .into_iter()
175        .find(|worktree| !known.contains(worktree.path.as_path()))
176        .map(|worktree| worktree.path);
177    added.or_else(|| {
178        let requested = working_directory(&command.globals).join(as_path_os(&command.path));
179        requested.join(".git").exists().then_some(requested)
180    })
181}
182
183/// The directory git resolves relative paths against, after applying every `-C`.
184fn working_directory(globals: &[OsString]) -> PathBuf {
185    let mut directory = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
186    let mut arguments = globals.iter();
187    while let Some(argument) = arguments.next() {
188        if argument == "-C" {
189            if let Some(value) = arguments.next() {
190                directory = directory.join(value);
191            }
192        }
193    }
194    directory
195}
196
197fn as_path_os(path: &OsString) -> PathBuf {
198    PathBuf::from(path)
199}
200
201/// Steps 3 to 6: choose a source, plan, clone, and write the scratch index.
202fn populate(git: &Git, destination: &Path, before: &[source::Worktree], stats: &mut Stats) {
203    let cloner = clone::for_this_platform();
204    stats.clone_backend = cloner.backend();
205
206    let Ok(head) = git.capture_line(Some(destination), ["rev-parse", "HEAD"]) else {
207        stats.fall_back("the new worktree has no HEAD");
208        return;
209    };
210    let object_hash = match git
211        .capture_line(Some(destination), ["rev-parse", "--show-object-format"])
212        .as_deref()
213    {
214        Ok("sha1") => gix_hash::Kind::Sha1,
215        Ok("sha256") => gix_hash::Kind::Sha256,
216        _ => {
217            stats.fall_back("unknown object format");
218            return;
219        }
220    };
221
222    let Ok(destination_index) = git.capture_line(
223        Some(destination),
224        ["rev-parse", "--path-format=absolute", "--git-path", "index"],
225    ) else {
226        stats.fall_back("could not locate the new worktree's index");
227        return;
228    };
229    let destination_index = PathBuf::from(destination_index);
230    let version = scratch_index::default_version(
231        std::env::var("GIT_INDEX_VERSION").ok().as_deref(),
232        git.config(destination, "index.version").as_deref(),
233        git.config(destination, "feature.manyFiles").as_deref() == Some("true"),
234    );
235    if version != scratch_index::SUPPORTED_VERSION {
236        stats.fall_back(format!("the repository writes index version {version}"));
237        return;
238    }
239    // Git keeps the shape of the index it reads, the same way it keeps the version.
240    // In a `core.splitIndex` repository a plain `git worktree add` writes a small
241    // index carrying a `link` extension, with the entries in a `sharedindex` file;
242    // a scratch index written whole would make git write the final one whole too,
243    // and the difference is visible in the worktree's admin directory.
244    if splits_the_index(git, destination) {
245        stats.fall_back("the repository splits the index");
246        return;
247    }
248
249    let Some(source) = source::choose(git, before, destination, &head) else {
250        stats.fall_back("no usable source checkout");
251        return;
252    };
253    stats.source = Some(source.clone());
254
255    if let Some(reason) = conversion_mismatch(git, &source, destination) {
256        stats.fall_back(reason);
257        return;
258    }
259
260    let Some(target) = listing(git, destination, &head) else {
261        stats.fall_back("could not read the target tree");
262        return;
263    };
264    let Ok(source_head) = git.capture_line(Some(&source), ["rev-parse", "HEAD"]) else {
265        stats.fall_back("the source checkout has no HEAD");
266        return;
267    };
268    let Some(source_tree) = listing(git, &source, &source_head) else {
269        stats.fall_back("could not read the source tree");
270        return;
271    };
272
273    let Ok(index_path) = git.capture_line(
274        Some(&source),
275        ["rev-parse", "--path-format=absolute", "--git-path", "index"],
276    ) else {
277        stats.fall_back("could not locate the source index");
278        return;
279    };
280    let Ok(source_index) = gix_index::File::at(
281        PathBuf::from(index_path),
282        object_hash,
283        true,
284        gix_index::decode::Options::default(),
285    ) else {
286        stats.fall_back("could not read the source index");
287        return;
288    };
289
290    let (source_attributes, suspect_attributes) =
291        plan::source_attribute_files(&source_index, &source);
292    let dirty_attributes: Vec<Vec<u8>> = changed_paths(git, &source, &suspect_attributes)
293        .into_iter()
294        .collect();
295    let mut poisoned = verify::poisoned_prefixes(
296        &target.attribute_files(),
297        &source_attributes,
298        &dirty_attributes,
299    );
300    let source_paths: Vec<Vec<u8>> = source_index
301        .entries()
302        .iter()
303        .map(|entry| entry.path(&source_index).to_vec())
304        .collect();
305    let (colliding, colliding_prefixes) = plan::colliding_paths(&target, &source_paths);
306    poisoned.extend(colliding_prefixes);
307
308    let mut verified = plan::verify_paths(&target, &source_index, &source, &poisoned, &colliding);
309    let considered = verified.considered;
310    let racy_paths: Vec<Vec<u8>> = verified
311        .racy
312        .iter()
313        .map(|planned| planned.path.clone())
314        .collect();
315    let changed = changed_paths(git, &source, &racy_paths);
316    verified.paths.extend(
317        verified
318            .racy
319            .iter()
320            .filter(|planned| !changed.contains(&planned.path))
321            .cloned(),
322    );
323    verified.paths.sort_by(|a, b| a.path.cmp(&b.path));
324    drop_converted_paths(git, &source, &mut verified.paths);
325
326    let plan = plan::assemble(
327        &target,
328        &source_tree,
329        &source,
330        destination,
331        verified.paths,
332        cloner.clones_directories(),
333    );
334
335    let (records, demotion) = materialise(cloner.as_ref(), &source, destination, &plan);
336    stats.cloned_directories = if demotion.is_none() {
337        plan.directories.len()
338    } else {
339        0
340    };
341    if let Some(reason) = demotion {
342        stats.fall_back(reason);
343    }
344    stats.cloned = records.len();
345    stats.skipped = considered.saturating_sub(records.len());
346    stats.checked_out_by_git = considered.saturating_sub(records.len());
347
348    if records.is_empty() {
349        if stats.fallback_reason.is_none() {
350            stats.fall_back("nothing in the target tree could be cloned");
351        }
352        return;
353    }
354
355    if scratch_index::write(&destination_index, object_hash, &records).is_err() {
356        stats.fall_back("could not write the scratch index");
357    }
358}
359
360fn listing(git: &Git, worktree: &Path, commit: &str) -> Option<tree::Listing> {
361    let output = git
362        .capture(Some(worktree), ["ls-tree", "-r", "-t", "-z", commit])
363        .ok()?;
364    tree::parse(&output)
365}
366
367/// Whether the two worktrees would convert blob bytes differently.
368fn conversion_mismatch(git: &Git, source: &Path, destination: &Path) -> Option<String> {
369    for key in CONVERSION_KEYS {
370        if git.config(source, key) != git.config(destination, key) {
371            return Some(format!("{key} differs between the worktrees"));
372        }
373    }
374    let source_attributes = worktree_attributes(git, source);
375    let destination_attributes = worktree_attributes(git, destination);
376    (source_attributes != destination_attributes)
377        .then(|| "the worktrees have different attributes files".to_string())
378}
379
380/// The contents of a worktree's own `info/attributes`, if it has one.
381fn worktree_attributes(git: &Git, worktree: &Path) -> Option<Vec<u8>> {
382    let path = git
383        .capture_line(
384            Some(worktree),
385            [
386                "rev-parse",
387                "--path-format=absolute",
388                "--git-path",
389                "info/attributes",
390            ],
391        )
392        .ok()?;
393    std::fs::read(path).ok()
394}
395
396/// Removes the paths a checkout would rewrite on its way to the working tree.
397///
398/// Every remaining path is one where the blob's bytes and the working tree's bytes are the
399/// same thing, which is what makes the index stat cache a sufficient answer. The
400/// attributes are read from the source, which is sound because the plan has already
401/// established that both sides carry the same attribute blobs.
402fn drop_converted_paths(git: &Git, source: &Path, paths: &mut Vec<Planned>) {
403    if paths.is_empty() {
404        return;
405    }
406    let endings = LineEndings::from_config(
407        git.config(source, "core.autocrlf").as_deref(),
408        git.config(source, "core.eol").as_deref(),
409    );
410    let mut request = Vec::new();
411    for planned in paths.iter() {
412        request.extend_from_slice(&planned.path);
413        request.push(0);
414    }
415    let mut arguments: Vec<&str> = vec!["check-attr", "-z", "--stdin"];
416    arguments.extend(attributes::CONVERTING_ATTRIBUTES);
417    let Ok(output) = git.capture_with_input(Some(source), arguments, &request) else {
418        paths.clear();
419        return;
420    };
421    let reported = attributes::parse_check_attr(&output);
422    paths.retain(|planned| match reported.get(&planned.path) {
423        Some(values) => !attributes::converts(values, endings),
424        None => false,
425    });
426}
427
428/// Asks git which of these paths really differ from what the index records.
429///
430/// Git re-reads them with the repository's filters applied, which is the only correct way
431/// to compare a working-tree file to a blob; hashing them here would compare the wrong
432/// bytes. Paths git cannot be asked about are reported as changed, so they get dropped.
433fn changed_paths(git: &Git, source: &Path, paths: &[Vec<u8>]) -> HashSet<Vec<u8>> {
434    if paths.is_empty() {
435        return HashSet::new();
436    }
437    let mut arguments: Vec<OsString> = ["diff-files", "-z", "--name-only", "--"]
438        .iter()
439        .map(OsString::from)
440        .collect();
441    // Past a certain number of paths the command line stops being a way to ask the
442    // question, so ask about the whole worktree instead and let the caller pick out the
443    // paths it cares about.
444    if paths.len() <= RACY_PATH_LIMIT {
445        arguments.extend(paths.iter().map(|path| as_path(path).into_os_string()));
446    }
447    let Ok(output) = git.capture(Some(source), arguments) else {
448        return paths.iter().cloned().collect();
449    };
450    output
451        .split(|byte| *byte == 0)
452        .filter(|path| !path.is_empty())
453        .map(<[u8]>::to_vec)
454        .collect()
455}
456
457/// Clones the plan and reports what actually landed.
458///
459/// The first failed clone demotes the run: the rest of the plan is abandoned rather than
460/// retried path by path, because a filesystem that cannot clone one file cannot clone the
461/// next one either. Whatever was cloned before the failure is still correct and is kept.
462fn materialise(
463    cloner: &dyn BlockCloner,
464    source: &Path,
465    destination: &Path,
466    plan: &plan::Plan,
467) -> (Vec<Record>, Option<String>) {
468    let mut demotion = None;
469    let umask = umask();
470
471    for directory in &plan.directories {
472        if interrupt::requested() {
473            demotion = Some("interrupted".to_string());
474            break;
475        }
476        let target = destination.join(as_path(directory));
477        if let Some(parent) = target.parent() {
478            let _ = std::fs::create_dir_all(parent);
479        }
480        if let Err(error) = cloner.clone_directory(&source.join(as_path(directory)), &target) {
481            let _ = std::fs::remove_dir_all(&target);
482            demotion = Some(format!("cloning a directory failed: {error}"));
483            break;
484        }
485    }
486
487    if demotion.is_none() {
488        for planned in &plan.files {
489            if interrupt::requested() {
490                demotion = Some("interrupted".to_string());
491                break;
492            }
493            let target = destination.join(as_path(&planned.path));
494            if let Some(parent) = target.parent() {
495                let _ = std::fs::create_dir_all(parent);
496            }
497            if let Err(error) = clone_one(
498                cloner,
499                &source.join(as_path(&planned.path)),
500                &target,
501                planned,
502            ) {
503                let _ = std::fs::remove_file(&target);
504                demotion = Some(format!("cloning a file failed: {error}"));
505                break;
506            }
507        }
508    }
509
510    if demotion.is_none() {
511        for directory in &plan.directories_created {
512            set_mode(&destination.join(as_path(directory)), directory_mode(umask));
513        }
514    }
515
516    let records = plan
517        .materialised
518        .iter()
519        .filter_map(|planned| {
520            let target = destination.join(as_path(&planned.path));
521            conform_to_checkout(&target, planned, umask).map(|stat| Record {
522                path: planned.path.clone(),
523                mode: planned.mode,
524                oid: planned.oid,
525                stat,
526            })
527        })
528        .collect();
529
530    (records, demotion)
531}
532
533/// Materialises one path. A symlink in the source is recreated as a symlink; there are no
534/// blocks to share, and a platform that stores symlinks as plain files takes the file path.
535fn clone_one(
536    cloner: &dyn BlockCloner,
537    source: &Path,
538    destination: &Path,
539    planned: &Planned,
540) -> io::Result<()> {
541    let is_symlink = std::fs::symlink_metadata(source)?.file_type().is_symlink();
542    if planned.mode == 0o120000 && is_symlink {
543        let target = std::fs::read_link(source)?;
544        return symlink(&target, destination);
545    }
546    cloner.clone_file(source, destination)
547}
548
549#[cfg(unix)]
550fn symlink(target: &Path, link: &Path) -> io::Result<()> {
551    std::os::unix::fs::symlink(target, link)
552}
553
554#[cfg(windows)]
555fn symlink(target: &Path, link: &Path) -> io::Result<()> {
556    std::os::windows::fs::symlink_file(target, link)
557}
558
559#[cfg(not(any(unix, windows)))]
560fn symlink(_target: &Path, _link: &Path) -> io::Result<()> {
561    Err(io::Error::from(io::ErrorKind::Unsupported))
562}
563
564/// Gives the clone the permissions and modification time a checkout would have produced,
565/// and returns the stat data that describes it afterwards.
566///
567/// A block clone copies the source's permission bits along with its blocks, but a checkout
568/// derives them from the tree: `0777` for an executable and `0666` otherwise, each masked
569/// by the process umask. So a source file somebody ran `chmod 600` on, or that a filter
570/// created under a different umask, arrives with permissions git would never have written.
571/// Spec §6.1 puts it plainly — take modes from the tree, never from `stat`.
572///
573/// The modification time is set for a different reason. Git treats an index entry whose
574/// mtime is not older than the index file's own mtime as racily clean and re-reads the
575/// file, which would undo the whole point of cloning. A clone carrying the source's mtime
576/// is comfortably older than the index we are about to write. `clonefile` already copies
577/// timestamps; the reflink primitives do not.
578///
579/// Permissions are set first: changing them moves ctime, and the stat data has to describe
580/// the file as it is finally left.
581fn conform_to_checkout(path: &Path, planned: &Planned, umask: u32) -> Option<Stat> {
582    if planned.mode != SYMLINK_MODE {
583        set_mode(path, checkout_mode(planned.mode, umask));
584    }
585    let wanted = FileTime::from_unix_time(i64::from(planned.mtime.secs), planned.mtime.nsecs);
586    let mut metadata = Metadata::from_path_no_follow(path).ok()?;
587    if Stat::from_fs(&metadata).ok()?.mtime != planned.mtime {
588        filetime::set_symlink_file_times(path, wanted, wanted).ok()?;
589        metadata = Metadata::from_path_no_follow(path).ok()?;
590    }
591    Stat::from_fs(&metadata).ok()
592}
593
594/// Whether this repository writes a split index, from the same sources git consults.
595///
596/// `GIT_TEST_SPLIT_INDEX` is git's own test hook and is honoured for the same reason
597/// `GIT_INDEX_VERSION` is: a repository under test still has to come out identical.
598fn splits_the_index(git: &Git, destination: &Path) -> bool {
599    if std::env::var("GIT_TEST_SPLIT_INDEX").as_deref() == Ok("1") {
600        return true;
601    }
602    git.config(destination, "core.splitIndex").as_deref() == Some("true")
603}
604
605/// The permissions a checkout gives a path with this tree mode.
606fn checkout_mode(mode: u32, umask: u32) -> u32 {
607    let base = if mode == EXECUTABLE_MODE {
608        0o777
609    } else {
610        0o666
611    };
612    base & !umask
613}
614
615/// The permissions a checkout gives a directory it has to create.
616fn directory_mode(umask: u32) -> u32 {
617    0o777 & !umask
618}
619
620#[cfg(unix)]
621fn set_mode(path: &Path, mode: u32) {
622    use std::os::unix::fs::PermissionsExt;
623    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode));
624}
625
626#[cfg(not(unix))]
627fn set_mode(_path: &Path, _mode: u32) {}
628
629/// The process umask, read the only way the C library allows: by setting it and putting it
630/// back. Nothing else in the process creates files while this runs.
631#[cfg(unix)]
632fn umask() -> u32 {
633    // `mode_t` is 16 bits on macOS and 32 on Linux, so the widening is a real
634    // conversion on one target and a no-op on the other. Neither target should
635    // have to spell the type out.
636    #[allow(clippy::useless_conversion)]
637    // SAFETY: single-threaded at this point, so no other file creation can see the gap.
638    unsafe {
639        let previous = libc::umask(0);
640        libc::umask(previous);
641        u32::from(previous)
642    }
643}
644
645#[cfg(not(unix))]
646fn umask() -> u32 {
647    0
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    #[test]
655    fn permissions_come_from_the_tree_and_the_umask() {
656        assert_eq!(checkout_mode(0o100644, 0o022), 0o644);
657        assert_eq!(checkout_mode(0o100755, 0o022), 0o755);
658        assert_eq!(checkout_mode(0o100644, 0o077), 0o600);
659        assert_eq!(checkout_mode(0o100755, 0o077), 0o700);
660        assert_eq!(checkout_mode(0o100644, 0o000), 0o666);
661        assert_eq!(directory_mode(0o022), 0o755);
662    }
663
664    #[test]
665    fn applies_every_dash_c_in_order() {
666        // The first -C is spelled as this platform spells an absolute path, so the
667        // test asserts the ordering rather than the separator.
668        let root = if cfg!(windows) { "C:\\repo" } else { "/repo" };
669        let globals = ["-C", root, "-c", "x=y", "-C", "sub"]
670            .iter()
671            .map(OsString::from)
672            .collect::<Vec<_>>();
673        assert_eq!(working_directory(&globals), PathBuf::from(root).join("sub"));
674    }
675}