feat: Add dry-run mode to ingest, sync, and stats commands
Enables preview of operations without making changes, useful for understanding what would happen before committing to a full sync. Ingest dry-run (--dry-run flag): - Shows resource type, sync mode (full vs incremental), project list - Per-project info: existing count, has_cursor, last_synced timestamp - No GitLab API calls, no database writes Sync dry-run (--dry-run flag): - Preview all four stages: issues ingest, MRs ingest, docs, embed - Shows which stages would run vs be skipped (--no-docs, --no-embed) - Per-project breakdown for both entity types Stats repair dry-run (--dry-run flag): - Shows what would be repaired without executing repairs - "would fix" vs "fixed" indicator in terminal output - dry_run: true field in JSON response Implementation details: - DryRunPreview struct captures project-level sync state - SyncDryRunResult aggregates previews for all sync stages - Terminal output uses yellow styling for "would" actions - JSON output includes dry_run: true at top level Flag handling: - --dry-run and --no-dry-run pair for explicit control - Defaults to false (normal operation) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ use crate::core::metrics::{MetricsLayer, StageTiming};
|
||||
|
||||
use super::embed::run_embed;
|
||||
use super::generate_docs::run_generate_docs;
|
||||
use super::ingest::{IngestDisplay, run_ingest};
|
||||
use super::ingest::{DryRunPreview, IngestDisplay, run_ingest, run_ingest_dry_run};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SyncOptions {
|
||||
@@ -22,6 +22,7 @@ pub struct SyncOptions {
|
||||
pub no_docs: bool,
|
||||
pub no_events: bool,
|
||||
pub robot_mode: bool,
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
@@ -74,6 +75,11 @@ pub async fn run_sync(
|
||||
..SyncResult::default()
|
||||
};
|
||||
|
||||
// Handle dry_run mode - show preview without making any changes
|
||||
if options.dry_run {
|
||||
return run_sync_dry_run(config, &options).await;
|
||||
}
|
||||
|
||||
let ingest_display = if options.robot_mode {
|
||||
IngestDisplay::silent()
|
||||
} else {
|
||||
@@ -103,6 +109,7 @@ pub async fn run_sync(
|
||||
None,
|
||||
options.force,
|
||||
options.full,
|
||||
false, // dry_run - sync has its own dry_run handling
|
||||
ingest_display,
|
||||
Some(spinner.clone()),
|
||||
)
|
||||
@@ -127,6 +134,7 @@ pub async fn run_sync(
|
||||
None,
|
||||
options.force,
|
||||
options.full,
|
||||
false, // dry_run - sync has its own dry_run handling
|
||||
ingest_display,
|
||||
Some(spinner.clone()),
|
||||
)
|
||||
@@ -369,3 +377,172 @@ pub fn print_sync_json(result: &SyncResult, elapsed_ms: u64, metrics: Option<&Me
|
||||
};
|
||||
println!("{}", serde_json::to_string(&output).unwrap());
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub struct SyncDryRunResult {
|
||||
pub issues_preview: DryRunPreview,
|
||||
pub mrs_preview: DryRunPreview,
|
||||
pub would_generate_docs: bool,
|
||||
pub would_embed: bool,
|
||||
}
|
||||
|
||||
async fn run_sync_dry_run(config: &Config, options: &SyncOptions) -> Result<SyncResult> {
|
||||
// Get dry run previews for both issues and MRs
|
||||
let issues_preview = run_ingest_dry_run(config, "issues", None, options.full)?;
|
||||
let mrs_preview = run_ingest_dry_run(config, "mrs", None, options.full)?;
|
||||
|
||||
let dry_result = SyncDryRunResult {
|
||||
issues_preview,
|
||||
mrs_preview,
|
||||
would_generate_docs: !options.no_docs,
|
||||
would_embed: !options.no_embed,
|
||||
};
|
||||
|
||||
if options.robot_mode {
|
||||
print_sync_dry_run_json(&dry_result);
|
||||
} else {
|
||||
print_sync_dry_run(&dry_result);
|
||||
}
|
||||
|
||||
// Return an empty SyncResult since this is just a preview
|
||||
Ok(SyncResult::default())
|
||||
}
|
||||
|
||||
pub fn print_sync_dry_run(result: &SyncDryRunResult) {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("Sync Dry Run Preview").cyan().bold(),
|
||||
style("(no changes will be made)").yellow()
|
||||
);
|
||||
println!();
|
||||
|
||||
println!("{}", style("Stage 1: Issues Ingestion").white().bold());
|
||||
println!(
|
||||
" Sync mode: {}",
|
||||
if result.issues_preview.sync_mode == "full" {
|
||||
style("full").yellow()
|
||||
} else {
|
||||
style("incremental").green()
|
||||
}
|
||||
);
|
||||
println!(" Projects: {}", result.issues_preview.projects.len());
|
||||
for project in &result.issues_preview.projects {
|
||||
let sync_status = if !project.has_cursor {
|
||||
style("initial sync").yellow()
|
||||
} else {
|
||||
style("incremental").green()
|
||||
};
|
||||
println!(
|
||||
" {} ({}) - {} existing",
|
||||
&project.path, sync_status, project.existing_count
|
||||
);
|
||||
}
|
||||
println!();
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
style("Stage 2: Merge Requests Ingestion").white().bold()
|
||||
);
|
||||
println!(
|
||||
" Sync mode: {}",
|
||||
if result.mrs_preview.sync_mode == "full" {
|
||||
style("full").yellow()
|
||||
} else {
|
||||
style("incremental").green()
|
||||
}
|
||||
);
|
||||
println!(" Projects: {}", result.mrs_preview.projects.len());
|
||||
for project in &result.mrs_preview.projects {
|
||||
let sync_status = if !project.has_cursor {
|
||||
style("initial sync").yellow()
|
||||
} else {
|
||||
style("incremental").green()
|
||||
};
|
||||
println!(
|
||||
" {} ({}) - {} existing",
|
||||
&project.path, sync_status, project.existing_count
|
||||
);
|
||||
}
|
||||
println!();
|
||||
|
||||
if result.would_generate_docs {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("Stage 3: Document Generation").white().bold(),
|
||||
style("(would run)").green()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("Stage 3: Document Generation").white().bold(),
|
||||
style("(skipped)").dim()
|
||||
);
|
||||
}
|
||||
|
||||
if result.would_embed {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("Stage 4: Embedding").white().bold(),
|
||||
style("(would run)").green()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("Stage 4: Embedding").white().bold(),
|
||||
style("(skipped)").dim()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SyncDryRunJsonOutput {
|
||||
ok: bool,
|
||||
dry_run: bool,
|
||||
data: SyncDryRunJsonData,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SyncDryRunJsonData {
|
||||
stages: Vec<SyncDryRunStage>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SyncDryRunStage {
|
||||
name: String,
|
||||
would_run: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
preview: Option<DryRunPreview>,
|
||||
}
|
||||
|
||||
pub fn print_sync_dry_run_json(result: &SyncDryRunResult) {
|
||||
let output = SyncDryRunJsonOutput {
|
||||
ok: true,
|
||||
dry_run: true,
|
||||
data: SyncDryRunJsonData {
|
||||
stages: vec![
|
||||
SyncDryRunStage {
|
||||
name: "ingest_issues".to_string(),
|
||||
would_run: true,
|
||||
preview: Some(result.issues_preview.clone()),
|
||||
},
|
||||
SyncDryRunStage {
|
||||
name: "ingest_mrs".to_string(),
|
||||
would_run: true,
|
||||
preview: Some(result.mrs_preview.clone()),
|
||||
},
|
||||
SyncDryRunStage {
|
||||
name: "generate_docs".to_string(),
|
||||
would_run: result.would_generate_docs,
|
||||
preview: None,
|
||||
},
|
||||
SyncDryRunStage {
|
||||
name: "embed".to_string(),
|
||||
would_run: result.would_embed,
|
||||
preview: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
println!("{}", serde_json::to_string(&output).unwrap());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user