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::{Config, HookEvent};
10use crate::error::ReleaseError;
11use crate::git::GitRepository;
12use crate::version::{BumpLevel, apply_bump, apply_prerelease_bump, determine_bump};
13use crate::version_files::{bump_version_file, discover_lock_files, is_supported_version_file};
14
15#[derive(Debug, Serialize)]
17pub struct ReleasePlan {
18 pub current_version: Option<Version>,
19 pub next_version: Version,
20 pub bump: BumpLevel,
21 pub commits: Vec<ConventionalCommit>,
22 pub tag_name: String,
23 pub floating_tag_name: Option<String>,
24 pub prerelease: bool,
25}
26
27pub trait ReleaseStrategy: Send + Sync {
29 fn plan(&self) -> Result<ReleasePlan, ReleaseError>;
31
32 fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError>;
34}
35
36pub trait VcsProvider: Send + Sync {
38 fn create_release(
40 &self,
41 tag: &str,
42 name: &str,
43 body: &str,
44 prerelease: bool,
45 draft: bool,
46 ) -> Result<String, ReleaseError>;
47
48 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError>;
50
51 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError>;
53
54 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError>;
56
57 fn repo_url(&self) -> Option<String> {
59 None
60 }
61
62 fn update_release(
65 &self,
66 _tag: &str,
67 _name: &str,
68 _body: &str,
69 _prerelease: bool,
70 _draft: bool,
71 ) -> Result<String, ReleaseError> {
72 Err(ReleaseError::Vcs(
73 "update_release not implemented for this provider".into(),
74 ))
75 }
76
77 fn upload_assets(&self, _tag: &str, _files: &[&str]) -> Result<(), ReleaseError> {
79 Ok(())
80 }
81
82 fn verify_release(&self, _tag: &str) -> Result<(), ReleaseError> {
84 Ok(())
85 }
86}
87
88pub struct NoopVcsProvider;
91
92impl VcsProvider for NoopVcsProvider {
93 fn create_release(
94 &self,
95 _tag: &str,
96 _name: &str,
97 _body: &str,
98 _prerelease: bool,
99 _draft: bool,
100 ) -> Result<String, ReleaseError> {
101 Ok(String::new())
102 }
103
104 fn compare_url(&self, _base: &str, _head: &str) -> Result<String, ReleaseError> {
105 Ok(String::new())
106 }
107
108 fn release_exists(&self, _tag: &str) -> Result<bool, ReleaseError> {
109 Ok(false)
110 }
111
112 fn delete_release(&self, _tag: &str) -> Result<(), ReleaseError> {
113 Ok(())
114 }
115}
116
117pub struct TrunkReleaseStrategy<G, V, C, F> {
119 pub git: G,
120 pub vcs: V,
121 pub parser: C,
122 pub formatter: F,
123 pub config: Config,
124 pub force: bool,
126}
127
128impl<G, V, C, F> TrunkReleaseStrategy<G, V, C, F>
129where
130 G: GitRepository,
131 V: VcsProvider,
132 C: CommitParser,
133 F: ChangelogFormatter,
134{
135 fn format_changelog(&self, plan: &ReleasePlan) -> Result<String, ReleaseError> {
136 let today = today_string();
137 let compare_url = match &plan.current_version {
138 Some(v) => {
139 let base = format!("{}{v}", self.config.release.tag_prefix);
140 self.vcs
141 .compare_url(&base, &plan.tag_name)
142 .ok()
143 .filter(|s| !s.is_empty())
144 }
145 None => None,
146 };
147 let entry = ChangelogEntry {
148 version: plan.next_version.to_string(),
149 date: today,
150 commits: plan.commits.clone(),
151 compare_url,
152 repo_url: self.vcs.repo_url(),
153 };
154 self.formatter.format(&[entry])
155 }
156
157 fn release_name(&self, plan: &ReleasePlan) -> String {
159 if let Some(ref template_str) = self.config.release.release_name_template {
160 let mut env = minijinja::Environment::new();
161 if env.add_template("release_name", template_str).is_ok()
162 && let Ok(tmpl) = env.get_template("release_name")
163 && let Ok(rendered) = tmpl.render(minijinja::context! {
164 version => plan.next_version.to_string(),
165 tag_name => &plan.tag_name,
166 tag_prefix => &self.config.release.tag_prefix,
167 })
168 {
169 return rendered;
170 }
171 eprintln!("warning: invalid release_name_template, falling back to tag name");
172 }
173 plan.tag_name.clone()
174 }
175}
176
177impl<G, V, C, F> ReleaseStrategy for TrunkReleaseStrategy<G, V, C, F>
178where
179 G: GitRepository,
180 V: VcsProvider,
181 C: CommitParser,
182 F: ChangelogFormatter,
183{
184 fn plan(&self) -> Result<ReleasePlan, ReleaseError> {
185 let is_prerelease = self.config.release.prerelease.is_some();
186
187 let all_tags = self.git.all_tags(&self.config.release.tag_prefix)?;
190 let latest_stable = all_tags.iter().rev().find(|t| t.version.pre.is_empty());
191 let latest_any = all_tags.last();
192
193 let tag_info = if is_prerelease {
195 latest_any
196 } else {
197 latest_stable.or(latest_any)
198 };
199
200 let (current_version, from_sha) = match tag_info {
201 Some(info) => (Some(info.version.clone()), Some(info.sha.as_str())),
202 None => (None, None),
203 };
204
205 let raw_commits = if let Some(ref path) = self.config.release.path_filter {
206 self.git.commits_since_in_path(from_sha, path)?
207 } else {
208 self.git.commits_since(from_sha)?
209 };
210 if raw_commits.is_empty() {
211 if self.force
213 && let Some(info) = tag_info
214 {
215 let head = self.git.head_sha()?;
216 if head == info.sha {
217 let floating_tag_name = if self.config.release.floating_tags {
218 Some(format!(
219 "{}{}",
220 self.config.release.tag_prefix, info.version.major
221 ))
222 } else {
223 None
224 };
225 return Ok(ReleasePlan {
226 current_version: Some(info.version.clone()),
227 next_version: info.version.clone(),
228 bump: BumpLevel::Patch,
229 commits: vec![],
230 tag_name: info.name.clone(),
231 floating_tag_name,
232 prerelease: is_prerelease,
233 });
234 }
235 }
236 let (tag, sha) = match tag_info {
237 Some(info) => (info.name.clone(), info.sha.clone()),
238 None => ("(none)".into(), "(none)".into()),
239 };
240 return Err(ReleaseError::NoCommits { tag, sha });
241 }
242
243 let conventional_commits: Vec<ConventionalCommit> = raw_commits
244 .iter()
245 .filter(|c| !c.message.starts_with("chore(release):"))
246 .filter_map(|c| self.parser.parse(c).ok())
247 .collect();
248
249 let classifier = DefaultCommitClassifier::new(
250 self.config.commit.types.clone(),
251 self.config.commit.pattern.clone(),
252 );
253 let tag_for_err = tag_info
254 .map(|i| i.name.clone())
255 .unwrap_or_else(|| "(none)".into());
256 let commit_count = conventional_commits.len();
257 let bump = match determine_bump(&conventional_commits, &classifier) {
258 Some(b) => b,
259 None if self.force => BumpLevel::Patch,
260 None => {
261 return Err(ReleaseError::NoBump {
262 tag: tag_for_err,
263 commit_count,
264 });
265 }
266 };
267
268 let base_version = if is_prerelease {
270 latest_stable
271 .map(|t| t.version.clone())
272 .or(current_version.clone())
273 .unwrap_or(Version::new(0, 0, 0))
274 } else {
275 current_version.clone().unwrap_or(Version::new(0, 0, 0))
276 };
277
278 let bump = if base_version.major == 0 && bump == BumpLevel::Major && !self.force {
281 eprintln!(
282 "v0 protection: breaking change detected at v{base_version}, \
283 downshifting major → minor (use --force to bump to v1)"
284 );
285 BumpLevel::Minor
286 } else {
287 bump
288 };
289
290 let next_version = if let Some(ref prerelease_id) = self.config.release.prerelease {
291 let existing_versions: Vec<Version> =
292 all_tags.iter().map(|t| t.version.clone()).collect();
293 apply_prerelease_bump(&base_version, bump, prerelease_id, &existing_versions)
294 } else {
295 apply_bump(&base_version, bump)
296 };
297
298 let tag_name = format!("{}{next_version}", self.config.release.tag_prefix);
299
300 let floating_tag_name = if self.config.release.floating_tags && !is_prerelease {
302 Some(format!(
303 "{}{}",
304 self.config.release.tag_prefix, next_version.major
305 ))
306 } else {
307 None
308 };
309
310 Ok(ReleasePlan {
311 current_version,
312 next_version,
313 bump,
314 commits: conventional_commits,
315 tag_name,
316 floating_tag_name,
317 prerelease: is_prerelease,
318 })
319 }
320
321 fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
322 let version_str = plan.next_version.to_string();
323
324 let env = release_env(&version_str, &plan.tag_name);
326 let env_refs: Vec<(&str, &str)> =
327 env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
328 if !dry_run {
329 crate::hooks::run_event(&self.config.hooks, HookEvent::PreRelease, &env_refs)?;
330 }
331
332 let changelog_body = self.format_changelog(plan)?;
334
335 self.bump_and_build(plan, &version_str, &changelog_body, dry_run)?;
337
338 self.create_and_push_tags(plan, &changelog_body, dry_run)?;
340
341 self.create_or_update_release(plan, &changelog_body, dry_run)?;
343 self.upload_artifacts(plan, dry_run)?;
344 self.verify_release_exists(plan, dry_run)?;
345
346 if !dry_run {
348 crate::hooks::run_event(&self.config.hooks, HookEvent::PostRelease, &env_refs)?;
349 }
350
351 if dry_run {
352 eprintln!("[dry-run] Changelog:\n{changelog_body}");
353 } else {
354 eprintln!("Released {}", plan.tag_name);
355 }
356 Ok(())
357 }
358}
359
360fn release_env(version: &str, tag: &str) -> Vec<(String, String)> {
362 vec![
363 ("SR_VERSION".into(), version.into()),
364 ("SR_TAG".into(), tag.into()),
365 ]
366}
367
368impl<G, V, C, F> TrunkReleaseStrategy<G, V, C, F>
369where
370 G: GitRepository,
371 V: VcsProvider,
372 C: CommitParser,
373 F: ChangelogFormatter,
374{
375 fn bump_and_build(
376 &self,
377 plan: &ReleasePlan,
378 version_str: &str,
379 changelog_body: &str,
380 dry_run: bool,
381 ) -> Result<(), ReleaseError> {
382 if dry_run {
383 for file in &self.config.release.version_files {
384 let filename = Path::new(file)
385 .file_name()
386 .and_then(|n| n.to_str())
387 .unwrap_or_default();
388 if is_supported_version_file(filename) {
389 eprintln!("[dry-run] Would bump version in: {file}");
390 } else if self.config.release.version_files_strict {
391 return Err(ReleaseError::VersionBump(format!(
392 "unsupported version file: {filename}"
393 )));
394 } else {
395 eprintln!("[dry-run] warning: unsupported version file, would skip: {file}");
396 }
397 }
398 if !self.config.release.stage_files.is_empty() {
399 eprintln!(
400 "[dry-run] Would stage additional files: {}",
401 self.config.release.stage_files.join(", ")
402 );
403 }
404 return Ok(());
405 }
406
407 let mut file_snapshots: Vec<(String, Option<String>)> = Vec::new();
409 for file in &self.config.release.version_files {
410 let path = Path::new(file);
411 let contents = if path.exists() {
412 Some(
413 fs::read_to_string(path)
414 .map_err(|e| ReleaseError::VersionBump(e.to_string()))?,
415 )
416 } else {
417 None
418 };
419 file_snapshots.push((file.clone(), contents));
420 }
421 if let Some(ref changelog_file) = self.config.release.changelog.file {
422 let path = Path::new(changelog_file);
423 let contents = if path.exists() {
424 Some(fs::read_to_string(path).map_err(|e| ReleaseError::Changelog(e.to_string()))?)
425 } else {
426 None
427 };
428 file_snapshots.push((changelog_file.clone(), contents));
429 }
430
431 let files_to_stage = match self.execute_mutations(version_str, changelog_body) {
433 Ok(files) => files,
434 Err(e) => {
435 eprintln!("error during release, restoring files...");
436 restore_snapshots(&file_snapshots);
437 return Err(e);
438 }
439 };
440
441 let mut paths_to_stage: Vec<String> = Vec::new();
443 if let Some(ref changelog_file) = self.config.release.changelog.file {
444 paths_to_stage.push(changelog_file.clone());
445 }
446 for file in &files_to_stage {
447 paths_to_stage.push(file.clone());
448 }
449 if !self.config.release.stage_files.is_empty() {
450 let extra =
451 resolve_globs(&self.config.release.stage_files).map_err(ReleaseError::Config)?;
452 paths_to_stage.extend(extra);
453 }
454 if !paths_to_stage.is_empty() {
455 let refs: Vec<&str> = paths_to_stage.iter().map(|s| s.as_str()).collect();
456 let commit_msg = format!("chore(release): {} [skip ci]", plan.tag_name);
457 self.git.stage_and_commit(&refs, &commit_msg)?;
458 }
459 Ok(())
460 }
461
462 fn create_and_push_tags(
463 &self,
464 plan: &ReleasePlan,
465 changelog_body: &str,
466 dry_run: bool,
467 ) -> Result<(), ReleaseError> {
468 if dry_run {
469 let sign_label = if self.config.release.sign_tags {
470 " (signed)"
471 } else {
472 ""
473 };
474 eprintln!("[dry-run] Would create tag: {}{sign_label}", plan.tag_name);
475 eprintln!("[dry-run] Would push commit and tag: {}", plan.tag_name);
476 if let Some(ref floating) = plan.floating_tag_name {
477 eprintln!("[dry-run] Would create/update floating tag: {floating}");
478 eprintln!("[dry-run] Would force-push floating tag: {floating}");
479 }
480 return Ok(());
481 }
482
483 if !self.git.tag_exists(&plan.tag_name)? {
485 let tag_message = format!("{}\n\n{}", plan.tag_name, changelog_body);
486 self.git
487 .create_tag(&plan.tag_name, &tag_message, self.config.release.sign_tags)?;
488 }
489
490 self.git.push()?;
492
493 if !self.git.remote_tag_exists(&plan.tag_name)? {
495 self.git.push_tag(&plan.tag_name)?;
496 }
497
498 if let Some(ref floating) = plan.floating_tag_name {
500 self.git.force_create_tag(floating)?;
501 self.git.force_push_tag(floating)?;
502 }
503 Ok(())
504 }
505
506 fn create_or_update_release(
507 &self,
508 plan: &ReleasePlan,
509 changelog_body: &str,
510 dry_run: bool,
511 ) -> Result<(), ReleaseError> {
512 if dry_run {
513 let draft_label = if self.config.release.draft {
514 " (draft)"
515 } else {
516 ""
517 };
518 let release_name = self.release_name(plan);
519 eprintln!(
520 "[dry-run] Would create GitHub release \"{release_name}\" for {}{draft_label}",
521 plan.tag_name
522 );
523 return Ok(());
524 }
525
526 let release_name = self.release_name(plan);
527 if self.vcs.release_exists(&plan.tag_name)? {
528 self.vcs.update_release(
529 &plan.tag_name,
530 &release_name,
531 changelog_body,
532 plan.prerelease,
533 self.config.release.draft,
534 )?;
535 } else {
536 self.vcs.create_release(
537 &plan.tag_name,
538 &release_name,
539 changelog_body,
540 plan.prerelease,
541 self.config.release.draft,
542 )?;
543 }
544 Ok(())
545 }
546
547 fn upload_artifacts(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
548 if self.config.release.artifacts.is_empty() {
549 return Ok(());
550 }
551
552 let resolved = resolve_globs(&self.config.release.artifacts).map_err(ReleaseError::Vcs)?;
553
554 if dry_run {
555 if resolved.is_empty() {
556 eprintln!("[dry-run] Artifact patterns matched no files");
557 } else {
558 eprintln!("[dry-run] Would upload {} artifact(s):", resolved.len());
559 for f in &resolved {
560 eprintln!("[dry-run] {f}");
561 }
562 }
563 return Ok(());
564 }
565
566 if !resolved.is_empty() {
567 let file_refs: Vec<&str> = resolved.iter().map(|s| s.as_str()).collect();
568 self.vcs.upload_assets(&plan.tag_name, &file_refs)?;
569 eprintln!(
570 "Uploaded {} artifact(s) to {}",
571 resolved.len(),
572 plan.tag_name
573 );
574 }
575 Ok(())
576 }
577
578 fn verify_release_exists(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
579 if dry_run {
580 eprintln!("[dry-run] Would verify release: {}", plan.tag_name);
581 return Ok(());
582 }
583
584 if let Err(e) = self.vcs.verify_release(&plan.tag_name) {
585 eprintln!("warning: post-release verification failed: {e}");
586 eprintln!(
587 " The tag {} was pushed but the GitHub release may be incomplete.",
588 plan.tag_name
589 );
590 eprintln!(" Re-run with --force to retry.");
591 }
592 Ok(())
593 }
594
595 fn execute_mutations(
598 &self,
599 version_str: &str,
600 changelog_body: &str,
601 ) -> Result<Vec<String>, ReleaseError> {
602 let mut files_to_stage: Vec<String> = Vec::new();
603 for file in &self.config.release.version_files {
604 match bump_version_file(Path::new(file), version_str) {
605 Ok(extra) => {
606 files_to_stage.push(file.clone());
607 for extra_path in extra {
608 files_to_stage.push(extra_path.to_string_lossy().into_owned());
609 }
610 }
611 Err(e) if !self.config.release.version_files_strict => {
612 eprintln!("warning: {e} — skipping {file}");
613 }
614 Err(e) => return Err(e),
615 }
616 }
617
618 for lock_file in discover_lock_files(&files_to_stage) {
620 let lock_str = lock_file.to_string_lossy().into_owned();
621 if !files_to_stage.contains(&lock_str) {
622 files_to_stage.push(lock_str);
623 }
624 }
625
626 if let Some(ref changelog_file) = self.config.release.changelog.file {
628 let path = Path::new(changelog_file);
629 let existing = if path.exists() {
630 fs::read_to_string(path).map_err(|e| ReleaseError::Changelog(e.to_string()))?
631 } else {
632 String::new()
633 };
634 let new_content = if existing.is_empty() {
635 format!("# Changelog\n\n{changelog_body}\n")
636 } else {
637 match existing.find("\n\n") {
638 Some(pos) => {
639 let (header, rest) = existing.split_at(pos);
640 format!("{header}\n\n{changelog_body}\n{rest}")
641 }
642 None => format!("{existing}\n\n{changelog_body}\n"),
643 }
644 };
645 fs::write(path, new_content).map_err(|e| ReleaseError::Changelog(e.to_string()))?;
646 }
647
648 Ok(files_to_stage)
649 }
650}
651
652fn restore_snapshots(snapshots: &[(String, Option<String>)]) {
654 for (file, contents) in snapshots {
655 let path = Path::new(file);
656 match contents {
657 Some(data) => {
658 if let Err(e) = fs::write(path, data) {
659 eprintln!("warning: failed to restore {file}: {e}");
660 }
661 }
662 None => {
663 if path.exists()
665 && let Err(e) = fs::remove_file(path)
666 {
667 eprintln!("warning: failed to remove {file}: {e}");
668 }
669 }
670 }
671 }
672}
673
674fn resolve_globs(patterns: &[String]) -> Result<Vec<String>, String> {
676 let mut files = std::collections::BTreeSet::new();
677 for pattern in patterns {
678 let paths =
679 glob::glob(pattern).map_err(|e| format!("invalid glob pattern '{pattern}': {e}"))?;
680 for entry in paths {
681 match entry {
682 Ok(path) if path.is_file() => {
683 files.insert(path.to_string_lossy().into_owned());
684 }
685 Ok(_) => {}
686 Err(e) => {
687 return Err(format!("glob error for pattern '{pattern}': {e}"));
688 }
689 }
690 }
691 }
692 Ok(files.into_iter().collect())
693}
694
695pub fn today_string() -> String {
696 let secs = std::time::SystemTime::now()
699 .duration_since(std::time::UNIX_EPOCH)
700 .unwrap_or_default()
701 .as_secs() as i64;
702
703 let z = secs / 86400 + 719468;
704 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
705 let doe = (z - era * 146097) as u32;
706 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
707 let y = yoe as i64 + era * 400;
708 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
709 let mp = (5 * doy + 2) / 153;
710 let d = doy - (153 * mp + 2) / 5 + 1;
711 let m = if mp < 10 { mp + 3 } else { mp - 9 };
712 let y = if m <= 2 { y + 1 } else { y };
713
714 format!("{y:04}-{m:02}-{d:02}")
715}
716
717#[cfg(test)]
718mod tests {
719 use std::sync::Mutex;
720
721 use super::*;
722 use crate::changelog::DefaultChangelogFormatter;
723 use crate::commit::{Commit, DefaultCommitParser};
724 use crate::config::{CommitConfig, Config, HooksConfig, ReleaseConfig};
725 use crate::git::{GitRepository, TagInfo};
726
727 struct FakeGit {
730 tags: Vec<TagInfo>,
731 commits: Vec<Commit>,
732 path_commits: Option<Vec<Commit>>,
734 head: String,
735 created_tags: Mutex<Vec<String>>,
736 pushed_tags: Mutex<Vec<String>>,
737 committed: Mutex<Vec<(Vec<String>, String)>>,
738 push_count: Mutex<u32>,
739 force_created_tags: Mutex<Vec<String>>,
740 force_pushed_tags: Mutex<Vec<String>>,
741 }
742
743 impl FakeGit {
744 fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
745 let head = tags
746 .last()
747 .map(|t| t.sha.clone())
748 .unwrap_or_else(|| "0".repeat(40));
749 Self {
750 tags,
751 commits,
752 path_commits: None,
753 head,
754 created_tags: Mutex::new(Vec::new()),
755 pushed_tags: Mutex::new(Vec::new()),
756 committed: Mutex::new(Vec::new()),
757 push_count: Mutex::new(0),
758 force_created_tags: Mutex::new(Vec::new()),
759 force_pushed_tags: Mutex::new(Vec::new()),
760 }
761 }
762 }
763
764 impl GitRepository for FakeGit {
765 fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
766 Ok(self.tags.last().cloned())
767 }
768
769 fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
770 Ok(self.commits.clone())
771 }
772
773 fn create_tag(&self, name: &str, _message: &str, _sign: bool) -> Result<(), ReleaseError> {
774 self.created_tags.lock().unwrap().push(name.to_string());
775 Ok(())
776 }
777
778 fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
779 self.pushed_tags.lock().unwrap().push(name.to_string());
780 Ok(())
781 }
782
783 fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
784 self.committed.lock().unwrap().push((
785 paths.iter().map(|s| s.to_string()).collect(),
786 message.to_string(),
787 ));
788 Ok(true)
789 }
790
791 fn push(&self) -> Result<(), ReleaseError> {
792 *self.push_count.lock().unwrap() += 1;
793 Ok(())
794 }
795
796 fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
797 Ok(self
798 .created_tags
799 .lock()
800 .unwrap()
801 .contains(&name.to_string()))
802 }
803
804 fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
805 Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
806 }
807
808 fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
809 Ok(self.tags.clone())
810 }
811
812 fn commits_between(
813 &self,
814 _from: Option<&str>,
815 _to: &str,
816 ) -> Result<Vec<Commit>, ReleaseError> {
817 Ok(self.commits.clone())
818 }
819
820 fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
821 Ok("2026-01-01".into())
822 }
823
824 fn force_create_tag(&self, name: &str) -> Result<(), ReleaseError> {
825 self.force_created_tags
826 .lock()
827 .unwrap()
828 .push(name.to_string());
829 Ok(())
830 }
831
832 fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
833 self.force_pushed_tags
834 .lock()
835 .unwrap()
836 .push(name.to_string());
837 Ok(())
838 }
839
840 fn head_sha(&self) -> Result<String, ReleaseError> {
841 Ok(self.head.clone())
842 }
843
844 fn commits_since_in_path(
845 &self,
846 _from: Option<&str>,
847 _path: &str,
848 ) -> Result<Vec<Commit>, ReleaseError> {
849 Ok(self
850 .path_commits
851 .clone()
852 .unwrap_or_else(|| self.commits.clone()))
853 }
854 }
855
856 struct FakeVcs {
857 releases: Mutex<Vec<(String, String)>>,
858 deleted_releases: Mutex<Vec<String>>,
859 uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
860 }
861
862 impl FakeVcs {
863 fn new() -> Self {
864 Self {
865 releases: Mutex::new(Vec::new()),
866 deleted_releases: Mutex::new(Vec::new()),
867 uploaded_assets: Mutex::new(Vec::new()),
868 }
869 }
870 }
871
872 impl VcsProvider for FakeVcs {
873 fn create_release(
874 &self,
875 tag: &str,
876 _name: &str,
877 body: &str,
878 _prerelease: bool,
879 _draft: bool,
880 ) -> Result<String, ReleaseError> {
881 self.releases
882 .lock()
883 .unwrap()
884 .push((tag.to_string(), body.to_string()));
885 Ok(format!("https://github.com/test/release/{tag}"))
886 }
887
888 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
889 Ok(format!("https://github.com/test/compare/{base}...{head}"))
890 }
891
892 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
893 Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
894 }
895
896 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
897 self.deleted_releases.lock().unwrap().push(tag.to_string());
898 self.releases.lock().unwrap().retain(|(t, _)| t != tag);
899 Ok(())
900 }
901
902 fn update_release(
903 &self,
904 tag: &str,
905 _name: &str,
906 body: &str,
907 _prerelease: bool,
908 _draft: bool,
909 ) -> Result<String, ReleaseError> {
910 let mut releases = self.releases.lock().unwrap();
911 if let Some(entry) = releases.iter_mut().find(|(t, _)| t == tag) {
912 entry.1 = body.to_string();
913 }
914 Ok(format!("https://github.com/test/release/{tag}"))
915 }
916
917 fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
918 self.uploaded_assets.lock().unwrap().push((
919 tag.to_string(),
920 files.iter().map(|s| s.to_string()).collect(),
921 ));
922 Ok(())
923 }
924
925 fn repo_url(&self) -> Option<String> {
926 Some("https://github.com/test/repo".into())
927 }
928 }
929
930 fn test_config() -> ReleaseConfig {
935 ReleaseConfig {
936 changelog: crate::config::ChangelogConfig {
937 file: None,
938 ..Default::default()
939 },
940 ..Default::default()
941 }
942 }
943
944 fn raw_commit(msg: &str) -> Commit {
945 Commit {
946 sha: "a".repeat(40),
947 message: msg.into(),
948 }
949 }
950
951 fn make_strategy(
952 tags: Vec<TagInfo>,
953 commits: Vec<Commit>,
954 release_config: ReleaseConfig,
955 ) -> TrunkReleaseStrategy<FakeGit, FakeVcs, DefaultCommitParser, DefaultChangelogFormatter>
956 {
957 let config = Config {
958 commit: CommitConfig::default(),
959 release: release_config,
960 hooks: HooksConfig::default(),
961 packages: vec![],
962 };
963 let types = config.commit.types.clone();
964 let breaking_section = config.commit.breaking_section.clone();
965 let misc_section = config.commit.misc_section.clone();
966 TrunkReleaseStrategy {
967 git: FakeGit::new(tags, commits),
968 vcs: FakeVcs::new(),
969 parser: DefaultCommitParser,
970 formatter: DefaultChangelogFormatter::new(None, types, breaking_section, misc_section),
971 config,
972 force: false,
973 }
974 }
975
976 #[test]
979 fn plan_no_commits_returns_error() {
980 let s = make_strategy(vec![], vec![], ReleaseConfig::default());
981 let err = s.plan().unwrap_err();
982 assert!(matches!(err, ReleaseError::NoCommits { .. }));
983 }
984
985 #[test]
986 fn plan_no_releasable_returns_error() {
987 let s = make_strategy(
988 vec![],
989 vec![raw_commit("chore: tidy up")],
990 ReleaseConfig::default(),
991 );
992 let err = s.plan().unwrap_err();
993 assert!(matches!(err, ReleaseError::NoBump { .. }));
994 }
995
996 #[test]
997 fn force_releases_patch_when_no_releasable_commits() {
998 let tag = TagInfo {
999 name: "v1.2.3".into(),
1000 version: Version::new(1, 2, 3),
1001 sha: "d".repeat(40),
1002 };
1003 let mut s = make_strategy(
1004 vec![tag],
1005 vec![raw_commit("chore: rename package")],
1006 ReleaseConfig::default(),
1007 );
1008 s.force = true;
1009 let plan = s.plan().unwrap();
1010 assert_eq!(plan.next_version, Version::new(1, 2, 4));
1011 assert_eq!(plan.bump, BumpLevel::Patch);
1012 }
1013
1014 #[test]
1015 fn plan_first_release() {
1016 let s = make_strategy(
1017 vec![],
1018 vec![raw_commit("feat: initial feature")],
1019 ReleaseConfig::default(),
1020 );
1021 let plan = s.plan().unwrap();
1022 assert_eq!(plan.next_version, Version::new(0, 1, 0));
1023 assert_eq!(plan.tag_name, "v0.1.0");
1024 assert!(plan.current_version.is_none());
1025 }
1026
1027 #[test]
1028 fn plan_increments_existing() {
1029 let tag = TagInfo {
1030 name: "v1.2.3".into(),
1031 version: Version::new(1, 2, 3),
1032 sha: "b".repeat(40),
1033 };
1034 let s = make_strategy(
1035 vec![tag],
1036 vec![raw_commit("fix: patch bug")],
1037 ReleaseConfig::default(),
1038 );
1039 let plan = s.plan().unwrap();
1040 assert_eq!(plan.next_version, Version::new(1, 2, 4));
1041 }
1042
1043 #[test]
1044 fn plan_breaking_bump() {
1045 let tag = TagInfo {
1046 name: "v1.2.3".into(),
1047 version: Version::new(1, 2, 3),
1048 sha: "c".repeat(40),
1049 };
1050 let s = make_strategy(
1051 vec![tag],
1052 vec![raw_commit("feat!: breaking change")],
1053 ReleaseConfig::default(),
1054 );
1055 let plan = s.plan().unwrap();
1056 assert_eq!(plan.next_version, Version::new(2, 0, 0));
1057 }
1058
1059 #[test]
1060 fn plan_v0_breaking_downshifts_to_minor() {
1061 let tag = TagInfo {
1062 name: "v0.5.0".into(),
1063 version: Version::new(0, 5, 0),
1064 sha: "c".repeat(40),
1065 };
1066 let s = make_strategy(
1067 vec![tag],
1068 vec![raw_commit("feat!: breaking change")],
1069 ReleaseConfig::default(),
1070 );
1071 let plan = s.plan().unwrap();
1072 assert_eq!(plan.next_version, Version::new(0, 6, 0));
1074 assert_eq!(plan.bump, BumpLevel::Minor);
1075 }
1076
1077 #[test]
1078 fn plan_v0_breaking_with_force_bumps_major() {
1079 let tag = TagInfo {
1080 name: "v0.5.0".into(),
1081 version: Version::new(0, 5, 0),
1082 sha: "c".repeat(40),
1083 };
1084 let mut s = make_strategy(
1085 vec![tag],
1086 vec![raw_commit("feat!: breaking change")],
1087 ReleaseConfig::default(),
1088 );
1089 s.force = true;
1090 let plan = s.plan().unwrap();
1091 assert_eq!(plan.next_version, Version::new(1, 0, 0));
1093 assert_eq!(plan.bump, BumpLevel::Major);
1094 }
1095
1096 #[test]
1097 fn plan_v0_feat_stays_minor() {
1098 let tag = TagInfo {
1099 name: "v0.5.0".into(),
1100 version: Version::new(0, 5, 0),
1101 sha: "c".repeat(40),
1102 };
1103 let s = make_strategy(
1104 vec![tag],
1105 vec![raw_commit("feat: new feature")],
1106 ReleaseConfig::default(),
1107 );
1108 let plan = s.plan().unwrap();
1109 assert_eq!(plan.next_version, Version::new(0, 6, 0));
1111 assert_eq!(plan.bump, BumpLevel::Minor);
1112 }
1113
1114 #[test]
1115 fn plan_v0_fix_stays_patch() {
1116 let tag = TagInfo {
1117 name: "v0.5.0".into(),
1118 version: Version::new(0, 5, 0),
1119 sha: "c".repeat(40),
1120 };
1121 let s = make_strategy(
1122 vec![tag],
1123 vec![raw_commit("fix: bug fix")],
1124 ReleaseConfig::default(),
1125 );
1126 let plan = s.plan().unwrap();
1127 assert_eq!(plan.next_version, Version::new(0, 5, 1));
1129 assert_eq!(plan.bump, BumpLevel::Patch);
1130 }
1131
1132 #[test]
1135 fn execute_dry_run_no_side_effects() {
1136 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1137 let plan = s.plan().unwrap();
1138 s.execute(&plan, true).unwrap();
1139
1140 assert!(s.git.created_tags.lock().unwrap().is_empty());
1141 assert!(s.git.pushed_tags.lock().unwrap().is_empty());
1142 }
1143
1144 #[test]
1145 fn execute_creates_and_pushes_tag() {
1146 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1147 let plan = s.plan().unwrap();
1148 s.execute(&plan, false).unwrap();
1149
1150 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1151 assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
1152 }
1153
1154 #[test]
1155 fn execute_calls_vcs_create_release() {
1156 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1157 let plan = s.plan().unwrap();
1158 s.execute(&plan, false).unwrap();
1159
1160 let releases = s.vcs.releases.lock().unwrap();
1161 assert_eq!(releases.len(), 1);
1162 assert_eq!(releases[0].0, "v0.1.0");
1163 assert!(!releases[0].1.is_empty());
1164 }
1165
1166 #[test]
1167 fn execute_commits_changelog_before_tag() {
1168 let dir = tempfile::tempdir().unwrap();
1169 let changelog_path = dir.path().join("CHANGELOG.md");
1170
1171 let config = ReleaseConfig {
1172 changelog: crate::config::ChangelogConfig {
1173 file: Some(changelog_path.to_str().unwrap().to_string()),
1174 ..Default::default()
1175 },
1176 ..Default::default()
1177 };
1178
1179 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1180 let plan = s.plan().unwrap();
1181 s.execute(&plan, false).unwrap();
1182
1183 let committed = s.git.committed.lock().unwrap();
1185 assert_eq!(committed.len(), 1);
1186 assert_eq!(
1187 committed[0].0,
1188 vec![changelog_path.to_str().unwrap().to_string()]
1189 );
1190 assert!(committed[0].1.contains("chore(release): v0.1.0"));
1191
1192 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1194 }
1195
1196 #[test]
1197 fn execute_skips_existing_tag() {
1198 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1199 let plan = s.plan().unwrap();
1200
1201 s.git
1203 .created_tags
1204 .lock()
1205 .unwrap()
1206 .push("v0.1.0".to_string());
1207
1208 s.execute(&plan, false).unwrap();
1209
1210 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1212 }
1213
1214 #[test]
1215 fn execute_skips_existing_release() {
1216 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1217 let plan = s.plan().unwrap();
1218
1219 s.vcs
1221 .releases
1222 .lock()
1223 .unwrap()
1224 .push(("v0.1.0".to_string(), "old notes".to_string()));
1225
1226 s.execute(&plan, false).unwrap();
1227
1228 let deleted = s.vcs.deleted_releases.lock().unwrap();
1230 assert!(deleted.is_empty(), "update should not delete");
1231
1232 let releases = s.vcs.releases.lock().unwrap();
1233 assert_eq!(releases.len(), 1);
1234 assert_eq!(releases[0].0, "v0.1.0");
1235 assert_ne!(releases[0].1, "old notes");
1236 }
1237
1238 #[test]
1239 fn execute_idempotent_rerun() {
1240 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1241 let plan = s.plan().unwrap();
1242
1243 s.execute(&plan, false).unwrap();
1245
1246 s.execute(&plan, false).unwrap();
1248
1249 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1251
1252 assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
1254
1255 assert_eq!(*s.git.push_count.lock().unwrap(), 2);
1257
1258 let deleted = s.vcs.deleted_releases.lock().unwrap();
1260 assert!(deleted.is_empty(), "update should not delete");
1261
1262 let releases = s.vcs.releases.lock().unwrap();
1263 assert_eq!(releases.len(), 1);
1264 assert_eq!(releases[0].0, "v0.1.0");
1265 }
1266
1267 #[test]
1268 fn execute_bumps_version_files() {
1269 let dir = tempfile::tempdir().unwrap();
1270 let cargo_path = dir.path().join("Cargo.toml");
1271 std::fs::write(
1272 &cargo_path,
1273 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1274 )
1275 .unwrap();
1276
1277 let config = ReleaseConfig {
1278 version_files: vec![cargo_path.to_str().unwrap().to_string()],
1279 ..test_config()
1280 };
1281
1282 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1283 let plan = s.plan().unwrap();
1284 s.execute(&plan, false).unwrap();
1285
1286 let contents = std::fs::read_to_string(&cargo_path).unwrap();
1288 assert!(contents.contains("version = \"0.1.0\""));
1289
1290 let committed = s.git.committed.lock().unwrap();
1292 assert_eq!(committed.len(), 1);
1293 assert!(
1294 committed[0]
1295 .0
1296 .contains(&cargo_path.to_str().unwrap().to_string())
1297 );
1298 }
1299
1300 #[test]
1301 fn execute_stages_changelog_and_version_files_together() {
1302 let dir = tempfile::tempdir().unwrap();
1303 let cargo_path = dir.path().join("Cargo.toml");
1304 std::fs::write(
1305 &cargo_path,
1306 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1307 )
1308 .unwrap();
1309
1310 let changelog_path = dir.path().join("CHANGELOG.md");
1311
1312 let config = ReleaseConfig {
1313 changelog: crate::config::ChangelogConfig {
1314 file: Some(changelog_path.to_str().unwrap().to_string()),
1315 ..Default::default()
1316 },
1317 version_files: vec![cargo_path.to_str().unwrap().to_string()],
1318 ..Default::default()
1319 };
1320
1321 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1322 let plan = s.plan().unwrap();
1323 s.execute(&plan, false).unwrap();
1324
1325 let committed = s.git.committed.lock().unwrap();
1327 assert_eq!(committed.len(), 1);
1328 assert!(
1329 committed[0]
1330 .0
1331 .contains(&changelog_path.to_str().unwrap().to_string())
1332 );
1333 assert!(
1334 committed[0]
1335 .0
1336 .contains(&cargo_path.to_str().unwrap().to_string())
1337 );
1338 }
1339
1340 #[test]
1343 fn execute_uploads_artifacts() {
1344 let dir = tempfile::tempdir().unwrap();
1345 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1346 std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
1347
1348 let config = ReleaseConfig {
1349 artifacts: vec![
1350 dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
1351 dir.path().join("*.zip").to_str().unwrap().to_string(),
1352 ],
1353 ..test_config()
1354 };
1355
1356 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1357 let plan = s.plan().unwrap();
1358 s.execute(&plan, false).unwrap();
1359
1360 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1361 assert_eq!(uploaded.len(), 1);
1362 assert_eq!(uploaded[0].0, "v0.1.0");
1363 assert_eq!(uploaded[0].1.len(), 2);
1364 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.tar.gz")));
1365 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip")));
1366 }
1367
1368 #[test]
1369 fn execute_dry_run_shows_artifacts() {
1370 let dir = tempfile::tempdir().unwrap();
1371 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1372
1373 let config = ReleaseConfig {
1374 artifacts: vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()],
1375 ..test_config()
1376 };
1377
1378 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1379 let plan = s.plan().unwrap();
1380 s.execute(&plan, true).unwrap();
1381
1382 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1384 assert!(uploaded.is_empty());
1385 }
1386
1387 #[test]
1388 fn execute_no_artifacts_skips_upload() {
1389 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1390 let plan = s.plan().unwrap();
1391 s.execute(&plan, false).unwrap();
1392
1393 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1394 assert!(uploaded.is_empty());
1395 }
1396
1397 #[test]
1398 fn resolve_globs_basic() {
1399 let dir = tempfile::tempdir().unwrap();
1400 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
1401 std::fs::write(dir.path().join("b.txt"), "b").unwrap();
1402 std::fs::create_dir(dir.path().join("subdir")).unwrap();
1403
1404 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1405 let result = resolve_globs(&[pattern]).unwrap();
1406 assert_eq!(result.len(), 2);
1407 assert!(result.iter().any(|f: &String| f.ends_with("a.txt")));
1408 assert!(result.iter().any(|f: &String| f.ends_with("b.txt")));
1409 }
1410
1411 #[test]
1412 fn resolve_globs_deduplicates() {
1413 let dir = tempfile::tempdir().unwrap();
1414 std::fs::write(dir.path().join("file.txt"), "data").unwrap();
1415
1416 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1417 let result = resolve_globs(&[pattern.clone(), pattern]).unwrap();
1419 assert_eq!(result.len(), 1);
1420 }
1421
1422 #[test]
1425 fn plan_floating_tag_when_enabled() {
1426 let tag = TagInfo {
1427 name: "v3.2.0".into(),
1428 version: Version::new(3, 2, 0),
1429 sha: "d".repeat(40),
1430 };
1431 let config = ReleaseConfig {
1432 floating_tags: true,
1433 ..Default::default()
1434 };
1435
1436 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1437 let plan = s.plan().unwrap();
1438 assert_eq!(plan.next_version, Version::new(3, 2, 1));
1439 assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
1440 }
1441
1442 #[test]
1443 fn plan_no_floating_tag_when_disabled() {
1444 let s = make_strategy(
1445 vec![],
1446 vec![raw_commit("feat: something")],
1447 ReleaseConfig {
1448 floating_tags: false,
1449 ..Default::default()
1450 },
1451 );
1452 let plan = s.plan().unwrap();
1453 assert!(plan.floating_tag_name.is_none());
1454 }
1455
1456 #[test]
1457 fn plan_floating_tag_custom_prefix() {
1458 let tag = TagInfo {
1459 name: "release-2.5.0".into(),
1460 version: Version::new(2, 5, 0),
1461 sha: "e".repeat(40),
1462 };
1463 let config = ReleaseConfig {
1464 floating_tags: true,
1465 tag_prefix: "release-".into(),
1466 ..Default::default()
1467 };
1468
1469 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1470 let plan = s.plan().unwrap();
1471 assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
1472 }
1473
1474 #[test]
1475 fn execute_floating_tags_force_create_and_push() {
1476 let config = ReleaseConfig {
1477 floating_tags: true,
1478 ..test_config()
1479 };
1480
1481 let tag = TagInfo {
1482 name: "v1.2.3".into(),
1483 version: Version::new(1, 2, 3),
1484 sha: "f".repeat(40),
1485 };
1486 let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
1487 let plan = s.plan().unwrap();
1488 assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
1489
1490 s.execute(&plan, false).unwrap();
1491
1492 assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
1493 assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
1494 }
1495
1496 #[test]
1497 fn execute_no_floating_tags_when_disabled() {
1498 let s = make_strategy(
1499 vec![],
1500 vec![raw_commit("feat: something")],
1501 ReleaseConfig {
1502 floating_tags: false,
1503 ..test_config()
1504 },
1505 );
1506 let plan = s.plan().unwrap();
1507 assert!(plan.floating_tag_name.is_none());
1508
1509 s.execute(&plan, false).unwrap();
1510
1511 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1512 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1513 }
1514
1515 #[test]
1516 fn execute_floating_tags_dry_run_no_side_effects() {
1517 let config = ReleaseConfig {
1518 floating_tags: true,
1519 ..test_config()
1520 };
1521
1522 let tag = TagInfo {
1523 name: "v2.0.0".into(),
1524 version: Version::new(2, 0, 0),
1525 sha: "a".repeat(40),
1526 };
1527 let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
1528 let plan = s.plan().unwrap();
1529 assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
1530
1531 s.execute(&plan, true).unwrap();
1532
1533 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1534 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1535 }
1536
1537 #[test]
1538 fn execute_floating_tags_idempotent() {
1539 let config = ReleaseConfig {
1540 floating_tags: true,
1541 ..test_config()
1542 };
1543
1544 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1545 let plan = s.plan().unwrap();
1546 assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
1547
1548 s.execute(&plan, false).unwrap();
1550 s.execute(&plan, false).unwrap();
1551
1552 assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
1554 assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
1555 }
1556
1557 #[test]
1560 fn force_rerelease_when_tag_at_head() {
1561 let tag = TagInfo {
1562 name: "v1.2.3".into(),
1563 version: Version::new(1, 2, 3),
1564 sha: "a".repeat(40),
1565 };
1566 let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1567 s.git.head = "a".repeat(40);
1569 s.force = true;
1570
1571 let plan = s.plan().unwrap();
1572 assert_eq!(plan.next_version, Version::new(1, 2, 3));
1573 assert_eq!(plan.tag_name, "v1.2.3");
1574 assert!(plan.commits.is_empty());
1575 assert_eq!(plan.current_version, Some(Version::new(1, 2, 3)));
1576 }
1577
1578 #[test]
1579 fn force_fails_when_tag_not_at_head() {
1580 let tag = TagInfo {
1581 name: "v1.2.3".into(),
1582 version: Version::new(1, 2, 3),
1583 sha: "a".repeat(40),
1584 };
1585 let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1586 s.git.head = "b".repeat(40);
1588 s.force = true;
1589
1590 let err = s.plan().unwrap_err();
1591 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1592 }
1593}