Oregami
Repositories/oxedyne/ore

oxedyne/ore/cli/src/tree.rs

9.4 KiB, 29 runs

created by r2848102244:19, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Where a rendered tree lands on a filesystem.
2//!
3//! What a tree *is* -- the render, the layout, and the policy that decides which
4//! of two files claiming one path keeps the name -- is in [`ore_store::tree`],
5//! because the forge shows the same names on a page as this puts on a disk and
6//! one policy implemented twice is a policy that drifts. It is re-exported here
7//! so that `tree::Tree` still means what it always meant in this crate.
8//!
9//! What is left in this module is the half only a tool with a working copy has
10//! any use for: turning a path the history carries into a place on the
11//! filesystem, writing a tree there, and saying what moved.
12
13use crate::capture;
14use crate::repo::IGNORE_FILE;
15
16use oxedyne_fe2o3_core::prelude::*;
17use oxedyne_fe2o3_ore::op::Mode;
18
19// The tree, the layout and the clash policy, which live in the shared store
20// crate and are named here as they always were.
21pub use ore_store::tree::{
22 ancestry,
23 at,
24 derived_name,
25 file_reached,
26 shown,
27 whole,
28 Clash,
29 Layout,
30 Tree,
31 CLASH_MARK,
32};
33
34use std::ffi::OsStr;
35use std::fs;
36use std::path::{
37 Path,
38 PathBuf,
39};
40
41
42/// Turns a path the history carries into a place on the filesystem.
43///
44/// The bytes are written as they stand rather than through a string, so a path
45/// git permits and UTF-8 does not still lands where it says. What is refused is a
46/// path that would leave the working copy: an empty one, an absolute one, or one
47/// climbing through a parent directory.
48pub fn on_disk(root: &Path, path: &[u8])
49 -> Outcome<PathBuf>
50{
51 if path.is_empty() {
52 return Err(err!(
53 "A file carries an empty path, which names nowhere in a working copy.";
54 Invalid, Data, Missing));
55 }
56 if path[0] == b'/' {
57 return Err(err!(
58 "The path {:?} is absolute, and a working copy holds only what is beneath \
59 it.", shown(path);
60 Invalid, Data, Range));
61 }
62 for part in path.split(|b| *b == b'/') {
63 if part == b".." {
64 return Err(err!(
65 "The path {:?} climbs out of the working copy.", shown(path);
66 Invalid, Data, Range));
67 }
68 }
69 Ok(root.join(res!(os_path(path))))
70}
71
72/// Turns path bytes into a filesystem path.
73#[cfg(unix)]
74fn os_path(path: &[u8])
75 -> Outcome<PathBuf>
76{
77 use std::os::unix::ffi::OsStrExt;
78 Ok(PathBuf::from(OsStr::from_bytes(path)))
79}
80
81/// Turns path bytes into a filesystem path, where only UTF-8 can be spelled.
82#[cfg(not(unix))]
83fn os_path(path: &[u8])
84 -> Outcome<PathBuf>
85{
86 match std::str::from_utf8(path) {
87 Ok(s) => Ok(PathBuf::from(s)),
88 Err(e) => Err(err!(e,
89 "The path {:?} is not UTF-8, and this platform's filesystem names are.",
90 shown(path);
91 Invalid, Data, String)),
92 }
93}
94
95/// Returns the bytes of a filesystem name.
96#[cfg(unix)]
97pub fn name_bytes(name: &OsStr)
98 -> Outcome<Vec<u8>>
99{
100 use std::os::unix::ffi::OsStrExt;
101 Ok(name.as_bytes().to_vec())
102}
103
104/// Returns the bytes of a filesystem name, where only UTF-8 can be spelled.
105#[cfg(not(unix))]
106pub fn name_bytes(name: &OsStr)
107 -> Outcome<Vec<u8>>
108{
109 match name.to_str() {
110 Some(s) => Ok(s.as_bytes().to_vec()),
111 None => Err(err!(
112 "The name {:?} is not UTF-8, and this platform's filesystem names are.",
113 name;
114 Invalid, Data, String)),
115 }
116}
117
118
119/// Puts a file on disk into the mode the history says it is.
120///
121/// A symbolic link is refused rather than written as a regular file holding the
122/// link target. The vocabulary records one and a mirror can write one back out,
123/// but this working copy cannot yet hold one: the scan reads regular files only,
124/// so a link written here would look like a deletion to the very next verb. That
125/// is the wrong kind of nothing, and no repository on the replacement path holds
126/// a symbolic link, so the honest answer is to say so and stop.
127#[cfg(unix)]
128fn set_mode(full: &Path, path: &[u8], mode: Mode)
129 -> Outcome<()>
130{
131 use std::os::unix::fs::PermissionsExt;
132 if let Mode::Symlink = mode {
133 return Err(err!(
134 "The history says {:?} is a symbolic link, and a working copy here holds \
135 regular files only. The link is safe in the log; nothing is written for it.",
136 shown(path);
137 Invalid, Data, Unimplemented));
138 }
139 let meta = match fs::metadata(full) {
140 Ok(m) => m,
141 Err(e) => return Err(err!(e,
142 "The file {:?} could not be measured.", full;
143 IO, File, Read)),
144 };
145 let was = meta.permissions().mode();
146 // Executable wherever readable, which is what git's own checkout arrives at
147 // from a file the umask made: 0644 becomes 0755, and 0600 becomes 0700.
148 let want = match mode {
149 Mode::Executable => was | ((was & 0o444) >> 2),
150 _ => was & !0o111,
151 };
152 if want == was {
153 return Ok(());
154 }
155 match fs::set_permissions(full, fs::Permissions::from_mode(want)) {
156 Ok(()) => Ok(()),
157 Err(e) => Err(err!(e,
158 "The mode of {:?} could not be set to {:o}.", full, want;
159 IO, File, Write)),
160 }
161}
162
163/// Puts a file into the mode the history says it is, where the filesystem
164/// carries no such thing.
165#[cfg(not(unix))]
166fn set_mode(_full: &Path, path: &[u8], mode: Mode)
167 -> Outcome<()>
168{
169 if let Mode::Symlink = mode {
170 return Err(err!(
171 "The history says {:?} is a symbolic link, which this platform's working \
172 copy cannot hold.", shown(path);
173 Invalid, Data, Unimplemented));
174 }
175 Ok(())
176}
177
178
179/// What materialising a tree did to the working copy.
180pub struct Moved {
181 /// Paths written that were not there before.
182 pub created: Vec<Vec<u8>>,
183 /// Paths whose bytes changed.
184 pub updated: Vec<Vec<u8>>,
185 /// Paths removed, because the state being materialised has no such file.
186 pub removed: Vec<Vec<u8>>,
187 /// How many paths were already exactly right.
188 pub unchanged: usize,
189 /// The paths two live files claimed, and the names the losers took.
190 pub clashes: Vec<Clash>,
191}
192
193impl Moved {
194 /// Reports whether nothing at all moved.
195 pub fn is_empty(&self) -> bool {
196 self.created.is_empty() && self.updated.is_empty() && self.removed.is_empty()
197 }
198}
199
200/// What becomes of a file the working copy holds and the rendered tree does not.
201pub enum Surplus {
202 Remove, // every verb that captures first, so the file is already in the history
203 Keep, // `import`, which captures nothing, so removing one would lose it
204}
205
206/// Writes a rendered tree into the working copy, and says what moved.
207///
208/// Under [`Surplus::Remove`] a file the tree does not describe is removed, since
209/// the point of the exercise is to see that state and not a mixture of two, and
210/// nothing is lost by it: the capture the verb began with has already recorded
211/// whatever was there. **That reasoning holds only for a caller that captured
212/// first.** `import` does not, so it renders under [`Surplus::Keep`] and takes
213/// nothing off the disk that it never wrote down.
214///
215/// The ignore file is never removed under either. It is not content so much as
216/// the boundary of what the next capture will read, and a history only grows --
217/// so withdrawing it is not a past state being shown but the next verb being
218/// handed the whole of `target/` to record for ever. Rendering a state that
219/// predates the rules leaves them in place and says so.
220///
221/// A file's mode is written whether or not its bytes changed. A script whose
222/// executable bit has been cleared on disk is not the file the history says it
223/// is, even where every byte of it matches, so the bit is asserted on every
224/// path of the tree rather than only on the ones being rewritten.
225pub fn materialise(root: &Path, tree: &Tree, surplus: Surplus)
226 -> Outcome<Moved>
227{
228 let ignore = res!(capture::Ignore::read(root));
229 let layout = res!(tree.layout());
230 let want = res!(tree.contents());
231 let modes = res!(tree.modes());
232 // Tracked paths are read past the ignore rules, so a tracked file a rule
233 // happens to match is compared rather than blindly rewritten.
234 let (disk, _) = res!(capture::scan(root, &ignore, want.keys()));
235 let mut moved = Moved {
236 created: Vec::new(),
237 updated: Vec::new(),
238 removed: Vec::new(),
239 unchanged: 0,
240 clashes: layout.clashes,
241 };
242 for (path, bytes) in &want {
243 let full = res!(on_disk(root, path));
244 let mode = modes.get(path).copied().unwrap_or_default();
245 match disk.get(path) {
246 Some(have) if have == bytes => {
247 moved.unchanged += 1;
248 res!(set_mode(&full, path, mode));
249 continue;
250 },
251 Some(_) => moved.updated.push(path.clone()),
252 None => moved.created.push(path.clone()),
253 }
254 if let Some(parent) = full.parent() {
255 match fs::create_dir_all(parent) {
256 Ok(()) => (),
257 Err(e) => return Err(err!(e,
258 "The directory {:?} could not be created.", parent;
259 IO, File, Write)),
260 }
261 }
262 match fs::write(&full, bytes) {
263 Ok(()) => (),
264 Err(e) => return Err(err!(e,
265 "The file {:?} could not be written.", full;
266 IO, File, Write)),
267 }
268 res!(set_mode(&full, path, mode));
269 }
270 for path in disk.keys() {
271 if want.contains_key(path) {
272 continue;
273 }
274 if let Surplus::Keep = surplus {
275 continue;
276 }
277 // The rules that bound the next capture outlive any state being shown.
278 if path.as_slice() == IGNORE_FILE.as_bytes() {
279 continue;
280 }
281 let full = res!(on_disk(root, path));
282 match fs::remove_file(&full) {
283 Ok(()) => (),
284 Err(e) => return Err(err!(e,
285 "The file {:?} could not be removed.", full;
286 IO, File, Write)),
287 }
288 moved.removed.push(path.clone());
289 // A directory left empty by the removal is not part of the state either,
290 // and leaving it behind would show a tree the history does not describe.
291 let mut at = full.parent().map(|p| p.to_path_buf());
292 while let Some(dir) = at {
293 if dir == root || fs::remove_dir(&dir).is_err() {
294 break;
295 }
296 at = dir.parent().map(|p| p.to_path_buf());
297 }
298 }
299 moved.created.sort();
300 moved.updated.sort();
301 moved.removed.sort();
302 Ok(moved)
303}