1use 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
27const EXECUTABLE_MODE: u32 = 0o100755;
29const SYMLINK_MODE: u32 = 0o120000;
30
31const RACY_PATH_LIMIT: usize = 1000;
33
34const CONVERSION_KEYS: &[&str] = &["core.autocrlf", "core.eol", "core.symlinks"];
37
38pub fn add(command: &AddCommand, stats: &mut Stats) -> ExitCode {
40 let git = Git::new(command.globals.clone());
41
42 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 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
101fn 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
142fn 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
164fn 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
183fn 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
201fn 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 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
367fn 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
380fn 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
396fn 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
428fn 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 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
457fn 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
533fn 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
564fn 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
594fn 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
605fn 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
615fn 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#[cfg(unix)]
632fn umask() -> u32 {
633 #[allow(clippy::useless_conversion)]
637 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 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}