git_slides/
git.rs

1// git-slides — Navigate through Git commits like presentation slides.
2// Copyright (C) 2024  Quentin Richert
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17use std::env;
18use std::path::PathBuf;
19use std::process::{Command, Stdio};
20
21pub struct Commit {
22    pub hash: String,
23    pub title: String,
24}
25
26#[must_use]
27pub fn is_git_in_path() -> bool {
28    Command::new("git")
29        .arg("--version")
30        .stdin(Stdio::null())
31        .stdout(Stdio::null())
32        .stderr(Stdio::null())
33        .status()
34        .is_ok()
35}
36
37#[must_use]
38pub fn find_git_directory() -> Option<PathBuf> {
39    let mut current_dir = env::current_dir().ok()?;
40
41    loop {
42        let git_dir = current_dir.join(".git");
43        if git_dir.is_dir() {
44            return Some(git_dir);
45        }
46        if !current_dir.pop() {
47            break;
48        }
49    }
50
51    None
52}
53
54#[must_use]
55pub fn current_commit_hash() -> Option<String> {
56    let output = Command::new("git")
57        .arg("rev-parse")
58        .arg("--verify")
59        .arg("--quiet")
60        .arg("HEAD^{commit}")
61        .output();
62
63    if let Ok(output) = output {
64        if output.status.success() {
65            let hash = String::from_utf8_lossy(&output.stdout).trim().to_owned();
66            return Some(hash);
67        }
68    }
69
70    None
71}
72
73#[must_use]
74pub fn current_branch() -> Option<String> {
75    let output = Command::new("git")
76        .arg("symbolic-ref")
77        .arg("--short")
78        .arg("--quiet")
79        .arg("HEAD")
80        .output();
81
82    if let Ok(output) = output {
83        if output.status.success() {
84            let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
85            return Some(branch);
86        }
87    }
88
89    None
90}
91
92#[must_use]
93pub fn ref_to_commit_hash(ref_: &str) -> Option<String> {
94    let output = Command::new("git")
95        .arg("rev-parse")
96        .arg("--verify")
97        .arg("--quiet")
98        .arg("--end-of-options")
99        .arg(format!("{ref_}^{{commit}}"))
100        .output();
101
102    if let Ok(output) = output {
103        if output.status.success() {
104            let hash = String::from_utf8_lossy(&output.stdout).trim().to_owned();
105            return Some(hash);
106        }
107    }
108
109    None
110}
111
112#[cfg(not(tarpaulin_include))] // Does not ignore '(return) Vec::new()'.
113#[must_use]
114pub fn history_up_to_commit(commit: &str) -> Vec<Commit> {
115    let output = Command::new("git")
116        .arg("rev-list")
117        .arg("--first-parent")
118        .arg("--format=%H %s")
119        .arg("--no-commit-header")
120        .arg("--reverse")
121        .arg(commit)
122        .output();
123
124    if let Ok(output) = output {
125        if output.status.success() {
126            let commits: Vec<Commit> = String::from_utf8_lossy(&output.stdout)
127                .lines()
128                .filter_map(|line| {
129                    let pieces = line.split_once(' ')?;
130                    let hash = String::from(pieces.0);
131                    let title = String::from(pieces.1);
132                    Some(Commit { hash, title })
133                })
134                .collect();
135            return commits;
136        }
137    }
138
139    // Should never happen, because we always have at least one commit.
140    Vec::new()
141}
142
143#[cfg(not(tarpaulin_include))] // Does not ignore 'return false'.
144#[must_use]
145pub fn checkout(commit: &str) -> bool {
146    let status = Command::new("git")
147        .arg("checkout")
148        .arg(commit)
149        .stdin(Stdio::null())
150        .stdout(Stdio::null())
151        .stderr(Stdio::null())
152        .status();
153
154    let Ok(status) = status else {
155        return false;
156    };
157
158    status.success()
159}
160
161#[cfg(not(tarpaulin_include))] // Does not ignore 'return false'.
162#[must_use]
163pub fn is_working_directory_clean() -> bool {
164    let output = Command::new("git")
165        .arg("status")
166        .arg("--untracked-files=no")
167        .arg("--porcelain")
168        .output();
169
170    let Ok(output) = output else {
171        return false;
172    };
173
174    String::from_utf8_lossy(&output.stdout).trim().is_empty()
175}
176
177#[cfg(not(tarpaulin_include))] // Does not ignore 'return false'.
178#[must_use]
179pub fn stash() -> bool {
180    let status = Command::new("git")
181        .arg("stash")
182        .stdin(Stdio::null())
183        .stdout(Stdio::null())
184        .stderr(Stdio::null())
185        .status();
186
187    let Ok(status) = status else {
188        return false;
189    };
190
191    status.success()
192}