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;
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
23pub 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
53pub 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
74pub fn unstage<I, P>(paths: I) -> rootcause::Result<()>
79where
80 I: IntoIterator<Item = P>,
81 P: AsRef<str>,
82{
83 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
99pub 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
116pub 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#[derive(Clone, Debug)]
128#[cfg_attr(test, derive(Eq, PartialEq))]
129pub struct GitStatusEntry {
130 pub path: PathBuf,
131 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 pub fn absolute_path(&self) -> PathBuf {
142 self.repo_root.join(&self.path)
143 }
144
145 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 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#[derive(Clone, Debug)]
187#[cfg_attr(test, derive(Eq, PartialEq))]
188pub enum IndexState {
189 New,
191 Modified,
193 Deleted,
195 Renamed,
197 Typechange,
199}
200
201impl IndexState {
202 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 pub const fn is_new(&self) -> bool {
219 matches!(self, Self::New)
220 }
221}
222
223#[derive(Clone, Debug)]
225#[cfg_attr(test, derive(Eq, PartialEq))]
226pub enum WorktreeState {
227 New,
229 Modified,
231 Deleted,
233 Renamed,
235 Typechange,
237 Unreadable,
239}
240
241impl WorktreeState {
242 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 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 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}