From b8143dfc862a95218cf5e994d9c382237d23b4c3 Mon Sep 17 00:00:00 2001 From: Nikita Bayev Date: Mon, 23 Mar 2026 15:07:37 +0500 Subject: [PATCH 01/11] feat: add onboarding command entrypoint Made-with: Cursor --- src/main.rs | 29 +++++++++++++++++++++++++++++ src/onboarding.rs | 14 ++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 src/onboarding.rs diff --git a/src/main.rs b/src/main.rs index 6c9d8ff..5b0f84c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod db; mod hook; mod init; mod interactive; +mod onboarding; mod paths; mod render; mod summary_group; @@ -124,6 +125,8 @@ enum Commands { Config, /// Show database metadata. Metadata, + /// Import existing commit history for the current repository (onboarding). + Onboard, /// Update diddo to the latest release. Update(UpdateArgs), } @@ -311,6 +314,7 @@ fn run_cli(cli: ParsedCli) { Some(Commands::Hook) => run_hook_command(), Some(Commands::Config) => run_config_command(), Some(Commands::Metadata) => run_metadata_command(), + Some(Commands::Onboard) => run_onboard_command(), Some(Commands::Update(args)) => run_update_command(args), _ => run_summary_command(cli), }; @@ -393,6 +397,14 @@ fn run_metadata_command() -> Result<(), Box> { Ok(()) } +fn run_onboard_command() -> Result<(), Box> { + let paths = paths::AppPaths::new()?; + let database = db::Database::open(&paths.db_path)?; + let config = config::AppConfig::load(&paths.config_path)?; + + onboarding::run(&database, paths.config_path.as_path(), config) +} + fn run_update_command(args: UpdateArgs) -> Result<(), Box> { update::run(args.yes) } @@ -481,6 +493,7 @@ fn summary_request_from_cli(cli: ParsedCli) -> Option<(SummarySelection, Summary | Commands::Hook | Commands::Config | Commands::Metadata + | Commands::Onboard | Commands::Update(_), ) => None, } @@ -1307,6 +1320,20 @@ mod tests { assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument); } + #[test] + fn parses_onboard_subcommand() { + let cli = parse_cli(["diddo", "onboard"]).unwrap(); + + assert_eq!(cli.command, Some(Commands::Onboard)); + } + + #[test] + fn rejects_summary_flags_on_onboard_command() { + let error = parse_cli(["diddo", "onboard", "--md"]).unwrap_err(); + + assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument); + } + #[test] fn rejects_range_without_from() { let error = parse_cli(["diddo", "range"]).unwrap_err(); @@ -1396,6 +1423,7 @@ mod tests { parse_cli(["diddo", "range", "--from", "2026-03-01"]).unwrap(), ); let init = summary_request_from_cli(parse_cli(["diddo", "init"]).unwrap()); + let onboard = summary_request_from_cli(parse_cli(["diddo", "onboard"]).unwrap()); assert_eq!( today, @@ -1437,6 +1465,7 @@ mod tests { )) ); assert_eq!(init, None); + assert_eq!(onboard, None); } #[test] diff --git a/src/onboarding.rs b/src/onboarding.rs new file mode 100644 index 0000000..5e893f9 --- /dev/null +++ b/src/onboarding.rs @@ -0,0 +1,14 @@ +use std::error::Error; +use std::path::Path; + +use crate::config; +use crate::db; + +/// Full onboarding flow (git scan, identity selection, import) is implemented incrementally. +pub fn run( + _database: &db::Database, + _config_path: &Path, + _config: config::AppConfig, +) -> Result<(), Box> { + Ok(()) +} From 3d3ab455b0c865c39c3f0f313042d602f8bea9c1 Mon Sep 17 00:00:00 2001 From: Nikita Bayev Date: Mon, 23 Mar 2026 15:08:56 +0500 Subject: [PATCH 02/11] feat: add onboarding identity alias config Made-with: Cursor --- src/config.rs | 130 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 124 insertions(+), 6 deletions(-) diff --git a/src/config.rs b/src/config.rs index 21979b7..3566b65 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,15 +1,40 @@ use std::{fs, io, path::Path}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(default)] pub struct AppConfig { pub ai: AiConfig, pub update: UpdateConfig, + pub onboarding: OnboardingConfig, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +/// User preferences for manual repository onboarding (identity aliases, etc.). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(default)] +pub struct OnboardingConfig { + pub save_selected_identities: bool, + pub identity_aliases: Vec, +} + +impl Default for OnboardingConfig { + fn default() -> Self { + Self { + save_selected_identities: true, + identity_aliases: Vec::new(), + } + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(default)] +pub struct IdentityAlias { + pub name: Option, + pub email: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(default)] pub struct UpdateConfig { pub auto_check: bool, @@ -21,7 +46,7 @@ impl Default for UpdateConfig { } } -#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(default)] pub struct AiConfig { pub provider: Option, @@ -31,7 +56,7 @@ pub struct AiConfig { pub cli: AiCliConfig, } -#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(default)] pub struct AiCliConfig { pub prefer: Option, @@ -56,6 +81,43 @@ impl AppConfig { } } +/// Persist onboarding identity aliases, preserving other config sections when possible. +#[allow(dead_code)] // Used by tests; `onboarding` will call this when saving aliases after import. +pub fn save_onboarding_aliases(path: &Path, aliases: &[IdentityAlias]) -> io::Result<()> { + let mut config = if path.exists() { + AppConfig::load(path)? + } else { + AppConfig::default() + }; + + config.onboarding.identity_aliases = aliases.iter().cloned().map(normalize_identity_alias).collect(); + + let contents = toml::to_string_pretty(&config).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("failed to serialize config: {error}"), + ) + })?; + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, contents) +} + +fn normalize_identity_alias(alias: IdentityAlias) -> IdentityAlias { + IdentityAlias { + name: normalize_optional_string(alias.name), + email: normalize_optional_string(alias.email), + } +} + +fn normalize_optional_string(value: Option) -> Option { + value + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + #[allow(dead_code)] impl AiConfig { pub fn normalized_provider(&self) -> Option { @@ -150,7 +212,7 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use super::AppConfig; + use super::{AppConfig, IdentityAlias, save_onboarding_aliases}; #[test] fn returns_default_config_when_file_does_not_exist() { @@ -498,6 +560,62 @@ prompt_instructions = " \n\t " fs::remove_dir_all(temp).unwrap(); } + #[test] + fn parses_onboarding_identity_aliases_from_toml() { + let temp = temp_dir("onboarding-parse"); + let config_path = temp.join("config.toml"); + + fs::write( + &config_path, + r#"[onboarding] +save_selected_identities = true + +[[onboarding.identity_aliases]] +name = "Nikita Bayev" +email = "nikita@old-company.com" + +[[onboarding.identity_aliases]] +email = "drugoi@example.com" +"#, + ) + .unwrap(); + + let config = AppConfig::load(&config_path).unwrap(); + + assert_eq!(config.onboarding.identity_aliases.len(), 2); + assert_eq!( + config.onboarding.identity_aliases[0].email.as_deref(), + Some("nikita@old-company.com") + ); + + fs::remove_dir_all(temp).unwrap(); + } + + #[test] + fn save_onboarding_aliases_writes_expected_toml() { + let temp = temp_dir("onboarding-save"); + let config_path = temp.join("config.toml"); + + let aliases = [ + IdentityAlias { + name: Some("Nikita Bayev".to_string()), + email: Some("nikita@old-company.com".to_string()), + }, + IdentityAlias { + name: None, + email: Some("drugoi@example.com".to_string()), + }, + ]; + + save_onboarding_aliases(&config_path, &aliases).unwrap(); + + let written = fs::read_to_string(&config_path).unwrap(); + assert!(written.contains("[onboarding]")); + assert!(written.contains("[[onboarding.identity_aliases]]")); + + fs::remove_dir_all(temp).unwrap(); + } + fn env_lock() -> &'static Mutex<()> { static ENV_LOCK: OnceLock> = OnceLock::new(); From 81550eda00252b2f34e3e633cc2d0a49cbf4f03a Mon Sep 17 00:00:00 2001 From: Nikita Bayev Date: Mon, 23 Mar 2026 15:09:28 +0500 Subject: [PATCH 03/11] feat: add onboarding import core Made-with: Cursor --- src/onboarding.rs | 225 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 222 insertions(+), 3 deletions(-) diff --git a/src/onboarding.rs b/src/onboarding.rs index 5e893f9..b9b3632 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -1,14 +1,233 @@ +#![cfg_attr( + not(test), + allow(dead_code) +)] // Core helpers are covered by unit tests; `run()` wires them in later tasks. + use std::error::Error; use std::path::Path; +use chrono::{DateTime, NaiveDate, Utc}; + use crate::config; -use crate::db; +use crate::db::{Commit, Database}; + +/// Commit metadata scanned from `git log` before conversion to [`Commit`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScannedCommit { + pub hash: String, + pub message: String, + pub author_name: String, + pub author_email: Option, + pub committed_at: DateTime, +} + +/// Author identity the user selected for import (email preferred; name when email is absent on the commit). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IdentityCandidate { + pub name: Option, + pub email: Option, +} + +/// Counts for one import run. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ImportOutcome { + pub scanned: usize, + pub matched: usize, + pub inserted: usize, + pub skipped_duplicates: usize, +} -/// Full onboarding flow (git scan, identity selection, import) is implemented incrementally. +/// Full onboarding flow (interactive UI, git scan) is implemented incrementally. pub fn run( - _database: &db::Database, + _database: &Database, _config_path: &Path, _config: config::AppConfig, ) -> Result<(), Box> { Ok(()) } + +/// Keep commits on or after `cutoff` (local date vs UTC timestamps) whose author matches `selected`. +pub fn filter_importable_commits( + commits: &[ScannedCommit], + cutoff: NaiveDate, + selected: &[IdentityCandidate], +) -> Vec { + commits + .iter() + .filter(|c| { + c.committed_at.date_naive() >= cutoff && identity_matches(c, selected) + }) + .cloned() + .collect() +} + +fn identity_matches(commit: &ScannedCommit, selected: &[IdentityCandidate]) -> bool { + selected.iter().any(|id| { + if let Some(ref ce) = commit.author_email { + id.email + .as_ref() + .is_some_and(|se| ce.eq_ignore_ascii_case(se)) + } else { + match (&commit.author_name, &id.name) { + (cn, Some(sn)) => cn == sn, + _ => false, + } + } + }) +} + +pub fn to_db_commit( + scanned: &ScannedCommit, + repo_path: &str, + repo_name: &str, + branch: &str, +) -> Commit { + Commit { + id: None, + hash: scanned.hash.clone(), + message: scanned.message.clone(), + repo_path: repo_path.to_string(), + repo_name: repo_name.to_string(), + branch: branch.to_string(), + files_changed: 0, + insertions: 0, + deletions: 0, + committed_at: scanned.committed_at, + author_email: scanned.author_email.clone(), + } +} + +/// Insert scanned commits into the database. Duplicate `(repo_path, hash)` rows are updated in place, not double-counted. +pub fn import_commits( + database: &Database, + repo_path: &str, + repo_name: &str, + branch: &str, + commits: &[ScannedCommit], +) -> Result { + let scanned = commits.len(); + let matched = commits.len(); + let count_before = database.commit_count()?; + + for c in commits { + let row = to_db_commit(c, repo_path, repo_name, branch); + database.insert_commit(&row)?; + } + + let count_after = database.commit_count()?; + let inserted = (count_after - count_before) as usize; + let skipped_duplicates = matched.saturating_sub(inserted); + + Ok(ImportOutcome { + scanned, + matched, + inserted, + skipped_duplicates, + }) +} + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + + use super::*; + use crate::db::Database; + + fn sample_commit( + hash: &str, + message: &str, + author_name: &str, + author_email: Option<&str>, + committed_at: DateTime, + ) -> ScannedCommit { + ScannedCommit { + hash: hash.to_string(), + message: message.to_string(), + author_name: author_name.to_string(), + author_email: author_email.map(str::to_string), + committed_at, + } + } + + #[test] + fn filters_commits_on_or_after_cutoff_date() { + let cutoff = NaiveDate::from_ymd_opt(2026, 1, 2).unwrap(); + let selected = vec![IdentityCandidate { + name: None, + email: Some("me@example.com".to_string()), + }]; + let commits = vec![ + sample_commit( + "aaa1111", + "old", + "Me", + Some("me@example.com"), + Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap(), + ), + sample_commit( + "bbb2222", + "new", + "Me", + Some("me@example.com"), + Utc.with_ymd_and_hms(2026, 1, 3, 12, 0, 0).unwrap(), + ), + ]; + + let imported = filter_importable_commits(&commits, cutoff, &selected); + + assert_eq!( + imported.iter().map(|c| c.hash.as_str()).collect::>(), + vec!["bbb2222"] + ); + } + + #[test] + fn imports_only_selected_identities() { + let cutoff = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); + let selected = vec![IdentityCandidate { + name: None, + email: Some("me@example.com".to_string()), + }]; + let commits = vec![ + sample_commit( + "aaa1111", + "a", + "A", + Some("other@example.com"), + Utc.with_ymd_and_hms(2026, 1, 5, 12, 0, 0).unwrap(), + ), + sample_commit( + "bbb2222", + "b", + "Me", + Some("me@example.com"), + Utc.with_ymd_and_hms(2026, 1, 5, 12, 0, 0).unwrap(), + ), + ]; + + let imported = filter_importable_commits(&commits, cutoff, &selected); + + assert_eq!(imported.len(), 1); + assert_eq!(imported[0].author_email.as_deref(), Some("me@example.com")); + } + + #[test] + fn import_is_idempotent_when_re_run() { + let database = Database::open_in_memory().unwrap(); + let repo_path = "/tmp/repo"; + let repo_name = "repo"; + let branch = "main"; + let filtered = vec![sample_commit( + "abc1234", + "msg", + "Me", + Some("me@example.com"), + Utc.with_ymd_and_hms(2026, 2, 1, 12, 0, 0).unwrap(), + )]; + + import_commits(&database, repo_path, repo_name, branch, &filtered).unwrap(); + import_commits(&database, repo_path, repo_name, branch, &filtered).unwrap(); + + assert_eq!(database.commit_count().unwrap(), 1); + } +} From f80bb8a261e7f91f5ee8ab8ddc13469c44635bb3 Mon Sep 17 00:00:00 2001 From: Nikita Bayev Date: Mon, 23 Mar 2026 15:10:37 +0500 Subject: [PATCH 04/11] feat: add onboarding git history scan Made-with: Cursor --- src/onboarding.rs | 123 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/src/onboarding.rs b/src/onboarding.rs index b9b3632..fa69305 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -46,6 +46,106 @@ pub fn run( Ok(()) } +/// `git log` line format: hash, subject, author name, author email, commit date (RFC3339), separated by ASCII unit separator. +#[allow(dead_code)] // Used when wiring `git log`; must stay aligned with [`parse_git_log_output`]. +pub const GIT_LOG_FORMAT: &str = "%H%x1f%s%x1f%an%x1f%ae%x1f%cI"; + +/// Parse output from `git log` using [`GIT_LOG_FORMAT`] (one record per line). +pub fn parse_git_log_output(output: &str) -> Result, String> { + let mut commits = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + let parts: Vec<&str> = line.split('\x1f').collect(); + if parts.len() != 5 { + return Err(format!( + "expected 5 fields per log line, got {}", + parts.len() + )); + } + + let hash = parts[0].trim().to_string(); + let message = parts[1].to_string(); + let author_name = parts[2].trim().to_string(); + let author_email = { + let e = parts[3].trim(); + if e.is_empty() { + None + } else { + Some(e.to_string()) + } + }; + + let committed_at = DateTime::parse_from_rfc3339(parts[4].trim()) + .map_err(|e| e.to_string())? + .with_timezone(&Utc); + + commits.push(ScannedCommit { + hash, + message, + author_name, + author_email, + committed_at, + }); + } + + Ok(commits) +} + +/// Unique author identities from scanned commits, sorted with email-backed rows first, then by email and name. +pub fn detect_identities(commits: &[ScannedCommit]) -> Vec { + use std::collections::HashSet; + + let mut seen: HashSet<(Option, Option)> = HashSet::new(); + let mut out = Vec::new(); + + for c in commits { + let email = c + .author_email + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let name = if c.author_name.trim().is_empty() { + None + } else { + Some(c.author_name.trim().to_string()) + }; + + if email.is_none() && name.is_none() { + continue; + } + + let key = (email.clone(), name.clone()); + if seen.insert(key) { + out.push(IdentityCandidate { name, email }); + } + } + + out.sort_by(|a, b| match (&a.email, &b.email) { + (Some(ea), Some(eb)) => ea + .cmp(eb) + .then_with(|| cmp_opt_str(&a.name, &b.name)), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => cmp_opt_str(&a.name, &b.name), + }); + + out +} + +fn cmp_opt_str(a: &Option, b: &Option) -> std::cmp::Ordering { + match (a, b) { + (Some(x), Some(y)) => x.cmp(y), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } +} + /// Keep commits on or after `cutoff` (local date vs UTC timestamps) whose author matches `selected`. pub fn filter_importable_commits( commits: &[ScannedCommit], @@ -230,4 +330,27 @@ mod tests { assert_eq!(database.commit_count().unwrap(), 1); } + + fn sample_log_two_commits() -> &'static str { + "abc1234\x1ffirst\x1fAuthor One\x1fme@example.com\x1f2026-01-01T12:00:00+00:00\ndef5678\x1fsecond\x1fAuthor Two\x1fother@example.com\x1f2026-01-02T12:00:00+00:00" + } + + #[test] + fn parse_git_log_output_builds_scanned_commits() { + let commits = parse_git_log_output(sample_log_two_commits()).unwrap(); + + assert_eq!(commits.len(), 2); + assert_eq!(commits[0].hash, "abc1234"); + } + + #[test] + fn detected_identities_include_unique_name_email_pairs() { + let commits = parse_git_log_output(sample_log_two_commits()).unwrap(); + let identities = detect_identities(&commits); + + assert_eq!(identities.len(), 2); + assert!(identities + .iter() + .any(|i| i.email.as_deref() == Some("me@example.com"))); + } } From f24a70e1aae2138c4222e1682a92d0b727ce2e50 Mon Sep 17 00:00:00 2001 From: Nikita Bayev Date: Mon, 23 Mar 2026 15:11:23 +0500 Subject: [PATCH 05/11] feat: preselect onboarding identities from git and config - Add GitIdentity and build_preselected_identities (anchors, saved aliases, detected email matches; name-only only when no email in scan) - Unit tests for alias preselection and name-only guard Made-with: Cursor --- src/onboarding.rs | 146 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) diff --git a/src/onboarding.rs b/src/onboarding.rs index fa69305..bb36be1 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -8,7 +8,7 @@ use std::path::Path; use chrono::{DateTime, NaiveDate, Utc}; -use crate::config; +use crate::config::{self, IdentityAlias}; use crate::db::{Commit, Database}; /// Commit metadata scanned from `git log` before conversion to [`Commit`]. @@ -28,6 +28,13 @@ pub struct IdentityCandidate { pub email: Option, } +/// Current repository identity from `git config user.name` / `user.email`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct GitIdentity { + pub name: Option, + pub email: Option, +} + /// Counts for one import run. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ImportOutcome { @@ -146,6 +153,93 @@ fn cmp_opt_str(a: &Option, b: &Option) -> std::cmp::Ordering { } } +/// Preselect identities for import: current git user, saved config aliases, and detected authors that match anchor emails. +/// When the scan contains any email-backed author, name-only detected identities are not auto-selected (user must confirm). +pub fn build_preselected_identities( + current: &GitIdentity, + saved_aliases: &[IdentityAlias], + detected: &[IdentityCandidate], +) -> Vec { + use std::collections::HashSet; + + fn push_unique( + out: &mut Vec, + seen: &mut HashSet<(Option, Option)>, + id: IdentityCandidate, + ) { + let key = (id.email.clone(), id.name.clone()); + if seen.insert(key) { + out.push(id); + } + } + + let mut seen: HashSet<(Option, Option)> = HashSet::new(); + let mut out = Vec::new(); + + if let Some(ref e) = current.email { + let e = e.trim(); + if !e.is_empty() { + push_unique( + &mut out, + &mut seen, + IdentityCandidate { + name: current + .name + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + email: Some(e.to_string()), + }, + ); + } + } + + for a in saved_aliases { + let email = a + .email + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let name = a + .name + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if email.is_none() && name.is_none() { + continue; + } + push_unique(&mut out, &mut seen, IdentityCandidate { name, email }); + } + + let anchor_emails: HashSet = out + .iter() + .filter_map(|i| i.email.as_ref().map(|e| e.to_ascii_lowercase())) + .collect(); + + let detected_has_email = detected.iter().any(|d| d.email.is_some()); + + for d in detected { + if let Some(ref de) = d.email { + if anchor_emails + .iter() + .any(|a| a.eq_ignore_ascii_case(de)) + { + push_unique(&mut out, &mut seen, d.clone()); + } + } else if !detected_has_email + && let (Some(cn), Some(dn)) = ( + current.name.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()), + d.name.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()), + ) + && cn == dn + { + push_unique(&mut out, &mut seen, d.clone()); + } + } + + out +} + /// Keep commits on or after `cutoff` (local date vs UTC timestamps) whose author matches `selected`. pub fn filter_importable_commits( commits: &[ScannedCommit], @@ -231,6 +325,7 @@ mod tests { use chrono::TimeZone; use super::*; + use crate::config::IdentityAlias; use crate::db::Database; fn sample_commit( @@ -353,4 +448,53 @@ mod tests { .iter() .any(|i| i.email.as_deref() == Some("me@example.com"))); } + + #[test] + fn preselects_current_git_email_and_saved_aliases() { + let current_identity = GitIdentity { + name: Some("Me".to_string()), + email: Some("me@example.com".to_string()), + }; + let saved_aliases = vec![IdentityAlias { + name: None, + email: Some("me@old-company.com".to_string()), + }]; + let detected = vec![IdentityCandidate { + name: Some("Other".to_string()), + email: Some("other@example.com".to_string()), + }]; + + let selected = build_preselected_identities(¤t_identity, &saved_aliases, &detected); + + assert!(selected + .iter() + .any(|i| i.email.as_deref() == Some("me@example.com"))); + assert!(selected + .iter() + .any(|i| i.email.as_deref() == Some("me@old-company.com"))); + } + + #[test] + fn name_only_matches_are_not_auto_selected_when_email_candidates_exist() { + let current_identity = GitIdentity { + name: Some("User".to_string()), + email: Some("me@example.com".to_string()), + }; + let detected = vec![ + IdentityCandidate { + name: Some("Nikita".to_string()), + email: None, + }, + IdentityCandidate { + name: Some("Other".to_string()), + email: Some("x@y.com".to_string()), + }, + ]; + + let selected = build_preselected_identities(¤t_identity, &[], &detected); + + assert!(!selected + .iter() + .any(|i| i.name.as_deref() == Some("Nikita") && i.email.is_none())); + } } From 78d0c818be2c958f47d35a620e63a20643a15393 Mon Sep 17 00:00:00 2001 From: Nikita Bayev Date: Mon, 23 Mar 2026 15:15:51 +0500 Subject: [PATCH 06/11] feat: interactive onboarding flow and documentation - Implement run/run_with: git repo validation, git log scan, cutoff prompt, identity list + selection, filter + import with scanned/matched stats - Extend import_commits with explicit scanned/matched counts for outcomes - Optional merge of new identities into config via save_onboarding_aliases - Tests: empty history, selective import with injected git; help lists onboard - README: onboard section; add design + implementation plan docs Made-with: Cursor --- README.md | 17 +- .../2026-03-23-manual-onboarding-design.md | 202 ++++++++ ...-03-23-manual-onboarding-implementation.md | 487 ++++++++++++++++++ src/config.rs | 7 +- src/main.rs | 22 +- src/onboarding.rs | 462 +++++++++++++++-- 6 files changed, 1149 insertions(+), 48 deletions(-) create mode 100644 docs/plans/2026-03-23-manual-onboarding-design.md create mode 100644 docs/plans/2026-03-23-manual-onboarding-implementation.md diff --git a/README.md b/README.md index 340eeca..0ad8a00 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,22 @@ Also added diddo post-commit to this repo's local hooks (.husky) so commits are If a `post-commit` already exists in that directory, it is backed up as `post-commit.diddo-prev` and the new script chains to it. -`diddo` only records commits made after setup. It does not backfill old git history into the database. +`diddo` only records commits made after setup by default; it does not backfill old git history into the database automatically. + +### Importing older history (`diddo onboard`) + +To import existing commits from the **current repository** into the database (for example after you start using `diddo` on an existing project), run: + +```bash +diddo onboard +``` + +From the repository root, you’ll be prompted for: + +- A **cutoff date** (`YYYY-MM-DD` or `DD.MM.YYYY`). Commits **on or after** that calendar day (in each commit’s local date) are considered. +- Which **author identities** to include (numbered list from `git log`; comma-separated indexes, or Enter to accept preselected identities based on your current `git config` and any saved aliases). + +Optional identity aliases are stored under `[onboarding]` in your config when you confirm saving them. To see where `diddo` stores its config, database, and managed hooks on your machine: diff --git a/docs/plans/2026-03-23-manual-onboarding-design.md b/docs/plans/2026-03-23-manual-onboarding-design.md new file mode 100644 index 0000000..ddec597 --- /dev/null +++ b/docs/plans/2026-03-23-manual-onboarding-design.md @@ -0,0 +1,202 @@ +# diddo manual onboarding — Design Document + +Manual onboarding imports existing commit history from a repository into `diddo`'s local database without changing how future hook-based tracking works. + +## Scope + +- **Command:** `diddo onboard` +- **Primary goal:** backfill commit history for an existing repository from a configurable cutoff date forward +- **Import filter:** only import commits authored by identities that belong to the current user +- **Identity source:** preselect the current repo git identity, optionally include reusable aliases saved in config, and allow the user to confirm additional detected identities +- **Out of scope:** + - replacing `diddo init` + - importing all authors by default + - changing summary query behavior + - keeping a separate imported-history table + +## Recommended approach + +Add a dedicated onboarding command instead of extending `diddo init`. + +Why this approach: + +- `diddo init` already handles hook installation and local/global hook edge cases; keeping onboarding separate avoids mixing setup with historical import +- backfilling old history is a user-driven, higher-risk action that benefits from an explicit command and confirmation flow +- a standalone command can later support both interactive and scripted usage without complicating the main setup path + +## User flow + +1. User runs `diddo onboard` inside an existing git repository. +2. `diddo` asks for a cutoff date, meaning "import commits on or after this date". +3. `diddo` scans git history in that range and detects author names and emails. +4. `diddo` preselects the current repo identity from git config and any saved onboarding aliases from config. +5. `diddo` shows detected identities and asks the user which ones should count as their own history. +6. `diddo` imports only commits whose author matches one of the selected identities. +7. `diddo` reports scan and import totals, including duplicates skipped because they already exist in the database. +8. If the user selected new identities, `diddo` offers to save them as reusable aliases for future onboarding. + +## Command shape + +Initial command: + +- `diddo onboard` + +Possible follow-up flags for later iterations: + +- `--from ` to bypass the cutoff prompt +- `--yes` for non-interactive confirmation +- `--author-email ` for scripted imports + +The default path should remain interactive because identity confirmation is the key safety step. + +## Architecture + +### New module + +Add a new module such as `src/onboarding.rs` responsible for: + +- validating repository context +- collecting onboarding inputs +- scanning git history from the cutoff date forward +- resolving author identity matches +- converting git history into `db::Commit` records +- importing records through the existing database insertion path + +### Existing database reuse + +Reuse the current `commits` table and `db::Commit` model. + +Why: + +- the existing unique index on `(repo_path, hash)` already gives safe re-import behavior +- imported commits should behave exactly like hook-recorded commits in summaries and metadata +- avoiding a second history table keeps queries, rendering, and AI summaries unchanged + +### Separation of concerns + +Keep these layers separate: + +- **Prompt/UI flow:** collect cutoff date, show identities, confirm save behavior +- **Git scan logic:** enumerate commit metadata and detected author identities +- **Import logic:** filter matching commits and write them to the database +- **Config persistence:** load and optionally save onboarding aliases + +This separation makes the feature easier to test and allows non-interactive onboarding later without rewriting the core logic. + +## Data model and config + +Do not store identity aliases in the database. Store them in config instead because they represent user preference rather than commit facts. + +Proposed config shape: + +```toml +[onboarding] +save_selected_identities = true + +[[onboarding.identity_aliases]] +name = "Nikita Bayev" +email = "nikita@old-company.com" + +[[onboarding.identity_aliases]] +email = "drugoi@example.com" +``` + +Proposed config behavior: + +- load aliases during onboarding and include them in the candidate identity set +- when the user selects newly detected identities, offer to persist them +- treat config-save failures as warnings if the import itself already succeeded + +## Identity matching + +### Matching rules + +- Prefer email-based matching whenever an email is present. +- Allow name-based selection only as a fallback for commits missing author email. +- Preselect the current repo git identity from `git config user.email` and, if useful, `git config user.name`. +- Merge saved aliases from config into the selected set before prompting. +- Show detected identities from the scanned date range so the user can explicitly confirm extra matches. + +### Rationale + +- emails are more precise and stable than names +- users may have older company or personal emails that should still map to the same person +- requiring confirmation avoids accidentally importing another contributor's work in shared repositories + +## Data flow + +1. Verify the current directory is a git repository. +2. Ask for or read the cutoff date. +3. Scan git history on or after that date and collect: + - commit hash + - commit message/subject + - author name + - author email + - commit timestamp +4. Build the candidate identity set from: + - current repo git identity + - saved onboarding aliases + - identities detected in the scanned history +5. Ask the user to confirm which identities belong to them. +6. Filter scanned commits to only matching identities. +7. Convert filtered commits into existing `db::Commit` rows. +8. Insert them using `insert_commit()`. +9. Report: + - total commits scanned + - matching commits found + - newly inserted commits + - duplicates skipped +10. Offer to save newly selected identities into config. + +## Error handling + +| Situation | Behavior | +|-----------|----------| +| Not in a git repo | Fail fast with a clear onboarding-specific error message. | +| Invalid cutoff date | Re-prompt in interactive mode or return a CLI parse error in flag-driven mode. | +| No commits on or after cutoff | Print a friendly "nothing to import" message and exit successfully. | +| No matching identities detected | Show detected identities and require explicit user selection instead of guessing. | +| Some commits have no author email | Allow manual name-based selection, but message that it is less precise. | +| Config save fails after successful import | Print a warning and keep the imported commits. | +| Import interrupted midway | Safe to rerun because duplicate commits are deduped by `(repo_path, hash)`. | + +## Testing + +### CLI tests + +- parse `diddo onboard` +- parse future optional flags such as `--from` +- reject invalid dates when flags are used + +### Identity resolution tests + +- current git identity only +- current identity plus saved aliases +- overlapping or duplicate aliases +- email-first matching with name-based fallback +- explicit manual selection when no auto-match exists + +### Import logic tests + +Use `db::Database::open_in_memory()`: + +- imports only commits on or after the cutoff date +- imports only commits whose identity matches the selected set +- skips duplicates on rerun +- handles mixed-author history correctly + +### Integration-style onboarding tests + +- repo validation +- empty history after cutoff +- prompt and selection behavior +- alias persistence into config + +## Success criteria + +- Users can backfill history for an existing repo without reinstalling hooks. +- The cutoff date clearly means "import commits on or after this date". +- Only commits belonging to the current user are imported. +- Users can review and expand identity matching when they have used multiple names or emails. +- Re-running onboarding is safe and does not create duplicate commit rows. +- Imported commits appear in existing summaries and metadata without any special-case query logic. diff --git a/docs/plans/2026-03-23-manual-onboarding-implementation.md b/docs/plans/2026-03-23-manual-onboarding-implementation.md new file mode 100644 index 0000000..6d74b6b --- /dev/null +++ b/docs/plans/2026-03-23-manual-onboarding-implementation.md @@ -0,0 +1,487 @@ +# Manual Onboarding Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a `diddo onboard` command that imports commits from the current repository on or after a cutoff date, limited to user-selected author identities that belong to the current user. + +**Architecture:** Keep onboarding separate from hook setup and summary rendering. Add a dedicated `src/onboarding.rs` module for git scanning, identity resolution, and import orchestration; keep prompt flow, import logic, and config persistence separated so the core import logic remains easy to test. + +**Tech Stack:** Rust, clap, rusqlite, chrono, existing `diddo` config and path helpers, git CLI subprocesses, in-memory database tests. + +--- + +### Task 1: Add CLI surface for onboarding + +**Files:** +- Modify: `src/main.rs` +- Test: `src/main.rs` + +**Step 1: Write the failing CLI parsing tests** + +Add tests near the existing CLI parsing tests in `src/main.rs` for: + +```rust +#[test] +fn parses_onboard_subcommand() { + let cli = parse_cli(["diddo", "onboard"]).unwrap(); + + assert_eq!(cli.command, Some(Commands::Onboard)); +} + +#[test] +fn rejects_summary_flags_on_onboard_command() { + let error = parse_cli(["diddo", "onboard", "--md"]).unwrap_err(); + + assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test parses_onboard_subcommand rejects_summary_flags_on_onboard_command` + +Expected: FAIL because `Commands::Onboard` does not exist yet. + +**Step 3: Write minimal implementation** + +In `src/main.rs`: + +- add `mod onboarding;` +- add `Onboard` to `Commands` +- update main command dispatch so `Some(Commands::Onboard)` calls a new `run_onboard_command()` helper +- keep onboarding outside summary argument parsing + +Minimal shape: + +```rust +#[derive(Subcommand, Debug, Clone, Copy, PartialEq, Eq)] +enum Commands { + // ... + /// Import existing history for the current repository. + Onboard, +} + +fn run_onboard_command() -> Result<(), Box> { + let paths = paths::AppPaths::new()?; + let database = db::Database::open(&paths.db_path)?; + let config = config::AppConfig::load(&paths.config_path)?; + + onboarding::run(&database, &paths.config_path, config) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cargo test parses_onboard_subcommand rejects_summary_flags_on_onboard_command` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/main.rs +git commit -m "feat: add onboarding command entrypoint" +``` + +### Task 2: Add onboarding config model and persistence helpers + +**Files:** +- Modify: `src/config.rs` +- Test: `src/config.rs` + +**Step 1: Write the failing config tests** + +Add tests in `src/config.rs` for: + +```rust +#[test] +fn parses_onboarding_identity_aliases_from_toml() { + let config = AppConfig::load(&config_path).unwrap(); + + assert_eq!(config.onboarding.identity_aliases.len(), 2); + assert_eq!( + config.onboarding.identity_aliases[0].email.as_deref(), + Some("nikita@old-company.com") + ); +} + +#[test] +fn save_onboarding_aliases_writes_expected_toml() { + save_onboarding_aliases(&config_path, &aliases).unwrap(); + + let written = fs::read_to_string(&config_path).unwrap(); + assert!(written.contains("[onboarding]")); + assert!(written.contains("[[onboarding.identity_aliases]]")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test parses_onboarding_identity_aliases_from_toml save_onboarding_aliases_writes_expected_toml` + +Expected: FAIL because onboarding config structs and save helpers do not exist. + +**Step 3: Write minimal implementation** + +In `src/config.rs`: + +- extend `AppConfig` with `pub onboarding: OnboardingConfig` +- add: + +```rust +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)] +#[serde(default)] +pub struct OnboardingConfig { + pub save_selected_identities: bool, + pub identity_aliases: Vec, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)] +#[serde(default)] +pub struct IdentityAlias { + pub name: Option, + pub email: Option, +} +``` + +- implement `Default` for `OnboardingConfig` with `save_selected_identities: true` +- add a small write helper that persists onboarding aliases without disturbing unrelated config sections more than necessary +- keep normalization simple: trim empty names/emails to `None` + +**Step 4: Run test to verify it passes** + +Run: `cargo test parses_onboarding_identity_aliases_from_toml save_onboarding_aliases_writes_expected_toml` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/config.rs +git commit -m "feat: add onboarding identity alias config" +``` + +### Task 3: Build onboarding core types and pure import logic + +**Files:** +- Create: `src/onboarding.rs` +- Modify: `src/main.rs` +- Test: `src/onboarding.rs` + +**Step 1: Write the failing pure-logic tests** + +Add tests in `src/onboarding.rs` for: + +```rust +#[test] +fn filters_commits_on_or_after_cutoff_date() { + let imported = filter_importable_commits(&commits, cutoff, &selected_identities); + + assert_eq!(imported.iter().map(|c| c.hash.as_str()).collect::>(), vec!["bbb2222"]); +} + +#[test] +fn imports_only_selected_identities() { + let imported = filter_importable_commits(&commits, cutoff, &selected_identities); + + assert_eq!(imported.len(), 1); + assert_eq!(imported[0].author_email.as_deref(), Some("me@example.com")); +} + +#[test] +fn import_is_idempotent_when_re_run() { + import_commits(&database, &repo_path, &repo_name, &filtered).unwrap(); + import_commits(&database, &repo_path, &repo_name, &filtered).unwrap(); + + assert_eq!(database.commit_count().unwrap(), 1); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test filters_commits_on_or_after_cutoff_date imports_only_selected_identities import_is_idempotent_when_re_run` + +Expected: FAIL because onboarding module and helpers do not exist. + +**Step 3: Write minimal implementation** + +Create `src/onboarding.rs` with: + +- `ScannedCommit` struct containing hash, message, author name, author email, branch placeholder if needed, and committed timestamp +- `IdentityCandidate` struct for name/email combinations +- pure helpers: + - `filter_importable_commits(...)` + - `identity_matches(...)` + - `to_db_commit(...)` + - `import_commits(...)` +- summary result struct: + +```rust +pub struct ImportOutcome { + pub scanned: usize, + pub matched: usize, + pub inserted: usize, + pub skipped_duplicates: usize, +} +``` + +Implementation rules: + +- cutoff is inclusive: import commits where `committed_at.date_naive() >= cutoff` +- prefer email matches; allow name fallback only when email is missing +- write through existing `db::Database::insert_commit()` +- compute skipped duplicates by comparing count before and after import, or by tracking attempted rows versus resulting unique rows + +**Step 4: Run test to verify it passes** + +Run: `cargo test filters_commits_on_or_after_cutoff_date imports_only_selected_identities import_is_idempotent_when_re_run` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/onboarding.rs src/main.rs +git commit -m "feat: add onboarding import core" +``` + +### Task 4: Add git scan and identity detection adapters + +**Files:** +- Modify: `src/onboarding.rs` +- Test: `src/onboarding.rs` + +**Step 1: Write the failing adapter tests** + +Add tests that cover: + +```rust +#[test] +fn parse_git_log_output_builds_scanned_commits() { + let commits = parse_git_log_output(sample_log()).unwrap(); + + assert_eq!(commits.len(), 2); + assert_eq!(commits[0].hash, "abc1234"); +} + +#[test] +fn detected_identities_include_unique_name_email_pairs() { + let identities = detect_identities(&commits); + + assert_eq!(identities.len(), 2); + assert!(identities.iter().any(|i| i.email.as_deref() == Some("me@example.com"))); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test parse_git_log_output_builds_scanned_commits detected_identities_include_unique_name_email_pairs` + +Expected: FAIL because git log parsing and identity detection helpers are not implemented. + +**Step 3: Write minimal implementation** + +In `src/onboarding.rs`: + +- add a git adapter function that shells out to `git log` +- use a delimiter-safe format such as `%H%x1f%s%x1f%an%x1f%ae%x1f%cI` +- parse each record into `ScannedCommit` +- add `detect_identities(&[ScannedCommit]) -> Vec` +- sort identities predictably, preferring email-backed identities first, then by email/name text +- inject the git command runner in tests rather than invoking the real repo + +**Step 4: Run test to verify it passes** + +Run: `cargo test parse_git_log_output_builds_scanned_commits detected_identities_include_unique_name_email_pairs` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/onboarding.rs +git commit -m "feat: add onboarding git history scan" +``` + +### Task 5: Wire current identity resolution and alias preselection + +**Files:** +- Modify: `src/onboarding.rs` +- Modify: `src/config.rs` +- Test: `src/onboarding.rs` + +**Step 1: Write the failing identity resolution tests** + +Add tests for: + +```rust +#[test] +fn preselects_current_git_email_and_saved_aliases() { + let selected = build_preselected_identities(current_identity, &saved_aliases, &detected); + + assert!(selected.iter().any(|i| i.email.as_deref() == Some("me@example.com"))); + assert!(selected.iter().any(|i| i.email.as_deref() == Some("me@old-company.com"))); +} + +#[test] +fn name_only_matches_are_not_auto_selected_when_email_candidates_exist() { + let selected = build_preselected_identities(current_identity, &saved_aliases, &detected); + + assert!(!selected.iter().any(|i| i.name.as_deref() == Some("Nikita"))); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test preselects_current_git_email_and_saved_aliases name_only_matches_are_not_auto_selected_when_email_candidates_exist` + +Expected: FAIL because preselection logic does not exist yet. + +**Step 3: Write minimal implementation** + +In `src/onboarding.rs`: + +- add a helper to read current repo identity from `git config user.email` and `git config user.name` +- add `build_preselected_identities(...)` +- combine: + - current repo identity + - saved aliases from config + - detected identities from scanned commits +- auto-select exact email matches first +- keep name-only candidates visible but unselected unless the user confirms them + +**Step 4: Run test to verify it passes** + +Run: `cargo test preselects_current_git_email_and_saved_aliases name_only_matches_are_not_auto_selected_when_email_candidates_exist` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/onboarding.rs src/config.rs +git commit -m "feat: preselect onboarding identities" +``` + +### Task 6: Add interactive onboarding orchestration + +**Files:** +- Modify: `src/onboarding.rs` +- Modify: `src/main.rs` +- Test: `src/onboarding.rs` + +**Step 1: Write the failing orchestration tests** + +Add tests for: + +```rust +#[test] +fn returns_nothing_to_import_when_cutoff_has_no_commits() { + let outcome = run_with(deps).unwrap(); + + assert_eq!(outcome.inserted, 0); + assert_eq!(outcome.scanned, 0); +} + +#[test] +fn onboarding_run_imports_only_user_confirmed_identities() { + let outcome = run_with(deps).unwrap(); + + assert_eq!(outcome.scanned, 3); + assert_eq!(outcome.matched, 2); + assert_eq!(outcome.inserted, 2); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test returns_nothing_to_import_when_cutoff_has_no_commits onboarding_run_imports_only_user_confirmed_identities` + +Expected: FAIL because the end-to-end onboarding runner is not wired. + +**Step 3: Write minimal implementation** + +In `src/onboarding.rs`: + +- expose `pub fn run(...) -> Result<(), Box>` +- split orchestration into injected helpers for: + - reading cutoff date + - reading git identity + - scanning history + - selecting identities + - saving aliases +- print friendly terminal messages for: + - repo validation failure + - empty history after cutoff + - import summary counts +- keep UI simple in v1: + - prompt for cutoff date in `YYYY-MM-DD` or `DD.MM.YYYY` + - print numbered identity candidates + - accept comma-separated selections + - ask whether to save newly selected aliases + +**Step 4: Run test to verify it passes** + +Run: `cargo test returns_nothing_to_import_when_cutoff_has_no_commits onboarding_run_imports_only_user_confirmed_identities` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/onboarding.rs src/main.rs +git commit -m "feat: add interactive onboarding flow" +``` + +### Task 7: Document the feature and verify the full suite + +**Files:** +- Modify: `README.md` +- Modify: `docs/plans/2026-03-23-manual-onboarding-design.md` +- Test: `src/main.rs`, `src/config.rs`, `src/onboarding.rs` + +**Step 1: Write the failing doc-adjacent tests or assertions if needed** + +If README examples require behavior validation, add or update a lightweight CLI test in `src/main.rs` that asserts the command help includes onboarding. + +Example: + +```rust +#[test] +fn help_output_mentions_onboard_command() { + let help = HelpCli::command().render_long_help().to_string(); + + assert!(help.contains("onboard")); +} +``` + +**Step 2: Run targeted verification** + +Run: `cargo test help_output_mentions_onboard_command` + +Expected: PASS after command help is updated. + +**Step 3: Update docs** + +- add `diddo onboard` to `README.md` +- explain that `diddo` normally records only future commits, while onboarding imports older history for the current repo +- document cutoff semantics clearly: "on or after this date" +- document identity confirmation and optional alias reuse + +**Step 4: Run full verification** + +Run: `cargo fmt -- --check` +Expected: PASS + +Run: `cargo test` +Expected: PASS with all tests green + +Run: `cargo run -- --help` +Expected: help output includes `onboard` + +**Step 5: Commit** + +```bash +git add README.md src/main.rs src/config.rs src/onboarding.rs docs/plans/2026-03-23-manual-onboarding-design.md +git commit -m "feat: add manual repository onboarding" +``` diff --git a/src/config.rs b/src/config.rs index 3566b65..dab7c61 100644 --- a/src/config.rs +++ b/src/config.rs @@ -82,7 +82,6 @@ impl AppConfig { } /// Persist onboarding identity aliases, preserving other config sections when possible. -#[allow(dead_code)] // Used by tests; `onboarding` will call this when saving aliases after import. pub fn save_onboarding_aliases(path: &Path, aliases: &[IdentityAlias]) -> io::Result<()> { let mut config = if path.exists() { AppConfig::load(path)? @@ -90,7 +89,11 @@ pub fn save_onboarding_aliases(path: &Path, aliases: &[IdentityAlias]) -> io::Re AppConfig::default() }; - config.onboarding.identity_aliases = aliases.iter().cloned().map(normalize_identity_alias).collect(); + config.onboarding.identity_aliases = aliases + .iter() + .cloned() + .map(normalize_identity_alias) + .collect(); let contents = toml::to_string_pretty(&config).map_err(|error| { io::Error::new( diff --git a/src/main.rs b/src/main.rs index 5b0f84c..8c691db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -972,13 +972,15 @@ mod tests { use std::path::PathBuf; use chrono::{NaiveDate, TimeZone, Utc}; + use clap::CommandFactory; use super::{ - AiSummaryAttempt, Commands, OutputFormat, ParsedCli, SummaryArgs, SummarySelection, - build_summary_data, compute_cache_key, format_commit_time, format_config_paths, - format_file_size, format_metadata, output_format, parse_cli, parse_interactive_selection, - parse_supported_date, range_date_format_error, render_empty_summary, render_summary_output, - resolve_summary_window, should_try_ai_summary, summary_request_from_cli, try_ai_summary, + AiSummaryAttempt, Commands, HelpCli, OutputFormat, ParsedCli, SummaryArgs, + SummarySelection, build_summary_data, compute_cache_key, format_commit_time, + format_config_paths, format_file_size, format_metadata, output_format, parse_cli, + parse_interactive_selection, parse_supported_date, range_date_format_error, + render_empty_summary, render_summary_output, resolve_summary_window, should_try_ai_summary, + summary_request_from_cli, try_ai_summary, }; use crate::{ ai::{AiError, AiProvider}, @@ -1327,6 +1329,16 @@ mod tests { assert_eq!(cli.command, Some(Commands::Onboard)); } + #[test] + fn help_output_mentions_onboard_command() { + let help = HelpCli::command().render_long_help().to_string(); + + assert!( + help.contains("onboard"), + "expected help to list onboard subcommand:\n{help}" + ); + } + #[test] fn rejects_summary_flags_on_onboard_command() { let error = parse_cli(["diddo", "onboard", "--md"]).unwrap_err(); diff --git a/src/onboarding.rs b/src/onboarding.rs index bb36be1..3c272a4 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -1,15 +1,13 @@ -#![cfg_attr( - not(test), - allow(dead_code) -)] // Core helpers are covered by unit tests; `run()` wires them in later tasks. - use std::error::Error; +use std::io::{self, BufRead, Write}; use std::path::Path; +use std::process::Command; use chrono::{DateTime, NaiveDate, Utc}; use crate::config::{self, IdentityAlias}; use crate::db::{Commit, Database}; +use crate::parse_supported_date; /// Commit metadata scanned from `git log` before conversion to [`Commit`]. #[derive(Debug, Clone, PartialEq, Eq)] @@ -44,17 +42,187 @@ pub struct ImportOutcome { pub skipped_duplicates: usize, } -/// Full onboarding flow (interactive UI, git scan) is implemented incrementally. +/// Run onboarding interactively (stdin/stdout, real `git` in the current directory). pub fn run( - _database: &Database, - _config_path: &Path, - _config: config::AppConfig, + database: &Database, + config_path: &Path, + config: config::AppConfig, ) -> Result<(), Box> { + let stdin = io::stdin(); + let mut stdin_lock = stdin.lock(); + let mut stdout = io::stdout(); + + let outcome = run_with( + database, + config_path, + config, + &mut || { + let mut line = String::new(); + stdin_lock.read_line(&mut line)?; + Ok(line) + }, + &mut |s: &str| { + stdout.write_all(s.as_bytes())?; + stdout.flush()?; + Ok(()) + }, + run_git_command, + )?; + + writeln!( + io::stdout(), + "Onboarding finished: {} commit(s) in date range, {} matched selected identities, {} inserted, {} skipped as duplicates.", + outcome.scanned, + outcome.matched, + outcome.inserted, + outcome.skipped_duplicates + )?; + Ok(()) } +/// Injected I/O and git runner for tests and embedding. +pub fn run_with( + database: &Database, + config_path: &Path, + config: config::AppConfig, + read_line: &mut R, + write_line: &mut W, + mut run_git: G, +) -> Result> +where + R: FnMut() -> io::Result, + W: FnMut(&str) -> io::Result<()>, + G: FnMut(&[&str]) -> io::Result, +{ + let repo_path = trim_git_output(run_git(&["rev-parse", "--show-toplevel"])?); + if repo_path.is_empty() { + return Err("not a git repository (git rev-parse --show-toplevel failed)".into()); + } + + let branch = normalize_branch_name(&trim_git_output(run_git(&[ + "rev-parse", + "--abbrev-ref", + "HEAD", + ])?)); + let repo_name = repo_name_from_path(&repo_path); + + let format_arg = format!("--format={}", GIT_LOG_FORMAT); + let log_out = run_git(&["log", &format_arg])?; + let all_commits = parse_git_log_output(&log_out).map_err(|e| -> Box { e.into() })?; + + if all_commits.is_empty() { + write_line("No commits found in this repository — nothing to import.\n")?; + return Ok(ImportOutcome::default()); + } + + write_line(&format!( + "Import commits on or after which date? ({}) ", + RANGE_DATE_HINT + ))?; + let cutoff_line = read_line()?; + let cutoff = parse_supported_date(cutoff_line.trim()) + .map_err(|e| -> Box { format!("invalid cutoff date: {e}").into() })?; + + let scanned = all_commits + .iter() + .filter(|c| c.committed_at.date_naive() >= cutoff) + .count(); + + if scanned == 0 { + write_line(&format!( + "No commits on or after {} — nothing to import.\n", + cutoff + ))?; + return Ok(ImportOutcome { + scanned: 0, + matched: 0, + inserted: 0, + skipped_duplicates: 0, + }); + } + + let detected = detect_identities(&all_commits); + let current = read_git_identity(&mut run_git)?; + let preselected = + build_preselected_identities(¤t, &config.onboarding.identity_aliases, &detected); + + if detected.is_empty() { + write_line("No author identities found in history — nothing to import.\n")?; + return Ok(ImportOutcome { + scanned, + matched: 0, + inserted: 0, + skipped_duplicates: 0, + }); + } + + write_line("\nAuthor identities (from git history):\n")?; + for (i, id) in detected.iter().enumerate() { + let label = format_identity_line(id); + let mark = if preselected.iter().any(|p| identity_same(p, id)) { + " (preselected)" + } else { + "" + }; + write_line(&format!("{}. {}{}\n", i + 1, label, mark))?; + } + write_line( + "\nEnter numbers to import, separated by commas (e.g. 1,3). Press Enter to use preselected only.\n> ", + )?; + + let selection_line = read_line()?; + let selected = parse_identity_selection(selection_line.trim(), &detected, &preselected)?; + + let filtered = filter_importable_commits(&all_commits, cutoff, &selected); + let matched = filtered.len(); + + let import_outcome = import_commits( + database, &repo_path, &repo_name, &branch, &filtered, scanned, matched, + )?; + + if config.onboarding.save_selected_identities { + let had_before = config.onboarding.identity_aliases.clone(); + + let selected_aliases: Vec = selected + .iter() + .map(|id| IdentityAlias { + name: id.name.clone(), + email: id.email.clone(), + }) + .collect(); + + let has_new_alias = selected_aliases.iter().any(|a| { + !had_before + .iter() + .any(|h| h.name == a.name && h.email == a.email) + }); + + if has_new_alias { + write_line("\nSave selected identities to config for next time? [y/N] ")?; + let yn = read_line()?; + if matches!(yn.trim().to_ascii_lowercase().as_str(), "y" | "yes") { + let mut merged = had_before; + for a in selected_aliases { + if !merged + .iter() + .any(|h| h.name == a.name && h.email == a.email) + { + merged.push(a); + } + } + config::save_onboarding_aliases(config_path, &merged)?; + write_line("Saved identity aliases to config.\n")?; + } + } + } + + Ok(import_outcome) +} + +const RANGE_DATE_HINT: &str = "YYYY-MM-DD or DD.MM.YYYY"; + /// `git log` line format: hash, subject, author name, author email, commit date (RFC3339), separated by ASCII unit separator. -#[allow(dead_code)] // Used when wiring `git log`; must stay aligned with [`parse_git_log_output`]. pub const GIT_LOG_FORMAT: &str = "%H%x1f%s%x1f%an%x1f%ae%x1f%cI"; /// Parse output from `git log` using [`GIT_LOG_FORMAT`] (one record per line). @@ -133,9 +301,7 @@ pub fn detect_identities(commits: &[ScannedCommit]) -> Vec { } out.sort_by(|a, b| match (&a.email, &b.email) { - (Some(ea), Some(eb)) => ea - .cmp(eb) - .then_with(|| cmp_opt_str(&a.name, &b.name)), + (Some(ea), Some(eb)) => ea.cmp(eb).then_with(|| cmp_opt_str(&a.name, &b.name)), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => cmp_opt_str(&a.name, &b.name), @@ -220,15 +386,16 @@ pub fn build_preselected_identities( for d in detected { if let Some(ref de) = d.email { - if anchor_emails - .iter() - .any(|a| a.eq_ignore_ascii_case(de)) - { + if anchor_emails.iter().any(|a| a.eq_ignore_ascii_case(de)) { push_unique(&mut out, &mut seen, d.clone()); } } else if !detected_has_email && let (Some(cn), Some(dn)) = ( - current.name.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()), + current + .name + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()), d.name.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()), ) && cn == dn @@ -248,9 +415,7 @@ pub fn filter_importable_commits( ) -> Vec { commits .iter() - .filter(|c| { - c.committed_at.date_naive() >= cutoff && identity_matches(c, selected) - }) + .filter(|c| c.committed_at.date_naive() >= cutoff && identity_matches(c, selected)) .cloned() .collect() } @@ -298,9 +463,9 @@ pub fn import_commits( repo_name: &str, branch: &str, commits: &[ScannedCommit], + scanned_total: usize, + matched_total: usize, ) -> Result { - let scanned = commits.len(); - let matched = commits.len(); let count_before = database.commit_count()?; for c in commits { @@ -310,16 +475,122 @@ pub fn import_commits( let count_after = database.commit_count()?; let inserted = (count_after - count_before) as usize; - let skipped_duplicates = matched.saturating_sub(inserted); + let skipped_duplicates = commits.len().saturating_sub(inserted); Ok(ImportOutcome { - scanned, - matched, + scanned: scanned_total, + matched: matched_total, inserted, skipped_duplicates, }) } +fn trim_git_output(output: String) -> String { + output.trim_end_matches(['\r', '\n']).trim().to_string() +} + +fn repo_name_from_path(repo_path: &str) -> String { + Path::new(repo_path) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("unknown") + .to_string() +} + +fn normalize_branch_name(branch: &str) -> String { + if branch == "HEAD" { + return "detached".to_string(); + } + branch.to_string() +} + +fn read_git_identity(run_git: &mut F) -> io::Result +where + F: FnMut(&[&str]) -> io::Result, +{ + let email = run_git(&["config", "user.email"]) + .ok() + .map(trim_git_output) + .filter(|s| !s.is_empty()); + let name = run_git(&["config", "user.name"]) + .ok() + .map(trim_git_output) + .filter(|s| !s.is_empty()); + Ok(GitIdentity { name, email }) +} + +fn format_identity_line(id: &IdentityCandidate) -> String { + match (&id.name, &id.email) { + (Some(n), Some(e)) => format!("{n} <{e}>"), + (None, Some(e)) => e.clone(), + (Some(n), None) => format!("{n} (no email)"), + (None, None) => "(unknown)".to_string(), + } +} + +fn identity_same(a: &IdentityCandidate, b: &IdentityCandidate) -> bool { + a.name == b.name && a.email == b.email +} + +fn parse_identity_selection( + line: &str, + candidates: &[IdentityCandidate], + preselected: &[IdentityCandidate], +) -> Result, String> { + if line.is_empty() { + return Ok(preselected.to_vec()); + } + + use std::collections::HashSet; + + let mut seen_idx: HashSet = HashSet::new(); + let mut out = Vec::new(); + + for part in line.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let n: usize = part + .parse() + .map_err(|_| format!("invalid selection index: {part}"))?; + let idx = n + .checked_sub(1) + .ok_or_else(|| "selection indices must be >= 1".to_string())?; + let id = candidates + .get(idx) + .ok_or_else(|| format!("no identity at index {n}"))?; + if seen_idx.insert(idx) { + out.push(id.clone()); + } + } + + if out.is_empty() { + return Err("no identities selected".into()); + } + + Ok(out) +} + +fn run_git_command(args: &[&str]) -> io::Result { + let output = Command::new("git").args(args).output()?; + + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).into_owned()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let command = format!("git {}", args.join(" ")); + let message = if stderr.is_empty() { + format!("{command} failed") + } else { + format!("{command} failed: {stderr}") + }; + + Err(io::Error::other(message)) +} + #[cfg(test)] mod tests { use chrono::TimeZone; @@ -420,8 +691,26 @@ mod tests { Utc.with_ymd_and_hms(2026, 2, 1, 12, 0, 0).unwrap(), )]; - import_commits(&database, repo_path, repo_name, branch, &filtered).unwrap(); - import_commits(&database, repo_path, repo_name, branch, &filtered).unwrap(); + import_commits( + &database, + repo_path, + repo_name, + branch, + &filtered, + filtered.len(), + filtered.len(), + ) + .unwrap(); + import_commits( + &database, + repo_path, + repo_name, + branch, + &filtered, + filtered.len(), + filtered.len(), + ) + .unwrap(); assert_eq!(database.commit_count().unwrap(), 1); } @@ -444,9 +733,11 @@ mod tests { let identities = detect_identities(&commits); assert_eq!(identities.len(), 2); - assert!(identities - .iter() - .any(|i| i.email.as_deref() == Some("me@example.com"))); + assert!( + identities + .iter() + .any(|i| i.email.as_deref() == Some("me@example.com")) + ); } #[test] @@ -466,12 +757,16 @@ mod tests { let selected = build_preselected_identities(¤t_identity, &saved_aliases, &detected); - assert!(selected - .iter() - .any(|i| i.email.as_deref() == Some("me@example.com"))); - assert!(selected - .iter() - .any(|i| i.email.as_deref() == Some("me@old-company.com"))); + assert!( + selected + .iter() + .any(|i| i.email.as_deref() == Some("me@example.com")) + ); + assert!( + selected + .iter() + .any(|i| i.email.as_deref() == Some("me@old-company.com")) + ); } #[test] @@ -493,8 +788,95 @@ mod tests { let selected = build_preselected_identities(¤t_identity, &[], &detected); - assert!(!selected - .iter() - .any(|i| i.name.as_deref() == Some("Nikita") && i.email.is_none())); + assert!( + !selected + .iter() + .any(|i| i.name.as_deref() == Some("Nikita") && i.email.is_none()) + ); + } + + #[test] + fn returns_nothing_to_import_when_cutoff_has_no_commits() { + use std::collections::VecDeque; + use std::io; + + let database = Database::open_in_memory().unwrap(); + let config_path = std::env::temp_dir().join("diddo-onboard-empty.toml"); + let config = config::AppConfig::default(); + + let mut lines: VecDeque = VecDeque::new(); + let mut read_line = move || Ok(lines.pop_front().map(|s| s + "\n").unwrap_or_default()); + let mut write_line = |_s: &str| -> io::Result<()> { Ok(()) }; + + let mut run_git = |args: &[&str]| -> io::Result { + match args { + ["rev-parse", "--show-toplevel"] => Ok("/tmp/repo\n".to_string()), + ["rev-parse", "--abbrev-ref", "HEAD"] => Ok("main\n".to_string()), + _ if args.first() == Some(&"log") => Ok(String::new()), + _ => Err(io::Error::other(format!("unexpected git args: {args:?}"))), + } + }; + + let outcome = run_with( + &database, + &config_path, + config, + &mut read_line, + &mut write_line, + &mut run_git, + ) + .unwrap(); + + assert_eq!(outcome.scanned, 0); + assert_eq!(outcome.inserted, 0); + } + + #[test] + fn onboarding_run_imports_only_user_confirmed_identities() { + use std::collections::VecDeque; + use std::io; + + let database = Database::open_in_memory().unwrap(); + let config_path = std::env::temp_dir().join("diddo-onboard-import.toml"); + let mut config = config::AppConfig::default(); + config.onboarding.save_selected_identities = false; + + let three = concat!( + "h1\x1fm1\x1fMe\x1fme@example.com\x1f2026-01-01T12:00:00+00:00\n", + "h2\x1fm2\x1fMe\x1fme@example.com\x1f2026-01-02T12:00:00+00:00\n", + "h3\x1fm3\x1fOther\x1fother@example.com\x1f2026-01-03T12:00:00+00:00\n", + ); + + let mut lines: VecDeque = + VecDeque::from(["2026-01-01".to_string(), "1".to_string()]); + let mut read_line = move || Ok(lines.pop_front().map(|s| s + "\n").unwrap_or_default()); + let mut write_line = |_s: &str| -> io::Result<()> { Ok(()) }; + + let log = three.to_string(); + let mut run_git = move |args: &[&str]| -> io::Result { + match args { + ["rev-parse", "--show-toplevel"] => Ok("/tmp/repo\n".to_string()), + ["rev-parse", "--abbrev-ref", "HEAD"] => Ok("main\n".to_string()), + ["config", "user.email"] => Ok("me@example.com\n".to_string()), + ["config", "user.name"] => Ok("Me\n".to_string()), + _ if args.first() == Some(&"log") => Ok(log.clone()), + _ => Err(io::Error::other(format!("unexpected git args: {args:?}"))), + } + }; + + let outcome = run_with( + &database, + &config_path, + config, + &mut read_line, + &mut write_line, + &mut run_git, + ) + .unwrap(); + + assert_eq!(outcome.scanned, 3); + assert_eq!(outcome.matched, 2); + assert_eq!(outcome.inserted, 2); + assert_eq!(outcome.skipped_duplicates, 0); } } From d2ed05a924381ff53efd2fc5bc2ca009f60e2ed9 Mon Sep 17 00:00:00 2001 From: Nikita Bayev Date: Mon, 23 Mar 2026 15:17:48 +0500 Subject: [PATCH 07/11] chore: trim rustdoc noise and simplify onboarding error mapping Made-with: Cursor --- src/config.rs | 2 -- src/main.rs | 7 ++----- src/onboarding.rs | 17 ++--------------- 3 files changed, 4 insertions(+), 22 deletions(-) diff --git a/src/config.rs b/src/config.rs index dab7c61..9a2123b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,7 +10,6 @@ pub struct AppConfig { pub onboarding: OnboardingConfig, } -/// User preferences for manual repository onboarding (identity aliases, etc.). #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(default)] pub struct OnboardingConfig { @@ -81,7 +80,6 @@ impl AppConfig { } } -/// Persist onboarding identity aliases, preserving other config sections when possible. pub fn save_onboarding_aliases(path: &Path, aliases: &[IdentityAlias]) -> io::Result<()> { let mut config = if path.exists() { AppConfig::load(path)? diff --git a/src/main.rs b/src/main.rs index 8c691db..98d7e27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -125,7 +125,7 @@ enum Commands { Config, /// Show database metadata. Metadata, - /// Import existing commit history for the current repository (onboarding). + /// Import existing commit history for the current repository. Onboard, /// Update diddo to the latest release. Update(UpdateArgs), @@ -1333,10 +1333,7 @@ mod tests { fn help_output_mentions_onboard_command() { let help = HelpCli::command().render_long_help().to_string(); - assert!( - help.contains("onboard"), - "expected help to list onboard subcommand:\n{help}" - ); + assert!(help.contains("onboard")); } #[test] diff --git a/src/onboarding.rs b/src/onboarding.rs index 3c272a4..48554a3 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -9,7 +9,6 @@ use crate::config::{self, IdentityAlias}; use crate::db::{Commit, Database}; use crate::parse_supported_date; -/// Commit metadata scanned from `git log` before conversion to [`Commit`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ScannedCommit { pub hash: String, @@ -19,21 +18,18 @@ pub struct ScannedCommit { pub committed_at: DateTime, } -/// Author identity the user selected for import (email preferred; name when email is absent on the commit). #[derive(Debug, Clone, PartialEq, Eq)] pub struct IdentityCandidate { pub name: Option, pub email: Option, } -/// Current repository identity from `git config user.name` / `user.email`. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct GitIdentity { pub name: Option, pub email: Option, } -/// Counts for one import run. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ImportOutcome { pub scanned: usize, @@ -42,7 +38,6 @@ pub struct ImportOutcome { pub skipped_duplicates: usize, } -/// Run onboarding interactively (stdin/stdout, real `git` in the current directory). pub fn run( database: &Database, config_path: &Path, @@ -81,7 +76,6 @@ pub fn run( Ok(()) } -/// Injected I/O and git runner for tests and embedding. pub fn run_with( database: &Database, config_path: &Path, @@ -109,7 +103,7 @@ where let format_arg = format!("--format={}", GIT_LOG_FORMAT); let log_out = run_git(&["log", &format_arg])?; - let all_commits = parse_git_log_output(&log_out).map_err(|e| -> Box { e.into() })?; + let all_commits = parse_git_log_output(&log_out).map_err(Box::::from)?; if all_commits.is_empty() { write_line("No commits found in this repository — nothing to import.\n")?; @@ -122,7 +116,7 @@ where ))?; let cutoff_line = read_line()?; let cutoff = parse_supported_date(cutoff_line.trim()) - .map_err(|e| -> Box { format!("invalid cutoff date: {e}").into() })?; + .map_err(|e| Box::::from(format!("invalid cutoff date: {e}")))?; let scanned = all_commits .iter() @@ -222,10 +216,8 @@ where const RANGE_DATE_HINT: &str = "YYYY-MM-DD or DD.MM.YYYY"; -/// `git log` line format: hash, subject, author name, author email, commit date (RFC3339), separated by ASCII unit separator. pub const GIT_LOG_FORMAT: &str = "%H%x1f%s%x1f%an%x1f%ae%x1f%cI"; -/// Parse output from `git log` using [`GIT_LOG_FORMAT`] (one record per line). pub fn parse_git_log_output(output: &str) -> Result, String> { let mut commits = Vec::new(); @@ -271,7 +263,6 @@ pub fn parse_git_log_output(output: &str) -> Result, String> Ok(commits) } -/// Unique author identities from scanned commits, sorted with email-backed rows first, then by email and name. pub fn detect_identities(commits: &[ScannedCommit]) -> Vec { use std::collections::HashSet; @@ -319,8 +310,6 @@ fn cmp_opt_str(a: &Option, b: &Option) -> std::cmp::Ordering { } } -/// Preselect identities for import: current git user, saved config aliases, and detected authors that match anchor emails. -/// When the scan contains any email-backed author, name-only detected identities are not auto-selected (user must confirm). pub fn build_preselected_identities( current: &GitIdentity, saved_aliases: &[IdentityAlias], @@ -407,7 +396,6 @@ pub fn build_preselected_identities( out } -/// Keep commits on or after `cutoff` (local date vs UTC timestamps) whose author matches `selected`. pub fn filter_importable_commits( commits: &[ScannedCommit], cutoff: NaiveDate, @@ -456,7 +444,6 @@ pub fn to_db_commit( } } -/// Insert scanned commits into the database. Duplicate `(repo_path, hash)` rows are updated in place, not double-counted. pub fn import_commits( database: &Database, repo_path: &str, From eb5c2eb221c3eba1c4d4a39923de98ff826d7298 Mon Sep 17 00:00:00 2001 From: Nikita Bayev Date: Mon, 23 Mar 2026 16:02:38 +0500 Subject: [PATCH 08/11] fix: onboarding review follow-up (dedupe, config, cutoff) - Store full git SHA in hook; scan with git log -z and %B for hook-parity messages - Add committed_local_date from %cI for cutoff; name-only identity match is ASCII case-insensitive - save_onboarding_aliases uses AppConfig::load_from_file (no env merge into TOML) - Tests for dedupe, cutoff boundaries, identity selection, env key not persisted - README cutoff wording; drop redundant field rustdoc Made-with: Cursor --- README.md | 2 +- src/config.rs | 55 ++++++++--- src/hook.rs | 16 ++-- src/onboarding.rs | 236 ++++++++++++++++++++++++++++++++++++++++------ 4 files changed, 262 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 0ad8a00..41dd58a 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ diddo onboard From the repository root, you’ll be prompted for: -- A **cutoff date** (`YYYY-MM-DD` or `DD.MM.YYYY`). Commits **on or after** that calendar day (in each commit’s local date) are considered. +- A **cutoff date** (`YYYY-MM-DD` or `DD.MM.YYYY`). Included commits are those **on or after** that day in each commit’s `%cI` local calendar (not “UTC day” alone). - Which **author identities** to include (numbered list from `git log`; comma-separated indexes, or Enter to accept preselected identities based on your current `git config` and any saved aliases). Optional identity aliases are stored under `[onboarding]` in your config when you confirm saving them. diff --git a/src/config.rs b/src/config.rs index 9a2123b..80b8e68 100644 --- a/src/config.rs +++ b/src/config.rs @@ -63,26 +63,30 @@ pub struct AiCliConfig { impl AppConfig { pub fn load(path: &Path) -> io::Result { - let mut config = if path.exists() { - let contents = fs::read_to_string(path)?; - toml::from_str::(&contents).map_err(|error| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("failed to parse config file {}: {error}", path.display()), - ) - })? - } else { - Self::default() - }; + let mut config = Self::load_from_file(path)?; config.ai.apply_environment_defaults(); Ok(config) } + + pub fn load_from_file(path: &Path) -> io::Result { + if !path.exists() { + return Ok(Self::default()); + } + + let contents = fs::read_to_string(path)?; + toml::from_str::(&contents).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("failed to parse config file {}: {error}", path.display()), + ) + }) + } } pub fn save_onboarding_aliases(path: &Path, aliases: &[IdentityAlias]) -> io::Result<()> { let mut config = if path.exists() { - AppConfig::load(path)? + AppConfig::load_from_file(path)? } else { AppConfig::default() }; @@ -617,6 +621,33 @@ email = "drugoi@example.com" fs::remove_dir_all(temp).unwrap(); } + #[test] + fn save_onboarding_aliases_does_not_write_env_only_api_key() { + let _guard = env_lock().lock().unwrap(); + let temp = temp_dir("onboarding-save-env-key"); + let config_path = temp.join("config.toml"); + + fs::write(&config_path, "[ai]\nprovider = \"openai\"\n").unwrap(); + unsafe { + std::env::set_var("DIDDO_OPENAI_KEY", "secret-from-env-only"); + } + + let aliases = vec![IdentityAlias { + name: None, + email: Some("a@b.com".to_string()), + }]; + save_onboarding_aliases(&config_path, &aliases).unwrap(); + + let written = fs::read_to_string(&config_path).unwrap(); + assert!(!written.contains("secret-from-env-only")); + assert!(!written.contains("api_key")); + + unsafe { + std::env::remove_var("DIDDO_OPENAI_KEY"); + } + fs::remove_dir_all(temp).unwrap(); + } + fn env_lock() -> &'static Mutex<()> { static ENV_LOCK: OnceLock> = OnceLock::new(); diff --git a/src/hook.rs b/src/hook.rs index 4e5b0b9..659d778 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -23,7 +23,7 @@ where F: FnMut(&[&str]) -> io::Result, G: FnMut() -> io::Result, { - let hash = trim_git_output(run_git(&["rev-parse", "--short", "HEAD"])?); + let hash = trim_git_output(run_git(&["rev-parse", "HEAD"])?); let message = trim_git_output(run_git(&["log", "-1", "--format=%B"])?); let committed_at = parse_git_timestamp(&trim_git_output(run_git(&["log", "-1", "--format=%cI"])?))?; @@ -146,6 +146,8 @@ mod tests { use super::{build_commit, normalize_branch_name, parse_git_timestamp, run_with}; use crate::db::Database; + const TEST_HEAD_HASH: &str = "abcd1234abcd1234abcd1234abcd1234abcd1234"; + #[test] fn stores_author_email_from_git_config() { let database = Database::open_in_memory().unwrap(); @@ -154,7 +156,7 @@ mod tests { run_with( &database, |args| match args { - ["rev-parse", "--short", "HEAD"] => Ok("abc1234\n".to_string()), + ["rev-parse", "HEAD"] => Ok(format!("{TEST_HEAD_HASH}\n")), ["log", "-1", "--format=%B"] => Ok("feat: add hook storage\n".to_string()), ["log", "-1", "--format=%cI"] => Ok("2026-03-10T12:00:00+00:00\n".to_string()), ["rev-parse", "--show-toplevel"] => { @@ -187,7 +189,7 @@ mod tests { run_with( &database, |args| match args { - ["rev-parse", "--short", "HEAD"] => Ok("abc1234\n".to_string()), + ["rev-parse", "HEAD"] => Ok(format!("{TEST_HEAD_HASH}\n")), ["log", "-1", "--format=%B"] => Ok("feat: add hook storage\n".to_string()), ["log", "-1", "--format=%cI"] => Ok("2026-03-10T12:00:00+00:00\n".to_string()), ["rev-parse", "--show-toplevel"] => { @@ -206,7 +208,7 @@ mod tests { .unwrap(); assert_eq!(commits.len(), 1); - assert_eq!(commits[0].hash, "abc1234"); + assert_eq!(commits[0].hash, TEST_HEAD_HASH); assert_eq!(commits[0].message, "feat: add hook storage"); assert_eq!(commits[0].repo_path, "/Users/example/projects/diddo"); assert_eq!(commits[0].repo_name, "diddo"); @@ -224,7 +226,7 @@ mod tests { #[test] fn author_email_is_none_when_config_fails() { let mut run_git = |args: &[&str]| match args { - ["rev-parse", "--short", "HEAD"] => Ok("abc1234\n".to_string()), + ["rev-parse", "HEAD"] => Ok(format!("{TEST_HEAD_HASH}\n")), ["log", "-1", "--format=%B"] => Ok("feat: add hook storage\n".to_string()), ["log", "-1", "--format=%cI"] => Ok("2026-03-10T12:00:00+00:00\n".to_string()), ["rev-parse", "--show-toplevel"] => Ok("/Users/example/projects/diddo\n".to_string()), @@ -243,7 +245,7 @@ mod tests { #[test] fn defaults_diff_stats_to_zero_when_extraction_fails() { let mut run_git = |args: &[&str]| match args { - ["rev-parse", "--short", "HEAD"] => Ok("abc1234\n".to_string()), + ["rev-parse", "HEAD"] => Ok(format!("{TEST_HEAD_HASH}\n")), ["log", "-1", "--format=%B"] => Ok("feat: add hook storage\n".to_string()), ["log", "-1", "--format=%cI"] => Ok("2026-03-10T12:00:00+00:00\n".to_string()), ["rev-parse", "--show-toplevel"] => Ok("/Users/example/projects/diddo\n".to_string()), @@ -269,7 +271,7 @@ mod tests { #[test] fn uses_git_commit_timestamp_for_committed_at() { let mut run_git = |args: &[&str]| match args { - ["rev-parse", "--short", "HEAD"] => Ok("abc1234\n".to_string()), + ["rev-parse", "HEAD"] => Ok(format!("{TEST_HEAD_HASH}\n")), ["log", "-1", "--format=%B"] => Ok("feat: add hook storage\n".to_string()), ["log", "-1", "--format=%cI"] => Ok("2026-03-09T23:45:00-05:00\n".to_string()), ["rev-parse", "--show-toplevel"] => Ok("/Users/example/projects/diddo\n".to_string()), diff --git a/src/onboarding.rs b/src/onboarding.rs index 48554a3..6743b0c 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -16,6 +16,7 @@ pub struct ScannedCommit { pub author_name: String, pub author_email: Option, pub committed_at: DateTime, + pub committed_local_date: NaiveDate, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -102,7 +103,7 @@ where let repo_name = repo_name_from_path(&repo_path); let format_arg = format!("--format={}", GIT_LOG_FORMAT); - let log_out = run_git(&["log", &format_arg])?; + let log_out = run_git(&["log", "-z", &format_arg])?; let all_commits = parse_git_log_output(&log_out).map_err(Box::::from)?; if all_commits.is_empty() { @@ -120,7 +121,7 @@ where let scanned = all_commits .iter() - .filter(|c| c.committed_at.date_naive() >= cutoff) + .filter(|c| c.committed_local_date >= cutoff) .count(); if scanned == 0 { @@ -216,30 +217,29 @@ where const RANGE_DATE_HINT: &str = "YYYY-MM-DD or DD.MM.YYYY"; -pub const GIT_LOG_FORMAT: &str = "%H%x1f%s%x1f%an%x1f%ae%x1f%cI"; +pub const GIT_LOG_FORMAT: &str = "%H%x1f%an%x1f%ae%x1f%cI%x1f%B"; pub fn parse_git_log_output(output: &str) -> Result, String> { let mut commits = Vec::new(); - for line in output.lines() { - let line = line.trim(); - if line.is_empty() { + for record in output.split('\0') { + let record = record.trim(); + if record.is_empty() { continue; } - let parts: Vec<&str> = line.split('\x1f').collect(); + let parts: Vec<&str> = record.split('\x1f').collect(); if parts.len() != 5 { return Err(format!( - "expected 5 fields per log line, got {}", + "expected 5 fields per commit record, got {}", parts.len() )); } let hash = parts[0].trim().to_string(); - let message = parts[1].to_string(); - let author_name = parts[2].trim().to_string(); + let author_name = parts[1].trim().to_string(); let author_email = { - let e = parts[3].trim(); + let e = parts[2].trim(); if e.is_empty() { None } else { @@ -247,9 +247,11 @@ pub fn parse_git_log_output(output: &str) -> Result, String> } }; - let committed_at = DateTime::parse_from_rfc3339(parts[4].trim()) - .map_err(|e| e.to_string())? - .with_timezone(&Utc); + let dt = DateTime::parse_from_rfc3339(parts[3].trim()).map_err(|e| e.to_string())?; + let committed_at = dt.with_timezone(&Utc); + let committed_local_date = dt.naive_local().date(); + + let message = parts[4].to_string(); commits.push(ScannedCommit { hash, @@ -257,6 +259,7 @@ pub fn parse_git_log_output(output: &str) -> Result, String> author_name, author_email, committed_at, + committed_local_date, }); } @@ -403,7 +406,7 @@ pub fn filter_importable_commits( ) -> Vec { commits .iter() - .filter(|c| c.committed_at.date_naive() >= cutoff && identity_matches(c, selected)) + .filter(|c| c.committed_local_date >= cutoff && identity_matches(c, selected)) .cloned() .collect() } @@ -415,8 +418,8 @@ fn identity_matches(commit: &ScannedCommit, selected: &[IdentityCandidate]) -> b .as_ref() .is_some_and(|se| ce.eq_ignore_ascii_case(se)) } else { - match (&commit.author_name, &id.name) { - (cn, Some(sn)) => cn == sn, + match (&id.name, &commit.author_name) { + (Some(sn), cn) => cn.trim().eq_ignore_ascii_case(sn.trim()), _ => false, } } @@ -580,11 +583,13 @@ fn run_git_command(args: &[&str]) -> io::Result { #[cfg(test)] mod tests { - use chrono::TimeZone; + use chrono::{DateTime, TimeZone, Utc}; use super::*; use crate::config::IdentityAlias; - use crate::db::Database; + use crate::db::{Commit, Database}; + + const TEST_FULL_HASH: &str = "abcd1234abcd1234abcd1234abcd1234abcd1234"; fn sample_commit( hash: &str, @@ -599,6 +604,7 @@ mod tests { author_name: author_name.to_string(), author_email: author_email.map(str::to_string), committed_at, + committed_local_date: committed_at.date_naive(), } } @@ -703,7 +709,8 @@ mod tests { } fn sample_log_two_commits() -> &'static str { - "abc1234\x1ffirst\x1fAuthor One\x1fme@example.com\x1f2026-01-01T12:00:00+00:00\ndef5678\x1fsecond\x1fAuthor Two\x1fother@example.com\x1f2026-01-02T12:00:00+00:00" + "abc1234\x1fAuthor One\x1fme@example.com\x1f2026-01-01T12:00:00+00:00\x1ffirst subject\0\ + def5678\x1fAuthor Two\x1fother@example.com\x1f2026-01-02T12:00:00+00:00\x1fsecond subject\0" } #[test] @@ -712,6 +719,7 @@ mod tests { assert_eq!(commits.len(), 2); assert_eq!(commits[0].hash, "abc1234"); + assert_eq!(commits[0].message, "first subject"); } #[test] @@ -783,7 +791,141 @@ mod tests { } #[test] - fn returns_nothing_to_import_when_cutoff_has_no_commits() { + fn import_is_deduped_when_commit_already_exists_from_hook_style_row() { + let database = Database::open_in_memory().unwrap(); + let repo_path = "/tmp/repo"; + let repo_name = "repo"; + let branch = "main"; + let msg = "feat: subject\n\nbody"; + let committed_at = Utc.with_ymd_and_hms(2026, 2, 1, 12, 0, 0).unwrap(); + + database + .insert_commit(&Commit { + id: None, + hash: TEST_FULL_HASH.to_string(), + message: msg.to_string(), + repo_path: repo_path.to_string(), + repo_name: repo_name.to_string(), + branch: branch.to_string(), + files_changed: 2, + insertions: 1, + deletions: 0, + committed_at, + author_email: Some("me@example.com".to_string()), + }) + .unwrap(); + + let scanned = ScannedCommit { + hash: TEST_FULL_HASH.to_string(), + message: msg.to_string(), + author_name: "Me".to_string(), + author_email: Some("me@example.com".to_string()), + committed_at, + committed_local_date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(), + }; + + let outcome = + import_commits(&database, repo_path, repo_name, branch, &[scanned], 1, 1).unwrap(); + + assert_eq!(database.commit_count().unwrap(), 1); + assert_eq!(outcome.inserted, 0); + assert_eq!(outcome.skipped_duplicates, 1); + } + + #[test] + fn cutoff_uses_commit_local_date_from_git_iso_offset() { + let cutoff = NaiveDate::from_ymd_opt(2026, 1, 2).unwrap(); + let selected = vec![IdentityCandidate { + name: None, + email: Some("me@example.com".to_string()), + }]; + let dt = DateTime::parse_from_rfc3339("2026-01-02T04:00:00+09:00").unwrap(); + let committed_at = dt.with_timezone(&Utc); + let commits = vec![ScannedCommit { + hash: "x".to_string(), + message: "m".to_string(), + author_name: "Me".to_string(), + author_email: Some("me@example.com".to_string()), + committed_at, + committed_local_date: dt.naive_local().date(), + }]; + + let imported = filter_importable_commits(&commits, cutoff, &selected); + + assert_eq!(imported.len(), 1); + } + + #[test] + fn identity_name_match_is_ascii_case_insensitive() { + let cutoff = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); + let selected = vec![IdentityCandidate { + name: Some("Me User".to_string()), + email: None, + }]; + let commits = vec![ScannedCommit { + hash: "x".to_string(), + message: "m".to_string(), + author_name: " me user ".to_string(), + author_email: None, + committed_at: Utc.with_ymd_and_hms(2026, 1, 5, 12, 0, 0).unwrap(), + committed_local_date: NaiveDate::from_ymd_opt(2026, 1, 5).unwrap(), + }]; + + let imported = filter_importable_commits(&commits, cutoff, &selected); + + assert_eq!(imported.len(), 1); + } + + #[test] + fn parse_identity_selection_prefers_preselected_when_line_empty() { + let candidates = vec![IdentityCandidate { + name: None, + email: Some("a@b.com".to_string()), + }]; + let preselected = vec![IdentityCandidate { + name: Some("X".to_string()), + email: Some("x@y.com".to_string()), + }]; + + let selected = super::parse_identity_selection("", &candidates, &preselected).unwrap(); + + assert_eq!(selected, preselected); + } + + #[test] + fn parse_identity_selection_parses_indices_and_dedupes() { + let candidates = vec![ + IdentityCandidate { + name: None, + email: Some("a@b.com".to_string()), + }, + IdentityCandidate { + name: None, + email: Some("c@d.com".to_string()), + }, + ]; + + let selected = super::parse_identity_selection("2, 1, 2", &candidates, &[]).unwrap(); + + assert_eq!(selected.len(), 2); + assert_eq!(selected[0].email.as_deref(), Some("c@d.com")); + assert_eq!(selected[1].email.as_deref(), Some("a@b.com")); + } + + #[test] + fn parse_identity_selection_errors_on_invalid_index() { + let candidates = vec![IdentityCandidate { + name: None, + email: Some("a@b.com".to_string()), + }]; + + assert!(super::parse_identity_selection("2", &candidates, &[]).is_err()); + assert!(super::parse_identity_selection("0", &candidates, &[]).is_err()); + assert!(super::parse_identity_selection("x", &candidates, &[]).is_err()); + } + + #[test] + fn returns_empty_outcome_when_git_log_has_no_commits() { use std::collections::VecDeque; use std::io; @@ -818,6 +960,43 @@ mod tests { assert_eq!(outcome.inserted, 0); } + #[test] + fn nothing_to_import_when_no_commits_on_or_after_cutoff() { + use std::collections::VecDeque; + use std::io; + + let database = Database::open_in_memory().unwrap(); + let config_path = std::env::temp_dir().join("diddo-onboard-before-cutoff.toml"); + let config = config::AppConfig::default(); + + let mut lines: VecDeque = VecDeque::from(["2026-01-01".to_string()]); + let mut read_line = move || Ok(lines.pop_front().map(|s| s + "\n").unwrap_or_default()); + let mut write_line = |_s: &str| -> io::Result<()> { Ok(()) }; + + let log = "h1\x1fMe\x1fme@example.com\x1f2025-12-31T12:00:00+00:00\x1fold\n\0"; + let mut run_git = move |args: &[&str]| -> io::Result { + match args { + ["rev-parse", "--show-toplevel"] => Ok("/tmp/repo\n".to_string()), + ["rev-parse", "--abbrev-ref", "HEAD"] => Ok("main\n".to_string()), + _ if args.first() == Some(&"log") => Ok(log.to_string()), + _ => Err(io::Error::other(format!("unexpected git args: {args:?}"))), + } + }; + + let outcome = run_with( + &database, + &config_path, + config, + &mut read_line, + &mut write_line, + &mut run_git, + ) + .unwrap(); + + assert_eq!(outcome.scanned, 0); + assert_eq!(outcome.inserted, 0); + } + #[test] fn onboarding_run_imports_only_user_confirmed_identities() { use std::collections::VecDeque; @@ -828,18 +1007,21 @@ mod tests { let mut config = config::AppConfig::default(); config.onboarding.save_selected_identities = false; - let three = concat!( - "h1\x1fm1\x1fMe\x1fme@example.com\x1f2026-01-01T12:00:00+00:00\n", - "h2\x1fm2\x1fMe\x1fme@example.com\x1f2026-01-02T12:00:00+00:00\n", - "h3\x1fm3\x1fOther\x1fother@example.com\x1f2026-01-03T12:00:00+00:00\n", - ); + let log = concat!( + "h1\x1fMe\x1fme@example.com\x1f2026-01-01T12:00:00+00:00\x1fm1\n", + "\0", + "h2\x1fMe\x1fme@example.com\x1f2026-01-02T12:00:00+00:00\x1fm2\n", + "\0", + "h3\x1fOther\x1fother@example.com\x1f2026-01-03T12:00:00+00:00\x1fm3\n", + "\0", + ) + .to_string(); let mut lines: VecDeque = VecDeque::from(["2026-01-01".to_string(), "1".to_string()]); let mut read_line = move || Ok(lines.pop_front().map(|s| s + "\n").unwrap_or_default()); let mut write_line = |_s: &str| -> io::Result<()> { Ok(()) }; - let log = three.to_string(); let mut run_git = move |args: &[&str]| -> io::Result { match args { ["rev-parse", "--show-toplevel"] => Ok("/tmp/repo\n".to_string()), From 36e03b1f4408adb78165d3ca92965ac85b0d8d7a Mon Sep 17 00:00:00 2001 From: Nikita Bayev Date: Mon, 23 Mar 2026 20:53:30 +0500 Subject: [PATCH 09/11] feat: add rust-toolchain configuration and update workflows - Introduced rust-toolchain.toml to specify the Rust version (1.93.0). - Updated GitHub workflows (release.yml and test.yml) to read the Rust toolchain channel from the new configuration file. - Adjusted Rust installation steps to utilize the specified toolchain from the configuration. Made-with: Cursor --- .github/workflows/release.yml | 23 +++++++++++++++++++++++ .github/workflows/test.yml | 9 +++++++++ rust-toolchain.toml | 2 ++ 3 files changed, 34 insertions(+) create mode 100644 rust-toolchain.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d5cf5d..d4255ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,9 +35,16 @@ jobs: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT fi + - name: Read Rust toolchain channel + id: rust + run: | + channel=$(grep -E '^channel\s*=' rust-toolchain.toml | head -1 | sed -E 's/^channel\s*=\s*"([^"]+)".*/\1/') + echo "channel=${channel}" >> $GITHUB_OUTPUT + - name: Install Rust uses: dtolnay/rust-toolchain@stable with: + toolchain: ${{ steps.rust.outputs.channel }} targets: ${{ matrix.target }} - name: Build @@ -78,9 +85,16 @@ jobs: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT fi + - name: Read Rust toolchain channel + id: rust + run: | + channel=$(grep -E '^channel\s*=' rust-toolchain.toml | head -1 | sed -E 's/^channel\s*=\s*"([^"]+)".*/\1/') + echo "channel=${channel}" >> $GITHUB_OUTPUT + - name: Install Rust uses: dtolnay/rust-toolchain@stable with: + toolchain: ${{ steps.rust.outputs.channel }} targets: ${{ matrix.target }} - name: Install aarch64 cross-compiler @@ -120,8 +134,17 @@ jobs: echo "version=$ver" >> $env:GITHUB_OUTPUT } + - name: Read Rust toolchain channel + id: rust + shell: bash + run: | + channel=$(grep -E '^channel\s*=' rust-toolchain.toml | head -1 | sed -E 's/^channel\s*=\s*"([^"]+)".*/\1/') + echo "channel=${channel}" >> $GITHUB_OUTPUT + - name: Install Rust uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ steps.rust.outputs.channel }} - name: Build run: cargo build --release diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2136583..1548595 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,8 +14,17 @@ jobs: - name: Checkout uses: actions/checkout@v6 + - name: Read Rust toolchain channel + id: rust + run: | + channel=$(grep -E '^channel\s*=' rust-toolchain.toml | head -1 | sed -E 's/^channel\s*=\s*"([^"]+)".*/\1/') + echo "channel=${channel}" >> $GITHUB_OUTPUT + - name: Install Rust uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ steps.rust.outputs.channel }} + components: rustfmt, clippy - name: Run tests run: cargo test diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..075062e --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "1.93.0" From a8497857c7e650849f57495ccff63399f79c855b Mon Sep 17 00:00:00 2001 From: Nikita Bayev Date: Mon, 23 Mar 2026 21:18:30 +0500 Subject: [PATCH 10/11] feat: implement commit hash migration and expand abbreviated hashes - Added functionality to migrate abbreviated commit hashes to their full SHA-1 equivalents in the database. - Introduced helper functions to validate hash formats and execute git commands for hash resolution. - Implemented tests to ensure correct migration behavior and deduplication of commit entries. Made-with: Cursor --- src/db.rs | 242 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 241 insertions(+), 1 deletion(-) diff --git a/src/db.rs b/src/db.rs index 9566eed..8783e77 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,4 +1,4 @@ -use std::{fs, path::Path}; +use std::{fs, path::Path, process::Command}; use chrono::{DateTime, Local, LocalResult, NaiveDate, TimeZone, Utc}; use rusqlite::{Connection, Result, Row, params, types::Type}; @@ -74,6 +74,7 @@ impl Database { fn initialize(connection: Connection) -> Result { connection.execute_batch(SCHEMA)?; run_author_email_migration(&connection)?; + run_commit_hash_migration(&connection)?; Ok(Self { connection }) } @@ -209,6 +210,82 @@ fn run_author_email_migration(conn: &Connection) -> Result<()> { Ok(()) } +fn hash_is_all_hex(hash: &str) -> bool { + !hash.is_empty() && hash.as_bytes().iter().all(|b| b.is_ascii_hexdigit()) +} + +fn hash_looks_like_full_oid(hash: &str) -> bool { + let len = hash.len(); + (len == 40 || len == 64) && hash_is_all_hex(hash) +} + +fn git_rev_parse_oid(repo_path: &Path, oid: &str) -> Option { + let output = Command::new("git") + .arg("-C") + .arg(repo_path) + .arg("rev-parse") + .arg(oid) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn run_commit_hash_migration(conn: &Connection) -> Result<()> { + let mut stmt = conn.prepare("SELECT DISTINCT repo_path, hash FROM commits")?; + let pairs: Vec<(String, String)> = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? + .collect::>>()?; + + for (repo_path, hash) in pairs { + if !hash_is_all_hex(&hash) || hash_looks_like_full_oid(&hash) { + continue; + } + + let repo = Path::new(&repo_path); + if !repo.is_dir() { + continue; + } + + let Some(full) = git_rev_parse_oid(repo, &hash) else { + continue; + }; + if full == hash { + continue; + } + + let existing_full: i64 = conn.query_row( + "SELECT COUNT(*) FROM commits WHERE repo_path = ?1 AND hash = ?2", + params![&repo_path, &full], + |row| row.get(0), + )?; + + if existing_full > 0 { + conn.execute( + "DELETE FROM commits WHERE repo_path = ?1 AND hash = ?2", + params![&repo_path, &hash], + )?; + } else { + conn.execute( + "UPDATE commits SET hash = ?1 WHERE repo_path = ?2 AND hash = ?3", + params![&full, &repo_path, &hash], + )?; + } + } + + Ok(()) +} + fn date_range_bounds_in_timezone( from: NaiveDate, to: NaiveDate, @@ -271,6 +348,7 @@ mod tests { use std::{ fs, path::Path, + process::Command, time::{SystemTime, UNIX_EPOCH}, }; @@ -506,6 +584,168 @@ mod tests { fs::remove_dir_all(path.parent().unwrap()).ok(); } + #[test] + fn migration_expands_abbreviated_commit_hashes_with_git_repo() { + if !git_cli_available() { + return; + } + + let root = unique_temp_path("diddo-hash-migration-expand"); + let repo = root.join("repo"); + fs::create_dir_all(&repo).unwrap(); + run_git(&repo, &["init"]); + run_git(&repo, &["config", "user.email", "migration@test.local"]); + run_git(&repo, &["config", "user.name", "Migration Test"]); + fs::write(repo.join("f.txt"), "x").unwrap(); + run_git(&repo, &["add", "f.txt"]); + run_git(&repo, &["commit", "-m", "init"]); + + let repo_path = git_stdout_trim(&repo, &["rev-parse", "--show-toplevel"]); + let short = git_stdout_trim(&repo, &["rev-parse", "--short", "HEAD"]); + let full = git_stdout_trim(&repo, &["rev-parse", "HEAD"]); + assert_ne!( + short, full, + "expected abbreviated hash to differ from full oid" + ); + + let db_path = root.join("d.sqlite"); + let committed_at = Utc::now(); + let today = committed_at.date_naive(); + { + let database = Database::open(&db_path).unwrap(); + let commit = Commit { + id: None, + hash: short.clone(), + message: "hook-era short hash".to_string(), + repo_path: repo_path.clone(), + repo_name: "repo".to_string(), + branch: "main".to_string(), + files_changed: 1, + insertions: 1, + deletions: 0, + committed_at, + author_email: None, + }; + database.insert_commit(&commit).unwrap(); + assert_eq!(database.commit_count().unwrap(), 1); + drop(database); + } + + { + let database = Database::open(&db_path).unwrap(); + assert_eq!(database.commit_count().unwrap(), 1); + let commits = database.query_date(today).unwrap(); + assert_eq!(commits.len(), 1); + assert_eq!(commits[0].hash, full); + } + + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn migration_drops_abbreviated_row_when_full_hash_row_exists() { + if !git_cli_available() { + return; + } + + let root = unique_temp_path("diddo-hash-migration-dedupe"); + let repo = root.join("repo"); + fs::create_dir_all(&repo).unwrap(); + run_git(&repo, &["init"]); + run_git(&repo, &["config", "user.email", "migration@test.local"]); + run_git(&repo, &["config", "user.name", "Migration Test"]); + fs::write(repo.join("f.txt"), "x").unwrap(); + run_git(&repo, &["add", "f.txt"]); + run_git(&repo, &["commit", "-m", "init"]); + + let repo_path = git_stdout_trim(&repo, &["rev-parse", "--show-toplevel"]); + let short = git_stdout_trim(&repo, &["rev-parse", "--short", "HEAD"]); + let full = git_stdout_trim(&repo, &["rev-parse", "HEAD"]); + assert_ne!(short, full); + + let committed_at = Utc::now(); + let today = committed_at.date_naive(); + let db_path = root.join("d.sqlite"); + { + let database = Database::open(&db_path).unwrap(); + database + .insert_commit(&Commit { + id: None, + hash: short, + message: "short".to_string(), + repo_path: repo_path.clone(), + repo_name: "repo".to_string(), + branch: "main".to_string(), + files_changed: 1, + insertions: 1, + deletions: 0, + committed_at, + author_email: None, + }) + .unwrap(); + database + .insert_commit(&Commit { + id: None, + hash: full.clone(), + message: "full".to_string(), + repo_path: repo_path.clone(), + repo_name: "repo".to_string(), + branch: "main".to_string(), + files_changed: 2, + insertions: 2, + deletions: 1, + committed_at, + author_email: Some("x@y.z".to_string()), + }) + .unwrap(); + assert_eq!(database.commit_count().unwrap(), 2); + drop(database); + } + + { + let database = Database::open(&db_path).unwrap(); + assert_eq!(database.commit_count().unwrap(), 1); + let commits = database.query_date(today).unwrap(); + assert_eq!(commits.len(), 1); + assert_eq!(commits[0].hash, full); + } + + fs::remove_dir_all(&root).ok(); + } + + fn git_cli_available() -> bool { + Command::new("git") + .arg("--version") + .output() + .is_ok_and(|o| o.status.success()) + } + + fn run_git(repo: &Path, args: &[&str]) { + let status = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .status() + .unwrap(); + assert!(status.success(), "git {:?} failed", args); + } + + fn git_stdout_trim(repo: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {:?} stderr={}", + args, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() + } + #[test] fn reinserting_same_commit_updates_incomplete_metadata() { let database = Database::open_in_memory().unwrap(); From 7ce9f4a9b00c6254656cb2b6371470e6fbff2899 Mon Sep 17 00:00:00 2001 From: Nikita Bayev Date: Mon, 23 Mar 2026 21:52:12 +0500 Subject: [PATCH 11/11] =?UTF-8?q?fix(onboard):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20insert-if-new,=20identity=20case,=20test=20isolatio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add insert_commit_if_new (ON CONFLICT DO NOTHING) so onboarding does not overwrite hook-recorded diff stats; import_commits counts inserts directly. - Extend dedup test to assert files_changed/insertions/deletions unchanged. - Make identity_same and alias merge dedup ASCII case-insensitive for email/name. - Use unique temp dirs for onboarding integration tests to avoid parallel flakes. Made-with: Cursor --- src/db.rs | 35 +++++++++++++++++++ src/onboarding.rs | 87 ++++++++++++++++++++++++++++++++++++----------- 2 files changed, 102 insertions(+), 20 deletions(-) diff --git a/src/db.rs b/src/db.rs index 8783e77..ee71db9 100644 --- a/src/db.rs +++ b/src/db.rs @@ -119,6 +119,41 @@ impl Database { Ok(()) } + /// Inserts a row only when no commit exists for `(repo_path, hash)`. + /// Returns `true` if a new row was inserted. Unlike [`Self::insert_commit`], this does not + /// overwrite diff stats or other fields when the commit already exists (e.g. hook vs onboard). + pub fn insert_commit_if_new(&self, commit: &Commit) -> Result { + let rows = self.connection.execute( + "INSERT INTO commits ( + hash, + message, + repo_path, + repo_name, + branch, + files_changed, + insertions, + deletions, + committed_at, + author_email + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(repo_path, hash) DO NOTHING", + params![ + &commit.hash, + &commit.message, + &commit.repo_path, + &commit.repo_name, + &commit.branch, + commit.files_changed, + commit.insertions, + commit.deletions, + commit.committed_at.to_rfc3339(), + &commit.author_email, + ], + )?; + + Ok(rows > 0) + } + pub fn query_date(&self, date: NaiveDate) -> Result> { let (start, end) = date_range_bounds_local(date, date)?; self.query_date_range_raw(&start, &end) diff --git a/src/onboarding.rs b/src/onboarding.rs index 6743b0c..18aa1e1 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -187,11 +187,9 @@ where }) .collect(); - let has_new_alias = selected_aliases.iter().any(|a| { - !had_before - .iter() - .any(|h| h.name == a.name && h.email == a.email) - }); + let has_new_alias = selected_aliases + .iter() + .any(|a| !had_before.iter().any(|h| identity_alias_same(h, a))); if has_new_alias { write_line("\nSave selected identities to config for next time? [y/N] ")?; @@ -199,10 +197,7 @@ where if matches!(yn.trim().to_ascii_lowercase().as_str(), "y" | "yes") { let mut merged = had_before; for a in selected_aliases { - if !merged - .iter() - .any(|h| h.name == a.name && h.email == a.email) - { + if !merged.iter().any(|h| identity_alias_same(h, &a)) { merged.push(a); } } @@ -456,15 +451,13 @@ pub fn import_commits( scanned_total: usize, matched_total: usize, ) -> Result { - let count_before = database.commit_count()?; - + let mut inserted = 0usize; for c in commits { let row = to_db_commit(c, repo_path, repo_name, branch); - database.insert_commit(&row)?; + if database.insert_commit_if_new(&row)? { + inserted += 1; + } } - - let count_after = database.commit_count()?; - let inserted = (count_after - count_before) as usize; let skipped_duplicates = commits.len().saturating_sub(inserted); Ok(ImportOutcome { @@ -519,8 +512,20 @@ fn format_identity_line(id: &IdentityCandidate) -> String { } } +fn optional_str_eq_ignore_case(a: &Option, b: &Option) -> bool { + match (a, b) { + (Some(x), Some(y)) => x.eq_ignore_ascii_case(y), + (None, None) => true, + _ => false, + } +} + fn identity_same(a: &IdentityCandidate, b: &IdentityCandidate) -> bool { - a.name == b.name && a.email == b.email + optional_str_eq_ignore_case(&a.email, &b.email) && optional_str_eq_ignore_case(&a.name, &b.name) +} + +fn identity_alias_same(a: &IdentityAlias, b: &IdentityAlias) -> bool { + optional_str_eq_ignore_case(&a.email, &b.email) && optional_str_eq_ignore_case(&a.name, &b.name) } fn parse_identity_selection( @@ -583,12 +588,28 @@ fn run_git_command(args: &[&str]) -> io::Result { #[cfg(test)] mod tests { - use chrono::{DateTime, TimeZone, Utc}; + use std::time::{SystemTime, UNIX_EPOCH}; + + use chrono::{DateTime, NaiveDate, TimeZone, Utc}; use super::*; use crate::config::IdentityAlias; use crate::db::{Commit, Database}; + fn temp_dir_onboard(label: &str) -> std::path::PathBuf { + let unique = format!( + "diddo-onboard-{label}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let path = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&path).unwrap(); + path + } + const TEST_FULL_HASH: &str = "abcd1234abcd1234abcd1234abcd1234abcd1234"; fn sample_commit( @@ -830,6 +851,26 @@ mod tests { assert_eq!(database.commit_count().unwrap(), 1); assert_eq!(outcome.inserted, 0); assert_eq!(outcome.skipped_duplicates, 1); + + let day = NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(); + let rows = database.query_date(day).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].files_changed, 2); + assert_eq!(rows[0].insertions, 1); + assert_eq!(rows[0].deletions, 0); + } + + #[test] + fn identity_same_treats_ascii_case_variants_as_equal() { + let a = IdentityCandidate { + name: Some("User".to_string()), + email: Some("Me@X.com".to_string()), + }; + let b = IdentityCandidate { + name: Some("user".to_string()), + email: Some("me@x.com".to_string()), + }; + assert!(super::identity_same(&a, &b)); } #[test] @@ -930,7 +971,8 @@ mod tests { use std::io; let database = Database::open_in_memory().unwrap(); - let config_path = std::env::temp_dir().join("diddo-onboard-empty.toml"); + let temp = temp_dir_onboard("empty"); + let config_path = temp.join("config.toml"); let config = config::AppConfig::default(); let mut lines: VecDeque = VecDeque::new(); @@ -958,6 +1000,7 @@ mod tests { assert_eq!(outcome.scanned, 0); assert_eq!(outcome.inserted, 0); + let _ = std::fs::remove_dir_all(temp); } #[test] @@ -966,7 +1009,8 @@ mod tests { use std::io; let database = Database::open_in_memory().unwrap(); - let config_path = std::env::temp_dir().join("diddo-onboard-before-cutoff.toml"); + let temp = temp_dir_onboard("before-cutoff"); + let config_path = temp.join("config.toml"); let config = config::AppConfig::default(); let mut lines: VecDeque = VecDeque::from(["2026-01-01".to_string()]); @@ -995,6 +1039,7 @@ mod tests { assert_eq!(outcome.scanned, 0); assert_eq!(outcome.inserted, 0); + let _ = std::fs::remove_dir_all(temp); } #[test] @@ -1003,7 +1048,8 @@ mod tests { use std::io; let database = Database::open_in_memory().unwrap(); - let config_path = std::env::temp_dir().join("diddo-onboard-import.toml"); + let temp = temp_dir_onboard("import"); + let config_path = temp.join("config.toml"); let mut config = config::AppConfig::default(); config.onboarding.save_selected_identities = false; @@ -1047,5 +1093,6 @@ mod tests { assert_eq!(outcome.matched, 2); assert_eq!(outcome.inserted, 2); assert_eq!(outcome.skipped_duplicates, 0); + let _ = std::fs::remove_dir_all(temp); } }