| 1 | //! Working directories: turning a folder into a snapshot and a snapshot back into a folder. |
| 2 | |
| 3 | use crate::ignore::Ignore; |
| 4 | use crate::objects::{self, Entry, Id, Kind, Objects}; |
| 5 | use std::collections::BTreeMap; |
| 6 | use std::fs; |
| 7 | use std::io; |
| 8 | use std::os::unix::fs::PermissionsExt; |
| 9 | use std::path::Path; |
| 10 | |
| 11 | #[derive(Debug)] |
| 12 | pub 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 | |
| 19 | impl 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 | |
| 29 | impl From<io::Error> for Error { |
| 30 | fn from(e: io::Error) -> Self { |
| 31 | Error::Io(e) |
| 32 | } |
| 33 | } |
| 34 | impl From<objects::Error> for Error { |
| 35 | fn from(e: objects::Error) -> Self { |
| 36 | Error::Objects(e) |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | pub type Result<T> = std::result::Result<T, Error>; |
| 41 | |
| 42 | pub 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. |
| 47 | pub 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). |
| 53 | fn 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. |
| 90 | pub 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 | |
| 131 | fn 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)] |
| 142 | pub 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. |
| 149 | pub 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)] |
| 173 | mod 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 | } |