1use 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#[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#[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#[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
71fn 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
84fn get_version_from_tag<'a>(
90 tag: &'a str,
91 tag_name: &PythonFormatString,
92 parse_version_regex: ®ex::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 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 async fn latest_tag_info(
171 &self,
172 tag_name: &PythonFormatString,
173 parse_version_regex: ®ex::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 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(¤t_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
262impl 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: ®ex::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, ®ex_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 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 }
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 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 repo.add(&dirty_files[0..1]).await?;
494 sim_assert_eq_sorted!(repo.dirty_files().await?, dirty_files[0..1]);
495
496 repo.add(&dirty_files).await?;
498 sim_assert_eq_sorted!(repo.dirty_files().await?, dirty_files);
499 Ok(())
500 }
501}