Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 5 |
| use std::fs; |
| 6 | use std::io::Read; |
| 7 | use std::path::{Path, PathBuf}; |
| 8 |
| 9 | use anyhow::{Context, Result, anyhow}; |
| 10 | use rayon::prelude::*; |
| 11 | use vcs_core::Id; |
| 12 | use vcs_core::tree::{self, Entry, EntryKind, Files}; |
| 13 |
| 14 | use crate::objects::Store; |
| 15 | use crate::sys; |
| 16 |
| 17 | pub const DIR: &str = ".vcs"; |
| 18 |
| 19 | /// The workspace folder containing `start`, if any. |
| 20 | pub fn find(start: &Path) -> Option<PathBuf> { |
| 21 | let mut dir = Some(start); |
| 22 | while let Some(d) = dir { |
| 23 | if d.join(DIR).join("workspace").is_file() { |
| 24 | return Some(d.to_path_buf()); |
| 25 | } |
| 26 | dir = d.parent(); |
| 27 | } |
| 28 | None |
| 29 | } |
| 30 |
| 31 | pub fn read_id(root: &Path) -> Result<String> { |
| 32 | Ok(fs::read_to_string(root.join(DIR).join("workspace"))?.trim().to_string()) |
| 33 | } |
| 34 |
| 35 | pub fn write_id(root: &Path, id: &str) -> Result<()> { |
| 36 | let dir = root.join(DIR); |
| 37 | fs::create_dir_all(&dir)?; |
| 38 | fs::write(dir.join("workspace"), format!("{id}\n"))?; |
| 39 | // Keep the folder out of anything that walks the tree. |
| 40 | fs::write(dir.join(".gitignore"), "*\n")?; |
| 41 | Ok(()) |
| 42 | } |
| 43 |
| 44 | /// An exclusive lock on the workspace, held while a command or the saver |
| 45 | /// works in it. |
| 46 | pub struct Lock { |
| 47 | _file: fs::File, |
| 48 | } |
| 49 |
| 50 | pub fn lock(root: &Path) -> Result<Lock> { |
| 51 | use fs4::fs_std::FileExt; |
| 52 | let path = root.join(DIR).join("lock"); |
| 53 | let file = fs::OpenOptions::new().create(true).truncate(false).write(true).open(&path)?; |
| 54 | file.lock_exclusive().context("locking the workspace")?; |
| 55 | Ok(Lock { _file: file }) |
| 56 | } |
| 57 |
| 58 | /// Like [`lock`], but gives up at once if someone else holds it. |
| 59 | pub fn try_lock(root: &Path) -> Result<Option<Lock>> { |
| 60 | use fs4::fs_std::FileExt; |
| 61 | let path = root.join(DIR).join("lock"); |
| 62 | let file = fs::OpenOptions::new().create(true).truncate(false).write(true).open(&path)?; |
| 63 | match file.try_lock_exclusive() { |
| 64 | Ok(true) => Ok(Some(Lock { _file: file })), |
| 65 | Ok(false) => Ok(None), |
| 66 | Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None), |
| 67 | Err(e) => Err(e.into()), |
| 68 | } |
| 69 | } |
| 70 |
| 71 | // The stat index: what each tracked file looked like when we last knew its |
| 72 | // blob, so unchanged files are never read again. |
| 73 |
| 74 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 75 | pub struct Stat { |
| 76 | pub size: u64, |
| 77 | pub mtime_ns: i64, |
| 78 | pub ino: u64, |
| 79 | } |
| 80 |
| 81 | impl Stat { |
| 82 | fn of(meta: &fs::Metadata) -> Stat { |
| 83 | let (mtime_ns, ino) = sys::times(meta); |
| 84 | Stat { size: meta.len(), mtime_ns, ino } |
| 85 | } |
| 86 | } |
| 87 |
| 88 | #[derive(Debug, Clone, Copy)] |
| 89 | pub struct IndexEntry { |
| 90 | pub entry: Entry, |
| 91 | pub stat: Stat, |
| 92 | } |
| 93 |
| 94 | #[derive(Debug, Default, Clone)] |
| 95 | pub struct Index { |
| 96 | pub entries: BTreeMap<String, IndexEntry>, |
| 97 | /// When the index was written (ns); files changed just before it are |
| 98 | /// re-read next time (git's "racy" rule, see `racy_window`). |
| 99 | pub written_ns: i64, |
| 100 | /// The tree these entries make, so a save that finds nothing new |
| 101 | /// doesn't build it again. |
| 102 | pub tree: Option<Id>, |
| 103 | } |
| 104 |
| 105 | const MAGIC: &[u8] = b"VCSI3\n"; |
| 106 |
| 107 | impl Index { |
| 108 | pub fn load(root: &Path) -> Index { |
| 109 | Self::try_load(root).unwrap_or_default() |
| 110 | } |
| 111 |
| 112 | fn try_load(root: &Path) -> Result<Index> { |
| 113 | let bytes = fs::read(root.join(DIR).join("index"))?; |
| 114 | if !bytes.starts_with(MAGIC) { |
| 115 | return Err(anyhow!("bad index")); |
| 116 | } |
| 117 | let mut at = MAGIC.len(); |
| 118 | let take = |at: &mut usize, n: usize| -> Result<&[u8]> { |
| 119 | let slice = bytes.get(*at..*at + n).ok_or_else(|| anyhow!("short index"))?; |
| 120 | *at += n; |
| 121 | Ok(slice) |
| 122 | }; |
| 123 | let written_ns = i64::from_le_bytes(take(&mut at, 8)?.try_into()?); |
| 124 | let tree = Id::from_slice(take(&mut at, 32)?).filter(|t| *t != Id::ZERO); |
| 125 | let count = u32::from_le_bytes(take(&mut at, 4)?.try_into()?); |
| 126 | let mut entries = BTreeMap::new(); |
| 127 | for _ in 0..count { |
| 128 | let len = u16::from_le_bytes(take(&mut at, 2)?.try_into()?) as usize; |
| 129 | let path = String::from_utf8(take(&mut at, len)?.to_vec())?; |
| 130 | let id = Id::from_slice(take(&mut at, 32)?).ok_or_else(|| anyhow!("bad index"))?; |
| 131 | let kind = EntryKind::from_u8(take(&mut at, 1)?[0]).ok_or_else(|| anyhow!("bad index"))?; |
| 132 | let size = u64::from_le_bytes(take(&mut at, 8)?.try_into()?); |
| 133 | let mtime_ns = i64::from_le_bytes(take(&mut at, 8)?.try_into()?); |
| 134 | let ino = u64::from_le_bytes(take(&mut at, 8)?.try_into()?); |
| 135 | entries.insert(path, IndexEntry { entry: Entry { kind, id }, stat: Stat { size, mtime_ns, ino } }); |
| 136 | } |
| 137 | Ok(Index { entries, written_ns, tree }) |
| 138 | } |
| 139 |
| 140 | /// Writes the index. It's a cache (a lost one only means reading files |
| 141 | /// again), so it isn't synced to disk. |
| 142 | pub fn save(&mut self, root: &Path) -> Result<()> { |
| 143 | self.written_ns = now_ns(); |
| 144 | let mut out = Vec::with_capacity(self.entries.len() * 80 + 64); |
| 145 | out.extend_from_slice(MAGIC); |
| 146 | out.extend_from_slice(&self.written_ns.to_le_bytes()); |
| 147 | out.extend_from_slice(&self.tree.unwrap_or(Id::ZERO).0); |
| 148 | out.extend_from_slice(&(self.entries.len() as u32).to_le_bytes()); |
| 149 | for (path, e) in &self.entries { |
| 150 | out.extend_from_slice(&(path.len() as u16).to_le_bytes()); |
| 151 | out.extend_from_slice(path.as_bytes()); |
| 152 | out.extend_from_slice(&e.entry.id.0); |
| 153 | out.push(e.entry.kind as u8); |
| 154 | out.extend_from_slice(&e.stat.size.to_le_bytes()); |
| 155 | out.extend_from_slice(&e.stat.mtime_ns.to_le_bytes()); |
| 156 | out.extend_from_slice(&e.stat.ino.to_le_bytes()); |
| 157 | } |
| 158 | let tmp = root.join(DIR).join(format!("index.tmp{}", std::process::id())); |
| 159 | fs::write(&tmp, &out)?; |
| 160 | fs::rename(tmp, root.join(DIR).join("index"))?; |
| 161 | Ok(()) |
| 162 | } |
| 163 |
| 164 | /// Whether writing `next` over this index would change anything: new |
| 165 | /// entries, or entries still inside the racy second. |
| 166 | pub fn same_as(&self, next: &Index) -> bool { |
| 167 | self.tree.is_some() |
| 168 | && self.tree == next.tree |
| 169 | && self.entries.len() == next.entries.len() |
| 170 | && self.entries.iter().zip(next.entries.iter()).all(|((p, a), (q, b))| { |
| 171 | p == q && a.entry == b.entry && a.stat == b.stat && a.stat.mtime_ns < self.written_ns - racy_window(a.stat.mtime_ns) |
| 172 | }) |
| 173 | } |
| 174 |
| 175 | /// Whether `stat` proves the file still has the indexed content. |
| 176 | fn unchanged(&self, entry: &IndexEntry, stat: Stat) -> bool { |
| 177 | entry.stat == stat && stat.mtime_ns < self.written_ns - racy_window(stat.mtime_ns) |
| 178 | } |
| 179 | } |
| 180 |
| 181 | /// How close to the index's writing a change can hide: a file written again |
| 182 | /// within the same timestamp tick looks unchanged. Timestamps here have |
| 183 | /// nanoseconds, and the kernel moves them every few milliseconds; a whole |
| 184 | /// second means a disk with coarse timestamps (FAT, old HFS+). |
| 185 | fn racy_window(mtime_ns: i64) -> i64 { |
| 186 | if mtime_ns % 1_000_000_000 == 0 { 2_000_000_000 } else { 50_000_000 } |
| 187 | } |
| 188 |
| 189 | fn now_ns() -> i64 { |
| 190 | std::time::SystemTime::now() |
| 191 | .duration_since(std::time::UNIX_EPOCH) |
| 192 | .map(|d| d.as_nanos() as i64) |
| 193 | .unwrap_or(0) |
| 194 | } |
| 195 |
| 196 | // Rules for what a workspace keeps out of its trees. |
| 197 |
| 198 | /// What `vcs.toml` says. |
| 199 | #[derive(Debug, Default, Clone, serde::Deserialize)] |
| 200 | pub struct Config { |
| 201 | #[serde(default)] |
| 202 | pub workspace: WorkspaceConfig, |
| 203 | #[serde(default)] |
| 204 | pub mine: MineConfig, |
| 205 | } |
| 206 |
| 207 | #[derive(Debug, Default, Clone, serde::Deserialize)] |
| 208 | pub struct WorkspaceConfig { |
| 209 | #[serde(default)] |
| 210 | pub copy: Vec<String>, |
| 211 | } |
| 212 |
| 213 | #[derive(Debug, Default, Clone, serde::Deserialize)] |
| 214 | pub struct MineConfig { |
| 215 | #[serde(default)] |
| 216 | pub paths: Vec<String>, |
| 217 | } |
| 218 |
| 219 | pub fn config(root: &Path) -> Config { |
| 220 | fs::read_to_string(root.join("vcs.toml")) |
| 221 | .ok() |
| 222 | .and_then(|text| toml::from_str(&text).ok()) |
| 223 | .unwrap_or_default() |
| 224 | } |
| 225 |
| 226 | pub const DEFAULT_MINE: &[&str] = &[".env", ".env.*", "!.env.example", "!.env.sample", "!.env.template"]; |
| 227 |
| 228 | /// Paths that stay the user's own: never saved into a change, never landed. |
| 229 | pub struct Mine { |
| 230 | include: globset::GlobSet, |
| 231 | exclude: globset::GlobSet, |
| 232 | } |
| 233 |
| 234 | impl Mine { |
| 235 | /// The project's rules (vcs.toml, or the defaults) plus the person's own. |
| 236 | pub fn new(config: &Config, personal: &[String]) -> Mine { |
| 237 | let mut include = globset::GlobSetBuilder::new(); |
| 238 | let mut exclude = globset::GlobSetBuilder::new(); |
| 239 | let mut patterns: Vec<String> = if config.mine.paths.is_empty() { |
| 240 | DEFAULT_MINE.iter().map(|s| s.to_string()).collect() |
| 241 | } else { |
| 242 | config.mine.paths.clone() |
| 243 | }; |
| 244 | patterns.extend(personal.iter().cloned()); |
| 245 | for pattern in patterns { |
| 246 | let (set, p) = match pattern.strip_prefix('!') { |
| 247 | Some(p) => (&mut exclude, p.to_string()), |
| 248 | None => (&mut include, pattern), |
| 249 | }; |
| 250 | // A bare name matches at any depth, as in .gitignore. |
| 251 | let p = p.trim_end_matches('/').to_string(); |
| 252 | let globs = if p.contains('/') { vec![p.trim_start_matches('/').to_string()] } else { vec![p.clone(), format!("**/{p}")] }; |
| 253 | for g in globs { |
| 254 | for g in [g.clone(), format!("{g}/**")] { |
| 255 | if let Ok(glob) = globset::GlobBuilder::new(&g).literal_separator(true).build() { |
| 256 | set.add(glob); |
| 257 | } |
| 258 | } |
| 259 | } |
| 260 | } |
| 261 | Mine { |
| 262 | include: include.build().unwrap_or_else(|_| globset::GlobSet::empty()), |
| 263 | exclude: exclude.build().unwrap_or_else(|_| globset::GlobSet::empty()), |
| 264 | } |
| 265 | } |
| 266 |
| 267 | pub fn matches(&self, path: &str) -> bool { |
| 268 | self.include.is_match(path) && !self.exclude.is_match(path) |
| 269 | } |
| 270 | } |
| 271 |
| 272 |
| 273 | /// What saving a workspace found. |
| 274 | pub struct Snapshot { |
| 275 | pub files: Files, |
| 276 | pub tree: Id, |
| 277 | pub index: Index, |
| 278 | /// Mine files in the folder (kept out of the tree): path, file ID, exec. |
| 279 | pub mine: Vec<(String, Id, bool)>, |
| 280 | } |
| 281 |
| 282 | struct Seen { |
| 283 | stat: Stat, |
| 284 | kind: EntryKind, |
| 285 | abs: PathBuf, |
| 286 | } |
| 287 |
| 288 |
| 289 | fn in_scope(only: &[String], path: &str) -> bool { |
| 290 | only.is_empty() || only.iter().any(|o| tree::under(path, o)) |
| 291 | } |
| 292 |
| 293 | /// Saves the folder's content into the store and returns its tree. `base` |
| 294 | /// is the tree the change starts from: files it tracks are kept even when |
| 295 | /// .gitignore matches them, and with `only` set every path outside those |
| 296 | /// folders comes from it unchanged. |
| 297 | /// `main_paths` are the files the change's base tracks: rules for mine |
| 298 | /// files never take a file main already has. |
| 299 | pub fn snapshot( |
| 300 | root: &Path, |
| 301 | store: &Store, |
| 302 | base: &Files, |
| 303 | only: &[String], |
| 304 | index: &Index, |
| 305 | personal: &[String], |
| 306 | main_paths: &std::collections::HashSet<String>, |
| 307 | ) -> Result<Snapshot> { |
| 308 | let config = config(root); |
| 309 | let mine = Mine::new(&config, personal); |
| 310 |
| 311 | // Walk the folder, honouring .gitignore files inside it. |
| 312 | let mut walk = ignore::WalkBuilder::new(root); |
| 313 | walk.hidden(false) |
| 314 | .git_ignore(true) |
| 315 | .git_global(false) |
| 316 | .git_exclude(false) |
| 317 | .ignore(false) |
| 318 | .parents(false) |
| 319 | .require_git(false) |
| 320 | .follow_links(false) |
| 321 | // vcs's and git's own folders, at any depth (a nested git checkout |
| 322 | // too), and names no project can hold. |
| 323 | .filter_entry(|e| e.depth() == 0 || e.file_name().to_str().is_none_or(tree::safe_name)); |
| 324 | let found = std::sync::Mutex::new(Vec::<(String, Seen)>::new()); |
| 325 | walk.build_parallel().run(|| { |
| 326 | let found = &found; |
| 327 | Box::new(move |result| { |
| 328 | let Ok(entry) = result else { return ignore::WalkState::Continue }; |
| 329 | if entry.depth() == 0 { |
| 330 | return ignore::WalkState::Continue; |
| 331 | } |
| 332 | let Some(kind) = entry.file_type() else { return ignore::WalkState::Continue }; |
| 333 | if kind.is_dir() { |
| 334 | return ignore::WalkState::Continue; |
| 335 | } |
| 336 | let Ok(rel) = entry.path().strip_prefix(root) else { return ignore::WalkState::Continue }; |
| 337 | let Some(rel) = sys::tree_path(rel) else { return ignore::WalkState::Continue }; |
| 338 | let Ok(meta) = entry.path().symlink_metadata() else { return ignore::WalkState::Continue }; |
| 339 | let was = index.entries.get(&rel).map(|e| e.entry.kind).or_else(|| base.get(&rel).map(|e| e.kind)); |
| 340 | let seen = Seen { stat: Stat::of(&meta), kind: sys::kind_of(&meta, was), abs: entry.path().to_path_buf() }; |
| 341 | found.lock().unwrap().push((rel, seen)); |
| 342 | ignore::WalkState::Continue |
| 343 | }) |
| 344 | }); |
| 345 | let mut seen: BTreeMap<String, Seen> = found.into_inner().unwrap().into_iter().collect(); |
| 346 |
| 347 | // Tracked files the walk skipped (ignored but tracked) still count. |
| 348 | for path in base.keys().chain(index.entries.keys()) { |
| 349 | if seen.contains_key(path) || !in_scope(only, path) { |
| 350 | continue; |
| 351 | } |
| 352 | let abs = root.join(path); |
| 353 | if let Ok(meta) = abs.symlink_metadata() |
| 354 | && !meta.is_dir() |
| 355 | { |
| 356 | let was = index.entries.get(path).map(|e| e.entry.kind).or_else(|| base.get(path).map(|e| e.kind)); |
| 357 | seen.insert(path.clone(), Seen { stat: Stat::of(&meta), kind: sys::kind_of(&meta, was), abs }); |
| 358 | } |
| 359 | } |
| 360 |
| 361 | // Submodules are pointers, not files here: they carry over as they are, |
| 362 | // and nothing inside their folders is tracked. |
| 363 | let links: Vec<(String, Entry)> = base.iter().filter(|(p, e)| e.kind == EntryKind::Gitlink && in_scope(only, p)).map(|(p, e)| (p.clone(), *e)).collect(); |
| 364 | if !links.is_empty() { |
| 365 | seen.retain(|path, _| !links.iter().any(|(l, _)| tree::under(path, l))); |
| 366 | } |
| 367 |
| 368 | // Mine files stay out of trees unless main already tracks them. |
| 369 | let mut mine_paths = Vec::new(); |
| 370 | seen.retain(|path, s| { |
| 371 | if !main_paths.contains(path) && mine.matches(path) { |
| 372 | mine_paths.push((path.clone(), s.abs.clone(), s.kind == EntryKind::Exec)); |
| 373 | false |
| 374 | } else { |
| 375 | in_scope(only, path) |
| 376 | } |
| 377 | }); |
| 378 |
| 379 | // Read and store what might have changed, in parallel. Objects are |
| 380 | // files named by their IDs, so parallel writes are safe. |
| 381 | let jobs: Vec<(&String, &Seen)> = seen |
| 382 | .iter() |
| 383 | .filter(|(path, s)| match index.entries.get(*path) { |
| 384 | Some(e) => !(index.unchanged(e, s.stat) && e.entry.kind == s.kind), |
| 385 | None => true, |
| 386 | }) |
| 387 | .collect(); |
| 388 | let stored: BTreeMap<String, Id> = jobs |
| 389 | .par_iter() |
| 390 | .map(|(path, s)| -> Result<(String, Id)> { |
| 391 | let bytes = read_content(&s.abs, s.kind)?; |
| 392 | Ok(((*path).clone(), store.put_file(&bytes)?)) |
| 393 | }) |
| 394 | .collect::<Result<BTreeMap<_, _>>>()?; |
| 395 |
| 396 | let mut files = Files::new(); |
| 397 | for (path, entry) in base { |
| 398 | if !in_scope(only, path) { |
| 399 | files.insert(path.clone(), *entry); |
| 400 | } |
| 401 | } |
| 402 | let mut next = Index::default(); |
| 403 | for (path, entry) in &links { |
| 404 | files.insert(path.clone(), *entry); |
| 405 | let stat = index.entries.get(path).map(|e| e.stat).unwrap_or(Stat { size: 0, mtime_ns: 0, ino: 0 }); |
| 406 | next.entries.insert(path.clone(), IndexEntry { entry: *entry, stat }); |
| 407 | } |
| 408 | for (path, s) in &seen { |
| 409 | let id = match stored.get(path) { |
| 410 | Some(id) => *id, |
| 411 | None => index.entries[path].entry.id, |
| 412 | }; |
| 413 | let entry = Entry { kind: s.kind, id }; |
| 414 | files.insert(path.clone(), entry); |
| 415 | next.entries.insert(path.clone(), IndexEntry { entry, stat: s.stat }); |
| 416 | } |
| 417 | // Nothing new since the index's tree: it's still the tree. |
| 418 | let unchanged = stored.is_empty() && only.is_empty() && next.entries.len() == index.entries.len() && next.entries.keys().eq(index.entries.keys()); |
| 419 | let tree = match index.tree { |
| 420 | Some(t) if unchanged && store.has(&t) => t, |
| 421 | _ => { |
| 422 | let (tree, objects) = tree::build(&files); |
| 423 | for o in &objects { |
| 424 | store.put(o)?; |
| 425 | } |
| 426 | tree |
| 427 | } |
| 428 | }; |
| 429 | next.tree = Some(tree); |
| 430 | let mut mine_out = Vec::new(); |
| 431 | for (path, abs, exec) in mine_paths { |
| 432 | let bytes = read_content(&abs, EntryKind::File)?; |
| 433 | mine_out.push((path, store.put_file(&bytes)?, exec)); |
| 434 | } |
| 435 | Ok(Snapshot { files, tree, index: next, mine: mine_out }) |
| 436 | } |
| 437 |
| 438 | fn read_content(path: &Path, kind: EntryKind) -> Result<Vec<u8>> { |
| 439 | if kind == EntryKind::Link { |
| 440 | return Ok(sys::read_link(path)?); |
| 441 | } |
| 442 | let mut bytes = Vec::new(); |
| 443 | fs::File::open(path) |
| 444 | .with_context(|| format!("reading {}", path.display()))? |
| 445 | .read_to_end(&mut bytes)?; |
| 446 | Ok(bytes) |
| 447 | } |
| 448 |
| 449 | /// Another workspace to copy unchanged files from. |
| 450 | pub struct CowSource<'a> { |
| 451 | pub root: &'a Path, |
| 452 | pub index: &'a Index, |
| 453 | } |
| 454 |
| 455 | /// Makes the folder hold exactly `target` (within `only`), changing only |
| 456 | /// what differs from `index`. Untracked and ignored files are left alone. |
| 457 | /// Returns the new index. |
| 458 | pub fn materialize(root: &Path, store: &Store, index: &Index, target: &Files, only: &[String], cow: Option<CowSource>) -> Result<Index> { |
| 459 | let wanted: BTreeMap<&String, &Entry> = target.iter().filter(|(p, _)| in_scope(only, p)).collect(); |
| 460 |
| 461 | // Remove tracked files that the target doesn't have. |
| 462 | let mut removed_dirs = Vec::new(); |
| 463 | for path in index.entries.keys() { |
| 464 | if !wanted.contains_key(path) { |
| 465 | let abs = root.join(path); |
| 466 | if abs.symlink_metadata().is_ok_and(|m| !m.is_dir()) { |
| 467 | fs::remove_file(&abs)?; |
| 468 | } |
| 469 | if let Some(parent) = abs.parent() { |
| 470 | removed_dirs.push(parent.to_path_buf()); |
| 471 | } |
| 472 | } |
| 473 | } |
| 474 | // Empty folders left behind go too (never the root). |
| 475 | removed_dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count())); |
| 476 | removed_dirs.dedup(); |
| 477 | for dir in removed_dirs { |
| 478 | let mut d = dir.as_path(); |
| 479 | while d != root && d.starts_with(root) { |
| 480 | if fs::remove_dir(d).is_err() { |
| 481 | break; |
| 482 | } |
| 483 | match d.parent() { |
| 484 | Some(p) => d = p, |
| 485 | None => break, |
| 486 | } |
| 487 | } |
| 488 | } |
| 489 |
| 490 | // Never vcs's or git's own folders, or outside the workspace, whatever a |
| 491 | // host sends; and nothing this system can't have a file by. |
| 492 | if let Some(bad) = wanted.keys().find(|p| !tree::valid_path(p) || !p.split('/').all(sys::fits)) { |
| 493 | return Err(anyhow!( |
| 494 | "{bad} can't be written here: {}", |
| 495 | if tree::valid_path(bad) { "this system can't have a file by that name" } else { "it's vcs's or git's own folder, or not a safe path" } |
| 496 | )); |
| 497 | } |
| 498 |
| 499 | // Write what's new or different. |
| 500 | let mut keep = Index::default(); |
| 501 | let mut jobs = Vec::new(); |
| 502 | for (path, entry) in &wanted { |
| 503 | if let Some(existing) = index.entries.get(*path) |
| 504 | && existing.entry == **entry |
| 505 | && let Ok(meta) = root.join(path).symlink_metadata() |
| 506 | && Stat::of(&meta) == existing.stat |
| 507 | { |
| 508 | keep.entries.insert((*path).clone(), *existing); |
| 509 | continue; |
| 510 | } |
| 511 | jobs.push(((*path).clone(), **entry)); |
| 512 | } |
| 513 | // Links go last, so what they point at is there first (Windows makes a |
| 514 | // link to a folder differently from one to a file). |
| 515 | let (links, files): (Vec<(String, Entry)>, Vec<(String, Entry)>) = jobs.into_iter().partition(|(_, e)| e.kind == EntryKind::Link); |
| 516 | let write = |(path, entry): &(String, Entry)| -> Result<(String, IndexEntry)> { |
| 517 | let abs = root.join(path); |
| 518 | if let Some(parent) = abs.parent() { |
| 519 | make_dirs(root, parent)?; |
| 520 | } |
| 521 | if entry.kind == EntryKind::Gitlink { |
| 522 | // A submodule's folder, left as it is (checked out or not), or |
| 523 | // made empty, as git leaves one it hasn't checked out. |
| 524 | if abs.symlink_metadata().is_ok_and(|m| !m.is_dir()) { |
| 525 | fs::remove_file(&abs)?; |
| 526 | } |
| 527 | make_dirs(root, &abs)?; |
| 528 | let meta = abs.symlink_metadata()?; |
| 529 | return Ok((path.clone(), IndexEntry { entry: *entry, stat: Stat::of(&meta) })); |
| 530 | } |
| 531 | if let Ok(meta) = abs.symlink_metadata() { |
| 532 | if meta.is_dir() { |
| 533 | fs::remove_dir_all(&abs)?; |
| 534 | } else { |
| 535 | fs::remove_file(&abs)?; |
| 536 | } |
| 537 | } |
| 538 | let copied = match &cow { |
| 539 | Some(src) if entry.kind != EntryKind::Link => copy_from(src, path, entry, &abs), |
| 540 | _ => false, |
| 541 | }; |
| 542 | if !copied { |
| 543 | let content = store.read_file(&entry.id).with_context(|| format!("missing content for {path}"))?; |
| 544 | if entry.kind == EntryKind::Link { |
| 545 | sys::write_link(&String::from_utf8_lossy(&content), &abs)?; |
| 546 | } else { |
| 547 | fs::write(&abs, content)?; |
| 548 | } |
| 549 | } |
| 550 | if entry.kind != EntryKind::Link { |
| 551 | sys::set_exec(&abs, entry.kind == EntryKind::Exec)?; |
| 552 | } |
| 553 | let meta = abs.symlink_metadata()?; |
| 554 | Ok((path.clone(), IndexEntry { entry: *entry, stat: Stat::of(&meta) })) |
| 555 | }; |
| 556 | let mut written: Vec<(String, IndexEntry)> = files.par_iter().map(write).collect::<Result<Vec<_>>>()?; |
| 557 | written.extend(links.par_iter().map(write).collect::<Result<Vec<_>>>()?); |
| 558 | for (path, e) in written { |
| 559 | keep.entries.insert(path, e); |
| 560 | } |
| 561 | Ok(keep) |
| 562 | } |
| 563 |
| 564 | /// Creates `dir` and its parents, replacing any file in the way. |
| 565 | fn make_dirs(root: &Path, dir: &Path) -> Result<()> { |
| 566 | if dir.is_dir() { |
| 567 | return Ok(()); |
| 568 | } |
| 569 | let mut missing = Vec::new(); |
| 570 | let mut d = dir; |
| 571 | while d != root && !d.is_dir() { |
| 572 | missing.push(d.to_path_buf()); |
| 573 | match d.parent() { |
| 574 | Some(p) => d = p, |
| 575 | None => break, |
| 576 | } |
| 577 | } |
| 578 | for m in missing.into_iter().rev() { |
| 579 | if m.symlink_metadata().is_ok_and(|meta| !meta.is_dir()) { |
| 580 | fs::remove_file(&m)?; |
| 581 | } |
| 582 | match fs::create_dir(&m) { |
| 583 | Ok(()) => {} |
| 584 | Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} |
| 585 | Err(e) => return Err(e.into()), |
| 586 | } |
| 587 | } |
| 588 | Ok(()) |
| 589 | } |
| 590 |
| 591 | /// Copies one file from another workspace if it still has the same content. |
| 592 | fn copy_from(src: &CowSource, path: &str, entry: &Entry, dest: &Path) -> bool { |
| 593 | let Some(theirs) = src.index.entries.get(path) else { return false }; |
| 594 | if theirs.entry != *entry { |
| 595 | return false; |
| 596 | } |
| 597 | let from = src.root.join(path); |
| 598 | let Ok(meta) = from.symlink_metadata() else { return false }; |
| 599 | if Stat::of(&meta) != theirs.stat { |
| 600 | return false; |
| 601 | } |
| 602 | reflink_copy::reflink_or_copy(&from, dest).is_ok() |
| 603 | } |
| 604 |
| 605 | /// Copies a build folder (like node_modules) from another workspace, |
| 606 | /// copy-on-write where the disk supports it. Returns how many files. |
| 607 | pub fn copy_folder(from: &Path, to: &Path) -> Result<u64> { |
| 608 | let mut count = 0; |
| 609 | if !from.is_dir() || to.exists() { |
| 610 | return Ok(0); |
| 611 | } |
| 612 | let mut stack = vec![from.to_path_buf()]; |
| 613 | while let Some(dir) = stack.pop() { |
| 614 | let rel = dir.strip_prefix(from)?; |
| 615 | fs::create_dir_all(to.join(rel))?; |
| 616 | for entry in fs::read_dir(&dir)? { |
| 617 | let entry = entry?; |
| 618 | let path = entry.path(); |
| 619 | let dest = to.join(path.strip_prefix(from)?); |
| 620 | let kind = entry.file_type()?; |
| 621 | if kind.is_dir() { |
| 622 | stack.push(path); |
| 623 | } else if kind.is_symlink() { |
| 624 | sys::write_link(&String::from_utf8_lossy(&sys::read_link(&path)?), &dest)?; |
| 625 | count += 1; |
| 626 | } else { |
| 627 | reflink_copy::reflink_or_copy(&path, &dest)?; |
| 628 | count += 1; |
| 629 | } |
| 630 | } |
| 631 | } |
| 632 | Ok(count) |
| 633 | } |
| 634 |
| 635 | /// Whether text still holds conflict markers. |
| 636 | pub fn has_markers(bytes: &[u8]) -> bool { |
| 637 | let text = String::from_utf8_lossy(bytes); |
| 638 | let mut open = false; |
| 639 | let mut middle = false; |
| 640 | for line in text.lines() { |
| 641 | if line.starts_with("<<<<<<< ") || line == "<<<<<<<" { |
| 642 | open = true; |
| 643 | } else if open && line == "=======" { |
| 644 | middle = true; |
| 645 | } else if open && middle && (line.starts_with(">>>>>>> ") || line == ">>>>>>>") { |
| 646 | return true; |
| 647 | } |
| 648 | } |
| 649 | false |
| 650 | } |
| 651 |
| 652 | #[cfg(test)] |
| 653 | mod tests { |
| 654 | use super::*; |
| 655 |
| 656 | #[test] |
| 657 | fn markers() { |
| 658 | assert!(has_markers(b"a\n<<<<<<< main 4\nx\n=======\ny\n>>>>>>> your change bcdf\nb\n")); |
| 659 | assert!(!has_markers(b"a\n=======\nb\n")); |
| 660 | } |
| 661 |
| 662 | #[test] |
| 663 | fn mine_defaults() { |
| 664 | let mine = Mine::new(&Config::default(), &[]); |
| 665 | assert!(mine.matches(".env")); |
| 666 | assert!(mine.matches(".env.production")); |
| 667 | assert!(mine.matches("apps/web/.env.local")); |
| 668 | assert!(!mine.matches(".env.example")); |
| 669 | assert!(!mine.matches("src/env.ts")); |
| 670 | } |
| 671 |
| 672 | #[test] |
| 673 | fn snapshot_and_materialize() { |
| 674 | let objects = tempfile::tempdir().unwrap(); |
| 675 | let store = Store::open(objects.path()).unwrap(); |
| 676 | let ws = tempfile::tempdir().unwrap(); |
| 677 | let root = ws.path(); |
| 678 | write_id(root, "w1").unwrap(); |
| 679 | fs::create_dir_all(root.join("src")).unwrap(); |
| 680 | fs::write(root.join("README.md"), "hi\n").unwrap(); |
| 681 | fs::write(root.join("src/a.rs"), "fn a() {}\n").unwrap(); |
| 682 | fs::write(root.join(".gitignore"), "target/\n").unwrap(); |
| 683 | fs::create_dir_all(root.join("target")).unwrap(); |
| 684 | fs::write(root.join("target/junk"), "x").unwrap(); |
| 685 | fs::write(root.join(".env"), "SECRET=1\n").unwrap(); |
| 686 | let snap = snapshot(root, &store, &Files::new(), &[], &Index::default(), &[], &Default::default()).unwrap(); |
| 687 | assert!(snap.files.contains_key("README.md")); |
| 688 | assert!(snap.files.contains_key("src/a.rs")); |
| 689 | assert!(snap.files.contains_key(".gitignore")); |
| 690 | assert!(!snap.files.contains_key("target/junk")); |
| 691 | assert!(!snap.files.contains_key(".env")); |
| 692 | assert_eq!(snap.mine.len(), 1); |
| 693 |
| 694 | let ws2 = tempfile::tempdir().unwrap(); |
| 695 | write_id(ws2.path(), "w2").unwrap(); |
| 696 | let index2 = |
| 697 | materialize(ws2.path(), &store, &Index::default(), &snap.files, &[], Some(CowSource { root, index: &snap.index })).unwrap(); |
| 698 | assert_eq!(fs::read_to_string(ws2.path().join("src/a.rs")).unwrap(), "fn a() {}\n"); |
| 699 | let mut fewer = snap.files.clone(); |
| 700 | fewer.remove("src/a.rs"); |
| 701 | materialize(ws2.path(), &store, &index2, &fewer, &[], None).unwrap(); |
| 702 | assert!(!ws2.path().join("src").exists()); |
| 703 | } |
| 704 |
| 705 | /// An executable or a link stays one when it's edited, on a disk that |
| 706 | /// can't mark executables or make links (Windows). |
| 707 | #[test] |
| 708 | fn kinds_survive_edits() { |
| 709 | let objects = tempfile::tempdir().unwrap(); |
| 710 | let store = Store::open(objects.path()).unwrap(); |
| 711 | let ws = tempfile::tempdir().unwrap(); |
| 712 | let root = ws.path(); |
| 713 | write_id(root, "w1").unwrap(); |
| 714 | let mut files = Files::new(); |
| 715 | files.insert("run.sh".into(), Entry { kind: EntryKind::Exec, id: store.put_file(b"echo 1\n").unwrap() }); |
| 716 | files.insert("link".into(), Entry { kind: EntryKind::Link, id: store.put_file(b"run.sh").unwrap() }); |
| 717 | let index = materialize(root, &store, &Index::default(), &files, &[], None).unwrap(); |
| 718 | fs::write(root.join("run.sh"), "echo 2\n").unwrap(); |
| 719 | let snap = snapshot(root, &store, &files, &[], &index, &[], &Default::default()).unwrap(); |
| 720 | assert_eq!(snap.files["run.sh"].kind, EntryKind::Exec); |
| 721 | assert_eq!(snap.files["run.sh"].id, store.put_file(b"echo 2\n").unwrap()); |
| 722 | assert_eq!(snap.files["link"], files["link"]); |
| 723 | } |
| 724 | } |
| 725 |