Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions crates/minimal/src/file_upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,23 @@ pub fn is_vcs_root(dir: &Path) -> bool {
VCS_MARKERS.iter().any(|marker| dir.join(marker).exists())
}

/// Returns `true` when activating from `dir` should skip the workspace
/// upload without prompting: either `dir` is empty (nothing to sync) or
/// it is the user's home directory (`home`). An empty box has nothing
/// worth a confirmation, and `$HOME` — even when non-empty or itself a
/// VCS root — is far too much to bulk-upload on a stray keystroke, so
/// the home check ignores contents entirely. `home` is `None` when the
/// caller can't resolve one, leaving only the emptiness check. The
/// escape hatch for a genuinely wanted upload (`--sync tarball` passed
/// explicitly) is decided at the call site, not here.
pub fn is_empty_or_home(dir: &Path, home: Option<&Path>) -> bool {
let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
if home.is_some_and(|home| canon(dir) == canon(home)) {
return true;
}
std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none())
}

fn is_default_excluded(name: &str) -> bool {
DEFAULT_EXCLUDED_DIRS.contains(&name)
}
Expand Down Expand Up @@ -1102,6 +1119,34 @@ mod tests {
assert!(!is_vcs_root(dir.path()));
}

#[test]
fn is_empty_or_home_skips_empty_dir() {
let dir = tempfile::TempDir::new().unwrap();
assert!(is_empty_or_home(dir.path(), None));
}

#[test]
fn is_empty_or_home_skips_home_even_with_files() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("file.txt"), "x").unwrap();
assert!(is_empty_or_home(dir.path(), Some(dir.path())));
}

#[test]
fn is_empty_or_home_skips_home_that_is_a_vcs_root() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
assert!(is_empty_or_home(dir.path(), Some(dir.path())));
}

#[test]
fn is_empty_or_home_keeps_ordinary_non_empty_dir() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("file.txt"), "x").unwrap();
let home = tempfile::TempDir::new().unwrap();
assert!(!is_empty_or_home(dir.path(), Some(home.path())));
}

// ---- TarZstArchive per-entry API ----

/// Build an archive against an owned `Vec<u8>` writer and
Expand Down
36 changes: 32 additions & 4 deletions crates/minimal/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,8 +318,12 @@ pub struct ActivateArgs {
/// is given.
pub path: Option<String>,
/// How to load project files into the session.
#[arg(long, value_enum, default_value_t = SyncMode::Tarball)]
pub sync: SyncMode,
///
/// Defaults to `tarball`. Passing `--sync tarball` explicitly is also
/// the escape hatch that uploads an empty directory or `$HOME`, which
/// are otherwise skipped without a prompt.
#[arg(long, value_enum)]
pub sync: Option<SyncMode>,
/// Network mode: no-net, host-net (default), or own-ip.
///
/// Hidden from `--help` while `own-ip` is not usable on an installed host:
Expand Down Expand Up @@ -1400,16 +1404,31 @@ pub async fn cmd_activate(global: &GlobalArgs, args: ActivateArgs) -> Result<(),
let (contribution, user_policy) =
loadouts::compose_user_contribution(active, user_policy, compose_options)?;

// `--sync` defaults to tarball; `sync_explicit` records whether the
// user actually typed the flag, which distinguishes a deliberate
// `--sync tarball` (the escape hatch that force-uploads an empty dir
// or `$HOME`) from the implicit default.
let sync_explicit = args.sync.is_some();
let sync_mode = args.sync.unwrap_or(SyncMode::Tarball);

// Resolve the upload root before opening the daemon connection:
// a malformed mfile in an ancestor should fail loudly before
// we create a session on the daemon, so we don't leak a draft
// session. Only needed for tarball sync — `--sync none` skips
// the upload entirely (#770).
let upload_root = match args.sync {
let upload_root = match sync_mode {
SyncMode::None => None,
SyncMode::Tarball => Some(resolve_upload_root(&utf8_path)?),
};

// Skip the upload without prompting when the resolved root is an
// empty directory or `$HOME` — unless the user asked for it with an
// explicit `--sync tarball`, the escape hatch.
let skip_empty_or_home = !sync_explicit
&& upload_root.as_ref().is_some_and(|root| {
file_upload::is_empty_or_home(root.as_std_path(), std::env::home_dir().as_deref())
});
Comment on lines +1427 to +1430

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Move directory inspection off the async runtime thread.

is_empty_or_home performs synchronous canonicalize and read_dir here. A stalled filesystem can block the Tokio worker during activation; run this check in tokio::task::spawn_blocking and propagate the join error.

Proposed fix
-    let skip_empty_or_home = !sync_explicit
-        && upload_root.as_ref().is_some_and(|root| {
-            file_upload::is_empty_or_home(root.as_std_path(), std::env::home_dir().as_deref())
-        });
+    let skip_empty_or_home = if !sync_explicit {
+        if let Some(root) = upload_root.as_ref() {
+            let root = root.clone();
+            let home = std::env::home_dir();
+            tokio::task::spawn_blocking(move || {
+                file_upload::is_empty_or_home(root.as_std_path(), home.as_deref())
+            })
+            .await
+            .context("checking whether upload directory is empty or home")?
+        } else {
+            false
+        }
+    } else {
+        false
+    };

Based on learnings, avoid direct std::fs work from async tasks; use spawn_blocking.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let skip_empty_or_home = !sync_explicit
&& upload_root.as_ref().is_some_and(|root| {
file_upload::is_empty_or_home(root.as_std_path(), std::env::home_dir().as_deref())
});
let skip_empty_or_home = if !sync_explicit {
if let Some(root) = upload_root.as_ref() {
let root = root.clone();
let home = std::env::home_dir();
tokio::task::spawn_blocking(move || {
file_upload::is_empty_or_home(root.as_std_path(), home.as_deref())
})
.await
.context("checking whether upload directory is empty or home")?
} else {
false
}
} else {
false
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimal/src/lib.rs` around lines 1427 - 1430, Update the activation
flow around skip_empty_or_home so the synchronous file_upload::is_empty_or_home
inspection runs inside tokio::task::spawn_blocking rather than on the async
runtime thread. Preserve the existing condition and arguments, await the
blocking task, and propagate any task join error through the surrounding result
path.

Source: Learnings


let mut client = connect_daemon(global).await?;

use minimald_rpc::{
Expand Down Expand Up @@ -1444,8 +1463,17 @@ pub async fn cmd_activate(global: &GlobalArgs, args: ActivateArgs) -> Result<(),
// has to run before `ConfigureLoadout`. `--sync none` opts out;
// the daemon then composes against an empty workspace and the
// caller is on their own for getting files there.
match args.sync {
match sync_mode {
SyncMode::None => {}
SyncMode::Tarball if skip_empty_or_home => {
// An empty directory has nothing to sync, and `$HOME` is far
// too much to ship on a stray confirmation keypress — and if
// `$HOME` is itself a VCS root the old gate uploaded it with
// no prompt at all. Skip both silently by default; a
// deliberate `--sync tarball` (via `sync_explicit`) is the
// escape hatch that still uploads them.
eprintln!("Starting with an empty box (nothing here to sync)");
}
SyncMode::Tarball => {
// Upload from the project root — the directory the mfile
// lives in — rather than wherever the user invoked us. This
Expand Down
6 changes: 3 additions & 3 deletions crates/minimal/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ async fn activate_creates_session() {
let activate_args = ActivateArgs {
name: Some("test-session".to_string()),
path: Some(project.path().to_string_lossy().to_string()),
sync: SyncMode::Tarball,
sync: Some(SyncMode::Tarball),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add integration coverage for implicit sync behavior.

These fixtures all use Some(SyncMode::Tarball), so they only exercise the explicit escape hatch. Add activation coverage using sync: None for the default upload path and for skipped empty/$HOME roots, asserting the workspace and stderr contract.

As per coding guidelines, “When changing VM or daemon behavior, add or update the appropriate integration tests and run just e2e and/or just test-vm.”

Also applies to: 266-266, 345-345

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimal/tests/cli.rs` at line 224, Extend the integration fixtures in
crates/minimal/tests/cli.rs to cover implicit sync behavior with sync: None,
including the default upload path and skipped empty/$HOME roots. Assert the
expected workspace and stderr contracts for each case, while retaining existing
explicit SyncMode::Tarball coverage; run the relevant integration test command
afterward.

Source: Coding guidelines

network: CliNetworkMode::NoNet,
ingress: vec![],
loadout: vec![],
Expand Down Expand Up @@ -263,7 +263,7 @@ async fn activate_uploads_project_files() {
let activate_args = ActivateArgs {
name: Some("upload-test".to_string()),
path: Some(project.path().to_string_lossy().to_string()),
sync: SyncMode::Tarball,
sync: Some(SyncMode::Tarball),
network: CliNetworkMode::NoNet,
ingress: vec![],
loadout: vec![],
Expand Down Expand Up @@ -342,7 +342,7 @@ async fn activate_uses_repo_dir_when_no_positional_path() {
let activate_args = ActivateArgs {
name: Some("repo-dir-test".to_string()),
path: None,
sync: SyncMode::Tarball,
sync: Some(SyncMode::Tarball),
network: CliNetworkMode::NoNet,
ingress: vec![],
loadout: vec![],
Expand Down