diff --git a/.claude/skills/bflow/SKILL.md b/.claude/skills/bflow/SKILL.md index 9f023ec..90466a1 100644 --- a/.claude/skills/bflow/SKILL.md +++ b/.claude/skills/bflow/SKILL.md @@ -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 `) — 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 diff --git a/README.md b/README.md index 1648df4..dc0395c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/flows/finish_hotfix.rs b/src/flows/finish_hotfix.rs index 7c97e81..c21e31c 100644 --- a/src/flows/finish_hotfix.rs +++ b/src/flows/finish_hotfix.rs @@ -1,3 +1,4 @@ +use crate::flows::resume_hint; use crate::git::Git; use crate::version::SemVer; @@ -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)"); } @@ -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)"); } @@ -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)? { diff --git a/src/flows/finish_release.rs b/src/flows/finish_release.rs index b7878dc..b5847e2 100644 --- a/src/flows/finish_release.rs +++ b/src/flows/finish_release.rs @@ -1,3 +1,4 @@ +use crate::flows::resume_hint; use crate::git::Git; use crate::version::SemVer; @@ -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)"); } @@ -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)"); } diff --git a/src/flows/mod.rs b/src/flows/mod.rs index e6a60b3..aac1a3b 100644 --- a/src/flows/mod.rs +++ b/src/flows/mod.rs @@ -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" + ) +} diff --git a/src/main.rs b/src/main.rs index a7f2375..578162e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,13 +45,22 @@ fn run(command: Option) -> 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 @@ -97,9 +106,12 @@ fn run(command: Option) -> 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: @@ -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 { @@ -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, @@ -205,7 +233,7 @@ fn handle_abort(git_dir: &std::path::Path, state: Option) -> 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 ' to restore."); diff --git a/src/state.rs b/src/state.rs index 2a40dfa..a615033 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,7 +1,10 @@ use std::fs; use std::path::{Path, PathBuf}; -pub const STATE_FILE_NAME: &str = "bflow-finish.state"; +/// Folder under `.git/` holding one state file per in-progress finish. +pub const STATE_DIR_NAME: &str = "bflow-finish"; +/// Pre-2.4 single global state file, migrated on startup if found. +pub const LEGACY_STATE_FILE_NAME: &str = "bflow-finish.state"; pub const SCHEMA_VERSION: u32 = 1; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -45,12 +48,25 @@ impl FinishState { } } - pub fn path(git_dir: &Path) -> PathBuf { - git_dir.join(STATE_FILE_NAME) + /// Directory holding the per-branch state files. + pub fn dir(git_dir: &Path) -> PathBuf { + git_dir.join(STATE_DIR_NAME) } - pub fn load(git_dir: &Path) -> Result, String> { - let path = Self::path(git_dir); + /// State file name for a given source branch, e.g. `hotfix-2.5.2.state`. + pub fn file_name(kind: FinishKind, major: u32, minor: u32, patch: u32) -> String { + format!("{}-{}.{}.{}.state", kind.as_str(), major, minor, patch) + } + + /// Full path to the state file for a specific source branch. + pub fn path(git_dir: &Path, kind: FinishKind, major: u32, minor: u32, patch: u32) -> PathBuf { + Self::dir(git_dir).join(Self::file_name(kind, major, minor, patch)) + } + + /// Load the in-progress finish state for one specific source branch. + /// Returns `None` when no finish is in progress for that branch. + pub fn load(git_dir: &Path, kind: FinishKind, major: u32, minor: u32, patch: u32) -> Result, String> { + let path = Self::path(git_dir, kind, major, minor, patch); if !path.exists() { return Ok(None); } @@ -60,13 +76,17 @@ impl FinishState { } pub fn save(&self, git_dir: &Path) -> Result<(), String> { - let path = Self::path(git_dir); + let dir = Self::dir(git_dir); + fs::create_dir_all(&dir) + .map_err(|e| format!("Failed to create {}: {}", dir.display(), e))?; + let path = Self::path(git_dir, self.kind, self.major, self.minor, self.patch); fs::write(&path, self.serialize()) .map_err(|e| format!("Failed to write {}: {}", path.display(), e)) } - pub fn clear(git_dir: &Path) -> Result<(), String> { - let path = Self::path(git_dir); + /// Remove the state file for one specific source branch. Idempotent. + pub fn clear(git_dir: &Path, kind: FinishKind, major: u32, minor: u32, patch: u32) -> Result<(), String> { + let path = Self::path(git_dir, kind, major, minor, patch); if !path.exists() { return Ok(()); } @@ -74,6 +94,28 @@ impl FinishState { .map_err(|e| format!("Failed to remove {}: {}", path.display(), e)) } + /// One-time upgrade: move a pre-2.4 global `bflow-finish.state` file into the + /// per-branch folder under its own source-branch key. A corrupt legacy file is + /// dropped rather than bricking startup — the finish is idempotent and can be + /// re-driven from its source branch. + pub fn migrate_legacy(git_dir: &Path) -> Result<(), String> { + let legacy = git_dir.join(LEGACY_STATE_FILE_NAME); + if !legacy.exists() { + return Ok(()); + } + let contents = fs::read_to_string(&legacy) + .map_err(|e| format!("Failed to read {}: {}", legacy.display(), e))?; + match Self::parse(&contents) { + Ok(state) => state.save(git_dir)?, + Err(e) => eprintln!( + "Warning: discarding unreadable legacy finish state {}: {e}", + legacy.display() + ), + } + fs::remove_file(&legacy) + .map_err(|e| format!("Failed to remove {}: {}", legacy.display(), e)) + } + fn serialize(&self) -> String { let mut out = String::new(); out.push_str(&format!("version={}\n", SCHEMA_VERSION)); @@ -162,60 +204,135 @@ mod tests { dir } - #[test] - fn round_trips_release_state() { - let dir = tmp_dir(); - let s = FinishState { + fn release(major: u32, minor: u32, patch: u32) -> FinishState { + FinishState { kind: FinishKind::Release, - major: 1, minor: 3, patch: 0, + major, minor, patch, started_at: "1234".to_string(), stash_ref: None, - }; - s.save(&dir).unwrap(); - let loaded = FinishState::load(&dir).unwrap().unwrap(); - assert_eq!(loaded, s); - assert_eq!(loaded.source_branch(), "release/1.3.0"); - fs::remove_dir_all(&dir).ok(); + } } - #[test] - fn round_trips_hotfix_state_with_stash() { - let dir = tmp_dir(); - let s = FinishState { + fn hotfix(major: u32, minor: u32, patch: u32) -> FinishState { + FinishState { kind: FinishKind::Hotfix, - major: 2, minor: 0, patch: 4, + major, minor, patch, started_at: "5678".to_string(), stash_ref: Some("stash@{0}".to_string()), - }; + } + } + + #[test] + fn file_name_encodes_kind_and_version() { + assert_eq!(FinishState::file_name(FinishKind::Hotfix, 2, 5, 2), "hotfix-2.5.2.state"); + assert_eq!(FinishState::file_name(FinishKind::Release, 2, 4, 0), "release-2.4.0.state"); + } + + #[test] + fn saves_under_per_branch_path_and_round_trips() { + let dir = tmp_dir(); + let s = hotfix(2, 5, 2); s.save(&dir).unwrap(); - let loaded = FinishState::load(&dir).unwrap().unwrap(); + + // File lives in the bflow-finish/ folder, keyed by the source branch. + let expected = dir.join(STATE_DIR_NAME).join("hotfix-2.5.2.state"); + assert!(expected.exists(), "state should be saved at {}", expected.display()); + + let loaded = FinishState::load(&dir, FinishKind::Hotfix, 2, 5, 2).unwrap().unwrap(); assert_eq!(loaded, s); - assert_eq!(loaded.source_branch(), "hotfix/2.0.4"); + assert_eq!(loaded.source_branch(), "hotfix/2.5.2"); fs::remove_dir_all(&dir).ok(); } #[test] - fn load_returns_none_when_missing() { + fn load_returns_none_for_a_different_branch() { let dir = tmp_dir(); - assert_eq!(FinishState::load(&dir).unwrap(), None); + hotfix(2, 5, 2).save(&dir).unwrap(); + + // Same version, different kind -> no state for that branch. + assert_eq!(FinishState::load(&dir, FinishKind::Release, 2, 5, 2).unwrap(), None); + // Different version -> no state. + assert_eq!(FinishState::load(&dir, FinishKind::Hotfix, 2, 5, 3).unwrap(), None); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn two_finishes_coexist_without_colliding() { + let dir = tmp_dir(); + let rel = release(2, 4, 0); + let hot = hotfix(2, 5, 2); + rel.save(&dir).unwrap(); + hot.save(&dir).unwrap(); + + assert_eq!(FinishState::load(&dir, FinishKind::Release, 2, 4, 0).unwrap().unwrap(), rel); + assert_eq!(FinishState::load(&dir, FinishKind::Hotfix, 2, 5, 2).unwrap().unwrap(), hot); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn clear_removes_only_the_targeted_branch() { + let dir = tmp_dir(); + release(2, 4, 0).save(&dir).unwrap(); + hotfix(2, 5, 2).save(&dir).unwrap(); + + FinishState::clear(&dir, FinishKind::Hotfix, 2, 5, 2).unwrap(); + + assert_eq!(FinishState::load(&dir, FinishKind::Hotfix, 2, 5, 2).unwrap(), None); + assert!(FinishState::load(&dir, FinishKind::Release, 2, 4, 0).unwrap().is_some(), + "clearing the hotfix must not touch the release state"); fs::remove_dir_all(&dir).ok(); } #[test] fn clear_is_idempotent() { let dir = tmp_dir(); - FinishState::clear(&dir).unwrap(); - FinishState::clear(&dir).unwrap(); + FinishState::clear(&dir, FinishKind::Hotfix, 1, 0, 0).unwrap(); + FinishState::clear(&dir, FinishKind::Hotfix, 1, 0, 0).unwrap(); fs::remove_dir_all(&dir).ok(); } #[test] fn rejects_unknown_schema_version() { let dir = tmp_dir(); - let path = FinishState::path(&dir); + let path = FinishState::path(&dir, FinishKind::Release, 1, 0, 0); + fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(&path, "version=99\nkind=release\nmajor=1\nminor=0\npatch=0\nstarted_at=0\n").unwrap(); - let err = FinishState::load(&dir).unwrap_err(); + let err = FinishState::load(&dir, FinishKind::Release, 1, 0, 0).unwrap_err(); assert!(err.contains("Unsupported state file version")); fs::remove_dir_all(&dir).ok(); } + + #[test] + fn migrate_legacy_moves_global_file_into_per_branch_folder() { + let dir = tmp_dir(); + // Simulate a pre-upgrade global state file. + let legacy = dir.join(LEGACY_STATE_FILE_NAME); + fs::write(&legacy, release(1, 3, 0).serialize()).unwrap(); + + FinishState::migrate_legacy(&dir).unwrap(); + + assert!(!legacy.exists(), "legacy file should be removed after migration"); + let loaded = FinishState::load(&dir, FinishKind::Release, 1, 3, 0).unwrap().unwrap(); + assert_eq!(loaded.source_branch(), "release/1.3.0"); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn migrate_legacy_is_noop_without_legacy_file() { + let dir = tmp_dir(); + FinishState::migrate_legacy(&dir).unwrap(); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn migrate_legacy_discards_corrupt_global_file() { + let dir = tmp_dir(); + let legacy = dir.join(LEGACY_STATE_FILE_NAME); + fs::write(&legacy, "this is not a valid state file").unwrap(); + + // A corrupt legacy file must not brick startup; it is dropped. + FinishState::migrate_legacy(&dir).unwrap(); + assert!(!legacy.exists(), "corrupt legacy file should be removed"); + fs::remove_dir_all(&dir).ok(); + } } diff --git a/tests/finish_hotfix_test.rs b/tests/finish_hotfix_test.rs index 7ff927e..5dfd585 100644 --- a/tests/finish_hotfix_test.rs +++ b/tests/finish_hotfix_test.rs @@ -328,3 +328,26 @@ fn finish_hotfix_resume_when_branch_already_deleted_is_idempotent() { let calls = git.calls(); assert!(!calls.iter().any(|c| c.starts_with("delete_branch_")), "deletions should be skipped; calls: {calls:?}"); } + +// --- Conflict guidance: every merge step must tell the user to switch back --- + +#[test] +fn finish_hotfix_main_merge_conflict_names_source_branch_to_switch_back() { + let mut git = fresh_hotfix_mock(1, 0, 1); + git.fail_nth_merge = Some(1); // main merge + + let err = finish_hotfix(&git, 1, 0, 1).unwrap_err(); + assert!(err.contains("git switch hotfix/1.0.1"), + "main conflict should tell user to switch back to the hotfix branch; got: {err}"); + assert!(err.contains("bflow finish"), "should mention re-running bflow finish; got: {err}"); +} + +#[test] +fn finish_hotfix_develop_merge_conflict_names_source_branch_to_switch_back() { + let mut git = fresh_hotfix_mock(1, 0, 1); + git.fail_nth_merge = Some(2); // develop merge + + let err = finish_hotfix(&git, 1, 0, 1).unwrap_err(); + assert!(err.contains("git switch hotfix/1.0.1"), + "develop conflict should tell user to switch back to the hotfix branch; got: {err}"); +} diff --git a/tests/finish_release_test.rs b/tests/finish_release_test.rs index 0d2c20e..284da24 100644 --- a/tests/finish_release_test.rs +++ b/tests/finish_release_test.rs @@ -256,3 +256,26 @@ fn finish_release_fully_idempotent_no_op_on_second_run() { assert!(!calls.iter().any(|c| c.starts_with("push:"))); assert!(!calls.iter().any(|c| c.starts_with("delete_branch_"))); } + +// --- Conflict guidance: every merge step must tell the user to switch back --- + +#[test] +fn finish_release_main_merge_conflict_names_source_branch_to_switch_back() { + let mut git = fresh_release_mock(1, 1, &["v1.1.0-rc.1"]); + git.fail_nth_merge = Some(1); // main merge + + let err = finish_release(&git, 1, 1).unwrap_err(); + assert!(err.contains("git switch release/1.1.0"), + "main conflict should tell user to switch back to the release branch; got: {err}"); + assert!(err.contains("bflow finish"), "should mention re-running bflow finish; got: {err}"); +} + +#[test] +fn finish_release_develop_merge_conflict_names_source_branch_to_switch_back() { + let mut git = fresh_release_mock(1, 1, &["v1.1.0-rc.1"]); + git.fail_nth_merge = Some(2); // develop merge + + let err = finish_release(&git, 1, 1).unwrap_err(); + assert!(err.contains("git switch release/1.1.0"), + "develop conflict should tell user to switch back to the release branch; got: {err}"); +}