1use std::collections::{BTreeMap, HashMap, HashSet};
5use std::path::{Path, PathBuf};
6
7use gix_hash::ObjectId;
8
9use crate::tree::{directory_of, Listing};
10use crate::verify::{self, Verdict};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Planned {
15 pub path: Vec<u8>,
16 pub mode: u32,
18 pub oid: ObjectId,
19 pub mtime: gix_index::entry::stat::Time,
21}
22
23#[derive(Debug, Default)]
25pub struct Verified {
26 pub paths: Vec<Planned>,
28 pub racy: Vec<Planned>,
30 pub considered: usize,
32}
33
34#[derive(Debug, Default)]
36pub struct Plan {
37 pub directories: Vec<Vec<u8>>,
39 pub directories_created: Vec<Vec<u8>>,
43 pub files: Vec<Planned>,
45 pub materialised: Vec<Planned>,
47}
48
49pub fn colliding_paths(
65 target: &Listing,
66 source_paths: &[Vec<u8>],
67) -> (HashSet<Vec<u8>>, Vec<Vec<u8>>) {
68 let mut by_folded: HashMap<Vec<u8>, HashSet<Vec<u8>>> = HashMap::new();
73 let named = target
84 .blobs
85 .keys()
86 .chain(target.trees.keys())
87 .chain(target.gitlinks.iter())
88 .chain(source_paths.iter());
89 for path in named {
90 by_folded
91 .entry(path.to_ascii_lowercase())
92 .or_default()
93 .insert(path.clone());
94 }
95
96 let mut paths = HashSet::new();
97 let mut prefixes = Vec::new();
98 for (_, group) in by_folded.into_iter().filter(|(_, group)| group.len() > 1) {
99 for path in group {
100 if target.trees.contains_key(&path) {
103 let mut prefix = path.clone();
104 prefix.push(b'/');
105 prefixes.push(prefix);
106 }
107 paths.insert(path);
108 }
109 }
110 prefixes.sort();
111 (paths, prefixes)
112}
113
114pub fn verify_paths(
116 target: &Listing,
117 source_index: &gix_index::File,
118 source_root: &Path,
119 poisoned: &[Vec<u8>],
120 excluded: &HashSet<Vec<u8>>,
121) -> Verified {
122 let mut verified = Verified {
123 considered: target.blobs.len(),
124 ..Verified::default()
125 };
126 let timestamp = source_index.timestamp();
127
128 for (path, blob) in &target.blobs {
129 if excluded.contains(path) || verify::is_poisoned(path, poisoned) {
130 continue;
131 }
132 let Some(entry) = source_index.entry_by_path(path.as_slice().into()) else {
133 continue;
134 };
135 if !verify::entry_can_stand_in(blob, entry) {
136 continue;
137 }
138 let Ok(metadata) =
139 gix_index::fs::Metadata::from_path_no_follow(&source_root.join(as_path(path)))
140 else {
141 continue;
142 };
143 let planned = Planned {
144 path: path.clone(),
145 mode: blob.mode,
146 oid: blob.oid,
147 mtime: entry.stat.mtime,
148 };
149 match verify::stat_verdict(entry, &metadata, timestamp) {
150 Verdict::Clone => verified.paths.push(planned),
151 Verdict::AskGit => verified.racy.push(planned),
152 Verdict::Reject => {}
153 }
154 }
155 verified
156}
157
158pub fn assemble(
166 target: &Listing,
167 source: &Listing,
168 source_root: &Path,
169 destination: &Path,
170 verified: Vec<Planned>,
171 clone_directories: bool,
172) -> Plan {
173 let mut plan = Plan {
174 materialised: verified,
175 ..Plan::default()
176 };
177 let verified_paths: HashSet<&[u8]> = plan
178 .materialised
179 .iter()
180 .map(|planned| planned.path.as_slice())
181 .collect();
182
183 if clone_directories {
184 let children = children_by_directory(target);
185 let mut covered: Vec<Vec<u8>> = Vec::new();
186 for (path, oid) in &target.trees {
187 if covered.iter().any(|prefix| path.starts_with(prefix)) {
188 continue;
189 }
190 if source.trees.get(path) != Some(oid) {
191 continue;
192 }
193 if !subtree_is_fully_verified(target, path, &verified_paths) {
194 continue;
195 }
196 if destination.join(as_path(path)).exists() {
197 continue;
198 }
199 if !source_holds_only(source_root, path, &children) {
200 continue;
201 }
202 let mut prefix = path.clone();
203 prefix.push(b'/');
204 plan.directories_created.push(path.clone());
205 plan.directories_created.extend(
206 target
207 .trees
208 .range(prefix.clone()..)
209 .take_while(|(under, _)| under.starts_with(&prefix))
210 .map(|(under, _)| under.clone()),
211 );
212 covered.push(prefix);
213 plan.directories.push(path.clone());
214 }
215 plan.files = plan
216 .materialised
217 .iter()
218 .filter(|planned| {
219 !covered
220 .iter()
221 .any(|prefix| planned.path.starts_with(prefix))
222 })
223 .cloned()
224 .collect();
225 } else {
226 plan.files = plan.materialised.clone();
227 }
228
229 plan
230}
231
232fn subtree_is_fully_verified(
235 target: &Listing,
236 directory: &[u8],
237 verified: &HashSet<&[u8]>,
238) -> bool {
239 let mut prefix = directory.to_vec();
240 prefix.push(b'/');
241 let mut blobs = 0usize;
242 for (path, _) in target.blobs.range(prefix.clone()..) {
243 if !path.starts_with(&prefix) {
244 break;
245 }
246 if !verified.contains(path.as_slice()) {
247 return false;
248 }
249 blobs += 1;
250 }
251 if blobs == 0 {
252 return false;
253 }
254 if let Some(path) = target.gitlinks.range(prefix.clone()..).next() {
255 if path.starts_with(&prefix) {
256 return false;
257 }
258 }
259 true
260}
261
262fn children_by_directory(listing: &Listing) -> HashMap<Vec<u8>, HashSet<Vec<u8>>> {
264 let mut children: HashMap<Vec<u8>, HashSet<Vec<u8>>> = HashMap::new();
265 let paths = listing
266 .blobs
267 .keys()
268 .chain(listing.trees.keys())
269 .chain(listing.gitlinks.iter());
270 for path in paths {
271 let directory = directory_of(path);
272 let name = path[directory.len()..].to_vec();
273 children
274 .entry(directory.strip_suffix(b"/").unwrap_or(directory).to_vec())
275 .or_default()
276 .insert(name);
277 }
278 children
279}
280
281fn source_holds_only(
283 source_root: &Path,
284 directory: &[u8],
285 children: &HashMap<Vec<u8>, HashSet<Vec<u8>>>,
286) -> bool {
287 let Some(expected) = children.get(directory) else {
288 return false;
289 };
290 let Ok(entries) = std::fs::read_dir(source_root.join(as_path(directory))) else {
291 return false;
292 };
293 let mut seen: HashSet<Vec<u8>> = HashSet::new();
294 for entry in entries {
295 let Ok(entry) = entry else { return false };
296 seen.insert(file_name_bytes(&entry.file_name()));
297 }
298 if &seen != expected {
299 return false;
300 }
301 for name in expected {
302 let mut child = directory.to_vec();
303 child.push(b'/');
304 child.extend_from_slice(name);
305 if children.contains_key(&child) && !source_holds_only(source_root, &child, children) {
306 return false;
307 }
308 }
309 true
310}
311
312#[cfg(unix)]
313fn file_name_bytes(name: &std::ffi::OsStr) -> Vec<u8> {
314 use std::os::unix::ffi::OsStrExt;
315 name.as_bytes().to_vec()
316}
317
318#[cfg(not(unix))]
319fn file_name_bytes(name: &std::ffi::OsStr) -> Vec<u8> {
320 name.to_string_lossy().into_owned().into_bytes()
321}
322
323#[cfg(unix)]
325pub fn as_path(path: &[u8]) -> PathBuf {
326 use std::os::unix::ffi::OsStrExt;
327 PathBuf::from(std::ffi::OsStr::from_bytes(path))
328}
329
330#[cfg(not(unix))]
331pub fn as_path(path: &[u8]) -> PathBuf {
332 PathBuf::from(String::from_utf8_lossy(path).into_owned())
333}
334
335pub fn source_attribute_files(
338 source_index: &gix_index::File,
339 source_root: &Path,
340) -> (BTreeMap<Vec<u8>, ObjectId>, Vec<Vec<u8>>) {
341 let mut files = BTreeMap::new();
342 let mut suspect = Vec::new();
343 let timestamp = source_index.timestamp();
344 for entry in source_index.entries() {
345 let path = entry.path(source_index).to_vec();
346 if !crate::tree::is_attributes_file(&path) {
347 continue;
348 }
349 let verdict =
350 gix_index::fs::Metadata::from_path_no_follow(&source_root.join(as_path(&path)))
351 .map(|metadata| verify::stat_verdict(entry, &metadata, timestamp))
352 .unwrap_or(Verdict::Reject);
353 files.insert(path.clone(), entry.id);
354 if verdict != Verdict::Clone {
355 suspect.push(path);
356 }
357 }
358 (files, suspect)
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::tree::Blob;
365
366 fn oid(byte: u8) -> ObjectId {
367 ObjectId::from_hex(format!("{:02x}", byte).repeat(20).as_bytes()).unwrap()
368 }
369
370 fn listing(blobs: &[(&str, u8)], trees: &[(&str, u8)], gitlinks: &[&str]) -> Listing {
371 Listing {
372 blobs: blobs
373 .iter()
374 .map(|(path, byte)| {
375 (
376 path.as_bytes().to_vec(),
377 Blob {
378 mode: 0o100644,
379 oid: oid(*byte),
380 },
381 )
382 })
383 .collect(),
384 trees: trees
385 .iter()
386 .map(|(path, byte)| (path.as_bytes().to_vec(), oid(*byte)))
387 .collect(),
388 gitlinks: gitlinks
389 .iter()
390 .map(|path| path.as_bytes().to_vec())
391 .collect(),
392 }
393 }
394
395 #[test]
400 fn a_pair_only_the_source_has_still_disqualifies_the_target_twin() {
401 let listing = listing(&[("net/xt_mark.h", 0)], &[], &[]);
402 let (bare, _) = colliding_paths(&listing, &[]);
403 assert!(
404 bare.is_empty(),
405 "the target alone names no pair, so nothing collides"
406 );
407
408 let source = vec![b"net/xt_mark.h".to_vec(), b"net/XT_MARK.h".to_vec()];
409 let (widened, _) = colliding_paths(&listing, &source);
410 assert!(
411 widened.contains(b"net/xt_mark.h".as_slice()),
412 "the target's member of a pair the source holds must be dropped from the plan"
413 );
414 }
415
416 #[test]
417 fn maps_every_directory_to_its_own_children() {
418 let listing = listing(
419 &[("src/a.txt", 1), ("src/deep/b.txt", 2), ("top.txt", 3)],
420 &[("src", 4), ("src/deep", 5)],
421 &[],
422 );
423 let children = children_by_directory(&listing);
424 assert_eq!(
425 children[b"".as_slice()],
426 HashSet::from([b"src".to_vec(), b"top.txt".to_vec()])
427 );
428 assert_eq!(
429 children[b"src".as_slice()],
430 HashSet::from([b"a.txt".to_vec(), b"deep".to_vec()])
431 );
432 }
433
434 #[test]
435 fn a_subtree_needs_every_blob_verified() {
436 let listing = listing(&[("src/a.txt", 1), ("src/b.txt", 2)], &[("src", 4)], &[]);
437 let all = HashSet::from([b"src/a.txt".as_slice(), b"src/b.txt".as_slice()]);
438 assert!(subtree_is_fully_verified(&listing, b"src", &all));
439 let partial = HashSet::from([b"src/a.txt".as_slice()]);
440 assert!(!subtree_is_fully_verified(&listing, b"src", &partial));
441 }
442
443 #[test]
444 fn a_subtree_with_a_submodule_is_never_cloned_whole() {
445 let listing = listing(&[("src/a.txt", 1)], &[("src", 4)], &["src/vendor"]);
446 let all = HashSet::from([b"src/a.txt".as_slice()]);
447 assert!(!subtree_is_fully_verified(&listing, b"src", &all));
448 }
449
450 #[test]
451 fn an_empty_subtree_is_not_worth_cloning() {
452 let listing = listing(&[], &[("src", 4)], &[]);
453 assert!(!subtree_is_fully_verified(
454 &listing,
455 b"src",
456 &HashSet::new()
457 ));
458 }
459
460 #[test]
461 fn paths_that_differ_only_by_case_all_go_to_git() {
462 let listing = listing(
463 &[
464 ("net/xt_MARK.c", 1),
465 ("net/xt_mark.c", 2),
466 ("net/other.c", 3),
467 ],
468 &[("net", 4)],
469 &[],
470 );
471 let (paths, prefixes) = colliding_paths(&listing, &[]);
472 assert!(paths.contains(b"net/xt_MARK.c".as_slice()));
473 assert!(paths.contains(b"net/xt_mark.c".as_slice()));
474 assert!(!paths.contains(b"net/other.c".as_slice()));
475 assert!(prefixes.is_empty());
476 }
477
478 #[test]
479 fn directories_that_differ_only_by_case_take_their_subtrees_with_them() {
480 let listing = listing(
481 &[("Net/a.c", 1), ("net/b.c", 2)],
482 &[("Net", 3), ("net", 4)],
483 &[],
484 );
485 let (paths, prefixes) = colliding_paths(&listing, &[]);
486 assert!(paths.contains(b"Net".as_slice()));
487 assert_eq!(prefixes, vec![b"Net/".to_vec(), b"net/".to_vec()]);
488 }
489
490 #[test]
491 fn a_tree_and_a_blob_that_fold_together_both_go_to_git() {
492 let listing = listing(&[("Doc", 1), ("doc/a.c", 2)], &[("doc", 3)], &[]);
493 let (paths, prefixes) = colliding_paths(&listing, &[]);
494 assert!(paths.contains(b"Doc".as_slice()));
495 assert!(paths.contains(b"doc".as_slice()));
496 assert_eq!(prefixes, vec![b"doc/".to_vec()]);
497 }
498
499 #[test]
500 fn an_untracked_file_disqualifies_the_directory_clone() {
501 let scratch = std::env::temp_dir().join("git-sprout-plan-test");
502 let _ = std::fs::remove_dir_all(&scratch);
503 std::fs::create_dir_all(scratch.join("src")).unwrap();
504 std::fs::write(scratch.join("src/a.txt"), "a").unwrap();
505 let listing = listing(&[("src/a.txt", 1)], &[("src", 4)], &[]);
506 let children = children_by_directory(&listing);
507 assert!(source_holds_only(&scratch, b"src", &children));
508
509 std::fs::write(scratch.join("src/untracked.log"), "x").unwrap();
510 assert!(!source_holds_only(&scratch, b"src", &children));
511 let _ = std::fs::remove_dir_all(&scratch);
512 }
513}