1use crate::config_snapshot::{ConfigEntry, read_config_snapshot};
17use crate::landed_summary::short_name;
18use crate::repo_read_layout::ObjectFormat;
19use crate::repo_read_object::from_hex;
20use crate::repo_read_odb::ObjectDatabase;
21use crate::repo_read_tree::{CommitFields, FlatEntry, flatten_tree, parse_commit, subject_of};
22use crate::transaction_git::GitContext;
23use crate::worktree_add_plan::AddPlan;
24use crate::worktree_clean::head_commit;
25use crate::worktree_copy::{GitRunner, GitStatus, GitStep};
26use crate::fs_beneath::handle_path;
27use crate::worktree_add_binding::{Bound, path_identity};
28use crate::worktree_snapshot_source::SnapshotSource;
29use std::ffi::OsString;
30use std::path::{Path, PathBuf};
31
32#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct Target {
43 pub admin: PathBuf,
45 pub pointer: Vec<u8>,
47 pub commit: String,
49 pub differs: bool,
51 pub line: String,
53}
54
55fn attribute_files(tree: &[FlatEntry]) -> Vec<(&[u8], &[u8])> {
65 return tree
66 .iter()
67 .filter(|entry: &&FlatEntry| { return entry.path == b".gitattributes" || entry.path.ends_with(b"/.gitattributes"); })
68 .map(|entry: &FlatEntry| { return (entry.path.as_slice(), entry.id.as_slice()); })
69 .collect();
70}
71
72fn attributes_agree(database: &ObjectDatabase, from: &[u8], to: &[u8], format: ObjectFormat) -> Result<(), String> {
82 let (Some(old), Some(new)) = (flatten_tree(database, from, format), flatten_tree(database, to, format)) else { return Err(String::from("a tree is unreadable")) };
83 return if attribute_files(old.as_slice()) == attribute_files(new.as_slice()) { Ok(()) } else { Err(String::from("the .gitattributes files differ between the source and the new HEAD")) };
84}
85
86const CONVERSION_KEYS: &[&str] = &[
88 "core.autocrlf",
89 "core.eol",
90 "core.safecrlf",
91 "core.attributesfile",
92 "core.symlinks",
93 "core.filemode",
94 "core.ignorecase",
95 "core.precomposeunicode",
96 "core.protectntfs",
97 "core.protecthfs",
98 "core.checkroundtripencoding",
99 "core.sparsecheckout",
100 "core.sparsecheckoutcone",
101];
102
103fn conversion_settings(entries: &[ConfigEntry]) -> Vec<(String, Option<Vec<u8>>)> {
115 return entries
116 .iter()
117 .filter(|entry: &&ConfigEntry| { return entry.key.starts_with("filter.") || entry.key.starts_with("extensions.") || CONVERSION_KEYS.contains(&entry.key.as_str()); })
118 .map(|entry: &ConfigEntry| { return (entry.key.clone(), entry.value.clone()); })
119 .collect();
120}
121
122fn conversion_agrees(source: &[ConfigEntry], target: &[ConfigEntry]) -> Result<(), String> {
136 let (old, new): (Vec<(String, Option<Vec<u8>>)>, Vec<(String, Option<Vec<u8>>)>) = (conversion_settings(source), conversion_settings(target));
137 if old == new {
138 return Ok(());
139 }
140 let first: usize = (0..old.len().max(new.len())).find(|index: &usize| { return old.get(*index) != new.get(*index); }).unwrap_or(0);
141 let key: String = old.get(first).or_else(|| { return new.get(first); }).map(|(name, _value): &(String, Option<Vec<u8>>)| { return name.clone(); }).unwrap_or_default();
142 return Err(format!("the conversion setting {key} differs between the source and the new worktree, or comes in another order"));
143}
144
145fn filters_named(text: &str) -> Vec<String> {
147 return text
148 .lines()
149 .filter(|line: &&str| { return !line.trim_start().starts_with('#'); })
150 .flat_map(|line: &str| { return line.split_whitespace().filter_map(|token: &str| { return token.strip_prefix("filter=").map(str::to_owned); }); })
151 .collect();
152}
153
154const LFS_FILTER: [(&str, &str); 4] = [
156 ("filter.lfs.clean", "git-lfs clean -- %f"),
157 ("filter.lfs.smudge", "git-lfs smudge -- %f"),
158 ("filter.lfs.process", "git-lfs filter-process"),
159 ("filter.lfs.required", "true"),
160];
161
162fn external_conversion_refusal(entries: &[ConfigEntry], attribute_texts: &[String]) -> Option<String> {
175 if let Some(entry) = entries.iter().find(|entry: &&ConfigEntry| { return entry.key == "core.attributesfile" && !entry.value.as_deref().is_some_and(|value: &[u8]| { return value.starts_with(b"/"); }); }) {
176 return Some(format!("core.attributesFile is {:?}, which is not an absolute path", entry.value.as_deref().map(String::from_utf8_lossy)));
177 }
178 let used: Vec<String> = attribute_texts.iter().flat_map(|text: &String| { return filters_named(text.as_str()); }).collect();
179 for name in &used {
180 if name != "lfs" {
181 return Some(format!("an attribute applies the filter {name}, whose result is not proven equal in both worktrees"));
182 }
183 let value = |key: &str| -> Option<String> { return entries.iter().rev().find(|entry: &&ConfigEntry| { return entry.key == key; }).and_then(|entry: &ConfigEntry| { return entry.value.as_deref().map(|bytes: &[u8]| { return String::from_utf8_lossy(bytes).into_owned(); }); }); };
184 if LFS_FILTER.iter().any(|(key, expected): &(&str, &str)| { return value(key).as_deref() != Some(*expected); }) {
185 return Some(String::from("the lfs filter is not the standard Git LFS definition"));
186 }
187 if entries.iter().any(|entry: &ConfigEntry| { return entry.key.starts_with("lfs.extension."); }) {
188 return Some(String::from("Git LFS extensions are configured"));
189 }
190 }
191 return None;
192}
193
194fn untracked_attribute_texts(source: &SnapshotSource, entries: &[ConfigEntry]) -> Vec<String> {
197 let mut paths: Vec<PathBuf> = vec![source.common_dir.join("info/attributes")];
198 if let Some(value) = entries.iter().rev().find(|entry: &&ConfigEntry| { return entry.key == "core.attributesfile"; }).and_then(|entry: &ConfigEntry| { return entry.value.as_deref(); }) {
199 paths.push(PathBuf::from(String::from_utf8_lossy(value).into_owned()));
200 }
201 if let Some(home) = std::env::var_os("HOME") {
202 paths.push(PathBuf::from(home).join(".config/git/attributes"));
203 }
204 if let Some(config) = std::env::var_os("XDG_CONFIG_HOME") {
205 paths.push(PathBuf::from(config).join("git/attributes"));
206 }
207 return paths.iter().filter_map(|path: &PathBuf| { return std::fs::read(path.as_path()).ok(); }).map(|bytes: Vec<u8>| { return String::from_utf8_lossy(bytes.as_slice()).into_owned(); }).collect();
208}
209
210pub(crate) fn run_step(run_git: &mut GitRunner, arguments: &[OsString], environment: &[(OsString, OsString)]) -> Result<GitStatus, String> {
220 return run_git(&GitStep::Other { arguments, environment }).map_err(|error| { return error.0; });
221}
222
223pub(crate) fn checkout_environment(git_dir: &Path, work_tree: &Path) -> Vec<(OsString, OsString)> {
233 return vec![(OsString::from("GIT_DIR"), git_dir.as_os_str().to_os_string()), (OsString::from("GIT_WORK_TREE"), work_tree.as_os_str().to_os_string())];
234}
235
236pub fn prepare_target(source: &SnapshotSource, plan: &AddPlan, context: &GitContext, bound: &Bound) -> Result<Target, String> {
248 let destination: &Path = plan.destination.as_path();
249 if bound.binding.destination != destination {
250 return Err(String::from("the add is bound to another destination"));
251 }
252 let pointer: Vec<u8> = bound.binding.pointer.clone();
253 let admin: PathBuf = bound.binding.admin.clone();
254 if path_identity(destination) != Some(bound.binding.destination_id) {
256 return Err(String::from("the destination is no longer the directory Git made"));
257 }
258 let held_entries: usize = std::fs::read_dir(handle_path(&bound.destination)).map_err(|error| { return format!("the destination is unreadable ({error})"); })?.count();
259 if held_entries != 1 {
260 return Err(String::from("the destination holds more than Git's .git file"));
261 }
262 let target_config = read_config_snapshot(context, destination).ok_or_else(|| { return String::from("the new worktree's configuration could not be read"); })?;
263 conversion_agrees(source.config.entries(), target_config.entries())?;
264 let format: ObjectFormat = if source.clean.hash_length == 32 { ObjectFormat::Sha256 } else { ObjectFormat::Sha1 };
265 let commit: String = head_commit(admin.as_path(), source.common_dir.as_path(), format).ok_or_else(|| { return String::from("the new HEAD is not a plain ref to a commit"); })?;
266 let database: ObjectDatabase = ObjectDatabase::open(source.common_dir.as_path(), None, format).ok_or_else(|| { return String::from("the object database is not plain"); })?;
267 let name: Vec<u8> = from_hex(commit.as_bytes(), format).ok_or_else(|| { return String::from("the new HEAD is not an object name"); })?;
268 let raw = database.read(name.as_slice()).ok_or_else(|| { return String::from("the new HEAD commit is unreadable"); })?;
269 let fields: CommitFields = parse_commit(raw.bytes.as_slice(), format).ok_or_else(|| { return String::from("the new HEAD commit has a layout the reader does not know"); })?;
270 let differs: bool = fields.tree != source.clean.head_tree;
271 if differs {
272 attributes_agree(&database, source.clean.head_tree.as_slice(), fields.tree.as_slice(), format)?;
273 }
274 let mut attribute_texts: Vec<String> = untracked_attribute_texts(source, target_config.entries());
276 for (path, id) in &source.clean.attribute_blobs {
277 let blob = database.read(id.as_slice()).ok_or_else(|| { return format!("{} is unreadable", String::from_utf8_lossy(path)); })?;
278 attribute_texts.push(String::from_utf8_lossy(blob.bytes.as_slice()).into_owned());
279 }
280 if let Some(reason) = external_conversion_refusal(target_config.entries(), attribute_texts.as_slice()) {
281 return Err(reason);
282 }
283 let line: String = if plan.quiet {
284 String::new()
285 } else {
286 let subject: Vec<u8> = subject_of(fields.message.as_slice()).ok_or_else(|| { return String::from("the subject of the new HEAD needs Git to print"); })?;
287 format!("HEAD is now at {} {}\n", short_name(context, destination, source.common_dir.join("objects").as_path(), Some(&source.config), commit.as_str()), String::from_utf8_lossy(subject.as_slice()))
288 };
289 return Ok(Target { admin, pointer, commit, differs, line });
290}
291
292pub fn apply_difference(source: &SnapshotSource, target: &Target, work_tree: &Path, run_git: &mut GitRunner) -> Result<(), String> {
304 if !target.differs {
305 return Ok(());
306 }
307 let arguments: Vec<OsString> = vec![
308 OsString::from("-C"),
309 work_tree.as_os_str().to_os_string(),
310 OsString::from("read-tree"),
311 OsString::from("-m"),
312 OsString::from("-u"),
313 OsString::from(source.clean.head_commit.as_str()),
314 OsString::from(target.commit.as_str()),
315 ];
316 let status: GitStatus = run_step(run_git, arguments.as_slice(), checkout_environment(target.admin.as_path(), work_tree).as_slice())?;
317 return if status.succeeded() { Ok(()) } else { Err(format!("`git read-tree -m -u` ended with {:?}", status.code)) };
318}
319
320#[cfg(test)]
322#[path = "worktree_ladder_target_tests.rs"]
323mod tests;