From 72b5ebbb2df330a8b3709d363802e36a3b17d167 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Thu, 11 Jun 2026 08:02:00 -0600 Subject: [PATCH] Fix: five hands-on findings from 1.5.0 release testing - work commit: -m messages already carrying a conventional-commit prefix (type:, type(scope):, breaking ! variants; case-insensitive) are committed verbatim instead of double-prefixing (feat: feat: ...) - changelog/release: prefix classification is now case-insensitive and maps the CorvidLabs commit style (Add: -> Features, Update: -> Changes, Remove: -> Removals, Fix:/Refactor:/... -> their lowercase categories) instead of dumping those commits in "Other"; breaking ! markers classify by base type, matching the documented invariant - run --init: generic starter template no longer emits an unclosed quote in the commented `# lint = "echo 'add your linter'"` example - lanes init: follow-up hint points at `fledge lanes list` instead of `fledge lane`, which exits with a usage error - run --help / README / spec: pass-through example uses `-- --release`, which is valid when appended to `cargo test` (`--nocapture` is only accepted after cargo's own `--` separator) Specs bumped (work v15, changelog v5, release v6, run v6, lanes v25, main v11) and CHANGELOG.md updated under Unreleased. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 ++ README.md | 2 +- specs/changelog/changelog.spec.md | 7 +- specs/lanes/lanes.spec.md | 4 +- specs/main/main.spec.md | 3 +- specs/release/release.spec.md | 5 +- specs/run/run.spec.md | 11 ++- specs/work/work.spec.md | 13 ++- src/changelog.rs | 90 +++++++++++++++-- src/cli.rs | 2 +- src/lanes/defaults.rs | 2 +- src/release/changelog.rs | 21 +++- src/release/tests.rs | 58 +++++++++++ src/run.rs | 30 +++++- ..._introspect__tests__introspect_schema.snap | 2 +- src/work.rs | 96 ++++++++++++++++++- tests/lanes.rs | 7 ++ 17 files changed, 329 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d2b459..b030f56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixes + +- `fledge work commit` no longer double-prefixes the conventional type: `-m` messages that already start with `type:` / `type(scope):` (case-insensitive, including breaking `!` variants) are committed verbatim instead of becoming `feat: feat: ...` +- Changelog prefix matching (`fledge changelog` and `fledge release`) is now case-insensitive and understands the CorvidLabs commit style: `Add:` → Features, `Update:` → Changes, `Remove:` → Removals, `Fix:`/`Refactor:`/etc. map to their lowercase categories instead of landing in "Other" +- `fledge run --init` generic template no longer emits an unclosed quote in the commented `# lint = ...` example, which made the file unparseable when uncommented +- `fledge lanes init` hint now points at `fledge lanes list` (previously `fledge lane`, which exits with a usage error) +- `fledge run --help` pass-through example now uses a flag that works when appended to `cargo test` (`-- --release` instead of `-- --nocapture`) + ## [v1.5.0] - 2026-06-07 ### Other diff --git a/README.md b/README.md index dd5f7f1..5599c40 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Already have a project? `cd` into it, fledge auto-detects the stack: ```bash fledge run test # runs your language's test command fledge run build # same for build -fledge run test -- --nocapture # pass extra args through to the command after -- +fledge run test -- --release # pass extra args through to the command after -- fledge review # AI code review against the default branch fledge review --with-model ollama:gpt-oss:120b-cloud,ollama:qwen3-coder:480b-cloud # multi-model panel, parallel critiques on the same diff diff --git a/specs/changelog/changelog.spec.md b/specs/changelog/changelog.spec.md index 3a1946e..e7a7e53 100644 --- a/specs/changelog/changelog.spec.md +++ b/specs/changelog/changelog.spec.md @@ -1,6 +1,6 @@ --- module: changelog -version: 4 +version: 5 status: active files: - src/changelog.rs @@ -40,14 +40,14 @@ Generate changelogs from git tags and conventional commit messages. Groups commi 1. Lists tags sorted by version (newest first) using `git tag --sort=-version:refname` 2. Groups commits between adjacent tags using conventional commit prefixes -3. Recognizes prefixes: feat, fix, docs, style, refactor, perf, test, build, ci, chore +3. Recognizes prefixes case-insensitively: feat, fix, docs, style, refactor, perf, test, build, ci, chore, plus the CorvidLabs-style add (→ Features), update (→ Changes), and remove (→ Removals). `Fix:` and `fix:` classify identically 4. Handles scoped commits like `fix(parser): message` 5. Non-conventional commits are grouped under "Other" 6. `--unreleased` shows commits since the latest tag 7. `--tag` shows a single release 8. `--json` outputs structured JSON 9. Merge commits are excluded via `--no-merges` -10. Breaking change indicators (`!` after type, `BREAKING CHANGE:` footer) are not parsed separately — commits with `!` are classified by their base type (e.g. `feat!:` → Features) +10. Breaking change indicators (`!` after type, `BREAKING CHANGE:` footer) are not parsed separately — commits with `!` (`feat!:`, `fix(core)!:`) are classified by their base type (e.g. `feat!:` → Features) ## Behavioral Examples @@ -98,6 +98,7 @@ None (uses only git CLI and standard library) | Version | Date | Changes | |---------|------|---------| +| 5 | 2026-06-11 | Prefix matching is now case-insensitive and understands the CorvidLabs commit style: `Add:` → Features, `Update:` → Changes, `Remove:` → Removals, `Fix:`/`Refactor:`/etc. map to their lowercase categories instead of landing in Other. Breaking `!` markers (`feat!:`, `fix(core)!:`) now classify by base type, matching invariant 10 | | 4 | 2026-04-26 | Doc sync, behavioral example updated to show the post-tier-D envelope shape (was still showing the pre-1.0 bare-array form). No code change | | 3 | 2026-04-26 | **Breaking (tier D, 1.0):** `changelog --json` migrated from a bare top-level array to `{schema_version: 1, action: "changelog", releases: [...]}`. Same shape break tier C (#274) applied to the three pillars, this caught a remaining bare-array output. Last-chance shape break before 1.0 freezes the contract. Consumers reading `result[0]` now read `result.releases[0]` | | 2 | 2026-04-22 | Document that breaking changes are not parsed separately; note no types filter config | diff --git a/specs/lanes/lanes.spec.md b/specs/lanes/lanes.spec.md index d475f9a..ef4cd2e 100644 --- a/specs/lanes/lanes.spec.md +++ b/specs/lanes/lanes.spec.md @@ -1,6 +1,6 @@ --- module: lanes -version: 24 +version: 25 status: active files: - src/lanes/mod.rs @@ -207,6 +207,7 @@ $ fledge lanes run check # Init default lanes $ fledge lanes init ✅ Added default lanes to fledge.toml + Run fledge lanes list to see them. # Search community lanes on GitHub $ fledge lanes search @@ -333,6 +334,7 @@ files continue to load against v1 semantics indefinitely. | Version | Date | Changes | |---------|------|---------| +| 25 | 2026-06-11 | `lanes init` follow-up hint now points at `fledge lanes list` — it previously printed `fledge lane`, which is the subcommand alias without an action and exits with a usage error | | 24 | 2026-05-04 | Follow-up polish: (a) New `retry_delay` step option (seconds, default 1) — overrides the inter-attempt sleep, supports immediate retry with `retry_delay = 0`. (b) Windows process tree reaping via Job Object + `TerminateJobObject` — mirrors the Unix `process_group` + `killpg` fix from v23 so timeout no longer leaks grandchildren on Windows either. (c) `evaluate_when` now exposes a closure-injected `evaluate_when_with` so tests can supply a `HashMap` instead of mutating process-global env vars (edition-2024 prep) | | 23 | 2026-05-04 | Follow-up review fixes: (a) Process group kill on Unix — `timeout` now reaps the entire process tree via `killpg(SIGKILL)`, no more orphaned grandchildren from `sh -c "a && b"`. (b) Skipped step JSON entries normalized to include `success: null, duration_ms: null, error: null` so the per-step shape is consistent across completed and skipped rows; `LANES_RUN_SCHEMA` and `LANES_DRY_RUN_SCHEMA` reverted to 1 (additive only). (c) `--from ` on a parallel step now emits a specific error pointing at index targeting instead of the generic "no match" | | 22 | 2026-05-04 | Review fixes: (a) 1-second retry delay between attempts. (b) Timeout is per-attempt — each retry gets a fresh deadline. (c) Bump `LANES_RUN_SCHEMA` and `LANES_DRY_RUN_SCHEMA` from 1 → 2 for new fields (later reverted in v23). (d) Fix spec: remove private fns from Exported Functions, document process tree limitation on timeout | diff --git a/specs/main/main.spec.md b/specs/main/main.spec.md index e306774..3ca9000 100644 --- a/specs/main/main.spec.md +++ b/specs/main/main.spec.md @@ -1,6 +1,6 @@ --- module: main -version: 10 +version: 11 status: active files: - src/main.rs @@ -143,6 +143,7 @@ All modules are dependencies — main dispatches to every subcommand module. See | Version | Date | Changes | |---------|------|---------| +| 11 | 2026-06-11 | Help-text-only change in `src/cli.rs`: the `run` pass-through args example now shows `fledge run test -- --release` (valid when appended to `cargo test`) instead of `-- --nocapture`, which cargo rejects unless preceded by its own `--` | | 10 | 2026-04-29 | Document all public exports from `cli.rs`, `config_cmds.rs`, and `template_cmds.rs` now that these files are listed in spec frontmatter. No API changes | | 9 | 2026-04-26 | `templates list` empty case now exits 0 in both modes. JSON mode emits `{schema_version: 1, templates: [], hint}`; non-JSON prints "No templates configured" + hint. Previously both bailed with non-zero exit, breaking agents that call `templates list --json` defensively | | 8 | 2026-04-25 | **Breaking (tier C, #272):** `templates search --json` migrated from bare top-level array to `{schema_version: 1, results: [...]}`. Last-chance shape break before 1.0 | diff --git a/specs/release/release.spec.md b/specs/release/release.spec.md index bc35e6c..7cfe7ca 100644 --- a/specs/release/release.spec.md +++ b/specs/release/release.spec.md @@ -1,6 +1,6 @@ --- module: release -version: 5 +version: 6 status: active files: - src/release/mod.rs @@ -54,7 +54,7 @@ Provides a unified release workflow: version bumping across language ecosystems, 10. The plugin.toml bumper is scoped to the `[plugin]` table — a `version` key in another table (e.g. a `[[commands]]` row) is left untouched 11. When a Rust plugin carries both `Cargo.toml` and `plugin.toml`, both are bumped in the same release commit so they stay in sync 12. All git commands use explicit `current_dir` for correctness in any working directory context -13. Release has its own `classify_for_changelog()` function that mirrors `changelog::classify_commit()` — same type labels but independent implementations +13. Release has its own `classify_for_changelog()` function that mirrors `changelog::classify_commit()` — same type labels but independent implementations. Both match prefixes case-insensitively, accept breaking `!` markers, and map CorvidLabs-style `Add:` → Features, `Update:` → Changes, `Remove:` → Removals; `strip_conventional_prefix` strips the same prefixes case-insensitively ## Behavioral Examples @@ -133,6 +133,7 @@ Then version.txt is bumped alongside auto-detected files | Version | Date | Changes | |---------|------|---------| +| 6 | 2026-06-11 | `classify_for_changelog` and `strip_conventional_prefix` are now case-insensitive, accept breaking `!` markers, and recognize the CorvidLabs commit style (`Add:` → Features, `Update:` → Changes, `Remove:` → Removals) instead of grouping those commits under Other | | 5 | 2026-05-01 | **1.0 contract finalize, last-mile fix:** `release --dry-run --json` `files_to_bump` array now includes `[release].files` extras (e.g. `flake.nix`) so the dry-run envelope accurately previews what a real run writes. Previously, `detect_version_files` only looked at the hardcoded language candidates while `bump_version_files` (the real write path) also processed `[release].files` from `fledge.toml` — so dry-run reported `["Cargo.toml"]` while a real run also bumped `flake.nix`. Mirrored the bumper's existence + version-line-regex check in the detection path. Three new tests pin the contract. Caught in independent third-pass review pre-tag | | 4 | 2026-04-26 | Pre-release lane no longer pollutes `--json` stdout. When `--json` is set, `run_pre_lane` calls a new silent path `crate::lanes::run_for_pre_release(name, dry_run)` that runs steps with subprocess output suppressed and emits no envelope of its own. Fixes a double-envelope regression where `release --json --pre-lane ` previously emitted lane and release envelopes back-to-back on stdout. Failure path unchanged: lane bails with a plain stderr error and exit code 1 | | 3 | 2026-04-26 | Add `--json` flag. `release X.Y.Z --dry-run --json` emits `{schema_version: 1, action: "release", dry_run: true, version, no_bump, files_to_bump, will_changelog, will_tag, will_push, tag}`. `release X.Y.Z --json` (real run) emits `{schema_version: 1, action: "release", dry_run: false, version, old_version, files_bumped, changelog_updated, commit_created, tag_created, tag, pushed}` and suppresses prose output. Helper functions (`generate_changelog_entry`, `create_release_commit`, `create_tag`, `push_release`) gained a `quiet` param threaded from `opts.json`. New integration tests `cli_release_dry_run_json_emits_envelope` and `cli_release_dry_run_json_no_bump_flag` | diff --git a/specs/run/run.spec.md b/specs/run/run.spec.md index 3bf824a..95afaa2 100644 --- a/specs/run/run.spec.md +++ b/specs/run/run.spec.md @@ -1,6 +1,6 @@ --- module: run -version: 5 +version: 6 status: active files: - src/run.rs @@ -92,17 +92,17 @@ $ fledge run test --json {"schema_version": 1, "action": "run_task", "task": "test", "command": "cargo test", "exit_code": 0, "success": true, "stdout": "...", "stderr": "..."} # Pass arguments through to the task command (after `--`) -$ fledge run test -- --nocapture --test-threads=1 +$ fledge run test -- --release --quiet ▶️ Running task: test -# → runs: cargo test --nocapture --test-threads=1 +# → runs: cargo test --release --quiet # Pass a value through (e.g. a version) $ fledge run set-version -- 1.2.3 # → runs: ./set-version.sh 1.2.3 # Pass-through with JSON adds an `args` array to the envelope -$ fledge run test --json -- --nocapture -{"schema_version": 1, "action": "run_task", "task": "test", "command": "cargo test", "exit_code": 0, "success": true, "stdout": "...", "stderr": "...", "args": ["--nocapture"]} +$ fledge run test --json -- --release +{"schema_version": 1, "action": "run_task", "task": "test", "command": "cargo test", "exit_code": 0, "success": true, "stdout": "...", "stderr": "...", "args": ["--release"]} # Override project type $ fledge run --lang node @@ -130,6 +130,7 @@ Available tasks: | Version | Date | Changes | |---------|------|---------| +| 6 | 2026-06-11 | Fix `run --init` generic template emitting an unclosed quote in the commented `# lint = "echo 'add your linter'"` example (uncommenting it made fledge.toml unparseable). Pass-through examples now use flags valid when appended to `cargo test` (`--release`) instead of `--nocapture`, which cargo only accepts after its own `--` separator | | 5 | 2026-06-07 | Add task argument pass-through: `fledge run -- ` forwards args to the target task's command (named task only, not deps). POSIX uses real positional params (`"$@"`, auto-appended unless the command references `$1`/`$@`/…), so values are never interpolated into the command string — no injection surface. `--json` gains an `args` array when args are supplied. Additive and backward-compatible: arg-less runs are byte-identical to before. New `references_positional`/`build_task_command` helpers with unit + injection-safety tests | | 4 | 2026-04-26 | Doc sync, behavioral examples updated to show the post-tier-D envelope shapes for `run --json`, `run --json`, and `run --init --json`. No code change | | 3 | 2026-04-26 | Tier-D 1.0 envelope: all three `--json` paths now emit `{schema_version: 1, action, ...}`. `run --init --json` previously emitted prose ("✅ Created fledge.toml"), now `{action: "run_init", file, project_type, files_created}`, a real fix not just a wrapping. `run --list --json` adds `action: "run_list"` (was bare `{auto_detected, tasks}`). `run --json` adds `action: "run_task"` (was bare `{task, command, ...}`). Three new integration tests guard each shape | diff --git a/specs/work/work.spec.md b/specs/work/work.spec.md index d51f05a..bf3296a 100644 --- a/specs/work/work.spec.md +++ b/specs/work/work.spec.md @@ -1,6 +1,6 @@ --- module: work -version: 14 +version: 15 status: active files: - src/work.rs @@ -27,7 +27,7 @@ Provides opinionated git workflow commands for feature branch development. `fled | `WorkAction` | Enum of subcommands: Start, Commit, Push, Status, DeprecatedPr | | `WorkConfig` | Deserializable config with `branch_format` and `default_type` fields | | `sanitize_branch_name` | Normalizes a string into a valid git branch name (lowercase, hyphens, no leading/trailing hyphens) | -| `build_commit_message` | Builds a conventional-commit message string from type, optional scope, and message body | +| `build_commit_message` | Builds a conventional-commit message string from type, optional scope, and message body. Messages that already carry a conventional prefix are returned verbatim | | `build_branch_name` | (test-only) Constructs a branch name from components using WorkConfig | ### Structs & Enums @@ -72,6 +72,7 @@ Provides opinionated git workflow commands for feature branch development. `fled 9. `--prefix` bypasses type validation and format template, using raw `prefix/name` 10. `--issue N` prepends the issue number to the branch name segment: `N-name` 11. `commit` infers the commit type from the current branch prefix (e.g. `feat/` → `feat`) when `--type` is not provided; falls back to `WorkConfig.default_type` +12. `commit -m` messages that already start with a conventional-commit prefix (`type:`, `type(scope):`, or the breaking `type!:` / `type(scope)!:` variants) are used verbatim — the inferred type is never prepended a second time. The type is matched case-insensitively against the valid branch types plus `style`, `perf`, `test`, `build`, `ci`, and the CorvidLabs-style `add`/`update`/`remove` 13. `commit --all` runs `git add -A` before committing 14. `commit` requires staged changes; bails if nothing is staged (separate message if working tree is clean vs unstaged) 15. `commit` without `-m` or `--ai` prompts interactively via `dialoguer::Input`; non-interactive shells must provide `-m` or `--ai` @@ -151,6 +152,13 @@ $ fledge work commit --all -m "wire up search" feat: wire up search ``` +### work commit — message already conventionally prefixed (used verbatim) +``` +$ fledge work commit --all -m "feat: note change" +✅ Committed e7f8a9b on leif/feat/add-search + feat: note change +``` + ### work commit — AI-generated message ``` $ fledge work commit --ai @@ -302,6 +310,7 @@ $ fledge work status --json | Version | Date | Changes | |---------|------|---------| +| 15 | 2026-06-11 | `commit` no longer double-prefixes the conventional type: `-m` messages that already start with `type:` / `type(scope):` (case-insensitive, incl. breaking `!` variants and CorvidLabs-style `Add:`/`Update:`/`Remove:`) are committed verbatim instead of becoming e.g. `feat: feat: …`. New invariant 12; `has_conventional_prefix` helper added | | 14 | 2026-06-03 | Document `WorkConfig` in the export table to satisfy strict spec-sync validation | | 12 | 2026-05-01 | **Security:** `commit --ai --scope ` now validates `` against `[A-Za-z0-9_-]{1,64}` before interpolating it into the LLM prompt or commit message. Scopes containing whitespace, shell metacharacters, template syntax, or anything that could be read as instructions to the model are rejected at the boundary with a clear error | | 11 | 2026-04-30 | Pure git split: removed `pr` subcommand (moved to `fledge-plugin-github`), added `commit` and `push` subcommands with `--ai` support and conventional-commit formatting. `status` drops PR info, adds `dirty` count, bumps schema to v2. `generate_body_from_commits` removed; `build_commit_message` added | diff --git a/src/changelog.rs b/src/changelog.rs index 9494004..03f5cd5 100644 --- a/src/changelog.rs +++ b/src/changelog.rs @@ -217,6 +217,16 @@ fn build_release(tag: &str, date: &str, prev: Option<&str>) -> Result { }) } +/// Case-insensitive `str::strip_prefix` for ASCII prefixes. +fn strip_prefix_ignore_case<'msg>(msg: &'msg str, prefix: &str) -> Option<&'msg str> { + let head = msg.get(..prefix.len())?; + if head.eq_ignore_ascii_case(prefix) { + Some(&msg[prefix.len()..]) + } else { + None + } +} + fn classify_commit(msg: &str) -> (String, String) { let prefixes = [ ("feat", "Features"), @@ -229,19 +239,29 @@ fn classify_commit(msg: &str) -> (String, String) { ("build", "Build"), ("ci", "CI"), ("chore", "Chores"), + // CorvidLabs-style prefixes (`Add:`, `Update:`, `Remove:`) — matched + // case-insensitively like every other prefix. + ("add", "Features"), + ("update", "Changes"), + ("remove", "Removals"), ]; for (prefix, label) in &prefixes { - if let Some(rest) = msg.strip_prefix(prefix) { - if let Some(rest) = rest.strip_prefix(':') { - return (label.to_string(), rest.trim().to_string()); - } - if let Some(rest) = rest.strip_prefix('(') { - if let Some(after_scope) = rest.find("):") { - return ( - label.to_string(), - rest[after_scope + 2..].trim().to_string(), - ); + let Some(rest) = strip_prefix_ignore_case(msg, prefix) else { + continue; + }; + // Optional breaking-change marker: `type!:`. + let bare = rest.strip_prefix('!').unwrap_or(rest); + if let Some(after) = bare.strip_prefix(':') { + return (label.to_string(), after.trim().to_string()); + } + // Scoped form: `type(scope):` or `type(scope)!:`. + if let Some(scoped) = rest.strip_prefix('(') { + if let Some(close) = scoped.find(')') { + let tail = &scoped[close + 1..]; + let tail = tail.strip_prefix('!').unwrap_or(tail); + if let Some(after) = tail.strip_prefix(':') { + return (label.to_string(), after.trim().to_string()); } } } @@ -319,4 +339,54 @@ mod tests { assert_eq!(kind, "Fixes"); assert_eq!(msg, "handle edge case"); } + + #[test] + fn classify_case_insensitive() { + let (kind, msg) = classify_commit("Fix: broken link in README"); + assert_eq!(kind, "Fixes"); + assert_eq!(msg, "broken link in README"); + + let (kind, msg) = classify_commit("Feat: new command"); + assert_eq!(kind, "Features"); + assert_eq!(msg, "new command"); + + let (kind, _) = classify_commit("FIX(parser): uppercase scope"); + assert_eq!(kind, "Fixes"); + } + + #[test] + fn classify_corvidlabs_styles() { + let cases = vec![ + ("Add: search command", "Features", "search command"), + ("Fix: null pointer", "Fixes", "null pointer"), + ("Update: dependency pins", "Changes", "dependency pins"), + ("Remove: dead code", "Removals", "dead code"), + ("Refactor: extract helper", "Refactoring", "extract helper"), + ]; + for (input, expected_kind, expected_msg) in cases { + let (kind, msg) = classify_commit(input); + assert_eq!(kind, expected_kind, "failed for input: {input}"); + assert_eq!(msg, expected_msg, "failed for input: {input}"); + } + } + + #[test] + fn classify_breaking_change_marker() { + let (kind, msg) = classify_commit("feat!: drop legacy config"); + assert_eq!(kind, "Features"); + assert_eq!(msg, "drop legacy config"); + + let (kind, msg) = classify_commit("fix(core)!: change defaults"); + assert_eq!(kind, "Fixes"); + assert_eq!(msg, "change defaults"); + } + + #[test] + fn classify_prefix_requires_separator() { + // A prefix word without `:` or `(scope):` is not conventional. + let (kind, _) = classify_commit("Update readme"); + assert_eq!(kind, "Other"); + let (kind, _) = classify_commit("fixes: plural is not a type"); + assert_eq!(kind, "Other"); + } } diff --git a/src/cli.rs b/src/cli.rs index 0934760..f71a4fb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -198,7 +198,7 @@ pub enum Commands { #[arg(long)] json: bool, /// Arguments passed through to the task's command, after a `--` - /// separator. Example: `fledge run test -- --nocapture` or + /// separator. Example: `fledge run test -- --release` or /// `fledge run set-version -- 1.2.3`. They are appended to the /// command (or fill `$1`, `$@` if it references them) and never /// spliced into the command string. diff --git a/src/lanes/defaults.rs b/src/lanes/defaults.rs index 810d71a..d9df9a0 100644 --- a/src/lanes/defaults.rs +++ b/src/lanes/defaults.rs @@ -123,7 +123,7 @@ pub(crate) fn init_lanes(json: bool) -> Result<()> { style("✅").green().bold(), style("fledge.toml").cyan() ); - println!(" Run {} to see them.", style("fledge lane").cyan()); + println!(" Run {} to see them.", style("fledge lanes list").cyan()); } Ok(()) } diff --git a/src/release/changelog.rs b/src/release/changelog.rs index 964f438..bfa0233 100644 --- a/src/release/changelog.rs +++ b/src/release/changelog.rs @@ -102,11 +102,24 @@ pub(super) fn classify_for_changelog(msg: &str) -> &'static str { ("ci", "CI"), ("chore", "Chores"), ("style", "Style"), + // CorvidLabs-style prefixes (`Add:`, `Update:`, `Remove:`) — matched + // case-insensitively like every other prefix. + ("add", "Features"), + ("update", "Changes"), + ("remove", "Removals"), ]; for (prefix, label) in &prefixes { - if msg.starts_with(prefix) && msg[prefix.len()..].starts_with([':', '(']) { - return label; + let Some(head) = msg.get(..prefix.len()) else { + continue; + }; + if head.eq_ignore_ascii_case(prefix) { + // Optional breaking-change marker: `type!:`. + let rest = &msg[prefix.len()..]; + let rest = rest.strip_prefix('!').unwrap_or(rest); + if rest.starts_with([':', '(']) { + return label; + } } } @@ -117,6 +130,7 @@ pub(super) fn strip_conventional_prefix(msg: &str) -> &str { if let Some(colon_pos) = msg.find(':') { let prefix = &msg[..colon_pos]; let after = msg[colon_pos + 1..].trim_start(); + let prefix = prefix.strip_suffix('!').unwrap_or(prefix); let base = if let Some(paren) = prefix.find('(') { &prefix[..paren] } else { @@ -124,8 +138,9 @@ pub(super) fn strip_conventional_prefix(msg: &str) -> &str { }; let known = [ "feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", + "add", "update", "remove", ]; - if known.contains(&base) { + if known.iter().any(|k| base.eq_ignore_ascii_case(k)) { return after; } } diff --git a/src/release/tests.rs b/src/release/tests.rs index 60527c6..22654c7 100644 --- a/src/release/tests.rs +++ b/src/release/tests.rs @@ -250,6 +250,64 @@ fn classify_conventional_commits() { assert_eq!(changelog::classify_for_changelog("random message"), "Other"); } +#[test] +fn classify_case_insensitive_and_corvidlabs_styles() { + assert_eq!( + changelog::classify_for_changelog("Fix: broken link"), + "Fixes" + ); + assert_eq!( + changelog::classify_for_changelog("Add: search command"), + "Features" + ); + assert_eq!( + changelog::classify_for_changelog("Update: dependency pins"), + "Changes" + ); + assert_eq!( + changelog::classify_for_changelog("Remove: dead code"), + "Removals" + ); + assert_eq!( + changelog::classify_for_changelog("Refactor: extract helper"), + "Refactoring" + ); + assert_eq!( + changelog::classify_for_changelog("FIX(parser): uppercase scope"), + "Fixes" + ); + assert_eq!( + changelog::classify_for_changelog("feat!: breaking change"), + "Features" + ); + // A prefix word without `:` or `(` stays unclassified. + assert_eq!(changelog::classify_for_changelog("Update readme"), "Other"); +} + +#[test] +fn strip_prefix_case_insensitive_and_corvidlabs_styles() { + assert_eq!( + changelog::strip_conventional_prefix("Fix: broken link"), + "broken link" + ); + assert_eq!( + changelog::strip_conventional_prefix("Add: search command"), + "search command" + ); + assert_eq!( + changelog::strip_conventional_prefix("Update: dependency pins"), + "dependency pins" + ); + assert_eq!( + changelog::strip_conventional_prefix("Remove: dead code"), + "dead code" + ); + assert_eq!( + changelog::strip_conventional_prefix("feat!: breaking change"), + "breaking change" + ); +} + #[test] fn strip_prefix_simple() { assert_eq!( diff --git a/src/run.rs b/src/run.rs index 64a938b..fa43157 100644 --- a/src/run.rs +++ b/src/run.rs @@ -431,9 +431,9 @@ lint = "mvn checkstyle:check""# test = "swift test" # lint = "swiftlint" # uncomment if swiftlint is installed"# .to_string(), - _ => r#"# build = "make build" + _ => r##"# build = "make build" # test = "make test" -# lint = "echo 'add your linter'"# +# lint = "echo 'add your linter'""## .to_string(), } } @@ -888,6 +888,32 @@ dir = "client" } } + #[test] + fn task_defaults_are_valid_toml_when_uncommented() { + // Commented-out example tasks (`# lint = "..."`) must stay valid TOML + // once the user uncomments them. + let dir = tempfile::tempdir().unwrap(); + for project_type in &["python", "swift", "generic"] { + let defaults = task_defaults(project_type, dir.path()); + let uncommented: String = defaults + .lines() + .map(|line| { + let line = line.strip_prefix("# ").unwrap_or(line); + format!("{line}\n") + }) + .collect(); + let toml_str = format!("[tasks]\n{}", uncommented); + let result: Result = toml::from_str(&toml_str); + assert!( + result.is_ok(), + "Invalid TOML for {} after uncommenting: {:?}\n{}", + project_type, + result.err(), + toml_str + ); + } + } + #[test] fn task_defaults_bun_project() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/snapshots/fledge__introspect__tests__introspect_schema.snap b/src/snapshots/fledge__introspect__tests__introspect_schema.snap index f227b5d..6367de3 100644 --- a/src/snapshots/fledge__introspect__tests__introspect_schema.snap +++ b/src/snapshots/fledge__introspect__tests__introspect_schema.snap @@ -1844,7 +1844,7 @@ expression: output }, { "name": "args", - "help": "Arguments passed through to the task's command, after a `--` separator. Example: `fledge run test -- --nocapture` or `fledge run set-version -- 1.2.3`. They are appended to the command (or fill `$1`, `$@` if it references them) and never spliced into the command string", + "help": "Arguments passed through to the task's command, after a `--` separator. Example: `fledge run test -- --release` or `fledge run set-version -- 1.2.3`. They are appended to the command (or fill `$1`, `$@` if it references them) and never spliced into the command string", "required": false, "takes_value": true, "value_name": "ARGS", diff --git a/src/work.rs b/src/work.rs index 4d8f60e..dc1c783 100644 --- a/src/work.rs +++ b/src/work.rs @@ -9,6 +9,15 @@ const VALID_BRANCH_TYPES: &[&str] = &[ "feat", "feature", "fix", "bug", "chore", "task", "docs", "hotfix", "refactor", ]; +/// Commit types recognized as an existing conventional-commit prefix on a +/// `-m` message: the valid branch types plus the remaining standard +/// conventional-commit types and the capitalized `Add:`/`Update:`/`Remove:` +/// style used across CorvidLabs repos. Matching is case-insensitive. +const CONVENTIONAL_COMMIT_TYPES: &[&str] = &[ + "feat", "feature", "fix", "bug", "chore", "task", "docs", "hotfix", "refactor", "style", + "perf", "test", "build", "ci", "add", "update", "remove", +]; + /// Per-command JSON schema versions for `work` subcommands. See lanes.rs for /// rationale. const WORK_START_SCHEMA: u32 = 1; @@ -614,8 +623,33 @@ fn commits_ahead_of(branch: &str, base: &str) -> Result { Ok(output.parse().unwrap_or(0)) } +/// Does the message already start with a conventional-commit prefix, i.e. +/// `type:`, `type(scope):`, or a breaking-change variant (`type!:`, +/// `type(scope)!:`) where `type` is a known commit type (case-insensitive)? +fn has_conventional_prefix(message: &str) -> bool { + let Some(colon) = message.find(':') else { + return false; + }; + let head = &message[..colon]; + let head = head.strip_suffix('!').unwrap_or(head); + let base = match head.find('(') { + Some(open) if head.ends_with(')') => &head[..open], + Some(_) => return false, + None => head, + }; + CONVENTIONAL_COMMIT_TYPES + .iter() + .any(|known| base.eq_ignore_ascii_case(known)) +} + pub fn build_commit_message(commit_type: &str, scope: Option<&str>, message: &str) -> String { - let mut chars = message.trim().chars(); + let trimmed = message.trim(); + // Already conventional-commit formatted — use it verbatim instead of + // double-prefixing (e.g. `feat: feat: ...`). + if has_conventional_prefix(trimmed) { + return trimmed.to_string(); + } + let mut chars = trimmed.chars(); let msg = match chars.next() { Some(c) => c.to_lowercase().to_string() + chars.as_str(), None => String::new(), @@ -888,4 +922,64 @@ test = "cargo test" "fix(ui): fix padding" ); } + + #[test] + fn test_build_commit_message_already_prefixed() { + assert_eq!( + build_commit_message("feat", None, "feat: note change"), + "feat: note change" + ); + assert_eq!( + build_commit_message("feat", None, "fix: handle empty branch"), + "fix: handle empty branch" + ); + } + + #[test] + fn test_build_commit_message_already_prefixed_with_scope() { + assert_eq!( + build_commit_message("feat", Some("cli"), "fix(parser): handle empty input"), + "fix(parser): handle empty input" + ); + } + + #[test] + fn test_build_commit_message_already_prefixed_case_insensitive() { + assert_eq!( + build_commit_message("feat", None, "Fix: broken link"), + "Fix: broken link" + ); + assert_eq!( + build_commit_message("fix", None, "Add: search command"), + "Add: search command" + ); + assert_eq!( + build_commit_message("feat", None, "Update: dependency pins"), + "Update: dependency pins" + ); + } + + #[test] + fn test_build_commit_message_already_prefixed_breaking() { + assert_eq!( + build_commit_message("feat", None, "feat!: drop legacy config"), + "feat!: drop legacy config" + ); + assert_eq!( + build_commit_message("feat", None, "fix(core)!: change defaults"), + "fix(core)!: change defaults" + ); + } + + #[test] + fn test_build_commit_message_non_type_colon_still_prefixed() { + assert_eq!( + build_commit_message("feat", None, "note: change"), + "feat: note: change" + ); + assert_eq!( + build_commit_message("feat", None, "support http: and https: URLs"), + "feat: support http: and https: URLs" + ); + } } diff --git a/tests/lanes.rs b/tests/lanes.rs index 35abe3e..99fb4bc 100644 --- a/tests/lanes.rs +++ b/tests/lanes.rs @@ -106,6 +106,13 @@ fn cli_lane_init_adds_default_lanes() { assert!(output.status.success()); let content = fs::read_to_string(tmp.path().join("fledge.toml")).unwrap(); assert!(content.contains("[lanes")); + // The follow-up hint must reference a command that actually exists + // (`fledge lane` without a subcommand exits with a usage error). + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.contains("fledge lanes list"), + "init hint should point at `fledge lanes list`, got:\n{stdout}" + ); } #[test]