@@ -408,7 +408,7 @@ impl<'a> SplitEngine<'a> {
408408 Ok ( output. status . success ( ) && !output. stdout . is_empty ( ) )
409409 }
410410
411- /// Execute a split operation (ONE-TIME ONLY - use sync for updates )
411+ /// Execute a split operation (idempotent - re-runs sync new commits only )
412412 pub fn split ( & self , config : & SplitConfig ) -> RailResult < ( ) > {
413413 println ! ( "🚂 Splitting crate: {}" , config. crate_name) ;
414414 println ! ( " Mode: {:?}" , config. mode) ;
@@ -424,10 +424,14 @@ impl<'a> SplitEngine<'a> {
424424 println ! ( " Exclude patterns: {} configured" , config. exclude. len( ) ) ;
425425 }
426426
427- // Check if remote already exists - if so, error with helpful message
427+ // Check if target repo already exists (for idempotency)
428+ let target_exists = config. target_repo_path . join ( ".git" ) . exists ( ) ;
429+
430+ // Check if remote already exists - warn but allow re-run for idempotency
428431 if let Some ( ref remote_url) = config. remote_url {
429432 let remote_exists = self . check_remote_exists ( remote_url) ?;
430- if remote_exists {
433+ if remote_exists && !target_exists {
434+ // Remote exists but no local target - user probably wants to use sync instead
431435 return Err ( RailError :: with_help (
432436 format ! ( "Split already exists at {}" , remote_url) ,
433437 format ! (
@@ -438,9 +442,10 @@ impl<'a> SplitEngine<'a> {
438442 ) ,
439443 ) ) ;
440444 }
445+ // If both remote and target exist, we'll check mappings below for idempotency
441446 }
442447
443- // Create fresh target repo
448+ // Create or reuse target repo
444449 self . ensure_target_repo ( & config. target_repo_path ) ?;
445450
446451 // Discover workspace-level auxiliary files from workspace
@@ -461,13 +466,34 @@ impl<'a> SplitEngine<'a> {
461466 ) ;
462467 }
463468
464- // Create mapping store
469+ // Create mapping store and load existing mappings (from both workspace and target)
465470 let mut mapping_store = MappingStore :: new ( config. crate_name . clone ( ) ) ;
466471 mapping_store. load ( self . ctx . workspace_root ( ) ) ?;
472+ if target_exists {
473+ mapping_store. load ( & config. target_repo_path ) ?;
474+ }
467475
468476 // Walk filtered history to find commits touching the crate
469477 let filtered_commits = self . walk_filtered_history ( & config. crate_paths ) ?;
470478
479+ // Count how many commits are already mapped (for idempotency)
480+ let already_mapped_count = filtered_commits
481+ . iter ( )
482+ . filter ( |c| mapping_store. has_mapping ( & c. sha ) )
483+ . count ( ) ;
484+
485+ if already_mapped_count > 0 {
486+ println ! ( " Found {} commits already split (will skip)" , already_mapped_count) ;
487+ }
488+
489+ // Check if all commits are already mapped - nothing to do
490+ if already_mapped_count == filtered_commits. len ( ) && !filtered_commits. is_empty ( ) {
491+ println ! ( "\n ✅ Split already up-to-date!" ) ;
492+ println ! ( " All {} commits have been split previously." , filtered_commits. len( ) ) ;
493+ println ! ( " Target repo: {}" , config. target_repo_path. display( ) ) ;
494+ return Ok ( ( ) ) ;
495+ }
496+
471497 if filtered_commits. is_empty ( ) {
472498 println ! ( " No commits found that touch the crate paths" ) ;
473499 println ! ( " Falling back to current state copy..." ) ;
@@ -505,10 +531,31 @@ impl<'a> SplitEngine<'a> {
505531 let mut last_recreated_sha: Option < String > = None ;
506532
507533 let mut skipped_commits = 0usize ;
534+ let mut skipped_already_mapped = 0usize ;
535+
536+ // For incremental splits, find the last mapped commit's SHA in target repo
537+ // to use as parent for new commits
538+ if target_exists && already_mapped_count > 0 {
539+ // Find the most recent mapped commit and use its target SHA as last_recreated_sha
540+ for commit in filtered_commits. iter ( ) . rev ( ) {
541+ if let Ok ( Some ( target_sha) ) = mapping_store. get_mapping ( & commit. sha ) {
542+ last_recreated_sha = Some ( target_sha) ;
543+ break ;
544+ }
545+ }
546+ }
508547
509548 for ( idx, commit) in filtered_commits. iter ( ) . enumerate ( ) {
510- if ( idx + 1 ) % 10 == 0 || idx + 1 == filtered_commits. len ( ) {
511- println ! ( " Progress: {}/{} commits" , idx + 1 , filtered_commits. len( ) ) ;
549+ // Skip already-mapped commits (idempotency)
550+ if mapping_store. has_mapping ( & commit. sha ) {
551+ skipped_already_mapped += 1 ;
552+ continue ;
553+ }
554+
555+ let new_count = idx + 1 - skipped_already_mapped;
556+ let total_new = filtered_commits. len ( ) - already_mapped_count;
557+ if new_count. is_multiple_of ( 10 ) || new_count == total_new {
558+ println ! ( " Progress: {}/{} new commits" , new_count, total_new) ;
512559 }
513560
514561 // Use prefetched files if available
@@ -539,11 +586,19 @@ impl<'a> SplitEngine<'a> {
539586 last_recreated_sha = Some ( new_sha) ;
540587 }
541588
542- if skipped_commits > 0 {
543- println ! (
544- " Skipped {} commits where path didn't exist (dirty history)" ,
545- skipped_commits
546- ) ;
589+ if skipped_commits > 0 || skipped_already_mapped > 0 {
590+ if skipped_commits > 0 {
591+ println ! (
592+ " Skipped {} commits where path didn't exist (dirty history)" ,
593+ skipped_commits
594+ ) ;
595+ }
596+ if skipped_already_mapped > 0 {
597+ println ! (
598+ " Skipped {} commits already split (idempotent)" ,
599+ skipped_already_mapped
600+ ) ;
601+ }
547602 }
548603
549604 // Create workspace Cargo.toml if in workspace mode
0 commit comments