diff --git a/git-cliff-core/src/changelog.rs b/git-cliff-core/src/changelog.rs index 86a8e20300..b066f2528b 100644 --- a/git-cliff-core/src/changelog.rs +++ b/git-cliff-core/src/changelog.rs @@ -546,7 +546,6 @@ impl<'a> Changelog<'a> { } } } - for release in &self.releases { let write_result = write!( out, diff --git a/git-cliff-core/src/repo.rs b/git-cliff-core/src/repo.rs index a8cc93299a..6daa4bbc1f 100644 --- a/git-cliff-core/src/repo.rs +++ b/git-cliff-core/src/repo.rs @@ -20,6 +20,7 @@ use lazy_regex::{ Lazy, Regex, }; +use std::cmp::Reverse; use std::io; use std::path::PathBuf; use url::Url; @@ -354,10 +355,10 @@ impl Repository { /// It collects lightweight and annotated tags. pub fn tags( &self, - pattern: &Option, + pattern: Option<&Regex>, topo_order: bool, use_branch_tags: bool, - ) -> Result> { + ) -> Result> { let mut tags: Vec<(Commit, Tag)> = Vec::new(); let tag_names = self.inner.tag_names(None)?; let head_commit = self.inner.head()?.peel_to_commit()?; @@ -402,12 +403,9 @@ impl Repository { } } if !topo_order { - tags.sort_by(|a, b| a.0.time().seconds().cmp(&b.0.time().seconds())); + tags.sort_by_key(|(commit, _)| commit.time().seconds()); } - Ok(tags - .into_iter() - .map(|(a, b)| (a.id().to_string(), b)) - .collect()) + TaggedCommits::new(self, tags) } /// Returns the remote of the upstream repository. @@ -452,6 +450,147 @@ impl Repository { } } +/// Stores which commits are tagged with which tags. +#[derive(Debug)] +pub struct TaggedCommits<'a> { + /// All the commits in the repository. + pub commits: IndexMap>, + /// Commit ID to tag map. + tags: IndexMap, + /// List of tags' commit indexes. Points into `commits`. + /// + /// Sorted in reverse order, meaning the first element is the latest tag. + /// + /// Used for lookups. + tag_indexes: Vec, +} + +impl<'a> TaggedCommits<'a> { + fn new( + repository: &'a Repository, + tags: Vec<(Commit<'a>, Tag)>, + ) -> Result { + let commits = repository.commits(None, None, None)?; + let commits: IndexMap<_, _> = commits + .into_iter() + .map(|c| (c.id().to_string(), c)) + .collect(); + let mut tag_ids: Vec<_> = tags + .iter() + .filter_map(|(commit, _tag)| { + let id = commit.id().to_string(); + commits.get_index_of(&id) + }) + .collect(); + tag_ids.sort_by_key(|idx| Reverse(*idx)); + let tags = tags + .into_iter() + .map(|(commit, tag)| (commit.id().to_string(), tag)) + .collect(); + Ok(Self { + commits, + tag_indexes: tag_ids, + tags, + }) + } + + /// Returns the number of tags. + pub fn len(&self) -> usize { + self.tags.len() + } + + /// Returns `true` if there are no tags. + pub fn is_empty(&self) -> bool { + self.tags.is_empty() + } + + /// Returns an iterator over all the tags. + pub fn tags(&self) -> impl Iterator { + self.tags.iter().map(|(_, tag)| tag) + } + + /// Returns the last tag. + pub fn last(&self) -> Option<&Tag> { + self.tags().last() + } + + /// Returns the tag of the given commit. + /// + /// Note that this only searches for an exact match. + /// For a more general search, use [`get_closest`](Self::get_closest) + /// instead. + pub fn get(&self, commit: &str) -> Option<&Tag> { + self.tags.get(commit) + } + + /// Returns the tag at the given index. + /// + /// The index can be calculated with `tags().position()`. + pub fn get_index(&self, idx: usize) -> Option<&Tag> { + self.tags.get_index(idx).map(|(_, tag)| tag) + } + + /// Returns the tag closest to the given commit. + pub fn get_closest(&self, commit: &str) -> Option<&Tag> { + // Try exact match first. + if let Some(tagged) = self.get(commit) { + return Some(tagged); + } + + let index = self.commits.get_index_of(commit)?; + let tag_index = + *self.tag_indexes.iter().find(|tag_idx| index >= **tag_idx)?; + self.get_tag_by_id(tag_index) + } + + fn get_tag_by_id(&self, id: usize) -> Option<&Tag> { + let (commit_of_tag, _) = self.commits.get_index(id)?; + self.tags.get(commit_of_tag) + } + + /// Returns the commit of the given tag. + pub fn get_commit(&self, tag_name: &str) -> Option<&str> { + self.tags + .iter() + .find(|(_, t)| t.name == tag_name) + .map(|(commit, _)| commit.as_str()) + } + + /// Returns `true` if the given tag exists. + pub fn contains_commit(&self, commit: &str) -> bool { + self.tags.contains_key(commit) + } + + /// Inserts a new tagged commit. + pub fn insert(&mut self, commit: String, tag: Tag) { + if let Some(index) = self.commits.get_index_of(&commit) { + if let Err(idx) = self.binary_search(index) { + self.tag_indexes.insert(idx, index); + } + } + self.tags.insert(commit, tag); + } + + /// Retains only the tags specified by the predicate. + pub fn retain(&mut self, mut f: impl FnMut(&Tag) -> bool) { + self.tag_indexes.retain(|&idx| { + let panic_msg = "invalid TaggedCommits state"; + let (commit_of_tag, _) = self.commits.get_index(idx).expect(panic_msg); + let tag = self.tags.get(commit_of_tag).expect(panic_msg); + let retain = f(tag); + if !retain { + self.tags.shift_remove(commit_of_tag); + } + retain + }); + } + + fn binary_search(&self, index: usize) -> std::result::Result { + self.tag_indexes + .binary_search_by_key(&Reverse(index), |tag_idx| Reverse(*tag_idx)) + } +} + fn find_remote(url: &str) -> Result { url_path_segments(url).or_else(|err| { if url.contains("@") && url.contains(":") && url.contains("/") { @@ -522,12 +661,11 @@ fn ssh_path_segments(url: &str) -> Result { mod test { use super::*; use crate::commit::Commit as AppCommit; + use std::env; + use std::fs; + use std::path::Path; use std::process::Command; use std::str; - use std::{ - env, - fs, - }; use temp_dir::TempDir; fn get_last_commit_hash() -> Result { @@ -568,7 +706,7 @@ mod test { fn get_repository() -> Result { Repository::init( - PathBuf::from(env!("CARGO_MANIFEST_DIR")) + Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .expect("parent directory not found") .to_path_buf(), @@ -615,8 +753,8 @@ mod test { #[test] fn get_latest_tag() -> Result<()> { let repository = get_repository()?; - let tags = repository.tags(&None, false, false)?; - let latest = tags.last().expect("no tags found").1.name.clone(); + let tags = repository.tags(None, false, false)?; + let latest = tags.last().expect("no tags found").name.clone(); assert_eq!(get_last_tag()?, latest); let current = repository.current_tag().expect("a current tag").name; @@ -627,7 +765,7 @@ mod test { #[test] fn git_tags() -> Result<()> { let repository = get_repository()?; - let tags = repository.tags(&None, true, false)?; + let tags = repository.tags(None, true, false)?; assert_eq!( tags.get("2b8b4d3535f29231e05c3572e919634b9af907b6") .expect( @@ -646,8 +784,8 @@ mod test { "v0.1.0-beta.4" ); let tags = repository.tags( - &Some( - Regex::new("^v[0-9]+\\.[0-9]+\\.[0-9]$") + Some( + &Regex::new("^v[0-9]+\\.[0-9]+\\.[0-9]$") .expect("the regex is not valid"), ), true, @@ -661,7 +799,7 @@ mod test { .name, "v0.1.0" ); - assert!(!tags.contains_key("4ddef08debfff48117586296e49d5caa0800d1b5")); + assert!(!tags.contains_commit("4ddef08debfff48117586296e49d5caa0800d1b5")); Ok(()) } diff --git a/git-cliff-core/src/tag.rs b/git-cliff-core/src/tag.rs index 7fe8ab9cb3..912c50360c 100644 --- a/git-cliff-core/src/tag.rs +++ b/git-cliff-core/src/tag.rs @@ -9,6 +9,14 @@ pub struct Tag { pub message: Option, } +impl PartialEq for Tag { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + } +} + +impl Eq for Tag {} + #[cfg(test)] mod test { use super::*; diff --git a/git-cliff/src/lib.rs b/git-cliff/src/lib.rs index f7e458c59e..0641bb9bb2 100644 --- a/git-cliff/src/lib.rs +++ b/git-cliff/src/lib.rs @@ -36,6 +36,7 @@ use git_cliff_core::error::{ }; use git_cliff_core::release::Release; use git_cliff_core::repo::Repository; +use git_cliff_core::tag::Tag; use git_cliff_core::{ DEFAULT_CONFIG, IGNORE_FILE, @@ -85,14 +86,14 @@ fn process_repository<'a>( args: &Opt, ) -> Result>> { let mut tags = repository.tags( - &config.git.tag_pattern, + config.git.tag_pattern.as_ref(), args.topo_order, args.use_branch_tags, )?; let skip_regex = config.git.skip_tags.as_ref(); let ignore_regex = config.git.ignore_tags.as_ref(); let count_tags = config.git.count_tags.as_ref(); - tags.retain(|_, tag| { + tags.retain(|tag| { let name = &tag.name; // Keep skip tags to drop commits in the later stage. @@ -162,17 +163,19 @@ fn process_repository<'a>( // Parse commits. let mut commit_range = args.range.clone(); if args.unreleased { - if let Some(last_tag) = tags.last().map(|(k, _)| k) { - commit_range = Some(format!("{last_tag}..HEAD")); + if let Some(last_tag) = tags.last() { + commit_range = + Some(format!("{last_tag}..HEAD", last_tag = last_tag.name)); } } else if args.latest || args.current { if tags.len() < 2 { let commits = repository.commits(None, None, None)?; if let (Some(tag1), Some(tag2)) = ( commits.last().map(|c| c.id().to_string()), - tags.get_index(0).map(|(k, _)| k), + tags.get_index(0).map(|tag| &tag.name), ) { if tags.len() == 1 { + let tag2 = tags.get_commit(tag2).unwrap(); commit_range = Some(tag2.to_owned()); } else { commit_range = Some(format!("{tag1}..{tag2}")); @@ -183,10 +186,7 @@ fn process_repository<'a>( if args.current { if let Some(current_tag_index) = repository.current_tag().as_ref().and_then(|tag| { - tags.iter() - .enumerate() - .find(|(_, (_, v))| v.name == tag.name) - .map(|(i, _)| i) + tags.tags().enumerate().position(|(_, t)| t == tag) }) { match current_tag_index.checked_sub(1) { Some(i) => tag_index = i, @@ -203,11 +203,14 @@ fn process_repository<'a>( ))); } } - if let (Some(tag1), Some(tag2)) = ( - tags.get_index(tag_index).map(|(k, _)| k), - tags.get_index(tag_index + 1).map(|(k, _)| k), - ) { - commit_range = Some(format!("{tag1}..{tag2}")); + if let (Some(tag1), Some(tag2)) = + (tags.get_index(tag_index), tags.get_index(tag_index + 1)) + { + commit_range = Some(format!( + "{tag1}..{tag2}", + tag1 = tag1.name, + tag2 = tag2.name + )); } } } @@ -261,45 +264,60 @@ fn process_repository<'a>( } // Process releases. - let mut previous_release = Release::default(); + let mut releases = Vec::::new(); + let mut release = Release::default(); + let mut current_tag = commits + .last() + .and_then(|root| tags.get_closest(&root.id().to_string())); let mut first_processed_tag = None; - for git_commit in commits.iter().rev() { - let release = releases.last_mut().unwrap(); - let commit = Commit::from(git_commit); - let commit_id = commit.id.to_string(); - release.commits.push(commit); + + let fill_release = |release: &mut Release, tag: Option<&Tag>| -> Result<()> { release.repository = Some(repository.path().to_string_lossy().into_owned()); - if let Some(tag) = tags.get(&commit_id) { - release.version = Some(tag.name.to_string()); - release.message.clone_from(&tag.message); - release.commit_id = Some(commit_id); - release.timestamp = if args.tag.as_deref() == Some(tag.name.as_str()) { - match tag_timestamp { - Some(timestamp) => timestamp, - None => SystemTime::now() - .duration_since(UNIX_EPOCH)? - .as_secs() - .try_into()?, - } - } else { - git_commit.time().seconds() - }; - if first_processed_tag.is_none() { - first_processed_tag = Some(tag); + let Some(tag) = tag else { return Ok(()) }; + let Some(release_commit) = tags.get_commit(&tag.name) else { + return Ok(()); + }; + release.version = Some(tag.name.to_string()); + release.message.clone_from(&tag.message); + release.commit_id = Some(release_commit.to_string()); + release.timestamp = if args.tag.as_deref() == Some(&tag.name) { + match tag_timestamp { + Some(timestamp) => timestamp, + None => SystemTime::now() + .duration_since(UNIX_EPOCH)? + .as_secs() + .try_into()?, } - previous_release.previous = None; - release.previous = Some(Box::new(previous_release)); - previous_release = release.clone(); - releases.push(Release::default()); - } - } + } else { + repository + .find_commit(release_commit) + .map(|c| c.time().seconds()) + .unwrap_or_default() + }; + Ok(()) + }; + + for git_commit in commits.iter().rev() { + let commit = Commit::from(git_commit); - debug_assert!(!releases.is_empty()); + let new_tag = tags.get_closest(&commit.id); + if first_processed_tag.is_none() { + first_processed_tag = new_tag; + } + if new_tag == current_tag { + release.commits.push(commit); + continue; + } - if releases.len() > 1 { - previous_release.previous = None; - releases.last_mut().unwrap().previous = Some(Box::new(previous_release)); + // Found a new release, finalize the current one and append the commit to the + // new release. + fill_release(&mut release, current_tag)?; + current_tag = new_tag; + append_release(&mut releases, &mut release); + release.commits.push(commit); } + fill_release(&mut release, current_tag)?; + append_release(&mut releases, &mut release); if args.sort == Sort::Newest { for release in &mut releases { @@ -326,20 +344,20 @@ fn process_repository<'a>( // Get the previous tag of the first processed tag in the release loop. let first_tag = first_processed_tag .map(|tag| { - tags.iter() - .enumerate() - .find(|(_, (_, v))| v.name == tag.name) - .and_then(|(i, _)| i.checked_sub(1)) + tags.tags() + .position(|t| t == tag) + .and_then(|i| i.checked_sub(1)) .and_then(|i| tags.get_index(i)) }) .or_else(|| Some(tags.last())) .flatten(); // Set the previous release if the first tag is found. - if let Some((commit_id, tag)) = first_tag { + if let Some(tag) = first_tag { + let commit_id = tags.get_commit(&tag.name).unwrap(); let previous_release = Release { commit_id: Some(commit_id.to_string()), - version: Some(tag.name.clone()), + version: Some(tag.name.to_string()), timestamp: repository .find_commit(commit_id) .map(|v| v.time().seconds()) @@ -364,6 +382,15 @@ fn process_repository<'a>( Ok(releases) } +/// Appends `release` to `releases`, also setting the `previous` field and +/// resetting `release`. +fn append_release<'a>(releases: &mut Vec>, release: &mut Release<'a>) { + let mut previous = releases.last().cloned().unwrap_or_default(); + previous.previous = None; + release.previous = Some(Box::new(previous)); + releases.push(std::mem::take(release)); +} + /// Runs `git-cliff`. pub fn run(mut args: Opt) -> Result<()> { // Check if there is a new version available. @@ -652,6 +679,7 @@ pub fn run(mut args: Opt) -> Result<()> { return Ok(()); } } + if args.context { changelog.write_context(&mut out)?; return Ok(());