farhand-workspace 1.10.2

Agent-side workspace storage for Farhand: content-addressable store, copy-on-write cloning, locks, GC, and run history
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
use crate::cow::parse_base_project_name;
use crate::state::{read_state, write_state};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use tracing::{debug, info};

#[derive(Debug, Clone, Default)]
pub struct GcReport {
    pub total_workspaces_scanned: usize,
    pub caches_trimmed_bytes: u64,
    pub workspaces_deleted: usize,
    pub workspaces_deleted_bytes: u64,
    pub remaining_disk_bytes: u64,
}

/// Result of a CAS garbage-collection pass.
#[derive(Debug, Clone, Default)]
pub struct CasGcReport {
    pub objects_deleted: usize,
    pub bytes_freed: u64,
    pub remaining_bytes: u64,
}

#[derive(Debug, Clone)]
pub struct WorkspaceMetadata {
    pub path: PathBuf,
    pub name: String,
    /// The project name this workspace serves, as the client spells it.
    ///
    /// This is *not* `name`: `name` is the on-disk directory, which is
    /// sanitised and hash-suffixed. Locks are keyed by the client's project
    /// name, so the lock predicate must be given this field. Passing `name`
    /// made every lock check silently false.
    pub project: String,
    pub last_used_at: SystemTime,
    pub size_bytes: u64,
    pub is_canonical: bool,
}

/// Calculate total size of a directory recursively in bytes.
pub fn calculate_dir_size(path: &Path) -> u64 {
    let mut total = 0u64;
    if let Ok(entries) = fs::read_dir(path) {
        for entry in entries.flatten() {
            // `DirEntry::metadata` does not follow symlinks: a link reports
            // `is_symlink` and its own length, so `is_dir` is false and a
            // link to a directory is never recursed into. That is what keeps
            // a self-referential link from recursing forever and a link to `/`
            // from inflating the total. `fs::symlink_metadata` would say the
            // same thing; `fs::metadata` would NOT, and would reintroduce both.
            if let Ok(meta) = entry.metadata() {
                if meta.is_dir() {
                    total += calculate_dir_size(&entry.path());
                } else {
                    total += meta.len();
                }
            }
        }
    }
    total
}

/// Update workspace `last_used_at` timestamp.
pub fn touch_workspace(workspace_dir: &Path, _project_name: &str) {
    if let Some(mut state) = read_state(workspace_dir) {
        state.last_installed_at = SystemTime::now();
        let _ = write_state(workspace_dir, &state);
    }

    // Write / update .farhand-last-used marker file
    let stamp = workspace_dir.join(".farhand-last-used");
    let _ = fs::write(stamp, format!("{:?}", SystemTime::now()));
}

/// Retrieve last used timestamp for a workspace.
pub fn get_workspace_last_used(workspace_dir: &Path) -> SystemTime {
    if let Ok(meta) = fs::metadata(workspace_dir.join(".farhand-last-used")) {
        if let Ok(mtime) = meta.modified() {
            return mtime;
        }
    }

    if let Some(state) = read_state(workspace_dir) {
        return state.last_installed_at;
    }

    if let Ok(meta) = fs::metadata(workspace_dir.join(".farhand-state.json")) {
        if let Ok(mtime) = meta.modified() {
            return mtime;
        }
    }

    if let Ok(meta) = fs::metadata(workspace_dir) {
        if let Ok(mtime) = meta.modified() {
            return mtime;
        }
    }

    SystemTime::UNIX_EPOCH
}

/// Soft pruning: trims volatile compiler caches (e.g. incremental caches, node_modules/.cache).
/// Keeps installed dependencies and build artifacts intact.
pub fn trim_workspace_caches(workspace_dir: &Path) -> u64 {
    let cache_dirs = [
        "target/debug/incremental",
        "target/release/incremental",
        "node_modules/.cache",
        ".next/cache",
        "DerivedData",
        ".gradle/caches",
    ];

    let mut freed_bytes = 0u64;
    for rel_dir in cache_dirs {
        let dir = workspace_dir.join(rel_dir);
        if dir.is_dir() {
            let size = calculate_dir_size(&dir);
            if fs::remove_dir_all(&dir).is_ok() {
                freed_bytes += size;
                info!("Trimmed cache {} (freed {} bytes)", dir.display(), size);
            }
        }
    }

    freed_bytes
}

/// Scan all workspace directories under `workspaces_root`.
pub fn scan_workspaces(workspaces_root: &Path) -> Vec<WorkspaceMetadata> {
    let mut workspaces = Vec::new();

    let entries = match fs::read_dir(workspaces_root) {
        Ok(e) => e,
        Err(_) => return workspaces,
    };

    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            let file_name = path
                .file_name()
                .and_then(|s| s.to_str())
                .unwrap_or("")
                .to_string();

            // Skip non-workspace infrastructure dirs (CAS storage, state).
            if file_name == "cas" || file_name.starts_with('.') {
                continue;
            }

            let last_used = get_workspace_last_used(&path);
            let size = calculate_dir_size(&path);

            // A workspace is canonical if it does not have branch separators in its name
            // or explicitly matches main/master
            let is_canonical = parse_base_project_name(&file_name).is_none()
                || file_name.contains("__main")
                || file_name.contains("__master")
                || file_name.contains(":main")
                || file_name.contains(":master");

            // Prefer the project name recorded when the workspace was last
            // used; fall back to the directory name for workspaces created
            // before this field existed, which is the pre-fix behaviour and
            // therefore no worse.
            let project = read_state(&path)
                .map(|s| s.project)
                .filter(|p| !p.is_empty())
                .unwrap_or_else(|| file_name.clone());

            workspaces.push(WorkspaceMetadata {
                path,
                name: file_name,
                project,
                last_used_at: last_used,
                size_bytes: size,
                is_canonical,
            });
        }
    }

    workspaces
}

/// Execute automated Garbage Collection across `workspaces_root`.
///
/// `skip_locked` decides whether a workspace (by project name) may be
/// deleted or cache-trimmed — the daemon passes its workspace-lock check so
/// active runs are never destroyed underneath themselves.
pub fn run_garbage_collection(
    workspaces_root: &Path,
    max_disk_bytes: Option<u64>,
    ttl: Option<Duration>,
    skip_locked: &dyn Fn(&str) -> bool,
) -> GcReport {
    let mut report = GcReport::default();
    let mut workspaces = scan_workspaces(workspaces_root);
    report.total_workspaces_scanned = workspaces.len();

    let now = SystemTime::now();

    // 1. Evict any non-canonical workspace exceeding TTL
    if let Some(ttl_dur) = ttl {
        workspaces.retain(|ws| {
            if !ws.is_canonical {
                if skip_locked(&ws.project) {
                    debug!("GC: skipping locked workspace {} (active run)", ws.name);
                    return true;
                }
                if let Ok(age) = now.duration_since(ws.last_used_at) {
                    if age > ttl_dur {
                        info!(
                            "TTL expired for workspace {} (age {:?} > {:?}). Purging...",
                            ws.path.display(),
                            age,
                            ttl_dur
                        );
                        let _ = fs::remove_dir_all(&ws.path);
                        report.workspaces_deleted += 1;
                        report.workspaces_deleted_bytes += ws.size_bytes;
                        return false;
                    }
                }
            }
            true
        });
    }

    // 2. Check total storage quota
    let current_total: u64 = workspaces.iter().map(|w| w.size_bytes).sum();
    let mut current_usage = current_total;

    if let Some(max_bytes) = max_disk_bytes {
        if current_usage > max_bytes {
            // Sort workspaces oldest first (LRU)
            workspaces.sort_by_key(|a| a.last_used_at);

            // Tier 1: Soft trim caches of non-canonical workspaces
            for ws in &mut workspaces {
                if current_usage <= max_bytes {
                    break;
                }
                if !ws.is_canonical && !skip_locked(&ws.project) {
                    let trimmed = trim_workspace_caches(&ws.path);
                    report.caches_trimmed_bytes += trimmed;
                    current_usage = current_usage.saturating_sub(trimmed);
                    ws.size_bytes = ws.size_bytes.saturating_sub(trimmed);
                }
            }

            // Tier 2: Hard eviction of non-canonical workspaces (oldest first)
            for ws in &workspaces {
                if current_usage <= max_bytes {
                    break;
                }
                if !ws.is_canonical && !skip_locked(&ws.project) {
                    info!(
                        "Quota exceeded. Purging LRU workspace {} (size {} bytes)...",
                        ws.path.display(),
                        ws.size_bytes
                    );
                    let _ = fs::remove_dir_all(&ws.path);
                    report.workspaces_deleted += 1;
                    report.workspaces_deleted_bytes += ws.size_bytes;
                    current_usage = current_usage.saturating_sub(ws.size_bytes);
                }
            }
        }
    }

    report.remaining_disk_bytes = current_usage;
    report
}

/// Emergency disk cleanup pass: trims caches in non-canonical workspaces,
/// and if needed, evicts oldest non-canonical workspaces until target_bytes_to_free is reached.
///
/// `skip_locked` protects workspaces with active runs (same contract as
/// [`run_garbage_collection`]).
pub fn run_emergency_disk_gc(
    workspaces_root: &Path,
    target_bytes_to_free: u64,
    skip_locked: &dyn Fn(&str) -> bool,
) -> GcReport {
    let mut report = GcReport::default();
    let mut workspaces = scan_workspaces(workspaces_root);
    report.total_workspaces_scanned = workspaces.len();

    // Sort LRU: oldest last_used_at first
    workspaces.sort_by_key(|w| w.last_used_at);

    let mut freed_bytes = 0u64;

    // Phase 1: Trim incremental / compiler caches in non-canonical workspaces
    for ws in &mut workspaces {
        if freed_bytes >= target_bytes_to_free {
            break;
        }
        if !ws.is_canonical && !skip_locked(&ws.project) {
            let trimmed = trim_workspace_caches(&ws.path);
            report.caches_trimmed_bytes += trimmed;
            freed_bytes += trimmed;
            ws.size_bytes = ws.size_bytes.saturating_sub(trimmed);
        }
    }

    // Phase 2: Purge oldest non-canonical workspaces if still below target
    for ws in &workspaces {
        if freed_bytes >= target_bytes_to_free {
            break;
        }
        if !ws.is_canonical && !skip_locked(&ws.project) {
            info!(
                "Emergency GC: Purging LRU workspace {} (size {} bytes)...",
                ws.path.display(),
                ws.size_bytes
            );
            let _ = fs::remove_dir_all(&ws.path);
            report.workspaces_deleted += 1;
            report.workspaces_deleted_bytes += ws.size_bytes;
            freed_bytes += ws.size_bytes;
        }
    }

    report.remaining_disk_bytes = freed_bytes;
    report
}

/// Garbage-collect the content-addressable store.
///
/// CAS is a **cache**: an evicted object is simply re-uploaded on the next
/// manifest mismatch, so retention is a throughput trade, never a correctness
/// one. Policy:
/// 1. TTL — objects whose mtime is older than `ttl` are removed. Hydration
///    touches object mtimes (see `CasStore::materialize_to`), so this means
///    "unused since", not "ingested at".
/// 2. Quota — if the store exceeds `max_bytes`, oldest-mtime objects are
///    evicted first (LRU).
/// 3. Stale `.tmp` files (aborted `put_file` uploads) are removed after an
///    hour regardless of quota.
pub fn gc_cas(
    cas_objects_dir: &Path,
    max_bytes: Option<u64>,
    ttl: Option<Duration>,
) -> CasGcReport {
    let mut report = CasGcReport::default();

    let mut objects: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
    for entry in walkdir::WalkDir::new(cas_objects_dir)
        .follow_links(false)
        .into_iter()
        .flatten()
    {
        let path = entry.path();
        if !path.is_file() {
            continue;
        }
        let meta = match entry.metadata() {
            Ok(m) => m,
            Err(_) => continue,
        };
        objects.push((
            path.to_path_buf(),
            meta.len(),
            meta.modified().unwrap_or(SystemTime::now()),
        ));
    }

    let now = SystemTime::now();
    let mut remaining: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
    let is_tmp = |p: &Path| {
        p.file_name()
            .and_then(|s| s.to_str())
            .map(|s| s.contains(".tmp."))
            .unwrap_or(false)
    };

    for (path, size, mtime) in objects {
        let age = now.duration_since(mtime).unwrap_or_default();
        let stale_tmp = is_tmp(&path) && age > Duration::from_secs(3600);
        let expired = ttl.map(|t| age > t).unwrap_or(false);

        if stale_tmp || expired {
            if fs::remove_file(&path).is_ok() {
                report.objects_deleted += 1;
                report.bytes_freed += size;
            } else {
                remaining.push((path, size, mtime));
            }
        } else {
            remaining.push((path, size, mtime));
        }
    }

    // Quota: evict oldest-touched first.
    if let Some(max_bytes) = max_bytes {
        let mut total: u64 = remaining.iter().map(|(_, s, _)| *s).sum();
        if total > max_bytes {
            remaining.sort_by_key(|(_, _, mtime)| *mtime);
            let mut survivors = Vec::new();
            for (path, size, mtime) in remaining {
                if total > max_bytes && fs::remove_file(&path).is_ok() {
                    report.objects_deleted += 1;
                    report.bytes_freed += size;
                    total = total.saturating_sub(size);
                } else {
                    survivors.push((path, size, mtime));
                }
            }
            remaining = survivors;
        }
    }

    report.remaining_bytes = remaining.iter().map(|(_, s, _)| *s).sum();
    report
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn test_trim_workspace_caches() {
        let temp = tempdir().unwrap();
        let ws = temp.path().join("my-ws");

        let cache_dir = ws.join("target/debug/incremental");
        fs::create_dir_all(&cache_dir).unwrap();
        fs::write(cache_dir.join("cache.dat"), "1234567890").unwrap();

        let src_file = ws.join("src/main.rs");
        fs::create_dir_all(ws.join("src")).unwrap();
        fs::write(&src_file, "fn main() {}").unwrap();

        let freed = trim_workspace_caches(&ws);
        assert!(freed >= 10);
        assert!(!cache_dir.exists());
        assert!(src_file.exists());
    }

    #[test]
    fn test_run_garbage_collection_quota_eviction() {
        let temp = tempdir().unwrap();
        let root = temp.path();

        // 1. Canonical workspace (should NOT be deleted)
        let main_ws = root.join("my-repo__main-12345678");
        fs::create_dir_all(&main_ws).unwrap();
        fs::write(main_ws.join("data.bin"), vec![0u8; 1000]).unwrap();

        // 2. Old feature branch workspace
        let feat_ws = root.join("my-repo__feat1-87654321");
        fs::create_dir_all(&feat_ws).unwrap();
        fs::write(feat_ws.join("data.bin"), vec![0u8; 1000]).unwrap();

        // Quota is 1200 bytes, total is ~2000 bytes
        let report = run_garbage_collection(root, Some(1200), None, &|_| false);

        assert_eq!(report.workspaces_deleted, 1);
        assert!(!feat_ws.exists());
        assert!(main_ws.exists(), "Canonical workspace must be preserved");
    }

    #[test]
    fn test_run_garbage_collection_skips_locked_workspaces() {
        let temp = tempdir().unwrap();
        let root = temp.path();

        let feat_ws = root.join("my-repo__feat1-87654321");
        fs::create_dir_all(&feat_ws).unwrap();
        fs::write(feat_ws.join("data.bin"), vec![0u8; 1000]).unwrap();

        // Quota forces eviction, but the workspace's project is locked
        // (an active run holds it) — GC must leave it alone.
        let report = run_garbage_collection(root, Some(500), None, &|name| {
            name.starts_with("my-repo__feat1")
        });

        assert_eq!(report.workspaces_deleted, 0);
        assert!(feat_ws.exists(), "locked workspace must survive GC");
    }

    /// Reproduces the production wiring, which the test above does not.
    ///
    /// The daemon does not hand GC a name predicate — it hands it a `HashSet`
    /// of *client project names* taken from `locked_projects()`. The test above
    /// instead matched on the on-disk directory name, so it passed while the
    /// real check compared `my-repo:feat1` against `my-repo__feat1-87654321`,
    /// never matched, and let GC delete workspaces out from under live runs.
    #[test]
    fn test_gc_lock_check_matches_the_project_name_not_the_directory() {
        let temp = tempdir().unwrap();
        let root = temp.path();

        // Directory name is sanitised and hash-suffixed, as resolve_workspace_dir
        // produces it.
        let feat_ws = root.join("my-repo__feat1-87654321");
        fs::create_dir_all(&feat_ws).unwrap();
        fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();

        // The workspace records the name the client actually used.
        write_state(
            &feat_ws,
            &crate::state::WorkspaceState {
                version: 1,
                last_success_lockfile_hash: String::new(),
                last_installed_at: SystemTime::now(),
                template: "npm".to_string(),
                project: "my-repo:feat1".to_string(),
                toolchain: std::collections::BTreeMap::new(),
            },
        )
        .unwrap();

        // Exactly what fhd passes: a set of locked *project* names.
        let locked: std::collections::HashSet<String> =
            ["my-repo:feat1".to_string()].into_iter().collect();

        let report = run_garbage_collection(root, Some(500), None, &|name| locked.contains(name));

        assert_eq!(report.workspaces_deleted, 0, "GC deleted a live workspace");
        assert!(
            feat_ws.exists(),
            "a workspace with an active run was evicted by quota"
        );
    }

    /// The negative control for the test above: with nothing locked, the very
    /// same setup must be evicted. Without this, a GC that ignored locks
    /// entirely would also pass.
    #[test]
    fn test_gc_still_evicts_when_no_run_holds_the_lock() {
        let temp = tempdir().unwrap();
        let root = temp.path();

        let feat_ws = root.join("my-repo__feat1-87654321");
        fs::create_dir_all(&feat_ws).unwrap();
        fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();
        write_state(
            &feat_ws,
            &crate::state::WorkspaceState {
                version: 1,
                last_success_lockfile_hash: String::new(),
                last_installed_at: SystemTime::now(),
                template: "npm".to_string(),
                project: "my-repo:feat1".to_string(),
                toolchain: std::collections::BTreeMap::new(),
            },
        )
        .unwrap();

        let report = run_garbage_collection(root, Some(500), None, &|_| false);

        assert_eq!(report.workspaces_deleted, 1);
        assert!(!feat_ws.exists());
    }

    #[test]
    fn test_run_emergency_disk_gc() {
        let temp = tempdir().unwrap();
        let root = temp.path();

        let feat_ws = root.join("my-repo__feat1-87654321");
        fs::create_dir_all(&feat_ws).unwrap();
        fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();

        let report = run_emergency_disk_gc(root, 1000, &|_| false);
        assert_eq!(report.workspaces_deleted, 1);
        assert!(!feat_ws.exists());
    }

    #[test]
    fn test_run_emergency_disk_gc_skips_locked() {
        let temp = tempdir().unwrap();
        let root = temp.path();

        let feat_ws = root.join("my-repo__feat1-87654321");
        fs::create_dir_all(&feat_ws).unwrap();
        fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();

        let report = run_emergency_disk_gc(root, 1000, &|_| true);
        assert_eq!(report.workspaces_deleted, 0);
        assert!(feat_ws.exists());
    }
}

#[cfg(test)]
mod cas_gc_tests {
    use super::*;
    use std::time::Duration;

    /// Build a fake CAS object layout: cas_objects_dir/ab/cd/<hash>.
    fn make_object(dir: &Path, hash: &str, size: usize, age_secs: u64) -> PathBuf {
        let path = dir.join(&hash[..2]).join(&hash[2..4]).join(hash);
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(&path, vec![0u8; size]).unwrap();
        let old = SystemTime::now() - Duration::from_secs(age_secs);
        let f = fs::File::options().write(true).open(&path).unwrap();
        f.set_modified(old).unwrap();
        path
    }

    #[test]
    fn test_gc_cas_ttl_evicts_unused_objects() {
        let dir = tempfile::tempdir().unwrap();
        let fresh = make_object(dir.path(), "aa11fresh_object_1", 100, 0);
        let stale = make_object(dir.path(), "bb22stale_object_2", 100, 40 * 86400);

        let report = gc_cas(dir.path(), None, Some(Duration::from_secs(30 * 86400)));

        assert_eq!(report.objects_deleted, 1);
        assert_eq!(report.bytes_freed, 100);
        assert!(fresh.is_file(), "fresh object must survive");
        assert!(!stale.exists(), "stale object must be evicted");
    }

    #[test]
    fn test_gc_cas_quota_evicts_lru_first() {
        let dir = tempfile::tempdir().unwrap();
        let old = make_object(dir.path(), "cc33old_object_1111", 400, 86400);
        let new = make_object(dir.path(), "dd44new_object_1111", 400, 1);

        // Quota 500 bytes, store has 800 → oldest (old) must go first.
        let report = gc_cas(dir.path(), Some(500), None);

        assert_eq!(report.objects_deleted, 1);
        assert!(!old.exists(), "oldest object evicted under quota");
        assert!(new.is_file(), "newest object survives under quota");
    }

    #[test]
    fn test_gc_cas_removes_stale_tmp_files() {
        let dir = tempfile::tempdir().unwrap();
        let tmp = dir.path().join("ab").join("cd");
        fs::create_dir_all(&tmp).unwrap();
        let tmp_file = tmp.join("abcddeadbeef.tmp.12345.7");
        fs::write(&tmp_file, vec![0u8; 50]).unwrap();
        let old = SystemTime::now() - Duration::from_secs(7200);
        let f = fs::File::options().write(true).open(&tmp_file).unwrap();
        f.set_modified(old).unwrap();

        let report = gc_cas(dir.path(), None, None);

        assert!(!tmp_file.exists(), "stale tmp file must be cleaned");
        assert_eq!(report.objects_deleted, 1);
    }

    #[test]
    fn test_scan_workspaces_skips_cas_dir() {
        let root = tempfile::tempdir().unwrap();
        fs::create_dir_all(root.path().join("myrepo")).unwrap();
        fs::create_dir_all(root.path().join("cas").join("objects")).unwrap();

        let scanned = scan_workspaces(root.path());
        let names: Vec<&str> = scanned.iter().map(|w| w.name.as_str()).collect();
        assert_eq!(
            names,
            vec!["myrepo"],
            "cas dir must not count as a workspace"
        );
    }
}

// Unix-only, and gated at the module rather than per test so the imports
// below do not go unused on Windows and fail clippy there.
#[cfg(all(test, unix))]
mod symlink_accounting_tests {
    use super::*;
    use tempfile::tempdir;

    /// Characterisation, not a regression: `DirEntry::metadata` already does
    /// not follow symlinks, so this behaviour was never broken. The test pins
    /// it, because switching to `fs::metadata` — the obvious-looking
    /// "fix" — would reintroduce two real failures at once: infinite recursion
    /// on a self-referential link, and a workspace reporting the size of the
    /// whole filesystem, which the quota path would then act on by evicting
    /// other workspaces.
    #[cfg(unix)]
    #[test]
    fn symlinks_are_counted_as_links_and_never_recursed_into() {
        let dir = tempdir().unwrap();
        let root = dir.path();
        fs::write(root.join("real.bin"), vec![7u8; 4096]).unwrap();
        let before = calculate_dir_size(root);

        // A self-referential link, and a link to a large tree.
        let big = tempdir().unwrap();
        fs::write(big.path().join("payload.bin"), vec![1u8; 4 * 1024 * 1024]).unwrap();
        std::os::unix::fs::symlink(root, root.join("self")).unwrap();
        std::os::unix::fs::symlink(big.path(), root.join("big")).unwrap();

        let after = calculate_dir_size(root);

        assert!(
            after < 64 * 1024,
            "a 4 MiB tree behind a link was counted: {before} -> {after}"
        );
        assert!(
            after >= before,
            "the real file's own bytes must still count"
        );
    }
}