Skip to main content

ytil_git/
lib.rs

1//! Lightweight Git helpers atop [`git2`] with fallbacks to `git` CLI.
2
3use std::path::Path;
4use std::path::PathBuf;
5use std::process::Command;
6use std::sync::Arc;
7
8use git2::IntoCString;
9pub use git2::Repository;
10use git2::Status;
11use git2::StatusEntry;
12use git2::StatusOptions;
13use rootcause::prelude::ResultExt;
14pub use ytil_cmd::CmdError;
15use ytil_cmd::CmdExt;
16
17pub mod branch;
18pub mod diff;
19pub mod remote;
20pub mod repo;
21
22/// Enumerate combined staged + unstaged status entries.
23///
24/// # Errors
25/// - Repository discovery, status reading, or entry construction fails.
26pub fn get_status() -> rootcause::Result<Vec<GitStatusEntry>> {
27    let repo = crate::repo::discover(Path::new("."))
28        .context("error getting repo")
29        .attach("operation=status")?;
30    let repo_root = Arc::new(crate::repo::get_root(&repo));
31
32    let mut opts = StatusOptions::default();
33    opts.include_untracked(true);
34    opts.include_ignored(false);
35
36    let mut out = vec![];
37    for status_entry in repo
38        .statuses(Some(&mut opts))
39        .context("error getting statuses")
40        .attach_with(|| format!("repo_root={}", repo_root.display()))?
41        .iter()
42    {
43        out.push(
44            GitStatusEntry::try_from((Arc::clone(&repo_root), &status_entry))
45                .context("error creating status entry")
46                .attach_with(|| format!("repo_root={}", repo_root.display()))?,
47        );
48    }
49    Ok(out)
50}
51
52/// Restore one or more paths from index or optional branch.
53///
54/// # Errors
55/// - `git restore` command fails.
56pub fn restore<I, P>(paths: I, branch: Option<&str>) -> rootcause::Result<()>
57where
58    I: IntoIterator<Item = P>,
59    P: AsRef<str>,
60{
61    let mut cmd = Command::new("git");
62    cmd.arg("restore");
63    if let Some(branch) = branch {
64        cmd.arg(format!("--source={branch}"));
65    }
66    for p in paths {
67        cmd.arg(p.as_ref());
68    }
69    cmd.exec()?;
70    Ok(())
71}
72
73/// Unstage specific paths without touching working tree contents.
74///
75/// # Errors
76/// - `git restore --staged` command fails.
77pub fn unstage<I, P>(paths: I) -> rootcause::Result<()>
78where
79    I: IntoIterator<Item = P>,
80    P: AsRef<str>,
81{
82    // Use porcelain `git restore --staged` which modifies only the index (opposite of `git add`).
83    // This avoids resurrecting deleted files (observed when using libgit2 `reset_default`).
84    let mut cmd = Command::new("git");
85    cmd.args(["restore", "--staged"]);
86    let mut has_paths = false;
87    for p in paths {
88        cmd.arg(p.as_ref());
89        has_paths = true;
90    }
91    if !has_paths {
92        return Ok(());
93    }
94    cmd.exec().context("error restoring staged Git entries")?;
95    Ok(())
96}
97
98/// Stage pathspecs into the index (like `git add`).
99///
100/// # Errors
101/// - Loading, updating, or writing index fails.
102pub fn add_to_index<T, I>(repo: &mut Repository, paths: I) -> rootcause::Result<()>
103where
104    T: IntoCString,
105    I: IntoIterator<Item = T>,
106{
107    let mut index = repo.index().context("error loading index")?;
108    index
109        .add_all(paths, git2::IndexAddOption::DEFAULT, None)
110        .context("error adding paths to index")?;
111    index.write().context("error writing index")?;
112    Ok(())
113}
114
115/// Retrieves the commit hash of the current HEAD.
116///
117/// # Errors
118/// - HEAD resolution fails.
119pub fn get_current_commit_hash(repo: &Repository) -> rootcause::Result<String> {
120    let head = repo.head().context("error getting repo head")?;
121    let commit = head.peel_to_commit().context("error peeling head to commit")?;
122    Ok(commit.id().to_string())
123}
124
125/// Combined staged + worktree status for a path.
126#[derive(Clone, Debug)]
127#[cfg_attr(test, derive(Eq, PartialEq))]
128pub struct GitStatusEntry {
129    pub path: PathBuf,
130    /// Shared repository root; uses `Arc` to avoid cloning the `PathBuf` per entry.
131    pub repo_root: Arc<PathBuf>,
132    pub conflicted: bool,
133    pub ignored: bool,
134    pub index_state: Option<IndexState>,
135    pub worktree_state: Option<WorktreeState>,
136}
137
138impl GitStatusEntry {
139    /// Returns the absolute path of the entry relative to the repository root.
140    pub fn absolute_path(&self) -> PathBuf {
141        self.repo_root.join(&self.path)
142    }
143
144    /// Returns `true` if the entry is newly added (in index or worktree).
145    pub fn is_new(&self) -> bool {
146        if self.is_new_in_index() || self.worktree_state.as_ref().is_some_and(WorktreeState::is_new) {
147            return true;
148        }
149        false
150    }
151
152    pub fn is_new_in_index(&self) -> bool {
153        self.index_state.as_ref().is_some_and(IndexState::is_new)
154    }
155
156    /// Returns `true` if the entry has any staged (index) changes.
157    pub const fn is_staged(&self) -> bool {
158        self.index_state.is_some()
159    }
160}
161
162impl TryFrom<(Arc<PathBuf>, &StatusEntry<'_>)> for GitStatusEntry {
163    type Error = rootcause::Report;
164
165    fn try_from((repo_root, value): (Arc<PathBuf>, &StatusEntry<'_>)) -> Result<Self, Self::Error> {
166        let status = value.status();
167        let path = value
168            .path()
169            .context("error reading status path")
170            .map(PathBuf::from)
171            .attach_with(|| "context=StatusEntry".to_string())?;
172
173        Ok(Self {
174            path,
175            repo_root,
176            conflicted: status.contains(Status::CONFLICTED),
177            ignored: status.contains(Status::IGNORED),
178            index_state: IndexState::new(&status),
179            worktree_state: WorktreeState::new(&status),
180        })
181    }
182}
183
184/// Staged (index) status for a path.
185#[derive(Clone, Debug)]
186#[cfg_attr(test, derive(Eq, PartialEq))]
187pub enum IndexState {
188    /// Path added to the index.
189    New,
190    /// Path modified in the index.
191    Modified,
192    /// Path deleted from the index.
193    Deleted,
194    /// Path renamed in the index.
195    Renamed,
196    /// File type changed in the index (e.g. regular file -> symlink).
197    Typechange,
198}
199
200impl IndexState {
201    /// Creates an [`IndexState`] from a combined status bit‑set.
202    pub fn new(status: &Status) -> Option<Self> {
203        [
204            (Status::INDEX_NEW, Self::New),
205            (Status::INDEX_MODIFIED, Self::Modified),
206            (Status::INDEX_DELETED, Self::Deleted),
207            (Status::INDEX_RENAMED, Self::Renamed),
208            (Status::INDEX_TYPECHANGE, Self::Typechange),
209        ]
210        .iter()
211        .find(|(flag, _)| status.contains(*flag))
212        .map(|(_, v)| v)
213        .cloned()
214    }
215
216    /// Returns `true` if this represents a newly added path.
217    pub const fn is_new(&self) -> bool {
218        matches!(self, Self::New)
219    }
220}
221
222/// Unstaged (worktree) status for a path.
223#[derive(Clone, Debug)]
224#[cfg_attr(test, derive(Eq, PartialEq))]
225pub enum WorktreeState {
226    /// Path newly created in worktree.
227    New,
228    /// Path contents modified in worktree.
229    Modified,
230    /// Path deleted in worktree.
231    Deleted,
232    /// Path renamed in worktree.
233    Renamed,
234    /// File type changed in worktree.
235    Typechange,
236    /// Path unreadable (permissions or other I/O issues).
237    Unreadable,
238}
239
240impl WorktreeState {
241    /// Creates a [`WorktreeState`] from a combined status bit‑set.
242    pub fn new(status: &Status) -> Option<Self> {
243        [
244            (Status::WT_NEW, Self::New),
245            (Status::WT_MODIFIED, Self::Modified),
246            (Status::WT_DELETED, Self::Deleted),
247            (Status::WT_RENAMED, Self::Renamed),
248            (Status::WT_TYPECHANGE, Self::Typechange),
249            (Status::WT_UNREADABLE, Self::Unreadable),
250        ]
251        .iter()
252        .find(|(flag, _)| status.contains(*flag))
253        .map(|(_, v)| v)
254        .cloned()
255    }
256
257    /// Returns `true` if this represents a newly added path.
258    pub const fn is_new(&self) -> bool {
259        matches!(self, Self::New)
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use git2::Repository;
266    use git2::Signature;
267    use git2::Time;
268    use rstest::rstest;
269    use tempfile::TempDir;
270
271    use super::*;
272
273    #[rstest]
274    #[case::index_new(Some(IndexState::New), None, true)]
275    #[case::worktree_new(None, Some(WorktreeState::New), true)]
276    #[case::both_new(Some(IndexState::New), Some(WorktreeState::New), true)]
277    #[case::modified_index(Some(IndexState::Modified), None, false)]
278    #[case::modified_worktree(None, Some(WorktreeState::Modified), false)]
279    #[case::none(None, None, false)]
280    fn test_git_status_entry_is_new_when_entry_varies_returns_expected_bool(
281        #[case] index_state: Option<IndexState>,
282        #[case] worktree_state: Option<WorktreeState>,
283        #[case] expected: bool,
284    ) {
285        let entry = entry(index_state, worktree_state);
286        assert_eq!(entry.is_new(), expected);
287    }
288
289    #[rstest]
290    #[case::index_new(Some(IndexState::New), None, true)]
291    #[case::index_modified(Some(IndexState::Modified), None, true)]
292    #[case::index_deleted(Some(IndexState::Deleted), None, true)]
293    #[case::index_renamed(Some(IndexState::Renamed), None, true)]
294    #[case::index_typechange(Some(IndexState::Typechange), None, true)]
295    #[case::worktree_only_modified(None, Some(WorktreeState::Modified), false)]
296    #[case::worktree_only_new(None, Some(WorktreeState::New), false)]
297    #[case::both_staged_and_worktree(Some(IndexState::Modified), Some(WorktreeState::Modified), true)]
298    #[case::none(None, None, false)]
299    fn test_git_status_entry_is_staged_when_entry_varies_returns_expected_bool(
300        #[case] index_state: Option<IndexState>,
301        #[case] worktree_state: Option<WorktreeState>,
302        #[case] expected: bool,
303    ) {
304        let entry = entry(index_state, worktree_state);
305        assert_eq!(entry.is_staged(), expected);
306    }
307
308    #[rstest]
309    #[case(Status::INDEX_NEW, Some(IndexState::New))]
310    #[case(Status::INDEX_MODIFIED, Some(IndexState::Modified))]
311    #[case(Status::INDEX_DELETED, Some(IndexState::Deleted))]
312    #[case(Status::INDEX_RENAMED, Some(IndexState::Renamed))]
313    #[case(Status::INDEX_TYPECHANGE, Some(IndexState::Typechange))]
314    #[case(Status::WT_MODIFIED, None)]
315    fn test_index_state_new_maps_each_flag(#[case] input: Status, #[case] expected: Option<IndexState>) {
316        assert_eq!(IndexState::new(&input), expected);
317    }
318
319    #[rstest]
320    #[case(Status::WT_NEW, Some(WorktreeState::New))]
321    #[case(Status::WT_MODIFIED, Some(WorktreeState::Modified))]
322    #[case(Status::WT_DELETED, Some(WorktreeState::Deleted))]
323    #[case(Status::WT_RENAMED, Some(WorktreeState::Renamed))]
324    #[case(Status::WT_TYPECHANGE, Some(WorktreeState::Typechange))]
325    #[case(Status::WT_UNREADABLE, Some(WorktreeState::Unreadable))]
326    #[case(Status::INDEX_MODIFIED, None)]
327    fn test_worktree_state_new_maps_each_flag(#[case] input: Status, #[case] expected: Option<WorktreeState>) {
328        assert_eq!(WorktreeState::new(&input), expected);
329    }
330
331    fn entry(index_state: Option<IndexState>, worktree_state: Option<WorktreeState>) -> GitStatusEntry {
332        GitStatusEntry {
333            path: "p".into(),
334            repo_root: Arc::new(".".into()),
335            conflicted: false,
336            ignored: false,
337            index_state,
338            worktree_state,
339        }
340    }
341
342    pub fn init_test_repo(time: Option<Time>) -> (TempDir, Repository) {
343        let temp_dir = TempDir::new().unwrap();
344        let repo = Repository::init(temp_dir.path()).unwrap();
345
346        // Dummy initial commit
347        let mut index = repo.index().unwrap();
348        let oid = index.write_tree().unwrap();
349        let tree = repo.find_tree(oid).unwrap();
350        let sig = time.map_or_else(
351            || Signature::now("test", "test@example.com").unwrap(),
352            |time| Signature::new("test", "test@example.com", &time).unwrap(),
353        );
354        repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[]).unwrap();
355
356        drop(tree);
357
358        (temp_dir, repo)
359    }
360}