Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
85e48ee
refactor: single helper for open release/hotfix branch enumeration (M3)
jopmiddelkamp Jul 29, 2026
ca2c713
fix: deterministic order from GitCli::list_branches_matching (H4)
jopmiddelkamp Jul 29, 2026
40412b3
refactor: branch names from SemVer methods, not hand-concatenation (H1)
jopmiddelkamp Jul 29, 2026
fa3eacc
refactor: extract latest_rc helper in finish_release (M4)
jopmiddelkamp Jul 29, 2026
61a3916
refactor: drop dead blind-stash trait methods, demote rev_parse (M1)
jopmiddelkamp Jul 29, 2026
d999d0c
refactor: single editor_disabled rule in worktree (L5)
jopmiddelkamp Jul 29, 2026
50ebae5
refactor: shared PR-body precedence in hosting/mod.rs (L6)
jopmiddelkamp Jul 29, 2026
0283637
fix: GitHub adapter no longer swallows real 'gh pr view' failures (L3)
jopmiddelkamp Jul 29, 2026
7aeac33
refactor: rename Git::pull to Git::ff_merge (L1)
jopmiddelkamp Jul 29, 2026
2b9f715
refactor: RAII terminal guard replaces hand-written raw-mode cleanup …
jopmiddelkamp Jul 29, 2026
4415473
refactor: extract idempotent finish-step helpers into flows/mod.rs (M2)
jopmiddelkamp Jul 29, 2026
34b28d6
refactor: single source of truth for the work-branch taxonomy (H2, M5)
jopmiddelkamp Jul 29, 2026
5f23055
refactor: move Action and validate_branch_name to src/action.rs (M6)
jopmiddelkamp Jul 29, 2026
ae1c38e
refactor: derive finish-state writing from finish_identity (L7)
jopmiddelkamp Jul 29, 2026
b894395
refactor: one derivation of 'worktree implies no-checkout' in flows (L4)
jopmiddelkamp Jul 29, 2026
f758992
refactor: resolve PR templates at the composition root (M7)
jopmiddelkamp Jul 29, 2026
51f9a56
refactor: Prompter port removes menu calls from business flows (H3)
jopmiddelkamp Jul 29, 2026
31c16ca
refactor: shared tmp_dir helper for inline tests (L8)
jopmiddelkamp Jul 29, 2026
22ff34e
refactor: move the crash-safety lifecycle into the library (DC1)
jopmiddelkamp Jul 29, 2026
d22e971
chore: silence per-crate dead_code noise in tests/common
jopmiddelkamp Jul 29, 2026
ed43cff
docs: sync README architecture map and skill docs with the refactor
jopmiddelkamp Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .claude/skills/architecture/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Broad, global rules only. Implementation-level decisions, extension recipes, and

## Principles

1. **Ports & adapters.** Business logic lives in `flows/` and depends only on traits (`Git`, `HostingPlatform`, `Editor`). Concrete adapters are wired in `main.rs` — the only composition root. No subprocess calls outside adapter impls.
1. **Ports & adapters.** Business logic lives in `flows/` and depends only on traits (`Git`, `HostingPlatform`, `Editor`, `Prompter`). Concrete adapters are wired in `main.rs` — the only composition root. No subprocess calls outside adapter impls.
2. **Plug-and-play integrations.** Hosting providers are auto-detected from the remote and swappable; adding a provider or editor never touches flows.
3. **Integrate through the user's own CLIs** (`git`, `gh`, `az`) — never link libraries or call APIs directly. bflow inherits the user's auth, config, hooks, and signing.
4. **Minimal dependency budget.** Two crates today. A new dependency needs a very good reason; prefer hand-rolling small things.
Expand All @@ -26,8 +26,9 @@ Broad, global rules only. Implementation-level decisions, extension recipes, and

| Module | Responsibility |
|---|---|
| `main.rs` | Composition root: builds adapters, preflight, cross-cutting lifecycle (stash/state/resume), dispatches `Action` once |
| `cli.rs` / `menu.rs` | The two interfaces; both resolve to `Action` |
| `main.rs` | Composition root: builds adapters, preflight, hands off to `lifecycle::run` |
| `lifecycle.rs` | Cross-cutting lifecycle (stash/state/resume ordering), dispatches `Action` once |
| `cli.rs` / `menu.rs` | The two interfaces; both resolve to `Action` (`action.rs`) |
| `flows/` | Business logic per workflow |
| `git/`, `hosting/`, `editor.rs` | Ports (traits) + adapters (CLI impls); `hosting/detect.rs` picks the provider |
| `git/`, `hosting/`, `editor.rs`, `prompt.rs` | Ports (traits) + adapters (CLI impls); `hosting/detect.rs` picks the provider |
| `state.rs`, `worktree.rs`, `version.rs` | Finish state, worktree feature, SemVer |
6 changes: 4 additions & 2 deletions .claude/skills/architecture/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Catalog of deliberate choices — each entry: the choice, why, and the rejected
- **New git operation:** add method to `Git` trait → impl in `GitCli` → add to `MockGit` in `tests/common/mod.rs`.
- **New hosting provider:** new file in `hosting/` implementing `HostingPlatform` → `Provider` variant + parse arm in `detect.rs` → preflight arm in `main.rs::create_hosting`. Reuse `hosting::run_cli`.
- **New command/flow:** clap variant in `cli.rs` → `Action` variant + menu entry gated by `BranchType` → flow fn in `flows/` taking `&dyn` deps → dispatch arm in `run_flow`.
- **New work-branch type** (e.g. `perf/`): variant on `BranchType` → entry in the `WORK_TYPES` table + arm in `work_kind` in `git/branch.rs` (the compiler forces both — `work_kind` has no wildcard) → clap `StartKind` variant + arms in `cli.rs::resolve_action` → `pr_template_keys` arm. Menus, parent-branch detection, and `commit_type` (kind name, unless special-cased like feature→feat) follow from the table automatically.

## Plug-in Points

Expand All @@ -15,6 +16,7 @@ Catalog of deliberate choices — each entry: the choice, why, and the rejected
| `Git` | `GitCli`; `MockGit` in tests | Built once in `main.rs` |
| `HostingPlatform` | `GitHub` (`gh`), `AzureDevOps` (`az`); `MockHosting` | Auto-detected from origin remote URL (`hosting/detect.rs`) |
| `Editor` | `CommandEditor` (any command); `MockEditor` | `bflow.worktree.editor` git config |
| `Prompter` | `MenuPrompter` (interactive select); `MockPrompter` (scripted) | Built once in `main.rs` |

User-configurable without code changes: the worktree flow (`bflow.worktree.*` git config, local/global scope, `bflow worktree` wizard) and PR templates (`.github/pr-templates/bflow-<key>.md`, most-specific-first, falling back to the repo's native template).

Expand Down Expand Up @@ -70,15 +72,15 @@ User-configurable without code changes: the worktree flow (`bflow.worktree.*` gi
- **`Git` is ~45 fine-grained primitives** so ordering logic lives in flows and mocks can record exact sequences. Rejected: coarse `finish_release()`-style methods (untestable ordering).
- **`HostingPlatform` stays minimal (3 methods)**: `create_or_get_pr` is deliberately one method ("PR already open" is a normal resume outcome and the check-then-create dance is provider-specific); `open_url` is a default method (provider-agnostic, but on the trait as a test seam); the `template` param is a *path* (only `az` needs the contents read; `gh` takes `--body-file`).
- **Detection: pure core, thin shell.** `resolve`/`parse_remote` are pure functions unit-tested without a repo (same pattern in `template.rs` and `devops.rs` helpers). Unknown host → GitHub (preserves pre-detection behavior for GitLab/Enterprise users), but an *explicit* `devops` override with an unparseable remote is a hard error — asymmetric on purpose. `Provider::AzureDevOps` carries `{org, project, repo}` so detection and construction are one step. ADO PR URLs are synthesized from parsed coordinates (`az`'s `webUrl` is unreliable); `validate_pr_id` rejects `az`'s literal `"None"`.
- **Library crate + thin binary exists solely so `tests/` can link the flows.** Consequence: visibility falls exactly on the test boundary — `pub mod` at the top, tight function-level privacy inside (`run_cli`, parsers, runners all private). Orchestration (ordering, stash, state lifecycle) stays in `main.rs`, not the library.
- **Library crate + thin binary exists solely so `tests/` can link the flows.** Consequence: visibility falls exactly on the test boundary — `pub mod` at the top, tight function-level privacy inside (`run_cli`, parsers, runners all private). Orchestration (ordering, stash, state lifecycle) lives in `lifecycle::run` in the library, taking `&dyn` ports, so the reject → stash → write-state → dispatch contract and the three-way stash-pop policy are mock-tested (`tests/lifecycle_test.rs`); `main.rs` keeps only adapter construction, preflight, and the `bflow worktree` dispatch. *(Amended: this previously read "orchestration stays in main.rs" — that made the state-before-mutation invariant untestable, and a resume/`--base` bug slipped through this layer.)*
- **PR templates are repo files** (`.github/pr-templates/bflow-<key>.md`, specific → group → default → the repo's own native template → empty). The fix family shares one `bflow-fix.md` group key. Native-template path lists are per-provider knowledge, intentionally not shared.

## Testing Strategy

- **Call-recording mock: one `Vec<String>` of `format!`-encoded calls; assertions are exact sequences** (23-element scripts in `finish_release_test.rs`) — a reorder of merge-before-tag is a test failure. Strings, not a typed `Call` enum: expectations read as a runnable script and diff legibly.
- **One configurable `MockGit` with knob fields (state axes of git: what exists, what's merged, what's pushed), not a mock family.** Targeted failure injection (`fail_nth_merge` distinguishes "main merge conflicted" from "develop merge conflicted"); `RefCell` interior mutability keeps the trait `&self` so test bookkeeping never infects production signatures. Stateful where flows read back their own writes (stash save→find), dumb constants where nothing branches on the value.
- **Negative assertions prove guards fire before damage** (no `checkout:main` in the log after a gate error; `rev_list_count_result = 99` proves the RC gate didn't even run on resume). Idempotent-resume tests build a fully-completed world and assert *zero* mutating calls.
- **No real git anywhere in tests** (flows push/delete/PR — a sandbox would need a fake remote and stubs, and be slow on Windows CI). The one filesystem exception is `state.rs` round-trip tests. Tests split by *reachability*, not dogma: public behavior in `tests/` (one file per flow), private logic inline `#[cfg(test)]`. Scenario builders stay local per test file (DAMP over DRY); the tiny `stash_test.rs` deliberately pins the mock's own recording contract.
- **No real git anywhere in tests** (flows push/delete/PR — a sandbox would need a fake remote and stubs, and be slow on Windows CI). The one filesystem exception is `state.rs` round-trip tests. Tests split by *reachability*, not dogma: public behavior in `tests/` (one file per flow), private logic inline `#[cfg(test)]`. Scenario builders stay local per test file (DAMP over DRY); the tiny `mock_contract_test.rs` deliberately pins the mock's own recording contract.

## Delivery & CI

Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -568,8 +568,11 @@ jobs:

```
src/
├── main.rs — Entry point, preflight checks, dispatch
├── main.rs — Composition root: builds adapters, preflight, hands off to lifecycle
├── lib.rs — Library root, re-exports all modules
├── action.rs — Action enum: the single currency both interfaces resolve into
├── cli.rs — CLI subcommands (clap); resolves to Action
├── lifecycle.rs — Resume lookup, stash/state ordering contract, dispatch
├── git/
│ ├── mod.rs — Git trait + CLI implementation
│ └── branch.rs — Branch type detection and parsing
Expand All @@ -585,12 +588,14 @@ src/
│ └── finish_hotfix.rs — Finish hotfix with auto-tag, propagate to open releases (idempotent)
├── state.rs — Persisted finish state for conflict recovery
├── version.rs — SemVer parsing and bumping
├── menu.rs — Interactive menus via crossterm
├── menu.rs — Interactive menus via crossterm; implements Prompter
├── prompt.rs — Prompter trait: interactive selection as a port
├── editor.rs — Editor trait for opening worktrees
└── worktree.rs — Worktree config, path resolution, and setup wizard
├── worktree.rs — Worktree config, path resolution, and setup wizard
└── test_support.rs — Shared helpers for inline unit tests (test builds only)
```

The `Git` and `HostingPlatform` traits enable multiple hosting providers (GitHub and Azure DevOps today, GitLab/Bitbucket-ready) and testability.
The `Git`, `HostingPlatform`, `Editor`, and `Prompter` traits keep every flow fully mockable and make hosting providers swappable (GitHub and Azure DevOps today, GitLab/Bitbucket-ready).

## License

Expand Down
75 changes: 75 additions & 0 deletions src/action.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//! The single currency both interfaces resolve into: the menu (`menu.rs`) and
//! the CLI subcommands (`cli.rs`) each produce an `Action`, and nothing
//! downstream knows which interface ran. Lives in its own module so neither
//! interface has to import the other.

use crate::flows::start::ReleaseType;

#[derive(Debug, PartialEq)]
pub enum Action {
StartWorkBranch { prefix: String, name: String, from: String, no_checkout: bool, no_worktree: bool },
StartRelease(Option<ReleaseType>),
StartReleaseFix { name: String, no_checkout: bool, no_worktree: bool },
StartHotfixFix { name: String, no_checkout: bool, no_worktree: bool },
FinishWorkBranch { breaking: Option<bool>, base: Option<String> },
FinishReleaseFix,
FinishRelease,
FinishHotfix,
FinishHotfixFix,
AbortFinish,
BumpVersion,
SyncWithDevelop,
}

impl Action {
pub fn is_start(&self) -> bool {
matches!(
self,
Action::StartWorkBranch { .. }
| Action::StartRelease(_)
| Action::StartReleaseFix { .. }
| Action::StartHotfixFix { .. }
)
}

pub fn no_checkout(&self) -> bool {
match self {
Action::StartWorkBranch { no_checkout, .. } => *no_checkout,
Action::StartReleaseFix { no_checkout, .. } => *no_checkout,
Action::StartHotfixFix { no_checkout, .. } => *no_checkout,
_ => false,
}
}

/// Whether this action is a named-work-branch start eligible for the worktree
/// flow. Deliberately excludes `StartRelease`, unlike `is_start`.
pub fn worktree_eligible(&self) -> bool {
matches!(
self,
Action::StartWorkBranch { .. }
| Action::StartReleaseFix { .. }
| Action::StartHotfixFix { .. }
)
}

pub fn no_worktree(&self) -> bool {
match self {
Action::StartWorkBranch { no_worktree, .. } => *no_worktree,
Action::StartReleaseFix { no_worktree, .. } => *no_worktree,
Action::StartHotfixFix { no_worktree, .. } => *no_worktree,
_ => false,
}
}
}

/// Branch-name validation shared by the CLI (`--name`) and the interactive
/// prompt — same rules, same message, whichever interface ran.
pub fn validate_branch_name(input: &str) -> Result<(), String> {
if input.is_empty() {
return Err("Name cannot be empty".to_string());
}
if input.contains("..") || input.contains('~') || input.contains('^') || input.contains(':') || input.contains('\\') {
return Err("Invalid branch name. Avoid special characters (.. ~ ^ : \\)".to_string());
}
Ok(())
}
8 changes: 4 additions & 4 deletions src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use clap::{Args, Subcommand};
use crate::git::branch::BranchType;
use crate::flows::start::ReleaseType;
use crate::menu::{self, Action};
use crate::action::{validate_branch_name, Action};

#[derive(Subcommand)]
pub enum Commands {
Expand Down Expand Up @@ -141,7 +141,7 @@ pub enum StartKind {
}

fn start_work_branch(prefix: &str, name: String, base: String, no_checkout: bool, no_worktree: bool) -> Result<Action, String> {
menu::validate_branch_name(&name)?;
validate_branch_name(&name)?;
Ok(Action::StartWorkBranch { prefix: prefix.to_string(), name, from: base, no_checkout, no_worktree })
}

Expand Down Expand Up @@ -180,14 +180,14 @@ pub fn resolve_action(command: Commands, branch_type: &BranchType, worktree_enab
Ok(Action::StartRelease(release_type))
}
StartKind::ReleaseFix { name, opts } => {
menu::validate_branch_name(&name)?;
validate_branch_name(&name)?;
if !auto_discovers_target(&opts, worktree_enabled) {
require_release_branch(branch_type)?;
}
Ok(Action::StartReleaseFix { name, no_checkout: opts.no_checkout, no_worktree: opts.no_worktree })
}
StartKind::HotfixFix { name, opts } => {
menu::validate_branch_name(&name)?;
validate_branch_name(&name)?;
if !auto_discovers_target(&opts, worktree_enabled)
&& !matches!(branch_type, BranchType::Main | BranchType::Hotfix { .. })
{
Expand Down
Loading
Loading