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:
@@ -42,6 +42,23 @@ pub struct IngestResult {
|
||||
pub resource_events_failed: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize)]
|
||||
pub struct DryRunPreview {
|
||||
pub resource_type: String,
|
||||
pub projects: Vec<DryRunProjectPreview>,
|
||||
pub sync_mode: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize)]
|
||||
pub struct DryRunProjectPreview {
|
||||
pub path: String,
|
||||
pub local_id: i64,
|
||||
pub gitlab_id: i64,
|
||||
pub has_cursor: bool,
|
||||
pub last_synced: Option<String>,
|
||||
pub existing_count: i64,
|
||||
}
|
||||
|
||||
enum ProjectIngestOutcome {
|
||||
Issues {
|
||||
path: String,
|
||||
@@ -86,12 +103,14 @@ impl IngestDisplay {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn run_ingest(
|
||||
config: &Config,
|
||||
resource_type: &str,
|
||||
project_filter: Option<&str>,
|
||||
force: bool,
|
||||
full: bool,
|
||||
dry_run: bool,
|
||||
display: IngestDisplay,
|
||||
stage_bar: Option<ProgressBar>,
|
||||
) -> Result<IngestResult> {
|
||||
@@ -105,6 +124,7 @@ pub async fn run_ingest(
|
||||
project_filter,
|
||||
force,
|
||||
full,
|
||||
dry_run,
|
||||
display,
|
||||
stage_bar,
|
||||
)
|
||||
@@ -112,15 +132,107 @@ pub async fn run_ingest(
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn run_ingest_dry_run(
|
||||
config: &Config,
|
||||
resource_type: &str,
|
||||
project_filter: Option<&str>,
|
||||
full: bool,
|
||||
) -> Result<DryRunPreview> {
|
||||
if resource_type != "issues" && resource_type != "mrs" {
|
||||
return Err(LoreError::Other(format!(
|
||||
"Invalid resource type '{}'. Valid types: issues, mrs",
|
||||
resource_type
|
||||
)));
|
||||
}
|
||||
|
||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||
let conn = create_connection(&db_path)?;
|
||||
|
||||
let projects = get_projects_to_sync(&conn, &config.projects, project_filter)?;
|
||||
|
||||
if projects.is_empty() {
|
||||
if let Some(filter) = project_filter {
|
||||
return Err(LoreError::Other(format!(
|
||||
"Project '{}' not found in configuration",
|
||||
filter
|
||||
)));
|
||||
}
|
||||
return Err(LoreError::Other(
|
||||
"No projects configured. Run 'lore init' first.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut preview = DryRunPreview {
|
||||
resource_type: resource_type.to_string(),
|
||||
projects: Vec::new(),
|
||||
sync_mode: if full {
|
||||
"full".to_string()
|
||||
} else {
|
||||
"incremental".to_string()
|
||||
},
|
||||
};
|
||||
|
||||
for (local_project_id, gitlab_project_id, path) in &projects {
|
||||
let cursor_exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sync_cursors WHERE project_id = ? AND resource_type = ?)",
|
||||
(*local_project_id, resource_type),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
let last_synced: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT updated_at FROM sync_cursors WHERE project_id = ? AND resource_type = ?",
|
||||
(*local_project_id, resource_type),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
|
||||
let existing_count: i64 = if resource_type == "issues" {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM issues WHERE project_id = ?",
|
||||
[*local_project_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM merge_requests WHERE project_id = ?",
|
||||
[*local_project_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
preview.projects.push(DryRunProjectPreview {
|
||||
path: path.clone(),
|
||||
local_id: *local_project_id,
|
||||
gitlab_id: *gitlab_project_id,
|
||||
has_cursor: cursor_exists && !full,
|
||||
last_synced: if full { None } else { last_synced },
|
||||
existing_count,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(preview)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_ingest_inner(
|
||||
config: &Config,
|
||||
resource_type: &str,
|
||||
project_filter: Option<&str>,
|
||||
force: bool,
|
||||
full: bool,
|
||||
dry_run: bool,
|
||||
display: IngestDisplay,
|
||||
stage_bar: Option<ProgressBar>,
|
||||
) -> Result<IngestResult> {
|
||||
// In dry_run mode, we don't actually ingest - use run_ingest_dry_run instead
|
||||
// This flag is passed through for consistency but the actual dry-run logic
|
||||
// is handled at the caller level
|
||||
let _ = dry_run;
|
||||
if resource_type != "issues" && resource_type != "mrs" {
|
||||
return Err(LoreError::Other(format!(
|
||||
"Invalid resource type '{}'. Valid types: issues, mrs",
|
||||
@@ -759,3 +871,63 @@ pub fn print_ingest_summary(result: &IngestResult) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_dry_run_preview(preview: &DryRunPreview) {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("Dry Run Preview").cyan().bold(),
|
||||
style("(no changes will be made)").yellow()
|
||||
);
|
||||
println!();
|
||||
|
||||
let type_label = if preview.resource_type == "issues" {
|
||||
"issues"
|
||||
} else {
|
||||
"merge requests"
|
||||
};
|
||||
|
||||
println!(" Resource type: {}", style(type_label).white().bold());
|
||||
println!(
|
||||
" Sync mode: {}",
|
||||
if preview.sync_mode == "full" {
|
||||
style("full (all data will be re-fetched)").yellow()
|
||||
} else {
|
||||
style("incremental (only changes since last sync)").green()
|
||||
}
|
||||
);
|
||||
println!(" Projects: {}", preview.projects.len());
|
||||
println!();
|
||||
|
||||
println!("{}", style("Projects to sync:").cyan().bold());
|
||||
for project in &preview.projects {
|
||||
let sync_status = if !project.has_cursor {
|
||||
style("initial sync").yellow()
|
||||
} else {
|
||||
style("incremental").green()
|
||||
};
|
||||
|
||||
println!(" {} ({})", style(&project.path).white(), sync_status);
|
||||
println!(" Existing {}: {}", type_label, project.existing_count);
|
||||
|
||||
if let Some(ref last_synced) = project.last_synced {
|
||||
println!(" Last synced: {}", last_synced);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DryRunJsonOutput {
|
||||
ok: bool,
|
||||
dry_run: bool,
|
||||
data: DryRunPreview,
|
||||
}
|
||||
|
||||
pub fn print_dry_run_preview_json(preview: &DryRunPreview) {
|
||||
let output = DryRunJsonOutput {
|
||||
ok: true,
|
||||
dry_run: true,
|
||||
data: preview.clone(),
|
||||
};
|
||||
|
||||
println!("{}", serde_json::to_string(&output).unwrap());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user