Skip to content

Commit 70d213a

Browse files
committed
cargo-rail:
feat(split/sync): add safety rails with --allow-dirty and --yes flags Add dirty worktree validation and confirmation skip flags to split and sync commands for safer CI/automation usage. Changes: - Add --allow-dirty flag to bypass dirty worktree check - Add --yes/-y flag to skip interactive confirmation prompts - Add is_dirty() and dirty_files() methods to SystemGit - Add GitError::DirtyWorktree error variant with helpful message - Refactor to SplitRunArgs/SyncArgs structs for cleaner API - Add integration tests for safety rail behavior CLI pattern remains consistent with other commands: cargo rail split foo # Execute with confirmation cargo rail split foo --check # Preview only cargo rail split foo --yes # Skip confirmation (CI) cargo rail split foo --allow-dirty --yes # Force on dirty worktree
1 parent 4c43579 commit 70d213a

File tree

8 files changed

+409
-71
lines changed

8 files changed

+409
-71
lines changed

src/commands/cli.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,12 @@ pub enum Commands {
251251
/// Dry-run mode: preview changes without executing
252252
#[arg(long, short = 'c')]
253253
check: bool,
254+
/// Allow running on dirty worktree (uncommitted changes)
255+
#[arg(long)]
256+
allow_dirty: bool,
257+
/// Skip confirmation prompts (for CI/automation)
258+
#[arg(short = 'y', long)]
259+
yes: bool,
254260
/// Output format
255261
#[arg(long, short = 'f', default_value_t, value_enum)]
256262
format: OutputFormat,
@@ -369,6 +375,12 @@ pub enum SplitCommand {
369375
/// Dry-run mode: preview changes
370376
#[arg(long, short = 'c')]
371377
check: bool,
378+
/// Allow running on dirty worktree (uncommitted changes)
379+
#[arg(long)]
380+
allow_dirty: bool,
381+
/// Skip confirmation prompts (for CI/automation)
382+
#[arg(short = 'y', long)]
383+
yes: bool,
372384
/// Output format
373385
#[arg(long, short = 'f', default_value_t, value_enum)]
374386
format: OutputFormat,

src/commands/mod.rs

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,21 @@ pub fn dispatch(cmd: Commands, ctx: &WorkspaceContext) -> RailResult<()> {
139139
all,
140140
remote,
141141
check,
142+
allow_dirty,
143+
yes,
142144
format,
143-
} => run_split(ctx, crate_name, all, remote, check, format),
145+
} => run_split(
146+
ctx,
147+
split::SplitRunArgs {
148+
crate_name,
149+
all,
150+
remote,
151+
check,
152+
allow_dirty,
153+
yes,
154+
format,
155+
},
156+
),
144157
},
145158

146159
Commands::Sync {
@@ -151,17 +164,23 @@ pub fn dispatch(cmd: Commands, ctx: &WorkspaceContext) -> RailResult<()> {
151164
to_remote,
152165
strategy,
153166
check,
167+
allow_dirty,
168+
yes,
154169
format,
155170
} => run_sync(
156171
ctx,
157-
crate_name,
158-
all,
159-
remote,
160-
from_remote,
161-
to_remote,
162-
strategy,
163-
check,
164-
format,
172+
sync::SyncArgs {
173+
crate_name,
174+
all,
175+
remote,
176+
from_remote,
177+
to_remote,
178+
strategy,
179+
check,
180+
allow_dirty,
181+
yes,
182+
format,
183+
},
165184
),
166185

167186
// Release

src/commands/split.rs

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,40 +4,57 @@ use std::io::IsTerminal;
44

55
use crate::commands::common::{OutputFormat, SplitSyncConfigBuilder};
66
use crate::config::RailConfig;
7-
use crate::error::RailResult;
7+
use crate::error::{GitError, RailError, RailResult};
88
use crate::progress;
99
use crate::split::SplitEngine;
1010
use crate::utils;
1111
use crate::workspace::WorkspaceContext;
1212
use rayon::prelude::*;
1313

14+
/// Arguments for the split run command
15+
pub struct SplitRunArgs {
16+
/// Crate name to split (mutually exclusive with `all`)
17+
pub crate_name: Option<String>,
18+
/// Split all configured crates
19+
pub all: bool,
20+
/// Override remote repository URL
21+
pub remote: Option<String>,
22+
/// Dry-run mode: preview changes without executing
23+
pub check: bool,
24+
/// Allow running on dirty worktree (uncommitted changes)
25+
pub allow_dirty: bool,
26+
/// Skip confirmation prompts (for CI/automation)
27+
pub yes: bool,
28+
/// Output format
29+
pub format: OutputFormat,
30+
}
31+
1432
/// Run the split command
15-
pub fn run_split(
16-
ctx: &WorkspaceContext,
17-
crate_name: Option<String>,
18-
all: bool,
19-
remote: Option<String>,
20-
check: bool,
21-
format: OutputFormat,
22-
) -> RailResult<()> {
23-
let json = format.is_json();
33+
pub fn run_split(ctx: &WorkspaceContext, args: SplitRunArgs) -> RailResult<()> {
34+
let json = args.format.is_json();
2435

2536
// JSON mode enables structured error output and suppresses progress
2637
if json {
2738
crate::output::set_json_mode(true);
2839
}
2940

41+
// Dirty worktree check (unless --allow-dirty or --check mode)
42+
if !args.check && !args.allow_dirty && ctx.git.git().is_dirty()? {
43+
let files = ctx.git.git().dirty_files()?;
44+
return Err(RailError::Git(GitError::DirtyWorktree { files }));
45+
}
46+
3047
let builder = SplitSyncConfigBuilder::new(ctx)?
31-
.with_crate_or_all(crate_name.clone(), all)?
32-
.with_remote_override(remote)
48+
.with_crate_or_all(args.crate_name.clone(), args.all)?
49+
.with_remote_override(args.remote)
3350
.validate()?;
3451

3552
let config_count = builder.count();
3653
let configs = builder.build_split_configs()?;
3754

3855
// Check mode: show plan
39-
if check {
40-
match format {
56+
if args.check {
57+
match args.format {
4158
OutputFormat::Json => {
4259
let crates: Vec<_> = configs
4360
.iter()
@@ -98,8 +115,8 @@ pub fn run_split(
98115
return Err(crate::error::RailError::CheckHasPendingChanges);
99116
}
100117

101-
// Interactive confirmation
102-
if std::io::stdin().is_terminal() && !json {
118+
// Interactive confirmation (unless --yes)
119+
if !args.yes && std::io::stdin().is_terminal() && !json {
103120
println!("splitting {} crate(s):\n", config_count);
104121
for config in &configs {
105122
println!(" {} -> {}", config.crate_name, config.target_repo_path.display());
@@ -112,7 +129,7 @@ pub fn run_split(
112129
}
113130

114131
// Execute splits
115-
if config_count > 1 && all {
132+
if config_count > 1 && args.all {
116133
progress!("splitting {} crates...", config_count);
117134
let ctx = ctx.clone();
118135
let results: Vec<RailResult<()>> = configs

src/commands/sync.rs

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use std::io::IsTerminal;
44

55
use crate::commands::common::{OutputFormat, SplitSyncConfigBuilder};
6-
use crate::error::RailResult;
6+
use crate::error::{GitError, RailError, RailResult};
77
use crate::progress;
88
use crate::sync::{ConflictStrategy, SyncDirection, SyncEngine, SyncResult};
99
use crate::utils;
@@ -17,40 +17,59 @@ struct CrateSyncResult {
1717
skipped: bool,
1818
}
1919

20+
/// Arguments for the sync command
21+
pub struct SyncArgs {
22+
/// Crate name to sync (mutually exclusive with `all`)
23+
pub crate_name: Option<String>,
24+
/// Sync all configured crates
25+
pub all: bool,
26+
/// Override remote repository URL
27+
pub remote: Option<String>,
28+
/// Sync from remote to monorepo only
29+
pub from_remote: bool,
30+
/// Sync from monorepo to remote only
31+
pub to_remote: bool,
32+
/// Conflict resolution strategy
33+
pub strategy: ConflictStrategy,
34+
/// Dry-run mode: preview changes without executing
35+
pub check: bool,
36+
/// Allow running on dirty worktree (uncommitted changes)
37+
pub allow_dirty: bool,
38+
/// Skip confirmation prompts (for CI/automation)
39+
pub yes: bool,
40+
/// Output format
41+
pub format: OutputFormat,
42+
}
43+
2044
/// Run the sync command
21-
#[allow(clippy::too_many_arguments)]
22-
pub fn run_sync(
23-
ctx: &WorkspaceContext,
24-
crate_name: Option<String>,
25-
all: bool,
26-
remote: Option<String>,
27-
from_remote: bool,
28-
to_remote: bool,
29-
strategy: ConflictStrategy,
30-
check: bool,
31-
format: OutputFormat,
32-
) -> RailResult<()> {
33-
let json = format.is_json();
45+
pub fn run_sync(ctx: &WorkspaceContext, args: SyncArgs) -> RailResult<()> {
46+
let json = args.format.is_json();
3447

3548
// JSON mode enables structured error output and suppresses progress
3649
if json {
3750
crate::output::set_json_mode(true);
3851
}
3952

53+
// Dirty worktree check (unless --allow-dirty or --check mode)
54+
if !args.check && !args.allow_dirty && ctx.git.git().is_dirty()? {
55+
let files = ctx.git.git().dirty_files()?;
56+
return Err(RailError::Git(GitError::DirtyWorktree { files }));
57+
}
58+
4059
let builder = SplitSyncConfigBuilder::new(ctx)?
41-
.with_crate_or_all(crate_name.clone(), all)?
42-
.with_remote_override(remote);
60+
.with_crate_or_all(args.crate_name.clone(), args.all)?
61+
.with_remote_override(args.remote);
4362

4463
let config_count = builder.count();
4564

46-
if config_count == 0 && all {
65+
if config_count == 0 && args.all {
4766
return Err(crate::error::RailError::with_help(
4867
"no crates configured for sync",
4968
"run 'cargo rail split init' first",
5069
));
5170
}
5271

53-
let direction = match (from_remote, to_remote) {
72+
let direction = match (args.from_remote, args.to_remote) {
5473
(true, true) => {
5574
return Err(crate::error::RailError::with_help(
5675
"cannot use both --from-remote and --to-remote",
@@ -65,7 +84,7 @@ pub fn run_sync(
6584
let configs = builder.build_sync_configs()?;
6685

6786
// Check mode
68-
if check {
87+
if args.check {
6988
if json {
7089
let dir_str = match direction {
7190
SyncDirection::MonoToRemote => "to_remote",
@@ -92,7 +111,7 @@ pub fn run_sync(
92111
"command": "sync",
93112
"check": true,
94113
"direction": dir_str,
95-
"strategy": format!("{:?}", strategy).to_lowercase(),
114+
"strategy": format!("{:?}", args.strategy).to_lowercase(),
96115
"crates": crates,
97116
"count": configs.len()
98117
});
@@ -113,7 +132,7 @@ pub fn run_sync(
113132
println!(" direction: {}", dir_display);
114133
println!(" target: {}", sync_config.target_repo_path.display());
115134
println!(" remote: {}", sync_config.remote_url);
116-
println!(" strategy: {}", format!("{:?}", strategy).to_lowercase());
135+
println!(" strategy: {}", format!("{:?}", args.strategy).to_lowercase());
117136
if !target_exists {
118137
println!(" warning: target repo missing (run split first)");
119138
}
@@ -123,8 +142,8 @@ pub fn run_sync(
123142
return Err(crate::error::RailError::CheckHasPendingChanges);
124143
}
125144

126-
// Interactive confirmation
127-
if std::io::stdin().is_terminal() && !json {
145+
// Interactive confirmation (unless --yes)
146+
if !args.yes && std::io::stdin().is_terminal() && !json {
128147
let dir_sym = match direction {
129148
SyncDirection::MonoToRemote => "->",
130149
SyncDirection::RemoteToMono => "<-",
@@ -155,10 +174,11 @@ pub fn run_sync(
155174
}
156175

157176
// Execute syncs and collect per-crate results
158-
let crate_results: Vec<CrateSyncResult> = if config_count > 1 && all {
177+
let crate_results: Vec<CrateSyncResult> = if config_count > 1 && args.all {
159178
progress!("syncing {} crates...", config_count);
160179

161180
let ctx = ctx.clone();
181+
let strategy = args.strategy;
162182
let results: Vec<RailResult<CrateSyncResult>> = configs
163183
.into_par_iter()
164184
.map(|(sync_config, target_exists)| {
@@ -208,7 +228,7 @@ pub fn run_sync(
208228
}
209229

210230
progress!("syncing {}...", crate_name);
211-
let mut engine = SyncEngine::new(ctx, sync_config, strategy)?;
231+
let mut engine = SyncEngine::new(ctx, sync_config, args.strategy)?;
212232

213233
let result = match direction {
214234
SyncDirection::MonoToRemote => engine.sync_to_remote()?,

src/error.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,12 @@ pub enum GitError {
352352
/// Failure reason
353353
reason: String,
354354
},
355+
356+
/// Worktree has uncommitted changes
357+
DirtyWorktree {
358+
/// List of dirty files
359+
files: Vec<String>,
360+
},
355361
}
356362

357363
impl GitError {
@@ -367,6 +373,7 @@ impl GitError {
367373
}
368374
}
369375
GitError::RepoNotFound { path } => Some(format!("run 'git init {}' or verify the path", path.display())),
376+
GitError::DirtyWorktree { .. } => Some("commit or stash changes, or use --allow-dirty".to_string()),
370377
_ => None,
371378
}
372379
}
@@ -392,6 +399,20 @@ impl fmt::Display for GitError {
392399
GitError::PushFailed { remote, branch, reason } => {
393400
write!(f, "push to {}/{} failed: {}", remote, branch, reason.trim())
394401
}
402+
GitError::DirtyWorktree { files } => {
403+
let count = files.len();
404+
if count <= 5 {
405+
write!(f, "working tree has uncommitted changes:\n{}", files.join("\n"))
406+
} else {
407+
let shown: Vec<_> = files.iter().take(5).cloned().collect();
408+
write!(
409+
f,
410+
"working tree has uncommitted changes:\n{}\n ... and {} more",
411+
shown.join("\n"),
412+
count - 5
413+
)
414+
}
415+
}
395416
}
396417
}
397418
}

src/git/system.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,24 @@ impl SystemGit {
121121
Ok(None)
122122
}
123123

124+
/// Check if the worktree has uncommitted changes
125+
///
126+
/// Returns `true` if there are staged or unstaged changes, including untracked files.
127+
/// This is useful for safety checks before destructive operations.
128+
pub fn is_dirty(&self) -> RailResult<bool> {
129+
let output = self.run_git_stdout(&["status", "--porcelain"])?;
130+
Ok(!output.is_empty())
131+
}
132+
133+
/// Get list of dirty files in the worktree
134+
///
135+
/// Returns the files with their status prefixes (e.g., " M file.txt", "?? new.txt").
136+
/// Useful for displaying what's dirty when refusing to run on a dirty worktree.
137+
pub fn dirty_files(&self) -> RailResult<Vec<String>> {
138+
let output = self.run_git_stdout(&["status", "--porcelain"])?;
139+
Ok(output.lines().map(|s| s.to_string()).collect())
140+
}
141+
124142
/// Create a safe git command with isolated environment
125143
///
126144
/// - Sets working directory to repo path

0 commit comments

Comments
 (0)