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:
137
plans/init-refresh-flag.md
Normal file
137
plans/init-refresh-flag.md
Normal file
@@ -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<String>,
|
||||||
|
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<ProjectInfo>,
|
||||||
|
pub projects_failed: Vec<ProjectFailure>, // path + error message
|
||||||
|
pub orphans_found: Vec<String>, // paths in DB but not config
|
||||||
|
pub orphans_deleted: Vec<String>, // 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
|
||||||
@@ -232,6 +232,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
|||||||
(
|
(
|
||||||
"init",
|
"init",
|
||||||
&[
|
&[
|
||||||
|
"--refresh",
|
||||||
"--force",
|
"--force",
|
||||||
"--non-interactive",
|
"--non-interactive",
|
||||||
"--gitlab-url",
|
"--gitlab-url",
|
||||||
|
|||||||
@@ -38,6 +38,159 @@ pub struct ProjectInfo {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Refresh types ──
|
||||||
|
|
||||||
|
pub struct RefreshOptions {
|
||||||
|
pub config_path: Option<String>,
|
||||||
|
pub non_interactive: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RefreshResult {
|
||||||
|
pub user: UserInfo,
|
||||||
|
pub projects_registered: Vec<ProjectInfo>,
|
||||||
|
pub projects_failed: Vec<ProjectFailure>,
|
||||||
|
pub orphans_found: Vec<String>,
|
||||||
|
pub orphans_deleted: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<RefreshResult> {
|
||||||
|
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<ProjectFailure> = 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<String> = stmt
|
||||||
|
.query_map([], |row| row.get(0))?
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let orphans: Vec<String> = 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<usize> {
|
||||||
|
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<InitResult> {
|
pub async fn run_init(inputs: InitInputs, options: InitOptions) -> Result<InitResult> {
|
||||||
let config_path = get_config_path(options.config_path.as_deref());
|
let config_path = get_config_path(options.config_path.as_deref());
|
||||||
let data_dir = get_data_dir();
|
let data_dir = get_data_dir();
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ pub use ingest::{
|
|||||||
DryRunPreview, IngestDisplay, print_dry_run_preview, print_dry_run_preview_json,
|
DryRunPreview, IngestDisplay, print_dry_run_preview, print_dry_run_preview_json,
|
||||||
print_ingest_summary, print_ingest_summary_json, run_ingest, run_ingest_dry_run,
|
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::{
|
pub use list::{
|
||||||
ListFilters, MrListFilters, NoteListFilters, open_issue_in_browser, open_mr_in_browser,
|
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,
|
print_list_issues, print_list_issues_json, print_list_mrs, print_list_mrs_json,
|
||||||
|
|||||||
@@ -163,10 +163,15 @@ pub enum Commands {
|
|||||||
/// Initialize configuration and database
|
/// Initialize configuration and database
|
||||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
lore init # Interactive setup
|
lore init # Interactive setup
|
||||||
|
lore init --refresh # Register projects from existing config
|
||||||
lore init --force # Overwrite existing config
|
lore init --force # Overwrite existing config
|
||||||
lore --robot init --gitlab-url https://gitlab.com \\
|
lore --robot init --gitlab-url https://gitlab.com \\
|
||||||
--token-env-var GITLAB_TOKEN --projects group/repo # Non-interactive setup")]
|
--token-env-var GITLAB_TOKEN --projects group/repo # Non-interactive setup")]
|
||||||
Init {
|
Init {
|
||||||
|
/// Re-read config and register any new projects in the database
|
||||||
|
#[arg(long, conflicts_with = "force")]
|
||||||
|
refresh: bool,
|
||||||
|
|
||||||
/// Skip overwrite confirmation
|
/// Skip overwrite confirmation
|
||||||
#[arg(short = 'f', long)]
|
#[arg(short = 'f', long)]
|
||||||
force: bool,
|
force: bool,
|
||||||
|
|||||||
257
src/main.rs
257
src/main.rs
@@ -10,24 +10,24 @@ use lore::Config;
|
|||||||
use lore::cli::autocorrect::{self, CorrectionResult};
|
use lore::cli::autocorrect::{self, CorrectionResult};
|
||||||
use lore::cli::commands::{
|
use lore::cli::commands::{
|
||||||
IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters,
|
IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters,
|
||||||
NoteListFilters, SearchCliFilters, SyncOptions, TimelineParams, open_issue_in_browser,
|
NoteListFilters, RefreshOptions, RefreshResult, SearchCliFilters, SyncOptions, TimelineParams,
|
||||||
open_mr_in_browser, parse_trace_path, print_count, print_count_json, print_cron_install,
|
delete_orphan_projects, open_issue_in_browser, open_mr_in_browser, parse_trace_path,
|
||||||
print_cron_install_json, print_cron_status, print_cron_status_json, print_cron_uninstall,
|
print_count, print_count_json, print_cron_install, print_cron_install_json, print_cron_status,
|
||||||
print_cron_uninstall_json, print_doctor_results, print_drift_human, print_drift_json,
|
print_cron_status_json, print_cron_uninstall, print_cron_uninstall_json, print_doctor_results,
|
||||||
print_dry_run_preview, print_dry_run_preview_json, print_embed, print_embed_json,
|
print_drift_human, print_drift_json, print_dry_run_preview, print_dry_run_preview_json,
|
||||||
print_event_count, print_event_count_json, print_file_history, print_file_history_json,
|
print_embed, print_embed_json, print_event_count, print_event_count_json, print_file_history,
|
||||||
print_generate_docs, print_generate_docs_json, print_ingest_summary, print_ingest_summary_json,
|
print_file_history_json, print_generate_docs, print_generate_docs_json, print_ingest_summary,
|
||||||
print_list_issues, print_list_issues_json, print_list_mrs, print_list_mrs_json,
|
print_ingest_summary_json, print_list_issues, print_list_issues_json, print_list_mrs,
|
||||||
print_list_notes, print_list_notes_json, print_related_human, print_related_json,
|
print_list_mrs_json, print_list_notes, print_list_notes_json, print_related_human,
|
||||||
print_search_results, print_search_results_json, print_show_issue, print_show_issue_json,
|
print_related_json, print_search_results, print_search_results_json, print_show_issue,
|
||||||
print_show_mr, print_show_mr_json, print_stats, print_stats_json, print_sync, print_sync_json,
|
print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json,
|
||||||
print_sync_status, print_sync_status_json, print_timeline, print_timeline_json_with_meta,
|
print_sync, print_sync_json, print_sync_status, print_sync_status_json, print_timeline,
|
||||||
print_trace, print_trace_json, print_who_human, print_who_json, query_notes, run_auth_test,
|
print_timeline_json_with_meta, print_trace, print_trace_json, print_who_human, print_who_json,
|
||||||
run_count, run_count_events, run_cron_install, run_cron_status, run_cron_uninstall, run_doctor,
|
query_notes, run_auth_test, run_count, run_count_events, run_cron_install, run_cron_status,
|
||||||
run_drift, run_embed, run_file_history, run_generate_docs, run_ingest, run_ingest_dry_run,
|
run_cron_uninstall, run_doctor, run_drift, run_embed, run_file_history, run_generate_docs,
|
||||||
run_init, run_list_issues, run_list_mrs, run_me, run_related, run_search, run_show_issue,
|
run_ingest, run_ingest_dry_run, run_init, run_init_refresh, run_list_issues, run_list_mrs,
|
||||||
run_show_mr, run_stats, run_sync, run_sync_status, run_timeline, run_token_set, run_token_show,
|
run_me, run_related, run_search, run_show_issue, run_show_mr, run_stats, run_sync,
|
||||||
run_who,
|
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::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme};
|
||||||
use lore::cli::robot::{RobotMeta, strip_schemas};
|
use lore::cli::robot::{RobotMeta, strip_schemas};
|
||||||
@@ -264,6 +264,7 @@ async fn main() {
|
|||||||
Some(Commands::Version) => handle_version(robot_mode),
|
Some(Commands::Version) => handle_version(robot_mode),
|
||||||
Some(Commands::Completions { shell }) => handle_completions(&shell),
|
Some(Commands::Completions { shell }) => handle_completions(&shell),
|
||||||
Some(Commands::Init {
|
Some(Commands::Init {
|
||||||
|
refresh,
|
||||||
force,
|
force,
|
||||||
non_interactive,
|
non_interactive,
|
||||||
gitlab_url,
|
gitlab_url,
|
||||||
@@ -273,6 +274,7 @@ async fn main() {
|
|||||||
}) => {
|
}) => {
|
||||||
handle_init(
|
handle_init(
|
||||||
cli.config.as_deref(),
|
cli.config.as_deref(),
|
||||||
|
refresh,
|
||||||
force,
|
force,
|
||||||
non_interactive,
|
non_interactive,
|
||||||
robot_mode,
|
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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn handle_init(
|
async fn handle_init(
|
||||||
config_override: Option<&str>,
|
config_override: Option<&str>,
|
||||||
|
refresh: bool,
|
||||||
force: bool,
|
force: bool,
|
||||||
non_interactive: bool,
|
non_interactive: bool,
|
||||||
robot_mode: bool,
|
robot_mode: bool,
|
||||||
@@ -1325,6 +1515,11 @@ async fn handle_init(
|
|||||||
projects_flag: Option<String>,
|
projects_flag: Option<String>,
|
||||||
default_project_flag: Option<String>,
|
default_project_flag: Option<String>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> 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 {
|
if robot_mode {
|
||||||
let missing: Vec<&str> = [
|
let missing: Vec<&str> = [
|
||||||
gitlab_url_flag.is_none().then_some("--gitlab-url"),
|
gitlab_url_flag.is_none().then_some("--gitlab-url"),
|
||||||
@@ -1383,18 +1578,36 @@ async fn handle_init(
|
|||||||
eprintln!(
|
eprintln!(
|
||||||
"{}",
|
"{}",
|
||||||
Theme::error().render(&format!(
|
Theme::error().render(&format!(
|
||||||
"Config file exists at {}. Use --force to overwrite.",
|
"Config already exists at {}",
|
||||||
config_path.display()
|
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);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
let confirm = Confirm::new()
|
println!(
|
||||||
.with_prompt(format!(
|
"{}",
|
||||||
"Config file exists at {}. Overwrite?",
|
Theme::warning().render(&format!(
|
||||||
|
"Config already exists at {}",
|
||||||
config_path.display()
|
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)
|
.default(false)
|
||||||
.interact()?;
|
.interact()?;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user