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