bumpversion/vcs/
git.rs

1//! Git backend for version control operations.
2//!
3//! Implements the `VersionControlSystem` trait using git commands.
4use crate::{
5    command::run_command,
6    f_string::{PythonFormatString, Value},
7    vcs::{RevisionInfo, TagAndRevision, TagInfo, VersionControlSystem},
8};
9use async_process::Command;
10use std::path::{Path, PathBuf};
11use std::sync::LazyLock;
12
13/// Git VCS error type.
14#[derive(thiserror::Error, Debug)]
15pub enum Error {
16    #[error("io error: {0}")]
17    Io(#[from] std::io::Error),
18
19    #[error("UTF-8 decode error: {0}")]
20    Utf8(#[from] std::str::Utf8Error),
21
22    #[error("command failed: {0}")]
23    CommandFailed(#[from] crate::command::Error),
24
25    #[error("regex error: {0}")]
26    Regex(#[from] regex::Error),
27
28    #[error("invalid tag: {0}")]
29    InvalidTag(#[from] InvalidTagError),
30
31    #[error("failed to template {format_string}")]
32    MissingArgument {
33        #[source]
34        source: crate::f_string::MissingArgumentError,
35        format_string: PythonFormatString,
36    },
37}
38
39/// Errors parsing git tag strings into version metadata.
40#[derive(thiserror::Error, Debug)]
41pub enum InvalidTagError {
42    #[error("tag {0:?} is missing commit SHA")]
43    MissingCommitSha(String),
44    #[error("tag {0:?} is missing distance to latest tag")]
45    MissingDistanceToLatestTag(String),
46    #[error("invalid distance to latest tag for {tag:?}")]
47    InvalidDistanceToLatestTag {
48        #[source]
49        source: std::num::ParseIntError,
50        tag: String,
51    },
52    #[error("tag {0:?} is missing current tag")]
53    MissingCurrentTag(String),
54    #[error("tag {0:?} is missing version")]
55    MissingVersion(String),
56}
57
58/// Represents a git repository at a given filesystem path.
59#[derive(Debug, Clone, PartialEq, Eq, Hash)]
60#[allow(clippy::module_name_repetitions)]
61pub struct GitRepository {
62    path: PathBuf,
63}
64
65static FLAG_PATTERN: LazyLock<regex::Regex> = LazyLock::new(|| {
66    regex::RegexBuilder::new(r"^(\(\?[aiLmsux]+\))")
67        .build()
68        .unwrap()
69});
70
71/// Extract the regex flags from the regex pattern.
72///
73/// # Returns
74/// The tuple `(pattern_without flags, flags)`.
75fn extract_regex_flags(pattern: &str) -> (&str, &str) {
76    let bits: Vec<_> = FLAG_PATTERN.split(pattern).collect();
77    if bits.len() < 2 {
78        (pattern, "")
79    } else {
80        (bits[1], bits[0])
81    }
82}
83
84/// Return the version from a tag
85///
86/// # Errors
87/// - When the given `parse_version_regex` cannot be transformed to extract the
88///   current version from the git tag
89fn get_version_from_tag<'a>(
90    tag: &'a str,
91    tag_name: &PythonFormatString,
92    parse_version_regex: &regex::Regex,
93) -> Result<Option<&'a str>, regex::Error> {
94    let parse_pattern = parse_version_regex.as_str();
95    let version_pattern = parse_pattern.replace("\\\\", "\\");
96    let (version_pattern, regex_flags) = extract_regex_flags(&version_pattern);
97    let PythonFormatString(values) = tag_name;
98    let (prefix, suffix) = values
99        .iter()
100        .position(|value| value == &Value::Argument("new_version".to_string()))
101        .map(|idx| {
102            let prefix = &values[..idx];
103            let suffix = &values[(idx + 1)..];
104            (prefix, suffix)
105        })
106        .unwrap_or_default();
107
108    let prefix = prefix.iter().fold(String::new(), |mut acc, value| {
109        acc.push_str(&value.to_string());
110        acc
111    });
112    let suffix = suffix.iter().fold(String::new(), |mut acc, value| {
113        acc.push_str(&value.to_string());
114        acc
115    });
116
117    let pattern = format!(
118        "{regex_flags}{}(?P<current_version>{version_pattern}){}",
119        regex::escape(&prefix),
120        regex::escape(&suffix),
121    );
122    let tag_regex = regex::RegexBuilder::new(&pattern).build()?;
123    let version = tag_regex
124        .captures_iter(tag)
125        .filter_map(|m| m.name("current_version"))
126        .map(|m| m.as_str())
127        .next();
128    Ok(version)
129}
130
131pub static BRANCH_NAME_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
132    regex::RegexBuilder::new(r"([^a-zA-Z0-9]*)")
133        .build()
134        .unwrap()
135});
136
137impl GitRepository {
138    /// Returns a dictionary containing revision information.
139    async fn revision_info(&self) -> Result<Option<RevisionInfo>, Error> {
140        let mut cmd = Command::new("git");
141        cmd.args(["rev-parse", "--show-toplevel", "--abbrev-ref", "HEAD"])
142            .current_dir(&self.path);
143
144        let res = run_command(&mut cmd).await?;
145        let mut lines = res.stdout.lines().map(str::trim);
146        let Some(repository_root) = lines.next().map(PathBuf::from) else {
147            return Ok(None);
148        };
149        let Some(branch_name) = lines.next() else {
150            return Ok(None);
151        };
152        let short_branch_name: String = BRANCH_NAME_REGEX
153            .replace_all(branch_name, "")
154            .to_lowercase()
155            .chars()
156            .take(20)
157            .collect();
158
159        Ok(Some(RevisionInfo {
160            branch_name: branch_name.to_string(),
161            short_branch_name,
162            repository_root,
163        }))
164    }
165
166    /// Get the commit info for the repo.
167    ///
168    /// The `tag_name` is the tag name format used to locate the latest tag.
169    /// The `parse_pattern` is a regular expression pattern used to parse the version from the tag.
170    async fn latest_tag_info(
171        &self,
172        tag_name: &PythonFormatString,
173        parse_version_regex: &regex::Regex,
174    ) -> Result<Option<TagInfo>, Error> {
175        let tag_pattern = tag_name
176            .format(&[("new_version", "*")].into_iter().collect(), true)
177            .map_err(|source| Error::MissingArgument {
178                source,
179                format_string: tag_name.clone(),
180            })?;
181        // let tag_pattern = tag_name.replace("{new_version}", "*");
182
183        // get info about the latest tag in git
184        let match_tag_pattern_flag = format!("--match={tag_pattern}");
185        let mut cmd = Command::new("git");
186        cmd.args([
187            "describe",
188            "--dirty",
189            "--tags",
190            "--long",
191            "--abbrev=40",
192            &match_tag_pattern_flag,
193        ])
194        .current_dir(&self.path);
195
196        match run_command(&mut cmd).await {
197            Ok(tag_info) => {
198                let raw_tag = tag_info.stdout;
199                let mut tag_parts: Vec<&str> = raw_tag.split('-').collect();
200
201                let dirty = tag_parts
202                    .last()
203                    .is_some_and(|t| t.trim().eq_ignore_ascii_case("dirty"));
204                if dirty {
205                    let _ = tag_parts.pop();
206                }
207
208                let commit_sha = tag_parts
209                    .pop()
210                    .ok_or_else(|| InvalidTagError::MissingCommitSha(raw_tag.clone()))?
211                    .trim_start_matches('g')
212                    .to_string();
213
214                let distance_to_latest_tag = tag_parts
215                    .pop()
216                    .ok_or_else(|| InvalidTagError::MissingDistanceToLatestTag(raw_tag.clone()))?
217                    .parse::<usize>()
218                    .map_err(|source| InvalidTagError::InvalidDistanceToLatestTag {
219                        source,
220                        tag: raw_tag.clone(),
221                    })?;
222                let current_tag = tag_parts.join("-");
223                let version = get_version_from_tag(&current_tag, tag_name, parse_version_regex)?;
224                let current_numeric_version = current_tag.trim_start_matches('v').to_string();
225                let current_version = version
226                    .unwrap_or(current_numeric_version.as_str())
227                    .to_string();
228
229                tracing::debug!(
230                    dirty,
231                    commit_sha,
232                    distance_to_latest_tag,
233                    current_tag,
234                    version,
235                    current_numeric_version,
236                    current_version
237                );
238
239                Ok(Some(TagInfo {
240                    dirty,
241                    commit_sha,
242                    distance_to_latest_tag,
243                    current_tag,
244                    current_version,
245                }))
246            }
247            Err(err) => {
248                if let crate::command::Error::Failed { ref output, .. } = err {
249                    if output
250                        .stderr
251                        .contains("No names found, cannot describe anything")
252                    {
253                        return Ok(None);
254                    }
255                }
256                Err(err.into())
257            }
258        }
259    }
260}
261
262// #[async_trait::async_trait]
263impl VersionControlSystem for GitRepository {
264    type Error = Error;
265
266    fn open(path: impl Into<PathBuf>) -> Result<Self, Error> {
267        Ok(Self { path: path.into() })
268    }
269
270    fn path(&self) -> &Path {
271        &self.path
272    }
273
274    async fn commit<A, E, AS, EK, EV>(
275        &self,
276        message: &str,
277        extra_args: A,
278        env: E,
279    ) -> Result<(), Error>
280    where
281        A: IntoIterator<Item = AS>,
282        E: IntoIterator<Item = (EK, EV)>,
283        AS: AsRef<std::ffi::OsStr>,
284        EK: AsRef<std::ffi::OsStr>,
285        EV: AsRef<std::ffi::OsStr>,
286    {
287        use tokio::io::AsyncWriteExt;
288
289        let tmp = tempfile::TempDir::new()?;
290        let tmp_file_path = tmp.path().join("commit-message.txt");
291        let tmp_file = tokio::fs::OpenOptions::new()
292            .create(true)
293            .write(true)
294            .truncate(true)
295            .open(&tmp_file_path)
296            .await?;
297        let mut writer = tokio::io::BufWriter::new(tmp_file);
298        writer.write_all(message.as_bytes()).await?;
299        writer.flush().await?;
300
301        let mut cmd = Command::new("git");
302        cmd.arg("commit");
303        cmd.arg("-F");
304        cmd.arg(tmp_file_path.to_string_lossy().to_string());
305        cmd.args(extra_args);
306        cmd.envs(env);
307        cmd.current_dir(&self.path);
308        let _commit_output = run_command(&mut cmd).await?;
309        Ok(())
310    }
311
312    async fn add<P>(&self, files: impl IntoIterator<Item = P>) -> Result<(), Error>
313    where
314        P: AsRef<std::ffi::OsStr>,
315    {
316        let mut cmd = Command::new("git");
317        cmd.arg("add")
318            .arg("--update")
319            .args(files)
320            .current_dir(&self.path);
321        let _add_output = run_command(&mut cmd).await?;
322        Ok(())
323    }
324
325    async fn dirty_files(&self) -> Result<Vec<PathBuf>, Error> {
326        let mut cmd = Command::new("git");
327        cmd.args(["status", "-u", "--porcelain"])
328            .current_dir(&self.path);
329
330        let status_output = run_command(&mut cmd).await?;
331        let dirty = status_output
332            .stdout
333            .lines()
334            .map(str::trim)
335            .filter(|line| !line.is_empty())
336            .filter(|line| !line.starts_with("??"))
337            .filter_map(|line| line.split_once(' '))
338            .map(|(_, file)| self.path().join(file))
339            .collect();
340        Ok(dirty)
341    }
342
343    async fn tag(&self, name: &str, message: Option<&str>, sign: bool) -> Result<(), Error> {
344        let mut cmd = Command::new("git");
345        cmd.current_dir(&self.path);
346        cmd.args(["tag", name]);
347        if sign {
348            cmd.arg("--sign");
349        }
350        if let Some(message) = message {
351            cmd.args(["--message", message]);
352        }
353        let _tag_output = run_command(&mut cmd).await?;
354        Ok(())
355    }
356
357    async fn tags(&self) -> Result<Vec<String>, Error> {
358        let mut cmd = Command::new("git");
359        cmd.current_dir(&self.path);
360        cmd.args(["tag", "--list"]);
361        let output = run_command(&mut cmd).await?;
362        Ok(output
363            .stdout
364            .lines()
365            .map(|line| line.trim().to_string())
366            .collect())
367    }
368
369    async fn latest_tag_and_revision(
370        &self,
371        tag_name: &PythonFormatString,
372        parse_version_regex: &regex::Regex,
373    ) -> Result<TagAndRevision, Error> {
374        let mut cmd = Command::new("git");
375        cmd.args(["update-index", "--refresh", "-q"])
376            .current_dir(&self.path);
377        if let Err(err) = run_command(&mut cmd).await {
378            tracing::debug!("failed to update git index: {err}");
379        }
380
381        let tag = self.latest_tag_info(tag_name, parse_version_regex).await?;
382        let revision = self.revision_info().await.ok().flatten();
383
384        Ok(TagAndRevision { tag, revision })
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use crate::{
391        command::run_command,
392        f_string::PythonFormatString,
393        tests::sim_assert_eq_sorted,
394        vcs::{VersionControlSystem, git, temp::EphemeralRepository},
395    };
396    use async_process::Command;
397    use color_eyre::eyre;
398
399    use similar_asserts::assert_eq as sim_assert_eq;
400
401    use std::io::Write;
402    use std::path::PathBuf;
403
404    #[test]
405    fn test_get_version_from_tag() -> eyre::Result<()> {
406        crate::tests::init();
407        let regex_pattern =
408            regex::RegexBuilder::new(r"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)").build()?;
409        let tag_name = PythonFormatString::parse("v{new_version}")?;
410        let version = super::get_version_from_tag("v2.1.4", &tag_name, &regex_pattern)?;
411        dbg!(&version);
412        sim_assert_eq!(version, Some("2.1.4"));
413        Ok(())
414    }
415
416    #[ignore = "wip"]
417    #[tokio::test]
418    async fn test_create_empty_git_repo() -> eyre::Result<()> {
419        crate::tests::init();
420        let repo: EphemeralRepository<git::GitRepository> = EphemeralRepository::new().await?;
421        let status = run_command(
422            Command::new("git")
423                .args(["status"])
424                .current_dir(repo.path()),
425        )
426        .await?;
427        assert!(status.stdout.contains("No commits yet"));
428        Ok(())
429    }
430
431    #[ignore = "wip"]
432    #[tokio::test]
433    async fn test_tag() -> eyre::Result<()> {
434        crate::tests::init();
435        let repo: EphemeralRepository<git::GitRepository> = EphemeralRepository::new().await?;
436        let tags = vec![
437            None,
438            Some(("tag1", Some("tag1 message"))),
439            Some(("tag2", Some("tag2 message"))),
440        ];
441        // add a single file so we can commit and get a HEAD
442        let initial_file = repo.path().join("README.md");
443        std::fs::File::create(&initial_file)?.write_all(b"Hello, world!")?;
444
445        repo.add(&[initial_file]).await?;
446        repo.commit::<_, _, &str, &str, &str>("initial commit", [], [])
447            .await?;
448        similar_asserts::assert_eq!(repo.dirty_files().await?.len(), 0);
449
450        for (tag, previous) in tags[1..].iter().zip(&tags) {
451            dbg!(previous);
452            dbg!(tag);
453            // let latest = repo.latest_tag_info(None)?.map(|t| t.current_version);
454            // let previous = previous.map(|t| t.0.to_string());
455            // similar_asserts::assert_eq!(&previous, &latest);
456            // if let Some((tag_name, tag_message)) = *tag {
457            //     repo.tag(tag_name, tag_message, false)?;
458            // }
459        }
460        Ok(())
461    }
462
463    #[ignore = "wip"]
464    #[tokio::test]
465    async fn test_dirty_tree() -> eyre::Result<()> {
466        crate::tests::init();
467        let repo: EphemeralRepository<git::GitRepository> = EphemeralRepository::new().await?;
468        similar_asserts::assert_eq!(repo.dirty_files().await?.len(), 0);
469
470        // add some dirty files
471        let mut dirty_files: Vec<PathBuf> = ["foo.txt", "dir/bar.txt"]
472            .iter()
473            .map(|f| repo.path().join(f))
474            .collect();
475
476        for dirty_file in &dirty_files {
477            use tokio::io::AsyncWriteExt;
478            if let Some(parent) = dirty_file.parent() {
479                tokio::fs::create_dir_all(parent).await?;
480            }
481            let file = tokio::fs::OpenOptions::new()
482                .create(true)
483                .write(true)
484                .truncate(true)
485                .open(dirty_file)
486                .await?;
487            let mut writer = tokio::io::BufWriter::new(file);
488            writer.write_all(b"Hello, world!").await?;
489        }
490        similar_asserts::assert_eq!(repo.dirty_files().await?.len(), 0);
491
492        // track first file
493        repo.add(&dirty_files[0..1]).await?;
494        sim_assert_eq_sorted!(repo.dirty_files().await?, dirty_files[0..1]);
495
496        // track all files
497        repo.add(&dirty_files).await?;
498        sim_assert_eq_sorted!(repo.dirty_files().await?, dirty_files);
499        Ok(())
500    }
501}