1use std::path::Path;
9use std::path::PathBuf;
10use std::process::Command;
11
12use color_eyre::eyre::WrapErr;
13use color_eyre::eyre::eyre;
14use git2::IntoCString;
15pub use git2::Repository;
16use git2::Status;
17use git2::StatusEntry;
18use git2::StatusOptions;
19pub use ytil_cmd::CmdError;
20use ytil_cmd::CmdExt as _;
21
22pub mod branch;
23pub mod diff;
24pub mod remote;
25pub mod repo;
26
27pub fn get_status() -> color_eyre::Result<Vec<GitStatusEntry>> {
46 let repo =
47 crate::repo::discover(Path::new(".")).wrap_err_with(|| eyre!("error getting repo | operation=status"))?;
48 let repo_root = crate::repo::get_root(&repo);
49
50 let mut opts = StatusOptions::default();
51 opts.include_untracked(true);
52 opts.include_ignored(false);
53
54 let mut out = vec![];
55 for status_entry in repo
56 .statuses(Some(&mut opts))
57 .wrap_err_with(|| eyre!("error getting statuses | repo_root={}", repo_root.display()))?
58 .iter()
59 {
60 out.push(
61 GitStatusEntry::try_from((repo_root.clone(), &status_entry))
62 .wrap_err_with(|| eyre!("error creating status entry | repo_root={}", repo_root.display()))?,
63 );
64 }
65 Ok(out)
66}
67
68pub fn restore<I, P>(paths: I, branch: Option<&str>) -> color_eyre::Result<()>
84where
85 I: IntoIterator<Item = P>,
86 P: AsRef<str>,
87{
88 let mut cmd = Command::new("git");
89 cmd.arg("restore");
90 if let Some(branch) = branch {
91 cmd.arg(branch);
92 }
93 for p in paths {
94 cmd.arg(p.as_ref());
95 }
96 cmd.exec()?;
97 Ok(())
98}
99
100pub fn unstage(paths: &[&str]) -> color_eyre::Result<()> {
118 if paths.is_empty() {
119 return Ok(());
120 }
121 Command::new("git")
124 .args(["restore", "--staged"])
125 .args(paths)
126 .exec()
127 .wrap_err_with(|| eyre!("error restoring statged Git entries | paths={paths:?}"))?;
128 Ok(())
129}
130
131pub fn add_to_index<T, I>(repo: &mut Repository, paths: I) -> color_eyre::Result<()>
151where
152 T: IntoCString,
153 I: IntoIterator<Item = T>,
154{
155 let mut index = repo.index().wrap_err_with(|| eyre!("error loading index"))?;
156 index
157 .add_all(paths, git2::IndexAddOption::DEFAULT, None)
158 .wrap_err_with(|| eyre!("error adding paths to index"))?;
159 index.write().wrap_err_with(|| eyre!("error writing index"))?;
160 Ok(())
161}
162
163pub fn get_current_commit_hash(repo: &Repository) -> color_eyre::Result<String> {
170 let head = repo.head().wrap_err_with(|| eyre!("error getting repo head"))?;
171 let commit = head
172 .peel_to_commit()
173 .wrap_err_with(|| eyre!("error peeling head to commit"))?;
174 Ok(commit.id().to_string())
175}
176
177#[derive(Clone, Debug)]
182#[cfg_attr(test, derive(Eq, PartialEq))]
183pub struct GitStatusEntry {
184 pub path: PathBuf,
186 pub repo_root: PathBuf,
188 pub conflicted: bool,
190 pub ignored: bool,
192 pub index_state: Option<IndexState>,
194 pub worktree_state: Option<WorktreeState>,
196}
197
198impl GitStatusEntry {
199 pub fn absolute_path(&self) -> PathBuf {
201 self.repo_root.join(&self.path)
202 }
203
204 pub fn is_new(&self) -> bool {
206 if self.is_new_in_index() || self.worktree_state.as_ref().is_some_and(WorktreeState::is_new) {
207 return true;
208 }
209 false
210 }
211
212 pub fn is_new_in_index(&self) -> bool {
213 self.index_state.as_ref().is_some_and(IndexState::is_new)
214 }
215}
216
217impl TryFrom<(PathBuf, &StatusEntry<'_>)> for GitStatusEntry {
218 type Error = color_eyre::eyre::Error;
219
220 fn try_from((repo_root, value): (PathBuf, &StatusEntry<'_>)) -> Result<Self, Self::Error> {
221 let status = value.status();
222 let path = value
223 .path()
224 .map(PathBuf::from)
225 .ok_or_else(|| eyre!("error missing status path | context=StatusEntry"))?;
226
227 Ok(Self {
228 path,
229 repo_root,
230 conflicted: status.contains(Status::CONFLICTED),
231 ignored: status.contains(Status::IGNORED),
232 index_state: IndexState::new(&status),
233 worktree_state: WorktreeState::new(&status),
234 })
235 }
236}
237
238#[derive(Clone, Debug)]
240#[cfg_attr(test, derive(Eq, PartialEq))]
241pub enum IndexState {
242 New,
244 Modified,
246 Deleted,
248 Renamed,
250 Typechange,
252}
253
254impl IndexState {
255 pub fn new(status: &Status) -> Option<Self> {
257 [
258 (Status::INDEX_NEW, Self::New),
259 (Status::INDEX_MODIFIED, Self::Modified),
260 (Status::INDEX_DELETED, Self::Deleted),
261 (Status::INDEX_RENAMED, Self::Renamed),
262 (Status::INDEX_TYPECHANGE, Self::Typechange),
263 ]
264 .iter()
265 .find(|(flag, _)| status.contains(*flag))
266 .map(|(_, v)| v)
267 .cloned()
268 }
269
270 pub const fn is_new(&self) -> bool {
272 matches!(self, Self::New)
273 }
274}
275
276#[derive(Clone, Debug)]
278#[cfg_attr(test, derive(Eq, PartialEq))]
279pub enum WorktreeState {
280 New,
282 Modified,
284 Deleted,
286 Renamed,
288 Typechange,
290 Unreadable,
292}
293
294impl WorktreeState {
295 pub fn new(status: &Status) -> Option<Self> {
297 [
298 (Status::WT_NEW, Self::New),
299 (Status::WT_MODIFIED, Self::Modified),
300 (Status::WT_DELETED, Self::Deleted),
301 (Status::WT_RENAMED, Self::Renamed),
302 (Status::WT_TYPECHANGE, Self::Typechange),
303 (Status::WT_UNREADABLE, Self::Unreadable),
304 ]
305 .iter()
306 .find(|(flag, _)| status.contains(*flag))
307 .map(|(_, v)| v)
308 .cloned()
309 }
310
311 pub const fn is_new(&self) -> bool {
313 matches!(self, Self::New)
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use git2::Repository;
320 use git2::Signature;
321 use git2::Time;
322 use rstest::rstest;
323 use tempfile::TempDir;
324
325 use super::*;
326
327 #[rstest]
328 #[case::index_new(Some(IndexState::New), None, true)]
329 #[case::worktree_new(None, Some(WorktreeState::New), true)]
330 #[case::both_new(Some(IndexState::New), Some(WorktreeState::New), true)]
331 #[case::modified_index(Some(IndexState::Modified), None, false)]
332 #[case::modified_worktree(None, Some(WorktreeState::Modified), false)]
333 #[case::none(None, None, false)]
334 fn git_status_entry_is_new_cases(
335 #[case] index_state: Option<IndexState>,
336 #[case] worktree_state: Option<WorktreeState>,
337 #[case] expected: bool,
338 ) {
339 let entry = entry(index_state, worktree_state);
340 assert_eq!(entry.is_new(), expected);
341 }
342
343 #[rstest]
344 #[case(Status::INDEX_NEW, Some(IndexState::New))]
345 #[case(Status::INDEX_MODIFIED, Some(IndexState::Modified))]
346 #[case(Status::INDEX_DELETED, Some(IndexState::Deleted))]
347 #[case(Status::INDEX_RENAMED, Some(IndexState::Renamed))]
348 #[case(Status::INDEX_TYPECHANGE, Some(IndexState::Typechange))]
349 #[case(Status::WT_MODIFIED, None)]
350 fn index_state_new_maps_each_flag(#[case] input: Status, #[case] expected: Option<IndexState>) {
351 assert_eq!(IndexState::new(&input), expected);
352 }
353
354 #[rstest]
355 #[case(Status::WT_NEW, Some(WorktreeState::New))]
356 #[case(Status::WT_MODIFIED, Some(WorktreeState::Modified))]
357 #[case(Status::WT_DELETED, Some(WorktreeState::Deleted))]
358 #[case(Status::WT_RENAMED, Some(WorktreeState::Renamed))]
359 #[case(Status::WT_TYPECHANGE, Some(WorktreeState::Typechange))]
360 #[case(Status::WT_UNREADABLE, Some(WorktreeState::Unreadable))]
361 #[case(Status::INDEX_MODIFIED, None)]
362 fn worktree_state_new_maps_each_flag(#[case] input: Status, #[case] expected: Option<WorktreeState>) {
363 assert_eq!(WorktreeState::new(&input), expected);
364 }
365
366 fn entry(index_state: Option<IndexState>, worktree_state: Option<WorktreeState>) -> GitStatusEntry {
367 GitStatusEntry {
368 path: "p".into(),
369 repo_root: ".".into(),
370 conflicted: false,
371 ignored: false,
372 index_state,
373 worktree_state,
374 }
375 }
376
377 pub fn init_test_repo(time: Option<Time>) -> (TempDir, Repository) {
378 let temp_dir = TempDir::new().unwrap();
379 let repo = Repository::init(temp_dir.path()).unwrap();
380
381 let mut index = repo.index().unwrap();
383 let oid = index.write_tree().unwrap();
384 let tree = repo.find_tree(oid).unwrap();
385 let sig = time.map_or_else(
386 || Signature::now("test", "test@example.com").unwrap(),
387 |time| Signature::new("test", "test@example.com", &time).unwrap(),
388 );
389 repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[]).unwrap();
390
391 drop(tree);
392
393 (temp_dir, repo)
394 }
395}