Skip to main content

sr_core/
release.rs

1use std::fs;
2use std::path::Path;
3
4use semver::Version;
5use serde::Serialize;
6
7use crate::changelog::{ChangelogEntry, ChangelogFormatter};
8use crate::commit::{CommitParser, ConventionalCommit, DefaultCommitClassifier};
9use crate::config::ReleaseConfig;
10use crate::error::ReleaseError;
11use crate::git::GitRepository;
12use crate::hooks::{HookCommand, HookContext, HookRunner};
13use crate::version::{BumpLevel, apply_bump, determine_bump};
14use crate::version_files::bump_version_file;
15
16/// Build a `HookContext` populated with `SR_*` environment variables from a release plan.
17fn build_hook_context(plan: &ReleasePlan, phase: &str, dry_run: bool) -> HookContext {
18    HookContext::default()
19        .set("SR_VERSION", plan.next_version.to_string())
20        .set(
21            "SR_PREVIOUS_VERSION",
22            plan.current_version
23                .as_ref()
24                .map(|v| v.to_string())
25                .unwrap_or_default(),
26        )
27        .set("SR_TAG", &plan.tag_name)
28        .set("SR_BUMP_LEVEL", plan.bump.to_string())
29        .set("SR_DRY_RUN", dry_run.to_string())
30        .set("SR_COMMIT_COUNT", plan.commits.len().to_string())
31        .set("SR_HOOK_PHASE", phase)
32        .set(
33            "SR_FLOATING_TAG",
34            plan.floating_tag_name.as_deref().unwrap_or(""),
35        )
36}
37
38/// The computed plan for a release, before execution.
39#[derive(Debug, Serialize)]
40pub struct ReleasePlan {
41    pub current_version: Option<Version>,
42    pub next_version: Version,
43    pub bump: BumpLevel,
44    pub commits: Vec<ConventionalCommit>,
45    pub tag_name: String,
46    pub floating_tag_name: Option<String>,
47}
48
49/// Orchestrates the release flow.
50pub trait ReleaseStrategy: Send + Sync {
51    /// Plan the release without executing it.
52    fn plan(&self) -> Result<ReleasePlan, ReleaseError>;
53
54    /// Execute the release.
55    fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError>;
56}
57
58/// Abstraction over a remote VCS provider (e.g. GitHub, GitLab).
59pub trait VcsProvider: Send + Sync {
60    /// Create a release on the remote VCS.
61    fn create_release(
62        &self,
63        tag: &str,
64        name: &str,
65        body: &str,
66        prerelease: bool,
67    ) -> Result<String, ReleaseError>;
68
69    /// Generate a compare URL between two refs.
70    fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError>;
71
72    /// Check if a release already exists for the given tag.
73    fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError>;
74
75    /// Delete a release by tag.
76    fn delete_release(&self, tag: &str) -> Result<(), ReleaseError>;
77
78    /// Return the base URL of the repository (e.g. `https://github.com/owner/repo`).
79    fn repo_url(&self) -> Option<String> {
80        None
81    }
82
83    /// Upload asset files to an existing release identified by tag.
84    fn upload_assets(&self, _tag: &str, _files: &[&str]) -> Result<(), ReleaseError> {
85        Ok(())
86    }
87}
88
89/// Concrete release strategy implementing the trunk-based release flow.
90pub struct TrunkReleaseStrategy<G, V, C, F, H> {
91    pub git: G,
92    pub vcs: Option<V>,
93    pub parser: C,
94    pub formatter: F,
95    pub hooks: H,
96    pub config: ReleaseConfig,
97    /// When true, re-release the current tag if HEAD is at the latest tag.
98    pub force: bool,
99}
100
101fn to_hook_commands(commands: &[String]) -> Vec<HookCommand> {
102    commands
103        .iter()
104        .map(|c| HookCommand { command: c.clone() })
105        .collect()
106}
107
108impl<G, V, C, F, H> TrunkReleaseStrategy<G, V, C, F, H>
109where
110    G: GitRepository,
111    V: VcsProvider,
112    C: CommitParser,
113    F: ChangelogFormatter,
114    H: HookRunner,
115{
116    fn format_changelog(&self, plan: &ReleasePlan) -> Result<String, ReleaseError> {
117        let today = today_string();
118        let entry = ChangelogEntry {
119            version: plan.next_version.to_string(),
120            date: today,
121            commits: plan.commits.clone(),
122            compare_url: None,
123            repo_url: self.vcs.as_ref().and_then(|v| v.repo_url()),
124        };
125        self.formatter.format(&[entry])
126    }
127}
128
129impl<G, V, C, F, H> ReleaseStrategy for TrunkReleaseStrategy<G, V, C, F, H>
130where
131    G: GitRepository,
132    V: VcsProvider,
133    C: CommitParser,
134    F: ChangelogFormatter,
135    H: HookRunner,
136{
137    fn plan(&self) -> Result<ReleasePlan, ReleaseError> {
138        let tag_info = self.git.latest_tag(&self.config.tag_prefix)?;
139
140        let (current_version, from_sha) = match &tag_info {
141            Some(info) => (Some(info.version.clone()), Some(info.sha.as_str())),
142            None => (None, None),
143        };
144
145        let raw_commits = self.git.commits_since(from_sha)?;
146        if raw_commits.is_empty() {
147            // Force mode: re-release if HEAD is exactly at the latest tag
148            if self.force
149                && let Some(ref info) = tag_info
150            {
151                let head = self.git.head_sha()?;
152                if head == info.sha {
153                    let floating_tag_name = if self.config.floating_tags {
154                        Some(format!("{}{}", self.config.tag_prefix, info.version.major))
155                    } else {
156                        None
157                    };
158                    return Ok(ReleasePlan {
159                        current_version: Some(info.version.clone()),
160                        next_version: info.version.clone(),
161                        bump: BumpLevel::Patch, // nominal; no actual bump
162                        commits: vec![],
163                        tag_name: info.name.clone(),
164                        floating_tag_name,
165                    });
166                }
167            }
168            let (tag, sha) = match &tag_info {
169                Some(info) => (info.name.clone(), info.sha.clone()),
170                None => ("(none)".into(), "(none)".into()),
171            };
172            return Err(ReleaseError::NoCommits { tag, sha });
173        }
174
175        let conventional_commits: Vec<ConventionalCommit> = raw_commits
176            .iter()
177            .filter_map(|c| self.parser.parse(c).ok())
178            .collect();
179
180        let classifier = DefaultCommitClassifier::new(
181            self.config.types.clone(),
182            self.config.commit_pattern.clone(),
183        );
184        let tag_for_err = tag_info
185            .as_ref()
186            .map(|i| i.name.clone())
187            .unwrap_or_else(|| "(none)".into());
188        let commit_count = conventional_commits.len();
189        let bump =
190            determine_bump(&conventional_commits, &classifier).ok_or(ReleaseError::NoBump {
191                tag: tag_for_err,
192                commit_count,
193            })?;
194
195        let base_version = current_version.clone().unwrap_or(Version::new(0, 0, 0));
196        let next_version = apply_bump(&base_version, bump);
197        let tag_name = format!("{}{next_version}", self.config.tag_prefix);
198
199        let floating_tag_name = if self.config.floating_tags {
200            Some(format!("{}{}", self.config.tag_prefix, next_version.major))
201        } else {
202            None
203        };
204
205        Ok(ReleasePlan {
206            current_version,
207            next_version,
208            bump,
209            commits: conventional_commits,
210            tag_name,
211            floating_tag_name,
212        })
213    }
214
215    fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
216        if dry_run {
217            let changelog_body = self.format_changelog(plan)?;
218            let ctx = build_hook_context(plan, "pre_release", true);
219            let mut env_keys: Vec<&String> = ctx.env.keys().collect();
220            env_keys.sort();
221            eprintln!("[dry-run] Hook environment variables:");
222            for key in env_keys {
223                eprintln!("[dry-run]   {key}={}", ctx.env[key]);
224            }
225            eprintln!("[dry-run] Would create tag: {}", plan.tag_name);
226            eprintln!("[dry-run] Would push tag: {}", plan.tag_name);
227            if let Some(ref floating) = plan.floating_tag_name {
228                eprintln!("[dry-run] Would create/update floating tag: {floating}");
229                eprintln!("[dry-run] Would force-push floating tag: {floating}");
230            }
231            if self.vcs.is_some() {
232                eprintln!(
233                    "[dry-run] Would create GitHub release for {}",
234                    plan.tag_name
235                );
236            }
237            for file in &self.config.version_files {
238                let filename = Path::new(file)
239                    .file_name()
240                    .and_then(|n| n.to_str())
241                    .unwrap_or_default();
242                let supported = matches!(
243                    filename,
244                    "Cargo.toml"
245                        | "package.json"
246                        | "pyproject.toml"
247                        | "pom.xml"
248                        | "build.gradle"
249                        | "build.gradle.kts"
250                ) || filename.ends_with(".go");
251                if supported {
252                    eprintln!("[dry-run] Would bump version in: {file}");
253                } else if self.config.version_files_strict {
254                    return Err(ReleaseError::VersionBump(format!(
255                        "unsupported version file: {filename}"
256                    )));
257                } else {
258                    eprintln!("[dry-run] warning: unsupported version file, would skip: {file}");
259                }
260            }
261            if !self.config.artifacts.is_empty() {
262                let resolved = resolve_artifact_globs(&self.config.artifacts)?;
263                if resolved.is_empty() {
264                    eprintln!("[dry-run] Artifact patterns matched no files");
265                } else {
266                    eprintln!("[dry-run] Would upload {} artifact(s):", resolved.len());
267                    for f in &resolved {
268                        eprintln!("[dry-run]   {f}");
269                    }
270                }
271            }
272            for hook in &self.config.hooks.pre_release {
273                eprintln!("[dry-run] Would run pre-release hook: {hook}");
274            }
275            for hook in &self.config.hooks.post_tag {
276                eprintln!("[dry-run] Would run post-tag hook: {hook}");
277            }
278            for hook in &self.config.hooks.post_release {
279                eprintln!("[dry-run] Would run post-release hook: {hook}");
280            }
281            eprintln!("[dry-run] Changelog:\n{changelog_body}");
282            return Ok(());
283        }
284
285        let failure_ctx = build_hook_context(plan, "on_failure", false);
286        let run_failure_hooks = |err: ReleaseError| -> ReleaseError {
287            let _ = self.hooks.run(
288                &to_hook_commands(&self.config.hooks.on_failure),
289                &failure_ctx,
290            );
291            err
292        };
293
294        // 1. Pre-release hooks
295        let pre_ctx = build_hook_context(plan, "pre_release", false);
296        self.hooks
297            .run(&to_hook_commands(&self.config.hooks.pre_release), &pre_ctx)
298            .map_err(&run_failure_hooks)?;
299
300        // 2. Format changelog
301        let changelog_body = self.format_changelog(plan).map_err(&run_failure_hooks)?;
302
303        // 2.5 Bump version files
304        let version_str = plan.next_version.to_string();
305        let mut bumped_files: Vec<&str> = Vec::new();
306        for file in &self.config.version_files {
307            match bump_version_file(Path::new(file), &version_str) {
308                Ok(()) => bumped_files.push(file.as_str()),
309                Err(e) if !self.config.version_files_strict => {
310                    eprintln!("warning: {e} — skipping {file}");
311                }
312                Err(e) => return Err(run_failure_hooks(e)),
313            }
314        }
315
316        // 3. Write changelog file if configured
317        if let Some(ref changelog_file) = self.config.changelog.file {
318            let path = Path::new(changelog_file);
319            let existing = if path.exists() {
320                fs::read_to_string(path)
321                    .map_err(|e| ReleaseError::Changelog(e.to_string()))
322                    .map_err(&run_failure_hooks)?
323            } else {
324                String::new()
325            };
326            let new_content = if existing.is_empty() {
327                format!("# Changelog\n\n{changelog_body}\n")
328            } else {
329                // Insert after the first heading line
330                match existing.find("\n\n") {
331                    Some(pos) => {
332                        let (header, rest) = existing.split_at(pos);
333                        format!("{header}\n\n{changelog_body}\n{rest}")
334                    }
335                    None => format!("{existing}\n\n{changelog_body}\n"),
336                }
337            };
338            fs::write(path, new_content)
339                .map_err(|e| ReleaseError::Changelog(e.to_string()))
340                .map_err(&run_failure_hooks)?;
341        }
342
343        // 4. Stage and commit changelog + version files (skip if nothing to stage)
344        {
345            let mut paths_to_stage: Vec<&str> = Vec::new();
346            if let Some(ref changelog_file) = self.config.changelog.file {
347                paths_to_stage.push(changelog_file.as_str());
348            }
349            for file in &bumped_files {
350                paths_to_stage.push(*file);
351            }
352            if !paths_to_stage.is_empty() {
353                let commit_msg = format!("chore(release): {} [skip ci]", plan.tag_name);
354                self.git
355                    .stage_and_commit(&paths_to_stage, &commit_msg)
356                    .map_err(&run_failure_hooks)?;
357            }
358        }
359
360        // 5. Create tag (skip if it already exists locally)
361        if !self
362            .git
363            .tag_exists(&plan.tag_name)
364            .map_err(&run_failure_hooks)?
365        {
366            self.git
367                .create_tag(&plan.tag_name, &changelog_body)
368                .map_err(&run_failure_hooks)?;
369        }
370
371        // 6. Push commit (safe to re-run — no-op if up to date)
372        self.git.push().map_err(&run_failure_hooks)?;
373
374        // 7. Push tag (skip if tag already exists on remote)
375        if !self
376            .git
377            .remote_tag_exists(&plan.tag_name)
378            .map_err(&run_failure_hooks)?
379        {
380            self.git
381                .push_tag(&plan.tag_name)
382                .map_err(&run_failure_hooks)?;
383        }
384
385        // 7.5 Force-create and force-push floating tag (e.g. v3)
386        if let Some(ref floating) = plan.floating_tag_name {
387            let floating_msg = format!("Floating tag for {}", plan.tag_name);
388            self.git
389                .force_create_tag(floating, &floating_msg)
390                .map_err(&run_failure_hooks)?;
391            self.git
392                .force_push_tag(floating)
393                .map_err(&run_failure_hooks)?;
394        }
395
396        // 8. Post-tag hooks
397        let post_tag_ctx = build_hook_context(plan, "post_tag", false);
398        self.hooks
399            .run(
400                &to_hook_commands(&self.config.hooks.post_tag),
401                &post_tag_ctx,
402            )
403            .map_err(&run_failure_hooks)?;
404
405        // 9. Create GitHub release (skip if exists, or update it)
406        if let Some(ref vcs) = self.vcs {
407            let release_name = format!("{} {}", self.config.tag_prefix, plan.next_version);
408            if vcs
409                .release_exists(&plan.tag_name)
410                .map_err(&run_failure_hooks)?
411            {
412                // Delete and recreate to update the release notes
413                vcs.delete_release(&plan.tag_name)
414                    .map_err(&run_failure_hooks)?;
415            }
416            vcs.create_release(&plan.tag_name, &release_name, &changelog_body, false)
417                .map_err(&run_failure_hooks)?;
418        }
419
420        // 9.5 Upload artifacts
421        if let Some(ref vcs) = self.vcs
422            && !self.config.artifacts.is_empty()
423        {
424            let resolved =
425                resolve_artifact_globs(&self.config.artifacts).map_err(&run_failure_hooks)?;
426            if !resolved.is_empty() {
427                let file_refs: Vec<&str> = resolved.iter().map(|s| s.as_str()).collect();
428                vcs.upload_assets(&plan.tag_name, &file_refs)
429                    .map_err(&run_failure_hooks)?;
430                eprintln!(
431                    "Uploaded {} artifact(s) to {}",
432                    resolved.len(),
433                    plan.tag_name
434                );
435            }
436        }
437
438        // 10. Post-release hooks
439        let post_release_ctx = build_hook_context(plan, "post_release", false);
440        self.hooks
441            .run(
442                &to_hook_commands(&self.config.hooks.post_release),
443                &post_release_ctx,
444            )
445            .map_err(&run_failure_hooks)?;
446
447        eprintln!("Released {}", plan.tag_name);
448        Ok(())
449    }
450}
451
452fn resolve_artifact_globs(patterns: &[String]) -> Result<Vec<String>, ReleaseError> {
453    let mut files = std::collections::BTreeSet::new();
454    for pattern in patterns {
455        let paths = glob::glob(pattern)
456            .map_err(|e| ReleaseError::Vcs(format!("invalid glob pattern '{pattern}': {e}")))?;
457        for entry in paths {
458            match entry {
459                Ok(path) if path.is_file() => {
460                    files.insert(path.to_string_lossy().into_owned());
461                }
462                Ok(_) => {} // skip directories
463                Err(e) => {
464                    eprintln!("warning: glob error: {e}");
465                }
466            }
467        }
468    }
469    Ok(files.into_iter().collect())
470}
471
472pub fn today_string() -> String {
473    // Use a simple approach: read from the `date` command or fallback
474    std::process::Command::new("date")
475        .arg("+%Y-%m-%d")
476        .output()
477        .ok()
478        .and_then(|o| {
479            if o.status.success() {
480                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
481            } else {
482                None
483            }
484        })
485        .unwrap_or_else(|| "unknown".to_string())
486}
487
488#[cfg(test)]
489mod tests {
490    use std::sync::Mutex;
491
492    use super::*;
493    use crate::changelog::DefaultChangelogFormatter;
494    use crate::commit::{Commit, DefaultCommitParser};
495    use crate::config::ReleaseConfig;
496    use crate::git::{GitRepository, TagInfo};
497    use crate::hooks::{HookCommand, HookContext, HookRunner};
498
499    // --- Fakes ---
500
501    struct FakeGit {
502        tags: Vec<TagInfo>,
503        commits: Vec<Commit>,
504        head: String,
505        created_tags: Mutex<Vec<String>>,
506        pushed_tags: Mutex<Vec<String>>,
507        committed: Mutex<Vec<(Vec<String>, String)>>,
508        push_count: Mutex<u32>,
509        force_created_tags: Mutex<Vec<String>>,
510        force_pushed_tags: Mutex<Vec<String>>,
511    }
512
513    impl FakeGit {
514        fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
515            let head = tags
516                .last()
517                .map(|t| t.sha.clone())
518                .unwrap_or_else(|| "0".repeat(40));
519            Self {
520                tags,
521                commits,
522                head,
523                created_tags: Mutex::new(Vec::new()),
524                pushed_tags: Mutex::new(Vec::new()),
525                committed: Mutex::new(Vec::new()),
526                push_count: Mutex::new(0),
527                force_created_tags: Mutex::new(Vec::new()),
528                force_pushed_tags: Mutex::new(Vec::new()),
529            }
530        }
531    }
532
533    impl GitRepository for FakeGit {
534        fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
535            Ok(self.tags.last().cloned())
536        }
537
538        fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
539            Ok(self.commits.clone())
540        }
541
542        fn create_tag(&self, name: &str, _message: &str) -> Result<(), ReleaseError> {
543            self.created_tags.lock().unwrap().push(name.to_string());
544            Ok(())
545        }
546
547        fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
548            self.pushed_tags.lock().unwrap().push(name.to_string());
549            Ok(())
550        }
551
552        fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
553            self.committed.lock().unwrap().push((
554                paths.iter().map(|s| s.to_string()).collect(),
555                message.to_string(),
556            ));
557            Ok(true)
558        }
559
560        fn push(&self) -> Result<(), ReleaseError> {
561            *self.push_count.lock().unwrap() += 1;
562            Ok(())
563        }
564
565        fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
566            Ok(self
567                .created_tags
568                .lock()
569                .unwrap()
570                .contains(&name.to_string()))
571        }
572
573        fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
574            Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
575        }
576
577        fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
578            Ok(self.tags.clone())
579        }
580
581        fn commits_between(
582            &self,
583            _from: Option<&str>,
584            _to: &str,
585        ) -> Result<Vec<Commit>, ReleaseError> {
586            Ok(self.commits.clone())
587        }
588
589        fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
590            Ok("2026-01-01".into())
591        }
592
593        fn force_create_tag(&self, name: &str, _message: &str) -> Result<(), ReleaseError> {
594            self.force_created_tags
595                .lock()
596                .unwrap()
597                .push(name.to_string());
598            Ok(())
599        }
600
601        fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
602            self.force_pushed_tags
603                .lock()
604                .unwrap()
605                .push(name.to_string());
606            Ok(())
607        }
608
609        fn head_sha(&self) -> Result<String, ReleaseError> {
610            Ok(self.head.clone())
611        }
612    }
613
614    struct FakeVcs {
615        releases: Mutex<Vec<(String, String)>>,
616        deleted_releases: Mutex<Vec<String>>,
617        uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
618    }
619
620    impl FakeVcs {
621        fn new() -> Self {
622            Self {
623                releases: Mutex::new(Vec::new()),
624                deleted_releases: Mutex::new(Vec::new()),
625                uploaded_assets: Mutex::new(Vec::new()),
626            }
627        }
628    }
629
630    impl VcsProvider for FakeVcs {
631        fn create_release(
632            &self,
633            tag: &str,
634            _name: &str,
635            body: &str,
636            _prerelease: bool,
637        ) -> Result<String, ReleaseError> {
638            self.releases
639                .lock()
640                .unwrap()
641                .push((tag.to_string(), body.to_string()));
642            Ok(format!("https://github.com/test/release/{tag}"))
643        }
644
645        fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
646            Ok(format!("https://github.com/test/compare/{base}...{head}"))
647        }
648
649        fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
650            Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
651        }
652
653        fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
654            self.deleted_releases.lock().unwrap().push(tag.to_string());
655            self.releases.lock().unwrap().retain(|(t, _)| t != tag);
656            Ok(())
657        }
658
659        fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
660            self.uploaded_assets.lock().unwrap().push((
661                tag.to_string(),
662                files.iter().map(|s| s.to_string()).collect(),
663            ));
664            Ok(())
665        }
666
667        fn repo_url(&self) -> Option<String> {
668            Some("https://github.com/test/repo".into())
669        }
670    }
671
672    struct FakeHooks {
673        run_log: Mutex<Vec<String>>,
674        ctx_log: Mutex<Vec<HookContext>>,
675    }
676
677    impl FakeHooks {
678        fn new() -> Self {
679            Self {
680                run_log: Mutex::new(Vec::new()),
681                ctx_log: Mutex::new(Vec::new()),
682            }
683        }
684    }
685
686    impl HookRunner for FakeHooks {
687        fn run(&self, hooks: &[HookCommand], ctx: &HookContext) -> Result<(), ReleaseError> {
688            for h in hooks {
689                self.run_log.lock().unwrap().push(h.command.clone());
690            }
691            if !hooks.is_empty() {
692                self.ctx_log.lock().unwrap().push(ctx.clone());
693            }
694            Ok(())
695        }
696    }
697
698    // --- Helpers ---
699
700    fn raw_commit(msg: &str) -> Commit {
701        Commit {
702            sha: "a".repeat(40),
703            message: msg.into(),
704        }
705    }
706
707    fn make_strategy(
708        tags: Vec<TagInfo>,
709        commits: Vec<Commit>,
710        config: ReleaseConfig,
711    ) -> TrunkReleaseStrategy<
712        FakeGit,
713        FakeVcs,
714        DefaultCommitParser,
715        DefaultChangelogFormatter,
716        FakeHooks,
717    > {
718        let types = config.types.clone();
719        let breaking_section = config.breaking_section.clone();
720        let misc_section = config.misc_section.clone();
721        TrunkReleaseStrategy {
722            git: FakeGit::new(tags, commits),
723            vcs: Some(FakeVcs::new()),
724            parser: DefaultCommitParser,
725            formatter: DefaultChangelogFormatter::new(None, types, breaking_section, misc_section),
726            hooks: FakeHooks::new(),
727            config,
728            force: false,
729        }
730    }
731
732    // --- plan() tests ---
733
734    #[test]
735    fn plan_no_commits_returns_error() {
736        let s = make_strategy(vec![], vec![], ReleaseConfig::default());
737        let err = s.plan().unwrap_err();
738        assert!(matches!(err, ReleaseError::NoCommits { .. }));
739    }
740
741    #[test]
742    fn plan_no_releasable_returns_error() {
743        let s = make_strategy(
744            vec![],
745            vec![raw_commit("chore: tidy up")],
746            ReleaseConfig::default(),
747        );
748        let err = s.plan().unwrap_err();
749        assert!(matches!(err, ReleaseError::NoBump { .. }));
750    }
751
752    #[test]
753    fn plan_first_release() {
754        let s = make_strategy(
755            vec![],
756            vec![raw_commit("feat: initial feature")],
757            ReleaseConfig::default(),
758        );
759        let plan = s.plan().unwrap();
760        assert_eq!(plan.next_version, Version::new(0, 1, 0));
761        assert_eq!(plan.tag_name, "v0.1.0");
762        assert!(plan.current_version.is_none());
763    }
764
765    #[test]
766    fn plan_increments_existing() {
767        let tag = TagInfo {
768            name: "v1.2.3".into(),
769            version: Version::new(1, 2, 3),
770            sha: "b".repeat(40),
771        };
772        let s = make_strategy(
773            vec![tag],
774            vec![raw_commit("fix: patch bug")],
775            ReleaseConfig::default(),
776        );
777        let plan = s.plan().unwrap();
778        assert_eq!(plan.next_version, Version::new(1, 2, 4));
779    }
780
781    #[test]
782    fn plan_breaking_bump() {
783        let tag = TagInfo {
784            name: "v1.2.3".into(),
785            version: Version::new(1, 2, 3),
786            sha: "c".repeat(40),
787        };
788        let s = make_strategy(
789            vec![tag],
790            vec![raw_commit("feat!: breaking change")],
791            ReleaseConfig::default(),
792        );
793        let plan = s.plan().unwrap();
794        assert_eq!(plan.next_version, Version::new(2, 0, 0));
795    }
796
797    // --- execute() tests ---
798
799    #[test]
800    fn execute_dry_run_no_side_effects() {
801        let mut config = ReleaseConfig::default();
802        config.hooks.pre_release = vec!["echo pre".into()];
803        config.hooks.post_tag = vec!["echo post-tag".into()];
804        config.hooks.post_release = vec!["echo post-release".into()];
805
806        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
807        let plan = s.plan().unwrap();
808        s.execute(&plan, true).unwrap();
809
810        assert!(s.git.created_tags.lock().unwrap().is_empty());
811        assert!(s.git.pushed_tags.lock().unwrap().is_empty());
812        assert!(s.hooks.run_log.lock().unwrap().is_empty());
813    }
814
815    #[test]
816    fn execute_creates_and_pushes_tag() {
817        let s = make_strategy(
818            vec![],
819            vec![raw_commit("feat: something")],
820            ReleaseConfig::default(),
821        );
822        let plan = s.plan().unwrap();
823        s.execute(&plan, false).unwrap();
824
825        assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
826        assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
827    }
828
829    #[test]
830    fn execute_runs_hooks_in_order() {
831        let mut config = ReleaseConfig::default();
832        config.hooks.pre_release = vec!["echo pre".into()];
833        config.hooks.post_tag = vec!["echo post-tag".into()];
834        config.hooks.post_release = vec!["echo post-release".into()];
835
836        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
837        let plan = s.plan().unwrap();
838        s.execute(&plan, false).unwrap();
839
840        let log = s.hooks.run_log.lock().unwrap();
841        assert_eq!(*log, vec!["echo pre", "echo post-tag", "echo post-release"]);
842    }
843
844    #[test]
845    fn execute_calls_vcs_create_release() {
846        let s = make_strategy(
847            vec![],
848            vec![raw_commit("feat: something")],
849            ReleaseConfig::default(),
850        );
851        let plan = s.plan().unwrap();
852        s.execute(&plan, false).unwrap();
853
854        let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
855        assert_eq!(releases.len(), 1);
856        assert_eq!(releases[0].0, "v0.1.0");
857        assert!(!releases[0].1.is_empty());
858    }
859
860    #[test]
861    fn execute_commits_changelog_before_tag() {
862        let dir = tempfile::tempdir().unwrap();
863        let changelog_path = dir.path().join("CHANGELOG.md");
864
865        let mut config = ReleaseConfig::default();
866        config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
867
868        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
869        let plan = s.plan().unwrap();
870        s.execute(&plan, false).unwrap();
871
872        // Verify changelog was committed
873        let committed = s.git.committed.lock().unwrap();
874        assert_eq!(committed.len(), 1);
875        assert_eq!(
876            committed[0].0,
877            vec![changelog_path.to_str().unwrap().to_string()]
878        );
879        assert!(committed[0].1.contains("chore(release): v0.1.0"));
880
881        // Verify tag was created after commit
882        assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
883    }
884
885    #[test]
886    fn execute_skips_existing_tag() {
887        let s = make_strategy(
888            vec![],
889            vec![raw_commit("feat: something")],
890            ReleaseConfig::default(),
891        );
892        let plan = s.plan().unwrap();
893
894        // Pre-populate the tag to simulate it already existing
895        s.git
896            .created_tags
897            .lock()
898            .unwrap()
899            .push("v0.1.0".to_string());
900
901        s.execute(&plan, false).unwrap();
902
903        // Tag should not be created again (still only the one we pre-populated)
904        assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
905    }
906
907    #[test]
908    fn execute_skips_existing_release() {
909        let s = make_strategy(
910            vec![],
911            vec![raw_commit("feat: something")],
912            ReleaseConfig::default(),
913        );
914        let plan = s.plan().unwrap();
915
916        // Pre-populate a release to simulate it already existing
917        s.vcs
918            .as_ref()
919            .unwrap()
920            .releases
921            .lock()
922            .unwrap()
923            .push(("v0.1.0".to_string(), "old notes".to_string()));
924
925        s.execute(&plan, false).unwrap();
926
927        // Should have deleted the old release and created a new one
928        let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
929        assert_eq!(*deleted, vec!["v0.1.0"]);
930
931        let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
932        assert_eq!(releases.len(), 1);
933        assert_eq!(releases[0].0, "v0.1.0");
934        assert_ne!(releases[0].1, "old notes");
935    }
936
937    #[test]
938    fn execute_idempotent_rerun() {
939        let s = make_strategy(
940            vec![],
941            vec![raw_commit("feat: something")],
942            ReleaseConfig::default(),
943        );
944        let plan = s.plan().unwrap();
945
946        // First run
947        s.execute(&plan, false).unwrap();
948
949        // Second run should also succeed (idempotent)
950        s.execute(&plan, false).unwrap();
951
952        // Tag should only have been created once (second run skips because tag_exists)
953        assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
954
955        // Tag push should only happen once (second run skips because remote_tag_exists)
956        assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
957
958        // Push (commit) should happen twice (always safe)
959        assert_eq!(*s.git.push_count.lock().unwrap(), 2);
960
961        // Release should be deleted and recreated on second run
962        let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
963        assert_eq!(*deleted, vec!["v0.1.0"]);
964
965        let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
966        // One entry: delete removed the first, create added a replacement
967        assert_eq!(releases.len(), 1);
968        assert_eq!(releases[0].0, "v0.1.0");
969    }
970
971    #[test]
972    fn execute_passes_hook_context_with_version_info() {
973        let tag = TagInfo {
974            name: "v1.2.3".into(),
975            version: Version::new(1, 2, 3),
976            sha: "b".repeat(40),
977        };
978        let mut config = ReleaseConfig::default();
979        config.hooks.pre_release = vec!["echo pre".into()];
980        config.hooks.post_tag = vec!["echo post-tag".into()];
981        config.hooks.post_release = vec!["echo post-release".into()];
982
983        let s = make_strategy(
984            vec![tag],
985            vec![raw_commit("feat: first"), raw_commit("fix: second")],
986            config,
987        );
988        let plan = s.plan().unwrap();
989        s.execute(&plan, false).unwrap();
990
991        let ctx_log = s.hooks.ctx_log.lock().unwrap();
992        assert_eq!(ctx_log.len(), 3);
993
994        // pre_release context
995        let pre = &ctx_log[0];
996        assert_eq!(pre.env.get("SR_VERSION").unwrap(), "1.3.0");
997        assert_eq!(pre.env.get("SR_PREVIOUS_VERSION").unwrap(), "1.2.3");
998        assert_eq!(pre.env.get("SR_TAG").unwrap(), "v1.3.0");
999        assert_eq!(pre.env.get("SR_BUMP_LEVEL").unwrap(), "minor");
1000        assert_eq!(pre.env.get("SR_DRY_RUN").unwrap(), "false");
1001        assert_eq!(pre.env.get("SR_COMMIT_COUNT").unwrap(), "2");
1002        assert_eq!(pre.env.get("SR_HOOK_PHASE").unwrap(), "pre_release");
1003
1004        // post_tag context
1005        let post_tag = &ctx_log[1];
1006        assert_eq!(post_tag.env.get("SR_HOOK_PHASE").unwrap(), "post_tag");
1007        assert_eq!(post_tag.env.get("SR_VERSION").unwrap(), "1.3.0");
1008
1009        // post_release context
1010        let post_release = &ctx_log[2];
1011        assert_eq!(
1012            post_release.env.get("SR_HOOK_PHASE").unwrap(),
1013            "post_release"
1014        );
1015        assert_eq!(post_release.env.get("SR_VERSION").unwrap(), "1.3.0");
1016    }
1017
1018    #[test]
1019    fn hook_context_first_release_has_empty_previous_version() {
1020        let mut config = ReleaseConfig::default();
1021        config.hooks.pre_release = vec!["echo pre".into()];
1022
1023        let s = make_strategy(vec![], vec![raw_commit("feat: initial")], config);
1024        let plan = s.plan().unwrap();
1025        s.execute(&plan, false).unwrap();
1026
1027        let ctx_log = s.hooks.ctx_log.lock().unwrap();
1028        assert!(!ctx_log.is_empty());
1029        let pre = &ctx_log[0];
1030        assert_eq!(pre.env.get("SR_PREVIOUS_VERSION").unwrap(), "");
1031        assert_eq!(pre.env.get("SR_VERSION").unwrap(), "0.1.0");
1032    }
1033
1034    #[test]
1035    fn execute_bumps_version_files() {
1036        let dir = tempfile::tempdir().unwrap();
1037        let cargo_path = dir.path().join("Cargo.toml");
1038        std::fs::write(
1039            &cargo_path,
1040            "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1041        )
1042        .unwrap();
1043
1044        let mut config = ReleaseConfig::default();
1045        config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
1046
1047        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1048        let plan = s.plan().unwrap();
1049        s.execute(&plan, false).unwrap();
1050
1051        // Verify the file was bumped
1052        let contents = std::fs::read_to_string(&cargo_path).unwrap();
1053        assert!(contents.contains("version = \"0.1.0\""));
1054
1055        // Verify it was staged alongside the commit
1056        let committed = s.git.committed.lock().unwrap();
1057        assert_eq!(committed.len(), 1);
1058        assert!(
1059            committed[0]
1060                .0
1061                .contains(&cargo_path.to_str().unwrap().to_string())
1062        );
1063    }
1064
1065    #[test]
1066    fn execute_stages_changelog_and_version_files_together() {
1067        let dir = tempfile::tempdir().unwrap();
1068        let cargo_path = dir.path().join("Cargo.toml");
1069        std::fs::write(
1070            &cargo_path,
1071            "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1072        )
1073        .unwrap();
1074
1075        let changelog_path = dir.path().join("CHANGELOG.md");
1076
1077        let mut config = ReleaseConfig::default();
1078        config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
1079        config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
1080
1081        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1082        let plan = s.plan().unwrap();
1083        s.execute(&plan, false).unwrap();
1084
1085        // Both changelog and version file should be staged in a single commit
1086        let committed = s.git.committed.lock().unwrap();
1087        assert_eq!(committed.len(), 1);
1088        assert!(
1089            committed[0]
1090                .0
1091                .contains(&changelog_path.to_str().unwrap().to_string())
1092        );
1093        assert!(
1094            committed[0]
1095                .0
1096                .contains(&cargo_path.to_str().unwrap().to_string())
1097        );
1098    }
1099
1100    // --- artifact upload tests ---
1101
1102    #[test]
1103    fn execute_uploads_artifacts() {
1104        let dir = tempfile::tempdir().unwrap();
1105        std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1106        std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
1107
1108        let mut config = ReleaseConfig::default();
1109        config.artifacts = vec![
1110            dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
1111            dir.path().join("*.zip").to_str().unwrap().to_string(),
1112        ];
1113
1114        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1115        let plan = s.plan().unwrap();
1116        s.execute(&plan, false).unwrap();
1117
1118        let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1119        assert_eq!(uploaded.len(), 1);
1120        assert_eq!(uploaded[0].0, "v0.1.0");
1121        assert_eq!(uploaded[0].1.len(), 2);
1122        assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.tar.gz")));
1123        assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip")));
1124    }
1125
1126    #[test]
1127    fn execute_dry_run_shows_artifacts() {
1128        let dir = tempfile::tempdir().unwrap();
1129        std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1130
1131        let mut config = ReleaseConfig::default();
1132        config.artifacts = vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()];
1133
1134        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1135        let plan = s.plan().unwrap();
1136        s.execute(&plan, true).unwrap();
1137
1138        // No uploads should happen during dry-run
1139        let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1140        assert!(uploaded.is_empty());
1141    }
1142
1143    #[test]
1144    fn execute_no_artifacts_skips_upload() {
1145        let s = make_strategy(
1146            vec![],
1147            vec![raw_commit("feat: something")],
1148            ReleaseConfig::default(),
1149        );
1150        let plan = s.plan().unwrap();
1151        s.execute(&plan, false).unwrap();
1152
1153        let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1154        assert!(uploaded.is_empty());
1155    }
1156
1157    #[test]
1158    fn resolve_artifact_globs_basic() {
1159        let dir = tempfile::tempdir().unwrap();
1160        std::fs::write(dir.path().join("a.txt"), "a").unwrap();
1161        std::fs::write(dir.path().join("b.txt"), "b").unwrap();
1162        std::fs::create_dir(dir.path().join("subdir")).unwrap();
1163
1164        let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1165        let result = resolve_artifact_globs(&[pattern]).unwrap();
1166        assert_eq!(result.len(), 2);
1167        assert!(result.iter().any(|f| f.ends_with("a.txt")));
1168        assert!(result.iter().any(|f| f.ends_with("b.txt")));
1169    }
1170
1171    #[test]
1172    fn resolve_artifact_globs_deduplicates() {
1173        let dir = tempfile::tempdir().unwrap();
1174        std::fs::write(dir.path().join("file.txt"), "data").unwrap();
1175
1176        let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1177        // Same pattern twice should not produce duplicates
1178        let result = resolve_artifact_globs(&[pattern.clone(), pattern]).unwrap();
1179        assert_eq!(result.len(), 1);
1180    }
1181
1182    // --- floating tags tests ---
1183
1184    #[test]
1185    fn plan_floating_tag_when_enabled() {
1186        let tag = TagInfo {
1187            name: "v3.2.0".into(),
1188            version: Version::new(3, 2, 0),
1189            sha: "d".repeat(40),
1190        };
1191        let mut config = ReleaseConfig::default();
1192        config.floating_tags = true;
1193
1194        let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1195        let plan = s.plan().unwrap();
1196        assert_eq!(plan.next_version, Version::new(3, 2, 1));
1197        assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
1198    }
1199
1200    #[test]
1201    fn plan_no_floating_tag_when_disabled() {
1202        let s = make_strategy(
1203            vec![],
1204            vec![raw_commit("feat: something")],
1205            ReleaseConfig::default(),
1206        );
1207        let plan = s.plan().unwrap();
1208        assert!(plan.floating_tag_name.is_none());
1209    }
1210
1211    #[test]
1212    fn plan_floating_tag_custom_prefix() {
1213        let tag = TagInfo {
1214            name: "release-2.5.0".into(),
1215            version: Version::new(2, 5, 0),
1216            sha: "e".repeat(40),
1217        };
1218        let mut config = ReleaseConfig::default();
1219        config.floating_tags = true;
1220        config.tag_prefix = "release-".into();
1221
1222        let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1223        let plan = s.plan().unwrap();
1224        assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
1225    }
1226
1227    #[test]
1228    fn execute_floating_tags_force_create_and_push() {
1229        let mut config = ReleaseConfig::default();
1230        config.floating_tags = true;
1231
1232        let tag = TagInfo {
1233            name: "v1.2.3".into(),
1234            version: Version::new(1, 2, 3),
1235            sha: "f".repeat(40),
1236        };
1237        let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
1238        let plan = s.plan().unwrap();
1239        assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
1240
1241        s.execute(&plan, false).unwrap();
1242
1243        assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
1244        assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
1245    }
1246
1247    #[test]
1248    fn execute_no_floating_tags_when_disabled() {
1249        let s = make_strategy(
1250            vec![],
1251            vec![raw_commit("feat: something")],
1252            ReleaseConfig::default(),
1253        );
1254        let plan = s.plan().unwrap();
1255        assert!(plan.floating_tag_name.is_none());
1256
1257        s.execute(&plan, false).unwrap();
1258
1259        assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1260        assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1261    }
1262
1263    #[test]
1264    fn execute_floating_tags_dry_run_no_side_effects() {
1265        let mut config = ReleaseConfig::default();
1266        config.floating_tags = true;
1267
1268        let tag = TagInfo {
1269            name: "v2.0.0".into(),
1270            version: Version::new(2, 0, 0),
1271            sha: "a".repeat(40),
1272        };
1273        let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
1274        let plan = s.plan().unwrap();
1275        assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
1276
1277        s.execute(&plan, true).unwrap();
1278
1279        assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1280        assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1281    }
1282
1283    #[test]
1284    fn execute_floating_tags_idempotent() {
1285        let mut config = ReleaseConfig::default();
1286        config.floating_tags = true;
1287
1288        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1289        let plan = s.plan().unwrap();
1290        assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
1291
1292        // Run twice
1293        s.execute(&plan, false).unwrap();
1294        s.execute(&plan, false).unwrap();
1295
1296        // Force ops run every time (correct for floating tags)
1297        assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
1298        assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
1299    }
1300
1301    #[test]
1302    fn hook_context_includes_floating_tag() {
1303        let mut config = ReleaseConfig::default();
1304        config.floating_tags = true;
1305        config.hooks.pre_release = vec!["echo pre".into()];
1306
1307        let tag = TagInfo {
1308            name: "v3.0.0".into(),
1309            version: Version::new(3, 0, 0),
1310            sha: "b".repeat(40),
1311        };
1312        let s = make_strategy(vec![tag], vec![raw_commit("feat: new")], config);
1313        let plan = s.plan().unwrap();
1314        s.execute(&plan, false).unwrap();
1315
1316        let ctx_log = s.hooks.ctx_log.lock().unwrap();
1317        assert!(!ctx_log.is_empty());
1318        assert_eq!(ctx_log[0].env.get("SR_FLOATING_TAG").unwrap(), "v3");
1319    }
1320
1321    #[test]
1322    fn hook_context_floating_tag_empty_when_disabled() {
1323        let mut config = ReleaseConfig::default();
1324        config.hooks.pre_release = vec!["echo pre".into()];
1325
1326        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1327        let plan = s.plan().unwrap();
1328        s.execute(&plan, false).unwrap();
1329
1330        let ctx_log = s.hooks.ctx_log.lock().unwrap();
1331        assert!(!ctx_log.is_empty());
1332        assert_eq!(ctx_log[0].env.get("SR_FLOATING_TAG").unwrap(), "");
1333    }
1334
1335    // --- force mode tests ---
1336
1337    #[test]
1338    fn force_rerelease_when_tag_at_head() {
1339        let tag = TagInfo {
1340            name: "v1.2.3".into(),
1341            version: Version::new(1, 2, 3),
1342            sha: "a".repeat(40),
1343        };
1344        let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1345        // HEAD == tag SHA, and no new commits
1346        s.git.head = "a".repeat(40);
1347        s.force = true;
1348
1349        let plan = s.plan().unwrap();
1350        assert_eq!(plan.next_version, Version::new(1, 2, 3));
1351        assert_eq!(plan.tag_name, "v1.2.3");
1352        assert!(plan.commits.is_empty());
1353        assert_eq!(plan.current_version, Some(Version::new(1, 2, 3)));
1354    }
1355
1356    #[test]
1357    fn force_fails_when_tag_not_at_head() {
1358        let tag = TagInfo {
1359            name: "v1.2.3".into(),
1360            version: Version::new(1, 2, 3),
1361            sha: "a".repeat(40),
1362        };
1363        let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1364        // HEAD != tag SHA
1365        s.git.head = "b".repeat(40);
1366        s.force = true;
1367
1368        let err = s.plan().unwrap_err();
1369        assert!(matches!(err, ReleaseError::NoCommits { .. }));
1370    }
1371
1372    #[test]
1373    fn force_fails_with_no_tags() {
1374        let mut s = make_strategy(vec![], vec![], ReleaseConfig::default());
1375        s.force = true;
1376
1377        let err = s.plan().unwrap_err();
1378        assert!(matches!(err, ReleaseError::NoCommits { .. }));
1379    }
1380}