@@ -60,7 +60,7 @@ use openshell_providers::{
6060} ;
6161use owo_colors:: OwoColorize ;
6262use std:: collections:: { HashMap , HashSet } ;
63- use std:: io:: { IsTerminal , Read , Write } ;
63+ use std:: io:: { ErrorKind , IsTerminal , Read , Write } ;
6464use std:: path:: { Path , PathBuf } ;
6565use std:: process:: Command ;
6666use std:: time:: { Duration , Instant } ;
@@ -76,6 +76,15 @@ pub use openshell_core::forward::{
7676 find_forward_by_port, list_forwards, stop_forward, stop_forwards_for_sandbox,
7777} ;
7878
79+ #[ derive( Debug , PartialEq , Eq ) ]
80+ enum SandboxUploadPlan {
81+ GitAware {
82+ base_dir : PathBuf ,
83+ files : Vec < String > ,
84+ } ,
85+ Regular ,
86+ }
87+
7988/// Convert a sandbox phase integer to a human-readable string.
8089fn phase_name ( phase : i32 ) -> & ' static str {
8190 match SandboxPhase :: try_from ( phase) {
@@ -2007,26 +2016,29 @@ pub async fn sandbox_create(
20072016 "\u{2022} " . dimmed( ) ,
20082017 ) ;
20092018 let local = Path :: new ( local_path) ;
2010- if * git_ignore && let Ok ( ( base_dir, files) ) = git_sync_files ( local) {
2011- sandbox_sync_up_files (
2012- & effective_server,
2013- & sandbox_name,
2014- & base_dir,
2015- & files,
2016- local,
2017- dest,
2018- & effective_tls,
2019- )
2020- . await ?;
2021- } else if local. exists ( ) {
2022- sandbox_sync_up (
2023- & effective_server,
2024- & sandbox_name,
2025- local,
2026- dest,
2027- & effective_tls,
2028- )
2029- . await ?;
2019+ match sandbox_upload_plan ( local, * git_ignore) ? {
2020+ SandboxUploadPlan :: GitAware { base_dir, files } => {
2021+ sandbox_sync_up_files (
2022+ & effective_server,
2023+ & sandbox_name,
2024+ & base_dir,
2025+ & files,
2026+ local,
2027+ dest,
2028+ & effective_tls,
2029+ )
2030+ . await ?;
2031+ }
2032+ SandboxUploadPlan :: Regular => {
2033+ sandbox_sync_up (
2034+ & effective_server,
2035+ & sandbox_name,
2036+ local,
2037+ dest,
2038+ & effective_tls,
2039+ )
2040+ . await ?;
2041+ }
20302042 }
20312043 eprintln ! ( " {} Files uploaded" , "\u{2713} " . green( ) . bold( ) ) ;
20322044 }
@@ -5616,6 +5628,28 @@ pub fn git_sync_files(local_path: &Path) -> Result<(PathBuf, Vec<String>)> {
56165628 Ok ( ( base_dir, files) )
56175629}
56185630
5631+ fn sandbox_upload_plan ( local_path : & Path , git_ignore : bool ) -> Result < SandboxUploadPlan > {
5632+ let metadata = std:: fs:: symlink_metadata ( local_path) . map_err ( |err| {
5633+ if err. kind ( ) == ErrorKind :: NotFound {
5634+ miette:: miette!( "local path does not exist: {}" , local_path. display( ) )
5635+ } else {
5636+ miette:: miette!(
5637+ "failed to inspect local upload path: {}" ,
5638+ local_path. display( )
5639+ )
5640+ }
5641+ } ) ?;
5642+
5643+ if git_ignore
5644+ && !metadata. file_type ( ) . is_symlink ( )
5645+ && let Ok ( ( base_dir, files) ) = git_sync_files ( local_path)
5646+ {
5647+ return Ok ( SandboxUploadPlan :: GitAware { base_dir, files } ) ;
5648+ }
5649+
5650+ Ok ( SandboxUploadPlan :: Regular )
5651+ }
5652+
56195653fn scrub_git_env ( command : & mut Command ) -> & mut Command {
56205654 for key in [
56215655 "GIT_DIR" ,
@@ -7191,7 +7225,8 @@ mod tests {
71917225 plaintext_gateway_is_remote, progress_step_from_metadata,
71927226 provider_profile_allows_refresh_bootstrap, provisioning_timeout_message,
71937227 ready_false_condition_message, refresh_status_header, refresh_status_row, resolve_from,
7194- sandbox_should_persist, service_expose_status_error, service_url_for_gateway,
7228+ sandbox_should_persist, sandbox_upload_plan, service_expose_status_error,
7229+ service_url_for_gateway,
71957230 } ;
71967231 use crate :: TEST_ENV_LOCK ;
71977232 use hyper:: StatusCode ;
@@ -7936,6 +7971,51 @@ mod tests {
79367971 assert_eq ! ( files, vec![ "file.txt" , "inner/child.txt" ] ) ;
79377972 }
79387973
7974+ #[ test]
7975+ fn sandbox_upload_plan_errors_for_missing_local_path ( ) {
7976+ let tmpdir = tempfile:: tempdir ( ) . expect ( "create tmpdir" ) ;
7977+ let missing = tmpdir. path ( ) . join ( "missing" ) ;
7978+
7979+ let err = sandbox_upload_plan ( & missing, false ) . expect_err ( "missing path should error" ) ;
7980+
7981+ assert ! (
7982+ err. to_string( ) . contains( "local path does not exist" ) ,
7983+ "expected missing-path error, got: {err}"
7984+ ) ;
7985+ }
7986+
7987+ #[ test]
7988+ fn sandbox_upload_plan_errors_for_missing_local_path_with_git_ignore ( ) {
7989+ let tmpdir = tempfile:: tempdir ( ) . expect ( "create tmpdir" ) ;
7990+ let repo = tmpdir. path ( ) . join ( "repo" ) ;
7991+ fs:: create_dir_all ( & repo) . expect ( "create repo" ) ;
7992+ init_git_repo ( & repo) ;
7993+ let missing = repo. join ( "missing" ) ;
7994+
7995+ let err = sandbox_upload_plan ( & missing, true ) . expect_err ( "missing path should error" ) ;
7996+
7997+ assert ! (
7998+ err. to_string( ) . contains( "local path does not exist" ) ,
7999+ "expected missing-path error, got: {err}"
8000+ ) ;
8001+ }
8002+
8003+ #[ cfg( unix) ]
8004+ #[ test]
8005+ fn sandbox_upload_plan_uses_regular_upload_for_symlinks ( ) {
8006+ let tmpdir = tempfile:: tempdir ( ) . expect ( "create tmpdir" ) ;
8007+ let repo = tmpdir. path ( ) . join ( "repo" ) ;
8008+ fs:: create_dir_all ( repo. join ( "real-dir" ) ) . expect ( "create repo" ) ;
8009+ init_git_repo ( & repo) ;
8010+ fs:: write ( repo. join ( "real-dir/file.txt" ) , "file" ) . expect ( "write file.txt" ) ;
8011+ std:: os:: unix:: fs:: symlink ( "real-dir" , repo. join ( "link-dir" ) ) . expect ( "create symlink" ) ;
8012+
8013+ let plan = sandbox_upload_plan ( & repo. join ( "link-dir" ) , true )
8014+ . expect ( "symlink upload should be planned" ) ;
8015+
8016+ assert_eq ! ( plan, super :: SandboxUploadPlan :: Regular ) ;
8017+ }
8018+
79398019 #[ test]
79408020 fn git_sync_files_ignores_inherited_git_env ( ) {
79418021 let _lock = TEST_ENV_LOCK
0 commit comments