ytil_git/
lib.rs

1//! Provide lightweight Git helpers atop [`git2`] plus selective fallbacks to the system `git` binary.
2//!
3//! Wrap common operations (repo discovery, root resolution, status enumeration, branch listing,
4//! targeted fetch, branch switching, restore) in focused functions returning structured data
5//! ([`GitStatusEntry`], [`branch::Branch`]). Some semantics (previous branch with `switch -`, restore) defer to
6//! the porcelain CLI to avoid re‑implementing complex behavior.
7
8use 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
27/// Enumerate combined staged + unstaged status entries.
28///
29/// Builds [`GitStatusEntry`] values capturing index + worktree states plus conflict / ignore
30/// flags. Includes untracked, excludes ignored. Order matches libgit2 iteration order.
31///
32/// # Errors
33/// - Repository discovery fails.
34/// - Reading statuses fails.
35/// - A status entry omits a path (required to construct a [`GitStatusEntry`]).
36///
37/// # Rationale
38/// Centralizes translation from libgit2 status bitflags into a friendlier struct with helper
39/// methods used by higher‑level commands.
40///
41/// # Future Work
42/// - Add option to include ignored entries.
43/// - Parameterize repo path instead of implicit current directory.
44/// - Expose performance metrics (count, timing) for diagnostics.
45pub 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
68/// Restore one or more paths from index or optional branch,
69///
70/// Delegates to porcelain `git restore` rather than approximating behavior with libgit2.
71/// If `branch` is provided its tree is the source; otherwise the index / HEAD is used.
72///
73/// # Errors
74/// - Spawning or executing the `git restore` process fails.
75///
76/// # Rationale
77/// Porcelain subcommand encapsulates nuanced restore semantics (rename detection, pathspec
78/// interpretation) that would be complex and error‑prone to replicate directly.
79///
80/// # Future Work
81/// - Support partial restore when command fails mid‑batch by iterating per path.
82/// - Add dry‑run flag to preview intended operations.
83pub 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
100/// Unstage specific paths without touching working tree contents.
101///
102/// Thin wrapper over `git restore --staged <paths...>` which only affects the index
103/// (inverse of `git add`). Unlike using libgit2 `reset_default`, this avoids
104/// resurrecting deleted files whose blobs no longer exist on disk.
105///
106/// # Errors
107/// - Spawning or executing the `git restore --staged` command fails.
108///
109/// # Rationale
110/// Defers to porcelain for correctness (handles intent, pathspec edge cases) instead of
111/// manually editing the index via libgit2 which exhibited unintended side effects during
112/// experimentation.
113///
114/// # Future Work
115/// - Optionally fall back to libgit2 for environments lacking a `git` binary.
116/// - Capture command stderr and surface as richer context on failure.
117pub fn unstage(paths: &[&str]) -> color_eyre::Result<()> {
118    if paths.is_empty() {
119        return Ok(());
120    }
121    // Use porcelain `git restore --staged` which modifies only the index (opposite of `git add`).
122    // This avoids resurrecting deleted files (observed when using libgit2 `reset_default`).
123    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
131/// Stage pathspecs into the index (like `git add`),
132///
133/// Treats each item in `paths` as a pathspec and passes the collection to
134/// [`git2::Index::add_all`]. Ignores honored; re‑adding existing staged entries is a no‑op.
135///
136/// Supported pathspecs:
137/// - Files: "src/main.rs"
138/// - Directories (recursive): "src/"
139/// - Globs (libgit2 syntax): "*.rs", "docs/**/*.md"
140/// - Mixed file + pattern list
141///
142/// # Errors
143/// - Loading index fails.
144/// - Applying any pathspec fails.
145/// - Writing updated index fails.
146///
147/// # Future Work
148/// - Expose force option to include otherwise ignored files.
149/// - Return count of affected entries for diagnostics.
150pub 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
163/// Retrieves the commit hash of the current HEAD.
164///
165/// # Errors
166/// - If the repository cannot be opened.
167/// - If the HEAD reference cannot be resolved.
168/// - If the HEAD reference does not point to a commit.
169pub 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/// Combined staged + worktree status for a path
178///
179/// Aggregates index + worktree bitflags plus conflict / ignore markers into a higher‑level
180/// representation with convenience predicates (e.g. [`GitStatusEntry::is_new`]).
181#[derive(Clone, Debug)]
182#[cfg_attr(test, derive(Eq, PartialEq))]
183pub struct GitStatusEntry {
184    /// Path relative to the repository root.
185    pub path: PathBuf,
186    /// Absolute repository root path used to compute [`GitStatusEntry::absolute_path`].
187    pub repo_root: PathBuf,
188    /// `true` if the path is in a conflict state.
189    pub conflicted: bool,
190    /// `true` if the path is ignored.
191    pub ignored: bool,
192    /// Staged (index) status, if any.
193    pub index_state: Option<IndexState>,
194    /// Unstaged (worktree) status, if any.
195    pub worktree_state: Option<WorktreeState>,
196}
197
198impl GitStatusEntry {
199    /// Returns the absolute path of the entry relative to the repository root.
200    pub fn absolute_path(&self) -> PathBuf {
201        self.repo_root.join(&self.path)
202    }
203
204    /// Returns `true` if the entry is newly added (in index or worktree).
205    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/// Staged (index) status for a path.
239#[derive(Clone, Debug)]
240#[cfg_attr(test, derive(Eq, PartialEq))]
241pub enum IndexState {
242    /// Path added to the index.
243    New,
244    /// Path modified in the index.
245    Modified,
246    /// Path deleted from the index.
247    Deleted,
248    /// Path renamed in the index.
249    Renamed,
250    /// File type changed in the index (e.g. regular file -> symlink).
251    Typechange,
252}
253
254impl IndexState {
255    /// Creates an [`IndexState`] from a combined status bit‑set.
256    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    /// Returns `true` if this represents a newly added path.
271    pub const fn is_new(&self) -> bool {
272        matches!(self, Self::New)
273    }
274}
275
276/// Unstaged (worktree) status for a path.
277#[derive(Clone, Debug)]
278#[cfg_attr(test, derive(Eq, PartialEq))]
279pub enum WorktreeState {
280    /// Path newly created in worktree.
281    New,
282    /// Path contents modified in worktree.
283    Modified,
284    /// Path deleted in worktree.
285    Deleted,
286    /// Path renamed in worktree.
287    Renamed,
288    /// File type changed in worktree.
289    Typechange,
290    /// Path unreadable (permissions or other I/O issues).
291    Unreadable,
292}
293
294impl WorktreeState {
295    /// Creates a [`WorktreeState`] from a combined status bit‑set.
296    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    /// Returns `true` if this represents a newly added path.
312    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        // Dummy initial commit
382        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}