Skip to content
Merged
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
5 changes: 3 additions & 2 deletions .claude/skills/bflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ bflow finish --abort # discard an in-progress release/hotfix finish

1. Resolve the conflict in your editor.
2. `git add` the resolved files and `git commit` to complete the merge.
3. Re-run `bflow finish` — already-done steps (merges, tags, pushes, branch deletion) are detected and skipped.
3. **Switch back to the source branch** (`git switch <release|hotfix branch>`) — a conflict usually leaves HEAD on the target branch (e.g. develop). The conflict message names the branch.
4. Re-run `bflow finish` — already-done steps (merges, tags, pushes, branch deletion) are detected and skipped.

You may be on any branch when re-running (main, develop, a release branch); state is tracked in `.git/bflow-finish.state`. Use `bflow finish --abort` to discard the in-progress state if you want to bail out.
**Resume is branch-scoped.** Each in-progress finish is tracked in its own file under `.git/bflow-finish/` (e.g. `hotfix-2.5.2.state`), keyed by the source branch. bflow resumes **only when you are on that source branch** — from develop/main/feature it behaves normally, so a stalled finish never blocks other work. Two finishes (release + hotfix) can be in progress at once. Run `bflow finish --abort` from the source branch to discard its state. Legacy pre-2.4 `.git/bflow-finish.state` files are migrated automatically.

### PR templates

Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,16 @@ On feature, fix, and refactor branches, `bflow finish` asks whether the work con

#### Resuming after a merge conflict

`bflow finish` on **release** and **hotfix** branches is **idempotent**: if a merge into `main`, `develop`, or an open `release/*` branch conflicts, resolve the conflict in your editor, `git commit` the merge, then re-run `bflow finish`. Steps that already completed (merges, tags, pushes, branch deletion) are detected from git state and skipped — the flow continues from the first incomplete step.
`bflow finish` on **release** and **hotfix** branches is **idempotent**: if a merge into `main`, `develop`, or an open `release/*` branch conflicts, resolve the conflict in your editor and `git commit` the merge. A conflict usually leaves HEAD on the target branch (e.g. `develop`), so to continue you **switch back to the source branch and re-run `bflow finish`**:

State is tracked in `.git/bflow-finish.state` so re-runs work even after HEAD has moved off the source branch during conflict resolution. Use `bflow finish --abort` to discard the in-progress state and start fresh.
```bash
git switch hotfix/2.5.2 # back to the branch that started the finish
bflow finish # resumes; already-done steps are skipped
```

Steps that already completed (merges, tags, pushes, branch deletion) are detected from git state and skipped — the flow continues from the first incomplete step. The conflict message names the exact branch to switch back to.

**Resume is branch-scoped.** Each in-progress finish is tracked in its own file under `.git/bflow-finish/` (e.g. `hotfix-2.5.2.state`), keyed by the source branch. bflow only resumes when you are standing on that source branch — from `develop`, `main`, or any `feature/*` branch it behaves normally, so a stalled finish never blocks other work. Two finishes (e.g. a release and a hotfix) can be in progress at once without colliding. Use `bflow finish --abort` from the source branch to discard its in-progress state and start fresh.

#### PR templates

Expand Down
12 changes: 8 additions & 4 deletions src/flows/finish_hotfix.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::flows::resume_hint;
use crate::git::Git;
use crate::version::SemVer;

Expand All @@ -13,7 +14,8 @@ pub fn finish_hotfix(git: &dyn Git, major: u32, minor: u32, patch: u32) -> Resul
println!("Merging into main...");
git.checkout("main")?;
git.pull("origin/main")?;
git.merge(&hotfix_branch, &format!("chore: merge hotfix {version} into main"))?;
git.merge(&hotfix_branch, &format!("chore: merge hotfix {version} into main"))
.map_err(|e| format!("{e}\n{}", resume_hint(&hotfix_branch)))?;
} else {
println!("↷ skipped: merge into main (already merged)");
}
Expand Down Expand Up @@ -45,7 +47,8 @@ pub fn finish_hotfix(git: &dyn Git, major: u32, minor: u32, patch: u32) -> Resul
println!("Merging into develop...");
git.checkout("develop")?;
git.pull("origin/develop")?;
git.merge(&hotfix_branch, &format!("chore: merge hotfix {version} into develop"))?;
git.merge(&hotfix_branch, &format!("chore: merge hotfix {version} into develop"))
.map_err(|e| format!("{e}\n{}", resume_hint(&hotfix_branch)))?;
} else {
println!("↷ skipped: merge into develop (already merged)");
}
Expand Down Expand Up @@ -79,8 +82,9 @@ pub fn finish_hotfix(git: &dyn Git, major: u32, minor: u32, patch: u32) -> Resul
).map_err(|e| format!(
"{e}\n\
Hotfix {version} was merged into main and develop, but propagation into {release} failed.\n\
Resolve the conflict on {release}, commit the merge, then re-run 'bflow finish' to continue.\n\
(After all releases are updated, run 'bflow bump' on each to cut a fresh RC for staging.)"
{}\n\
(After all releases are updated, run 'bflow bump' on each to cut a fresh RC for staging.)",
resume_hint(&hotfix_branch)
))?;
}
if !git.is_pushed(release)? {
Expand Down
7 changes: 5 additions & 2 deletions src/flows/finish_release.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::flows::resume_hint;
use crate::git::Git;
use crate::version::SemVer;

Expand Down Expand Up @@ -69,7 +70,8 @@ pub fn finish_release(git: &dyn Git, major: u32, minor: u32) -> Result<(), Strin
println!("Merging into main...");
git.checkout("main")?;
git.pull("origin/main")?;
git.merge(&release_branch, &format!("chore: merge release {release} into main"))?;
git.merge(&release_branch, &format!("chore: merge release {release} into main"))
.map_err(|e| format!("{e}\n{}", resume_hint(&release_branch)))?;
} else {
println!("↷ skipped: merge into main (already merged)");
}
Expand Down Expand Up @@ -101,7 +103,8 @@ pub fn finish_release(git: &dyn Git, major: u32, minor: u32) -> Result<(), Strin
println!("Merging into develop...");
git.checkout("develop")?;
git.pull("origin/develop")?;
git.merge(&release_branch, &format!("chore: merge release {release} into develop"))?;
git.merge(&release_branch, &format!("chore: merge release {release} into develop"))
.map_err(|e| format!("{e}\n{}", resume_hint(&release_branch)))?;
} else {
println!("↷ skipped: merge into develop (already merged)");
}
Expand Down
13 changes: 13 additions & 0 deletions src/flows/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,16 @@ pub mod start;
pub mod finish_work;
pub mod finish_release;
pub mod finish_hotfix;

/// Guidance appended to a merge conflict during a release/hotfix finish.
///
/// Resume is branch-scoped: bflow only continues an interrupted finish when you
/// are standing on its source branch. A merge conflict usually leaves HEAD on the
/// target branch (e.g. develop), so the user must switch back before re-running.
pub(crate) fn resume_hint(source_branch: &str) -> String {
format!(
"Resolve the conflict and commit the merge, then switch back to the source \
branch and re-run 'bflow finish' to continue:\n \
git switch {source_branch}\n bflow finish"
)
}
50 changes: 39 additions & 11 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,22 @@ fn run(command: Option<Commands>) -> Result<(), String> {
})?;
let git_dir = git.git_dir()?;

// Resume context: if an in-progress finish state exists, it overrides
// branch-based dispatch (the user may have moved off the source branch
// while resolving conflicts).
let resume_state = FinishState::load(&git_dir)?;
// One-time upgrade of any pre-2.4 global state file into the per-branch folder.
FinishState::migrate_legacy(&git_dir)?;

// Resolve the action up-front so we can decide whether to fetch / stash / etc.
let branch_type = BranchType::parse(&branch_name);

// Resume context: an in-progress finish only resumes when you are standing on
// the source branch that started it. From develop/main/feature branches there
// is no resume — bflow behaves normally — so a stalled finish never hijacks
// other work. To continue after a conflict you switch back to the source
// branch and re-run 'bflow finish'.
let resume_state = match finish_identity(&branch_type) {
Some((kind, major, minor, patch)) => FinishState::load(&git_dir, kind, major, minor, patch)?,
None => None,
};

// Resolve the action up-front so we can decide whether to fetch / stash / etc.
let action = resolve_action_with_state(command, &branch_type, &branch_name, resume_state.as_ref())?;

// --abort short-circuits before any state-changing operation, even if the repo
Expand Down Expand Up @@ -97,9 +106,12 @@ fn run(command: Option<Commands>) -> Result<(), String> {

let result = run_flow(&git, &hosting, &branch_type, &branch_name, &action, no_checkout, resume_state.as_ref());

// Lifecycle: clear state on success of a release/hotfix finish.
// Lifecycle: clear state on success of a release/hotfix finish. Both a fresh
// finish and a resume run on the source branch, so its identity is available.
if result.is_ok() && (is_finish_with_state || resume_state.is_some()) {
FinishState::clear(&git_dir)?;
if let Some((kind, major, minor, patch)) = finish_identity(&branch_type) {
FinishState::clear(&git_dir, kind, major, minor, patch)?;
}
}

// Stash pop policy:
Expand Down Expand Up @@ -146,9 +158,10 @@ fn resolve_action_with_state(
}

// For `bflow finish` (or the default interactive path), an in-progress finish
// state takes precedence over branch-based dispatch. The user may be on main,
// develop, or a release branch after resolving a conflict — all of which would
// normally be rejected as "not finishable" by `resolve_action`.
// state takes precedence over branch-based dispatch. This state is only ever
// present when standing on the source branch (resume is branch-scoped), so it
// covers the case where a develop-merge conflict was resolved and the user has
// switched back to the release/hotfix branch to continue.
let is_finish_or_default = matches!(command, Some(Commands::Finish { .. }) | None);
if is_finish_or_default {
if let Some(state) = resume_state {
Expand All @@ -172,6 +185,21 @@ fn resolve_action_with_state(
}
}

/// Map a source branch to the (kind, version) identity of its finish state.
/// Only release and hotfix branches carry finish state; everything else (develop,
/// main, feature/*) yields None and therefore never resumes.
fn finish_identity(branch_type: &BranchType) -> Option<(FinishKind, u32, u32, u32)> {
match branch_type {
BranchType::Release { major, minor, patch } => {
Some((FinishKind::Release, *major, *minor, *patch))
}
BranchType::Hotfix { major, minor, patch } => {
Some((FinishKind::Hotfix, *major, *minor, *patch))
}
_ => None,
}
}

fn write_state_for_action(
action: &Action,
branch_type: &BranchType,
Expand Down Expand Up @@ -205,7 +233,7 @@ fn handle_abort(git_dir: &std::path::Path, state: Option<FinishState>) -> Result
Some(s) => {
println!("Aborting in-progress {} finish for {} (started_at={}).",
s.kind.as_str(), s.source_branch(), s.started_at);
FinishState::clear(git_dir)?;
FinishState::clear(git_dir, s.kind, s.major, s.minor, s.patch)?;
if let Some(msg) = &s.stash_ref {
println!("Your original uncommitted changes are still stashed as '{msg}'.");
println!("Run 'git stash list' to find it, then 'git stash pop <ref>' to restore.");
Expand Down
Loading
Loading