grove
at @11
1//! Working directories: turning a folder into a snapshot and a snapshot back into a folder.
2
3use crate::ignore::Ignore;
4use crate::objects::{self, Entry, Id, Kind, Objects};
5use std::collections::BTreeMap;
6use std::fs;
7use std::io;
8use std::os::unix::fs::PermissionsExt;
9use std::path::Path;
10
11#[derive(Debug)]
12pub enum Error {
13 Io(io::Error),
14 Objects(objects::Error),
15 /// A file name grove can't store (not UTF-8, or reserved).
16 BadName(String),
17}
18
19impl std::fmt::Display for Error {
20 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
21 match self {
22 Error::Io(e) => write!(f, "{e}"),
23 Error::Objects(e) => write!(f, "{e}"),
24 Error::BadName(n) => write!(f, "can't store a file named {n}"),
25 }
26 }
27}
28
29impl From<io::Error> for Error {
30 fn from(e: io::Error) -> Self {
31 Error::Io(e)
32 }
33}
34impl From<objects::Error> for Error {
35 fn from(e: objects::Error) -> Self {
36 Error::Objects(e)
37 }
38}
39
40pub type Result<T> = std::result::Result<T, Error>;
41
42pub fn load_ignore(root: &Path) -> Ignore {
43 Ignore::parse(&fs::read_to_string(root.join(".groveignore")).unwrap_or_default())
44}
45
46/// Store everything in `root` that isn't ignored; return the snapshot (root tree) id.
47pub fn snapshot(objects: &Objects, root: &Path) -> Result<Id> {
48 let ignore = load_ignore(root);
49 Ok(snapshot_dir(objects, root, "", &ignore)?.unwrap_or(objects.empty_tree()?))
50}
51
52/// None for a directory with nothing to store (empty directories aren't kept).
53fn snapshot_dir(objects: &Objects, dir: &Path, rel: &str, ignore: &Ignore) -> Result<Option<Id>> {
54 let mut entries = Vec::new();
55 let mut names: Vec<_> = fs::read_dir(dir)?.collect::<io::Result<Vec<_>>>()?;
56 names.sort_by_key(|e| e.file_name());
57 for e in names {
58 let name = e.file_name().into_string().map_err(|n| Error::BadName(format!("{n:?}")))?;
59 if !objects::valid_name(&name) {
60 return Err(Error::BadName(name));
61 }
62 let path = if rel.is_empty() { name.clone() } else { format!("{rel}/{name}") };
63 let meta = fs::symlink_metadata(e.path())?;
64 let ft = meta.file_type();
65 if ignore.ignored(&path, ft.is_dir()) {
66 continue;
67 }
68 if ft.is_dir() {
69 if let Some(id) = snapshot_dir(objects, &e.path(), &path, ignore)? {
70 entries.push(Entry { name, kind: Kind::Dir, id });
71 }
72 } else if ft.is_symlink() {
73 let target = fs::read_link(e.path())?;
74 let target = target.to_str().ok_or_else(|| Error::BadName(format!("link target of {path}")))?;
75 entries.push(Entry { name, kind: Kind::Link, id: objects.put_blob(target.as_bytes())? });
76 } else if ft.is_file() {
77 let kind = if meta.permissions().mode() & 0o111 != 0 { Kind::Exec } else { Kind::File };
78 entries.push(Entry { name, kind, id: objects.put_blob(&fs::read(e.path())?)? });
79 }
80 // Sockets, devices and pipes aren't project content.
81 }
82 if entries.is_empty() {
83 return Ok(None);
84 }
85 Ok(Some(objects.put_tree(&entries)?))
86}
87
88/// Write a snapshot into `root`. Files that were in `previous` but aren't in `tree` are
89/// removed; anything grove never tracked is left alone.
90pub fn materialize(objects: &Objects, tree: &Id, root: &Path, previous: Option<&Id>) -> Result<()> {
91 let want: BTreeMap<String, (Kind, Id)> = objects.flatten(tree)?.into_iter().map(|(p, k, i)| (p, (k, i))).collect();
92 if let Some(prev) = previous {
93 for (path, _, _) in objects.flatten(prev)? {
94 if !want.contains_key(&path) {
95 let full = root.join(&path);
96 match fs::remove_file(&full) {
97 Ok(()) => {}
98 Err(e) if e.kind() == io::ErrorKind::NotFound => {}
99 Err(e) => return Err(e.into()),
100 }
101 remove_empty_parents(root, &full);
102 }
103 }
104 }
105 for (path, (kind, id)) in &want {
106 let full = root.join(path);
107 if let Some(dir) = full.parent() {
108 fs::create_dir_all(dir)?;
109 }
110 let content = objects.get_blob(id)?;
111 // Replace whatever is there (a file, or a link of a different kind).
112 if fs::symlink_metadata(&full).is_ok() {
113 fs::remove_file(&full)?;
114 }
115 match kind {
116 Kind::Link => {
117 let target = String::from_utf8(content).map_err(|_| Error::BadName(path.clone()))?;
118 std::os::unix::fs::symlink(target, &full)?;
119 }
120 Kind::File | Kind::Exec => {
121 fs::write(&full, &content)?;
122 let mode = if *kind == Kind::Exec { 0o755 } else { 0o644 };
123 fs::set_permissions(&full, fs::Permissions::from_mode(mode))?;
124 }
125 Kind::Dir => unreachable!("flatten yields files only"),
126 }
127 }
128 Ok(())
129}
130
131fn remove_empty_parents(root: &Path, file: &Path) {
132 let mut dir = file.parent();
133 while let Some(d) = dir {
134 if d == root || fs::remove_dir(d).is_err() {
135 break;
136 }
137 dir = d.parent();
138 }
139}
140
141#[derive(Debug, PartialEq, Eq, Clone, Copy)]
142pub enum FileChange {
143 Added,
144 Removed,
145 Modified,
146}
147
148/// Paths that differ between two snapshots, sorted. A kind change (file to exec) counts as modified.
149pub fn compare(objects: &Objects, a: &Id, b: &Id) -> Result<Vec<(String, FileChange)>> {
150 if a == b {
151 return Ok(Vec::new());
152 }
153 let a: BTreeMap<_, _> = objects.flatten(a)?.into_iter().map(|(p, k, i)| (p, (k, i))).collect();
154 let b: BTreeMap<_, _> = objects.flatten(b)?.into_iter().map(|(p, k, i)| (p, (k, i))).collect();
155 let mut out = Vec::new();
156 for (path, old) in &a {
157 match b.get(path) {
158 None => out.push((path.clone(), FileChange::Removed)),
159 Some(new) if new != old => out.push((path.clone(), FileChange::Modified)),
160 _ => {}
161 }
162 }
163 for path in b.keys() {
164 if !a.contains_key(path) {
165 out.push((path.clone(), FileChange::Added));
166 }
167 }
168 out.sort_by(|x, y| x.0.cmp(&y.0));
169 Ok(out)
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::testdir::TestDir;
176
177 #[test]
178 fn snapshot_and_materialize_round_trip() {
179 let src = TestDir::new("wd-src");
180 let store = TestDir::new("wd-store");
181 let objects = Objects::open(store.path()).unwrap();
182 let root = src.path();
183 fs::create_dir_all(root.join("src/deep")).unwrap();
184 fs::create_dir_all(root.join("target")).unwrap();
185 fs::create_dir_all(root.join("empty")).unwrap();
186 fs::write(root.join("README"), "hi").unwrap();
187 fs::write(root.join("src/deep/a.rs"), "fn a() {}").unwrap();
188 fs::write(root.join("run.sh"), "#!/bin/sh").unwrap();
189 fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap();
190 std::os::unix::fs::symlink("README", root.join("link")).unwrap();
191 fs::write(root.join("target/out.bin"), "built").unwrap();
192 fs::write(root.join(".groveignore"), "target/\n").unwrap();
193
194 let snap = snapshot(&objects, root).unwrap();
195 let files: Vec<String> = objects.flatten(&snap).unwrap().into_iter().map(|(p, _, _)| p).collect();
196 assert_eq!(files, [".groveignore", "README", "link", "run.sh", "src/deep/a.rs"]);
197
198 let dst = TestDir::new("wd-dst");
199 materialize(&objects, &snap, dst.path(), None).unwrap();
200 assert_eq!(snapshot(&objects, dst.path()).unwrap(), snap, "identical round trip");
201 assert_eq!(fs::read_link(dst.path().join("link")).unwrap().to_str(), Some("README"));
202 assert!(fs::metadata(dst.path().join("run.sh")).unwrap().permissions().mode() & 0o111 != 0);
203 }
204
205 #[test]
206 fn moving_between_snapshots() {
207 let store = TestDir::new("wd-move-store");
208 let objects = Objects::open(store.path()).unwrap();
209 let wd = TestDir::new("wd-move");
210 let root = wd.path();
211 fs::create_dir_all(root.join("a/b")).unwrap();
212 fs::write(root.join("a/b/old.txt"), "old").unwrap();
213 fs::write(root.join("keep.txt"), "1").unwrap();
214 let first = snapshot(&objects, root).unwrap();
215
216 fs::remove_dir_all(root.join("a")).unwrap();
217 fs::write(root.join("keep.txt"), "2").unwrap();
218 fs::write(root.join("new.txt"), "new").unwrap();
219 let second = snapshot(&objects, root).unwrap();
220 assert_eq!(
221 compare(&objects, &first, &second).unwrap(),
222 vec![("a/b/old.txt".into(), FileChange::Removed), ("keep.txt".into(), FileChange::Modified), ("new.txt".into(), FileChange::Added)]
223 );
224
225 // Going back restores the old files and removes the new one, leaving untracked files.
226 fs::write(root.join("untracked.tmp"), "mine").unwrap();
227 materialize(&objects, &first, root, Some(&second)).unwrap();
228 assert!(root.join("a/b/old.txt").exists());
229 assert!(!root.join("new.txt").exists());
230 assert_eq!(fs::read_to_string(root.join("keep.txt")).unwrap(), "1");
231 assert!(root.join("untracked.tmp").exists());
232 }
233}