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