1#![deny(unsafe_code)]
5
6use fileset::FilesetError;
7use protocol::{FileEntry, ManifestPayload};
8use sha2::{Digest, Sha256};
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use tracing::debug;
13
14pub mod presets;
15pub use presets::{detect_preset_outputs, resolve_artifact_paths, Preset, DEFAULT_PRESETS};
16
17pub mod lock;
18pub use lock::WorkspaceLockManager;
19
20pub mod state;
21pub use state::{compute_lockfiles_hash, read_state, write_state, WorkspaceState};
22
23pub mod history;
24pub use history::{format_rfc3339, get_recent_runs, save_run, MAX_RUNS_RETAINED, RUNS_DIR};
25
26pub mod cow;
27pub use cow::{cow_clone_dir, cow_clone_file, find_seed_workspace, parse_base_project_name};
28
29pub mod cas;
30pub use cas::CasStore;
31
32pub mod gc;
33pub use gc::{
34 calculate_dir_size, gc_cas, get_workspace_last_used, run_emergency_disk_gc,
35 run_garbage_collection, scan_workspaces, touch_workspace, trim_workspace_caches, CasGcReport,
36 GcReport, WorkspaceMetadata,
37};
38
39pub mod disk;
40pub use disk::{get_disk_space, DiskSpace};
41
42pub mod change_token;
43pub use change_token::change_token;
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct DiffResult {
47 pub want: Vec<String>,
49 pub delete_extraneous: Vec<String>,
51}
52
53pub fn resolve_workspace_dir(base_dir: &Path, project_name: &str) -> PathBuf {
56 let mut hasher = Sha256::new();
57 hasher.update(project_name.as_bytes());
58 let hash = hasher.finalize();
59 let short_hash = hex::encode(&hash[..4]); let clean_name: String = project_name
62 .chars()
63 .map(|c| {
64 if c.is_alphanumeric() || c == '-' || c == '_' {
65 c
66 } else {
67 '_'
68 }
69 })
70 .collect();
71
72 base_dir.join(format!("{}-{}", clean_name, short_hash))
73}
74
75pub fn ensure_workspace_dir(base_dir: &Path, project_name: &str) -> std::io::Result<PathBuf> {
79 let ws_dir = resolve_workspace_dir(base_dir, project_name);
80 if ws_dir.is_dir() {
81 touch_workspace(&ws_dir, project_name);
82 return Ok(ws_dir);
83 }
84
85 if let Some(seed_dir) = find_seed_workspace(base_dir, project_name) {
86 tracing::info!(
87 "Forking new branch workspace for '{}' from seed '{}' via APFS CoW...",
88 project_name,
89 seed_dir.display()
90 );
91 if let Err(e) = cow_clone_dir(&seed_dir, &ws_dir) {
92 tracing::warn!("CoW clone failed ({}); creating clean directory", e);
93 fs::create_dir_all(&ws_dir)?;
94 }
95 } else {
96 fs::create_dir_all(&ws_dir)?;
97 }
98
99 touch_workspace(&ws_dir, project_name);
100 Ok(ws_dir)
101}
102
103pub fn default_workspaces_dir() -> PathBuf {
105 if let Ok(override_dir) = std::env::var("FARHAND_WORKDIR") {
106 return PathBuf::from(override_dir);
107 }
108
109 if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
110 PathBuf::from(home).join(".farhand").join("workspaces")
111 } else {
112 std::env::temp_dir().join("farhand").join("workspaces")
113 }
114}
115
116pub fn diff_manifests(
118 workspace_root: &Path,
119 client_manifest: &ManifestPayload,
120 extra_ignores: &[String],
121) -> Result<DiffResult, FilesetError> {
122 let mut client_map: HashMap<&str, &FileEntry> =
123 HashMap::with_capacity(client_manifest.files.len());
124 for f in &client_manifest.files {
125 client_map.insert(&f.path, f);
126 }
127
128 let index_path = workspace_root.join(fileset::HASH_INDEX_FILENAME);
134 let mut ignores = extra_ignores.to_vec();
135 ignores.push(fileset::HASH_INDEX_FILENAME.to_string());
136
137 let remote_files = if workspace_root.exists() {
138 let mut cache = fileset::load_hash_cache(&index_path);
139 let (files, stats) =
140 fileset::scan_cached_with(workspace_root, &ignores, &mut cache, &change_token)?;
141
142 if let Err(e) = fileset::save_hash_cache(&index_path, &cache) {
145 debug!("Could not write digest index: {e}");
146 }
147
148 debug!(
149 "Workspace scan reused {} of {} digests ({} re-hashed)",
150 stats.reused,
151 stats.reused + stats.hashed,
152 stats.hashed
153 );
154
155 files
156 } else {
157 HashMap::new()
158 };
159
160 let mut want = Vec::new();
161 for (rel_path, client_entry) in &client_map {
162 if fileset::would_ignore(workspace_root, rel_path, false, extra_ignores) {
170 continue;
171 }
172
173 match remote_files.get(*rel_path) {
174 Some(remote_meta) => {
175 if remote_meta.hash != client_entry.hash {
176 want.push((*rel_path).to_string());
177 }
178 }
179 None => {
181 want.push((*rel_path).to_string());
182 }
183 }
184 }
185
186 let mut delete_extraneous = Vec::new();
187 for rel_path in remote_files.keys() {
188 if !client_map.contains_key(rel_path.as_str()) {
189 if !fileset::is_default_ignored(rel_path)
193 && rel_path != state::STATE_FILENAME
194 && !rel_path.starts_with(".farhand-")
195 {
196 delete_extraneous.push(rel_path.clone());
197 }
198 }
199 }
200
201 want.sort();
202 delete_extraneous.sort();
203
204 Ok(DiffResult {
205 want,
206 delete_extraneous,
207 })
208}
209
210pub fn sum_artifact_bytes(workspace_root: &Path, rel_paths: &[String]) -> u64 {
216 let mut total = 0u64;
217 for rel in rel_paths {
218 let Ok(rel_buf) = protocol::from_wire_path(rel) else {
219 continue;
220 };
221 let path = workspace_root.join(rel_buf);
222 match std::fs::symlink_metadata(&path) {
223 Ok(m) if m.is_dir() => total = total.saturating_add(calculate_dir_size(&path)),
224 Ok(m) => total = total.saturating_add(m.len()),
225 Err(_) => continue,
226 }
227 }
228 total
229}
230
231pub fn apply_deletions(workspace_root: &Path, to_delete: &[String]) -> std::io::Result<usize> {
233 if !workspace_root.exists() {
234 return Ok(0);
235 }
236
237 let canonical_root = workspace_root.canonicalize()?;
238 let mut count = 0;
239
240 for rel_path in to_delete {
241 let safe_rel = match protocol::from_wire_path(rel_path) {
242 Ok(p) => p,
243 Err(_) => continue,
244 };
245
246 let target = canonical_root.join(&safe_rel);
247 if target.exists() {
248 if let Ok(canonical_target) = target.canonicalize() {
249 if canonical_target.starts_with(&canonical_root)
250 && canonical_target != canonical_root
251 && fs::remove_file(&canonical_target).is_ok()
252 {
253 count += 1;
254 let mut parent = canonical_target.parent();
256 while let Some(p) = parent {
257 if p == canonical_root {
258 break;
259 }
260 if fs::remove_dir(p).is_err() {
261 break; }
263 parent = p.parent();
264 }
265 }
266 }
267 }
268 }
269
270 Ok(count)
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use tempfile::tempdir;
277
278 #[test]
279 fn test_resolve_workspace_dir() {
280 let base = Path::new("/var/farhand/workspaces");
281 let dir1 = resolve_workspace_dir(base, "my-app");
282 let dir2 = resolve_workspace_dir(base, "my-app");
283 assert_eq!(dir1, dir2);
284 assert_eq!(dir1.parent(), Some(base));
285 assert!(dir1
286 .file_name()
287 .and_then(|n| n.to_str())
288 .is_some_and(|s| s.starts_with("my-app-")));
289
290 let dir3 = resolve_workspace_dir(base, "other-project");
291 assert_ne!(dir1, dir3);
292 }
293
294 #[test]
295 fn test_diff_fresh_workspace() {
296 let dir = tempdir().unwrap();
297 let ws = dir.path().join("ws");
298
299 let manifest = ManifestPayload {
300 files: vec![
301 FileEntry {
302 path: "src/main.rs".into(),
303 hash: "hash1".into(),
304 size: 10,
305 mode: 0o644,
306 },
307 FileEntry {
308 path: "Cargo.toml".into(),
309 hash: "hash2".into(),
310 size: 20,
311 mode: 0o644,
312 },
313 ],
314 };
315
316 let diff = diff_manifests(&ws, &manifest, &[]).unwrap();
317 assert_eq!(diff.want, vec!["Cargo.toml", "src/main.rs"]);
318 assert!(diff.delete_extraneous.is_empty());
319 }
320
321 #[test]
322 fn test_diff_unchanged_workspace() {
323 let dir = tempdir().unwrap();
324 let ws = dir.path().join("ws");
325 fs::create_dir_all(ws.join("src")).unwrap();
326
327 fs::write(ws.join("src/main.rs"), b"fn main() {}").unwrap();
328 let hash = fileset::hash_file(&ws.join("src/main.rs")).unwrap();
329
330 let manifest = ManifestPayload {
331 files: vec![FileEntry {
332 path: "src/main.rs".into(),
333 hash,
334 size: 12,
335 mode: 0o644,
336 }],
337 };
338
339 let diff = diff_manifests(&ws, &manifest, &[]).unwrap();
340 assert!(
341 diff.want.is_empty(),
342 "Unchanged files should not be in want: {:?}",
343 diff.want
344 );
345 assert!(diff.delete_extraneous.is_empty());
346 }
347
348 #[test]
349 fn test_diff_modified_file() {
350 let dir = tempdir().unwrap();
351 let ws = dir.path().join("ws");
352 fs::create_dir_all(&ws).unwrap();
353
354 fs::write(ws.join("file.txt"), b"remote content").unwrap();
355
356 let manifest = ManifestPayload {
357 files: vec![FileEntry {
358 path: "file.txt".into(),
359 hash: "different_local_hash".into(),
360 size: 20,
361 mode: 0o644,
362 }],
363 };
364
365 let diff = diff_manifests(&ws, &manifest, &[]).unwrap();
366 assert_eq!(diff.want, vec!["file.txt"]);
367 assert!(diff.delete_extraneous.is_empty());
368 }
369
370 #[test]
371 fn test_deletion_safety_rule_section_5_1() {
372 let dir = tempdir().unwrap();
373 let ws = dir.path().join("ws");
374 fs::create_dir_all(ws.join("src")).unwrap();
375 fs::create_dir_all(ws.join("node_modules/react")).unwrap();
376 fs::create_dir_all(ws.join("target/release")).unwrap();
377
378 fs::write(ws.join("src/old_deleted_file.rs"), b"old").unwrap();
380
381 fs::write(ws.join("node_modules/react/index.js"), b"react").unwrap();
383 fs::write(ws.join("target/release/binary"), b"elf").unwrap();
384
385 let manifest = ManifestPayload {
387 files: vec![FileEntry {
388 path: "src/new_file.rs".into(),
389 hash: "new_hash".into(),
390 size: 10,
391 mode: 0o644,
392 }],
393 };
394
395 let diff = diff_manifests(&ws, &manifest, &[]).unwrap();
396
397 assert_eq!(diff.delete_extraneous, vec!["src/old_deleted_file.rs"]);
399
400 assert!(!diff
402 .delete_extraneous
403 .iter()
404 .any(|p| p.contains("node_modules")));
405 assert!(!diff.delete_extraneous.iter().any(|p| p.contains("target")));
406
407 let deleted = apply_deletions(&ws, &diff.delete_extraneous).unwrap();
409 assert_eq!(deleted, 1);
410 assert!(!ws.join("src/old_deleted_file.rs").exists());
411
412 assert!(ws.join("node_modules/react/index.js").exists());
414 assert!(ws.join("target/release/binary").exists());
415 }
416
417 #[test]
421 fn test_digest_index_is_never_transferred_or_deleted() {
422 let dir = tempdir().unwrap();
423 let ws = dir.path().join("ws");
424 fs::create_dir_all(&ws).unwrap();
425 fs::write(ws.join("file.txt"), b"content").unwrap();
426
427 let manifest = ManifestPayload {
428 files: vec![FileEntry {
429 path: "file.txt".into(),
430 hash: fileset::hash_file(&ws.join("file.txt")).unwrap(),
431 size: 7,
432 mode: 0o644,
433 }],
434 };
435
436 let first = diff_manifests(&ws, &manifest, &[]).unwrap();
437 assert!(first.want.is_empty());
438 assert!(first.delete_extraneous.is_empty());
439
440 let index = ws.join(fileset::HASH_INDEX_FILENAME);
441 assert!(index.is_file(), "the diff did not persist a digest index");
442
443 let second = diff_manifests(&ws, &manifest, &[]).unwrap();
445 assert!(second.want.is_empty());
446 assert!(
447 second.delete_extraneous.is_empty(),
448 "the index was treated as project state: {:?}",
449 second.delete_extraneous
450 );
451
452 fs::write(ws.join("stray.txt"), b"stray").unwrap();
454 let third = diff_manifests(&ws, &manifest, &[]).unwrap();
455 assert_eq!(third.delete_extraneous, vec!["stray.txt"]);
456 apply_deletions(&ws, &third.delete_extraneous).unwrap();
457 assert!(index.is_file(), "the digest index was deleted");
458 }
459
460 #[test]
464 fn test_digest_index_does_not_mask_a_remote_edit() {
465 let dir = tempdir().unwrap();
466 let ws = dir.path().join("ws");
467 fs::create_dir_all(&ws).unwrap();
468 fs::write(ws.join("file.txt"), b"first").unwrap();
469
470 let stale = ManifestPayload {
471 files: vec![FileEntry {
472 path: "file.txt".into(),
473 hash: fileset::hash_file(&ws.join("file.txt")).unwrap(),
474 size: 5,
475 mode: 0o644,
476 }],
477 };
478
479 assert!(diff_manifests(&ws, &stale, &[]).unwrap().want.is_empty());
481
482 fs::write(ws.join("file.txt"), b"other").unwrap();
485
486 let diff = diff_manifests(&ws, &stale, &[]).unwrap();
487 assert_eq!(
488 diff.want,
489 vec!["file.txt"],
490 "the cached digest masked a remote edit"
491 );
492 }
493
494 #[test]
504 fn test_ignored_path_is_never_requested_however_often_the_manifest_replays() {
505 let dir = tempdir().unwrap();
506 let ws = dir.path().join("ws");
507 fs::create_dir_all(&ws).unwrap();
508
509 let client_side = dir.path().join("client");
511 fs::create_dir_all(&client_side).unwrap();
512 fs::write(client_side.join("build.log"), b"chatter").unwrap();
513
514 let manifest = ManifestPayload {
515 files: vec![FileEntry {
516 path: "build.log".into(),
517 hash: fileset::hash_file(&client_side.join("build.log")).unwrap(),
518 size: 7,
519 mode: 0o644,
520 }],
521 };
522
523 let ignored = vec!["*.log".to_string()];
524
525 let first = diff_manifests(&ws, &manifest, &ignored).unwrap();
527 assert!(
528 !first.want.contains(&"build.log".to_string()),
529 "an ignored path was requested: {:?}",
530 first.want
531 );
532
533 let second = diff_manifests(&ws, &manifest, &ignored).unwrap();
537 assert_eq!(first.want, second.want, "the want set is not stable");
538 assert!(!second.want.contains(&"build.log".to_string()));
539 }
540
541 #[test]
544 fn test_unignored_new_file_is_still_requested() {
545 let dir = tempdir().unwrap();
546 let ws = dir.path().join("ws");
547 fs::create_dir_all(&ws).unwrap();
548
549 let client_side = dir.path().join("client");
550 fs::create_dir_all(&client_side).unwrap();
551 fs::write(client_side.join("main.rs"), b"fn main() {}").unwrap();
552
553 let manifest = ManifestPayload {
554 files: vec![FileEntry {
555 path: "main.rs".into(),
556 hash: fileset::hash_file(&client_side.join("main.rs")).unwrap(),
557 size: 12,
558 mode: 0o644,
559 }],
560 };
561
562 let diff = diff_manifests(&ws, &manifest, &["*.log".to_string()]).unwrap();
563 assert_eq!(diff.want, vec!["main.rs"]);
564 }
565
566 #[test]
571 fn test_diff_detects_a_same_size_overwrite_that_preserves_the_mtime() {
572 let dir = tempdir().unwrap();
573 let ws = dir.path().join("ws");
574 fs::create_dir_all(&ws).unwrap();
575 let path = ws.join("file.txt");
576 fs::write(&path, b"aaaa").unwrap();
577
578 let original_mtime = fs::metadata(&path).unwrap().modified().unwrap();
579
580 let manifest = ManifestPayload {
582 files: vec![FileEntry {
583 path: "file.txt".into(),
584 hash: fileset::hash_file(&path).unwrap(),
585 size: 4,
586 mode: 0o644,
587 }],
588 };
589 assert!(
590 diff_manifests(&ws, &manifest, &[]).unwrap().want.is_empty(),
591 "the first diff should see no change"
592 );
593
594 fs::write(&path, b"bbbb").unwrap();
598 restore_mtime(&path, original_mtime);
599
600 let diff = diff_manifests(&ws, &manifest, &[]).unwrap();
601 assert_eq!(
602 diff.want,
603 vec!["file.txt"],
604 "a preserved-mtime same-size rewrite was masked by the digest cache"
605 );
606 }
607
608 #[cfg(unix)]
609 fn restore_mtime(path: &Path, mtime: std::time::SystemTime) {
610 use std::os::unix::fs::PermissionsExt;
611 let mode = fs::metadata(path).unwrap().permissions().mode();
612 fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap();
613 fs::File::options()
614 .write(true)
615 .open(path)
616 .unwrap()
617 .set_modified(mtime)
618 .unwrap();
619 }
620
621 #[cfg(windows)]
622 fn restore_mtime(path: &Path, mtime: std::time::SystemTime) {
623 fs::File::options()
626 .write(true)
627 .open(path)
628 .unwrap()
629 .set_modified(mtime)
630 .unwrap();
631 }
632}