1mod cmd;
4#[cfg(feature = "test_fixture")]
5pub mod test_fixture;
6
7use std::{collections::HashSet, path::Path, process::Command};
8
9use anyhow::{Context, anyhow};
10use camino::{Utf8Path, Utf8PathBuf};
11use tracing::{Span, debug, instrument, trace, warn};
12
13#[derive(Debug)]
15pub struct Repo {
16 directory: Utf8PathBuf,
18 original_branch: String,
20 original_remote: String,
22}
23
24impl Repo {
25 #[instrument(skip_all)]
27 pub fn new(directory: impl AsRef<Utf8Path>) -> anyhow::Result<Self> {
28 debug!("initializing directory {:?}", directory.as_ref());
29
30 let (current_remote, current_branch) = Self::get_current_remote_and_branch(&directory)
31 .context("cannot determine current branch")?;
32
33 Ok(Self {
34 directory: directory.as_ref().to_path_buf(),
35 original_branch: current_branch,
36 original_remote: current_remote,
37 })
38 }
39
40 pub fn directory(&self) -> &Utf8Path {
41 &self.directory
42 }
43
44 fn get_current_remote_and_branch(
45 directory: impl AsRef<Utf8Path>,
46 ) -> anyhow::Result<(String, String)> {
47 match git_in_dir(
48 directory.as_ref(),
49 &[
50 "rev-parse",
51 "--abbrev-ref",
52 "--symbolic-full-name",
53 "@{upstream}",
54 ],
55 ) {
56 Ok(output) => output
57 .split_once('/')
58 .map(|(remote, branch)| (remote.to_string(), branch.to_string()))
59 .context("cannot determine current remote and branch"),
60
61 Err(e) => {
62 let err = e.to_string();
63 if err.contains("fatal: no upstream configured for branch") {
64 let branch = get_current_branch(directory)?;
65 warn!("no upstream configured for branch {branch}");
66 Ok(("origin".to_string(), branch))
67 } else if err.contains("fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree.") {
68 Err(anyhow!("git repository does not contain any commit."))
69 } else {
70 Err(e)
71 }
72 }
73 }
74 }
75
76 pub fn is_clean(&self) -> anyhow::Result<()> {
78 let changes = self.changes_except_typechanges()?;
79 anyhow::ensure!(
80 changes.is_empty(),
81 "the working directory of this project has uncommitted changes. If these files are both committed and in .gitignore, either delete them or remove them from .gitignore. Otherwise, please commit or stash these changes:\n{changes:?}"
82 );
83 Ok(())
84 }
85
86 pub fn checkout_new_branch(&self, branch: &str) -> anyhow::Result<()> {
87 self.git(&["checkout", "-b", branch])?;
88 Ok(())
89 }
90
91 pub fn delete_branch_in_remote(&self, branch: &str) -> anyhow::Result<()> {
92 self.push(&format!(":refs/heads/{branch}"))
93 .with_context(|| format!("can't delete temporary branch {branch}"))
94 }
95
96 pub fn add_all_and_commit(&self, message: &str) -> anyhow::Result<()> {
97 self.git(&["add", "."])?;
98 self.git(&["commit", "-m", message])?;
99 Ok(())
100 }
101
102 pub fn changes(&self, filter: impl FnMut(&&str) -> bool) -> anyhow::Result<Vec<String>> {
106 let output = self.git(&["status", "--porcelain"])?;
107 let changed_files = changed_files(&output, filter);
108 Ok(changed_files)
109 }
110
111 pub fn files_of_current_commit(&self) -> anyhow::Result<HashSet<Utf8PathBuf>> {
113 let output = self.git(&["show", "--oneline", "--name-only", "--pretty=format:"])?;
114 let changed_files = output
115 .lines()
116 .map(|l| l.trim())
117 .map(Utf8PathBuf::from)
118 .collect();
119 Ok(changed_files)
120 }
121
122 pub fn changes_except_typechanges(&self) -> anyhow::Result<Vec<String>> {
123 self.changes(|line| !line.starts_with("T "))
124 }
125
126 pub fn add<T: AsRef<str>>(&self, paths: &[T]) -> anyhow::Result<()> {
127 let mut args = vec!["add"];
128 let paths: Vec<&str> = paths.iter().map(|p| p.as_ref()).collect();
129 args.extend(paths);
130 self.git(&args)?;
131 Ok(())
132 }
133
134 pub fn commit(&self, message: &str) -> anyhow::Result<()> {
135 self.git(&["commit", "-m", message])?;
136 Ok(())
137 }
138
139 pub fn commit_signed(&self, message: &str) -> anyhow::Result<()> {
140 self.git(&["commit", "-s", "-m", message])?;
141 Ok(())
142 }
143
144 pub fn push(&self, obj: &str) -> anyhow::Result<()> {
145 self.git(&["push", &self.original_remote, obj])?;
146 Ok(())
147 }
148
149 pub fn fetch(&self, obj: &str) -> anyhow::Result<()> {
150 self.git(&["fetch", &self.original_remote, obj])?;
151 Ok(())
152 }
153
154 pub fn force_push(&self, obj: &str) -> anyhow::Result<()> {
155 self.git(&["push", &self.original_remote, obj, "--force-with-lease"])?;
161 Ok(())
162 }
163
164 #[instrument(skip(self))]
165 pub fn checkout_head(&self) -> anyhow::Result<()> {
166 self.checkout(&self.original_branch)?;
167 Ok(())
168 }
169
170 pub fn original_branch(&self) -> &str {
173 &self.original_branch
174 }
175
176 #[instrument(skip(self))]
177 fn current_commit(&self) -> anyhow::Result<String> {
178 self.nth_commit(1)
179 }
180
181 #[instrument(skip(self))]
182 fn previous_commit(&self) -> anyhow::Result<String> {
183 self.nth_commit(2)
184 }
185
186 #[instrument(
187 skip(self)
188 fields(
189 nth_commit = tracing::field::Empty,
190 )
191 )]
192 fn nth_commit(&self, nth: usize) -> anyhow::Result<String> {
193 let nth = nth.to_string();
194 let commit_list = self.git(&["--format=%H", "-n", &nth])?;
195 let last_commit = commit_list
196 .lines()
197 .last()
198 .context("repository has no commits")?;
199 Span::current().record("nth_commit", last_commit);
200
201 Ok(last_commit.to_string())
202 }
203
204 pub fn git(&self, args: &[&str]) -> anyhow::Result<String> {
206 git_in_dir(&self.directory, args)
207 }
208
209 pub fn stash_pop(&self) -> anyhow::Result<()> {
210 self.git(&["stash", "pop"])?;
211 Ok(())
212 }
213
214 pub fn checkout_last_commit_at_paths(&self, paths: &[&Path]) -> anyhow::Result<()> {
216 let previous_commit = self.last_commit_at_paths(paths)?;
217 self.checkout(&previous_commit)?;
218 Ok(())
219 }
220
221 fn last_commit_at_paths(&self, paths: &[&Path]) -> anyhow::Result<String> {
222 self.nth_commit_at_paths(1, paths)
223 .context("failed to get message of last commit")
224 }
225
226 fn previous_commit_at_paths(&self, paths: &[&Path]) -> anyhow::Result<String> {
227 self.nth_commit_at_paths(2, paths)
228 .context("failed to get message of previous commit")
229 }
230
231 pub fn checkout_previous_commit_at_paths(&self, paths: &[&Path]) -> anyhow::Result<()> {
232 let commit = self.previous_commit_at_paths(paths)?;
233 self.checkout(&commit)?;
234 Ok(())
235 }
236
237 #[instrument(skip(self))]
238 pub fn checkout(&self, object: &str) -> anyhow::Result<()> {
239 self.git(&["checkout", object])
240 .context("failed to checkout")?;
241 Ok(())
242 }
243
244 pub fn add_worktree(&self, path: impl AsRef<str>, object: &str) -> anyhow::Result<()> {
246 self.git(&["worktree", "add", "--detach", path.as_ref(), object])
247 .context("failed to create git worktree")?;
248
249 Ok(())
250 }
251
252 pub fn remove_worktree(&self, path: impl AsRef<str>) -> anyhow::Result<()> {
254 self.git(&["worktree", "remove", path.as_ref()])
255 .context("failed to remove worktree")?;
256
257 Ok(())
258 }
259
260 #[instrument(
262 skip(self)
263 fields(
264 nth_commit = tracing::field::Empty,
265 )
266 )]
267 fn nth_commit_at_paths(&self, nth: usize, paths: &[&Path]) -> anyhow::Result<String> {
268 let nth_str = nth.to_string();
269
270 let git_args = {
271 let mut git_args = vec!["log", "--format=%H", "-n", &nth_str, "--"];
272 for p in paths {
273 let path = p.to_str().expect("invalid path");
274 git_args.push(path);
275 }
276 git_args
277 };
278
279 let commit_list = self.git(&git_args)?;
280 let mut commits = commit_list.lines();
281 let last_commit = commits.nth(nth - 1).context("not enough commits")?;
282
283 Span::current().record("nth_commit", last_commit);
284 debug!("nth_commit found");
285 Ok(last_commit.to_string())
286 }
287
288 pub fn current_commit_message(&self) -> anyhow::Result<String> {
289 self.git(&["log", "-1", "--pretty=format:%B"])
290 }
291
292 pub fn get_author_name(&self, commit_hash: &str) -> anyhow::Result<String> {
293 self.get_commit_info("%an", commit_hash)
294 }
295
296 pub fn get_author_email(&self, commit_hash: &str) -> anyhow::Result<String> {
297 self.get_commit_info("%ae", commit_hash)
298 }
299
300 pub fn get_committer_name(&self, commit_hash: &str) -> anyhow::Result<String> {
301 self.get_commit_info("%cn", commit_hash)
302 }
303
304 pub fn get_committer_email(&self, commit_hash: &str) -> anyhow::Result<String> {
305 self.get_commit_info("%ce", commit_hash)
306 }
307
308 fn get_commit_info(&self, info: &str, commit_hash: &str) -> anyhow::Result<String> {
309 self.git(&["log", "-1", &format!("--pretty=format:{info}"), commit_hash])
310 }
311
312 pub fn current_commit_hash(&self) -> anyhow::Result<String> {
314 self.git(&["log", "-1", "--pretty=format:%H"])
315 .context("can't determine current commit hash")
316 }
317
318 pub fn tag(&self, name: &str, message: &str) -> anyhow::Result<String> {
320 self.git(&["tag", "-m", message, name])
321 }
322
323 pub fn get_tag_commit(&self, tag: &str) -> Option<String> {
325 self.git(&["rev-list", "-n", "1", tag]).ok()
326 }
327
328 pub fn get_all_tags(&self) -> Vec<String> {
330 match self
331 .git(&["tag", "--list"])
332 .ok()
333 .as_ref()
334 .map(|output| output.trim())
335 {
336 None | Some("") => vec![],
337 Some(output) => output.lines().map(|line| line.to_owned()).collect(),
338 }
339 }
340
341 pub fn is_ancestor(&self, maybe_ancestor_commit: &str, descendant_commit: &str) -> bool {
353 self.git(&[
354 "merge-base",
355 "--is-ancestor",
356 maybe_ancestor_commit,
357 descendant_commit,
358 ])
359 .is_ok()
360 }
361
362 pub fn original_remote(&self) -> &str {
364 &self.original_remote
365 }
366
367 pub fn original_remote_url(&self) -> anyhow::Result<String> {
369 let param = format!("remote.{}.url", self.original_remote);
370 self.git(&["config", "--get", ¶m])
371 }
372
373 pub fn tag_exists(&self, tag: &str) -> anyhow::Result<bool> {
374 let output = self
375 .git(&["tag", "-l", tag])
376 .context("cannot determine if git tag exists")?;
377 Ok(output.lines().count() >= 1)
378 }
379
380 pub fn get_branches_of_commit(&self, commit_hash: &str) -> anyhow::Result<Vec<String>> {
381 let output = self.git(&["branch", "--contains", commit_hash])?;
382 let branches = output
383 .lines()
384 .filter_map(|l| l.split_whitespace().last())
385 .map(|s| s.to_string())
386 .collect();
387 Ok(branches)
388 }
389}
390
391pub fn is_file_ignored(repo_path: &Utf8Path, file: &Utf8Path) -> bool {
392 let file = file.as_str();
393
394 git_in_dir(repo_path, &["check-ignore", "--no-index", file]).is_ok()
395}
396
397pub fn is_file_committed(repo_path: &Utf8Path, file: &Utf8Path) -> bool {
398 let file = file.as_str();
399 git_in_dir(repo_path, &["ls-files", "--error-unmatch", file]).is_ok()
400}
401
402fn changed_files(output: &str, filter: impl FnMut(&&str) -> bool) -> Vec<String> {
403 output
404 .lines()
405 .map(|l| l.trim())
406 .filter(filter)
408 .filter_map(|e| e.rsplit(' ').next())
409 .map(|e| e.to_string())
410 .collect()
411}
412
413#[instrument]
414pub fn git_in_dir(dir: &Utf8Path, args: &[&str]) -> anyhow::Result<String> {
415 let args: Vec<&str> = args.iter().map(|s| s.trim()).collect();
416 let output = Command::new("git")
417 .arg("-C")
418 .arg(dir)
419 .args(&args)
420 .output()
421 .with_context(|| {
422 format!("error while running git in directory `{dir:?}` with args `{args:?}`")
423 })?;
424 trace!("git {:?}: output = {:?}", args, output);
425 let stdout = cmd::string_from_bytes(output.stdout)?;
426 if output.status.success() {
427 Ok(stdout)
428 } else {
429 let mut error =
430 format!("error while running git in directory `{dir:?}` with args `{args:?}");
431 let stderr = cmd::string_from_bytes(output.stderr)?;
432 if !stdout.is_empty() || !stderr.is_empty() {
433 error.push(':');
434 }
435 if !stdout.is_empty() {
436 error.push_str("\n- stdout: ");
437 error.push_str(&stdout);
438 }
439 if !stderr.is_empty() {
440 error.push_str("\n- stderr: ");
441 error.push_str(&stderr);
442 }
443 Err(anyhow!(error))
444 }
445}
446
447fn get_current_branch(directory: impl AsRef<Utf8Path>) -> anyhow::Result<String> {
449 git_in_dir(directory.as_ref(), &["rev-parse", "--abbrev-ref", "HEAD"]).map_err(|e| {
450 if e.to_string().contains(
451 "fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree.",
452 ) {
453 anyhow!("git repository does not contain any commit.")
454 } else {
455 e
456 }
457 })
458}
459
460#[cfg(test)]
461mod tests {
462 use tempfile::tempdir;
463
464 use super::*;
465
466 #[test]
467 fn inexistent_previous_commit_detected() {
468 let repository_dir = tempdir().unwrap();
469 let repo = Repo::init(&repository_dir);
470 let file1 = repository_dir.as_ref().join("file1.txt");
471 repo.checkout_previous_commit_at_paths(&[&file1])
472 .unwrap_err();
473 }
474
475 #[test]
476 fn previous_commit_is_retrieved() {
477 test_logs::init();
478 let repository_dir = tempdir().unwrap();
479 let repo = Repo::init(&repository_dir);
480 let file1 = repository_dir.as_ref().join("file1.txt");
481 let file2 = repository_dir.as_ref().join("file2.txt");
482 {
483 fs_err::write(&file2, b"Hello, file2!-1").unwrap();
484 repo.add_all_and_commit("file2-1").unwrap();
485 fs_err::write(file1, b"Hello, file1!").unwrap();
486 repo.add_all_and_commit("file1").unwrap();
487 fs_err::write(&file2, b"Hello, file2!-2").unwrap();
488 repo.add_all_and_commit("file2-2").unwrap();
489 }
490 repo.checkout_previous_commit_at_paths(&[&file2]).unwrap();
491 assert_eq!(repo.current_commit_message().unwrap(), "file2-1");
492 }
493
494 #[test]
495 fn current_commit_is_retrieved() {
496 test_logs::init();
497 let repository_dir = tempdir().unwrap();
498 let repo = Repo::init(&repository_dir);
499 let file1 = repository_dir.as_ref().join("file1.txt");
500
501 let commit_message = r"feat: my feature
502
503 message
504
505 footer: small note";
506
507 {
508 fs_err::write(file1, b"Hello, file1!").unwrap();
509 repo.add_all_and_commit(commit_message).unwrap();
510 }
511 assert_eq!(repo.current_commit_message().unwrap(), commit_message);
512 }
513
514 #[test]
515 fn clean_project_is_recognized() {
516 test_logs::init();
517 let repository_dir = tempdir().unwrap();
518 let repo = Repo::init(&repository_dir);
519 repo.is_clean().unwrap();
520 }
521
522 #[test]
523 fn dirty_project_is_recognized() {
524 test_logs::init();
525 let repository_dir = tempdir().unwrap();
526 let repo = Repo::init(&repository_dir);
527 let file1 = repository_dir.as_ref().join("file1.txt");
528 fs_err::write(file1, b"Hello, file1!").unwrap();
529 assert!(repo.is_clean().is_err());
530 }
531
532 #[test]
533 fn changes_files_except_typechanges_are_detected() {
534 let git_status_output = r"T CHANGELOG.md
535M README.md
536A crates
537D crates/git_cmd/CHANGELOG.md
538";
539 let changed_files = changed_files(git_status_output, |line| !line.starts_with("T "));
540 let expected_changed_files = vec!["README.md", "crates", "crates/git_cmd/CHANGELOG.md"];
542 assert_eq!(changed_files, expected_changed_files);
543 }
544
545 #[test]
546 fn existing_tag_is_recognized() {
547 test_logs::init();
548 let repository_dir = tempdir().unwrap();
549 let repo = Repo::init(&repository_dir);
550 let file1 = repository_dir.as_ref().join("file1.txt");
551 {
552 fs_err::write(file1, b"Hello, file1!").unwrap();
553 repo.add_all_and_commit("file1").unwrap();
554 }
555 let version = "v1.0.0";
556 repo.tag(version, "test").unwrap();
557 assert!(repo.tag_exists(version).unwrap());
558 }
559
560 #[test]
561 fn non_existing_tag_is_recognized() {
562 test_logs::init();
563 let repository_dir = tempdir().unwrap();
564 let repo = Repo::init(&repository_dir);
565 let file1 = repository_dir.as_ref().join("file1.txt");
566 {
567 fs_err::write(file1, b"Hello, file1!").unwrap();
568 repo.add_all_and_commit("file1").unwrap();
569 }
570 repo.tag("v1.0.0", "test").unwrap();
571 assert!(!repo.tag_exists("v2.0.0").unwrap());
572 }
573
574 #[test]
575 fn tags_are_retrieved() {
576 test_logs::init();
577 let repository_dir = tempdir().unwrap();
578 let repo = Repo::init(&repository_dir);
579 repo.tag("v1.0.0", "test").unwrap();
580 let file1 = repository_dir.as_ref().join("file1.txt");
581 {
582 fs_err::write(file1, b"Hello, file1!").unwrap();
583 repo.add_all_and_commit("file1").unwrap();
584 }
585 repo.tag("v1.0.1", "test2").unwrap();
586 let tags = repo.get_all_tags();
587 assert_eq!(tags, vec!["v1.0.0", "v1.0.1"]);
588 }
589
590 #[test]
591 fn is_branch_of_commit_detected_correctly() {
592 test_logs::init();
593 let repository_dir = tempdir().unwrap();
594 let repo = Repo::init(&repository_dir);
595 let commit_hash = repo.current_commit_hash().unwrap();
596 let branches = repo.get_branches_of_commit(&commit_hash).unwrap();
597 assert_eq!(branches, vec![repo.original_branch()]);
598 }
599}