From 571c304031043afc9024bf96c676efb92324d2e3 Mon Sep 17 00:00:00 2001 From: teernisse Date: Mon, 2 Mar 2026 15:23:20 -0500 Subject: [PATCH] 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) --- plans/init-refresh-flag.md | 137 ++++++++++++++++++++ src/cli/autocorrect.rs | 1 + src/cli/commands/init.rs | 153 ++++++++++++++++++++++ src/cli/commands/mod.rs | 5 +- src/cli/mod.rs | 5 + src/main.rs | 257 +++++++++++++++++++++++++++++++++---- 6 files changed, 535 insertions(+), 23 deletions(-) create mode 100644 plans/init-refresh-flag.md diff --git a/plans/init-refresh-flag.md b/plans/init-refresh-flag.md new file mode 100644 index 0000000..b90e7bb --- /dev/null +++ b/plans/init-refresh-flag.md @@ -0,0 +1,137 @@ +# Plan: `lore init --refresh` + +**Created:** 2026-03-02 +**Status:** Complete + +## Problem + +When new repos are added to the config file, `lore sync` doesn't pick them up because project discovery only happens during `lore init`. Currently, users must use `--force` to overwrite their config, which is awkward. + +## Solution + +Add `--refresh` flag to `lore init` that reads the existing config and updates the database to match, without overwriting the config file. + +--- + +## Implementation Plan + +### 1. CLI Changes (`src/cli/mod.rs`) + +Add to init subcommand: +- `--refresh` flag (conflicts with `--force`) +- Ensure `--robot` / `-J` propagates to init + +### 2. Update `InitOptions` struct + +```rust +pub struct InitOptions { + pub config_path: Option, + pub force: bool, + pub non_interactive: bool, + pub refresh: bool, // NEW + pub robot_mode: bool, // NEW +} +``` + +### 3. New `RefreshResult` struct + +```rust +pub struct RefreshResult { + pub user: UserInfo, + pub projects_registered: Vec, + pub projects_failed: Vec, // path + error message + pub orphans_found: Vec, // paths in DB but not config + pub orphans_deleted: Vec, // if user said yes +} + +pub struct ProjectFailure { + pub path: String, + pub error: String, +} +``` + +### 4. Main logic: `run_init_refresh()` (new function) + +``` +1. Load config via Config::load() +2. Resolve token, call get_current_user() → validate auth +3. For each project in config.projects: + - Call client.get_project(path) + - On success: collect for DB upsert + - On failure: collect in projects_failed +4. Query DB for all existing projects +5. Compute orphans = DB projects - config projects +6. If orphans exist: + - Robot mode: include in output, no prompt, no delete + - Interactive: prompt "Delete N orphan projects? [y/N]" + - Default N → skip deletion + - Y → delete from DB +7. Upsert validated projects into DB +8. Return RefreshResult +``` + +### 5. Improve existing init error message + +In `run_init()`, when config exists and neither `--refresh` nor `--force`: + +**Current:** +> Config file exists at ~/.config/lore/config.json. Use --force to overwrite. + +**New:** +> Config already exists at ~/.config/lore/config.json. +> - Use `--refresh` to register new projects from config +> - Use `--force` to overwrite the config file + +### 6. Robot mode output + +```json +{ + "ok": true, + "data": { + "mode": "refresh", + "user": { "username": "...", "name": "..." }, + "projects_registered": [...], + "projects_failed": [...], + "orphans_found": ["old/project"], + "orphans_deleted": [] + }, + "meta": { "elapsed_ms": 123 } +} +``` + +### 7. Human output + +``` + ✓ Authenticated as @username (Full Name) + + Projects + ✓ group/project-a registered + ✓ group/project-b registered + ✗ group/nonexistent not found + + Orphans + • old/removed-project + + Delete 1 orphan project from database? [y/N]: n + + Registered 2 projects (1 failed, 1 orphan kept) +``` + +--- + +## Files to Touch + +1. **`src/cli/mod.rs`** — add `--refresh` and `--robot` to init subcommand args +2. **`src/cli/commands/init.rs`** — add `RefreshResult`, `run_init_refresh()`, update error message +3. **`src/main.rs`** (or CLI dispatch) — route `--refresh` to new function + +--- + +## Acceptance Criteria + +- [x] `lore init --refresh` reads existing config and registers projects +- [x] Validates GitLab auth before processing +- [x] Orphan projects prompt with default N (interactive mode) +- [x] Robot mode outputs JSON, no prompts, includes orphans in output +- [x] Existing `lore init` (no flags) suggests `--refresh` when config exists +- [x] `--refresh` and `--force` are mutually exclusive diff --git a/src/cli/autocorrect.rs b/src/cli/autocorrect.rs index ab8b1ca..191d882 100644 --- a/src/cli/autocorrect.rs +++ b/src/cli/autocorrect.rs @@ -232,6 +232,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[ ( "init", &[ + "--refresh", "--force", "--non-interactive", "--gitlab-url", diff --git a/src/cli/commands/init.rs b/src/cli/commands/init.rs index 5185e6c..ceb9f15 100644 --- a/src/cli/commands/init.rs +++ b/src/cli/commands/init.rs @@ -38,6 +38,159 @@ pub struct ProjectInfo { pub name: String, } +// ── Refresh types ── + +pub struct RefreshOptions { + pub config_path: Option, + pub non_interactive: bool, +} + +pub struct RefreshResult { + pub user: UserInfo, + pub projects_registered: Vec, + pub projects_failed: Vec, + pub orphans_found: Vec, + pub orphans_deleted: Vec, +} + +pub struct ProjectFailure { + pub path: String, + pub error: String, +} + +/// Re-read existing config and register any new projects in the database. +/// Does NOT modify the config file. +pub async fn run_init_refresh(options: RefreshOptions) -> Result { + let config_path = get_config_path(options.config_path.as_deref()); + + if !config_path.exists() { + return Err(LoreError::ConfigNotFound { + path: config_path.display().to_string(), + }); + } + + let config = Config::load(options.config_path.as_deref())?; + let token = config.gitlab.resolve_token()?; + let client = GitLabClient::new(&config.gitlab.base_url, &token, None); + + // Validate auth + let gitlab_user = client.get_current_user().await.map_err(|e| { + if matches!(e, LoreError::GitLabAuthFailed) { + LoreError::Other(format!( + "Authentication failed for {}", + config.gitlab.base_url + )) + } else { + e + } + })?; + + let user = UserInfo { + username: gitlab_user.username, + name: gitlab_user.name, + }; + + // Validate each project + let mut validated_projects: Vec<(ProjectInfo, GitLabProject)> = Vec::new(); + let mut failed_projects: Vec = Vec::new(); + + for project_config in &config.projects { + match client.get_project(&project_config.path).await { + Ok(project) => { + validated_projects.push(( + ProjectInfo { + path: project_config.path.clone(), + name: project.name.clone().unwrap_or_else(|| { + project_config + .path + .split('/') + .next_back() + .unwrap_or(&project_config.path) + .to_string() + }), + }, + project, + )); + } + Err(e) => { + let error_msg = if matches!(e, LoreError::GitLabNotFound { .. }) { + "not found".to_string() + } else { + e.to_string() + }; + failed_projects.push(ProjectFailure { + path: project_config.path.clone(), + error: error_msg, + }); + } + } + } + + // Open database + let data_dir = get_data_dir(); + let db_path = data_dir.join("lore.db"); + let conn = create_connection(&db_path)?; + run_migrations(&conn)?; + + // Find orphans: projects in DB but not in config + let config_paths: std::collections::HashSet<&str> = + config.projects.iter().map(|p| p.path.as_str()).collect(); + + let mut stmt = conn.prepare("SELECT path_with_namespace FROM projects")?; + let db_projects: Vec = stmt + .query_map([], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect(); + + let orphans: Vec = db_projects + .into_iter() + .filter(|p| !config_paths.contains(p.as_str())) + .collect(); + + // Upsert validated projects + for (_, gitlab_project) in &validated_projects { + conn.execute( + "INSERT INTO projects (gitlab_project_id, path_with_namespace, default_branch, web_url) + VALUES (?, ?, ?, ?) + ON CONFLICT(gitlab_project_id) DO UPDATE SET + path_with_namespace = excluded.path_with_namespace, + default_branch = excluded.default_branch, + web_url = excluded.web_url", + ( + gitlab_project.id, + &gitlab_project.path_with_namespace, + &gitlab_project.default_branch, + &gitlab_project.web_url, + ), + )?; + } + + Ok(RefreshResult { + user, + projects_registered: validated_projects.into_iter().map(|(p, _)| p).collect(), + projects_failed: failed_projects, + orphans_found: orphans, + orphans_deleted: Vec::new(), // Caller handles deletion after user prompt + }) +} + +/// Delete orphan projects from the database. +pub fn delete_orphan_projects(config_path: Option<&str>, orphans: &[String]) -> Result { + let data_dir = get_data_dir(); + let db_path = data_dir.join("lore.db"); + let conn = create_connection(&db_path)?; + + let _ = config_path; // Reserved for future use + + let mut deleted = 0; + for path in orphans { + let rows = conn.execute("DELETE FROM projects WHERE path_with_namespace = ?", [path])?; + deleted += rows; + } + + Ok(deleted) +} + pub async fn run_init(inputs: InitInputs, options: InitOptions) -> Result { let config_path = get_config_path(options.config_path.as_deref()); let data_dir = get_data_dir(); diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index aba600f..d22f236 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -42,7 +42,10 @@ pub use ingest::{ DryRunPreview, IngestDisplay, print_dry_run_preview, print_dry_run_preview_json, print_ingest_summary, print_ingest_summary_json, run_ingest, run_ingest_dry_run, }; -pub use init::{InitInputs, InitOptions, InitResult, run_init, run_token_set, run_token_show}; +pub use init::{ + InitInputs, InitOptions, InitResult, RefreshOptions, RefreshResult, delete_orphan_projects, + run_init, run_init_refresh, run_token_set, run_token_show, +}; pub use list::{ ListFilters, MrListFilters, NoteListFilters, open_issue_in_browser, open_mr_in_browser, print_list_issues, print_list_issues_json, print_list_mrs, print_list_mrs_json, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 92d328d..9547ec7 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -163,10 +163,15 @@ pub enum Commands { /// Initialize configuration and database #[command(after_help = "\x1b[1mExamples:\x1b[0m lore init # Interactive setup + lore init --refresh # Register projects from existing config lore init --force # Overwrite existing config lore --robot init --gitlab-url https://gitlab.com \\ --token-env-var GITLAB_TOKEN --projects group/repo # Non-interactive setup")] Init { + /// Re-read config and register any new projects in the database + #[arg(long, conflicts_with = "force")] + refresh: bool, + /// Skip overwrite confirmation #[arg(short = 'f', long)] force: bool, diff --git a/src/main.rs b/src/main.rs index d5cc41b..5e9f5d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, + #[serde(skip_serializing_if = "Vec::is_empty")] + projects_failed: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + orphans_found: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + orphans_deleted: Vec, +} + +#[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> { + 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 = 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 = 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, default_project_flag: Option, ) -> Result<(), Box> { + // ── 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()?;