diff --git a/git-cliff-core/src/lib.rs b/git-cliff-core/src/lib.rs index 2f0ec12a73..f75d801975 100644 --- a/git-cliff-core/src/lib.rs +++ b/git-cliff-core/src/lib.rs @@ -43,6 +43,9 @@ pub mod statistics; pub mod summary; /// Git tag. pub mod tag; +/// Tagged commits. +#[cfg(feature = "repo")] +pub mod tagged_commit; /// Template engine. pub mod template; diff --git a/git-cliff-core/src/repo.rs b/git-cliff-core/src/repo.rs index 1453e7293b..a16bc703fc 100644 --- a/git-cliff-core/src/repo.rs +++ b/git-cliff-core/src/repo.rs @@ -8,7 +8,6 @@ use git2::{ Worktree, }; use glob::Pattern; -use indexmap::IndexMap; use regex::Regex; use url::Url; @@ -16,6 +15,7 @@ use crate::commit::CommitStatistics; use crate::config::Remote; use crate::error::{Error, Result}; use crate::tag::Tag; +use crate::tagged_commit::TaggedCommits; /// Regex for replacing the signature part of a tag message. static TAG_SIGNATURE_REGEX: LazyLock = LazyLock::new(|| { @@ -506,10 +506,11 @@ impl Repository { /// commit is in the descendant graph of the `head_commit` or is the /// `head_commit` itself, Changelog should include the tag. fn should_include_tag(&self, head_commit: &Commit, tag_commit: &Commit) -> Result { - Ok(self - .inner - .graph_descendant_of(head_commit.id(), tag_commit.id())? || - head_commit.id() == tag_commit.id()) + self.is_descendant_of(head_commit.id(), tag_commit.id()) + } + + pub(crate) fn is_descendant_of(&self, descendant: Oid, ancestor: Oid) -> Result { + Ok(descendant == ancestor || self.inner.graph_descendant_of(descendant, ancestor)?) } /// Parses and returns a commit-tag map. @@ -520,7 +521,7 @@ impl Repository { pattern: &Option, 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()?; @@ -562,10 +563,7 @@ impl Repository { if !topo_order { tags.sort_by_key(|a| a.0.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. @@ -806,7 +804,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 6796b51be1..e0bec50888 100644 --- a/git-cliff-core/src/tag.rs +++ b/git-cliff-core/src/tag.rs @@ -1,7 +1,7 @@ /// Common tag object that is parsed from a repository. /// /// Lightweight tags will have `None` as message. -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] pub struct Tag { /// The name of the tag pub name: String, diff --git a/git-cliff-core/src/tagged_commit.rs b/git-cliff-core/src/tagged_commit.rs new file mode 100644 index 0000000000..e55e8ee452 --- /dev/null +++ b/git-cliff-core/src/tagged_commit.rs @@ -0,0 +1,285 @@ +//! Tagged commit lookup. + +use std::cmp::Reverse; + +use git2::Commit; +use indexmap::IndexMap; + +use crate::error::Result; +use crate::repo::Repository; +use crate::tag::Tag; + +/// Stores which commits are tagged with which tags. +pub struct TaggedCommits<'a> { + repository: &'a Repository, + commits: IndexMap>, + tags: IndexMap, + tag_indexes: Vec, +} + +impl<'a> TaggedCommits<'a> { + pub(crate) fn new(repository: &'a Repository, tags: Vec<(Commit<'a>, Tag)>) -> Result { + let commits = repository.commits(None, None, None, false)?; + let commits: IndexMap<_, _> = commits + .into_iter() + .map(|c| (c.id().to_string(), c)) + .collect(); + let mut tag_indexes: Vec<_> = tags + .iter() + .filter_map(|(commit, _tag)| { + let id = commit.id().to_string(); + commits.get_index_of(&id) + }) + .collect(); + tag_indexes.sort_by_key(|idx| Reverse(*idx)); + let tags = tags + .into_iter() + .map(|(commit, tag)| (commit.id().to_string(), tag)) + .collect(); + Ok(Self { + repository, + commits, + tags, + tag_indexes, + }) + } + + /// Returns the number of tags. + #[must_use] + pub fn len(&self) -> usize { + self.tags.len() + } + + /// Returns `true` if there are no tags. + #[must_use] + pub fn is_empty(&self) -> bool { + self.tags.is_empty() + } + + /// Returns an iterator over all the tags. + pub fn iter(&self) -> impl Iterator { + self.tags.iter().map(|(commit, tag)| (commit.as_str(), tag)) + } + + /// Returns an iterator over all the tags. + pub fn tags(&self) -> impl Iterator { + self.iter().map(|(_, tag)| tag) + } + + /// Returns the last tag. + #[must_use] + pub fn last(&self) -> Option<(&str, &Tag)> { + self.iter().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. + #[must_use] + 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()`. + #[must_use] + pub fn get_index(&self, idx: usize) -> Option<(&str, &Tag)> { + self.tags + .get_index(idx) + .map(|(commit, tag)| (commit.as_str(), tag)) + } + + /// Returns the tag closest to the given commit. + #[must_use] + pub fn get_closest(&self, commit: &str) -> Option<&Tag> { + if let Some(tagged) = self.get(commit) { + return Some(tagged); + } + + let commit = self.commits.get(commit)?; + for (tag_commit, tag) in &self.tags { + let Some(tag_commit) = self.commits.get(tag_commit) else { + continue; + }; + if self + .repository + .is_descendant_of(tag_commit.id(), commit.id()) + .ok()? + { + return Some(tag); + } + } + None + } + + /// Returns the commit of the given tag. + #[must_use] + pub fn get_commit(&self, tag_name: &str) -> Option<&str> { + self.tags + .iter() + .find(|(_, tag)| tag.name == tag_name) + .map(|(commit, _)| commit.as_str()) + } + + /// Returns `true` if the given tag exists. + #[must_use] + 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) { + let insert_pos = self + .tag_indexes + .get(idx) + .and_then(|tag_index| self.commits.get_index(*tag_index)) + .and_then(|(tag_commit, _)| self.tags.get_index_of(tag_commit)) + .unwrap_or(self.tags.len()); + self.tag_indexes.insert(idx, index); + self.tags.shift_insert(insert_pos, commit, tag); + return; + } + } + 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.tags.retain(|_, tag| f(tag)); + self.tag_indexes.retain(|&idx| { + self.commits + .get_index(idx) + .is_some_and(|(commit, _)| self.tags.contains_key(commit)) + }); + } + + fn binary_search(&self, index: usize) -> std::result::Result { + self.tag_indexes + .binary_search_by_key(&Reverse(index), |tag_idx| Reverse(*tag_idx)) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use git2::{ObjectType, Repository as GitRepository, Signature, Time}; + use temp_dir::TempDir; + + use super::*; + + fn create_commit( + repository: &GitRepository, + path: &Path, + name: &str, + content: &str, + second: i64, + ) -> Result { + fs::write(path.join(name), content).expect("failed to write test file"); + let mut index = repository.index()?; + index.add_path(Path::new(name))?; + index.write()?; + let tree_id = index.write_tree()?; + let tree = repository.find_tree(tree_id)?; + let signature = Signature::new("test", "test@example.com", &Time::new(second, 0))?; + let parents = repository + .head() + .ok() + .and_then(|head| head.peel_to_commit().ok()) + .into_iter() + .collect::>(); + let parent_refs = parents.iter().collect::>(); + Ok(repository + .commit( + Some("HEAD"), + &signature, + &signature, + name, + &tree, + &parent_refs, + )? + .to_string()) + } + + fn create_tagged_repository() -> Result<(Repository, TempDir, Vec)> { + let temp_dir = TempDir::with_prefix("git-cliff-").expect("failed to create temp dir"); + let path = temp_dir.path(); + let git_repository = GitRepository::init(path)?; + + let commits = vec![ + create_commit(&git_repository, path, "one", "one", 1)?, + create_commit(&git_repository, path, "two", "two", 2)?, + create_commit(&git_repository, path, "three", "three", 3)?, + create_commit(&git_repository, path, "four", "four", 4)?, + create_commit(&git_repository, path, "five", "five", 5)?, + ]; + for (tag, commit) in [("v1.0.0", &commits[0]), ("v2.0.0", &commits[3])] { + let object = git_repository.find_object(commit.parse()?, Some(ObjectType::Commit))?; + git_repository.tag_lightweight(tag, &object, false)?; + } + + Ok((Repository::discover(path.to_path_buf())?, temp_dir, commits)) + } + + #[test] + fn gets_closest_tag_for_untagged_commit() -> Result<()> { + let (repository, _temp_dir, commits) = create_tagged_repository()?; + let tags = repository.tags(&None, false, false)?; + + assert_eq!( + tags.get(&commits[0]).expect("expected exact tag").name, + "v1.0.0" + ); + assert_eq!( + tags.get_closest(&commits[1]) + .expect("expected closest tag") + .name, + "v2.0.0" + ); + assert!(tags.get_closest(&commits[4]).is_none()); + Ok(()) + } + + #[test] + fn retain_updates_closest_tag_indexes() -> Result<()> { + let (repository, _temp_dir, commits) = create_tagged_repository()?; + let mut tags = repository.tags(&None, false, false)?; + + tags.retain(|tag| tag.name != "v2.0.0"); + + assert!(tags.get(&commits[3]).is_none()); + assert_eq!( + tags.get_closest(&commits[0]) + .expect("expected retained tag") + .name, + "v1.0.0" + ); + assert!(tags.get_closest(&commits[1]).is_none()); + Ok(()) + } + + #[test] + fn insert_preserves_closest_tag_order() -> Result<()> { + let (repository, _temp_dir, commits) = create_tagged_repository()?; + let mut tags = repository.tags(&None, false, false)?; + + tags.insert(commits[2].clone(), Tag { + name: String::from("v1.5.0"), + message: None, + }); + + assert_eq!(tags.get_commit("v1.5.0"), Some(commits[2].as_str())); + assert_eq!( + tags.get_closest(&commits[1]) + .expect("expected inserted tag") + .name, + "v1.5.0" + ); + Ok(()) + } +} diff --git a/git-cliff/src/lib.rs b/git-cliff/src/lib.rs index bcb5b62bbf..f0f2e9e71e 100644 --- a/git-cliff/src/lib.rs +++ b/git-cliff/src/lib.rs @@ -25,6 +25,7 @@ use git_cliff_core::embed::{BuiltinConfig, EmbeddedConfig}; use git_cliff_core::error::{Error, Result}; use git_cliff_core::release::Release; use git_cliff_core::repo::{Repository, SubmoduleRange}; +use git_cliff_core::tag::Tag; use git_cliff_core::{DEFAULT_CONFIG, IGNORE_FILE}; use glob::Pattern; @@ -61,7 +62,7 @@ fn determine_commit_range( let mut commit_range = args.range.clone(); if args.unreleased { - if let Some(last_tag) = tags.last().map(|(k, _)| k) { + if let Some((last_tag, _)) = tags.last() { commit_range = Some(format!("{last_tag}..HEAD")); } } else if args.latest || args.current { @@ -69,7 +70,7 @@ fn determine_commit_range( let commits = repository.commits(None, None, None, config.git.topo_order_commits)?; 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(|(commit, _)| commit), ) { if tags.len() == 1 { commit_range = Some(tag2.to_owned()); @@ -101,8 +102,8 @@ fn determine_commit_range( } } if let (Some(tag1), Some(tag2)) = ( - tags.get_index(tag_index).map(|(k, _)| k), - tags.get_index(tag_index + 1).map(|(k, _)| k), + tags.get_index(tag_index).map(|(commit, _)| commit), + tags.get_index(tag_index + 1).map(|(commit, _)| commit), ) { commit_range = Some(format!("{tag1}..{tag2}")); } @@ -202,7 +203,7 @@ fn process_repository<'a>( let ignore_regex = config.git.ignore_tags.as_ref(); let count_tags = config.git.count_tags.as_ref(); let recurse_submodules = config.git.recurse_submodules.unwrap_or(false); - tags.retain(|_, tag| { + tags.retain(|tag| { let name = &tag.name; // Keep skip tags to drop commits in the later stage. @@ -322,7 +323,7 @@ fn process_repository<'a>( } // Update tags. - let mut releases = vec![Release::default()]; + let mut release = Release::default(); let mut tag_timestamp = None; if let Some(ref tag) = args.tag { if let Some(commit_id) = commits.first().map(|c| c.id().to_string()) { @@ -336,8 +337,8 @@ fn process_repository<'a>( } } } else { - releases[0].version = Some(tag.clone()); - releases[0].timestamp = Some( + release.version = Some(tag.clone()); + release.timestamp = Some( SystemTime::now() .duration_since(UNIX_EPOCH)? .as_secs() @@ -347,11 +348,50 @@ fn process_repository<'a>( } // Process releases. - let mut previous_release = Release::default(); - let mut first_processed_tag = None; let repository_path = repository.root_path()?.to_string_lossy().into_owned(); + let mut releases = Vec::::new(); + let mut current_tag = commits + .last() + .and_then(|root| tags.get_closest(&root.id().to_string())); + let mut first_processed_tag = None; + + let push_release = |releases: &mut Vec>, + release: &mut Release<'a>, + tag: Option<&Tag>| + -> Result<()> { + release.repository = Some(repository_path.clone()); + if let Some(tag) = tag { + if let Some(release_commit) = tags.get_commit(&tag.name) { + release.version = Some(tag.name.clone()); + release.message.clone_from(&tag.message); + release.commit_id = Some(release_commit.to_string()); + release.timestamp = Some(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()?, + } + } else { + repository + .find_commit(release_commit) + .map(|c| c.time().seconds()) + .unwrap_or_default() + }); + } + } + if release.commit_id.is_none() { + release.commit_id = release.commits.last().map(|commit| commit.id.clone()); + } + let mut previous = releases.last().cloned().unwrap_or_default(); + previous.previous = None; + release.previous = Some(Box::new(previous)); + releases.push(std::mem::take(release)); + Ok(()) + }; + for git_commit in commits.iter().rev() { - let release = releases.last_mut().unwrap(); let mut commit = Commit::from(git_commit); commit.statistics = match repository.commit_statistics(git_commit) { Ok(statistics) => statistics, @@ -369,42 +409,18 @@ fn process_repository<'a>( } Err(err) => return Err(err), }; - let commit_id = commit.id.clone(); - release.commits.push(commit); - release.repository = Some(repository_path.clone()); - release.commit_id = Some(commit_id); - if let Some(tag) = tags.get(release.commit_id.as_ref().unwrap()) { - release.version = Some(tag.name.clone()); - release.message.clone_from(&tag.message); - release.timestamp = if args.tag.as_deref() == Some(tag.name.as_str()) { - match tag_timestamp { - Some(timestamp) => Some(timestamp), - None => Some( - SystemTime::now() - .duration_since(UNIX_EPOCH)? - .as_secs() - .try_into()?, - ), - } - } else { - Some(git_commit.time().seconds()) - }; - if first_processed_tag.is_none() { - first_processed_tag = Some(tag); - } - previous_release.previous = None; - release.previous = Some(Box::new(previous_release)); - previous_release = release.clone(); - releases.push(Release::default()); - } - } - - debug_assert!(!releases.is_empty()); - if releases.len() > 1 { - previous_release.previous = None; - releases.last_mut().unwrap().previous = Some(Box::new(previous_release)); + let new_tag = tags.get_closest(&commit.id); + if first_processed_tag.is_none() { + first_processed_tag = new_tag; + } + if new_tag != current_tag { + push_release(&mut releases, &mut release, current_tag)?; + current_tag = new_tag; + } + release.commits.push(commit); } + push_release(&mut releases, &mut release, current_tag)?; if args.sort == Sort::Newest { for release in &mut releases { @@ -443,7 +459,7 @@ fn process_repository<'a>( // Set the previous release if the first tag is found. if let Some((commit_id, tag)) = first_tag { let previous_release = Release { - commit_id: Some(commit_id.clone()), + commit_id: Some(commit_id.to_string()), version: Some(tag.name.clone()), timestamp: Some( repository diff --git a/npm/git-cliff/yarn.lock b/npm/git-cliff/yarn.lock index d41f1f5b63..bd55224975 100644 --- a/npm/git-cliff/yarn.lock +++ b/npm/git-cliff/yarn.lock @@ -1496,44 +1496,44 @@ __metadata: languageName: node linkType: hard -"git-cliff-darwin-arm64@npm:2.12.0": - version: 2.12.0 - resolution: "git-cliff-darwin-arm64@npm:2.12.0" +"git-cliff-darwin-arm64@npm:2.13.1": + version: 2.13.1 + resolution: "git-cliff-darwin-arm64@npm:2.13.1" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"git-cliff-darwin-x64@npm:2.12.0": - version: 2.12.0 - resolution: "git-cliff-darwin-x64@npm:2.12.0" +"git-cliff-darwin-x64@npm:2.13.1": + version: 2.13.1 + resolution: "git-cliff-darwin-x64@npm:2.13.1" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"git-cliff-linux-arm64@npm:2.12.0": - version: 2.12.0 - resolution: "git-cliff-linux-arm64@npm:2.12.0" +"git-cliff-linux-arm64@npm:2.13.1": + version: 2.13.1 + resolution: "git-cliff-linux-arm64@npm:2.13.1" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"git-cliff-linux-x64@npm:2.12.0": - version: 2.12.0 - resolution: "git-cliff-linux-x64@npm:2.12.0" +"git-cliff-linux-x64@npm:2.13.1": + version: 2.13.1 + resolution: "git-cliff-linux-x64@npm:2.13.1" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"git-cliff-windows-arm64@npm:2.12.0": - version: 2.12.0 - resolution: "git-cliff-windows-arm64@npm:2.12.0" +"git-cliff-windows-arm64@npm:2.13.1": + version: 2.13.1 + resolution: "git-cliff-windows-arm64@npm:2.13.1" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"git-cliff-windows-x64@npm:2.12.0": - version: 2.12.0 - resolution: "git-cliff-windows-x64@npm:2.12.0" +"git-cliff-windows-x64@npm:2.13.1": + version: 2.13.1 + resolution: "git-cliff-windows-x64@npm:2.13.1" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -1549,12 +1549,12 @@ __metadata: "@typescript-eslint/parser": "npm:^8.38.0" eslint: "npm:^9.32.0" execa: "npm:^9.6.0" - git-cliff-darwin-arm64: "npm:2.12.0" - git-cliff-darwin-x64: "npm:2.12.0" - git-cliff-linux-arm64: "npm:2.12.0" - git-cliff-linux-x64: "npm:2.12.0" - git-cliff-windows-arm64: "npm:2.12.0" - git-cliff-windows-x64: "npm:2.12.0" + git-cliff-darwin-arm64: "npm:2.13.1" + git-cliff-darwin-x64: "npm:2.13.1" + git-cliff-linux-arm64: "npm:2.13.1" + git-cliff-linux-x64: "npm:2.13.1" + git-cliff-windows-arm64: "npm:2.13.1" + git-cliff-windows-x64: "npm:2.13.1" tsup: "npm:^8.5.0" typescript: "npm:^5.8.3" typescript-eslint: "npm:^8.38.0" diff --git a/website/blog/git-cliff-2.2.0.md b/website/blog/git-cliff-2.2.0.md index d3a37b27ed..ce267a30df 100644 --- a/website/blog/git-cliff-2.2.0.md +++ b/website/blog/git-cliff-2.2.0.md @@ -61,7 +61,7 @@ breaking_always_bump_major = true Template rendering errors are now more verbose! -For example, let's throw an error in the template with using [throw](https://keats.github.io/tera/docs/#throw) function: +For example, let's throw an error in the template with using [throw](https://keats.github.io/tera/#throw) function: ```toml [changelog] diff --git a/website/blog/git-cliff-2.9.0.md b/website/blog/git-cliff-2.9.0.md index 28f441b638..27ffc0129a 100644 --- a/website/blog/git-cliff-2.9.0.md +++ b/website/blog/git-cliff-2.9.0.md @@ -126,7 +126,7 @@ a140cef0405e0bcbfb5de44ff59e091527d91b38..a9d4050212a18f6b3bd76e2e41fbb9045d268b :::tip -You can use the [`truncate`](https://keats.github.io/tera/docs/#truncate) filter to shorten the commit range: +You can use the [`truncate`](https://keats.github.io/tera/#truncate) filter to shorten the commit range: ```jinja {{ commit_range.from | truncate(length=7, end="") }}..{{ commit_range.to | truncate(length=7, end="") }} diff --git a/website/docs/configuration/git.md b/website/docs/configuration/git.md index c87e508669..e58327b00a 100644 --- a/website/docs/configuration/git.md +++ b/website/docs/configuration/git.md @@ -235,7 +235,7 @@ Examples: - Set the group of the commit by using its SHA1. - `{ field = "author.name", pattern = "John Doe", group = "John's stuff" }` - If the author's name attribute of the commit matches the pattern "John Doe" (as a regex), override the scope with "John's stuff". - - All values that are part of the commit context can be used. Nested fields can be accessed via the [dot notation](https://keats.github.io/tera/docs/#dot-notation). Some commonly used ones are: + - All values that are part of the commit context can be used. Nested fields can be accessed via the [dot notation](https://keats.github.io/tera/#dot-notation). Some commonly used ones are: - `id` - `message` - `author.name` diff --git a/website/docs/integration/gitea.md b/website/docs/integration/gitea.md index 10e09a388a..1ca3ebca68 100644 --- a/website/docs/integration/gitea.md +++ b/website/docs/integration/gitea.md @@ -39,7 +39,7 @@ token = "***" :::tip -[Gitea REST API](https://gitea.com/api/swagger) is being used to retrieve data from Gitea. +[Gitea REST API](https://docs.gitea.com/api/1.24/) is being used to retrieve data from Gitea. It does not require authentication for public repositories. If your project uses a private repository, you need to create an access token under _Settings_ > _Applications_ > _Access tokens_. diff --git a/website/docs/templating/syntax.md b/website/docs/templating/syntax.md index 7889a380b9..67e36d7542 100644 --- a/website/docs/templating/syntax.md +++ b/website/docs/templating/syntax.md @@ -15,7 +15,7 @@ There are 3 kinds of delimiters and those cannot be changed: -See the [Tera Documentation](https://keats.github.io/tera/docs/#templates) for more information about [control structures](https://keats.github.io/tera/docs/#control-structures), [built-ins filters](https://keats.github.io/tera/docs/#built-ins), etc. +See the [Tera Documentation](https://keats.github.io/tera/#template) for more information about [control structures](https://keats.github.io/tera/#control-structures), [built-ins filters](https://keats.github.io/tera/#built-ins), etc. ## Custom built-in filters