heddle-cli 0.2.1

An AI-native version control system
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
// SPDX-License-Identifier: Apache-2.0
//! Lifecycle helpers for `--workspace light` threads.
//!
//! Virtualized threads project the thread's content-addressed tree
//! through a FUSE mount instead of materializing a checkout. The
//! mount itself lives in [`crates/mount`]; this module is the thin
//! CLI-side adapter that:
//!
//! * builds the conventional mount-point path
//!   (`.{repo_name}-heddle-mounts/{name}/`),
//! * spawns a background FUSE session when the thread starts,
//! * unmounts cleanly when the thread is dropped.
//!
//! The OS-specific implementation lives in the `linux` submodule
//! and only compiles with `--features mount` on Linux. Every other
//! build gets the [`virtualized_unsupported_error`] runtime check
//! so the rest of the CLI keeps building everywhere.

use std::path::{Path, PathBuf};

use anyhow::anyhow;

/// Compute the conventional mount point for a virtualized thread.
///
/// Mirrors [`default_lightweight_thread_path`] / [`default_private_thread_path`]
/// (which produce `.{repo_name}-heddle-threads/{name}/root/`) but
/// uses a sibling parent directory so a single repo can host both
/// lightweight checkouts and virtual mounts side-by-side without
/// path collisions.
///
/// Resolved template: `<repo_parent>/.<repo_name>-heddle-mounts/<sanitized_name>/`
pub(crate) fn default_virtualized_mount_path(
    workspace_parent: &Path,
    repo_name: &str,
    sanitized_thread: &str,
) -> PathBuf {
    workspace_parent
        .join(format!(".{repo_name}-heddle-mounts"))
        .join(sanitized_thread)
}

/// Anchored error for any non-Linux / unfeatured build path. Keeps
/// the runtime message identical regardless of which gate failed,
/// because either way the user has the same fix: rebuild on Linux
/// with `--features mount`. Both call sites (`cmd_daemon_serve`
/// fallback and the `fallback` module's `spawn_mount_for_thread`)
/// are gated `#[cfg(not(all(target_os = "linux", feature = "mount")))]`,
/// so on Linux + `--features mount` this function is genuinely
/// unused — `#[allow(dead_code)]` keeps it visible across configs
/// without tripping `-D dead-code`.
#[allow(dead_code)]
pub(crate) fn virtualized_unsupported_error() -> anyhow::Error {
    anyhow!("Virtualized workspace requires Linux + heddle built with --features mount")
}

#[cfg(all(target_os = "linux", feature = "mount"))]
mod linux {
    use std::{
        path::{Path, PathBuf},
        sync::Mutex,
    };

    use anyhow::{Context, Result, anyhow};
    use mount::{ContentAddressedMount, FuseShell};
    use repo::Repository;
    use tracing::warn;

    use crate::util::OnceMap;

    /// The opaque handle a CLI caller stashes alongside the thread to
    /// keep the mount alive. Dropping it triggers the `BackgroundSession`
    /// drop, which unmounts the FS.
    ///
    /// We don't actually persist this handle anywhere across CLI
    /// invocations — the FUSE mount lives only as long as the
    /// `heddle thread start` process. That's an intentional v1
    /// simplification: when the user kills heddle, the kernel
    /// receives the unmount via `BackgroundSession::drop` and the
    /// mount goes away. A reviewer can argue that
    /// long-lived virtualized threads need a dedicated background
    /// mount daemon — see the TODO at the bottom of
    /// `spawn_mount_for_thread`.
    pub struct MountHandle {
        // `BackgroundSession` lives as long as the heddle process; it's
        // the dtor that triggers the unmount. We store it in an Option
        // so an explicit `drop` becomes safe.
        session: Mutex<Option<mount::BackgroundSession>>,
        mountpoint: PathBuf,
    }

    impl MountHandle {
        /// Force the mount to unmount immediately. Returns an error
        /// only if the unmount actually fails (the session drop is
        /// infallible, but a stale-mount cleanup may still surface
        /// a libc error).
        pub fn unmount(&self) -> Result<()> {
            // Drop the BackgroundSession. fuser's session destructor
            // sends an unmount to the kernel; if that races with a
            // stale mount we'll see it as an error from `fusermount`
            // on the next mount attempt — so this is best-effort.
            let mut guard = self.session.lock().expect("mount session lock");
            *guard = None;
            Ok(())
        }

        pub fn mountpoint(&self) -> &Path {
            &self.mountpoint
        }
    }

    /// Process-global registry of live mount handles, keyed by
    /// thread name. Storing the handles here is what keeps the
    /// `BackgroundSession` alive past the `cmd_start` return — if
    /// the handle dropped at function exit, the mount would
    /// unmount immediately.
    static REGISTRY: OnceMap<String, std::sync::Arc<MountHandle>> = OnceMap::new();

    /// Mount `thread_id` into `mountpoint` in a background FUSE
    /// session. Creates the mount directory (`mkdir -p` semantics)
    /// before mounting. Returns the live mount handle.
    pub fn spawn_mount_for_thread(
        repo: Repository,
        thread_id: &str,
        mountpoint: &Path,
    ) -> Result<std::sync::Arc<MountHandle>> {
        std::fs::create_dir_all(mountpoint)
            .with_context(|| format!("create mount point {}", mountpoint.display()))?;

        let mount = ContentAddressedMount::new(repo, thread_id)
            .map_err(|e| anyhow!("open content-addressed mount for {thread_id}: {e}"))?;
        let shell = FuseShell::new(mount);
        let session = shell.mount_background(mountpoint).map_err(|e| {
            anyhow!(
                "spawn FUSE background session at {}: {e}",
                mountpoint.display()
            )
        })?;

        let handle = std::sync::Arc::new(MountHandle {
            session: Mutex::new(Some(session)),
            mountpoint: mountpoint.to_path_buf(),
        });
        REGISTRY.insert(thread_id.to_string(), std::sync::Arc::clone(&handle));

        // The mount lifetime here is pinned to the heddle process
        // that started it: when the CLI exits, the
        // BackgroundSession drops and the kernel unmounts. The
        // long-lived alternative — the `heddled` daemon — owns the
        // FUSE session across CLI invocations. As of 2026-05-02 the
        // daemon is the *default* for `--workspace light`;
        // this in-process path runs when the user passes
        // `--no-daemon`, or as the silent fallback when the daemon
        // is unavailable on the host. See `docs/design/mount-daemon.md`
        // and `crates/cli/src/cli/commands/daemon/`.
        Ok(handle)
    }

    /// Pop the registry entry for `thread_id` (if any) and unmount
    /// it. Logs and swallows unmount errors so a thread drop can't
    /// be blocked by a wedged mount.
    pub fn unmount_thread_if_mounted(thread_id: &str) -> bool {
        let Some(handle) = REGISTRY.remove(&thread_id.to_string()) else {
            return false;
        };
        if let Err(err) = handle.unmount() {
            warn!(
                thread = thread_id,
                mountpoint = %handle.mountpoint().display(),
                "unmount failed: {err}"
            );
        }
        true
    }
}

#[cfg(all(target_os = "linux", feature = "mount"))]
#[allow(unused_imports)] // Re-exported for downstream callers / tests.
pub(crate) use linux::MountHandle;
#[cfg(all(target_os = "linux", feature = "mount"))]
pub(crate) use linux::{spawn_mount_for_thread, unmount_thread_if_mounted};

#[cfg(not(all(target_os = "linux", feature = "mount")))]
mod stub {
    use std::path::Path;

    use anyhow::Result;

    /// Placeholder type so call sites compile on every platform.
    /// Constructing one is impossible because the constructors
    /// are gated to Linux+feature.
    pub struct MountHandle(std::convert::Infallible);

    pub fn spawn_mount_for_thread(
        _repo: repo::Repository,
        _thread_id: &str,
        _mountpoint: &Path,
    ) -> Result<std::sync::Arc<MountHandle>> {
        Err(super::virtualized_unsupported_error())
    }

    pub fn unmount_thread_if_mounted(_thread_id: &str) -> bool {
        false
    }
}

#[cfg(not(all(target_os = "linux", feature = "mount")))]
#[allow(unused_imports)] // Re-exported for downstream callers / tests.
pub(crate) use stub::MountHandle;
#[cfg(not(all(target_os = "linux", feature = "mount")))]
pub(crate) use stub::{spawn_mount_for_thread, unmount_thread_if_mounted};

/// What kind of mount the caller wants for this `--workspace
/// virtualized` start. Maps the CLI's two-bool `--daemon` /
/// `--no-daemon` pair onto a single tri-state: prefer-daemon
/// (default; fall back silently if the daemon is unavailable),
/// or in-process-only (the caller passed `--no-daemon`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MountOwnership {
    /// Try the daemon; on `Unavailable` errors, fall back to the
    /// in-process mount and log a one-line warning.
    PreferDaemon,
    /// Skip the daemon entirely. The mount lives and dies with this
    /// CLI process — exactly the pre-default-flip behaviour.
    InProcess,
}

impl MountOwnership {
    /// Translate the `(daemon, no_daemon)` pair from the parsed CLI
    /// args into a single ownership choice. Both flags use clap's
    /// `overrides_with`, so at most one is true at a time —
    /// `--daemon --no-daemon` resolves to the *last* flag the user
    /// typed. This helper centralises the precedence so callers
    /// don't reimplement it.
    pub fn from_flags(daemon: bool, no_daemon: bool) -> Self {
        if no_daemon || !daemon {
            // `--no-daemon` (or `daemon=false`, which `overrides_with`
            // produces when --no-daemon comes after --daemon) => in-process.
            Self::InProcess
        } else {
            Self::PreferDaemon
        }
    }
}

/// Establish the FUSE mount for a `--workspace light` thread,
/// honoring the user's daemon preference. This is the dispatch
/// point that flipped on 2026-05-02:
///
/// * `PreferDaemon` (default) → ask `heddled` to own the mount;
///   silently fall back to the in-process path with a warning if
///   the daemon turns out to be unavailable on this host.
/// * `InProcess` (`--no-daemon`) → skip the daemon entirely; same
///   behaviour as the pre-flip default.
///
/// The repo handle is opened internally on the in-process path; the
/// daemon path uses its own `Repository::open` inside the daemon
/// process. Callers should keep their existing `Repository` for the
/// rest of `start_thread` — see the comment at the original call
/// site in `thread.rs` for the two-handle pattern.
pub(crate) fn establish_virtualized_mount(
    repo_root: &Path,
    thread_id: &str,
    mountpoint: &Path,
    ownership: MountOwnership,
) -> anyhow::Result<()> {
    match ownership {
        MountOwnership::PreferDaemon => {
            let attempt = crate::cli::commands::daemon_client::mount_via_daemon_classified(
                repo_root, thread_id, mountpoint,
            );
            match classify_daemon_attempt(attempt, thread_id) {
                DaemonAttemptResolution::Daemon => Ok(()),
                DaemonAttemptResolution::FallbackInProcess => {
                    let mount_repo = repo::Repository::open(repo_root)?;
                    spawn_mount_for_thread(mount_repo, thread_id, mountpoint)?;
                    Ok(())
                }
                DaemonAttemptResolution::Fatal(err) => Err(err),
            }
        }
        MountOwnership::InProcess => {
            let mount_repo = repo::Repository::open(repo_root)?;
            spawn_mount_for_thread(mount_repo, thread_id, mountpoint)?;
            Ok(())
        }
    }
}

/// What `establish_virtualized_mount` does after the daemon attempt
/// returns. Extracted so the warning + fallback decision is a pure
/// function we can unit-test without standing up a real daemon or a
/// real FUSE mount.
#[derive(Debug)]
enum DaemonAttemptResolution {
    /// Daemon owned the mount successfully — nothing more to do.
    Daemon,
    /// Daemon was unavailable but the failure was the kind we
    /// silently recover from. Caller should run the in-process path.
    FallbackInProcess,
    /// Daemon responded with something we must surface (real conflict
    /// or unparseable response).
    Fatal(anyhow::Error),
}

/// Pure decision: given the daemon's response (Ok, Unavailable, or
/// Fatal) and the thread id, produce the next action and emit a
/// warning if we're falling back. Side effect is the `tracing::warn`
/// call on the fallback path; everything else is pure.
fn classify_daemon_attempt(
    attempt: std::result::Result<
        std::path::PathBuf,
        crate::cli::commands::daemon_client::DaemonMountError,
    >,
    thread_id: &str,
) -> DaemonAttemptResolution {
    use crate::cli::commands::daemon_client::DaemonMountError;
    match attempt {
        Ok(_) => DaemonAttemptResolution::Daemon,
        Err(DaemonMountError::Fatal(err)) => DaemonAttemptResolution::Fatal(err),
        Err(DaemonMountError::Unavailable(reason)) => {
            // One-line warning on the fallback path. Phrased so the
            // operator can copy the suppression hint verbatim.
            tracing::warn!(
                thread = thread_id,
                "daemon unavailable ({reason}); using in-process mount. \
                 Pass --no-daemon to suppress this warning."
            );
            DaemonAttemptResolution::FallbackInProcess
        }
    }
}

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

    /// Default state (no flags set explicitly) hits the daemon
    /// path. This is the load-bearing assertion for the
    /// 2026-05-02 default flip.
    #[test]
    fn default_flags_prefer_daemon() {
        // clap's `default_value_t = true` produces (daemon=true,
        // no_daemon=false) when no flag is passed.
        assert_eq!(
            MountOwnership::from_flags(true, false),
            MountOwnership::PreferDaemon,
        );
    }

    /// `--no-daemon` opts out and pins us to the in-process
    /// mount. clap's `overrides_with` clears `daemon` to false
    /// when `no_daemon` wins, but we also accept the
    /// (daemon=true, no_daemon=true) shape for safety —
    /// `overrides_with` is the only thing keeping that pair from
    /// being observable, and that contract is fragile across
    /// clap versions.
    #[test]
    fn no_daemon_flag_uses_in_process() {
        assert_eq!(
            MountOwnership::from_flags(false, true),
            MountOwnership::InProcess,
        );
        assert_eq!(
            MountOwnership::from_flags(true, true),
            MountOwnership::InProcess,
        );
    }

    /// `--daemon --no-daemon` (in either order) collapses to
    /// the *last* flag wins via `overrides_with`. Whichever
    /// state clap hands us, we should never accidentally
    /// promote `--no-daemon` to the daemon path.
    #[test]
    fn overrides_with_resolves_conflicting_flags_to_in_process_when_no_daemon_wins() {
        // `--daemon --no-daemon` → (daemon=false, no_daemon=true).
        assert_eq!(
            MountOwnership::from_flags(false, true),
            MountOwnership::InProcess,
        );
    }

    /// `--no-daemon --daemon` order — `--daemon` wins via
    /// `overrides_with`. We should treat this exactly like
    /// the default.
    #[test]
    fn overrides_with_resolves_conflicting_flags_to_daemon_when_daemon_wins() {
        // `--no-daemon --daemon` → (daemon=true, no_daemon=false).
        assert_eq!(
            MountOwnership::from_flags(true, false),
            MountOwnership::PreferDaemon,
        );
    }

    /// Daemon attempt that succeeded should resolve to the daemon
    /// path with no fallback and no warning.
    #[test]
    fn classify_daemon_attempt_ok_resolves_to_daemon() {
        let resolution =
            classify_daemon_attempt(Ok(std::path::PathBuf::from("/tmp/some-mount")), "thread-x");
        assert!(matches!(resolution, DaemonAttemptResolution::Daemon));
    }

    /// `Unavailable` is the canonical "host can't run the daemon
    /// right now" signal — falls back silently to in-process. The
    /// warning emission is best-effort and observed by the
    /// integration test; here we just lock in the dispatch.
    #[test]
    fn classify_daemon_attempt_unavailable_falls_back_to_in_process() {
        use crate::cli::commands::daemon_client::DaemonMountError;
        let resolution = classify_daemon_attempt(
            Err(DaemonMountError::Unavailable(
                "could not start daemon: exec failed".to_string(),
            )),
            "thread-y",
        );
        assert!(matches!(
            resolution,
            DaemonAttemptResolution::FallbackInProcess
        ));
    }

    /// `Fatal` errors must surface — the in-process fallback would
    /// either hide a real conflict or mask a daemon-side bug.
    #[test]
    fn classify_daemon_attempt_fatal_does_not_fall_back() {
        use crate::cli::commands::daemon_client::DaemonMountError;
        let resolution = classify_daemon_attempt(
            Err(DaemonMountError::Fatal(anyhow!(
                "daemon mount failed: [mount_conflict] thread X is already mounted at Y"
            ))),
            "thread-z",
        );
        match resolution {
            DaemonAttemptResolution::Fatal(err) => {
                assert!(
                    err.to_string().contains("mount_conflict"),
                    "fatal error should preserve the daemon-reported code, got {err:?}"
                );
            }
            other => {
                panic!("expected Fatal, got {other:?} — fallback would hide the real conflict")
            }
        }
    }

    /// Sanity: the warning's "Pass --no-daemon to suppress this
    /// warning." hint is in the source so an operator who greps the
    /// codebase finds the dispatch site. Regression catch for
    /// accidental rewordings that would break the contract with
    /// users who learn the suppression flag from the warning.
    #[test]
    fn fallback_warning_text_mentions_no_daemon_suppression() {
        // We pass an Unavailable in to trigger the warn path, then
        // re-read the source line via include_str! to assert the
        // exact string is present. include_str! is preferred over
        // a tracing-subscriber capture here: zero deps, runs on
        // every host, and locks in the user-visible message
        // verbatim.
        let source = include_str!("mount_lifecycle.rs");
        assert!(
            source.contains("Pass --no-daemon to suppress this warning."),
            "warning hint must be present verbatim so users learn the opt-out flag"
        );
    }
}