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::os::unix::fs::{MetadataExt, PermissionsExt}; |
| 8 | use std::path::{Path, PathBuf}; |
| 9 |
| 10 | use anyhow::{Context, Result, anyhow}; |
| 11 | use rayon::prelude::*; |
| 12 | use vcs_core::Id; |
| 13 | use vcs_core::tree::{self, Entry, EntryKind, Files}; |
| 14 |
| 15 | use crate::objects::Store; |
| 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 | Stat { |
| 84 | size: meta.len(), |
| 85 | mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(), |
| 86 | ino: meta.ino(), |
| 87 | } |
| 88 | } |
| 89 | } |
| 90 |
| 91 | #[derive(Debug, Clone, Copy)] |
| 92 | pub struct IndexEntry { |
| 93 | pub entry: Entry, |
| 94 | pub stat: Stat, |
| 95 | } |
| 96 |
| 97 | #[derive(Debug, Default, Clone)] |
| 98 | pub struct Index { |
| 99 | pub entries: BTreeMap<String, IndexEntry>, |
| 100 | /// When the index was written (ns); files changed just before it are |
| 101 | /// re-read next time (git's "racy" rule, see `racy_window`). |
| 102 | pub written_ns: i64, |
| 103 | /// The tree these entries make, so a save that finds nothing new |
| 104 | /// doesn't build it again. |
| 105 | pub tree: Option<Id>, |
| 106 | } |
| 107 |
| 108 | const MAGIC: &[u8] = b"VCSI3\n"; |
| 109 |
| 110 | impl Index { |
| 111 | pub fn load(root: &Path) -> Index { |
| 112 | Self::try_load(root).unwrap_or_default() |
| 113 | } |
| 114 |
| 115 | fn try_load(root: &Path) -> Result<Index> { |
| 116 | let bytes = fs::read(root.join(DIR).join("index"))?; |
| 117 | if !bytes.starts_with(MAGIC) { |
| 118 | return Err(anyhow!("bad index")); |
| 119 | } |
| 120 | let mut at = MAGIC.len(); |
| 121 | let take = |at: &mut usize, n: usize| -> Result<&[u8]> { |
| 122 | let slice = bytes.get(*at..*at + n).ok_or_else(|| anyhow!("short index"))?; |
| 123 | *at += n; |
| 124 | Ok(slice) |
| 125 | }; |
| 126 | let written_ns = i64::from_le_bytes(take(&mut at, 8)?.try_into()?); |
| 127 | let tree = Id::from_slice(take(&mut at, 32)?).filter(|t| *t != Id::ZERO); |
| 128 | let count = u32::from_le_bytes(take(&mut at, 4)?.try_into()?); |
| 129 | let mut entries = BTreeMap::new(); |
| 130 | for _ in 0..count { |
| 131 | let len = u16::from_le_bytes(take(&mut at, 2)?.try_into()?) as usize; |
| 132 | let path = String::from_utf8(take(&mut at, len)?.to_vec())?; |
| 133 | let id = Id::from_slice(take(&mut at, 32)?).ok_or_else(|| anyhow!("bad index"))?; |
| 134 | let kind = EntryKind::from_u8(take(&mut at, 1)?[0]).ok_or_else(|| anyhow!("bad index"))?; |
| 135 | let size = u64::from_le_bytes(take(&mut at, 8)?.try_into()?); |
| 136 | let mtime_ns = i64::from_le_bytes(take(&mut at, 8)?.try_into()?); |
| 137 | let ino = u64::from_le_bytes(take(&mut at, 8)?.try_into()?); |
| 138 | entries.insert(path, IndexEntry { entry: Entry { kind, id }, stat: Stat { size, mtime_ns, ino } }); |
| 139 | } |
| 140 | Ok(Index { entries, written_ns, tree }) |
| 141 | } |
| 142 |
| 143 | /// Writes the index. It's a cache (a lost one only means reading files |
| 144 | /// again), so it isn't synced to disk. |
| 145 | pub fn save(&mut self, root: &Path) -> Result<()> { |
| 146 | self.written_ns = now_ns(); |
| 147 | let mut out = Vec::with_capacity(self.entries.len() * 80 + 64); |
| 148 | out.extend_from_slice(MAGIC); |
| 149 | out.extend_from_slice(&self.written_ns.to_le_bytes()); |
| 150 | out.extend_from_slice(&self.tree.unwrap_or(Id::ZERO).0); |
| 151 | out.extend_from_slice(&(self.entries.len() as u32).to_le_bytes()); |
| 152 | for (path, e) in &self.entries { |
| 153 | out.extend_from_slice(&(path.len() as u16).to_le_bytes()); |
| 154 | out.extend_from_slice(path.as_bytes()); |
| 155 | out.extend_from_slice(&e.entry.id.0); |
| 156 | out.push(e.entry.kind as u8); |
| 157 | out.extend_from_slice(&e.stat.size.to_le_bytes()); |
| 158 | out.extend_from_slice(&e.stat.mtime_ns.to_le_bytes()); |
| 159 | out.extend_from_slice(&e.stat.ino.to_le_bytes()); |
| 160 | } |
| 161 | let tmp = root.join(DIR).join(format!("index.tmp{}", std::process::id())); |
| 162 | fs::write(&tmp, &out)?; |
| 163 | fs::rename(tmp, root.join(DIR).join("index"))?; |
| 164 | Ok(()) |
| 165 | } |
| 166 |
| 167 | /// Whether writing `next` over this index would change anything: new |
| 168 | /// entries, or entries still inside the racy second. |
| 169 | pub fn same_as(&self, next: &Index) -> bool { |
| 170 | self.tree.is_some() |
| 171 | && self.tree == next.tree |
| 172 | && self.entries.len() == next.entries.len() |
| 173 | && self.entries.iter().zip(next.entries.iter()).all(|((p, a), (q, b))| { |
| 174 | p == q && a.entry == b.entry && a.stat == b.stat && a.stat.mtime_ns < self.written_ns - racy_window(a.stat.mtime_ns) |
| 175 | }) |
| 176 | } |
| 177 |
| 178 | /// Whether `stat` proves the file still has the indexed content. |
| 179 | fn unchanged(&self, entry: &IndexEntry, stat: Stat) -> bool { |
| 180 | entry.stat == stat && stat.mtime_ns < self.written_ns - racy_window(stat.mtime_ns) |
| 181 | } |
| 182 | } |
| 183 |
| 184 | /// How close to the index's writing a change can hide: a file written again |
| 185 | /// within the same timestamp tick looks unchanged. Timestamps here have |
| 186 | /// nanoseconds, and the kernel moves them every few milliseconds; a whole |
| 187 | /// second means a disk with coarse timestamps (FAT, old HFS+). |
| 188 | fn racy_window(mtime_ns: i64) -> i64 { |
| 189 | if mtime_ns % 1_000_000_000 == 0 { 2_000_000_000 } else { 50_000_000 } |
| 190 | } |
| 191 |
| 192 | fn now_ns() -> i64 { |
| 193 | std::time::SystemTime::now() |
| 194 | .duration_since(std::time::UNIX_EPOCH) |
| 195 | .map(|d| d.as_nanos() as i64) |
| 196 | .unwrap_or(0) |
| 197 | } |
| 198 |
| 199 | // Rules for what a workspace keeps out of its trees. |
| 200 |
| 201 | /// What `vcs.toml` says. |
| 202 | #[derive(Debug, Default, Clone, serde::Deserialize)] |
| 203 | pub struct Config { |
| 204 | #[serde(default)] |
| 205 | pub workspace: WorkspaceConfig, |
| 206 | #[serde(default)] |
| 207 | pub mine: MineConfig, |
| 208 | } |
| 209 |
| 210 | #[derive(Debug, Default, Clone, serde::Deserialize)] |
| 211 | pub struct WorkspaceConfig { |
| 212 | #[serde(default)] |
| 213 | pub copy: Vec<String>, |
| 214 | } |
| 215 |
| 216 | #[derive(Debug, Default, Clone, serde::Deserialize)] |
| 217 | pub struct MineConfig { |
| 218 | #[serde(default)] |
| 219 | pub paths: Vec<String>, |
| 220 | } |
| 221 |
| 222 | pub fn config(root: &Path) -> Config { |
| 223 | fs::read_to_string(root.join("vcs.toml")) |
| 224 | .ok() |
| 225 | .and_then(|text| toml::from_str(&text).ok()) |
| 226 | .unwrap_or_default() |
| 227 | } |
| 228 |
| 229 | pub const DEFAULT_MINE: &[&str] = &[".env", ".env.*", "!.env.example", "!.env.sample", "!.env.template"]; |
| 230 |
| 231 | /// Paths that stay the user's own: never saved into a change, never landed. |
| 232 | pub struct Mine { |
| 233 | include: globset::GlobSet, |
| 234 | exclude: globset::GlobSet, |
| 235 | } |
| 236 |
| 237 | impl Mine { |
| 238 | /// The project's rules (vcs.toml, or the defaults) plus the person's own. |
| 239 | pub fn new(config: &Config, personal: &[String]) -> Mine { |
| 240 | let mut include = globset::GlobSetBuilder::new(); |
| 241 | let mut exclude = globset::GlobSetBuilder::new(); |
| 242 | let mut patterns: Vec<String> = if config.mine.paths.is_empty() { |
| 243 | DEFAULT_MINE.iter().map(|s| s.to_string()).collect() |
| 244 | } else { |
| 245 | config.mine.paths.clone() |
| 246 | }; |
| 247 | patterns.extend(personal.iter().cloned()); |
| 248 | for pattern in patterns { |
| 249 | let (set, p) = match pattern.strip_prefix('!') { |
| 250 | Some(p) => (&mut exclude, p.to_string()), |
| 251 | None => (&mut include, pattern), |
| 252 | }; |
| 253 | // A bare name matches at any depth, as in .gitignore. |
| 254 | let p = p.trim_end_matches('/').to_string(); |
| 255 | let globs = if p.contains('/') { vec![p.trim_start_matches('/').to_string()] } else { vec![p.clone(), format!("**/{p}")] }; |
| 256 | for g in globs { |
| 257 | for g in [g.clone(), format!("{g}/**")] { |
| 258 | if let Ok(glob) = globset::GlobBuilder::new(&g).literal_separator(true).build() { |
| 259 | set.add(glob); |
| 260 | } |
| 261 | } |
| 262 | } |
| 263 | } |
| 264 | Mine { |
| 265 | include: include.build().unwrap_or_else(|_| globset::GlobSet::empty()), |
| 266 | exclude: exclude.build().unwrap_or_else(|_| globset::GlobSet::empty()), |
| 267 | } |
| 268 | } |
| 269 |
| 270 | pub fn matches(&self, path: &str) -> bool { |
| 271 | self.include.is_match(path) && !self.exclude.is_match(path) |
| 272 | } |
| 273 | } |
| 274 |
| 275 |
| 276 | /// What saving a workspace found. |
| 277 | pub struct Snapshot { |
| 278 | pub files: Files, |
| 279 | pub tree: Id, |
| 280 | pub index: Index, |
| 281 | /// Mine files in the folder (kept out of the tree): path, file ID, exec. |
| 282 | pub mine: Vec<(String, Id, bool)>, |
| 283 | } |
| 284 |
| 285 | struct Seen { |
| 286 | stat: Stat, |
| 287 | kind: EntryKind, |
| 288 | abs: PathBuf, |
| 289 | } |
| 290 |
| 291 | fn kind_of(meta: &fs::Metadata) -> EntryKind { |
| 292 | if meta.file_type().is_symlink() { |
| 293 | EntryKind::Link |
| 294 | } else if meta.permissions().mode() & 0o111 != 0 { |
| 295 | EntryKind::Exec |
| 296 | } else { |
| 297 | EntryKind::File |
| 298 | } |
| 299 | } |
| 300 |
| 301 | fn in_scope(only: &[String], path: &str) -> bool { |
| 302 | only.is_empty() || only.iter().any(|o| tree::under(path, o)) |
| 303 | } |
| 304 |
| 305 | /// Saves the folder's content into the store and returns its tree. `base` |
| 306 | /// is the tree the change starts from: files it tracks are kept even when |
| 307 | /// .gitignore matches them, and with `only` set every path outside those |
| 308 | /// folders comes from it unchanged. |
| 309 | /// `main_paths` are the files the change's base tracks: rules for mine |
| 310 | /// files never take a file main already has. |
| 311 | pub fn snapshot( |
| 312 | root: &Path, |
| 313 | store: &Store, |
| 314 | base: &Files, |
| 315 | only: &[String], |
| 316 | index: &Index, |
| 317 | personal: &[String], |
| 318 | main_paths: &std::collections::HashSet<String>, |
| 319 | ) -> Result<Snapshot> { |
| 320 | let config = config(root); |
| 321 | let mine = Mine::new(&config, personal); |
| 322 |
| 323 | // Walk the folder, honouring .gitignore files inside it. |
| 324 | let mut walk = ignore::WalkBuilder::new(root); |
| 325 | walk.hidden(false) |
| 326 | .git_ignore(true) |
| 327 | .git_global(false) |
| 328 | .git_exclude(false) |
| 329 | .ignore(false) |
| 330 | .parents(false) |
| 331 | .require_git(false) |
| 332 | .follow_links(false) |
| 333 | .filter_entry(|e| { |
| 334 | let name = e.file_name(); |
| 335 | !(e.depth() == 1 && (name == DIR || name == ".git")) |
| 336 | }); |
| 337 | let found = std::sync::Mutex::new(Vec::<(String, Seen)>::new()); |
| 338 | walk.build_parallel().run(|| { |
| 339 | let found = &found; |
| 340 | Box::new(move |result| { |
| 341 | let Ok(entry) = result else { return ignore::WalkState::Continue }; |
| 342 | if entry.depth() == 0 { |
| 343 | return ignore::WalkState::Continue; |
| 344 | } |
| 345 | let Some(kind) = entry.file_type() else { return ignore::WalkState::Continue }; |
| 346 | if kind.is_dir() { |
| 347 | return ignore::WalkState::Continue; |
| 348 | } |
| 349 | let Ok(rel) = entry.path().strip_prefix(root) else { return ignore::WalkState::Continue }; |
| 350 | let Some(rel) = rel.to_str() else { return ignore::WalkState::Continue }; |
| 351 | let Ok(meta) = entry.path().symlink_metadata() else { return ignore::WalkState::Continue }; |
| 352 | let seen = Seen { stat: Stat::of(&meta), kind: kind_of(&meta), abs: entry.path().to_path_buf() }; |
| 353 | found.lock().unwrap().push((rel.to_string(), seen)); |
| 354 | ignore::WalkState::Continue |
| 355 | }) |
| 356 | }); |
| 357 | let mut seen: BTreeMap<String, Seen> = found.into_inner().unwrap().into_iter().collect(); |
| 358 |
| 359 | // Tracked files the walk skipped (ignored but tracked) still count. |
| 360 | for path in base.keys().chain(index.entries.keys()) { |
| 361 | if seen.contains_key(path) || !in_scope(only, path) { |
| 362 | continue; |
| 363 | } |
| 364 | let abs = root.join(path); |
| 365 | if let Ok(meta) = abs.symlink_metadata() |
| 366 | && !meta.is_dir() |
| 367 | { |
| 368 | seen.insert(path.clone(), Seen { stat: Stat::of(&meta), kind: kind_of(&meta), abs }); |
| 369 | } |
| 370 | } |
| 371 |
| 372 | // Mine files stay out of trees unless main already tracks them. |
| 373 | let mut mine_paths = Vec::new(); |
| 374 | seen.retain(|path, s| { |
| 375 | if !main_paths.contains(path) && mine.matches(path) { |
| 376 | mine_paths.push((path.clone(), s.abs.clone(), s.kind == EntryKind::Exec)); |
| 377 | false |
| 378 | } else { |
| 379 | in_scope(only, path) |
| 380 | } |
| 381 | }); |
| 382 |
| 383 | // Read and store what might have changed, in parallel. Objects are |
| 384 | // files named by their IDs, so parallel writes are safe. |
| 385 | let jobs: Vec<(&String, &Seen)> = seen |
| 386 | .iter() |
| 387 | .filter(|(path, s)| match index.entries.get(*path) { |
| 388 | Some(e) => !(index.unchanged(e, s.stat) && e.entry.kind == s.kind), |
| 389 | None => true, |
| 390 | }) |
| 391 | .collect(); |
| 392 | let stored: BTreeMap<String, Id> = jobs |
| 393 | .par_iter() |
| 394 | .map(|(path, s)| -> Result<(String, Id)> { |
| 395 | let bytes = read_content(&s.abs, s.kind)?; |
| 396 | Ok(((*path).clone(), store.put_file(&bytes)?)) |
| 397 | }) |
| 398 | .collect::<Result<BTreeMap<_, _>>>()?; |
| 399 |
| 400 | let mut files = Files::new(); |
| 401 | for (path, entry) in base { |
| 402 | if !in_scope(only, path) { |
| 403 | files.insert(path.clone(), *entry); |
| 404 | } |
| 405 | } |
| 406 | let mut next = Index::default(); |
| 407 | for (path, s) in &seen { |
| 408 | let id = match stored.get(path) { |
| 409 | Some(id) => *id, |
| 410 | None => index.entries[path].entry.id, |
| 411 | }; |
| 412 | let entry = Entry { kind: s.kind, id }; |
| 413 | files.insert(path.clone(), entry); |
| 414 | next.entries.insert(path.clone(), IndexEntry { entry, stat: s.stat }); |
| 415 | } |
| 416 | // Nothing new since the index's tree: it's still the tree. |
| 417 | let unchanged = stored.is_empty() && only.is_empty() && next.entries.len() == index.entries.len() && next.entries.keys().eq(index.entries.keys()); |
| 418 | let tree = match index.tree { |
| 419 | Some(t) if unchanged && store.has(&t) => t, |
| 420 | _ => { |
| 421 | let (tree, objects) = tree::build(&files); |
| 422 | for o in &objects { |
| 423 | store.put(o)?; |
| 424 | } |
| 425 | tree |
| 426 | } |
| 427 | }; |
| 428 | next.tree = Some(tree); |
| 429 | let mut mine_out = Vec::new(); |
| 430 | for (path, abs, exec) in mine_paths { |
| 431 | let bytes = read_content(&abs, EntryKind::File)?; |
| 432 | mine_out.push((path, store.put_file(&bytes)?, exec)); |
| 433 | } |
| 434 | Ok(Snapshot { files, tree, index: next, mine: mine_out }) |
| 435 | } |
| 436 |
| 437 | fn read_content(path: &Path, kind: EntryKind) -> Result<Vec<u8>> { |
| 438 | if kind == EntryKind::Link { |
| 439 | let target = fs::read_link(path)?; |
| 440 | return Ok(target.to_string_lossy().into_owned().into_bytes()); |
| 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 | // Write what's new or different. |
| 491 | let mut keep = Index::default(); |
| 492 | let mut jobs = Vec::new(); |
| 493 | for (path, entry) in &wanted { |
| 494 | if let Some(existing) = index.entries.get(*path) |
| 495 | && existing.entry == **entry |
| 496 | && let Ok(meta) = root.join(path).symlink_metadata() |
| 497 | && Stat::of(&meta) == existing.stat |
| 498 | { |
| 499 | keep.entries.insert((*path).clone(), *existing); |
| 500 | continue; |
| 501 | } |
| 502 | jobs.push(((*path).clone(), **entry)); |
| 503 | } |
| 504 | let written: Vec<(String, IndexEntry)> = jobs |
| 505 | .par_iter() |
| 506 | .map(|(path, entry)| -> Result<(String, IndexEntry)> { |
| 507 | let abs = root.join(path); |
| 508 | if let Some(parent) = abs.parent() { |
| 509 | make_dirs(root, parent)?; |
| 510 | } |
| 511 | if let Ok(meta) = abs.symlink_metadata() { |
| 512 | if meta.is_dir() { |
| 513 | fs::remove_dir_all(&abs)?; |
| 514 | } else { |
| 515 | fs::remove_file(&abs)?; |
| 516 | } |
| 517 | } |
| 518 | let copied = match &cow { |
| 519 | Some(src) if entry.kind != EntryKind::Link => copy_from(src, path, entry, &abs), |
| 520 | _ => false, |
| 521 | }; |
| 522 | if !copied { |
| 523 | let content = store.read_file(&entry.id).with_context(|| format!("missing content for {path}"))?; |
| 524 | if entry.kind == EntryKind::Link { |
| 525 | std::os::unix::fs::symlink(String::from_utf8_lossy(&content).into_owned(), &abs)?; |
| 526 | } else { |
| 527 | fs::write(&abs, content)?; |
| 528 | } |
| 529 | } |
| 530 | if entry.kind != EntryKind::Link { |
| 531 | let mode = if entry.kind == EntryKind::Exec { 0o755 } else { 0o644 }; |
| 532 | fs::set_permissions(&abs, fs::Permissions::from_mode(mode))?; |
| 533 | } |
| 534 | let meta = abs.symlink_metadata()?; |
| 535 | Ok((path.clone(), IndexEntry { entry: *entry, stat: Stat::of(&meta) })) |
| 536 | }) |
| 537 | .collect::<Result<Vec<_>>>()?; |
| 538 | for (path, e) in written { |
| 539 | keep.entries.insert(path, e); |
| 540 | } |
| 541 | Ok(keep) |
| 542 | } |
| 543 |
| 544 | /// Creates `dir` and its parents, replacing any file in the way. |
| 545 | fn make_dirs(root: &Path, dir: &Path) -> Result<()> { |
| 546 | if dir.is_dir() { |
| 547 | return Ok(()); |
| 548 | } |
| 549 | let mut missing = Vec::new(); |
| 550 | let mut d = dir; |
| 551 | while d != root && !d.is_dir() { |
| 552 | missing.push(d.to_path_buf()); |
| 553 | match d.parent() { |
| 554 | Some(p) => d = p, |
| 555 | None => break, |
| 556 | } |
| 557 | } |
| 558 | for m in missing.into_iter().rev() { |
| 559 | if m.symlink_metadata().is_ok_and(|meta| !meta.is_dir()) { |
| 560 | fs::remove_file(&m)?; |
| 561 | } |
| 562 | match fs::create_dir(&m) { |
| 563 | Ok(()) => {} |
| 564 | Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} |
| 565 | Err(e) => return Err(e.into()), |
| 566 | } |
| 567 | } |
| 568 | Ok(()) |
| 569 | } |
| 570 |
| 571 | /// Copies one file from another workspace if it still has the same content. |
| 572 | fn copy_from(src: &CowSource, path: &str, entry: &Entry, dest: &Path) -> bool { |
| 573 | let Some(theirs) = src.index.entries.get(path) else { return false }; |
| 574 | if theirs.entry != *entry { |
| 575 | return false; |
| 576 | } |
| 577 | let from = src.root.join(path); |
| 578 | let Ok(meta) = from.symlink_metadata() else { return false }; |
| 579 | if Stat::of(&meta) != theirs.stat { |
| 580 | return false; |
| 581 | } |
| 582 | reflink_copy::reflink_or_copy(&from, dest).is_ok() |
| 583 | } |
| 584 |
| 585 | /// Copies a build folder (like node_modules) from another workspace, |
| 586 | /// copy-on-write where the disk supports it. Returns how many files. |
| 587 | pub fn copy_folder(from: &Path, to: &Path) -> Result<u64> { |
| 588 | let mut count = 0; |
| 589 | if !from.is_dir() || to.exists() { |
| 590 | return Ok(0); |
| 591 | } |
| 592 | let mut stack = vec![from.to_path_buf()]; |
| 593 | while let Some(dir) = stack.pop() { |
| 594 | let rel = dir.strip_prefix(from)?; |
| 595 | fs::create_dir_all(to.join(rel))?; |
| 596 | for entry in fs::read_dir(&dir)? { |
| 597 | let entry = entry?; |
| 598 | let path = entry.path(); |
| 599 | let dest = to.join(path.strip_prefix(from)?); |
| 600 | let kind = entry.file_type()?; |
| 601 | if kind.is_dir() { |
| 602 | stack.push(path); |
| 603 | } else if kind.is_symlink() { |
| 604 | std::os::unix::fs::symlink(fs::read_link(&path)?, &dest)?; |
| 605 | count += 1; |
| 606 | } else { |
| 607 | reflink_copy::reflink_or_copy(&path, &dest)?; |
| 608 | count += 1; |
| 609 | } |
| 610 | } |
| 611 | } |
| 612 | Ok(count) |
| 613 | } |
| 614 |
| 615 | /// Whether text still holds conflict markers. |
| 616 | pub fn has_markers(bytes: &[u8]) -> bool { |
| 617 | let text = String::from_utf8_lossy(bytes); |
| 618 | let mut open = false; |
| 619 | let mut middle = false; |
| 620 | for line in text.lines() { |
| 621 | if line.starts_with("<<<<<<< ") || line == "<<<<<<<" { |
| 622 | open = true; |
| 623 | } else if open && line == "=======" { |
| 624 | middle = true; |
| 625 | } else if open && middle && (line.starts_with(">>>>>>> ") || line == ">>>>>>>") { |
| 626 | return true; |
| 627 | } |
| 628 | } |
| 629 | false |
| 630 | } |
| 631 |
| 632 | #[cfg(test)] |
| 633 | mod tests { |
| 634 | use super::*; |
| 635 |
| 636 | #[test] |
| 637 | fn markers() { |
| 638 | assert!(has_markers(b"a\n<<<<<<< main 4\nx\n=======\ny\n>>>>>>> your change bcdf\nb\n")); |
| 639 | assert!(!has_markers(b"a\n=======\nb\n")); |
| 640 | } |
| 641 |
| 642 | #[test] |
| 643 | fn mine_defaults() { |
| 644 | let mine = Mine::new(&Config::default(), &[]); |
| 645 | assert!(mine.matches(".env")); |
| 646 | assert!(mine.matches(".env.production")); |
| 647 | assert!(mine.matches("apps/web/.env.local")); |
| 648 | assert!(!mine.matches(".env.example")); |
| 649 | assert!(!mine.matches("src/env.ts")); |
| 650 | } |
| 651 |
| 652 | #[test] |
| 653 | fn snapshot_and_materialize() { |
| 654 | let objects = tempfile::tempdir().unwrap(); |
| 655 | let store = Store::open(objects.path()).unwrap(); |
| 656 | let ws = tempfile::tempdir().unwrap(); |
| 657 | let root = ws.path(); |
| 658 | write_id(root, "w1").unwrap(); |
| 659 | fs::create_dir_all(root.join("src")).unwrap(); |
| 660 | fs::write(root.join("README.md"), "hi\n").unwrap(); |
| 661 | fs::write(root.join("src/a.rs"), "fn a() {}\n").unwrap(); |
| 662 | fs::write(root.join(".gitignore"), "target/\n").unwrap(); |
| 663 | fs::create_dir_all(root.join("target")).unwrap(); |
| 664 | fs::write(root.join("target/junk"), "x").unwrap(); |
| 665 | fs::write(root.join(".env"), "SECRET=1\n").unwrap(); |
| 666 | let snap = snapshot(root, &store, &Files::new(), &[], &Index::default(), &[], &Default::default()).unwrap(); |
| 667 | assert!(snap.files.contains_key("README.md")); |
| 668 | assert!(snap.files.contains_key("src/a.rs")); |
| 669 | assert!(snap.files.contains_key(".gitignore")); |
| 670 | assert!(!snap.files.contains_key("target/junk")); |
| 671 | assert!(!snap.files.contains_key(".env")); |
| 672 | assert_eq!(snap.mine.len(), 1); |
| 673 |
| 674 | let ws2 = tempfile::tempdir().unwrap(); |
| 675 | write_id(ws2.path(), "w2").unwrap(); |
| 676 | let index2 = |
| 677 | materialize(ws2.path(), &store, &Index::default(), &snap.files, &[], Some(CowSource { root, index: &snap.index })).unwrap(); |
| 678 | assert_eq!(fs::read_to_string(ws2.path().join("src/a.rs")).unwrap(), "fn a() {}\n"); |
| 679 | let mut fewer = snap.files.clone(); |
| 680 | fewer.remove("src/a.rs"); |
| 681 | materialize(ws2.path(), &store, &index2, &fewer, &[], None).unwrap(); |
| 682 | assert!(!ws2.path().join("src").exists()); |
| 683 | } |
| 684 | } |
| 685 |