Skip to main content

git_policy_cli/worktree/
worktree_ladder_target.rs

1//! What:
2//!  What both rungs that fill a worktree themselves need to know about the target of a `git worktree add` once real Git
3//!  ran without a checkout: the administrative directory, Git's pointer, the commit and tree `HEAD` names, whether the
4//!  tree differs from the source's, the line Git's checkout would print, and the step that applies the difference.
5//! Why:
6//!  The snapshot and clone rungs differ in how the source's files reach the destination and agree on everything else, so
7//!  the shared reading of Git's result and the single `git read-tree -m -u` step live in one place. The first version keeps
8//!  Git for the two-tree update because it owns the conversion filters (`doc/planning/cli-git-btrfs-worktree.md`).
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const target = prepareTarget(source, plan, context); // throws a reason
13//! await applyDifference(source, target, workTree, runGit);
14//! ```
15
16use 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/// What:
33///  The target of one `git worktree add` as real Git left it.
34/// Why:
35///   The rungs read these facts once and use them in turn.
36///
37/// In TS you'd write (pseudocode):
38/// ```ts
39/// type Target = { admin: string; pointer: Uint8Array; commit: string; differs: boolean; line: string };
40/// ```
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct Target {
43    /// The administrative directory of the new worktree.
44    pub admin: PathBuf,
45    /// The bytes of the `.git` file Git wrote in the destination.
46    pub pointer: Vec<u8>,
47    /// The commit the new `HEAD` names, as hexadecimal.
48    pub commit: String,
49    /// Whether the tree of that commit differs from the source's.
50    pub differs: bool,
51    /// The line Git's checkout prints (empty for `--quiet`).
52    pub line: String,
53}
54
55/// What:
56///  The `.gitattributes` files of a tree as path and object name.
57/// Why:
58///   Unchanged paths keep the source's bytes, so the conversion rules of the two trees must agree for them.
59///
60/// In TS you'd write (pseudocode):
61/// ```ts
62/// function attributeFiles(tree: FlatEntry[]): [Uint8Array, Uint8Array][];
63/// ```
64fn 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
72/// What:
73///  Whether the two trees carry the same `.gitattributes` files.
74/// Why:
75///   A different rule could change how a path both trees share is written, which only Git's checkout gets right.
76///
77/// In TS you'd write (pseudocode):
78/// ```ts
79/// function attributesAgree(database: ObjectDatabase, from: Uint8Array, to: Uint8Array, format: ObjectFormat): boolean;
80/// ```
81fn 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
86/// The configuration keys, besides the `filter.` section, that change how a file is written to or read from a worktree.
87const 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
103/// What:
104///  The settings of a configuration listing that decide how files are converted: `filter.*`, `extensions.*` and the
105///  conversion keys, in the order the listing gives them, duplicates kept.
106/// Why:
107///   For most keys the last value wins, so the order is part of the meaning; two listings with the same values in another
108///  order are different configurations, and a set would call them equal.
109///
110/// In TS you'd write (pseudocode):
111/// ```ts
112/// function conversionSettings(entries: ConfigEntry[]): [string, string | undefined][];
113/// ```
114fn 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
122/// What:
123///  Check that the new worktree sees the same conversion settings as the source, in the same order, read in the new
124///  worktree's own context.
125/// Why:
126///   A conditional include (`onbranch`, `gitdir`, `hasconfig`) can give the new branch other filters than the source's,
127///  and then the source's bytes are not what Git's checkout would write. Git evaluates the conditions for the new
128///  worktree, so comparing its listing with the source's covers every conditional without parsing one; the sequence, not
129///  the set, is compared because the last value of a key wins.
130///
131/// In TS you'd write (pseudocode):
132/// ```ts
133/// function conversionAgrees(source: ConfigEntry[], target: ConfigEntry[]): void; // throws the first difference
134/// ```
135fn 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
145/// The attribute names a line of an attributes file applies as a filter, one per `filter=<name>` token.
146fn 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
154/// The standard definition of the Git LFS filter, which converts a file by its content alone.
155const 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
162/// What:
163///  Refuse a conversion that equal settings do not prove equal: an attribute that selects a filter other than the standard
164///  Git LFS one, an attributes file named by a relative path, or LFS configured beyond its standard definition.
165/// Why:
166///   A filter command may behave by branch, directory or environment, `core.attributesFile` may mean another file in
167///  another worktree, and LFS extensions change the pointer a clean produces; none of that is visible in equal strings.
168///  The standard LFS filter is a function of the file's content and the same program, so it is allowed.
169///
170/// In TS you'd write (pseudocode):
171/// ```ts
172/// function externalConversionRefusal(settings: ConfigEntry[], attributeTexts: string[]): string | undefined;
173/// ```
174fn 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
194/// The text of every attributes file that applies to both worktrees besides the tracked ones: the repository's
195///  `info/attributes`, the file `core.attributesFile` names and the default global file.
196fn 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
210/// What:
211///  Run one Git command through the lifecycle's runner and require status zero.
212/// Why:
213///   Every Git run of the ladder inherits the caller's streams and reports only a status.
214///
215/// In TS you'd write (pseudocode):
216/// ```ts
217/// await runStep(runGit, args, env);
218/// ```
219pub(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
223/// What:
224///  The environment Git gives the commands of its own checkout: its git directory and work tree.
225/// Why:
226///   The ladder's runs name the same two so that they see the new worktree whatever the caller's directory.
227///
228/// In TS you'd write (pseudocode):
229/// ```ts
230/// const env = { GIT_DIR: gitDir, GIT_WORK_TREE: workTree };
231/// ```
232pub(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
236/// What:
237///  Read the result of the `--no-checkout` run: the pointer, the administrative directory, the new `HEAD` and the line.
238///  The error is the reason the rungs cannot serve the command.
239/// Why:
240///   A destination that holds more than Git's pointer, a `HEAD` the readers cannot resolve, a commit whose subject needs
241///  Git to print, or `.gitattributes` files that differ between the two trees leave the checkout to Git.
242///
243/// In TS you'd write (pseudocode):
244/// ```ts
245/// function prepareTarget(source: SnapshotSource, plan: AddPlan, context: GitContext): Target; // throws a reason
246/// ```
247pub 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    // The destination as bound must still be the one directory Git made, holding nothing but Git's pointer.
255    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    // The tracked attributes files are the same in both trees by now, so the source's stand for both.
275    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
292/// What:
293///  Apply the difference between the source's tree and the new `HEAD` to `work_tree`, whose index is the source's:
294///  nothing when the trees are equal, else one `git read-tree -m -u`.
295/// Why:
296///   Git owns the conversion filters, so the paths that differ are written by Git and the unchanged ones keep the bytes
297///  the source had.
298///
299/// In TS you'd write (pseudocode):
300/// ```ts
301/// await applyDifference(source, target, workTree, runGit); // throws a reason
302/// ```
303pub 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/// Ladder target controls stay out of the release executable.
321#[cfg(test)]
322#[path = "worktree_ladder_target_tests.rs"]
323mod tests;