diff --git a/crates/minimal/src/file_upload.rs b/crates/minimal/src/file_upload.rs index e9f69f0c4..2a5cde14e 100644 --- a/crates/minimal/src/file_upload.rs +++ b/crates/minimal/src/file_upload.rs @@ -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) } @@ -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` writer and diff --git a/crates/minimal/src/lib.rs b/crates/minimal/src/lib.rs index 0e486ffca..e739f2d1a 100644 --- a/crates/minimal/src/lib.rs +++ b/crates/minimal/src/lib.rs @@ -318,8 +318,12 @@ pub struct ActivateArgs { /// is given. pub path: Option, /// 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, /// Network mode: no-net, host-net (default), or own-ip. /// /// Hidden from `--help` while `own-ip` is not usable on an installed host: @@ -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()) + }); + let mut client = connect_daemon(global).await?; use minimald_rpc::{ @@ -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 diff --git a/crates/minimal/tests/cli.rs b/crates/minimal/tests/cli.rs index 3571fc9db..3d4c793f0 100644 --- a/crates/minimal/tests/cli.rs +++ b/crates/minimal/tests/cli.rs @@ -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), network: CliNetworkMode::NoNet, ingress: vec![], loadout: vec![], @@ -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![], @@ -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![],