1use 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
22pub 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
51pub 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
72pub fn unstage<I, P>(paths: I) -> rootcause::Result<()>
77where
78 I: IntoIterator<Item = P>,
79 P: AsRef<str>,
80{
81 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
97pub 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
114pub 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#[derive(Clone, Debug)]
126#[cfg_attr(test, derive(Eq, PartialEq))]
127pub struct GitStatusEntry {
128 pub path: PathBuf,
129 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 pub fn absolute_path(&self) -> PathBuf {
140 self.repo_root.join(&self.path)
141 }
142
143 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 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#[derive(Clone, Debug)]
182#[cfg_attr(test, derive(Eq, PartialEq))]
183pub enum IndexState {
184 New,
186 Modified,
188 Deleted,
190 Renamed,
192 Typechange,
194}
195
196impl IndexState {
197 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 pub const fn is_new(&self) -> bool {
214 matches!(self, Self::New)
215 }
216}
217
218#[derive(Clone, Debug)]
220#[cfg_attr(test, derive(Eq, PartialEq))]
221pub enum WorktreeState {
222 New,
224 Modified,
226 Deleted,
228 Renamed,
230 Typechange,
232 Unreadable,
234}
235
236impl WorktreeState {
237 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 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 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}