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/README.md b/README.md index 340eeca..41dd58a 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`). 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. 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/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" diff --git a/src/config.rs b/src/config.rs index 21979b7..80b8e68 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,15 +1,39 @@ 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)] +#[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 +45,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 +55,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, @@ -39,21 +63,64 @@ 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_from_file(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)] @@ -150,7 +217,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 +565,89 @@ 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(); + } + + #[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/db.rs b/src/db.rs index 9566eed..ee71db9 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 }) } @@ -118,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) @@ -209,6 +245,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 +383,7 @@ mod tests { use std::{ fs, path::Path, + process::Command, time::{SystemTime, UNIX_EPOCH}, }; @@ -506,6 +619,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(); 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/main.rs b/src/main.rs index 6c9d8ff..98d7e27 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. + 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, } @@ -959,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}, @@ -1307,6 +1322,27 @@ 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 help_output_mentions_onboard_command() { + let help = HelpCli::command().render_long_help().to_string(); + + assert!(help.contains("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 +1432,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 +1474,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..18aa1e1 --- /dev/null +++ b/src/onboarding.rs @@ -0,0 +1,1098 @@ +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; + +#[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, + pub committed_local_date: NaiveDate, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IdentityCandidate { + pub name: Option, + pub email: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct GitIdentity { + pub name: Option, + pub email: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ImportOutcome { + pub scanned: usize, + pub matched: usize, + pub inserted: usize, + pub skipped_duplicates: usize, +} + +pub fn run( + 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(()) +} + +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", "-z", &format_arg])?; + 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")?; + 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::::from(format!("invalid cutoff date: {e}")))?; + + let scanned = all_commits + .iter() + .filter(|c| c.committed_local_date >= 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| identity_alias_same(h, a))); + + 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| identity_alias_same(h, &a)) { + 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"; + +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 record in output.split('\0') { + let record = record.trim(); + if record.is_empty() { + continue; + } + + let parts: Vec<&str> = record.split('\x1f').collect(); + if parts.len() != 5 { + return Err(format!( + "expected 5 fields per commit record, got {}", + parts.len() + )); + } + + let hash = parts[0].trim().to_string(); + let author_name = parts[1].trim().to_string(); + let author_email = { + let e = parts[2].trim(); + if e.is_empty() { + None + } else { + Some(e.to_string()) + } + }; + + 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, + message, + author_name, + author_email, + committed_at, + committed_local_date, + }); + } + + Ok(commits) +} + +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, + } +} + +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 +} + +pub fn filter_importable_commits( + commits: &[ScannedCommit], + cutoff: NaiveDate, + selected: &[IdentityCandidate], +) -> Vec { + commits + .iter() + .filter(|c| c.committed_local_date >= 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 (&id.name, &commit.author_name) { + (Some(sn), cn) => cn.trim().eq_ignore_ascii_case(sn.trim()), + _ => 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(), + } +} + +pub fn import_commits( + database: &Database, + repo_path: &str, + repo_name: &str, + branch: &str, + commits: &[ScannedCommit], + scanned_total: usize, + matched_total: usize, +) -> Result { + let mut inserted = 0usize; + for c in commits { + let row = to_db_commit(c, repo_path, repo_name, branch); + if database.insert_commit_if_new(&row)? { + inserted += 1; + } + } + let skipped_duplicates = commits.len().saturating_sub(inserted); + + Ok(ImportOutcome { + 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 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 { + 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( + 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 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( + 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, + committed_local_date: committed_at.date_naive(), + } + } + + #[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, + 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); + } + + fn sample_log_two_commits() -> &'static str { + "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] + 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"); + assert_eq!(commits[0].message, "first subject"); + } + + #[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")) + ); + } + + #[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()) + ); + } + + #[test] + 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); + + 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] + 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; + + let database = Database::open_in_memory().unwrap(); + let temp = temp_dir_onboard("empty"); + let config_path = temp.join("config.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); + let _ = std::fs::remove_dir_all(temp); + } + + #[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 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()]); + 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); + let _ = std::fs::remove_dir_all(temp); + } + + #[test] + fn onboarding_run_imports_only_user_confirmed_identities() { + use std::collections::VecDeque; + use std::io; + + let database = Database::open_in_memory().unwrap(); + 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; + + 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 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); + let _ = std::fs::remove_dir_all(temp); + } +}