Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion .claude/skills/architecture/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ User-configurable without code changes: the worktree flow (`bflow.worktree.*` gi

- **Repo policy lives in a committed `.bflow/config` file, not git config.** `mode` (`free`|`protected`) and `keep-release-branches` are team decisions a fresh clone must see identically; git config is per-clone and would silently drift on either one — the same fresh-clone argument that keeps the version script itself a repo file rather than a personal setting. Parsed by a pure `repo_config::parse`, read once (`repo_config::load`) and threaded into flows as data — same shape as `hosting/detect.rs` and `mainline::resolve_main_branch`, never a mid-flow lookup. Rejected: git config keys for `mode`/`keep-release-branches` — drift here changes what `finish` *does* (merge vs. PR, delete vs. keep), not just a cosmetic setting.
- **`VersionScript` is a port with a pure `interpret` and a zero-policy spawn**, the same split as `CommandEditor::open`: `ScriptCli::run` has nothing to decide (spawn, capture exit code + stderr), so the outcome policy — exit 0 is success, any other code carries the script path/exit code/stderr, a signal names itself — lives entirely in `interpret`, a pure function unit-tested directly without a process. Discovery (`version_script::resolve`) is likewise pure, parametrized on `windows: bool` rather than reading `cfg!` internally, so both platforms are unit-tested on one OS. Resolved once, eagerly, in `main.rs`: a repo carrying only the other platform's script file errors on *every* command, not just the ones that would run it. Accepted as louder than deferring the error into the flow that needs it — the alternative is plumbing an `Err` through the lifecycle for a case that only ever means "a committed file is broken" (same spirit as the accepted mainline-resolution-on-abort gap in Known Gaps).
- **Checkout-less hotfix creation runs the version script in an ephemeral worktree** (`run_version_script_in_temp_worktree`, `flows/mod.rs` — `run_version_script`'s sibling, same script→clean-check→stage→commit rule via the `_at` primitives). The invariant "release/hotfix branches carry their version" holds in every mode; the temp worktree (`worktree_path`-disjoint `temp_worktree_path`, `bflow.worktree.*` ignored — internal plumbing) is removed on success and failure alike, and the branch's *own* script copy runs with cwd there (`VersionScript::run_in` — content belongs to the tree it applies to). A failing script falls back to warn-and-continue with manual recovery (M2 precedent): a broken script must never block a hotfix. This supersedes mutation-audit trap 9's "the script must never run without a checkout" — replaced spec test: `hotfix_no_checkout_runs_script_in_temp_worktree`. Rejected: keeping the skip+warning (a manual step inside incident response, contradicting principle 10); a hard error (a repo with worktree mode plus a version script could not start hotfixes at all).
- **Protected-mode landing progress is derived, never stored.** No `FinishState` for a protected finish/sync — progress is re-derived from `HostingPlatform::merged_pr_to` plus git state on every call, never from a file. `sync_with_develop_protected` uses `landed_pr`, which compares `branch_sha` (a branch that moved on after its PR merged is not landed; new commits get a fresh PR); `finish_release_protected`/`finish_hotfix_protected` use `leg_landed` instead — see the next entry for why the two differ. Precedent: work-branch finish already treats "a merged PR" as the completion record instead of writing state. Rejected: a state file — a protected finish never merges locally, so there is no half-done local mutation for a crash to leave behind that git+PR state doesn't already answer; a state file would be a second, driftable copy of what the hosting platform already knows.
- **"Landed" is two questions, not one.** *Has this leg landed at least once?* (`leg_landed`, `flows/mod.rs`) is answered by the leg's merged PR's merge commit being contained in the target (`is_ancestor` against `origin/{target}`) — stays true once the source branch gains commits afterwards, which is what lets `finish_release_protected`/`finish_hotfix_protected` resume past a leg that already completed instead of dead-ending or silently re-opening its PR. *Is anything left on this branch to delete?* (`tip_landed_somewhere`) is answered by the branch's current tip matching one of the landed legs' PR head SHAs — squash merges leave no ancestry to fall back on, so this is what stops `finish_release_cleanup`/`finish_hotfix_cleanup` from deleting commits a squash merge left in no target. Rejected: one predicate for both — head-SHA equality alone (`landed_pr`'s rule) dead-ends the finish the moment a later leg's conflict-resolution commit lands on the source branch; containment alone would delete commits sitting on the tip that never actually landed anywhere. `sync` deliberately keeps `landed_pr`'s head-SHA equality: a release with new commits after a sync landed genuinely does need syncing again, so staleness there must reopen work, not be waved through.
- **Tag-mainline containment is checked before the main leg's PR lookup is trusted for tagging.** `finish_release_protected`/`finish_hotfix_protected` test `is_ancestor(tag_commit, origin/{main})` before deciding what to do with a fresh `leg_landed` result for the main leg — otherwise a superseded/re-opened main PR would make `tag_at_if_missing` compare an already-correct tag against the *wrong* PR's merge commit and reject it. Accepted narrowing: a hand-placed tag that already sits on any mainline-contained commit is taken as landed without tracing it back to a specific PR. Under squash merges — the normal case — a wrong tag's commit is never actually on the mainline, so `tag_at_if_missing`'s identity check still catches the realistic mistake.
Expand All @@ -93,7 +94,7 @@ User-configurable without code changes: the worktree flow (`bflow.worktree.*` gi

## Boundaries & Extensibility

- **`Git` is 48 fine-grained primitives** so ordering logic lives in flows and mocks can record exact sequences. Rejected: coarse `finish_release()`-style methods (untestable ordering). One deliberate exception to cwd-relative primitives: `remove_current_worktree` runs via `-C <main root>` (git refuses to remove the worktree it runs in) and must be the *last* git call of a flow — the process cwd is deleted afterwards, so remote-branch deletion is ordered before it.
- **`Git` is 48 fine-grained primitives** so ordering logic lives in flows and mocks can record exact sequences. Rejected: coarse `finish_release()`-style methods (untestable ordering). Deliberate exceptions to cwd-relative primitives: `remove_current_worktree` runs via `-C <main root>` (git refuses to remove the worktree it runs in) and must be the *last* git call of a flow — the process cwd is deleted afterwards, so remote-branch deletion is ordered before it; and the `_at` family (`is_working_tree_clean_at`/`stage_all_at`/`commit_at`, plus `remove_worktree(path)`) targets the ephemeral version-script worktree via `-C <dir>` — no last-call constraint there, the process never stands in it. `CommandRunner` stays cwd-free in both cases: the directory travels in the arg vector.
- **`repo_root` and `worktree_root` answer different questions; neither replaces the other.** `repo_root` (`git worktree list --porcelain`, first entry) is always the MAIN tree's root — worktree *bookkeeping* (`worktree.rs`'s folder naming, `remove_current_worktree`'s `-C` target above) is deliberately anchored there no matter which worktree the command runs in. `worktree_root` (`git rev-parse --show-toplevel`) is the CURRENT tree's root — repo *content* (`.bflow/config`, the version script, PR templates: `main.rs`, `lifecycle::resolve_pr_template`/`resolve_landing_template`) must be read from here, because a linked worktree can have a different branch checked out than the main tree, and the main tree's copy would silently apply that other branch's policy. Rejected: one accessor for both — `repo_root` for everything is exactly what shipped that bug; `worktree_root` for everything would break folder naming and misdirect `remove_current_worktree`'s `-C`, both of which need the main tree regardless of where the command runs.
- **`HostingPlatform` stays minimal (5 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); `merged_pr` reports the branch's most recent PR only when merged (open → still in play; closed-unmerged → a fresh PR, never cleanup) and carries the merged head SHA because squash merges break ancestor checks; `merged_pr_to` answers the base-filtered question ("did head land in *that* base") that protected-mode landing needs and `merged_pr` cannot answer (see Landing Modes & Version Script); `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`).
- **`Prompter` is three methods, one actor.** `select` plus `prompt_name`/`prompt_line`, which differ in *shaping*, not validation (see Input shaping below); `prompt_name` owns its re-prompt loop so callers never receive an invalid name. Widened from one method so `menu::show_menu` — the interactive twin of `cli::resolve_action` — could be tested at all. The branch-type gating table is genuinely dual-encoded: `show_menu` owns the *offer* side (which actions a branch lists), `cli::resolve_action` owns the *reject* side (which it refuses, with its own terser wording). Neither derives from the other, so parity is a test obligation, not a structural guarantee — `every_work_kind_in_the_table_has_a_working_start_subcommand` in `cli_test.rs` pins the half that can drift silently. Rejected: a second trait for text input (same actor, same lifetime, same single impl — premature polymorphism).
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/bflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ bflow sync # merge release into develop (on release/* only)

**Papercut**: version files can conflict on merge (hotfix vs. develop, major release vs. develop) — resolve manually, bflow does not auto-resolve.

`--no-checkout` hotfix creation skips the script (HEAD isn't on the new branch) and warns with the manual recovery steps.
`--no-checkout` hotfix creation still runs the script: via a temporary worktree of the new branch (commit lands there, worktree removed, user's checkout untouched). Only a failing script falls back to a warning with manual recovery steps.

### Tag Strategy

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ bflow start hotfix-fix --name <name> [--no-checkout] [--no-worktree] # must

`--major` / `--minor` on `start release` skips the interactive prompt and forces the bump level. Useful for scripts and AI agents.

`--no-checkout` creates and pushes the branch without switching to it. You stay on your current branch. Designed for [git worktree](https://git-scm.com/docs/git-worktree) workflows. Not available for `start release`.
`--no-checkout` creates and pushes the branch without switching to it. You stay on your current branch. Prefer [worktree mode](#worktree-integration), which manages the worktree and editor for you — `--no-checkout` is the low-level escape hatch for users with their own worktree tooling. Not available for `start release`.

`--no-worktree` skips the optional [worktree flow](#worktree-integration) for a single command when `bflow.worktree.enabled` is set. No effect otherwise.

Expand Down Expand Up @@ -588,10 +588,12 @@ Because `develop` and an open hotfix or release branch can carry different versi

#### Hotfix branches created without checkout

`bflow start hotfix-fix --no-checkout` (or an active [worktree](#worktree-integration) flow) creates the hotfix branch without switching to it, so bflow cannot safely commit version files there — HEAD stays on your current branch. bflow skips the script and warns instead of guessing:
`bflow start hotfix-fix --no-checkout` (or an active [worktree](#worktree-integration) flow) creates the hotfix branch without switching to it — HEAD stays on your current branch. The version script still runs: bflow adds a temporary git worktree for `hotfix/X.Y.Z`, runs the branch's own copy of the script there, commits, removes the worktree, and pushes the branch carrying its version commit. Your checkout and any local changes are never touched.

Only if the script itself fails does bflow fall back to a warning with the manual recovery steps — a broken script never blocks an urgent hotfix:

```
⚠ Version script not run: hotfix/1.2.1 was created without checkout, so bflow cannot commit version files there.
⚠ Version script failed in a temporary worktree: ...
Recover manually: git switch hotfix/1.2.1, run set-version.sh 1.2.1, commit, and push.
```

Expand Down
26 changes: 26 additions & 0 deletions src/flows/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,32 @@ pub(crate) fn run_version_script(git: &dyn Git, script: &dyn VersionScript, vers
Ok(true)
}

/// `run_version_script`'s checkout-less sibling: same rule (script → clean
/// check → stage → commit), executed in an ephemeral worktree of `branch` so
/// the user's checkout is never touched. The worktree is removed on success
/// and on failure alike; a removal failure only warns, naming the command —
/// the version commit (the actual work) is not at stake by then.
pub(crate) fn run_version_script_in_temp_worktree(git: &dyn Git, script: &dyn VersionScript, branch: &str, version: &SemVer) -> Result<bool, String> {
let repo_root = git.repo_root()?;
let path = crate::worktree::temp_worktree_path(&repo_root, branch);
git.add_worktree(&path, branch)?;
Comment on lines +249 to +250
let result = (|| {
let release = version.to_release();
script.run_in(&path, &release.to_string())?;
if git.is_working_tree_clean_at(&path)? {
return Ok(false);
}
git.stage_all_at(&path)?;
git.commit_at(&path, &format!("chore: set version {release}"))?;
Ok(true)
})();
if let Err(e) = git.remove_worktree(&path) {
eprintln!("Warning: could not remove the temporary worktree: {e}");
eprintln!(" Remove it yourself: git worktree remove --force {}", path.display());
}
result
}

/// List `{prefix}/*` branches that are still open, excluding any that already
/// shipped — reusing a shipped branch would make `bflow start` loop onto a
/// dead branch forever and hotfix fan-out merge into history that already
Expand Down
18 changes: 10 additions & 8 deletions src/flows/start.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::flows::{open_versioned_branches, require_clean_tree, run_version_script};
use crate::flows::{open_versioned_branches, require_clean_tree, run_version_script, run_version_script_in_temp_worktree};
use crate::git::Git;
use crate::hosting::HostingPlatform;
use crate::prompt::Prompter;
Expand Down Expand Up @@ -320,13 +320,15 @@ fn resolve_or_create_hotfix(git: &dyn Git, hosting: &dyn HostingPlatform, cfg: &
if no_checkout {
git.create_branch_no_checkout(&branch, main_branch)?;
if let Some(script) = script {
eprintln!(
"⚠ Version script not run: {branch} was created without checkout, so bflow cannot commit version files there."
);
eprintln!(
" Recover manually: git switch {branch}, run {} {next}, commit, and push.",
script.display_name(),
);
// Warn-and-continue (M2 precedent): a broken script must never
// block the hotfix — the branch push below still proceeds.
if let Err(e) = run_version_script_in_temp_worktree(git, script, &branch, &next) {
eprintln!("⚠ Version script failed in a temporary worktree: {e}");
Comment on lines +325 to +326
eprintln!(
" Recover manually: git switch {branch}, run {} {next}, commit, and push.",
script.display_name(),
);
}
}
} else {
git.checkout(main_branch)?;
Expand Down
29 changes: 29 additions & 0 deletions src/git/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ pub trait Git {
// Version commits and merge-commit tagging
fn stage_all(&self) -> Result<()>;
fn commit(&self, message: &str) -> Result<()>;
// The `_at` family runs in another working tree via `-C` — used for the
// ephemeral version-script worktree, whose commit must never touch the
// user's checkout. Refs are shared across worktrees, so `push` needs no
// `_at` variant.
fn is_working_tree_clean_at(&self, dir: &Path) -> Result<bool>;
fn stage_all_at(&self, dir: &Path) -> Result<()>;
fn commit_at(&self, dir: &Path, message: &str) -> Result<()>;
/// Remove the worktree at `path`. Unlike `remove_current_worktree` this has
/// no last-operation constraint: the process never stands in `path`.
/// `--force`, because a failed script can leave the tree dirty and cleanup
/// must still succeed.
fn remove_worktree(&self, path: &Path) -> Result<()>;
/// Tags `sha` (not HEAD) — a separate method from `create_tag` because a
/// landing PR's merge commit is created by the hosting platform, not by a
/// local `git merge`.
Expand Down Expand Up @@ -404,6 +416,23 @@ impl Git for GitCli<'_> {

fn stage_all(&self) -> Result<()> { self.run(&["add", "-A"]).map(|_| ()) }
fn commit(&self, message: &str) -> Result<()> { self.run(&["commit", "-m", message]).map(|_| ()) }
fn is_working_tree_clean_at(&self, dir: &Path) -> Result<bool> {
let dir = dir.display().to_string();
let output = self.run(&["-C", &dir, "status", "--porcelain"])?;
Ok(output.is_empty())
}
fn stage_all_at(&self, dir: &Path) -> Result<()> {
let dir = dir.display().to_string();
self.run(&["-C", &dir, "add", "-A"]).map(|_| ())
}
fn commit_at(&self, dir: &Path, message: &str) -> Result<()> {
let dir = dir.display().to_string();
self.run(&["-C", &dir, "commit", "-m", message]).map(|_| ())
}
fn remove_worktree(&self, path: &Path) -> Result<()> {
let path = path.display().to_string();
self.run(&["worktree", "remove", "--force", &path]).map(|_| ())
}
fn create_tag_at(&self, tag: &str, message: &str, sha: &str) -> Result<()> {
self.run(&["tag", "-a", tag, "-m", message, sha]).map(|_| ())
}
Expand Down
Loading
Loading