Skip to content

Commit 6b8faa3

Browse files
committed
cargo-rail: added 'split/sync' safety rails; defrred 'git centralization'; removed the four unwrap()/expect() calls from the production code; fixed the 'process::exit()' in the lib code.
1 parent 70d213a commit 6b8faa3

File tree

13 files changed

+994
-89
lines changed

13 files changed

+994
-89
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Thumbs.db
1919
rules.md
2020
release.md
2121
todo.md
22+
PLAN-centralize-git.md
2223
**/demo.cast
2324
distribution/
2425
backlog/

src/cargo/manifest_writer.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -327,12 +327,8 @@ impl ManifestWriter {
327327

328328
// Update the dependency entry
329329
// If it's a simple string like `serde = "1.0"`, we need to convert it to a table
330-
if dep_item.is_str() {
330+
if let Some(version) = dep_item.as_str() {
331331
// Convert simple string (e.g., `dep = "1.0"`) to inline table with features
332-
let version = dep_item
333-
.as_str()
334-
.expect("dep_item.is_str() was true but as_str() returned None")
335-
.to_string();
336332
let mut inline_table = toml_edit::InlineTable::new();
337333
inline_table.insert("version", toml_edit::Value::from(version));
338334
inline_table.insert("features", manifest_ops::build_feature_array(&existing_features));

src/commands/config.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ pub fn run_config_validate_standalone(
170170
"{}",
171171
serde_json::to_string_pretty(&result).map_err(|e| RailError::message(e.to_string()))?
172172
);
173-
std::process::exit(2);
173+
return Err(RailError::ExitWithCode { code: 2 });
174174
} else {
175175
println!("no configuration file found");
176176
println!("\nhelp: run 'cargo rail init' to create one");
@@ -315,7 +315,7 @@ pub fn run_config_validate_standalone(
315315
if valid {
316316
Ok(())
317317
} else if json {
318-
std::process::exit(2);
318+
Err(RailError::ExitWithCode { code: 2 })
319319
} else {
320320
Err(RailError::message("configuration validation failed"))
321321
}
@@ -501,7 +501,7 @@ pub fn run_config_sync(workspace_root: &Path, check: bool, format: OutputFormat)
501501

502502
if has_changes {
503503
// Exit with code 1 to indicate changes are needed
504-
std::process::exit(1);
504+
return Err(RailError::CheckHasPendingChanges);
505505
}
506506
} else {
507507
// Apply mode

src/commands/test.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,9 @@ pub fn run_test(ctx: &WorkspaceContext, config: TestConfig) -> RailResult<()> {
147147
.map_err(|e| crate::error::RailError::message(format!("{} failed: {}", runner.name(), e)))?;
148148

149149
if !status.success() {
150-
std::process::exit(status.code().unwrap_or(1));
150+
return Err(crate::error::RailError::ExitWithCode {
151+
code: status.code().unwrap_or(1),
152+
});
151153
}
152154

153155
println!("\nall tests passed");

src/commands/unify.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,10 @@ pub fn run_unify_apply(
393393
// Find a suitable member's path
394394
let first_member = &members[0];
395395
if let Some(pkg) = ctx.cargo.get_package(first_member) {
396-
let member_path = pkg.manifest_path.parent().unwrap();
396+
let member_path = pkg
397+
.manifest_path
398+
.parent()
399+
.ok_or_else(|| RailError::message(format!("Invalid manifest path: {}", pkg.manifest_path)))?;
397400
let relative_path = member_path.strip_prefix(ctx.workspace_root()).unwrap_or(member_path);
398401
progress!(
399402
" note: using '{}' as transitive host (virtual workspace detected)",

src/error.rs

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,24 @@ use std::path::PathBuf;
1616
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1717
pub enum ExitCode {
1818
/// Success - everything is good
19-
Success = 0,
19+
Success,
2020
/// Check failed - changes would be made (use in --check mode)
21-
CheckFailed = 1,
21+
CheckFailed,
2222
/// Error occurred (user or system error)
23-
Error = 2,
23+
Error,
24+
/// Custom exit code (e.g., propagated from subprocess)
25+
Custom(i32),
2426
}
2527

2628
impl ExitCode {
2729
/// Convert to i32 for process exit
2830
pub fn as_i32(self) -> i32 {
29-
self as i32
31+
match self {
32+
ExitCode::Success => 0,
33+
ExitCode::CheckFailed => 1,
34+
ExitCode::Error => 2,
35+
ExitCode::Custom(code) => code,
36+
}
3037
}
3138
}
3239

@@ -57,6 +64,17 @@ pub enum RailError {
5764
/// Used by --check commands to signal that changes would be made.
5865
/// This is not a failure, but CI should treat it as "action needed".
5966
CheckHasPendingChanges,
67+
68+
/// Exit with specific code (no error message printed)
69+
///
70+
/// Used for:
71+
/// - Propagating subprocess exit codes (e.g., cargo test failures)
72+
/// - Silent exits after JSON output has been written
73+
/// - Any case where we need a specific exit code without error output
74+
ExitWithCode {
75+
/// The exit code to use
76+
code: i32,
77+
},
6078
}
6179

6280
impl RailError {
@@ -95,6 +113,7 @@ impl RailError {
95113
pub fn exit_code(&self) -> ExitCode {
96114
match self {
97115
RailError::CheckHasPendingChanges => ExitCode::CheckFailed,
116+
RailError::ExitWithCode { code } => ExitCode::Custom(*code),
98117
_ => ExitCode::Error,
99118
}
100119
}
@@ -123,7 +142,8 @@ impl fmt::Display for RailError {
123142
}
124143
Ok(())
125144
}
126-
RailError::CheckHasPendingChanges => Ok(()), // Silent - no message
145+
RailError::CheckHasPendingChanges => Ok(()), // Silent - CI signal
146+
RailError::ExitWithCode { .. } => Ok(()), // Silent - exit code only
127147
}
128148
}
129149
}
@@ -463,9 +483,13 @@ struct JsonError {
463483
///
464484
/// In JSON mode, outputs a structured JSON error object instead of text.
465485
pub fn print_error(error: &RailError) {
466-
// CheckHasPendingChanges is not an error - it's a signal for CI
467-
// Don't print "error:" prefix for it
468-
if matches!(error, RailError::CheckHasPendingChanges) {
486+
// These are not errors to display - they're exit code signals
487+
// CheckHasPendingChanges: CI signal for "changes would be made"
488+
// ExitWithCode: subprocess errors or silent exits after JSON output
489+
if matches!(
490+
error,
491+
RailError::CheckHasPendingChanges | RailError::ExitWithCode { .. }
492+
) {
469493
return;
470494
}
471495

src/git/ops.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,12 @@ impl SystemGit {
323323
Ok(())
324324
}
325325

326+
/// Check if a local branch exists
327+
pub fn branch_exists(&self, branch_name: &str) -> RailResult<bool> {
328+
let ref_name = format!("refs/heads/{}", branch_name);
329+
Ok(self.run_git_check(&["show-ref", "--verify", "--quiet", &ref_name]))
330+
}
331+
326332
/// Create and checkout a branch
327333
pub fn create_and_checkout_branch(&self, branch_name: &str) -> RailResult<()> {
328334
self.create_branch(branch_name)?;

src/release/publisher.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,9 +358,13 @@ impl<'a> ReleasePublisher<'a> {
358358

359359
/// Create GitHub release using gh CLI
360360
fn create_github_release(&self, plan: &CrateReleasePlan) -> RailResult<()> {
361-
let check = Command::new("gh").args(["--version"]).output();
361+
let gh_available = Command::new("gh")
362+
.args(["--version"])
363+
.output()
364+
.map(|o| o.status.success())
365+
.unwrap_or(false);
362366

363-
if check.is_err() || !check.unwrap().status.success() {
367+
if !gh_available {
364368
eprintln!(" skipped github release (gh CLI not found)");
365369
return Ok(());
366370
}

src/release/version.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ impl BumpType {
8989

9090
if current.pre.is_empty() {
9191
// No prerelease - add rc.1
92-
next.pre = semver::Prerelease::new("rc.1").unwrap();
92+
next.pre = semver::Prerelease::new("rc.1").expect("BUG: 'rc.1' is a valid semver prerelease identifier");
9393
} else {
9494
// Increment the numeric part of the prerelease
9595
// Supports: rc.1 -> rc.2, alpha.3 -> alpha.4, beta.0 -> beta.1

src/split/engine.rs

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)