feat(init): add --refresh flag for project re-registration

When new projects are added to the config file, `lore sync` doesn't pick
them up because project discovery only happens during `lore init`. 
Previously, users had to use `--force` to overwrite their entire config.

The new `--refresh` flag reads the existing config and updates the
database to match, without modifying the config file itself.

Features:
- Validates GitLab authentication before processing
- Registers new projects from config into the database
- Detects orphan projects (in DB but removed from config)
- Interactive mode: prompts to delete orphans (default: No)
- Robot mode: returns JSON with orphan info, no prompts

Usage:
  lore init --refresh              # Interactive
  lore --robot init --refresh      # JSON output

Improved UX: When running `lore init` with an existing config and no
flags, the error message now suggests using `--refresh` to register
new projects or `--force` to overwrite the config file.

Implementation:
- Added RefreshOptions and RefreshResult types to init module
- Added run_init_refresh() for core refresh logic
- Added delete_orphan_projects() helper for orphan cleanup
- Added handle_init_refresh() in main.rs for CLI handling
- Added JSON output types for robot mode
- Registered --refresh in autocorrect.rs command flags registry
- --refresh conflicts with --force (mutually exclusive)
This commit is contained in:
teernisse
2026-03-02 15:23:20 -05:00
parent e4ac7020b3
commit 571c304031
6 changed files with 535 additions and 23 deletions

View File

@@ -10,24 +10,24 @@ use lore::Config;
use lore::cli::autocorrect::{self, CorrectionResult};
use lore::cli::commands::{
IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters,
NoteListFilters, SearchCliFilters, SyncOptions, TimelineParams, open_issue_in_browser,
open_mr_in_browser, parse_trace_path, print_count, print_count_json, print_cron_install,
print_cron_install_json, print_cron_status, print_cron_status_json, print_cron_uninstall,
print_cron_uninstall_json, print_doctor_results, print_drift_human, print_drift_json,
print_dry_run_preview, print_dry_run_preview_json, print_embed, print_embed_json,
print_event_count, print_event_count_json, print_file_history, print_file_history_json,
print_generate_docs, print_generate_docs_json, print_ingest_summary, print_ingest_summary_json,
print_list_issues, print_list_issues_json, print_list_mrs, print_list_mrs_json,
print_list_notes, print_list_notes_json, print_related_human, print_related_json,
print_search_results, print_search_results_json, print_show_issue, print_show_issue_json,
print_show_mr, print_show_mr_json, print_stats, print_stats_json, print_sync, print_sync_json,
print_sync_status, print_sync_status_json, print_timeline, print_timeline_json_with_meta,
print_trace, print_trace_json, print_who_human, print_who_json, query_notes, run_auth_test,
run_count, run_count_events, run_cron_install, run_cron_status, run_cron_uninstall, run_doctor,
run_drift, run_embed, run_file_history, run_generate_docs, run_ingest, run_ingest_dry_run,
run_init, run_list_issues, run_list_mrs, run_me, run_related, run_search, run_show_issue,
run_show_mr, run_stats, run_sync, run_sync_status, run_timeline, run_token_set, run_token_show,
run_who,
NoteListFilters, RefreshOptions, RefreshResult, SearchCliFilters, SyncOptions, TimelineParams,
delete_orphan_projects, open_issue_in_browser, open_mr_in_browser, parse_trace_path,
print_count, print_count_json, print_cron_install, print_cron_install_json, print_cron_status,
print_cron_status_json, print_cron_uninstall, print_cron_uninstall_json, print_doctor_results,
print_drift_human, print_drift_json, print_dry_run_preview, print_dry_run_preview_json,
print_embed, print_embed_json, print_event_count, print_event_count_json, print_file_history,
print_file_history_json, print_generate_docs, print_generate_docs_json, print_ingest_summary,
print_ingest_summary_json, print_list_issues, print_list_issues_json, print_list_mrs,
print_list_mrs_json, print_list_notes, print_list_notes_json, print_related_human,
print_related_json, print_search_results, print_search_results_json, print_show_issue,
print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json,
print_sync, print_sync_json, print_sync_status, print_sync_status_json, print_timeline,
print_timeline_json_with_meta, print_trace, print_trace_json, print_who_human, print_who_json,
query_notes, run_auth_test, run_count, run_count_events, run_cron_install, run_cron_status,
run_cron_uninstall, run_doctor, run_drift, run_embed, run_file_history, run_generate_docs,
run_ingest, run_ingest_dry_run, run_init, run_init_refresh, run_list_issues, run_list_mrs,
run_me, run_related, run_search, run_show_issue, run_show_mr, run_stats, run_sync,
run_sync_status, run_timeline, run_token_set, run_token_show, run_who,
};
use lore::cli::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme};
use lore::cli::robot::{RobotMeta, strip_schemas};
@@ -264,6 +264,7 @@ async fn main() {
Some(Commands::Version) => handle_version(robot_mode),
Some(Commands::Completions { shell }) => handle_completions(&shell),
Some(Commands::Init {
refresh,
force,
non_interactive,
gitlab_url,
@@ -273,6 +274,7 @@ async fn main() {
}) => {
handle_init(
cli.config.as_deref(),
refresh,
force,
non_interactive,
robot_mode,
@@ -1314,9 +1316,197 @@ fn print_init_json(result: &InitResult) {
);
}
// ── Refresh JSON types ──
#[derive(Serialize)]
struct RefreshOutput {
ok: bool,
data: RefreshOutputData,
}
#[derive(Serialize)]
struct RefreshOutputData {
mode: &'static str,
user: InitOutputUser,
projects_registered: Vec<InitOutputProject>,
#[serde(skip_serializing_if = "Vec::is_empty")]
projects_failed: Vec<RefreshOutputFailure>,
#[serde(skip_serializing_if = "Vec::is_empty")]
orphans_found: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
orphans_deleted: Vec<String>,
}
#[derive(Serialize)]
struct RefreshOutputFailure {
path: String,
error: String,
}
fn print_refresh_json(result: &RefreshResult) {
let output = RefreshOutput {
ok: true,
data: RefreshOutputData {
mode: "refresh",
user: InitOutputUser {
username: result.user.username.clone(),
name: result.user.name.clone(),
},
projects_registered: result
.projects_registered
.iter()
.map(|p| InitOutputProject {
path: p.path.clone(),
name: p.name.clone(),
})
.collect(),
projects_failed: result
.projects_failed
.iter()
.map(|p| RefreshOutputFailure {
path: p.path.clone(),
error: p.error.clone(),
})
.collect(),
orphans_found: result.orphans_found.clone(),
orphans_deleted: result.orphans_deleted.clone(),
},
};
println!(
"{}",
serde_json::to_string(&output).unwrap_or_else(|e| {
format!(r#"{{"ok":false,"error":{{"code":"INTERNAL_ERROR","message":"JSON serialization failed: {e}"}}}}"#)
})
);
}
async fn handle_init_refresh(
config_override: Option<&str>,
non_interactive: bool,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let mut result = run_init_refresh(RefreshOptions {
config_path: config_override.map(String::from),
non_interactive,
})
.await?;
// Handle orphan deletion prompt (interactive only)
let mut orphans_deleted: Vec<String> = Vec::new();
if !result.orphans_found.is_empty() && !robot_mode && !non_interactive {
println!(
"\n{}",
Theme::warning().render(&format!(
"Found {} orphan project{} in database (not in config):",
result.orphans_found.len(),
if result.orphans_found.len() == 1 {
""
} else {
"s"
}
))
);
for orphan in &result.orphans_found {
println!(" {}", Theme::muted().render(&format!("{orphan}")));
}
println!();
let confirm = Confirm::new()
.with_prompt(format!(
"Delete {} orphan project{} from database?",
result.orphans_found.len(),
if result.orphans_found.len() == 1 {
""
} else {
"s"
}
))
.default(false)
.interact()?;
if confirm {
let deleted = delete_orphan_projects(config_override, &result.orphans_found)?;
orphans_deleted = result.orphans_found.clone();
println!(
"{}",
Theme::success().render(&format!(" Deleted {deleted} orphan project(s)"))
);
}
}
result.orphans_deleted = orphans_deleted;
if robot_mode {
print_refresh_json(&result);
return Ok(());
}
// Human output
println!(
"\n{}",
Theme::success().render(&format!(
"\u{2713} Authenticated as @{} ({})",
result.user.username, result.user.name
))
);
if !result.projects_registered.is_empty() {
println!("\n {}", Theme::bold().render("Projects"));
for project in &result.projects_registered {
println!(
" {} {:<40} registered",
Theme::success().render("\u{2713}"),
project.path
);
}
}
if !result.projects_failed.is_empty() {
for failure in &result.projects_failed {
println!(
" {} {:<40} {}",
Theme::error().render("\u{2717}"),
failure.path,
failure.error
);
}
}
// Summary
let registered = result.projects_registered.len();
let failed = result.projects_failed.len();
let orphans_kept = result.orphans_found.len() - result.orphans_deleted.len();
let mut summary_parts: Vec<String> = Vec::new();
summary_parts.push(format!(
"{} project{} registered",
registered,
if registered == 1 { "" } else { "s" }
));
if failed > 0 {
summary_parts.push(format!("{failed} failed"));
}
if !result.orphans_deleted.is_empty() {
summary_parts.push(format!(
"{} orphan(s) deleted",
result.orphans_deleted.len()
));
}
if orphans_kept > 0 {
summary_parts.push(format!("{orphans_kept} orphan(s) kept"));
}
println!(
"\n{}",
Theme::info().render(&format!(" {}", summary_parts.join(", ")))
);
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn handle_init(
config_override: Option<&str>,
refresh: bool,
force: bool,
non_interactive: bool,
robot_mode: bool,
@@ -1325,6 +1515,11 @@ async fn handle_init(
projects_flag: Option<String>,
default_project_flag: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
// ── Handle --refresh mode ──
if refresh {
return handle_init_refresh(config_override, non_interactive, robot_mode).await;
}
if robot_mode {
let missing: Vec<&str> = [
gitlab_url_flag.is_none().then_some("--gitlab-url"),
@@ -1383,18 +1578,36 @@ async fn handle_init(
eprintln!(
"{}",
Theme::error().render(&format!(
"Config file exists at {}. Use --force to overwrite.",
"Config already exists at {}",
config_path.display()
))
);
eprintln!(
"{}",
Theme::info().render(" • Use --refresh to register new projects from config")
);
eprintln!(
"{}",
Theme::info().render(" • Use --force to overwrite the config file")
);
std::process::exit(2);
}
let confirm = Confirm::new()
.with_prompt(format!(
"Config file exists at {}. Overwrite?",
println!(
"{}",
Theme::warning().render(&format!(
"Config already exists at {}",
config_path.display()
))
);
println!(
"{}",
Theme::info().render(" • Use --refresh to register new projects from config")
);
println!();
let confirm = Confirm::new()
.with_prompt("Overwrite existing config?")
.default(false)
.interact()?;