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:
@@ -38,6 +38,159 @@ pub struct ProjectInfo {
|
||||
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> {
|
||||
let config_path = get_config_path(options.config_path.as_deref());
|
||||
let data_dir = get_data_dir();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user