gitcc_core/
commit.rs

1//! Git commands
2
3use std::{
4    cmp::max,
5    collections::{BTreeMap, HashMap},
6    fmt::Display,
7    path::Path,
8};
9
10use gitcc_convco::{ConvcoMessage, DEFAULT_CONVCO_INCR_MINOR_TYPES, DEFAULT_CONVCO_TYPES};
11use gitcc_git::discover_repo;
12use semver::Version;
13use serde::{Deserialize, Serialize};
14use time::OffsetDateTime;
15
16pub use gitcc_git::StatusShow;
17
18use crate::{Config, Error};
19
20/// Commits configuration
21#[derive(Debug, Serialize, Deserialize)]
22pub struct CommitConfig {
23    /// Valid commit types (key + description)
24    pub types: BTreeMap<String, String>,
25}
26
27impl Default for CommitConfig {
28    fn default() -> Self {
29        Self {
30            types: DEFAULT_CONVCO_TYPES
31                .iter()
32                .map(|(k, v)| (k.to_string(), v.to_string()))
33                .collect(),
34        }
35    }
36}
37
38/// Versioning configuration
39#[derive(Debug, Serialize, Deserialize)]
40pub struct VersioningConfig {
41    /// List of commit types which increment the minor version
42    ///
43    /// Those are enhancements (vs fixes or cosmetic changes)
44    pub types_incr_minor: Vec<String>,
45}
46
47impl Default for VersioningConfig {
48    fn default() -> Self {
49        Self {
50            types_incr_minor: DEFAULT_CONVCO_INCR_MINOR_TYPES
51                .map(|s| s.to_string())
52                .to_vec(),
53        }
54    }
55}
56
57/// A commit
58///
59/// This commit object extends the std commit with:
60/// - its tag
61/// - the parsed conventional message
62#[derive(Debug)]
63pub struct Commit {
64    /// ID (hash)
65    pub id: String,
66    /// Date
67    pub date: OffsetDateTime,
68    /// Author name
69    pub author_name: String,
70    /// Author email
71    pub author_email: String,
72    /// Committer name
73    pub committer_name: String,
74    /// Committer email
75    pub committer_email: String,
76    /// Raw message
77    pub raw_message: String,
78    /// Parsed convco message (None if not a conventional message)
79    pub conv_message: Option<ConvcoMessage>,
80    /// Tag object
81    pub tag: Option<gitcc_git::Tag>,
82    /// Version to which the commit belongs (None = unreleased)
83    pub version_tag: Option<gitcc_git::Tag>,
84}
85
86impl Commit {
87    /// Returns the short id (7 chars)
88    pub fn short_id(&self) -> String {
89        let mut short_id = self.id.clone();
90        short_id.truncate(7);
91        short_id
92    }
93
94    /// Returns the commit subject (1st line)
95    pub fn subject(&self) -> String {
96        if let Some(line) = self.raw_message.lines().next() {
97            return line.to_string();
98        }
99        unreachable!()
100    }
101}
102
103/// The semver version increment
104#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
105pub enum VersionIncr {
106    None,
107    Patch,
108    Minor,
109    Major,
110}
111
112impl Display for VersionIncr {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            VersionIncr::None => write!(f, "na"),
116            VersionIncr::Patch => write!(f, "patch"),
117            VersionIncr::Minor => write!(f, "minor"),
118            VersionIncr::Major => write!(f, "major"),
119        }
120    }
121}
122
123impl VersionIncr {
124    /// Applies a version increment to a version
125    fn apply(&self, version: &Option<Version>) -> Version {
126        if let Some(v) = version {
127            if v.major == 0 {
128                match self {
129                    VersionIncr::None => v.clone(),
130                    VersionIncr::Patch => Version::new(0, v.minor + 1, 0),
131                    VersionIncr::Minor => Version::new(0, v.minor + 1, 0),
132                    VersionIncr::Major => Version::new(0, v.minor + 1, 0),
133                }
134            } else {
135                match self {
136                    VersionIncr::None => v.clone(),
137                    VersionIncr::Patch => Version::new(v.major, v.minor, v.patch + 1),
138                    VersionIncr::Minor => Version::new(v.major, v.minor + 1, 0),
139                    VersionIncr::Major => Version::new(v.major + 1, 0, 0),
140                }
141            }
142        } else {
143            Version::new(0, 1, 0)
144        }
145    }
146}
147
148/// Extension trait for conventional messages
149pub trait ConvcoMessageExt {
150    /// Determines the kin of version increment for a conventional message
151    fn version_incr_kind(&self, cfg: &VersioningConfig) -> VersionIncr;
152}
153
154impl ConvcoMessageExt for ConvcoMessage {
155    fn version_incr_kind(&self, cfg: &VersioningConfig) -> VersionIncr {
156        if self.is_breaking_change() {
157            return VersionIncr::Major;
158        }
159
160        if cfg.types_incr_minor.contains(&self.r#type) {
161            return VersionIncr::Minor;
162        }
163        VersionIncr::Patch
164    }
165}
166
167/// Commit history
168#[derive(Debug)]
169pub struct CommitHistory {
170    /// Commits
171    ///
172    /// The list is ordered with the last commit first
173    pub commits: Vec<Commit>,
174    /// Current version
175    pub curr_version: Option<Version>,
176    /// Next version (unreleased)
177    pub next_version: Version,
178}
179
180impl CommitHistory {
181    pub fn next_version_str(&self) -> String {
182        format!("v{}", self.next_version)
183    }
184}
185
186/// Checks if the repo has unstaged or untracked files
187pub fn git_status(
188    cwd: &Path,
189    show: StatusShow,
190) -> Result<BTreeMap<String, gitcc_git::Status>, Error> {
191    let repo = discover_repo(cwd)?;
192    let files = gitcc_git::repo_status(&repo, show)?;
193    Ok(files)
194}
195
196/// Adds all changes to the index
197pub fn git_add_all(cwd: &Path) -> Result<(), Error> {
198    let repo = discover_repo(cwd)?;
199    gitcc_git::add_all(&repo)?;
200    Ok(())
201}
202
203/// Returns the history of all commits
204pub fn commit_history(cwd: &Path, cfg: &Config) -> Result<CommitHistory, Error> {
205    let repo = gitcc_git::discover_repo(cwd)?;
206    let git_commits = gitcc_git::commit_log(&repo)?;
207    let map_commit_to_tag: HashMap<_, _> = gitcc_git::get_tag_refs(&repo)?
208        .into_iter()
209        .map(|t| (t.commit_id.clone(), t))
210        .collect();
211
212    let mut commits = Vec::new();
213    let mut curr_version: Option<Version> = None; // current version
214    let mut latest_version_tag: Option<gitcc_git::Tag> = None;
215    let mut unreleased_incr_kind = VersionIncr::None; // type of increment for the next version
216    let mut is_commit_released = false;
217    for c in git_commits {
218        // NB: this loop is with the last commit first, so we walk towards the 1st commit
219        let conv_message = match c.message.parse::<ConvcoMessage>() {
220            Ok(m) => {
221                if !cfg.commit.types.contains_key(&m.r#type) {
222                    log::debug!("commit {} has an invalid type: {}", c.id, m.r#type);
223                }
224                Some(m)
225            }
226            Err(err) => {
227                log::debug!(
228                    "commit {} does not follow the conventional commit format: {}",
229                    c.id,
230                    err
231                );
232                None
233            }
234        };
235
236        let tag = map_commit_to_tag.get(&c.id).cloned();
237
238        // if an annotated tag is found, set the commit version
239        let mut has_annotated_tag = false;
240        if let Some(tag) = &tag {
241            if tag.is_annotated() {
242                has_annotated_tag = true
243            }
244        }
245        if has_annotated_tag {
246            let tag = tag.clone().unwrap();
247            let tag_name = tag.name.trim();
248            let tag_version = tag_name.strip_prefix('v').unwrap_or(tag_name);
249            match tag_version.parse::<Version>() {
250                Ok(v) => {
251                    // eprintln!(" => version: {}", v);
252                    latest_version_tag = Some(tag);
253                    if curr_version.is_none() {
254                        curr_version = Some(v);
255                    }
256                    is_commit_released = true;
257                }
258                Err(err) => {
259                    log::debug!(
260                        "commit {} has tag {} which is not a semver version: {}",
261                        c.id,
262                        tag.name,
263                        err
264                    );
265                }
266            }
267        }
268
269        // find how to increment the next version for unreleaed commits
270        if !is_commit_released {
271            if let Some(m) = &conv_message {
272                let commit_incr_kind = m.version_incr_kind(&cfg.version);
273                unreleased_incr_kind = max(unreleased_incr_kind, commit_incr_kind);
274            } else {
275                unreleased_incr_kind = max(unreleased_incr_kind, VersionIncr::Patch);
276            }
277        }
278
279        commits.push(Commit {
280            id: c.id,
281            date: c.date,
282            author_name: c.author_name,
283            author_email: c.author_email,
284            committer_name: c.committer_name,
285            committer_email: c.committer_email,
286            raw_message: c.message,
287            conv_message,
288            tag,
289            version_tag: latest_version_tag.clone(),
290        });
291    }
292
293    let next_version = unreleased_incr_kind.apply(&curr_version);
294
295    Ok(CommitHistory {
296        commits,
297        curr_version,
298        next_version,
299    })
300}
301
302/// Commits the changes to git
303pub fn commit_changes(cwd: &Path, message: &str) -> Result<gitcc_git::Commit, Error> {
304    let repo = gitcc_git::discover_repo(cwd)?;
305    Ok(gitcc_git::commit_to_head(&repo, message)?)
306}
307
308#[cfg(test)]
309mod tests {
310    use time::macros::format_description;
311
312    use super::*;
313
314    #[test]
315    fn test_history() {
316        let cwd = std::env::current_dir().unwrap();
317        let cfg = Config::load_from_fs(&cwd).unwrap().unwrap_or_default();
318        let history = commit_history(&cwd, &cfg).unwrap();
319        for c in &history.commits {
320            eprintln!(
321                "{}: {} | {} | {} {}",
322                c.date
323                    .format(format_description!("[year]-[month]-[day]"))
324                    .unwrap(),
325                c.conv_message
326                    .as_ref()
327                    .map(|m| m.r#type.clone())
328                    .unwrap_or("--".to_string()),
329                if c.version_tag.is_none() {
330                    c.conv_message
331                        .as_ref()
332                        .map(|m| m.version_incr_kind(&cfg.version).to_string())
333                        .unwrap_or("--".to_string())
334                } else {
335                    "--".to_string()
336                },
337                c.version_tag
338                    .as_ref()
339                    .map(|t| t.name.to_string())
340                    .unwrap_or("unreleased".to_string()),
341                if let Some(tag) = &c.tag {
342                    format!("<- {}", &tag.name)
343                } else {
344                    "".to_string()
345                }
346            );
347        }
348        eprintln!();
349        eprintln!(
350            "current version: {}",
351            history
352                .curr_version
353                .map(|v| v.to_string())
354                .unwrap_or("unreleased".to_string())
355        );
356        eprintln!("next version: {}", history.next_version);
357        eprintln!();
358    }
359}