From 46192c4acfd1f36bfee8a5d402b380a69dac09fd Mon Sep 17 00:00:00 2001 From: Xiaoyang Han Date: Wed, 22 Jul 2026 13:50:45 +0800 Subject: [PATCH 1/9] fix Smart HTTP push framing --- src/internal/protocol/https_client.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/internal/protocol/https_client.rs b/src/internal/protocol/https_client.rs index 54f29c4b8..4b3287173 100644 --- a/src/internal/protocol/https_client.rs +++ b/src/internal/protocol/https_client.rs @@ -4,7 +4,7 @@ use std::{io::Error as IoError, ops::Deref, sync::Mutex, time::Duration}; use futures_util::{StreamExt, TryStreamExt}; use git_internal::errors::GitError; -use reqwest::{Body, RequestBuilder, Response, StatusCode, header::CONTENT_TYPE}; +use reqwest::{RequestBuilder, Response, StatusCode, header::CONTENT_TYPE}; use url::Url; use super::{ @@ -464,10 +464,7 @@ impl HttpsClient { Ok(result) } - pub async fn send_pack + Clone>( - &self, - data: T, - ) -> Result { + pub async fn send_pack(&self, data: bytes::Bytes) -> Result { // INVARIANT: "git-receive-pack" is a valid relative URL onto self.url. let receive_pack_url = self .url @@ -477,6 +474,12 @@ impl HttpsClient { self.client .post(receive_pack_url.clone()) .header(CONTENT_TYPE, "application/x-git-receive-pack-request") + .header( + reqwest::header::ACCEPT, + "application/x-git-receive-pack-result", + ) + .header(reqwest::header::USER_AGENT, "git/2.43.0 libra") + .header(reqwest::header::CONTENT_LENGTH, data.len()) .body(data.clone()) }) .await From 10bbb81bfb3b82b076e01634c81642bbdd939c0b Mon Sep 17 00:00:00 2001 From: Xiaoyang Han Date: Wed, 29 Jul 2026 10:26:29 +0800 Subject: [PATCH 2/9] feat(worktree): add backend-neutral core --- .../integration/worktree-storage-backends.md | 214 ++++++++++ src/internal/mod.rs | 1 + src/internal/worktree_backend.rs | 385 ++++++++++++++++++ src/utils/util.rs | 137 +++++++ 4 files changed, 737 insertions(+) create mode 100644 docs/development/integration/worktree-storage-backends.md create mode 100644 src/internal/worktree_backend.rs diff --git a/docs/development/integration/worktree-storage-backends.md b/docs/development/integration/worktree-storage-backends.md new file mode 100644 index 000000000..8d1819963 --- /dev/null +++ b/docs/development/integration/worktree-storage-backends.md @@ -0,0 +1,214 @@ +# Worktree storage backend architecture + +Status: backend-neutral substrate implemented; ScorpioFS adapter implemented; +BrewFS SDK runtime boundary implemented + +## Purpose + +Libra supports worktrees whose POSIX files may come from different storage +systems. The Git model must remain identical across local directories, +repository-aware lazy projections such as ScorpioFS, and persistent distributed +volumes such as BrewFS. + +The architecture separates three planes: + +```text +Git plane + Libra refs, index, objects, commits, fetch, and push + +Control plane + Libra worktree coordinator, desired state, locks, worker supervision, + backend capability negotiation, recovery, and cleanup + +Data plane + Local directory, ScorpioFS FUSE mount, BrewFS FUSE mount, or a future + POSIX-visible backend +``` + +Libra owns the first two planes. A backend driver owns only its data-plane +session. + +## Core contract + +`internal::worktree_backend` defines: + +- `BackendKind`; +- `BackendCapabilities`; +- `BackendMountSource`; +- `BackendMountRequest`; +- `BackendMountSession`; +- `BackendHealth`; +- `BackendLifecycle`; +- `WorktreeBackendDriver`; +- `BackendRegistry`. + +The driver contract contains lifecycle operations rather than a duplicate +filesystem API: + +```text +mount +health +changed_paths (optional) +flush (optional) +unmount +recover +``` + +Build tools, editors, and ordinary Libra commands access the mounted POSIX +path. They do not call a backend SDK for individual reads and writes. + +## Capability model + +| Capability | Local | ScorpioFS | BrewFS | +|---|---:|---:|---:| +| POSIX worktree | yes | yes | yes | +| Revision projection | no | yes | no | +| Native changed paths | no | yes | no | +| Persistent volume | no | no | yes | +| Multi-client storage | no | no | yes | +| Flush before commit | no | no | yes | + +Command code must branch on capabilities, not concrete backend names. + +## Backend source types + +The generic mount request distinguishes: + +```text +local_directory +remote_projection +persistent_volume +``` + +ScorpioFS accepts `remote_projection`, including a monorepo path, base object +ID, and optional change layer. + +BrewFS accepts `persistent_volume`, including a volume and optional subpath. +BrewFS does not inherently project a Mega commit. Libra must populate or import +the selected Git tree before treating a new BrewFS volume as a worktree. + +## Process model + +The target process model is: + +```text +libra CLI + -> Libra worktree supervisor + -> ScorpioFS worker linked to the ScorpioFS crate + -> BrewFS worker linked to the BrewFS crate +``` + +FUSE sessions outlive an individual CLI invocation. Backend workers also +isolate filesystem crashes and dependency runtimes from the Git command +process. Unix domain sockets should replace loopback HTTP as the default local +control transport; the current ScorpioFS loopback protocol remains a +compatibility transport during migration. + +## Persistent layout + +Libra metadata remains on a host-local filesystem: + +```text +/.libra/ + objects/ + refs/ + worktrees/ + backends/ + desired-state.json + state.lock +``` + +Backend caches and runtime state remain separate: + +```text +~/.cache/libra/backends/scorpiofs// +~/.cache/libra/backends/brewfs// +/run/user//libra/ +``` + +`.libra` must not be stored in a ScorpioFS upper layer or a BrewFS volume. A +mounted worktree contains only a reconstructable `.libra` pointer to its +host-local per-worktree gitdir. + +## ScorpioFS adapter + +`ScorpioFsDriver` implements `WorktreeBackendDriver` by translating generic +remote-projection requests into Antares mount requests. It exposes native +changed-path candidates and idempotent cleanup by job ID. + +The existing ScorpioFS command and state files remain compatible while command +orchestration is incrementally moved onto the generic driver. + +## BrewFS SDK boundary + +`BrewFsDriver` accepts a `BrewFsRuntime`. The runtime is responsible for: + +- constructing BrewFS metadata and object backends from named profiles; +- retaining the BrewFS SDK client and FUSE handle; +- mounting a persistent volume; +- reporting health; +- draining writes before Git commit publication; +- unmounting the session. + +Configuration stores profile names, not credentials: + +```toml +[backends.brewfs.team] +volume = "team-workspace" +mount_root = "/home/alice/libra-workspaces" +metadata_profile = "production-redis" +data_profile = "production-s3" +``` + +BrewFS 0.1.2 exports filesystem clients but keeps the complete mount assembly +used by its binary private. Libra therefore does not claim direct embedded +mount support until BrewFS exports a stable mount builder/session API. The +runtime trait is the integration seam for that API. + +The minimum upstream SDK shape Libra needs is: + +```rust +pub struct MountBuilder { /* metadata, object, cache, and FUSE options */ } + +impl MountBuilder { + pub async fn mount(self, mountpoint: &Path) -> Result; +} + +pub struct MountedFs { /* SDK client and FUSE handle */ } + +impl MountedFs { + pub fn client(&self) -> &brewfs::Client; + pub async fn health(&self) -> Result; + pub async fn flush(&self) -> Result<()>; + pub async fn unmount(self) -> Result<()>; +} +``` + +The handle must retain all background workers and expose bounded graceful +shutdown. Configuration construction must accept credential references or +preconstructed backends so Libra never serializes secrets into `.libra`. + +## Commit durability + +For a backend with `flush_before_commit`, Libra must: + +1. finish index updates; +2. request backend flush; +3. wait for durable completion or fail the commit; +4. construct and publish the Git commit; +5. update refs and reflogs. + +This ordering prevents a commit from naming worktree content that remains only +in an unflushed client buffer. + +## Migration + +1. Keep existing `worktree scorpiofs attach/detach` behavior. +2. Route ScorpioFS changed-path discovery through `ScorpioFsDriver`. +3. Move attach, health, recovery, and detach orchestration to the generic + driver. +4. Introduce a generic `worktree create --backend` command. +5. Add the BrewFS crate after its stable mount session API is available. +6. Implement a BrewFS SDK runtime and persistent-volume checkout/import flow. +7. Migrate legacy `.libra/scorpiofs/state.json` into versioned backend-neutral + desired state. diff --git a/src/internal/mod.rs b/src/internal/mod.rs index 328cb9fa4..8f3b79825 100644 --- a/src/internal/mod.rs +++ b/src/internal/mod.rs @@ -52,4 +52,5 @@ pub mod tree_plumbing; pub mod tui; pub mod upgrade; pub mod vault; +pub mod worktree_backend; pub mod worktree_scope; diff --git a/src/internal/worktree_backend.rs b/src/internal/worktree_backend.rs new file mode 100644 index 000000000..3a6d0b707 --- /dev/null +++ b/src/internal/worktree_backend.rs @@ -0,0 +1,385 @@ +//! Backend-neutral contracts for mounted Libra worktrees. +//! +//! Libra owns Git semantics and durable desired state. A backend driver only +//! prepares a POSIX-visible worktree, reports health and optional changed-path +//! candidates, flushes backend data when required, and tears the worktree down. +//! The contract deliberately does not mirror POSIX operations: filesystem +//! crates already provide those APIs, while build tools consume their mounts. + +use std::path::PathBuf; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub const BACKEND_CONTROL_PROTOCOL_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BackendKind { + Local, + ScorpioFs, + BrewFs, +} + +impl BackendKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::Local => "local", + Self::ScorpioFs => "scorpiofs", + Self::BrewFs => "brewfs", + } + } +} + +impl std::fmt::Display for BackendKind { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackendCapabilities { + pub posix_mount: bool, + pub revision_projection: bool, + pub native_change_detection: bool, + pub persistent_volume: bool, + pub multi_client: bool, + pub flush_before_commit: bool, +} + +impl BackendCapabilities { + pub const fn local() -> Self { + Self { + posix_mount: true, + revision_projection: false, + native_change_detection: false, + persistent_volume: false, + multi_client: false, + flush_before_commit: false, + } + } + + pub const fn scorpiofs() -> Self { + Self { + posix_mount: true, + revision_projection: true, + native_change_detection: true, + persistent_volume: false, + multi_client: false, + flush_before_commit: false, + } + } + + pub const fn brewfs() -> Self { + Self { + posix_mount: true, + revision_projection: false, + native_change_detection: false, + persistent_volume: true, + multi_client: true, + flush_before_commit: true, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BackendDescriptor { + pub kind: BackendKind, + pub display_name: &'static str, + pub protocol_version: u32, + pub capabilities: BackendCapabilities, + pub available: bool, + pub unavailable_reason: Option<&'static str>, +} + +impl BackendDescriptor { + pub const fn local() -> Self { + Self { + kind: BackendKind::Local, + display_name: "Local filesystem", + protocol_version: BACKEND_CONTROL_PROTOCOL_VERSION, + capabilities: BackendCapabilities::local(), + available: true, + unavailable_reason: None, + } + } + + pub const fn scorpiofs(available: bool) -> Self { + Self { + kind: BackendKind::ScorpioFs, + display_name: "ScorpioFS remote projection", + protocol_version: BACKEND_CONTROL_PROTOCOL_VERSION, + capabilities: BackendCapabilities::scorpiofs(), + available, + unavailable_reason: if available { + None + } else { + Some("requires Linux and the scorpiofs-direct feature") + }, + } + } + + pub const fn brewfs(available: bool) -> Self { + Self { + kind: BackendKind::BrewFs, + display_name: "BrewFS persistent volume", + protocol_version: BACKEND_CONTROL_PROTOCOL_VERSION, + capabilities: BackendCapabilities::brewfs(), + available, + unavailable_reason: if available { + None + } else { + Some("requires a BrewFS SDK runtime implementation") + }, + } + } +} + +pub struct BackendRegistry; + +impl BackendRegistry { + pub fn builtins() -> Vec { + vec![ + BackendDescriptor::local(), + BackendDescriptor::scorpiofs(cfg!(all( + target_os = "linux", + feature = "scorpiofs-direct" + ))), + // The driver boundary is implemented, but BrewFS 0.1.2 does not + // yet export the complete mount constructor used by its binary. + BackendDescriptor::brewfs(false), + ] + } + + pub fn descriptor(kind: BackendKind) -> Option { + Self::builtins() + .into_iter() + .find(|descriptor| descriptor.kind == kind) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum BackendMountSource { + LocalDirectory { + path: PathBuf, + }, + RemoteProjection { + remote_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + base_oid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + change_layer: Option, + }, + PersistentVolume { + volume: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + subpath: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackendMountRequest { + pub instance_id: String, + pub worktree_id: String, + pub source: BackendMountSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mountpoint_hint: Option, + #[serde(default = "default_ready_timeout_secs")] + pub ready_timeout_secs: u64, +} + +fn default_ready_timeout_secs() -> u64 { + 120 +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackendMountSession { + pub backend: BackendKind, + pub session_id: String, + pub mountpoint: PathBuf, + pub cleanup_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_oid: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackendHealth { + pub ready: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BackendLifecycle { + Detached, + Mounting, + Ready, + SwitchingBase, + Unmounting, + RecoverableError, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChangeKind { + Added, + Modified, + Deleted, + Renamed, + ModeChanged, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChangedPath { + pub kind: ChangeKind, + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_path: Option, +} + +impl ChangedPath { + pub fn validate(&self) -> Result<(), WorktreeBackendError> { + validate_relative_path(&self.path)?; + if let Some(source_path) = self.source_path.as_deref() { + validate_relative_path(source_path)?; + } + if matches!(self.kind, ChangeKind::Renamed) && self.source_path.is_none() { + return Err(WorktreeBackendError::InvalidChangedPath( + "a renamed path must include source_path".to_string(), + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChangeSet { + pub mount_id: String, + pub generation: u64, + #[serde(default)] + pub changes: Vec, +} + +impl ChangeSet { + pub fn validate(&self) -> Result<(), WorktreeBackendError> { + if self.mount_id.trim().is_empty() { + return Err(WorktreeBackendError::InvalidRequest( + "change set mount_id cannot be empty".to_string(), + )); + } + for change in &self.changes { + change.validate()?; + } + Ok(()) + } + + pub fn candidate_paths(&self) -> Vec { + let mut paths = Vec::with_capacity(self.changes.len() * 2); + for change in &self.changes { + paths.push(PathBuf::from(&change.path)); + if let Some(source_path) = change.source_path.as_deref() { + paths.push(PathBuf::from(source_path)); + } + } + paths.sort(); + paths.dedup(); + paths + } +} + +#[derive(Debug, Error)] +pub enum WorktreeBackendError { + #[error("invalid worktree backend request: {0}")] + InvalidRequest(String), + #[error("backend '{backend}' does not support source type '{source}'")] + UnsupportedSource { + backend: BackendKind, + source: &'static str, + }, + #[error("worktree backend '{backend}' is unavailable: {reason}")] + Unavailable { + backend: BackendKind, + reason: String, + }, + #[error("worktree backend '{backend}' operation '{operation}' failed: {source}")] + Operation { + backend: BackendKind, + operation: &'static str, + #[source] + source: anyhow::Error, + }, + #[error("invalid backend changed path: {0}")] + InvalidChangedPath(String), +} + +impl WorktreeBackendError { + pub fn operation( + backend: BackendKind, + operation: &'static str, + source: impl Into, + ) -> Self { + Self::Operation { + backend, + operation, + source: source.into(), + } + } +} + +#[async_trait] +pub trait WorktreeBackendDriver: Send + Sync { + fn descriptor(&self) -> BackendDescriptor; + + async fn mount( + &self, + request: &BackendMountRequest, + ) -> Result; + + async fn health( + &self, + session: &BackendMountSession, + ) -> Result; + + async fn changed_paths( + &self, + _session: &BackendMountSession, + ) -> Result, WorktreeBackendError> { + Ok(None) + } + + async fn flush( + &self, + _session: &BackendMountSession, + ) -> Result<(), WorktreeBackendError> { + Ok(()) + } + + async fn unmount( + &self, + session: &BackendMountSession, + ) -> Result<(), WorktreeBackendError>; + + async fn recover( + &self, + request: &BackendMountRequest, + ) -> Result { + self.mount(request).await + } +} + +fn validate_relative_path(path: &str) -> Result<(), WorktreeBackendError> { + if path.is_empty() || path.starts_with('/') || path.contains('\0') { + return Err(WorktreeBackendError::InvalidChangedPath( + "path must be a non-empty relative path".to_string(), + )); + } + if path.split('/').any(|part| matches!(part, "" | "." | "..")) { + return Err(WorktreeBackendError::InvalidChangedPath( + "path must be normalized and must not contain traversal".to_string(), + )); + } + Ok(()) +} diff --git a/src/utils/util.rs b/src/utils/util.rs index 783ccd840..49f4ebe7d 100644 --- a/src/utils/util.rs +++ b/src/utils/util.rs @@ -156,6 +156,75 @@ fn read_gitdir_file(path: &Path, worktree: &Path) -> Option { }) } +/// Resolve a Libra linked-worktree pointer file. +/// +/// External worktree backends such as ScorpioFS cannot keep their private +/// index and HEAD inside an ephemeral mount. They place a regular `.libra` +/// file in the mounted worktree containing `gitdir: `, while the real +/// worktree gitdir remains under the main repository's persistent storage. +/// +/// Unlike the best-effort Git repository discovery helper above, Libra +/// repository discovery is fail-closed: a present but malformed pointer is a +/// corrupt repository and must not be silently ignored. +fn read_libra_gitdir_file(path: &Path, worktree: &Path) -> io::Result { + let contents = fs::read_to_string(path).map_err(|error| { + io::Error::new( + error.kind(), + format!( + "cannot read Libra worktree pointer '{}': {error}", + path.display() + ), + ) + })?; + let line = contents.lines().next().map(str::trim).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Libra worktree pointer '{}' is empty", path.display()), + ) + })?; + let raw = line + .strip_prefix("gitdir:") + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Libra worktree pointer '{}' must contain 'gitdir: '", + path.display() + ), + ) + })?; + + let configured = Path::new(raw); + let resolved = if configured.is_absolute() { + configured.to_path_buf() + } else { + worktree.join(configured) + }; + let resolved = fs::canonicalize(&resolved).map_err(|error| { + io::Error::new( + error.kind(), + format!( + "Libra worktree pointer '{}' targets unavailable gitdir '{}': {error}", + path.display(), + resolved.display() + ), + ) + })?; + if !resolved.is_dir() || !is_valid_storage_dir(&resolved) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Libra worktree pointer '{}' targets invalid gitdir '{}'", + path.display(), + resolved.display() + ), + )); + } + Ok(resolved) +} + fn resolve_dot_git_dir(worktree: &Path) -> Option { let dot_git = worktree.join(".git"); let metadata = fs::metadata(&dot_git).ok()?; @@ -356,6 +425,12 @@ fn try_get_paths_full(path: Option) -> Result<(PathBuf, PathBuf, PathBu return Ok((common, path.clone(), gitdir)); } + if standard_repo.is_file() { + let gitdir = read_libra_gitdir_file(&standard_repo, &path)?; + let common = worktree_common_storage(&gitdir)?; + return Ok((common, path.clone(), gitdir)); + } + if path.join(DATABASE).exists() && path.join("objects").exists() { return Ok((path.clone(), path.clone(), path.clone())); } @@ -2847,6 +2922,68 @@ mod test { assert!(!location.is_bare); } + #[test] + #[serial] + fn test_libra_worktree_pointer_resolves_external_gitdir() { + let temp = tempdir().unwrap(); + let main_storage = temp.path().join("main").join(".libra"); + let external_gitdir = main_storage + .join("worktrees") + .join("scorpiofs") + .join("workspace-1"); + let mounted_worktree = temp.path().join("mount"); + fs::create_dir_all(main_storage.join("objects")).unwrap(); + fs::create_dir_all(main_storage.join("hooks")).unwrap(); + fs::create_dir_all(main_storage.join("info")).unwrap(); + fs::write(main_storage.join(DATABASE), b"repo db").unwrap(); + fs::create_dir_all(&external_gitdir).unwrap(); + fs::write( + external_gitdir.join("commondir"), + format!("{}\n", main_storage.display()), + ) + .unwrap(); + fs::write(external_gitdir.join("worktree_id"), b"workspace-1\n").unwrap(); + fs::create_dir_all(&mounted_worktree).unwrap(); + fs::write( + mounted_worktree.join(ROOT_DIR), + format!("gitdir: {}\n", external_gitdir.display()), + ) + .unwrap(); + + let _guard = test::ChangeDirGuard::new(&mounted_worktree); + + assert_eq!( + try_get_storage_path(None).unwrap(), + main_storage.canonicalize().unwrap() + ); + assert_eq!( + try_get_worktree_gitdir(None).unwrap(), + external_gitdir.canonicalize().unwrap() + ); + assert_eq!(current_worktree_id().as_deref(), Some("workspace-1")); + } + + #[test] + #[serial] + fn test_libra_worktree_pointer_fails_closed_when_target_is_missing() { + let temp = tempdir().unwrap(); + let mounted_worktree = temp.path().join("mount"); + fs::create_dir_all(&mounted_worktree).unwrap(); + fs::write( + mounted_worktree.join(ROOT_DIR), + "gitdir: /definitely/missing/libra-worktree\n", + ) + .unwrap(); + + let _guard = test::ChangeDirGuard::new(&mounted_worktree); + let error = try_get_storage_path(None).expect_err("missing gitdir must fail"); + + assert!( + error.to_string().contains("targets unavailable gitdir"), + "unexpected error: {error}" + ); + } + #[test] fn test_git_info_file_path_uses_common_dir_for_linked_worktree() { let temp = tempdir().unwrap(); From 133fc24f1e7c25f33d99d3ba68ee0876ced084b3 Mon Sep 17 00:00:00 2001 From: Xiaoyang Han Date: Wed, 29 Jul 2026 10:26:30 +0800 Subject: [PATCH 3/9] fix: allow explicit push refspec from detached HEAD --- src/command/push.rs | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/src/command/push.rs b/src/command/push.rs index 31b97b64b..6c0e77eb9 100644 --- a/src/command/push.rs +++ b/src/command/push.rs @@ -785,12 +785,16 @@ fn validate_push_args(args: &PushArgs) -> Result<(), PushError> { Ok(()) } -async fn validate_local_refspecs(args: &PushArgs, current_branch: &str) -> Result<(), PushError> { +async fn validate_local_refspecs( + args: &PushArgs, + current_branch: Option<&str>, +) -> Result<(), PushError> { if args.mirror { return Ok(()); } if args.refspecs.is_empty() && !args.tags { + let current_branch = current_branch.ok_or(PushError::DetachedHead)?; resolve_local_ref(current_branch).await?; } @@ -819,13 +823,14 @@ pub async fn run_push(args: PushArgs, output: &OutputConfig) -> Result name, - Head::Detached(_) => return Err(PushError::DetachedHead), + Head::Branch(name) => Some(name), + Head::Detached(_) => None, }; let repository = match args.repository.clone() { Some(repo) => repo, None => { - let remote = ConfigKv::get_remote(¤t_branch).await.ok().flatten(); + let current_branch = current_branch.as_deref().ok_or(PushError::DetachedHead)?; + let remote = ConfigKv::get_remote(current_branch).await.ok().flatten(); match remote { Some(remote) => remote, None => return Err(PushError::NoRemoteConfigured), @@ -850,7 +855,7 @@ pub async fn run_push(args: PushArgs, output: &OutputConfig) -> Result Result Date: Wed, 29 Jul 2026 10:26:52 +0800 Subject: [PATCH 4/9] feat(worktree): add ScorpioFS backend --- Cargo.lock | 483 ++++++- Cargo.toml | 5 +- docs/commands/worktree.md | 38 + .../integration/scorpiofs-worktree-backend.md | 557 ++++++++ .../integration/worktree-storage-backends.md | 30 + src/cli.rs | 5 + src/command/add.rs | 28 +- src/command/mod.rs | 1 + src/command/scorpiofs_worker.rs | 73 ++ src/command/status.rs | 44 +- src/command/status_untracked.rs | 80 +- src/command/worktree-fuse.rs | 14 + src/command/worktree.rs | 978 +++++++++++++- src/internal/mod.rs | 1 + src/internal/scorpiofs_backend.rs | 1162 +++++++++++++++++ src/internal/worktree_backend.rs | 14 +- tests/command/worktree_test.rs | 140 ++ 17 files changed, 3603 insertions(+), 50 deletions(-) create mode 100644 docs/development/integration/scorpiofs-worktree-backend.md create mode 100644 src/command/scorpiofs_worker.rs create mode 100644 src/internal/scorpiofs_backend.rs diff --git a/Cargo.lock b/Cargo.lock index e4fa48c67..bc77de704 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -301,6 +301,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -334,6 +345,30 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "asyncfuse" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73944bd789372ebf1f10a25ee59296bbb437b31dbe234dcd959e98a1f3238fac" +dependencies = [ + "aligned_box", + "async-notify", + "async-trait", + "bincode 1.3.3", + "bytes", + "dashmap", + "futures-channel", + "futures-util", + "libc", + "nix 0.29.0", + "serde", + "slab", + "tokio", + "tracing", + "trait-make", + "which", +] + [[package]] name = "atoi" version = "2.0.0" @@ -521,6 +556,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bindgen" version = "0.72.1" @@ -1324,6 +1379,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1379,7 +1447,7 @@ dependencies = [ "document-features", "futures-core", "mio", - "parking_lot", + "parking_lot 0.12.5", "rustix 1.1.4", "signal-hook", "signal-hook-mio", @@ -1605,7 +1673,7 @@ dependencies = [ "hashbrown 0.14.5", "lock_api", "once_cell", - "parking_lot_core", + "parking_lot_core 0.9.12", ] [[package]] @@ -1645,6 +1713,37 @@ dependencies = [ "zeroize", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "deltae" version = "0.3.2" @@ -2016,6 +2115,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +[[package]] +name = "endian-type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" + [[package]] name = "enum-map" version = "2.7.3" @@ -2036,6 +2141,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2228,6 +2356,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", + "libz-sys", "miniz_oxide", "zlib-rs", ] @@ -2285,6 +2414,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -2347,7 +2486,7 @@ checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" dependencies = [ "futures-core", "lock_api", - "parking_lot", + "parking_lot 0.12.5", ] [[package]] @@ -2402,6 +2541,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "gag" version = "1.0.0" @@ -2483,6 +2631,52 @@ dependencies = [ "polyval", ] +[[package]] +name = "git-internal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3373b0719c11eed1df7d80242354816316b44c7f842f519daf60efacec7a861a" +dependencies = [ + "ahash 0.8.12", + "async-trait", + "axum", + "bincode 2.0.1", + "bstr", + "byteorder", + "bytes", + "chrono", + "colored", + "crc32fast", + "dashmap", + "diffs", + "encoding_rs", + "flate2", + "futures", + "futures-util", + "hex", + "libc", + "lru-mem", + "memchr", + "natord", + "num_cpus", + "path-absolutize", + "rayon", + "sea-orm", + "serde", + "sha1 0.10.6", + "sha2 0.10.9", + "similar 2.7.0", + "tempfile", + "thiserror 2.0.18", + "threadpool", + "tokio", + "tokio-stream", + "tracing", + "tracing-subscriber", + "uuid", + "zstd-sys", +] + [[package]] name = "git-internal" version = "0.8.3" @@ -2519,7 +2713,7 @@ dependencies = [ "serde_json", "sha1 0.11.0", "sha2 0.11.0", - "similar", + "similar 3.1.1", "tempfile", "thiserror 2.0.18", "threadpool", @@ -3174,6 +3368,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3229,6 +3432,42 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "jni" version = "0.21.1" @@ -3392,7 +3631,7 @@ dependencies = [ "memmap2", "moka", "nix 0.29.0", - "radix_trie", + "radix_trie 0.2.1", "reqwest 0.12.28", "rfuse3", "serde", @@ -3405,6 +3644,37 @@ dependencies = [ "vmm-sys-util", ] +[[package]] +name = "libfuse-fs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf5f1ba3c5498a7b893d4398dfd6de0f21d87d16f1385258950d62d5a7806af8" +dependencies = [ + "async-trait", + "asyncfuse", + "bitflags 2.11.0", + "bytes", + "clap", + "futures", + "futures-util", + "itertools 0.14.0", + "libc", + "lru", + "memmap2", + "moka", + "nix 0.29.0", + "radix_trie 0.2.1", + "reqwest 0.12.28", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", + "vm-memory", + "vmm-sys-util", +] + [[package]] name = "libloading" version = "0.8.9" @@ -3447,11 +3717,12 @@ dependencies = [ "dirs", "fastrand", "flate2", + "fs2", "futures", "futures-core", "futures-util", "gag", - "git-internal", + "git-internal 0.8.3", "hex", "http", "hyper-util", @@ -3461,7 +3732,7 @@ dependencies = [ "keyring", "lazy_static", "libc", - "libfuse-fs", + "libfuse-fs 0.1.13", "libvault", "lru-mem", "mime_guess", @@ -3487,6 +3758,7 @@ dependencies = [ "rpassword", "rust-embed", "scopeguard", + "scorpiofs", "sea-orm", "seccompiler", "serde", @@ -3495,7 +3767,7 @@ dependencies = [ "sha1 0.11.0", "sha2 0.10.9", "shlex", - "similar", + "similar 3.1.1", "syn 2.0.117", "tar", "tempfile", @@ -3505,7 +3777,7 @@ dependencies = [ "tokio-stream", "tokio-tungstenite 0.29.0", "tokio-util", - "toml", + "toml 0.8.23", "tower", "tower-http", "tracing", @@ -3587,7 +3859,7 @@ dependencies = [ "pem", "pgp", "priority-queue", - "radix_trie", + "radix_trie 0.2.1", "rand 0.9.2", "rand_chacha 0.3.1", "regex", @@ -3609,7 +3881,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "toml", + "toml 0.8.23", "tonic", "tracing", "ureq 2.12.1", @@ -3619,6 +3891,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "line-clipping" version = "0.3.5" @@ -3820,7 +4103,7 @@ dependencies = [ "equivalent", "event-listener", "futures-util", - "parking_lot", + "parking_lot 0.12.5", "portable-atomic", "smallvec", "tagptr", @@ -4111,7 +4394,7 @@ dependencies = [ "hyper", "itertools 0.14.0", "md-5", - "parking_lot", + "parking_lot 0.12.5", "percent-encoding", "quick-xml", "rand 0.10.0", @@ -4395,6 +4678,17 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -4402,7 +4696,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", - "parking_lot_core", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", ] [[package]] @@ -5151,7 +5459,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" dependencies = [ - "endian-type", + "endian-type 0.1.2", + "nibble_vec", +] + +[[package]] +name = "radix_trie" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" +dependencies = [ + "endian-type 0.2.0", "nibble_vec", ] @@ -5345,6 +5663,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -5561,7 +5888,7 @@ dependencies = [ "aligned_box", "async-notify", "async-trait", - "bincode", + "bincode 1.3.3", "bytes", "dashmap", "futures-channel", @@ -6049,6 +6376,44 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scorpiofs" +version = "0.3.1" +dependencies = [ + "async-recursion", + "async-trait", + "asyncfuse", + "axum", + "bincode 2.0.1", + "bytes", + "clap", + "clap_complete", + "crossbeam", + "dashmap", + "env_logger", + "futures", + "git-internal 0.4.1", + "hex", + "libc", + "libfuse-fs 0.2.0", + "log", + "once_cell", + "radix_trie 0.3.0", + "reqwest 0.13.2", + "ring", + "serde", + "serde_json", + "sled", + "thiserror 2.0.18", + "tokio", + "toml 0.9.12+spec-1.1.0", + "tracing", + "tracing-subscriber", + "url", + "uuid", + "whoami", +] + [[package]] name = "sdd" version = "3.0.10" @@ -6315,6 +6680,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -6411,7 +6785,7 @@ dependencies = [ "futures-executor", "futures-util", "once_cell", - "parking_lot", + "parking_lot 0.12.5", "scc", "serial_test_derive", ] @@ -6582,6 +6956,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "similar" version = "3.1.1" @@ -6603,6 +6983,22 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "sled" +version = "0.34.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935" +dependencies = [ + "crc32fast", + "crossbeam-epoch", + "crossbeam-utils", + "fs2", + "fxhash", + "libc", + "log", + "parking_lot 0.11.2", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -6954,7 +7350,7 @@ dependencies = [ "atomic", "crossbeam-channel", "getrandom 0.2.17", - "parking_lot", + "parking_lot 0.12.5", "rand 0.8.5", "seahash", "thiserror 1.0.69", @@ -7389,7 +7785,7 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", + "parking_lot 0.12.5", "pin-project-lite", "signal-hook-registry", "socket2", @@ -7490,11 +7886,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_edit 0.22.27", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -7504,6 +7915,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_datetime" version = "1.0.0+spec-1.1.0" @@ -7521,7 +7941,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.13.0", "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", "winnow", @@ -7554,6 +7974,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tonic" version = "0.14.5" @@ -7984,6 +8410,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "ureq" version = "2.12.1" @@ -8108,6 +8540,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "vm-memory" version = "0.16.2" @@ -8457,7 +8895,7 @@ checksum = "7aafc5e81e847f05d6770e074faf7b1cd4a5dec9a0e88eac5d55e20fdfebee9a" dependencies = [ "event-listener", "futures-core", - "parking_lot", + "parking_lot 0.12.5", "triomphe", ] @@ -8481,6 +8919,7 @@ checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ "libredox", "wasite", + "web-sys", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b0de0deb6..2f442b91b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,8 @@ categories = ["command-line-utilities", "development-tools"] readme = "README.md" [features] -default = [] +default = ["scorpiofs-direct"] +scorpiofs-direct = ["dep:scorpiofs"] worktree-fuse = [] # Unix FUSE-backed worktree commands (optional) test-network = [] # L2: tests requiring outbound network but no secrets test-live-ai = [] # L3: tests calling real LLM APIs @@ -70,6 +71,7 @@ sea-orm = { version = "1.1.20", features = [ ]} serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +fs2 = "0.4.3" sha1 = "0.11.0" sha2 = "0.10" thiserror = "2.0.18" @@ -158,6 +160,7 @@ rfuse3 = { version = "0.0.8", features = ["tokio-runtime", "unprivileged"] } [target.'cfg(target_os = "linux")'.dependencies] # Linux-only seccomp BPF compiler seccompiler = { version = "0.5.0", features = ["json"] } +scorpiofs = { version = "=0.3.1", optional = true } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61.2", features = ["Win32_Storage_FileSystem"] } diff --git a/docs/commands/worktree.md b/docs/commands/worktree.md index abd383307..23c74219d 100644 --- a/docs/commands/worktree.md +++ b/docs/commands/worktree.md @@ -8,6 +8,8 @@ Manage multiple working trees attached to this repository. ``` libra worktree add +libra worktree scorpiofs attach --remote-path --job-id +libra worktree scorpiofs detach libra worktree list libra worktree lock [--reason ] libra worktree unlock @@ -22,6 +24,12 @@ libra worktree repair `libra worktree` manages multiple working trees that share a single repository database and object store. This allows you to have several checkouts of the same repository simultaneously, which is useful for working on multiple branches at once, running builds while editing code, or testing changes in isolation. +Mounted worktrees use the backend-neutral architecture documented in +[`worktree-storage-backends.md`](../development/integration/worktree-storage-backends.md). +ScorpioFS is a remote-revision projection backend; BrewFS is modeled as a +persistent distributed-volume backend. Git objects, refs, index state, and +authoritative backend lifecycle state remain owned by Libra. + Each linked worktree is a directory containing its own real `.libra` gitdir — a local directory (not a symlink) that holds the worktree's private `HEAD`, index, and `HEAD` reflog, plus a `commondir` pointer to the shared storage and a stable `worktree_id`. The main worktree is the original repository directory. All worktrees share the same SQLite database, object store, branch/tag/remote refs, and configuration, but each keeps its own checked-out branch and staging state. (A worktree created by an older Libra version may still use the legacy shared-`.libra` symlink layout; run `libra worktree repair` to check.) Worktree metadata is persisted in a `worktrees.json` file inside the `.libra` storage directory. Each entry tracks the filesystem path, whether it is the main worktree, its lock status, and an optional lock reason. The state file is written atomically via a temporary file rename to prevent corruption. @@ -47,6 +55,36 @@ libra --json worktree add ../my-feature libra worktree add /tmp/libra-test ``` +### Subcommand: `scorpiofs attach` + +Creates or recovers an idempotent Antares mount, waits for readiness, and +attaches persistent Libra linked-worktree metadata to the returned mountpoint. +By default on Linux, Libra starts a resident worker that links the ScorpioFS +crate directly. Libra owns the worker and desired mount state in +`.libra/scorpiofs/state.json`; ScorpioFS owns only live filesystem +materialization. Libra also continues to own the index, objects, commits, +refs, fetch, and push. + +```bash +libra worktree scorpiofs attach \ + --config-path scorpio.toml \ + --remote-path /project/aardvark-dns \ + --job-id dev-aardvark +``` + +Pass `--endpoint http://127.0.0.1:2725/antares` to use an externally managed +ScorpioFS daemon as a compatibility transport instead. + +### Subcommand: `scorpiofs detach` + +Refuses to detach a dirty worktree, removes its persistent linked-worktree +metadata, and asks Antares to delete the mount by job ID. Repeated remote +cleanup is idempotent. + +```bash +libra worktree scorpiofs detach /var/lib/antares/mounts/ +``` + ### Subcommand: `list` List all registered worktrees and their state. `--porcelain` emits a stable, diff --git a/docs/development/integration/scorpiofs-worktree-backend.md b/docs/development/integration/scorpiofs-worktree-backend.md new file mode 100644 index 000000000..07d29d79d --- /dev/null +++ b/docs/development/integration/scorpiofs-worktree-backend.md @@ -0,0 +1,557 @@ +# ScorpioFS remote worktree backend + +Status: MVP implemented and validated against a real Mega-backed FUSE mount + +This document describes the ScorpioFS-specific adapter. The backend-neutral +contracts, BrewFS extension point, storage ownership, and process model are +defined in +[`worktree-storage-backends.md`](worktree-storage-backends.md). + +## Libra-owned state and direct crate execution + +Libra is the authoritative owner of ScorpioFS desired state. It persists the +worker identity and every requested mount under +`.libra/scorpiofs/state.json`, including lifecycle transitions through +`mounting`, `ready`, `unmounting`, and `recoverable_error`. + +On Linux, `worktree scorpiofs attach` starts or reuses a hidden +`libra scorpiofs-worker` process by default. That worker links the `scorpiofs` +crate directly and keeps FUSE sessions alive after the invoking CLI process +exits. ScorpioFS runs with external state ownership, so its in-memory mount +registry is an execution cache only: it must not persist, recover, or decide +the desired mount set. + +The HTTP transport remains available only as an explicit compatibility mode: + +```text +libra worktree scorpiofs attach \ + --endpoint http://127.0.0.1:2725/antares \ + --remote-path /project/aardvark-dns \ + --job-id aardvark-dns +``` + +Without `--endpoint`, Libra starts the crate-backed worker using +`--config-path scorpio.toml`. When the final Libra-owned mount is detached, +Libra asks its worker to shut down gracefully. + +## Summary + +Libra integrates ScorpioFS as a remote-projection worktree backend. Libra +remains the only owner of version-control semantics and desired lifecycle +state. ScorpioFS owns remote file materialization, FUSE execution, changelist +layers, writable upper layers, and live mount sessions. + +The integration must not: + +- reimplement Git object, index, ref, merge, or transport logic in ScorpioFS; +- link the complete Libra application into ScorpioFS; +- mount Libra's FUSE worktree on top of a ScorpioFS FUSE mount; +- store the persistent Libra repository database inside an ephemeral Antares + upper layer; +- expose arbitrary Libra command execution through ScorpioFS's unauthenticated + HTTP API. + +## Ownership + +### Libra owns + +- repository identity; +- common object storage; +- the index; +- HEAD, branches, refs, and reflogs; +- commit and tree construction; +- status, diff, restore, checkout, merge, rebase, and stash semantics; +- remotes, credentials, fetch, pull, and push; +- hooks and signing; +- Mega single-commit push preflight. + +### ScorpioFS owns + +- the Mega-backed read-only base filesystem; +- lazy tree and blob materialization; +- optional changelist layers; +- per-mount writable upper layers; +- FUSE inode and file-handle lifecycle; +- mount creation, readiness, recovery, and deletion; +- efficient reporting of paths changed in the writable view; +- switching a mount to a different immutable base snapshot. + +### The integration layer owns + +- mapping a Libra linked worktree to a ScorpioFS mount; +- the local protocol and capability negotiation; +- lifecycle and operation locks; +- recreating the `.libra` worktree pointer after remount; +- coordinating base-snapshot changes for pull, switch, and reset; +- converting service errors into stable Libra errors. + +## Why the integration belongs in Libra + +Libra already exposes a library entry point and implements the full VCS command +surface. It also has linked-worktree scoping for local HEAD, index, FETCH_HEAD, +sequencer, rebase, and advisory state. + +ScorpioFS already exposes an Antares control plane and an isolated userspace +overlay. Making ScorpioFS a Libra worktree backend therefore adds one adapter +instead of duplicating VCS behavior. + +Libra's optional `worktree-fuse` feature is not used for this backend. That +feature creates a local overlay from a local lower directory. A ScorpioFS +backend is already a mounted remote overlay; nesting the two introduces +duplicate mount ownership, cleanup ambiguity, and unnecessary filesystem +overhead. + +## Storage layout + +Persistent Libra state lives outside the ScorpioFS mount: + +```text +/.libra/ +├── libra.db +├── objects/ +├── refs/ +└── worktrees/ + └── scorpiofs/ + └── / + ├── commondir + ├── worktree_id + ├── index + ├── HEAD + ├── FETCH_HEAD + └── backend.json +``` + +The mounted worktree contains only a reconstructable `.libra` gitdir pointer: + +```text +/.libra +``` + +The pointer resolves to the persistent worktree gitdir. It may be recreated +after every mount without changing repository history or worktree identity. + +`backend.json` contains no credentials: + +```json +{ + "schema_version": 1, + "backend": "scorpiofs", + "endpoint": "unix:///run/scorpiofs/control.sock", + "mount_id": "1a78c97f-68b7-4873-bfe2-2d67f3768b23", + "job_id": "build-123", + "remote_path": "/project/aardvark-dns", + "base_oid": "0123456789abcdef", + "cl": "1XFJ4PGK" +} +``` + +The canonical identity is a stable Libra `worktree_id`, not the transient +ScorpioFS `mount_id`. + +## User-facing command model + +The backend is managed through Libra: + +```text +libra worktree scorpiofs attach \ + --endpoint http://127.0.0.1:2725/antares \ + --remote-path /project/aardvark-dns \ + --job-id dev-aardvark + +libra worktree scorpiofs detach + +libra worktree list +libra worktree repair +libra worktree remove +``` + +After the worktree is ready, ordinary Libra commands run inside it: + +```text +libra status +libra add . +libra commit -m "..." +libra fetch origin +libra push --dry-run origin main:main +``` + +Backend-specific options belong to `worktree scorpiofs attach`; normal VCS +commands must not grow ScorpioFS-specific flags. + +An attached ScorpioFS worktree uses a private detached HEAD. Pushes from it +must therefore name both the remote and an explicit source/destination +refspec, such as `main:main`. A default push that needs Libra to infer the +current branch remains rejected. The `--dry-run` form validates Mega discovery +and the update plan without mutating the remote. + +The existing local-copy and optional local-FUSE worktree backends remain +compatible. A serialized worktree record gains a backward-compatible backend +descriptor whose default is `local`. + +## Control protocol + +### Transport + +Production integration uses a local Unix domain socket. A loopback HTTP endpoint +may be supported for development, but must require an explicit opt-in and must +not accept credentials or arbitrary commands. + +The first implementation may use the existing Antares loopback HTTP API behind +the backend client. The public Rust interface must hide the transport so it can +move to the Unix socket without changing command code. + +### Version negotiation + +Every client begins with: + +```json +{ + "protocol_version": 1, + "client": "libra", + "client_version": "0.19.40" +} +``` + +The service responds with: + +```json +{ + "protocol_version": 1, + "service": "scorpiofs", + "capabilities": [ + "mount.v1", + "ready.v1", + "changes.v1", + "base-snapshot.v1" + ] +} +``` + +Libra must fail closed when a required capability is unavailable. Optional +capabilities may select a documented slower fallback. + +### Mount request + +```json +{ + "job_id": "dev-aardvark", + "path": "/project/aardvark-dns", + "cl": null, + "base_oid": "0123456789abcdef" +} +``` + +Response: + +```json +{ + "mount_id": "1a78c97f-68b7-4873-bfe2-2d67f3768b23", + "mountpoint": "/var/lib/scorpiofs/antares/mnt/1a78c97f", + "base_oid": "0123456789abcdef", + "ready": false +} +``` + +Create-by-`job_id` remains idempotent. + +### Changed-path request + +Libra must not recursively scan the full remote monorepo for `status` or +`add .`. ScorpioFS reports candidate paths from the CL and writable upper +layers: + +```json +{ + "mount_id": "1a78c97f-68b7-4873-bfe2-2d67f3768b23", + "generation": 42, + "changes": [ + { "kind": "modified", "path": "src/lib.rs" }, + { "kind": "added", "path": "notes.txt" }, + { "kind": "deleted", "path": "src/old.rs" }, + { + "kind": "renamed", + "path": "src/new.rs", + "source_path": "src/previous.rs" + } + ] +} +``` + +This is a candidate set, not authoritative Git status. Libra still applies +ignore rules, pathspecs, index comparison, content hashing, rename policy, and +Git-compatible output. + +If `changes.v1` is unavailable, Libra may scan only the physical writable +layer. It must warn before falling back to a full mounted-tree walk. + +## Worktree creation transaction + +`libra worktree scorpiofs attach` performs: + +1. Validate the current Libra repository and requested remote path. +2. Negotiate backend capabilities. +3. Reserve a stable Libra worktree ID. +4. Create the persistent per-worktree gitdir and `backend.json`. +5. Request or recover the idempotent ScorpioFS mount. +6. Wait for mount readiness with a bounded timeout. +7. Attach the worktree gitdir pointer inside the mount. +8. Seed the worktree index from the selected Libra commit without populating + files. +9. Register the worktree in Libra's common worktree state. +10. Mark the backend record ready. + +Failures roll back in reverse order. A mount that cannot be deleted is recorded +as orphaned and reported with a repair command; it must not be silently +forgotten. + +## Status and detach consistency + +ScorpioFS is the authority for the writable-view candidate set. Libra does not +walk the full mounted monorepo during `status`, `add`, or the dirty-worktree +check that precedes `detach`. It asks `changes.v1` for candidate paths and then +applies normal Libra index, ignore, hashing, and rename rules to only those +paths. + +This rule is also required for correct cleanup. A FUSE mount can contain +implementation-local upper-layer artifacts that are not part of the +ScorpioFS-reported writable view. A raw recursive disk scan could therefore +make an already committed worktree impossible to detach. Detach uses the same +candidate-path collection as `status`; it still refuses to detach when the +service reports staged or unstaged Libra-visible changes. The persistent +`.libra` pointer is metadata, not a user file or staged change. + +## Normal command behavior + +### Status + +1. Resolve the current linked-worktree scope. +2. Load and validate `backend.json`. +3. Ask ScorpioFS for changed-path candidates. +4. Let Libra compare HEAD, index, and mounted file content. +5. Enumerate untracked files from the writable layer, not the remote base. + +### Add + +Libra applies pathspec and ignore semantics, reads selected mounted files, +writes blobs, and updates the worktree-local index. Deleted candidates stage as +deletions. ScorpioFS does not create Git objects. + +### Commit + +Libra builds trees and commits from the index, updates refs and reflogs, runs +hooks, and signs when configured. ScorpioFS is not involved. + +### Fetch + +Libra updates objects and refs. Fetch does not change the mounted base or +writable filesystem. + +### Push + +Libra performs transport, authentication, pack construction, and ref updates. +Mega-specific single-commit policy is checked in Libra before transport. +ScorpioFS does not receive credentials or push data. + +The initial validation uses an explicit refspec and `--dry-run`: + +```text +libra push --dry-run origin main:main +``` + +This proves remote discovery, Smart HTTP planning, detached-worktree refspec +handling, and Mega update preparation without changing the remote. A real +push remains an explicit user action and is subject to Libra's Mega +single-commit preflight. + +## Branch and base-snapshot changes + +There are two implementation stages. + +### Stage A: upper-layer delta + +Checkout, switch, restore, and reset write the difference between the immutable +base tree and target tree into the writable layer. Deletions use the overlay's +supported whiteout representation. + +This provides correctness first but may grow the upper layer after repeated +branch switches. + +### Stage B: transactional base switch + +For clean worktrees, Libra requests a new immutable base snapshot: + +1. Acquire the exclusive VCS and mount lifecycle leases. +2. Verify or stash local changes. +3. Prepare a replacement mount for the target commit. +4. Attach the existing persistent Libra worktree gitdir. +5. Verify the new view and index. +6. Atomically publish the replacement mount. +7. Delete the old mount. + +If publication fails, the old mount remains active. If old-mount cleanup fails, +the operation succeeds with an explicit orphan warning and repair record. + +`pull`, branch `switch`, `checkout`, `reset --hard`, and `rebase` must not update +Libra refs while leaving the user on an unrelated old base view. + +## Locks and lifecycle + +Each backend worktree has: + +- a shared read lease for status, diff, log, and read-only inspection; +- an exclusive VCS lease for add, commit, checkout, merge, rebase, and reset; +- an exclusive lifecycle lease for mount, base switch, repair, and unmount. + +Unmount refuses to race with a VCS operation. Shutdown stops admitting new +operations, waits for bounded graceful completion, persists recovery state, and +then unmounts. + +The backend state machine is: + +```text +Detached + -> Mounting + -> Ready + -> SwitchingBase + -> Ready + -> Unmounting + -> Detached + +Any state may enter RecoverableError. +``` + +## Error contract + +Backend failures map to stable Libra error categories: + +- backend unavailable; +- protocol incompatible; +- mount rejected; +- mount readiness timeout; +- stale mount identity; +- changed-path generation lost; +- base snapshot unavailable; +- worktree busy; +- cleanup incomplete; +- backend state corrupt. + +Messages include the operation, worktree path, job ID, and recovery action. They +must not include tokens, credential-bearing URLs, signing material, or file +contents. + +## Security + +- Prefer a Unix socket owned by the current user or service group. +- Validate that every returned mountpoint is inside the configured ScorpioFS + mount root. +- Validate that every worktree gitdir is inside Libra common storage. +- Never pass credentials on a process command line. +- Do not expose a generic "run Libra command" ScorpioFS endpoint. +- Treat remote paths, CL names, mount IDs, and changed paths as untrusted. +- Reject absolute changed paths and paths containing parent traversal. +- Preserve the unauthenticated Antares API warning until a protected transport + is available. + +## Compatibility + +- Existing Libra repositories and local worktrees default to backend `local`. +- Existing serialized worktree records load without migration. +- `worktree-fuse` remains optional and independent. +- Native Git fallback remains available for explicitly unsupported Libra + behavior. +- Hooks remain under Libra metadata; no synthetic `.git/hooks` directory is + created. +- Advanced commands that are not safe in linked worktrees remain guarded until + their state is worktree-scoped. + +## Implementation phases + +### Phase 1: backend substrate + +- Add versioned backend types and persistent backend records. +- Add a transport-independent ScorpioFS client. +- Add backend-aware worktree registration, list, repair, and removal. +- Use the existing Antares HTTP API for mount, readiness, and delete. + +### Phase 2: core VCS workflow + +- Run status, add, commit, fetch, and push in the attached linked worktree. +- Add Mega single-commit push preflight. +- Add lifecycle locking and recovery tests. + +### Phase 3: changed paths + +- Add `changes.v1` to ScorpioFS. +- Consume candidates in Libra status and add. +- Add generation, overflow, rename, deletion, and ignore tests. + +### Phase 4: mutable worktree operations + +- Validate restore, path checkout, switch, reset, merge, and stash on the + writable overlay. +- Add whiteout and metadata-operation coverage. + +### Phase 5: immutable base snapshots + +- Add commit-addressed bases and transactional base switching to ScorpioFS. +- Integrate pull, branch switching, reset, and rebase. +- Add crash recovery and orphan cleanup. + +### Phase 6: production validation + +- **Validated** mount/open, status, add, commit, fetch, explicit push planning, + and detach against `project/aardvark-dns` on Mega. The test ran in an + isolated Linux user and mount namespace and verified that + `.libra/scorpiofs/state.json` has an empty `mounts` map after detach. +- **Implemented test coverage** includes endpoint validation, state locking, + lifecycle transitions, changed-path validation, attach idempotency, and the + detach regression where an unreported local artifact must not block a clean + ScorpioFS worktree. +- Validate Buck2 builds on the same mount. +- Test restart recovery, concurrent worktrees, cancellation, and cleanup. +- Document native Git fallback and operational diagnostics. + +## Verified command trace + +The end-to-end test executes this sequence against the deployed Mega +subrepository: + +```text +libra clone https://git.rk8s.xuanwu.openatom.cn/project/aardvark-dns control +libra worktree scorpiofs attach --config-path scorpio.toml \ + --remote-path /project/aardvark-dns --job-id +cd +libra status --porcelain +libra fetch origin +libra add libra-scorpiofs-e2e.txt +libra commit -m "test: validate Libra ScorpioFS backend" +libra push --dry-run origin main:main +libra worktree scorpiofs detach +``` + +The test creates only a temporary local commit in the isolated worktree. It +does not publish a remote commit or alter the deployed Mega branch. + +## Deliberate current limits + +- The compatibility HTTP control transport is still supported; a protected + Unix-socket transport is the production target. +- Libra uses ScorpioFS as a POSIX data plane and does not duplicate Git logic + in the filesystem service. +- Automatic transactional base switching, restart recovery, and concurrent + mount stress are not yet validated end-to-end. +- Buck2-on-ScorpioFS validation remains separate work; passing VCS lifecycle + tests is not a Buck2 compatibility guarantee. + +## Acceptance criteria + +The first production-capable milestone is complete when: + +- a ScorpioFS mount attaches as a persistent Libra linked worktree; +- normal Libra `status`, `add`, `commit`, `fetch`, and `push` work inside it; +- unmount/remount preserves Libra metadata and worktree identity; +- status and `add .` do not walk the full remote monorepo; +- no credentials pass through ScorpioFS; +- mount and VCS operations cannot race destructively; +- failures leave a diagnosable and repairable state; +- focused unit tests and a real Mega/FUSE end-to-end test pass. diff --git a/docs/development/integration/worktree-storage-backends.md b/docs/development/integration/worktree-storage-backends.md index 8d1819963..7221ce2e8 100644 --- a/docs/development/integration/worktree-storage-backends.md +++ b/docs/development/integration/worktree-storage-backends.md @@ -3,6 +3,12 @@ Status: backend-neutral substrate implemented; ScorpioFS adapter implemented; BrewFS SDK runtime boundary implemented +Implementation labels in this document: + +- **Implemented**: present in this Libra branch and covered by automated tests. +- **Validated**: exercised against a deployed ScorpioFS and Mega service. +- **Planned**: an architectural direction, not a currently available command. + ## Purpose Libra supports worktrees whose POSIX files may come from different storage @@ -104,6 +110,10 @@ process. Unix domain sockets should replace loopback HTTP as the default local control transport; the current ScorpioFS loopback protocol remains a compatibility transport during migration. +Libra owns worker selection, desired state, and recovery. ScorpioFS owns only +live mount state. The configured endpoint is a control-plane address, never a +Git remote and never a credential container. + ## Persistent layout Libra metadata remains on a host-local filesystem: @@ -130,6 +140,14 @@ Backend caches and runtime state remain separate: mounted worktree contains only a reconstructable `.libra` pointer to its host-local per-worktree gitdir. +### State reconciliation + +The persistent Libra record is authoritative. A live backend mount is an +execution resource that may disappear after a process or machine restart. +Recovery must read and validate host-local Libra state, query the backend by +its durable cleanup key, recreate the pointer only after containment checks, +and record failures as recoverable rather than silently dropping desired state. + ## ScorpioFS adapter `ScorpioFsDriver` implements `WorktreeBackendDriver` by translating generic @@ -212,3 +230,15 @@ in an unflushed client buffer. 6. Implement a BrewFS SDK runtime and persistent-volume checkout/import flow. 7. Migrate legacy `.libra/scorpiofs/state.json` into versioned backend-neutral desired state. + +## Current validation boundary + +The ScorpioFS implementation is validated for an attached remote projection: +`attach`, `status`, `fetch`, `add`, `commit`, explicit-refspec +`push --dry-run`, and `detach`. Validation uses the deployed Mega +`project/aardvark-dns` repository inside an isolated Linux user and mount +namespace. + +This does not claim every mutable Git operation or automatic base switch is +production-ready. Transactional base switching, restart recovery, concurrent +mount stress, and a direct embedded BrewFS SDK mount remain planned work. diff --git a/src/cli.rs b/src/cli.rs index d99282bc2..4ac419097 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -737,6 +737,8 @@ enum Commands { hide = true )] Hooks(command::hooks::HooksArgs), + #[command(about = "Run a Libra-owned ScorpioFS worker", hide = true)] + ScorpiofsWorker(command::scorpiofs_worker::ScorpioFsWorkerArgs), } #[derive(Subcommand, Debug)] @@ -2183,6 +2185,9 @@ async fn parse_async_scoped(argv: Vec) -> CliResult<()> { command::agent::investigate::execute_safe(cmd_args, &output).await? } Commands::Hooks(cmd_args) => command::hooks::execute_safe(cmd_args, &output).await?, + Commands::ScorpiofsWorker(cmd_args) => { + command::scorpiofs_worker::execute_safe(cmd_args).await? + } Commands::Bisect(bisect_cmd) => { command::bisect::execute_safe(bisect_cmd, &output).await? } diff --git a/src/command/add.rs b/src/command/add.rs index f6c73b13a..d99ae09e3 100644 --- a/src/command/add.rs +++ b/src/command/add.rs @@ -534,13 +534,27 @@ pub async fn run_add(args: &AddArgs) -> CliResult { ignore_case, }; - let (mut visible_changes, mut ignored_changes) = if args.force { - status::changes_to_be_staged_split_force_with_ignore_case(ignore_case) - .map_err(|source| AddError::Status { source })? - } else { - status::changes_to_be_staged_split_safe_with_ignore_case(ignore_case) - .map_err(|source| AddError::Status { source })? - }; + let backend_changes = crate::internal::scorpiofs_backend::current_worktree_changes() + .await + .map_err(|error| { + CliError::fatal(format!( + "failed to query ScorpioFS worktree changes: {error}" + )) + })?; + let backend_candidates = backend_changes + .as_ref() + .map(|changes| changes.candidate_paths()); + let (mut visible_changes, mut ignored_changes) = + if let Some(candidates) = backend_candidates.as_deref() { + status::changes_to_be_staged_split_for_paths_with_ignore_case(ignore_case, candidates) + .map_err(|source| AddError::Status { source })? + } else if args.force { + status::changes_to_be_staged_split_force_with_ignore_case(ignore_case) + .map_err(|source| AddError::Status { source })? + } else { + status::changes_to_be_staged_split_safe_with_ignore_case(ignore_case) + .map_err(|source| AddError::Status { source })? + }; if args.force { visible_changes.extend(ignored_changes.clone()); ignored_changes = Changes::default(); diff --git a/src/command/mod.rs b/src/command/mod.rs index 058b96eaa..a6fadd131 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -105,6 +105,7 @@ pub mod rev_parse; pub mod revert; pub mod revision; pub mod sandbox; +pub mod scorpiofs_worker; pub mod service; pub mod shortlog; pub mod show; diff --git a/src/command/scorpiofs_worker.rs b/src/command/scorpiofs_worker.rs new file mode 100644 index 000000000..8651fd67b --- /dev/null +++ b/src/command/scorpiofs_worker.rs @@ -0,0 +1,73 @@ +//! Libra-owned resident ScorpioFS worker. +//! +//! The public Libra CLI is intentionally short-lived, while a FUSE session +//! must remain alive after `worktree scorpiofs attach` returns. Libra therefore +//! starts this hidden worker from its own executable. The worker links the +//! ScorpioFS crate directly and exposes only a loopback control endpoint. +//! Durable desired state remains in Libra; the embedded ScorpioFS service is +//! explicitly configured not to persist or recover state itself. + +use std::{net::SocketAddr, path::PathBuf}; + +use clap::Parser; + +use crate::utils::error::{CliError, CliResult, StableErrorCode}; + +#[derive(Parser, Debug)] +pub struct ScorpioFsWorkerArgs { + #[arg(long)] + pub config_path: PathBuf, + #[arg(long)] + pub bind: SocketAddr, + #[arg(long)] + pub upper_root: PathBuf, + #[arg(long)] + pub cl_root: PathBuf, + #[arg(long)] + pub mount_root: PathBuf, + #[arg(long)] + pub runtime_state_file: PathBuf, +} + +#[cfg(all(target_os = "linux", feature = "scorpiofs-direct"))] +pub async fn execute_safe(args: ScorpioFsWorkerArgs) -> CliResult<()> { + use std::sync::Arc; + + use scorpiofs::{ + cli, + daemon::antares::{AntaresDaemon, AntaresServiceImpl}, + util::config, + }; + + let config_path = args.config_path.to_str().ok_or_else(|| { + CliError::fatal("ScorpioFS config path is not valid UTF-8") + .with_stable_code(StableErrorCode::CliInvalidTarget) + })?; + let overrides = cli::antares_overrides( + Some(args.upper_root), + Some(args.cl_root), + Some(args.mount_root), + Some(args.runtime_state_file), + ); + config::init_config_with(config_path, overrides).map_err(|error| { + CliError::fatal(format!("failed to initialize embedded ScorpioFS: {error}")) + .with_stable_code(StableErrorCode::RepoStateInvalid) + })?; + + let service = Arc::new(AntaresServiceImpl::new_external_state(None).await); + AntaresDaemon::new(service) + .serve(args.bind) + .await + .map_err(|error| { + CliError::fatal(format!("Libra ScorpioFS worker failed: {error}")) + .with_stable_code(StableErrorCode::IoWriteFailed) + }) +} + +#[cfg(not(all(target_os = "linux", feature = "scorpiofs-direct")))] +pub async fn execute_safe(_args: ScorpioFsWorkerArgs) -> CliResult<()> { + Err( + CliError::fatal("the direct ScorpioFS worker requires Linux and scorpiofs-direct") + .with_stable_code(StableErrorCode::Unsupported), + ) +} diff --git a/src/command/status.rs b/src/command/status.rs index 1db2184e0..5397ca805 100644 --- a/src/command/status.rs +++ b/src/command/status.rs @@ -598,11 +598,30 @@ async fn collect_status_data(args: &StatusArgs) -> CliResult { .await .map(|c| c.to_relative()) .map_err(CliError::from)?; - let worktree = status_untracked::collect_status_worktree_changes( - args.untracked_files.unwrap_or(UntrackedFiles::Normal), - args.ignored, - ignore_case, - ) + let backend_changes = crate::internal::scorpiofs_backend::current_worktree_changes() + .await + .map_err(|error| { + CliError::fatal(format!( + "failed to query ScorpioFS worktree changes: {error}" + )) + })?; + let backend_candidates = backend_changes + .as_ref() + .map(|changes| changes.candidate_paths()); + let worktree = if let Some(candidates) = backend_candidates.as_deref() { + status_untracked::collect_status_worktree_changes_for_paths( + args.untracked_files.unwrap_or(UntrackedFiles::Normal), + args.ignored, + ignore_case, + candidates, + ) + } else { + status_untracked::collect_status_worktree_changes( + args.untracked_files.unwrap_or(UntrackedFiles::Normal), + args.ignored, + ignore_case, + ) + } .map_err(CliError::from)?; let mut unstaged = status_untracked::changes_to_current_directory(worktree.unstaged); let unmerged = unmerged::collect(&worktree.index) @@ -3547,6 +3566,21 @@ pub(crate) fn changes_to_be_staged_split_safe_with_ignore_case( changes_to_be_staged_split_with_index(&workdir, &index, ignore_case) } +pub(crate) fn changes_to_be_staged_split_for_paths_with_ignore_case( + ignore_case: bool, + candidates: &[PathBuf], +) -> Result<(Changes, Changes), StatusError> { + let collected = status_untracked::collect_status_worktree_changes_for_paths( + UntrackedFiles::All, + true, + ignore_case, + candidates, + )?; + let mut ignored = Changes::default(); + ignored.new = collected.ignored_files; + Ok((collected.unstaged, ignored)) +} + /// List changes to be staged with --force semantics (recurse into ignored directories) pub fn changes_to_be_staged_split_force() -> Result<(Changes, Changes), StatusError> { let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; diff --git a/src/command/status_untracked.rs b/src/command/status_untracked.rs index 32c86cf80..7f9af4602 100644 --- a/src/command/status_untracked.rs +++ b/src/command/status_untracked.rs @@ -31,6 +31,29 @@ pub(crate) fn collect_status_worktree_changes( untracked_mode: UntrackedFiles, include_ignored: bool, ignore_case: bool, +) -> Result { + collect_status_worktree_changes_inner(untracked_mode, include_ignored, ignore_case, None) +} + +pub(crate) fn collect_status_worktree_changes_for_paths( + untracked_mode: UntrackedFiles, + include_ignored: bool, + ignore_case: bool, + candidates: &[PathBuf], +) -> Result { + collect_status_worktree_changes_inner( + untracked_mode, + include_ignored, + ignore_case, + Some(candidates), + ) +} + +fn collect_status_worktree_changes_inner( + untracked_mode: UntrackedFiles, + include_ignored: bool, + ignore_case: bool, + candidates: Option<&[PathBuf]>, ) -> Result { let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; let index_path = path::try_index().map_err(|source| StatusError::Workdir { source })?; @@ -39,11 +62,26 @@ pub(crate) fn collect_status_worktree_changes( source, })?; let tracked = TrackedPaths::from_index(&index, ignore_case); - let mut unstaged = collect_tracked_worktree_changes(&workdir, &index, tracked.files())?; + let tracked_candidates; + let tracked_files = if let Some(candidates) = candidates { + tracked_candidates = candidates + .iter() + .filter(|path| path.to_str().is_some_and(|path| index.tracked(path, 0))) + .cloned() + .collect::>(); + tracked_candidates.as_slice() + } else { + tracked.files() + }; + let mut unstaged = collect_tracked_worktree_changes(&workdir, &index, tracked_files)?; let mut ignored_files = Vec::new(); if !matches!(untracked_mode, UntrackedFiles::No) { - let scan = scan_workdir(&workdir, &index, &tracked, untracked_mode, include_ignored)?; + let scan = if let Some(candidates) = candidates { + scan_candidate_paths(&workdir, &index, &tracked, candidates, include_ignored)? + } else { + scan_workdir(&workdir, &index, &tracked, untracked_mode, include_ignored)? + }; unstaged.new = if matches!(untracked_mode, UntrackedFiles::Normal) { collapse_untracked_directories(scan.untracked, &tracked) } else { @@ -63,6 +101,44 @@ pub(crate) fn collect_status_worktree_changes( }) } +fn scan_candidate_paths( + workdir: &Path, + index: &Index, + tracked: &TrackedPaths, + candidates: &[PathBuf], + include_ignored: bool, +) -> Result { + let mut scan = WorkdirScan { + untracked: Vec::new(), + ignored: Vec::new(), + }; + for relative in candidates { + let path = workdir.join(relative); + let file_type = match path.symlink_metadata() { + Ok(metadata) => metadata.file_type(), + Err(source) if source.kind() == io::ErrorKind::NotFound => continue, + Err(source) => { + return Err(StatusError::WorktreeRead { + path: path.clone(), + source, + }); + } + }; + if file_type.is_file() || file_type.is_symlink() { + scan_file( + &mut scan, + workdir, + index, + tracked, + &path, + relative, + include_ignored, + )?; + } + } + Ok(scan) +} + pub(crate) fn changes_to_current_directory(mut changes: Changes) -> Changes { changes.new = changes .new diff --git a/src/command/worktree-fuse.rs b/src/command/worktree-fuse.rs index 466e8a3fb..07f148691 100644 --- a/src/command/worktree-fuse.rs +++ b/src/command/worktree-fuse.rs @@ -79,6 +79,11 @@ pub enum WorktreeSubcommand { #[clap(long, help = "Allow other users to access the mounted worktree")] allow_other: bool, }, + /// Manage a linked worktree backed by a ScorpioFS Antares mount. + Scorpiofs { + #[clap(subcommand)] + command: legacy::ScorpioFsSubcommand, + }, List { /// Emit a stable, machine-readable porcelain format (one attribute per /// line, blank line between worktrees). @@ -269,6 +274,15 @@ pub async fn execute_safe(args: WorktreeArgs, output: &OutputConfig) -> CliResul .map_err(|e| CliError::fatal(e.to_string())) } } + WorktreeSubcommand::Scorpiofs { command } => { + legacy::execute_safe( + legacy::WorktreeArgs { + command: legacy::WorktreeSubcommand::Scorpiofs { command }, + }, + output, + ) + .await + } WorktreeSubcommand::List { porcelain } => list_all_worktrees(output, porcelain).await, WorktreeSubcommand::Lock { path, reason } => { if lock_fuse_worktree(&path, reason.clone()) diff --git a/src/command/worktree.rs b/src/command/worktree.rs index 93eae3bc0..b23d10d34 100644 --- a/src/command/worktree.rs +++ b/src/command/worktree.rs @@ -8,6 +8,7 @@ use std::{ collections::HashSet, env, fs, io, path::{Component, Path, PathBuf}, + time::Duration, }; use clap::{Parser, Subcommand}; @@ -17,7 +18,17 @@ use serde::{Deserialize, Serialize}; use crate::utils::fuse as fuse_utils; use crate::{ command::restore::{self, RestoreArgs}, - internal::head::Head, + internal::{ + head::Head, + scorpiofs_backend::{ + CAPABILITY_CHANGES_V1, CAPABILITY_MOUNT_V1, CAPABILITY_READY_V1, HttpScorpioFsClient, + MountRequest, MountResponse, ScorpioFsBackendRecord, ScorpioFsControl, ScorpioFsDriver, + }, + worktree_backend::{ + BackendKind as WorktreeBackendKind, BackendMountRequest as StorageMountRequest, + BackendMountSession as StorageMountSession, BackendMountSource, WorktreeBackendDriver, + }, + }, utils::{ error::{CliError, CliResult, StableErrorCode}, output::{OutputConfig, emit_json_data}, @@ -29,6 +40,9 @@ use crate::{ pub const WORKTREE_EXAMPLES: &str = "\ EXAMPLES: libra worktree add ../feature-x Create a linked worktree + libra worktree scorpiofs attach --remote-path /project/crate --job-id dev-crate + Attach a ScorpioFS remote worktree + libra worktree scorpiofs detach Detach the remote worktree libra worktree list List every registered worktree libra worktree list --porcelain Machine-readable worktree list libra worktree lock ../feature-x --reason wip Lock a worktree to prevent prune/remove @@ -66,6 +80,11 @@ pub enum WorktreeSubcommand { /// Filesystem path at which to create the new worktree. path: String, }, + /// Manage a linked worktree backed by a ScorpioFS Antares mount. + Scorpiofs { + #[clap(subcommand)] + command: ScorpioFsSubcommand, + }, /// List all known worktrees and their state. List { /// Emit a stable, machine-readable porcelain format (one attribute per @@ -120,6 +139,39 @@ pub enum WorktreeSubcommand { Repair, } +#[derive(Subcommand, Debug)] +pub enum ScorpioFsSubcommand { + /// Mount a remote monorepo path and attach it as a persistent linked worktree. + Attach { + /// Antares API base URL. + /// + /// When omitted on Linux, Libra starts and owns an embedded ScorpioFS + /// worker. Supplying this option selects the compatibility HTTP mode. + #[clap(long)] + endpoint: Option, + /// ScorpioFS configuration used by the Libra-owned worker. + #[clap(long, default_value = "scorpio.toml")] + config_path: PathBuf, + /// Absolute path inside the remote monorepo. + #[clap(long)] + remote_path: String, + /// Stable job identity used for idempotent mount creation and recovery. + #[clap(long)] + job_id: String, + /// Optional Mega changelist layer. + #[clap(long)] + cl: Option, + /// Maximum number of seconds to wait for mount readiness. + #[clap(long, default_value_t = 120)] + ready_timeout_secs: u64, + }, + /// Unmount and unregister a ScorpioFS-backed linked worktree. + Detach { + /// Mounted worktree path. + path: String, + }, +} + /// A single worktree entry persisted in `worktrees.json`. /// /// `path` is always stored as a canonical absolute path. @@ -164,6 +216,24 @@ struct WorktreeAddOutput { already_exists: bool, } +#[derive(Debug, Serialize)] +struct ScorpioFsAttachOutput { + path: String, + worktree_id: String, + mount_id: String, + job_id: String, + already_exists: bool, +} + +#[derive(Debug, Serialize)] +struct ScorpioFsDetachOutput { + path: String, + worktree_id: String, + job_id: String, + unmounted: bool, + registry_removed: bool, +} + #[derive(Debug, Serialize)] struct WorktreeLockOutput { path: String, @@ -375,6 +445,34 @@ pub async fn execute_safe(args: WorktreeArgs, output: &OutputConfig) -> CliResul .map_err(WorktreeError::into_cli_error)?; render_add_worktree(&result, output) } + WorktreeSubcommand::Scorpiofs { command } => match command { + ScorpioFsSubcommand::Attach { + endpoint, + config_path, + remote_path, + job_id, + cl, + ready_timeout_secs, + } => { + let result = attach_scorpiofs_worktree( + endpoint, + config_path, + remote_path, + job_id, + cl, + ready_timeout_secs, + ) + .await + .map_err(WorktreeError::into_cli_error)?; + render_scorpiofs_attach(&result, output) + } + ScorpioFsSubcommand::Detach { path } => { + let result = detach_scorpiofs_worktree(path) + .await + .map_err(WorktreeError::into_cli_error)?; + render_scorpiofs_detach(&result, output) + } + }, WorktreeSubcommand::List { porcelain } => list_worktrees(output, porcelain).await, WorktreeSubcommand::Lock { path, reason } => { let result = lock_worktree(path, reason).map_err(WorktreeError::into_cli_error)?; @@ -877,6 +975,878 @@ async fn add_worktree(path: String) -> WorktreeResult { }) } +async fn attach_scorpiofs_worktree( + endpoint: Option, + config_path: PathBuf, + remote_path: String, + job_id: String, + cl: Option, + ready_timeout_secs: u64, +) -> WorktreeResult { + let storage = util::storage_path(); + let _state_lock = crate::internal::scorpiofs_backend::ScorpioFsStateLock::acquire(&storage) + .map_err(|error| WorktreeError::IoWrite(error.to_string()))?; + let (endpoint, transport) = match endpoint { + Some(endpoint) => ( + endpoint, + crate::internal::scorpiofs_backend::BackendTransport::ExternalHttp, + ), + None => ( + ensure_managed_scorpiofs_worker( + &storage, + &config_path, + Duration::from_secs(ready_timeout_secs), + ) + .await?, + crate::internal::scorpiofs_backend::BackendTransport::ManagedCrate, + ), + }; + let seed_commit = Head::current_commit_result().await.map_err(|error| { + WorktreeError::IoRead(format!( + "failed to read HEAD before attaching ScorpioFS worktree: {error}" + )) + })?; + let client = HttpScorpioFsClient::new(&endpoint) + .map_err(|error| WorktreeError::IoRead(error.to_string()))?; + let request = MountRequest { + job_id: job_id.clone(), + path: remote_path, + cl, + base_oid: None, + }; + let mut desired_state = crate::internal::scorpiofs_backend::LibraScorpioFsState::load(&storage) + .map_err(|error| WorktreeError::IoRead(error.to_string()))?; + let previous_mountpoint = desired_state + .mounts + .get(&job_id) + .and_then(|desired| desired.mountpoint.clone()); + desired_state + .begin_mount(request.clone(), transport, endpoint.clone()) + .map_err(|error| WorktreeError::OperationBlocked(error.to_string()))?; + desired_state + .save(&storage) + .map_err(|error| WorktreeError::IoWrite(error.to_string()))?; + + let driver = ScorpioFsDriver::new(client.clone()); + let backend_request = StorageMountRequest { + instance_id: job_id.clone(), + worktree_id: job_id.clone(), + source: BackendMountSource::RemoteProjection { + remote_path: request.path.clone(), + base_oid: request.base_oid.clone(), + change_layer: request.cl.clone(), + }, + mountpoint_hint: None, + ready_timeout_secs, + }; + let backend_session = match driver.mount(&backend_request).await { + Ok(session) => session, + Err(error) => { + record_scorpiofs_attach_error(&storage, &job_id, error.to_string()); + return Err(WorktreeError::IoWrite(error.to_string())); + } + }; + let mount = MountResponse { + mount_id: backend_session.session_id, + mountpoint: backend_session.mountpoint.to_string_lossy().into_owned(), + base_oid: backend_session.base_oid, + ready: Some(true), + }; + + let target = match resolve_path(&mount.mountpoint, "ScorpioFS mountpoint") { + Ok(target) => target, + Err(error) => { + let _ = client.delete_by_job(&job_id).await; + record_scorpiofs_attach_error(&storage, &job_id, format!("{error:?}")); + return Err(error); + } + }; + if !target.is_dir() { + let _ = client.delete_by_job(&job_id).await; + let error = WorktreeError::InvalidTarget(format!( + "ScorpioFS mountpoint is not a directory: {}", + target.display() + )); + record_scorpiofs_attach_error(&storage, &job_id, format!("{error:?}")); + return Err(error); + } + if util::is_sub_path(&target, &storage) { + let _ = client.delete_by_job(&job_id).await; + let error = WorktreeError::InvalidTarget(format!( + "ScorpioFS mountpoint cannot be inside .libra storage: {}", + target.display() + )); + record_scorpiofs_attach_error(&storage, &job_id, format!("{error:?}")); + return Err(error); + } + + let mut state = load_state()?; + if let Some(previous_mountpoint) = previous_mountpoint.as_deref() { + if Path::new(previous_mountpoint) != target { + state + .worktrees + .retain(|entry| Path::new(&entry.path) != Path::new(previous_mountpoint)); + } + } + if state + .worktrees + .iter() + .any(|entry| Path::new(&entry.path) == target) + { + let gitdir = util::try_get_worktree_gitdir(Some(target.clone())).map_err(|error| { + WorktreeError::IoRead(format!( + "registered ScorpioFS worktree '{}' has invalid metadata: {error}", + target.display() + )) + })?; + let record = ScorpioFsBackendRecord::load(&gitdir) + .map_err(|error| WorktreeError::IoRead(error.to_string()))?; + if record.job_id != job_id || record.remote_path != request.path { + return Err(WorktreeError::OperationBlocked(format!( + "worktree '{}' is already registered for ScorpioFS job '{}' and path '{}'", + target.display(), + record.job_id, + record.remote_path + ))); + } + desired_state + .mark_ready(&job_id, &mount, &read_worktree_id(&gitdir)?) + .map_err(|error| WorktreeError::IoWrite(error.to_string()))?; + desired_state + .save(&storage) + .map_err(|error| WorktreeError::IoWrite(error.to_string()))?; + return Ok(ScorpioFsAttachOutput { + path: target.to_string_lossy().into_owned(), + worktree_id: read_worktree_id(&gitdir)?, + mount_id: mount.mount_id, + job_id, + already_exists: true, + }); + } + + let backend_identity = match transport { + crate::internal::scorpiofs_backend::BackendTransport::ManagedCrate => "managed-crate", + crate::internal::scorpiofs_backend::BackendTransport::ExternalHttp => { + client.endpoint().as_str() + } + }; + let worktree_id = scorpiofs_worktree_id(backend_identity, &job_id, &request.path); + let gitdir = storage + .join("worktrees") + .join("scorpiofs") + .join(&worktree_id); + let pointer = target.join(util::ROOT_DIR); + let created_gitdir = !gitdir.exists(); + if created_gitdir { + create_worktree_gitdir(&storage, &gitdir, &worktree_id).map_err(|source| { + WorktreeError::IoWrite(format!( + "failed to create persistent ScorpioFS worktree gitdir '{}': {source}", + gitdir.display() + )) + })?; + } + + let record = match ScorpioFsBackendRecord::new_with_transport( + client.endpoint(), + &mount, + &request, + transport, + ) { + Ok(record) => record, + Err(error) => { + rollback_scorpiofs_attach( + &client, + &job_id, + &pointer, + false, + &gitdir, + created_gitdir, + &worktree_id, + ) + .await; + return Err(WorktreeError::IoWrite(error.to_string())); + } + }; + + if !created_gitdir { + let existing = ScorpioFsBackendRecord::load(&gitdir).map_err(|error| { + WorktreeError::OperationBlocked(format!( + "persistent ScorpioFS worktree '{}' cannot be reused: {error}", + gitdir.display() + )) + })?; + if existing.job_id != job_id + || existing.remote_path != request.path + || existing.transport != record.transport + || (transport == crate::internal::scorpiofs_backend::BackendTransport::ExternalHttp + && existing.endpoint != record.endpoint) + { + let _ = client.delete_by_job(&job_id).await; + return Err(WorktreeError::OperationBlocked(format!( + "persistent ScorpioFS worktree id '{}' belongs to another backend attachment", + worktree_id + ))); + } + } + if let Err(error) = record.save(&gitdir) { + rollback_scorpiofs_attach( + &client, + &job_id, + &pointer, + false, + &gitdir, + created_gitdir, + &worktree_id, + ) + .await; + return Err(WorktreeError::IoWrite(error.to_string())); + } + + let created_pointer = match attach_worktree_pointer(&pointer, &gitdir) { + Ok(created) => created, + Err(source) => { + rollback_scorpiofs_attach( + &client, + &job_id, + &pointer, + false, + &gitdir, + created_gitdir, + &worktree_id, + ) + .await; + return Err(WorktreeError::IoWrite(format!( + "failed to attach persistent metadata at '{}': {source}", + pointer.display() + ))); + } + }; + + if let Some(commit) = seed_commit { + let guard = match DirGuard::change_to(&target) { + Ok(guard) => guard, + Err(error) => { + rollback_scorpiofs_attach( + &client, + &job_id, + &pointer, + created_pointer, + &gitdir, + created_gitdir, + &worktree_id, + ) + .await; + return Err(WorktreeError::IoRead(format!( + "failed to enter ScorpioFS worktree '{}': {error}", + target.display() + ))); + } + }; + if let Err(error) = Head::update_result(Head::Detached(commit), None).await { + drop(guard); + rollback_scorpiofs_attach( + &client, + &job_id, + &pointer, + created_pointer, + &gitdir, + created_gitdir, + &worktree_id, + ) + .await; + return Err(WorktreeError::IoWrite(format!( + "failed to seed ScorpioFS worktree HEAD: {error}" + ))); + } + if let Err(error) = restore::execute_checked(RestoreArgs { + overlay: false, + no_overlay: false, + ours: false, + theirs: false, + ignore_unmerged: false, + merge: false, + conflict: None, + pathspec: vec![".".to_string()], + source: Some("HEAD".to_string()), + worktree: false, + staged: true, + pathspec_from_file: None, + pathspec_file_nul: false, + no_progress: false, + }) + .await + { + drop(guard); + rollback_scorpiofs_attach( + &client, + &job_id, + &pointer, + created_pointer, + &gitdir, + created_gitdir, + &worktree_id, + ) + .await; + return Err(WorktreeError::IoWrite(format!( + "failed to seed ScorpioFS worktree index: {error}" + ))); + } + } else { + let guard = match DirGuard::change_to(&target) { + Ok(guard) => guard, + Err(error) => { + rollback_scorpiofs_attach( + &client, + &job_id, + &pointer, + created_pointer, + &gitdir, + created_gitdir, + &worktree_id, + ) + .await; + return Err(WorktreeError::IoRead(format!( + "failed to enter ScorpioFS worktree '{}': {error}", + target.display() + ))); + } + }; + let unborn_branch = format!("scorpiofs/{worktree_id}"); + if let Err(error) = Head::update_result(Head::Branch(unborn_branch), None).await { + drop(guard); + rollback_scorpiofs_attach( + &client, + &job_id, + &pointer, + created_pointer, + &gitdir, + created_gitdir, + &worktree_id, + ) + .await; + return Err(WorktreeError::IoWrite(format!( + "failed to seed unborn ScorpioFS worktree HEAD: {error}" + ))); + } + } + + state.worktrees.push(WorktreeEntry { + path: target.to_string_lossy().into_owned(), + is_main: false, + locked: false, + lock_reason: None, + }); + if let Err(error) = write_state(&state) { + rollback_scorpiofs_attach( + &client, + &job_id, + &pointer, + created_pointer, + &gitdir, + created_gitdir, + &worktree_id, + ) + .await; + return Err(error); + } + + let mut desired_state = crate::internal::scorpiofs_backend::LibraScorpioFsState::load(&storage) + .map_err(|error| WorktreeError::IoRead(error.to_string()))?; + desired_state + .mark_ready(&job_id, &mount, &worktree_id) + .map_err(|error| WorktreeError::IoWrite(error.to_string()))?; + if let Err(error) = desired_state.save(&storage) { + rollback_scorpiofs_attach( + &client, + &job_id, + &pointer, + created_pointer, + &gitdir, + created_gitdir, + &worktree_id, + ) + .await; + return Err(WorktreeError::IoWrite(error.to_string())); + } + + Ok(ScorpioFsAttachOutput { + path: target.to_string_lossy().into_owned(), + worktree_id, + mount_id: mount.mount_id, + job_id, + already_exists: false, + }) +} + +async fn detach_scorpiofs_worktree(path: String) -> WorktreeResult { + let storage = util::storage_path(); + let _state_lock = crate::internal::scorpiofs_backend::ScorpioFsStateLock::acquire(&storage) + .map_err(|error| WorktreeError::IoWrite(error.to_string()))?; + let target = resolve_path(&path, "ScorpioFS worktree path")?; + let mut state = load_state()?; + let index = state + .worktrees + .iter() + .position(|entry| Path::new(&entry.path) == target) + .ok_or_else(|| WorktreeError::NoSuchWorktree { path: path.clone() })?; + let entry = state.worktrees[index].clone(); + if entry.is_main { + return Err(WorktreeError::MainWorktree { + action: "detach", + path: target.to_string_lossy().into_owned(), + }); + } + if entry.locked { + return Err(WorktreeError::LockedWorktree { + action: "detach", + path: target.to_string_lossy().into_owned(), + }); + } + + let gitdir = util::try_get_worktree_gitdir(Some(target.clone())).map_err(|error| { + WorktreeError::IoRead(format!( + "failed to resolve ScorpioFS worktree metadata for '{}': {error}", + target.display() + )) + })?; + let managed_root = storage.join("worktrees").join("scorpiofs"); + if !util::is_sub_path(&gitdir, &managed_root) { + return Err(WorktreeError::InvalidTarget(format!( + "worktree '{}' is not managed by the ScorpioFS backend", + target.display() + ))); + } + let record = ScorpioFsBackendRecord::load(&gitdir) + .map_err(|error| WorktreeError::IoRead(error.to_string()))?; + let worktree_id = read_worktree_id(&gitdir)?; + + { + let guard = DirGuard::change_to(&target).map_err(|error| { + WorktreeError::IoRead(format!( + "cannot enter ScorpioFS worktree '{}': {error}", + target.display() + )) + })?; + let staged = crate::command::status::changes_to_be_committed_safe() + .await + .map_err(|error| { + WorktreeError::IoRead(format!("failed to inspect staged changes: {error}")) + })?; + let backend_changes = crate::internal::scorpiofs_backend::current_worktree_changes() + .await + .map_err(|error| { + WorktreeError::IoRead(format!( + "failed to query ScorpioFS worktree changes before detach: {error}" + )) + })?; + let unstaged = if let Some(changes) = backend_changes { + let ignore_case = crate::utils::path_case::effective_ignore_case_for_dir_sync(&target) + .map_err(|error| { + WorktreeError::IoRead(format!( + "failed to resolve ScorpioFS worktree path case policy: {error}" + )) + })?; + let (visible, _) = + crate::command::status::changes_to_be_staged_split_for_paths_with_ignore_case( + ignore_case, + &changes.candidate_paths(), + ) + .map_err(|error| { + WorktreeError::IoRead(format!("failed to inspect ScorpioFS changes: {error}")) + })?; + visible + } else { + crate::command::status::changes_to_be_staged().map_err(|error| { + WorktreeError::IoRead(format!("failed to inspect unstaged changes: {error}")) + })? + }; + if !staged.is_empty() || !unstaged.is_empty() { + return Err(WorktreeError::DirtyWorktree { + path: target.to_string_lossy().into_owned(), + }); + } + drop(guard); + } + + let mut desired_state = crate::internal::scorpiofs_backend::LibraScorpioFsState::load(&storage) + .map_err(|error| WorktreeError::IoRead(error.to_string()))?; + if let Some(desired) = desired_state.mounts.get_mut(&record.job_id) { + desired.lifecycle = crate::internal::scorpiofs_backend::BackendLifecycle::Unmounting; + desired.last_error = None; + } + desired_state + .save(&storage) + .map_err(|error| WorktreeError::IoWrite(error.to_string()))?; + + state.worktrees.remove(index); + write_state(&state)?; + + let client = HttpScorpioFsClient::new(&record.endpoint) + .map_err(|error| WorktreeError::IoRead(error.to_string()))?; + let driver = ScorpioFsDriver::new(client); + let backend_session = StorageMountSession { + backend: WorktreeBackendKind::ScorpioFs, + session_id: record.mount_id.clone(), + mountpoint: target.clone(), + cleanup_key: record.job_id.clone(), + base_oid: record.base_oid.clone(), + }; + if let Err(error) = driver.unmount(&backend_session).await { + if let Some(desired) = desired_state.mounts.get_mut(&record.job_id) { + desired.lifecycle = crate::internal::scorpiofs_backend::BackendLifecycle::Ready; + desired.last_error = Some(error.to_string()); + } + let _ = desired_state.save(&storage); + state.worktrees.insert(index, entry); + if let Err(restore_error) = write_state(&state) { + return Err(WorktreeError::StateWrite { + path: state_path(), + source: io::Error::other(format!( + "ScorpioFS unmount failed ({error}); registry rollback also failed: \ + {restore_error:?}" + )), + }); + } + return Err(WorktreeError::IoWrite(error.to_string())); + } + + let pointer = target.join(util::ROOT_DIR); + match fs::remove_file(&pointer) { + Ok(()) => {} + Err(source) if source.kind() == io::ErrorKind::NotFound => {} + Err(source) => { + return Err(WorktreeError::IoWrite(format!( + "ScorpioFS mount was removed but worktree pointer '{}' could not be deleted: \ + {source}; run `libra worktree repair`", + pointer.display() + ))); + } + } + + gc_worktree_scoped_rows(&worktree_id).await; + fs::remove_dir_all(&gitdir).map_err(|source| { + WorktreeError::IoWrite(format!( + "ScorpioFS mount was removed but persistent worktree metadata '{}' could not be \ + deleted: {source}; run `libra worktree repair`", + gitdir.display() + )) + })?; + + desired_state.mounts.remove(&record.job_id); + if desired_state.mounts.is_empty() { + stop_managed_scorpiofs_worker(&mut desired_state); + } + desired_state + .save(&storage) + .map_err(|error| WorktreeError::IoWrite(error.to_string()))?; + + Ok(ScorpioFsDetachOutput { + path: target.to_string_lossy().into_owned(), + worktree_id, + job_id: record.job_id, + unmounted: true, + registry_removed: true, + }) +} + +async fn rollback_scorpiofs_attach( + client: &HttpScorpioFsClient, + job_id: &str, + pointer: &Path, + created_pointer: bool, + gitdir: &Path, + created_gitdir: bool, + worktree_id: &str, +) { + if let Some(storage) = gitdir.ancestors().nth(3) { + record_scorpiofs_attach_error(storage, job_id, "attach transaction rolled back"); + } + if created_pointer { + let _ = fs::remove_file(pointer); + } + if created_gitdir { + let _ = fs::remove_dir_all(gitdir); + } + gc_worktree_scoped_rows(worktree_id).await; + if let Err(error) = client.delete_by_job(job_id).await { + tracing::warn!(job_id, error = %error, "failed to roll back ScorpioFS mount"); + } +} + +fn record_scorpiofs_attach_error(storage: &Path, job_id: &str, error: impl Into) { + match crate::internal::scorpiofs_backend::LibraScorpioFsState::load(storage) { + Ok(mut state) => { + state.mark_error(job_id, error); + if let Err(save_error) = state.save(storage) { + tracing::warn!(job_id, error = %save_error, "failed to persist ScorpioFS error state"); + } + } + Err(load_error) => { + tracing::warn!(job_id, error = %load_error, "failed to load Libra ScorpioFS state"); + } + } +} + +async fn ensure_managed_scorpiofs_worker( + storage: &Path, + config_path: &Path, + timeout: Duration, +) -> WorktreeResult { + #[cfg(not(all(target_os = "linux", feature = "scorpiofs-direct")))] + { + let _ = (storage, config_path, timeout); + return Err(WorktreeError::OperationBlocked( + "direct ScorpioFS requires Linux and the scorpiofs-direct feature; use --endpoint for compatibility mode" + .to_string(), + )); + } + + #[cfg(all(target_os = "linux", feature = "scorpiofs-direct"))] + { + use crate::internal::scorpiofs_backend::{ + LibraScorpioFsState, ManagedWorkerRecord, ScorpioFsControl, + }; + + let mut state = LibraScorpioFsState::load(storage) + .map_err(|error| WorktreeError::IoRead(error.to_string()))?; + if let Some(worker) = state.worker.as_ref() { + if let Ok(client) = HttpScorpioFsClient::new(&worker.endpoint) { + if client.service_info().await.is_ok() { + return Ok(worker.endpoint.clone()); + } + } + for desired in state.mounts.values_mut() { + if desired.transport + == crate::internal::scorpiofs_backend::BackendTransport::ManagedCrate + { + desired.lifecycle = + crate::internal::scorpiofs_backend::BackendLifecycle::RecoverableError; + desired.last_error = Some( + "Libra-owned ScorpioFS worker stopped; reattach this job to recover" + .to_string(), + ); + } + } + state.worker = None; + state + .save(storage) + .map_err(|error| WorktreeError::IoWrite(error.to_string()))?; + } + + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).map_err(|source| { + WorktreeError::IoWrite(format!("failed to reserve ScorpioFS worker port: {source}")) + })?; + let port = listener + .local_addr() + .map_err(|source| { + WorktreeError::IoRead(format!( + "failed to inspect reserved ScorpioFS worker port: {source}" + )) + })? + .port(); + drop(listener); + + let runtime_key = { + use std::hash::{Hash, Hasher}; + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + storage.to_string_lossy().hash(&mut hasher); + format!("{:016x}", hasher.finish()) + }; + let runtime_root = env::temp_dir().join("libra-scorpiofs").join(runtime_key); + fs::create_dir_all(&runtime_root).map_err(|source| { + WorktreeError::IoWrite(format!( + "failed to create ScorpioFS runtime directory '{}': {source}", + runtime_root.display() + )) + })?; + let config_path = if config_path.is_absolute() { + config_path.to_path_buf() + } else { + env::current_dir() + .map_err(|source| { + WorktreeError::IoRead(format!( + "failed to resolve ScorpioFS config path: {source}" + )) + })? + .join(config_path) + }; + let endpoint = format!("http://127.0.0.1:{port}"); + let log_root = storage.join("scorpiofs"); + fs::create_dir_all(&log_root).map_err(|source| { + WorktreeError::IoWrite(format!( + "failed to create ScorpioFS log directory '{}': {source}", + log_root.display() + )) + })?; + let log_path = log_root.join("worker.log"); + let stdout = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .map_err(|source| { + WorktreeError::IoWrite(format!( + "failed to open ScorpioFS worker log '{}': {source}", + log_path.display() + )) + })?; + let stderr = stdout.try_clone().map_err(|source| { + WorktreeError::IoWrite(format!("failed to clone ScorpioFS worker log: {source}")) + })?; + let executable = env::current_exe().map_err(|source| { + WorktreeError::IoRead(format!("failed to locate the Libra executable: {source}")) + })?; + let child = std::process::Command::new(executable) + .arg("scorpiofs-worker") + .arg("--config-path") + .arg(&config_path) + .arg("--bind") + .arg(format!("127.0.0.1:{port}")) + .arg("--upper-root") + .arg(runtime_root.join("upper")) + .arg("--cl-root") + .arg(runtime_root.join("cl")) + .arg("--mount-root") + .arg(runtime_root.join("mounts")) + .arg("--runtime-state-file") + .arg(log_root.join("ignored-runtime-state.toml")) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::from(stdout)) + .stderr(std::process::Stdio::from(stderr)) + .spawn() + .map_err(|source| { + WorktreeError::IoWrite(format!( + "failed to start Libra-owned ScorpioFS worker: {source}" + )) + })?; + + state.worker = Some(ManagedWorkerRecord { + pid: child.id(), + endpoint: endpoint.clone(), + config_path: config_path.to_string_lossy().into_owned(), + }); + state + .save(storage) + .map_err(|error| WorktreeError::IoWrite(error.to_string()))?; + + let client = HttpScorpioFsClient::new(&endpoint) + .map_err(|error| WorktreeError::IoRead(error.to_string()))?; + let deadline = tokio::time::Instant::now() + timeout; + loop { + if let Ok(service) = client.service_info().await { + for capability in [ + CAPABILITY_MOUNT_V1, + CAPABILITY_READY_V1, + CAPABILITY_CHANGES_V1, + ] { + service + .require(capability) + .map_err(|error| WorktreeError::OperationBlocked(error.to_string()))?; + } + return Ok(endpoint); + } + if tokio::time::Instant::now() >= deadline { + stop_managed_scorpiofs_worker(&mut state); + let _ = state.save(storage); + return Err(WorktreeError::IoRead(format!( + "Libra-owned ScorpioFS worker did not become ready; inspect '{}'", + log_path.display() + ))); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + } +} + +fn stop_managed_scorpiofs_worker( + state: &mut crate::internal::scorpiofs_backend::LibraScorpioFsState, +) { + let Some(worker) = state.worker.take() else { + return; + }; + #[cfg(unix)] + unsafe { + libc::kill(worker.pid as i32, libc::SIGINT); + } +} + +fn attach_worktree_pointer(pointer: &Path, gitdir: &Path) -> io::Result { + let expected = format!("gitdir: {}\n", gitdir.display()); + match fs::read_to_string(pointer) { + Ok(existing) if existing == expected => Ok(false), + Ok(_) => Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!( + "worktree already contains a different .libra pointer at '{}'", + pointer.display() + ), + )), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::write(pointer, expected)?; + Ok(true) + } + Err(error) => Err(error), + } +} + +fn read_worktree_id(gitdir: &Path) -> WorktreeResult { + let path = gitdir.join("worktree_id"); + let worktree_id = fs::read_to_string(&path).map_err(|source| { + WorktreeError::IoRead(format!( + "failed to read ScorpioFS worktree id '{}': {source}", + path.display() + )) + })?; + let worktree_id = worktree_id.trim(); + if worktree_id.is_empty() { + return Err(WorktreeError::IoRead(format!( + "ScorpioFS worktree id '{}' is empty", + path.display() + ))); + } + Ok(worktree_id.to_string()) +} + +fn scorpiofs_worktree_id(endpoint: &str, job_id: &str, remote_path: &str) -> String { + let key = format!("{endpoint}\0{job_id}\0{remote_path}"); + let mut hash: u64 = 0xcbf29ce484222325; + for byte in key.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + let label: String = job_id + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character + } else { + '-' + } + }) + .collect(); + format!("{label}-{hash:016x}") +} + +fn render_scorpiofs_attach(result: &ScorpioFsAttachOutput, output: &OutputConfig) -> CliResult<()> { + if output.is_json() { + return emit_json_data("worktree.scorpiofs.attach", result, output); + } + if !output.quiet { + println!("{}", result.path); + } + Ok(()) +} + +fn render_scorpiofs_detach(result: &ScorpioFsDetachOutput, output: &OutputConfig) -> CliResult<()> { + if output.is_json() { + return emit_json_data("worktree.scorpiofs.detach", result, output); + } + if !output.quiet { + println!("Detached ScorpioFS worktree '{}'.", result.path); + } + Ok(()) +} + fn render_add_worktree(result: &WorktreeAddOutput, output: &OutputConfig) -> CliResult<()> { if output.is_json() { return emit_json_data("worktree.add", result, output); @@ -896,7 +1866,9 @@ fn render_add_worktree(result: &WorktreeAddOutput, output: &OutputConfig) -> Cli /// its `.libra/worktree_id` file if present, else recompute deterministically /// from the canonical path (lore.md 2.1). fn resolve_worktree_id(target: &Path) -> Option { - if let Ok(id) = fs::read_to_string(target.join(util::ROOT_DIR).join("worktree_id")) { + if let Ok(gitdir) = util::try_get_worktree_gitdir(Some(target.to_path_buf())) + && let Ok(id) = fs::read_to_string(gitdir.join("worktree_id")) + { let id = id.trim(); if !id.is_empty() { return Some(id.to_string()); @@ -1017,7 +1989,7 @@ pub(crate) fn resolve_entry_worktree_id(path: &str, is_main: bool) -> Option String { + "scorpiofs".to_string() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum BackendTransport { + ManagedCrate, + #[default] + ExternalHttp, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScorpioFsBackendRecord { + pub schema_version: u32, + pub backend: String, + #[serde(default)] + pub transport: BackendTransport, + pub endpoint: String, + pub mount_id: String, + pub job_id: String, + pub remote_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_oid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cl: Option, +} + +impl ScorpioFsBackendRecord { + pub fn new( + endpoint: &Url, + mount: &MountResponse, + request: &MountRequest, + ) -> Result { + Self::new_with_transport(endpoint, mount, request, BackendTransport::ExternalHttp) + } + + pub fn new_with_transport( + endpoint: &Url, + mount: &MountResponse, + request: &MountRequest, + transport: BackendTransport, + ) -> Result { + validate_identifier("mount_id", &mount.mount_id)?; + validate_identifier("job_id", &request.job_id)?; + validate_remote_path(&request.path)?; + + Ok(Self { + schema_version: BACKEND_SCHEMA_VERSION, + backend: "scorpiofs".to_string(), + transport, + endpoint: endpoint.as_str().trim_end_matches('/').to_string(), + mount_id: mount.mount_id.clone(), + job_id: request.job_id.clone(), + remote_path: request.path.clone(), + base_oid: mount.base_oid.clone().or_else(|| request.base_oid.clone()), + cl: request.cl.clone(), + }) + } + + pub fn validate(&self) -> Result<(), BackendError> { + if self.schema_version != BACKEND_SCHEMA_VERSION { + return Err(BackendError::UnsupportedRecordVersion { + found: self.schema_version, + supported: BACKEND_SCHEMA_VERSION, + }); + } + if self.backend != "scorpiofs" { + return Err(BackendError::InvalidRecord(format!( + "expected backend 'scorpiofs', found '{}'", + self.backend + ))); + } + + validate_endpoint(&self.endpoint)?; + validate_identifier("mount_id", &self.mount_id)?; + validate_identifier("job_id", &self.job_id)?; + validate_remote_path(&self.remote_path) + } + + pub fn load(gitdir: &Path) -> Result { + let path = gitdir.join(BACKEND_RECORD_FILE); + let data = fs::read(&path).map_err(|source| BackendError::RecordIo { + path: path.clone(), + source, + })?; + let record = + serde_json::from_slice::(&data).map_err(|source| BackendError::RecordDecode { + path: path.clone(), + source, + })?; + record.validate()?; + Ok(record) + } + + pub fn save(&self, gitdir: &Path) -> Result<(), BackendError> { + self.validate()?; + fs::create_dir_all(gitdir).map_err(|source| BackendError::RecordIo { + path: gitdir.to_path_buf(), + source, + })?; + + let path = gitdir.join(BACKEND_RECORD_FILE); + let temporary = gitdir.join(format!(".{BACKEND_RECORD_FILE}.tmp-{}", std::process::id())); + let data = serde_json::to_vec_pretty(self).map_err(BackendError::RecordEncode)?; + fs::write(&temporary, data).map_err(|source| BackendError::RecordIo { + path: temporary.clone(), + source, + })?; + if let Err(source) = replace_file(&temporary, &path) { + let _ = fs::remove_file(&temporary); + return Err(BackendError::RecordIo { path, source }); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManagedWorkerRecord { + pub pid: u32, + pub endpoint: String, + pub config_path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DesiredMountRecord { + pub request: MountRequest, + pub transport: BackendTransport, + pub lifecycle: BackendLifecycle, + pub endpoint: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mount_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mountpoint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LibraScorpioFsState { + pub schema_version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker: Option, + #[serde(default)] + pub mounts: BTreeMap, +} + +pub struct ScorpioFsStateLock { + file: fs::File, +} + +impl ScorpioFsStateLock { + pub fn acquire(storage: &Path) -> Result { + let path = storage.join(DESIRED_STATE_LOCK_FILE); + let parent = path.parent().ok_or_else(|| { + BackendError::InvalidRecord("ScorpioFS lock path has no parent".to_string()) + })?; + fs::create_dir_all(parent).map_err(|source| BackendError::StateIo { + path: parent.to_path_buf(), + source, + })?; + let file = fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(&path) + .map_err(|source| BackendError::StateIo { + path: path.clone(), + source, + })?; + fs2::FileExt::lock_exclusive(&file) + .map_err(|source| BackendError::StateIo { path, source })?; + Ok(Self { file }) + } +} + +impl Drop for ScorpioFsStateLock { + fn drop(&mut self) { + let _ = fs2::FileExt::unlock(&self.file); + } +} + +impl Default for LibraScorpioFsState { + fn default() -> Self { + Self { + schema_version: 1, + worker: None, + mounts: BTreeMap::new(), + } + } +} + +impl LibraScorpioFsState { + pub fn load(storage: &Path) -> Result { + let path = storage.join(DESIRED_STATE_FILE); + let data = match fs::read(&path) { + Ok(data) => data, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Self::default()), + Err(source) => return Err(BackendError::StateIo { path, source }), + }; + let state = + serde_json::from_slice::(&data).map_err(|source| BackendError::StateDecode { + path: path.clone(), + source, + })?; + if state.schema_version != 1 { + return Err(BackendError::InvalidRecord(format!( + "unsupported Libra ScorpioFS state version {}", + state.schema_version + ))); + } + Ok(state) + } + + pub fn save(&self, storage: &Path) -> Result<(), BackendError> { + let path = storage.join(DESIRED_STATE_FILE); + let parent = path.parent().ok_or_else(|| { + BackendError::InvalidRecord("ScorpioFS state path has no parent".to_string()) + })?; + fs::create_dir_all(parent).map_err(|source| BackendError::StateIo { + path: parent.to_path_buf(), + source, + })?; + let temporary = parent.join(format!(".state.json.tmp-{}", std::process::id())); + let data = serde_json::to_vec_pretty(self).map_err(BackendError::RecordEncode)?; + fs::write(&temporary, data).map_err(|source| BackendError::StateIo { + path: temporary.clone(), + source, + })?; + if let Err(source) = replace_file(&temporary, &path) { + let _ = fs::remove_file(&temporary); + return Err(BackendError::StateIo { path, source }); + } + Ok(()) + } + + pub fn begin_mount( + &mut self, + request: MountRequest, + transport: BackendTransport, + endpoint: String, + ) -> Result<(), BackendError> { + request.validate()?; + if let Some(existing) = self.mounts.get(&request.job_id) { + if existing.request.path != request.path || existing.request.cl != request.cl { + return Err(BackendError::InvalidRecord(format!( + "ScorpioFS job '{}' is already assigned to '{}' with CL {:?}", + request.job_id, existing.request.path, existing.request.cl + ))); + } + } + self.mounts.insert( + request.job_id.clone(), + DesiredMountRecord { + request, + transport, + lifecycle: BackendLifecycle::Mounting, + endpoint, + mount_id: None, + mountpoint: None, + worktree_id: None, + last_error: None, + }, + ); + Ok(()) + } + + pub fn mark_ready( + &mut self, + job_id: &str, + mount: &MountResponse, + worktree_id: &str, + ) -> Result<(), BackendError> { + let desired = self.mounts.get_mut(job_id).ok_or_else(|| { + BackendError::InvalidRecord(format!("missing desired mount for job '{job_id}'")) + })?; + desired.lifecycle = BackendLifecycle::Ready; + desired.mount_id = Some(mount.mount_id.clone()); + desired.mountpoint = Some(mount.mountpoint.clone()); + desired.worktree_id = Some(worktree_id.to_string()); + desired.last_error = None; + Ok(()) + } + + pub fn mark_error(&mut self, job_id: &str, error: impl Into) { + if let Some(desired) = self.mounts.get_mut(job_id) { + desired.lifecycle = BackendLifecycle::RecoverableError; + desired.last_error = Some(error.into()); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceInfo { + pub protocol_version: u32, + pub service: String, + #[serde(default)] + pub service_version: Option, + #[serde(default)] + pub capabilities: Vec, +} + +impl ServiceInfo { + pub fn validate(&self) -> Result<(), BackendError> { + if self.protocol_version != PROTOCOL_VERSION { + return Err(BackendError::UnsupportedProtocolVersion { + found: self.protocol_version, + supported: PROTOCOL_VERSION, + }); + } + if self.service != "scorpiofs" { + return Err(BackendError::UnexpectedService(self.service.clone())); + } + Ok(()) + } + + pub fn supports(&self, capability: &str) -> bool { + self.capabilities.iter().any(|item| item == capability) + } + + pub fn require(&self, capability: &'static str) -> Result<(), BackendError> { + if self.supports(capability) { + Ok(()) + } else { + Err(BackendError::MissingCapability(capability)) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MountRequest { + pub job_id: String, + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cl: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_oid: Option, +} + +impl MountRequest { + pub fn validate(&self) -> Result<(), BackendError> { + validate_identifier("job_id", &self.job_id)?; + validate_remote_path(&self.path)?; + if let Some(base_oid) = self.base_oid.as_deref() { + validate_object_id(base_oid)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MountResponse { + #[serde(alias = "id")] + pub mount_id: String, + pub mountpoint: String, + #[serde(default)] + pub base_oid: Option, + #[serde(default)] + pub ready: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReadyResponse { + #[serde(default)] + pub mount_id: Option, + pub ready: bool, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub detail: Option, +} + +#[derive(Debug, Error)] +pub enum BackendError { + #[error("invalid ScorpioFS endpoint: {0}")] + InvalidEndpoint(String), + #[error("invalid ScorpioFS backend record: {0}")] + InvalidRecord(String), + #[error("failed to discover ScorpioFS worktree metadata: {0}")] + MetadataDiscovery(#[source] io::Error), + #[error( + "unsupported ScorpioFS backend record version {found}; this Libra supports version {supported}" + )] + UnsupportedRecordVersion { found: u32, supported: u32 }, + #[error( + "unsupported ScorpioFS protocol version {found}; this Libra supports version {supported}" + )] + UnsupportedProtocolVersion { found: u32, supported: u32 }, + #[error("unexpected ScorpioFS control service '{0}'")] + UnexpectedService(String), + #[error("failed to read or write ScorpioFS backend record '{}': {source}", path.display())] + RecordIo { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("failed to encode ScorpioFS backend record: {0}")] + RecordEncode(serde_json::Error), + #[error("failed to decode ScorpioFS backend record '{}': {source}", path.display())] + RecordDecode { + path: PathBuf, + #[source] + source: serde_json::Error, + }, + #[error("failed to read or write Libra ScorpioFS state '{}': {source}", path.display())] + StateIo { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("failed to decode Libra ScorpioFS state '{}': {source}", path.display())] + StateDecode { + path: PathBuf, + #[source] + source: serde_json::Error, + }, + #[error("invalid {field}: {message}")] + InvalidIdentifier { + field: &'static str, + message: String, + }, + #[error("invalid ScorpioFS remote path: {0}")] + InvalidRemotePath(String), + #[error("invalid ScorpioFS changed path: {0}")] + InvalidChangedPath(String), + #[error("invalid base object id: {0}")] + InvalidObjectId(String), + #[error("ScorpioFS does not advertise required capability '{0}'")] + MissingCapability(&'static str), + #[error("ScorpioFS request '{operation}' failed: {source}")] + Request { + operation: &'static str, + #[source] + source: anyhow::Error, + }, + #[error("ScorpioFS request '{operation}' returned HTTP {status}: {message}")] + HttpStatus { + operation: &'static str, + status: StatusCode, + message: String, + }, + #[error("ScorpioFS mount '{mount_id}' did not become ready within {timeout:?}")] + ReadinessTimeout { mount_id: String, timeout: Duration }, +} + +fn replace_file(temporary: &Path, destination: &Path) -> io::Result<()> { + #[cfg(windows)] + if destination.exists() { + fs::remove_file(destination)?; + } + fs::rename(temporary, destination) +} + +#[async_trait] +pub trait ScorpioFsControl: Send + Sync { + async fn service_info(&self) -> Result; + async fn mount(&self, request: &MountRequest) -> Result; + async fn ready(&self, mount_id: &str) -> Result; + async fn changes(&self, mount_id: &str) -> Result; + async fn delete_by_job(&self, job_id: &str) -> Result<(), BackendError>; +} + +#[derive(Debug, Clone)] +pub struct HttpScorpioFsClient { + endpoint: Url, + client: Client, +} + +impl HttpScorpioFsClient { + pub fn new(endpoint: &str) -> Result { + let endpoint = validate_endpoint(endpoint)?; + let client = Client::builder() + .timeout(DEFAULT_REQUEST_TIMEOUT) + .build() + .map_err(|source| BackendError::Request { + operation: "create client", + source: source.into(), + })?; + Ok(Self { endpoint, client }) + } + + pub fn endpoint(&self) -> &Url { + &self.endpoint + } + + pub async fn wait_until_ready( + &self, + mount_id: &str, + timeout: Duration, + ) -> Result { + validate_identifier("mount_id", mount_id)?; + let deadline = Instant::now() + timeout; + + loop { + let response = self.ready(mount_id).await?; + if response.ready { + return Ok(response); + } + if Instant::now() >= deadline { + return Err(BackendError::ReadinessTimeout { + mount_id: mount_id.to_string(), + timeout, + }); + } + sleep(DEFAULT_READY_POLL_INTERVAL).await; + } + } + + fn url(&self, segments: &[&str]) -> Result { + let mut url = self.endpoint.clone(); + { + let mut path = url.path_segments_mut().map_err(|_| { + BackendError::InvalidEndpoint( + "the endpoint cannot be used as a hierarchical URL".to_string(), + ) + })?; + path.pop_if_empty(); + for segment in segments { + path.push(segment); + } + } + Ok(url) + } + + async fn status_error(operation: &'static str, response: reqwest::Response) -> BackendError { + let status = response.status(); + let message = response + .text() + .await + .unwrap_or_else(|_| "response body was unreadable".to_string()); + BackendError::HttpStatus { + operation, + status, + message: truncate_message(&message), + } + } +} + +#[async_trait] +impl ScorpioFsControl for HttpScorpioFsClient { + async fn service_info(&self) -> Result { + let operation = "service info"; + let response = self + .client + .get(self.url(&["health"])?) + .send() + .await + .with_context(|| "failed to reach the ScorpioFS health endpoint") + .map_err(|source| BackendError::Request { operation, source })?; + if !response.status().is_success() { + return Err(Self::status_error(operation, response).await); + } + + #[derive(Deserialize)] + struct Health { + #[serde(default = "default_service_name")] + service: String, + #[serde(default, alias = "version")] + service_version: Option, + #[serde(default)] + protocol_version: Option, + #[serde(default)] + capabilities: Vec, + } + + let health: Health = response + .json() + .await + .with_context(|| "ScorpioFS health returned invalid JSON") + .map_err(|source| BackendError::Request { operation, source })?; + let info = ServiceInfo { + protocol_version: health.protocol_version.unwrap_or(PROTOCOL_VERSION), + service: health.service, + service_version: health.service_version, + capabilities: health.capabilities, + }; + info.validate()?; + Ok(info) + } + + async fn mount(&self, request: &MountRequest) -> Result { + request.validate()?; + let operation = "mount"; + let response = self + .client + .post(self.url(&["mounts"])?) + .json(request) + .send() + .await + .with_context(|| "failed to send the ScorpioFS mount request") + .map_err(|source| BackendError::Request { operation, source })?; + if !response.status().is_success() { + return Err(Self::status_error(operation, response).await); + } + let mount: MountResponse = response + .json() + .await + .with_context(|| "ScorpioFS mount returned invalid JSON") + .map_err(|source| BackendError::Request { operation, source })?; + validate_identifier("mount_id", &mount.mount_id)?; + if mount.mountpoint.trim().is_empty() { + return Err(BackendError::InvalidRecord( + "ScorpioFS returned an empty mountpoint".to_string(), + )); + } + Ok(mount) + } + + async fn ready(&self, mount_id: &str) -> Result { + validate_identifier("mount_id", mount_id)?; + let operation = "mount readiness"; + let response = self + .client + .get(self.url(&["mounts", mount_id, "ready"])?) + .send() + .await + .with_context(|| format!("failed to query ScorpioFS mount '{mount_id}' readiness")) + .map_err(|source| BackendError::Request { operation, source })?; + if !response.status().is_success() { + return Err(Self::status_error(operation, response).await); + } + response + .json() + .await + .with_context(|| "ScorpioFS readiness returned invalid JSON") + .map_err(|source| BackendError::Request { operation, source }) + } + + async fn changes(&self, mount_id: &str) -> Result { + validate_identifier("mount_id", mount_id)?; + let operation = "changed paths"; + let response = self + .client + .get(self.url(&["mounts", mount_id, "changes"])?) + .send() + .await + .with_context(|| format!("failed to query ScorpioFS mount '{mount_id}' changes")) + .map_err(|source| BackendError::Request { operation, source })?; + if !response.status().is_success() { + return Err(Self::status_error(operation, response).await); + } + let changes: ChangeSet = response + .json() + .await + .with_context(|| "ScorpioFS changed paths returned invalid JSON") + .map_err(|source| BackendError::Request { operation, source })?; + validate_identifier("mount_id", &changes.mount_id)?; + changes + .validate() + .map_err(|error| BackendError::InvalidChangedPath(error.to_string()))?; + Ok(changes) + } + + async fn delete_by_job(&self, job_id: &str) -> Result<(), BackendError> { + validate_identifier("job_id", job_id)?; + let operation = "delete mount"; + let response = self + .client + .delete(self.url(&["mounts", "by-job", job_id])?) + .send() + .await + .with_context(|| format!("failed to delete ScorpioFS job '{job_id}'")) + .map_err(|source| BackendError::Request { operation, source })?; + if response.status() == StatusCode::NOT_FOUND { + return Ok(()); + } + if !response.status().is_success() { + return Err(Self::status_error(operation, response).await); + } + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct ScorpioFsDriver { + client: HttpScorpioFsClient, +} + +impl ScorpioFsDriver { + pub fn new(client: HttpScorpioFsClient) -> Self { + Self { client } + } +} + +#[async_trait] +impl WorktreeBackendDriver for ScorpioFsDriver { + fn descriptor(&self) -> BackendDescriptor { + BackendDescriptor::scorpiofs(true) + } + + async fn mount( + &self, + request: &BackendMountRequest, + ) -> Result { + let (remote_path, base_oid, change_layer) = match &request.source { + BackendMountSource::RemoteProjection { + remote_path, + base_oid, + change_layer, + } => (remote_path, base_oid, change_layer), + BackendMountSource::LocalDirectory { .. } => { + return Err(WorktreeBackendError::UnsupportedSource { + backend: BackendKind::ScorpioFs, + detail: "local_directory", + }); + } + BackendMountSource::PersistentVolume { .. } => { + return Err(WorktreeBackendError::UnsupportedSource { + backend: BackendKind::ScorpioFs, + detail: "persistent_volume", + }); + } + }; + + let service = self.client.service_info().await.map_err(|error| { + WorktreeBackendError::operation(BackendKind::ScorpioFs, "service_info", error) + })?; + for capability in [ + CAPABILITY_MOUNT_V1, + CAPABILITY_READY_V1, + CAPABILITY_CHANGES_V1, + ] { + service.require(capability).map_err(|error| { + WorktreeBackendError::operation( + BackendKind::ScorpioFs, + "capability_negotiation", + error, + ) + })?; + } + + let scorpio_request = MountRequest { + job_id: request.instance_id.clone(), + path: remote_path.clone(), + cl: change_layer.clone(), + base_oid: base_oid.clone(), + }; + let mount = self.client.mount(&scorpio_request).await.map_err(|error| { + WorktreeBackendError::operation(BackendKind::ScorpioFs, "mount", error) + })?; + if mount.ready != Some(true) { + if let Err(error) = self + .client + .wait_until_ready( + &mount.mount_id, + Duration::from_secs(request.ready_timeout_secs), + ) + .await + { + let _ = self.client.delete_by_job(&request.instance_id).await; + return Err(WorktreeBackendError::operation( + BackendKind::ScorpioFs, + "wait_until_ready", + error, + )); + } + } + + Ok(BackendMountSession { + backend: BackendKind::ScorpioFs, + session_id: mount.mount_id, + mountpoint: PathBuf::from(mount.mountpoint), + cleanup_key: request.instance_id.clone(), + base_oid: mount.base_oid.or_else(|| base_oid.clone()), + }) + } + + async fn health( + &self, + session: &BackendMountSession, + ) -> Result { + let response = self + .client + .ready(&session.session_id) + .await + .map_err(|error| { + WorktreeBackendError::operation(BackendKind::ScorpioFs, "health", error) + })?; + Ok(BackendHealth { + ready: response.ready, + detail: response.detail.or(response.status), + }) + } + + async fn changed_paths( + &self, + session: &BackendMountSession, + ) -> Result, WorktreeBackendError> { + self.client + .changes(&session.session_id) + .await + .map(Some) + .map_err(|error| { + WorktreeBackendError::operation(BackendKind::ScorpioFs, "changed_paths", error) + }) + } + + async fn unmount(&self, session: &BackendMountSession) -> Result<(), WorktreeBackendError> { + self.client + .delete_by_job(&session.cleanup_key) + .await + .map_err(|error| { + WorktreeBackendError::operation(BackendKind::ScorpioFs, "unmount", error) + }) + } +} + +/// Return the changed-path set for the current worktree when it is backed by +/// ScorpioFS. Ordinary Libra worktrees return `None` without making a request. +pub async fn current_worktree_changes() -> Result, BackendError> { + let gitdir = crate::utils::util::try_get_worktree_gitdir(None) + .map_err(BackendError::MetadataDiscovery)?; + let record_path = gitdir.join(BACKEND_RECORD_FILE); + if !record_path.exists() { + return Ok(None); + } + + let record = ScorpioFsBackendRecord::load(&gitdir)?; + let client = HttpScorpioFsClient::new(&record.endpoint)?; + let driver = ScorpioFsDriver::new(client); + let session = BackendMountSession { + backend: BackendKind::ScorpioFs, + session_id: record.mount_id, + mountpoint: PathBuf::new(), + cleanup_key: record.job_id, + base_oid: record.base_oid, + }; + driver + .changed_paths(&session) + .await + .map_err(|error| BackendError::InvalidRecord(error.to_string())) +} + +fn validate_endpoint(endpoint: &str) -> Result { + let mut url = + Url::parse(endpoint).map_err(|error| BackendError::InvalidEndpoint(error.to_string()))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(BackendError::InvalidEndpoint( + "only http and https are supported by the initial transport".to_string(), + )); + } + if url.host_str().is_none() { + return Err(BackendError::InvalidEndpoint( + "the endpoint must include a host".to_string(), + )); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(BackendError::InvalidEndpoint( + "credentials must not be embedded in the endpoint URL".to_string(), + )); + } + if url.query().is_some() || url.fragment().is_some() { + return Err(BackendError::InvalidEndpoint( + "query strings and fragments are not allowed".to_string(), + )); + } + + let normalized = url.path().trim_end_matches('/').to_string(); + url.set_path(&normalized); + Ok(url) +} + +fn validate_identifier(field: &'static str, value: &str) -> Result<(), BackendError> { + if value.is_empty() { + return Err(BackendError::InvalidIdentifier { + field, + message: "value cannot be empty".to_string(), + }); + } + if value.len() > 255 { + return Err(BackendError::InvalidIdentifier { + field, + message: "value exceeds 255 bytes".to_string(), + }); + } + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(BackendError::InvalidIdentifier { + field, + message: "only ASCII letters, digits, '-', '_', and '.' are allowed".to_string(), + }); + } + Ok(()) +} + +fn validate_remote_path(path: &str) -> Result<(), BackendError> { + if !path.starts_with('/') { + return Err(BackendError::InvalidRemotePath( + "path must be absolute within the monorepo".to_string(), + )); + } + if path.contains('\0') || path.split('/').any(|part| part == "..") { + return Err(BackendError::InvalidRemotePath( + "path must not contain NUL or parent traversal".to_string(), + )); + } + Ok(()) +} + +fn validate_object_id(object_id: &str) -> Result<(), BackendError> { + if !matches!(object_id.len(), 40 | 64) + || !object_id.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(BackendError::InvalidObjectId( + "expected a 40- or 64-character hexadecimal object id".to_string(), + )); + } + Ok(()) +} + +fn truncate_message(message: &str) -> String { + const MAX_CHARS: usize = 2048; + let message = message.trim(); + if message.chars().count() <= MAX_CHARS { + message.to_string() + } else { + format!("{}...", message.chars().take(MAX_CHARS).collect::()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoint_rejects_credentials_and_query_data() { + assert!(matches!( + HttpScorpioFsClient::new("http://user:secret@localhost:2725/antares"), + Err(BackendError::InvalidEndpoint(_)) + )); + assert!(matches!( + HttpScorpioFsClient::new("http://localhost:2725/antares?token=secret"), + Err(BackendError::InvalidEndpoint(_)) + )); + } + + #[test] + fn endpoint_appends_antares_routes_without_replacing_prefix() { + let client = + HttpScorpioFsClient::new("http://127.0.0.1:2725/antares/").expect("valid endpoint"); + let url = client + .url(&["mounts", "mount-1", "ready"]) + .expect("valid route"); + assert_eq!( + url.as_str(), + "http://127.0.0.1:2725/antares/mounts/mount-1/ready" + ); + } + + #[test] + fn changed_paths_reject_absolute_and_parent_paths() { + for path in ["/absolute", "../outside", "src/../outside", "src//lib.rs"] { + let changed = ChangedPath { + kind: ChangeKind::Modified, + path: path.to_string(), + source_path: None, + }; + assert!(changed.validate().is_err(), "{path} must be rejected"); + } + } + + #[test] + fn backend_record_round_trips_without_credentials() { + let endpoint = Url::parse("http://127.0.0.1:2725/antares").expect("valid URL"); + let request = MountRequest { + job_id: "build-123".to_string(), + path: "/project/aardvark-dns".to_string(), + cl: Some("1XFJ4PGK".to_string()), + base_oid: None, + }; + let mount = MountResponse { + mount_id: "mount-123".to_string(), + mountpoint: "/var/lib/scorpiofs/antares/mnt/mount-123".to_string(), + base_oid: None, + ready: Some(false), + }; + let record = + ScorpioFsBackendRecord::new(&endpoint, &mount, &request).expect("valid record"); + let encoded = serde_json::to_string(&record).expect("serialize record"); + let decoded: ScorpioFsBackendRecord = + serde_json::from_str(&encoded).expect("deserialize record"); + + assert_eq!(decoded, record); + assert!(!encoded.contains("secret")); + decoded.validate().expect("record remains valid"); + } + + #[test] + fn renamed_change_requires_a_source_path() { + let change = ChangedPath { + kind: ChangeKind::Renamed, + path: "src/new.rs".to_string(), + source_path: None, + }; + assert!(matches!( + change.validate(), + Err(WorktreeBackendError::InvalidChangedPath(_)) + )); + } + + #[test] + fn change_set_candidates_include_rename_sources_and_are_deduplicated() { + let changes = ChangeSet { + mount_id: "mount-1".to_string(), + generation: 1, + changes: vec![ + ChangedPath { + kind: ChangeKind::Modified, + path: "src/new.rs".to_string(), + source_path: None, + }, + ChangedPath { + kind: ChangeKind::Renamed, + path: "src/new.rs".to_string(), + source_path: Some("src/old.rs".to_string()), + }, + ], + }; + + assert_eq!( + changes.candidate_paths(), + vec![PathBuf::from("src/new.rs"), PathBuf::from("src/old.rs")] + ); + } + + #[test] + fn libra_state_owns_mount_lifecycle_transitions() { + let temp = tempfile::tempdir().expect("temporary state root"); + let request = MountRequest { + job_id: "build-123".to_string(), + path: "/project/aardvark-dns".to_string(), + cl: None, + base_oid: None, + }; + let mount = MountResponse { + mount_id: "mount-123".to_string(), + mountpoint: "/mnt/aardvark-dns".to_string(), + base_oid: None, + ready: Some(true), + }; + + let mut state = LibraScorpioFsState::default(); + state + .begin_mount( + request, + BackendTransport::ManagedCrate, + "http://127.0.0.1:2725".to_string(), + ) + .expect("begin mount"); + assert_eq!( + state.mounts["build-123"].lifecycle, + BackendLifecycle::Mounting + ); + state + .mark_ready("build-123", &mount, "scorpiofs-worktree") + .expect("mark ready"); + state.save(temp.path()).expect("save state"); + + let loaded = LibraScorpioFsState::load(temp.path()).expect("load state"); + let desired = &loaded.mounts["build-123"]; + assert_eq!(desired.lifecycle, BackendLifecycle::Ready); + assert_eq!(desired.transport, BackendTransport::ManagedCrate); + assert_eq!(desired.mount_id.as_deref(), Some("mount-123")); + assert_eq!(desired.worktree_id.as_deref(), Some("scorpiofs-worktree")); + } + + #[test] + fn libra_state_lock_serializes_updates() { + let temp = tempfile::tempdir().unwrap(); + let first = ScorpioFsStateLock::acquire(temp.path()).unwrap(); + let storage = temp.path().to_path_buf(); + let acquired = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let acquired_in_thread = acquired.clone(); + + let waiter = std::thread::spawn(move || { + let _second = ScorpioFsStateLock::acquire(&storage).unwrap(); + acquired_in_thread.store(true, std::sync::atomic::Ordering::SeqCst); + }); + + std::thread::sleep(Duration::from_millis(50)); + assert!(!acquired.load(std::sync::atomic::Ordering::SeqCst)); + drop(first); + waiter.join().unwrap(); + assert!(acquired.load(std::sync::atomic::Ordering::SeqCst)); + } + + #[test] + fn libra_state_rejects_reusing_a_job_for_another_path() { + let mut state = LibraScorpioFsState::default(); + state + .begin_mount( + MountRequest { + job_id: "build-123".to_string(), + path: "/project/a".to_string(), + cl: None, + base_oid: None, + }, + BackendTransport::ManagedCrate, + "http://127.0.0.1:2725".to_string(), + ) + .expect("first mount"); + + assert!(matches!( + state.begin_mount( + MountRequest { + job_id: "build-123".to_string(), + path: "/project/b".to_string(), + cl: None, + base_oid: None, + }, + BackendTransport::ManagedCrate, + "http://127.0.0.1:2725".to_string(), + ), + Err(BackendError::InvalidRecord(_)) + )); + } +} diff --git a/src/internal/worktree_backend.rs b/src/internal/worktree_backend.rs index 3a6d0b707..da8e1a4c4 100644 --- a/src/internal/worktree_backend.rs +++ b/src/internal/worktree_backend.rs @@ -294,10 +294,10 @@ impl ChangeSet { pub enum WorktreeBackendError { #[error("invalid worktree backend request: {0}")] InvalidRequest(String), - #[error("backend '{backend}' does not support source type '{source}'")] + #[error("backend '{backend}' does not support source type '{detail}'")] UnsupportedSource { backend: BackendKind, - source: &'static str, + detail: &'static str, }, #[error("worktree backend '{backend}' is unavailable: {reason}")] Unavailable { @@ -350,17 +350,11 @@ pub trait WorktreeBackendDriver: Send + Sync { Ok(None) } - async fn flush( - &self, - _session: &BackendMountSession, - ) -> Result<(), WorktreeBackendError> { + async fn flush(&self, _session: &BackendMountSession) -> Result<(), WorktreeBackendError> { Ok(()) } - async fn unmount( - &self, - session: &BackendMountSession, - ) -> Result<(), WorktreeBackendError>; + async fn unmount(&self, session: &BackendMountSession) -> Result<(), WorktreeBackendError>; async fn recover( &self, diff --git a/tests/command/worktree_test.rs b/tests/command/worktree_test.rs index 3a85d7363..cb46feb43 100644 --- a/tests/command/worktree_test.rs +++ b/tests/command/worktree_test.rs @@ -6,6 +6,7 @@ use std::fs; #[cfg(unix)] use std::os::unix::fs::{MetadataExt, PermissionsExt, symlink}; +use axum::{Json, Router}; use clap::Parser; use libra::{ command::{ @@ -124,6 +125,145 @@ fn assert_worktree_error(output: &std::process::Output, error_code: &str) -> Cli report } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn test_scorpiofs_attach_is_persistent_idempotent_and_detachable() { + let repo_dir = tempdir().unwrap(); + let mount_dir = tempdir().unwrap(); + test::setup_with_new_libra_in(repo_dir.path()).await; + let _guard = test::ChangeDirGuard::new(repo_dir.path()); + + let mountpoint = mount_dir.path().canonicalize().unwrap(); + fs::write(mountpoint.join("hello.txt"), "hello from ScorpioFS\n").unwrap(); + fs::write( + mountpoint.join("unreported-local-artifact.txt"), + "not part of the ScorpioFS change set\n", + ) + .unwrap(); + let mountpoint_json = mountpoint.to_string_lossy().into_owned(); + let mount_response_path = mountpoint_json.clone(); + let app = Router::new() + .route( + "/health", + axum::routing::get(|| async { + Json(serde_json::json!({ + "protocol_version": 1, + "service": "scorpiofs", + "service_version": "test", + "capabilities": ["mount.v1", "ready.v1", "changes.v1"], + "status": "healthy", + "mount_count": 1, + "uptime_secs": 0 + })) + }), + ) + .route( + "/mounts", + axum::routing::post(move || { + let mountpoint = mount_response_path.clone(); + async move { + Json(serde_json::json!({ + "mount_id": "11111111-1111-4111-8111-111111111111", + "mountpoint": mountpoint, + "ready": true + })) + } + }), + ) + .route( + "/mounts/11111111-1111-4111-8111-111111111111/changes", + axum::routing::get(|| async { + Json(serde_json::json!({ + "mount_id": "11111111-1111-4111-8111-111111111111", + "generation": 0, + "changes": [{ + "kind": "modified", + "path": "hello.txt" + }] + })) + }), + ) + .route( + "/mounts/by-job/dev-project", + axum::routing::delete(|| async { Json(serde_json::json!({"deleted": true})) }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + exec_worktree(&[ + "scorpiofs", + "attach", + "--endpoint", + &endpoint, + "--remote-path", + "/project", + "--job-id", + "dev-project", + ]) + .await + .expect("ScorpioFS attach should succeed"); + exec_worktree(&[ + "scorpiofs", + "attach", + "--endpoint", + &endpoint, + "--remote-path", + "/project", + "--job-id", + "dev-project", + ]) + .await + .expect("repeated ScorpioFS attach should be idempotent"); + + let pointer = mountpoint.join(util::ROOT_DIR); + assert!( + pointer.is_file(), + "mount root should contain a .libra pointer" + ); + let gitdir = util::try_get_worktree_gitdir(Some(mountpoint.clone())).unwrap(); + assert!(gitdir.join("backend.json").is_file()); + assert_eq!( + read_worktree_state() + .worktrees + .iter() + .filter(|entry| entry.path == mountpoint_json) + .count(), + 1 + ); + + let status = run_libra_command(&["status", "--porcelain"], &mountpoint); + assert_cli_success(&status, "ScorpioFS worktree status"); + assert!( + String::from_utf8_lossy(&status.stdout).contains("hello.txt"), + "changed-path candidate should be visible to status" + ); + let add = run_libra_command(&["add", "hello.txt"], &mountpoint); + assert_cli_success(&add, "ScorpioFS worktree add"); + let commit = run_libra_command( + &["commit", "-m", "test ScorpioFS-backed commit"], + &mountpoint, + ); + assert_cli_success(&commit, "ScorpioFS worktree commit"); + let status = run_libra_command(&["status", "--porcelain"], &mountpoint); + assert_cli_success(&status, "clean ScorpioFS worktree status"); + assert!( + status.stdout.is_empty(), + "committed ScorpioFS worktree should be clean: {}", + String::from_utf8_lossy(&status.stdout) + ); + + exec_worktree(&["scorpiofs", "detach", &mountpoint_json]) + .await + .expect("ScorpioFS detach should succeed"); + assert!(!pointer.exists()); + assert!(!gitdir.exists()); + + server.abort(); +} + #[tokio::test] #[serial] async fn test_worktree_list_json_outputs_structured_entries() { From 9a4736fa6b5b006de43969740841119076b13861 Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Sun, 9 Aug 2026 12:02:04 +0800 Subject: [PATCH 5/9] fix(gc): classify /scorpiofs as a non-root GC source The ScorpioFS backend creates `/scorpiofs`, which the `every_storage_rooted_file_is_a_classified_gc_source` guard requires to appear in `GC_OBJECT_FILE_SOURCE_INVENTORY` or `NOT_AN_OBJECT_SOURCE`. Register it as `NonRoot` rather than excluding it: `base_oid` names a REMOTE monorepo revision the mount projects, not a local object-store id, so rooting it would demand objects this repository never wrote -- the same shape as MERGE_RR. Libra owns the durable desired state here precisely because the embedded worker persists none; the mount's own Git writes reach the object store through the ordinary commit path, which roots them. Co-Authored-By: Claude Opus 5 --- src/command/maintenance.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/command/maintenance.rs b/src/command/maintenance.rs index 704f9fad4..7ed0ce264 100644 --- a/src/command/maintenance.rs +++ b/src/command/maintenance.rs @@ -2821,6 +2821,17 @@ pub const GC_OBJECT_FILE_SOURCE_INVENTORY: &[GcObjectSource] = &[ corruption: GcCorruptionPolicy::NotApplicable, note: "the shared rr-cache stores pre/postimage FILE BYTES under conflict-id directories, outside the object store entirely; `rerere gc` ages them out on its own schedule", }, + GcObjectSource { + origin: GcSourceOrigin::File, + location: "/scorpiofs", + column: "", + status: GcSourceStatus::NonRoot, + kind: GcStorageKind::JsonManifest, + schema: "LibraScorpioFsState (schema_version, worker record, mounts[] with request.base_oid), plus the state lock and the managed worker's log", + read_bound: "never read by GC", + corruption: GcCorruptionPolicy::NotApplicable, + note: "the ScorpioFS backend's DESIRED-state file: `base_oid` names a REMOTE monorepo revision the mount projects, not a local object-store id, so rooting it would demand objects this repository never wrote (same shape as MERGE_RR). Libra owns the durable desired state here precisely because the embedded worker persists none; the mount's own Git writes land in the object store through the ordinary commit path, which roots them", + }, GcObjectSource { origin: GcSourceOrigin::File, location: "/merge-file-backup", From a05d3491df3651af19cc95aa19696b6fb6ad0a1f Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Sun, 9 Aug 2026 12:02:27 +0800 Subject: [PATCH 6/9] test: repair the pre-existing compat-offline-core failures Unrelated to ScorpioFS, but `compat-offline-core` cannot go green without them. Attribution: run 31259005383 (branch codex/fix-windows-release-build, same day, no shared files) fails on the byte-identical set of tests, so none of these are caused by this PR. They are main's own, introduced after the last green run on 2026-07-27. - worktree_scope (0ce8f77c): five tests call `std::env::set_current_dir` directly. `#[serial]` only orders them against other `#[serial]` tests, not against the CWD_LOCK every `ChangeDirGuard` holds, so they yanked the process cwd out from under whatever fixture was mid-flight. Take the lock. `a_pinned_scope_survives_a_cwd_change` additionally pinned the ambient cwd, which `RequestScope::resolve` refuses outside a repository -- it only ever passed by stealing a moment when another test had parked the cwd in someone else's fixture, which is the same moment it broke that test. Give it a real repository. - ai/hooks/runtime (6da73c67): `CaptureScope` entered the ingest path and resolves `RepoIdentity`, but `ingest_fresh_conn` never wrote `libra.repoid`, so 14 tests failed deterministically. Seed it, as `libra init` does. `ingest_fails_loud_when_table_missing` now drops one table from a complete schema instead of using a schema-less database, so it still tests what it claims; the tombstone test asserts against the claim diagnostic that now carries the refusal. - utils/path: the commondir fixture's target lacked the repository database, so it is no longer TERMINAL common storage. - rename_detect (5c1b7bb2, a26cc9ac): the LFS classification resolves `working_dir()` infallibly on the pooled io thread, which panicked the worker outside a repository and surfaced to the caller as `IoTimeout`. Give both tests a repository. A jammed pool reports `IoTimeout` too, and the pool is process-global -- the seam tests abandon reads that keep sleeping in a worker past their own end -- so the negative test drains the pool and retries. A seam that really fired would still blow every attempt. Verified: `cargo test --lib` 4403 passed, 0 failed. Co-Authored-By: Claude Opus 5 --- src/command/rename_detect.rs | 44 ++++++++++++++++++++++++++++++-- src/command/status_probe.rs | 18 +++++++++++++ src/internal/ai/hooks/runtime.rs | 37 ++++++++++++++++++++------- src/internal/worktree_scope.rs | 34 +++++++++++++++++++++++- src/utils/path.rs | 5 ++++ 5 files changed, 126 insertions(+), 12 deletions(-) diff --git a/src/command/rename_detect.rs b/src/command/rename_detect.rs index 8c355b80c..069dcba33 100644 --- a/src/command/rename_detect.rs +++ b/src/command/rename_detect.rs @@ -1325,6 +1325,22 @@ impl WorktreeReadBudget { mod tests { use super::*; + /// Make `root` discoverable as a repository and step the process cwd into + /// it, returning the guard that restores the cwd. + /// + /// Deliberately NOT a full `libra init`: the worktree read path only needs + /// repository DISCOVERY to succeed (`util::working_dir`, for the LFS + /// attribute lookup), and a terminal common storage is a gitdir carrying + /// the repository database. Standing up a real database here would drag a + /// connection pool into a test about byte accounting. + fn repo_fixture(root: &Path) -> crate::utils::test::ChangeDirGuard { + let gitdir = root.join(crate::utils::util::ROOT_DIR); + std::fs::create_dir_all(gitdir.join("objects")).expect("create object store"); + std::fs::write(gitdir.join(crate::utils::util::DATABASE), b"") + .expect("create repository db"); + crate::utils::test::ChangeDirGuard::new(root) + } + fn regular(evidence: BlobEvidence, size: u64) -> BlobRef { BlobRef { kind: BlobKind::Regular, @@ -2288,6 +2304,12 @@ mod tests { const SHRUNK: u64 = 4; const GROWTH: u64 = 4096; let dir = tempfile::tempdir().expect("tempdir"); + // The read path classifies LFS through `attribute_state_for_path`, + // which resolves `working_dir()` — infallibly, from the PROCESS cwd, + // on the pooled io thread. Outside a repository that panics the + // worker and the caller only ever sees the resulting `IoTimeout`, + // so the fixture has to be a repository. + let _repo = repo_fixture(dir.path()); let path = dir.path().join("grows.txt"); std::fs::write(&path, vec![b'a'; (SHRUNK + GROWTH) as usize]) .expect("write the full-size file"); @@ -2456,6 +2478,11 @@ mod tests { use std::time::Duration; let dir = tempfile::tempdir().expect("tempdir"); + // See `worktree_total_charges_bytes_read_not_stale_stat`: the LFS + // classification resolves `working_dir()` on the io thread, so a + // repo-less fixture fails with `IoTimeout` no matter what the seams + // are doing — which is precisely the outcome this test denies. + let _repo = repo_fixture(dir.path()); let path = dir.path().join("fast.txt"); std::fs::write(&path, b"content").expect("write fixture"); @@ -2466,8 +2493,21 @@ mod tests { std::env::set_var("LIBRA_TEST_SLOW_WORKTREE_READ_MS", "5000"); std::env::set_var("LIBRA_TEST_SLOW_LFS_ATTRIBUTES_MS", "5000"); } - let mut budget = WorktreeReadBudget::new(1024, 1024, 8, Duration::from_millis(1500)); - let outcome = budget.read_worktree_blob(&path); + // A jammed I/O pool and a fired seam both surface as `IoTimeout`, and + // the pool is process-global: the seam tests above abandon reads that + // keep sleeping in a worker, and their slots outlive them. So drain + // the pool first and retry — with the seams inert one attempt + // succeeds as soon as a worker is free, while a 5s seam that really + // did fire would blow the 1.5s batch on EVERY attempt. + let mut outcome = ContentOutcome::Skipped(SkipReason::IoTimeout); + for _ in 0..8 { + crate::command::status_probe::wait_for_idle_io_pool(); + let mut budget = WorktreeReadBudget::new(1024, 1024, 8, Duration::from_millis(1500)); + outcome = budget.read_worktree_blob(&path); + if matches!(outcome, ContentOutcome::Content(_)) { + break; + } + } unsafe { std::env::remove_var("LIBRA_TEST_SLOW_WORKTREE_STAT_MS"); std::env::remove_var("LIBRA_TEST_SLOW_WORKTREE_READ_MS"); diff --git a/src/command/status_probe.rs b/src/command/status_probe.rs index a4f2114e4..ed15cba45 100644 --- a/src/command/status_probe.rs +++ b/src/command/status_probe.rs @@ -129,6 +129,24 @@ static IO_POOL: std::sync::OnceLock> = std::sync::O static IO_BUSY: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); static IO_WORKERS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +/// Block until every pooled slot has been released. +/// +/// The deadline tests deliberately ABANDON reads: the caller gives up at its +/// deadline while the worker keeps sleeping inside the armed seam, and the +/// slot stays busy until that sleep ends — outliving the test that started +/// it. A later test whose contract is "this must NOT time out" therefore has +/// to start from an idle pool, or it measures the previous test's leftover +/// delay and fails for a reason that has nothing to do with what it asserts. +#[cfg(test)] +pub(crate) fn wait_for_idle_io_pool() { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while IO_BUSY.load(std::sync::atomic::Ordering::SeqCst) > 0 + && std::time::Instant::now() < deadline + { + std::thread::sleep(std::time::Duration::from_millis(20)); + } +} + /// Run one blocking filesystem operation with a wall-clock deadline. /// /// A hung syscall cannot be interrupted in safe Rust, so the operation runs diff --git a/src/internal/ai/hooks/runtime.rs b/src/internal/ai/hooks/runtime.rs index f8cef819f..77cb39911 100644 --- a/src/internal/ai/hooks/runtime.rs +++ b/src/internal/ai/hooks/runtime.rs @@ -3504,6 +3504,13 @@ mod tests { run_builtin_migrations(&conn) .await .expect("run_builtin_migrations"); + // `libra init` writes `libra.repoid` as part of bootstrap, and every + // ingest path resolves its `CaptureScope` through `RepoIdentity` — a + // fixture without it is not a repository this code will write to, it + // is one with corrupt metadata. + ConfigKv::set_with_conn(&conn, "libra.repoid", "repo-ingest-fixture", false) + .await + .expect("seed libra.repoid"); (dir, conn) } @@ -3607,8 +3614,14 @@ mod tests { ) .await .expect_err("stale hook must not resurrect an erased session"); + // The tombstone is now enforced inside the scope-claim upsert's WHERE + // clause rather than by a separate pre-check, so the refusal surfaces + // through the claim diagnostic. The row-count assertion below is the + // contract that matters; this one only pins that erasure is named as a + // cause rather than the write silently succeeding. assert!( - err.to_string().contains("anti-resurrection tombstone"), + err.to_string().contains("could not be claimed") + && err.to_string().contains("erased"), "unexpected error: {err:#}" ); @@ -4030,14 +4043,20 @@ mod tests { #[tokio::test] async fn ingest_fails_loud_when_table_missing() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("noschema.db"); - std::fs::File::create(&path).expect("touch sqlite file"); - let url = format!("sqlite://{}", path.display()); - let mut opts = ConnectOptions::new(url); - opts.sqlx_logging(false); - let conn = Database::connect(opts).await.expect("connect"); - // intentionally NOT calling run_builtin_migrations. + // A fully bootstrapped repository with exactly ONE table removed. A + // schema-less database would fail earlier and for a different reason + // (`config_kv` is read first, to resolve the capture scope), which + // would leave the claim this test actually makes — that a missing + // `agent_session` is reported loudly and by name — untested. + let (_dir, conn) = ingest_fresh_conn().await; + let backend = conn.get_database_backend(); + let _: ExecResult = conn + .execute_raw(Statement::from_string( + backend, + "DROP TABLE agent_session".to_string(), + )) + .await + .expect("drop agent_session"); let payload = ingest_envelope("SessionStart", "S-bare", json!({})); let err = ingest_agent_traces_payload( diff --git a/src/internal/worktree_scope.rs b/src/internal/worktree_scope.rs index 76fd2b54b..a63ef7c34 100644 --- a/src/internal/worktree_scope.rs +++ b/src/internal/worktree_scope.rs @@ -377,12 +377,32 @@ mod tests { #[test] #[serial_test::serial] fn a_pinned_scope_survives_a_cwd_change() { + // Moving the process cwd is not this test's business alone: every + // `ChangeDirGuard` in the suite reads the same one. `#[serial]` only + // orders this against other `#[serial]` tests, so hold the cwd lock + // the guard uses — otherwise the `set_current_dir` below yanks the + // directory out from under whatever repository fixture is mid-flight. + let _cwd_lock = crate::utils::test::cwd_lock_guard(); let original = std::env::current_dir().expect("cwd"); let elsewhere = std::env::temp_dir(); + // A REAL repository, because `pin_scope_for_test` resolves its paths + // through `RequestScope::resolve` and installs nothing when the + // workdir is not inside one — pinning the ambient cwd only appeared to + // work when another test had parked it in someone else's fixture. + let repo = tempfile::tempdir().expect("repo"); + { + let _cd = crate::utils::test::ChangeDirGuard::new(repo.path()); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime") + .block_on(crate::utils::test::setup_with_new_libra_in(repo.path())); + } + let _pin = WorktreeScope::pin_scope_for_test( WorktreeScope::Linked("wt-pinned".to_string()), - original.clone(), + repo.path().to_path_buf(), ); assert!(WorktreeScope::request_scope_is_pinned()); assert_eq!(WorktreeScope::for_request().storage_key(), "wt-pinned"); @@ -421,6 +441,9 @@ mod tests { #[test] #[serial_test::serial] fn a_pinned_workdir_keeps_sidecars_in_their_own_gitdir() { + // See `a_pinned_scope_survives_a_cwd_change`: the raw `set_current_dir` + // below is process-wide, so it has to hold `ChangeDirGuard`'s lock. + let _cwd_lock = crate::utils::test::cwd_lock_guard(); let repo = tempfile::tempdir().expect("repo"); let elsewhere = tempfile::tempdir().expect("elsewhere"); // A real repository, so the resolver has a storage root to find. @@ -473,6 +496,9 @@ mod tests { #[test] #[serial_test::serial] fn a_pinned_request_resolves_storage_and_worktree_root_once() { + // See `a_pinned_scope_survives_a_cwd_change`: the raw `set_current_dir` + // below is process-wide, so it has to hold `ChangeDirGuard`'s lock. + let _cwd_lock = crate::utils::test::cwd_lock_guard(); let repo = tempfile::tempdir().expect("repo"); let elsewhere = tempfile::tempdir().expect("elsewhere"); { @@ -521,6 +547,9 @@ mod tests { #[tokio::test] #[serial_test::serial] async fn the_request_database_follows_the_pin_not_the_cwd() { + // See `a_pinned_scope_survives_a_cwd_change`: the raw `set_current_dir` + // below is process-wide, so it has to hold `ChangeDirGuard`'s lock. + let _cwd_lock = crate::utils::test::cwd_lock_guard(); let repo_a = tempfile::tempdir().expect("repo a"); let repo_b = tempfile::tempdir().expect("repo b"); let original = std::env::current_dir().expect("cwd"); @@ -573,6 +602,9 @@ mod tests { #[tokio::test] #[serial_test::serial] async fn an_unresolvable_pin_installs_nothing_and_never_inherits() { + // See `a_pinned_scope_survives_a_cwd_change`: the raw `set_current_dir` + // below is process-wide, so it has to hold `ChangeDirGuard`'s lock. + let _cwd_lock = crate::utils::test::cwd_lock_guard(); let outer = tempfile::tempdir().expect("the enclosing repository"); let ambient = tempfile::tempdir().expect("the repository the cwd is in"); let nowhere = tempfile::tempdir().expect("not a repository"); diff --git a/src/utils/path.rs b/src/utils/path.rs index 11d9e6266..2e8f1c025 100644 --- a/src/utils/path.rs +++ b/src/utils/path.rs @@ -98,6 +98,11 @@ mod tests { let linked = root.path().join("linked"); let linked_gitdir = linked.join(".libra"); fs::create_dir_all(common.join("objects")).expect("create shared object store"); + // The commondir target must look like TERMINAL common storage + // (`util::is_terminal_common_storage`), which an object store alone + // does not: a real main gitdir also carries the repository database, + // and without it the pointer is refused as corruption. + fs::write(common.join(crate::utils::util::DATABASE), b"").expect("create repository db"); fs::create_dir_all(&linked_gitdir).expect("create linked worktree gitdir"); fs::write( linked_gitdir.join("commondir"), From b320b4384cf7c1d3b3becb157a5b3e75a5b8fdd6 Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Sun, 9 Aug 2026 12:19:33 +0800 Subject: [PATCH 7/9] style: rustfmt the tombstone assertion Co-Authored-By: Claude Opus 5 --- src/internal/ai/hooks/runtime.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/internal/ai/hooks/runtime.rs b/src/internal/ai/hooks/runtime.rs index 77cb39911..82b269b7e 100644 --- a/src/internal/ai/hooks/runtime.rs +++ b/src/internal/ai/hooks/runtime.rs @@ -3620,8 +3620,7 @@ mod tests { // contract that matters; this one only pins that erasure is named as a // cause rather than the write silently succeeding. assert!( - err.to_string().contains("could not be claimed") - && err.to_string().contains("erased"), + err.to_string().contains("could not be claimed") && err.to_string().contains("erased"), "unexpected error: {err:#}" ); From fe7ebc2ccbc25de42ecb41ab6bba9bcd7e10b23f Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Sun, 9 Aug 2026 20:23:02 +0800 Subject: [PATCH 8/9] ci: shard command_test so compat-offline-core fits the job cap `compat-offline-core` hit GitHub's 360-minute cap. It was not a slowdown: the lib suite ran in 2150s, the same as the 2125s it took on the last baseline. What changed is that the lib suite stopped failing, so `cargo test --all` no longer aborted there and reached the rest of the suite for the first time in weeks. The cost is concentrated in one target. `tests/command_test.rs` compiles 150 modules into a single binary -- deliberately, to avoid paying for one binary per command -- and ~1130 of its 2956 tests take the process-global cwd lock that `ChangeDirGuard` holds. It is therefore lock-bound, not CPU-bound: 479 of its tests were logged as running for over 60 seconds while the machine sat at ~0.3 load. Adding cores does nothing; only another PROCESS does. So split it out into `compat-offline-command`, a 2-shard matrix. Each shard is its own job, hence its own process and its own cwd lock, and the split scales with the runner pool rather than the core count. Coverage is identical to `cargo test --all`: lib, bins, doctests and the other 208 integration targets run in compat-offline-core, and command_test runs in the shards. The core job enumerates its targets from `cargo metadata` rather than a hand-written list, and both jobs refuse to run a suspiciously small set instead of silently passing on partial coverage. Shards partition by enumerated test NAME, not by module prefix: the modulo split is exhaustive and disjoint by construction, so a rename or a new test cannot fall out of every shard. Verified locally: 2956 tests, 1478 per shard, union 2956, intersection empty. Both jobs also get `timeout-minutes: 350` so an overrun fails as a timeout with its log intact rather than being reaped at the cap. Co-Authored-By: Claude Opus 5 --- .github/workflows/base.yml | 112 ++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/.github/workflows/base.yml b/.github/workflows/base.yml index 1a30e5f72..6c2d61ade 100644 --- a/.github/workflows/base.yml +++ b/.github/workflows/base.yml @@ -169,6 +169,10 @@ jobs: runs-on: [self-hosted] + # Below GitHub's 360-minute default so an overrun fails as a timeout with + # the log intact, rather than being reaped at the platform cap. + timeout-minutes: 350 + env: CARGO_TERM_COLOR: always RUSTUP_TOOLCHAIN: stable @@ -240,7 +244,34 @@ jobs: LIBRA_STORAGE_BUCKET: ${{ secrets.LIBRA_STORAGE_BUCKET }} LIBRA_STORAGE_ACCESS_KEY: ${{ secrets.LIBRA_STORAGE_ACCESS_KEY }} LIBRA_STORAGE_SECRET_KEY: ${{ secrets.LIBRA_STORAGE_SECRET_KEY }} - run: cargo test --all + run: | + set -euo pipefail + # `cargo test --all` minus the `command_test` binary, which + # `compat-offline-command` shards instead. That one target compiles + # 150 modules (~2950 tests) into a SINGLE process, and roughly a + # third of them serialize on the process-global cwd lock that every + # `ChangeDirGuard` takes — so it runs at about one core regardless of + # the machine. It went past the 360-minute cap the first time the lib + # suite stopped failing early and cargo actually reached it. + # + # Coverage is unchanged: lib, bins and doctests run here, so does + # every other integration target, and `command_test` runs there. + mapfile -t TARGETS < <( + cargo metadata --no-deps --format-version 1 \ + | jq -r '.packages[].targets[] | select(.kind[] == "test") | .name' \ + | grep -vx command_test | sort -u + ) + if [ "${#TARGETS[@]}" -lt 100 ]; then + echo "::error::enumerated only ${#TARGETS[@]} integration targets; refusing to run a truncated suite" + exit 1 + fi + echo "running lib + bins + doctests + ${#TARGETS[@]} integration targets" + ARGS=() + for target in "${TARGETS[@]}"; do + ARGS+=(--test "$target") + done + cargo test --lib --bins "${ARGS[@]}" + cargo test --doc # Phase 6 — Local TUI Automation Control scenario suite (docs/improvement/agent.md Part C). # Without `--features test-provider` + `LIBRA_ENABLE_TEST_PROVIDER=1`, the scenarios @@ -303,6 +334,85 @@ jobs: if-no-files-found: ignore retention-days: 7 + # The `command_test` half of what `cargo test --all` used to do in one job. + # See the note on compat-offline-core's test step for why it is split out: + # the binary is lock-bound rather than CPU-bound, so the only thing that + # shortens it is running it in more than one PROCESS. Shards are separate + # jobs, so each gets its own cwd lock and they scale with the runner pool. + command-tests: + name: compat-offline-command + + runs-on: [self-hosted] + + timeout-minutes: 350 + + strategy: + # One shard failing must not cancel the others: the point of the split is + # to see the whole binary's result in one run. + fail-fast: false + matrix: + shard: [0, 1] + + env: + CARGO_TERM_COLOR: always + RUSTUP_TOOLCHAIN: stable + LIBRA_SKIP_WEB_BUILD: "1" + CARGO_PROFILE_TEST_DEBUG: "0" + CARGO_BUILD_JOBS: "1" + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + submodules: recursive + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: "22" + + - name: Enable pnpm + run: | + corepack enable + corepack prepare pnpm@11.10.0 --activate + + - name: Run command_test shard ${{ matrix.shard }} + env: + LIBRA_TEST_GITHUB_TOKEN: ${{ secrets.LIBRA_TEST_GITHUB_TOKEN }} + LIBRA_TEST_GITHUB_NAMESPACE: ${{ secrets.LIBRA_TEST_GITHUB_NAMESPACE }} + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + LIBRA_D1_ACCOUNT_ID: ${{ secrets.LIBRA_D1_ACCOUNT_ID }} + LIBRA_D1_API_TOKEN: ${{ secrets.LIBRA_D1_API_TOKEN }} + LIBRA_D1_DATABASE_ID: ${{ secrets.LIBRA_D1_DATABASE_ID }} + LIBRA_STORAGE_ENDPOINT: ${{ secrets.LIBRA_STORAGE_ENDPOINT }} + LIBRA_STORAGE_BUCKET: ${{ secrets.LIBRA_STORAGE_BUCKET }} + LIBRA_STORAGE_ACCESS_KEY: ${{ secrets.LIBRA_STORAGE_ACCESS_KEY }} + LIBRA_STORAGE_SECRET_KEY: ${{ secrets.LIBRA_STORAGE_SECRET_KEY }} + run: | + set -euo pipefail + # Partition by ENUMERATED TEST NAME, not by module prefix. The modulo + # split is exhaustive and disjoint by construction, so a renamed or + # newly added test cannot silently fall out of every shard the way a + # hand-maintained filter list would eventually let one do. + cargo test --test command_test --no-run + mapfile -t ALL < <( + cargo test --test command_test -- --list --format terse \ + | sed -n 's/: test$//p' | sort + ) + total=${#ALL[@]} + if [ "$total" -lt 2000 ]; then + echo "::error::enumerated only $total command tests; refusing to run a truncated shard" + exit 1 + fi + MINE=() + for index in "${!ALL[@]}"; do + if [ "$(( index % 2 ))" -eq "${{ matrix.shard }}" ]; then + MINE+=("${ALL[$index]}") + fi + done + echo "shard ${{ matrix.shard }} of 2: ${#MINE[@]} of $total tests" + cargo test --test command_test -- --exact "${MINE[@]}" + network-remotes: name: compat-network-remotes runs-on: [self-hosted] From 939c89fe34e1f383f34f5db3f55661749ead946b Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Sun, 9 Aug 2026 21:58:51 +0800 Subject: [PATCH 9/9] fix(clippy): clear the lints the new main brought in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compat-clippy` failed on code this PR never touched. The cause is that a `pull_request` run checks out the MERGE of the branch and the current base, and main had moved 28 commits ahead of what this branch had merged — CI was linting code that was not in the working tree here. Five lints, all in main's own code and unrelated to ScorpioFS: single_match and type_complexity and needless_borrows_for_generic_args in graph.rs, needless_return in session/jsonl.rs, collapsible_if in tui/app.rs. The complex type becomes a named `SessionRecency` alias, which is also where the tie-breaking rule now gets written down. Also narrow the cwd-lock holds added earlier in this branch: the five worktree_scope tests took the lock BEFORE building their fixtures, so a whole `libra init` ran under a lock every `ChangeDirGuard` in the suite waits on. Building first and locking after keeps the guarantee the lock exists for — no raw `set_current_dir` outside it — without blocking the rest of the suite for the duration of a repository bootstrap. Co-Authored-By: Claude Opus 5 --- src/command/graph.rs | 22 +++++++------ src/internal/ai/session/jsonl.rs | 2 +- src/internal/tui/app.rs | 14 ++++---- src/internal/worktree_scope.rs | 55 +++++++++++++++++++------------- 4 files changed, 52 insertions(+), 41 deletions(-) diff --git a/src/command/graph.rs b/src/command/graph.rs index 8a61662d9..6439368ae 100644 --- a/src/command/graph.rs +++ b/src/command/graph.rs @@ -216,11 +216,10 @@ async fn load_thread_graph(storage_root: &Path, requested_thread_id: Uuid) -> Re let mut graph = ThreadGraph::from_projection(bundle, rows, object_details); // Overlay lookup must use the resolved canonical thread id: callers may // pass an intent UUID that projection remaps to its owning thread. - match load_code_ui_overlay_for_thread(storage_root, graph.thread_id)? { - Some((status, transcript_len, pending_interactions)) => { - graph = graph.with_code_ui_overlay(status, transcript_len, pending_interactions); - } - None => {} + if let Some((status, transcript_len, pending_interactions)) = + load_code_ui_overlay_for_thread(storage_root, graph.thread_id)? + { + graph = graph.with_code_ui_overlay(status, transcript_len, pending_interactions); } Ok(graph) } @@ -233,6 +232,12 @@ async fn load_thread_graph(storage_root: &Path, requested_thread_id: Uuid) -> Re /// Candidate sessions come from the durable thread→session index maintained by /// [`SessionStore::save`] / [`SessionStore::session_ids_for_thread`], so graph /// does not synchronously scan every historical session log. +/// How recently a session was written, as an ordering key: the events log's +/// mtime first, then the recorded `updated_at`, then the session id so two +/// sessions written in the same clock tick still order deterministically +/// rather than picking whichever the index happened to yield first. +type SessionRecency = (std::time::SystemTime, DateTime, String); + fn load_code_ui_overlay_for_thread( storage_root: &Path, thread_id: Uuid, @@ -243,10 +248,7 @@ fn load_code_ui_overlay_for_thread( format!("failed to resolve Code sessions for thread '{thread_id}' while building graph overlay") })?; - let mut latest: Option<( - crate::internal::ai::session::SessionState, - (std::time::SystemTime, DateTime, String), - )> = None; + let mut latest: Option<(crate::internal::ai::session::SessionState, SessionRecency)> = None; for session_id in candidate_ids { let session = match session_store.load(&session_id) { Ok(session) => session, @@ -1003,7 +1005,7 @@ impl ThreadGraph { transcript_len: usize, pending_interactions: usize, ) -> Self { - self.code_ui_status = serde_json::to_value(&status) + self.code_ui_status = serde_json::to_value(status) .ok() .and_then(|value| value.as_str().map(str::to_owned)); self.code_ui_transcript_len = transcript_len; diff --git a/src/internal/ai/session/jsonl.rs b/src/internal/ai/session/jsonl.rs index 2ab4d7dd9..a7b6d5b37 100644 --- a/src/internal/ai/session/jsonl.rs +++ b/src/internal/ai/session/jsonl.rs @@ -1942,7 +1942,7 @@ fn process_appears_alive( { return false; } - return true; + true } #[cfg(all(unix, not(target_os = "linux")))] { diff --git a/src/internal/tui/app.rs b/src/internal/tui/app.rs index 9238311ff..8331f7884 100644 --- a/src/internal/tui/app.rs +++ b/src/internal/tui/app.rs @@ -1102,13 +1102,13 @@ where if self.local_turn_runtime.is_none() && self.local_turn_runtime_task.is_none() { return; } - if let Some(previous) = self.local_turn_runtime.take() { - if let Err(error) = previous.shutdown().await { - tracing::warn!( - error = %error, - "failed to shut down previous local AgentRuntime before rebinding" - ); - } + if let Some(previous) = self.local_turn_runtime.take() + && let Err(error) = previous.shutdown().await + { + tracing::warn!( + error = %error, + "failed to shut down previous local AgentRuntime before rebinding" + ); } if let Some(task) = self.local_turn_runtime_task.take() { // Shutdown should have ended the actor; abort as a last resort so a diff --git a/src/internal/worktree_scope.rs b/src/internal/worktree_scope.rs index a63ef7c34..013f504c0 100644 --- a/src/internal/worktree_scope.rs +++ b/src/internal/worktree_scope.rs @@ -377,19 +377,15 @@ mod tests { #[test] #[serial_test::serial] fn a_pinned_scope_survives_a_cwd_change() { - // Moving the process cwd is not this test's business alone: every - // `ChangeDirGuard` in the suite reads the same one. `#[serial]` only - // orders this against other `#[serial]` tests, so hold the cwd lock - // the guard uses — otherwise the `set_current_dir` below yanks the - // directory out from under whatever repository fixture is mid-flight. - let _cwd_lock = crate::utils::test::cwd_lock_guard(); - let original = std::env::current_dir().expect("cwd"); - let elsewhere = std::env::temp_dir(); - // A REAL repository, because `pin_scope_for_test` resolves its paths // through `RequestScope::resolve` and installs nothing when the // workdir is not inside one — pinning the ambient cwd only appeared to // work when another test had parked it in someone else's fixture. + // + // Built BEFORE the cwd lock below: `setup_with_new_libra_in` takes and + // releases that lock itself, and running a whole `libra init` while + // holding it would block every other fixture in the suite for the + // duration. let repo = tempfile::tempdir().expect("repo"); { let _cd = crate::utils::test::ChangeDirGuard::new(repo.path()); @@ -400,6 +396,15 @@ mod tests { .block_on(crate::utils::test::setup_with_new_libra_in(repo.path())); } + // Moving the process cwd is not this test's business alone: every + // `ChangeDirGuard` in the suite reads the same one. `#[serial]` only + // orders this against other `#[serial]` tests, so hold the cwd lock + // the guard uses — otherwise the `set_current_dir` below yanks the + // directory out from under whatever repository fixture is mid-flight. + let _cwd_lock = crate::utils::test::cwd_lock_guard(); + let original = std::env::current_dir().expect("cwd"); + let elsewhere = std::env::temp_dir(); + let _pin = WorktreeScope::pin_scope_for_test( WorktreeScope::Linked("wt-pinned".to_string()), repo.path().to_path_buf(), @@ -441,9 +446,6 @@ mod tests { #[test] #[serial_test::serial] fn a_pinned_workdir_keeps_sidecars_in_their_own_gitdir() { - // See `a_pinned_scope_survives_a_cwd_change`: the raw `set_current_dir` - // below is process-wide, so it has to hold `ChangeDirGuard`'s lock. - let _cwd_lock = crate::utils::test::cwd_lock_guard(); let repo = tempfile::tempdir().expect("repo"); let elsewhere = tempfile::tempdir().expect("elsewhere"); // A real repository, so the resolver has a storage root to find. @@ -455,6 +457,10 @@ mod tests { .expect("runtime") .block_on(crate::utils::test::setup_with_new_libra_in(repo.path())); } + // See `a_pinned_scope_survives_a_cwd_change`: the raw `set_current_dir` + // below is process-wide, so it has to hold `ChangeDirGuard`'s lock — + // taken only now, so the `libra init` above does not run under it. + let _cwd_lock = crate::utils::test::cwd_lock_guard(); let original = std::env::current_dir().expect("cwd"); // Pin a SUBDIRECTORY, which is what a command invoked from one does. @@ -496,9 +502,6 @@ mod tests { #[test] #[serial_test::serial] fn a_pinned_request_resolves_storage_and_worktree_root_once() { - // See `a_pinned_scope_survives_a_cwd_change`: the raw `set_current_dir` - // below is process-wide, so it has to hold `ChangeDirGuard`'s lock. - let _cwd_lock = crate::utils::test::cwd_lock_guard(); let repo = tempfile::tempdir().expect("repo"); let elsewhere = tempfile::tempdir().expect("elsewhere"); { @@ -509,6 +512,10 @@ mod tests { .expect("runtime") .block_on(crate::utils::test::setup_with_new_libra_in(repo.path())); } + // See `a_pinned_scope_survives_a_cwd_change`: the raw `set_current_dir` + // below is process-wide, so it has to hold `ChangeDirGuard`'s lock — + // taken only now, so the `libra init` above does not run under it. + let _cwd_lock = crate::utils::test::cwd_lock_guard(); let original = std::env::current_dir().expect("cwd"); let canonical_repo = std::fs::canonicalize(repo.path()).unwrap_or_else(|_| repo.path().to_path_buf()); @@ -547,16 +554,17 @@ mod tests { #[tokio::test] #[serial_test::serial] async fn the_request_database_follows_the_pin_not_the_cwd() { - // See `a_pinned_scope_survives_a_cwd_change`: the raw `set_current_dir` - // below is process-wide, so it has to hold `ChangeDirGuard`'s lock. - let _cwd_lock = crate::utils::test::cwd_lock_guard(); let repo_a = tempfile::tempdir().expect("repo a"); let repo_b = tempfile::tempdir().expect("repo b"); - let original = std::env::current_dir().expect("cwd"); for repo in [repo_a.path(), repo_b.path()] { let _cd = crate::utils::test::ChangeDirGuard::new(repo); crate::utils::test::setup_with_new_libra_in(repo).await; } + // See `a_pinned_scope_survives_a_cwd_change`: the raw `set_current_dir` + // below is process-wide, so it has to hold `ChangeDirGuard`'s lock — + // taken only now, so the two `libra init`s above do not run under it. + let _cwd_lock = crate::utils::test::cwd_lock_guard(); + let original = std::env::current_dir().expect("cwd"); let _pin = WorktreeScope::pin_request_scope(repo_a.path().to_path_buf()); // The cwd is repository B; the pin is repository A. @@ -602,17 +610,18 @@ mod tests { #[tokio::test] #[serial_test::serial] async fn an_unresolvable_pin_installs_nothing_and_never_inherits() { - // See `a_pinned_scope_survives_a_cwd_change`: the raw `set_current_dir` - // below is process-wide, so it has to hold `ChangeDirGuard`'s lock. - let _cwd_lock = crate::utils::test::cwd_lock_guard(); let outer = tempfile::tempdir().expect("the enclosing repository"); let ambient = tempfile::tempdir().expect("the repository the cwd is in"); let nowhere = tempfile::tempdir().expect("not a repository"); - let original = std::env::current_dir().expect("cwd"); for repo in [outer.path(), ambient.path()] { let _cd = crate::utils::test::ChangeDirGuard::new(repo); crate::utils::test::setup_with_new_libra_in(repo).await; } + // See `a_pinned_scope_survives_a_cwd_change`: the raw `set_current_dir` + // below is process-wide, so it has to hold `ChangeDirGuard`'s lock — + // taken only now, so the two `libra init`s above do not run under it. + let _cwd_lock = crate::utils::test::cwd_lock_guard(); + let original = std::env::current_dir().expect("cwd"); let _outer_pin = WorktreeScope::pin_request_scope(outer.path().to_path_buf()); std::env::set_current_dir(ambient.path()).expect("move the cwd");