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 .iter()
42 {
43 out.push(
44 GitStatusEntry::try_from((Arc::clone(&repo_root), &status_entry))
45 .context("error creating status entry")
46 .attach_with(|| format!("repo_root={}", repo_root.display()))?,
47 );
48 }
49 Ok(out)
50}
51
52pub fn restore<I, P>(paths: I, branch: Option<&str>) -> rootcause::Result<()>
57where
58 I: IntoIterator<Item = P>,
59 P: AsRef<str>,
60{
61 let mut cmd = Command::new("git");
62 cmd.arg("restore");
63 if let Some(branch) = branch {
64 cmd.arg(format!("--source={branch}"));
65 }
66 for p in paths {
67 cmd.arg(p.as_ref());
68 }
69 cmd.exec()?;
70 Ok(())
71}
72
73pub fn unstage<I, P>(paths: I) -> rootcause::Result<()>
78where
79 I: IntoIterator<Item = P>,
80 P: AsRef<str>,
81{
82 let mut cmd = Command::new("git");
85 cmd.args(["restore", "--staged"]);
86 let mut has_paths = false;
87 for p in paths {
88 cmd.arg(p.as_ref());
89 has_paths = true;
90 }
91 if !has_paths {
92 return Ok(());
93 }
94 cmd.exec().context("error restoring staged Git entries")?;
95 Ok(())
96}
97
98pub fn add_to_index<T, I>(repo: &mut Repository, paths: I) -> rootcause::Result<()>
103where
104 T: IntoCString,
105 I: IntoIterator<Item = T>,
106{
107 let mut index = repo.index().context("error loading index")?;
108 index
109 .add_all(paths, git2::IndexAddOption::DEFAULT, None)
110 .context("error adding paths to index")?;
111 index.write().context("error writing index")?;
112 Ok(())
113}
114
115pub fn get_current_commit_hash(repo: &Repository) -> rootcause::Result<String> {
120 let head = repo.head().context("error getting repo head")?;
121 let commit = head.peel_to_commit().context("error peeling head to commit")?;
122 Ok(commit.id().to_string())
123}
124
125#[derive(Clone, Debug)]
127#[cfg_attr(test, derive(Eq, PartialEq))]
128pub struct GitStatusEntry {
129 pub path: PathBuf,
130 pub repo_root: Arc<PathBuf>,
132 pub conflicted: bool,
133 pub ignored: bool,
134 pub index_state: Option<IndexState>,
135 pub worktree_state: Option<WorktreeState>,
136}
137
138impl GitStatusEntry {
139 pub fn absolute_path(&self) -> PathBuf {
141 self.repo_root.join(&self.path)
142 }
143
144 pub fn is_new(&self) -> bool {
146 if self.is_new_in_index() || self.worktree_state.as_ref().is_some_and(WorktreeState::is_new) {
147 return true;
148 }
149 false
150 }
151
152 pub fn is_new_in_index(&self) -> bool {
153 self.index_state.as_ref().is_some_and(IndexState::is_new)
154 }
155
156 pub const fn is_staged(&self) -> bool {
158 self.index_state.is_some()
159 }
160}
161
162impl TryFrom<(Arc<PathBuf>, &StatusEntry<'_>)> for GitStatusEntry {
163 type Error = rootcause::Report;
164
165 fn try_from((repo_root, value): (Arc<PathBuf>, &StatusEntry<'_>)) -> Result<Self, Self::Error> {
166 let status = value.status();
167 let path = value
168 .path()
169 .context("error reading status path")
170 .map(PathBuf::from)
171 .attach_with(|| "context=StatusEntry".to_string())?;
172
173 Ok(Self {
174 path,
175 repo_root,
176 conflicted: status.contains(Status::CONFLICTED),
177 ignored: status.contains(Status::IGNORED),
178 index_state: IndexState::new(&status),
179 worktree_state: WorktreeState::new(&status),
180 })
181 }
182}
183
184#[derive(Clone, Debug)]
186#[cfg_attr(test, derive(Eq, PartialEq))]
187pub enum IndexState {
188 New,
190 Modified,
192 Deleted,
194 Renamed,
196 Typechange,
198}
199
200impl IndexState {
201 pub fn new(status: &Status) -> Option<Self> {
203 [
204 (Status::INDEX_NEW, Self::New),
205 (Status::INDEX_MODIFIED, Self::Modified),
206 (Status::INDEX_DELETED, Self::Deleted),
207 (Status::INDEX_RENAMED, Self::Renamed),
208 (Status::INDEX_TYPECHANGE, Self::Typechange),
209 ]
210 .iter()
211 .find(|(flag, _)| status.contains(*flag))
212 .map(|(_, v)| v)
213 .cloned()
214 }
215
216 pub const fn is_new(&self) -> bool {
218 matches!(self, Self::New)
219 }
220}
221
222#[derive(Clone, Debug)]
224#[cfg_attr(test, derive(Eq, PartialEq))]
225pub enum WorktreeState {
226 New,
228 Modified,
230 Deleted,
232 Renamed,
234 Typechange,
236 Unreadable,
238}
239
240impl WorktreeState {
241 pub fn new(status: &Status) -> Option<Self> {
243 [
244 (Status::WT_NEW, Self::New),
245 (Status::WT_MODIFIED, Self::Modified),
246 (Status::WT_DELETED, Self::Deleted),
247 (Status::WT_RENAMED, Self::Renamed),
248 (Status::WT_TYPECHANGE, Self::Typechange),
249 (Status::WT_UNREADABLE, Self::Unreadable),
250 ]
251 .iter()
252 .find(|(flag, _)| status.contains(*flag))
253 .map(|(_, v)| v)
254 .cloned()
255 }
256
257 pub const fn is_new(&self) -> bool {
259 matches!(self, Self::New)
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use git2::Repository;
266 use git2::Signature;
267 use git2::Time;
268 use rstest::rstest;
269 use tempfile::TempDir;
270
271 use super::*;
272
273 #[rstest]
274 #[case::index_new(Some(IndexState::New), None, true)]
275 #[case::worktree_new(None, Some(WorktreeState::New), true)]
276 #[case::both_new(Some(IndexState::New), Some(WorktreeState::New), true)]
277 #[case::modified_index(Some(IndexState::Modified), None, false)]
278 #[case::modified_worktree(None, Some(WorktreeState::Modified), false)]
279 #[case::none(None, None, false)]
280 fn test_git_status_entry_is_new_when_entry_varies_returns_expected_bool(
281 #[case] index_state: Option<IndexState>,
282 #[case] worktree_state: Option<WorktreeState>,
283 #[case] expected: bool,
284 ) {
285 let entry = entry(index_state, worktree_state);
286 assert_eq!(entry.is_new(), expected);
287 }
288
289 #[rstest]
290 #[case::index_new(Some(IndexState::New), None, true)]
291 #[case::index_modified(Some(IndexState::Modified), None, true)]
292 #[case::index_deleted(Some(IndexState::Deleted), None, true)]
293 #[case::index_renamed(Some(IndexState::Renamed), None, true)]
294 #[case::index_typechange(Some(IndexState::Typechange), None, true)]
295 #[case::worktree_only_modified(None, Some(WorktreeState::Modified), false)]
296 #[case::worktree_only_new(None, Some(WorktreeState::New), false)]
297 #[case::both_staged_and_worktree(Some(IndexState::Modified), Some(WorktreeState::Modified), true)]
298 #[case::none(None, None, false)]
299 fn test_git_status_entry_is_staged_when_entry_varies_returns_expected_bool(
300 #[case] index_state: Option<IndexState>,
301 #[case] worktree_state: Option<WorktreeState>,
302 #[case] expected: bool,
303 ) {
304 let entry = entry(index_state, worktree_state);
305 assert_eq!(entry.is_staged(), expected);
306 }
307
308 #[rstest]
309 #[case(Status::INDEX_NEW, Some(IndexState::New))]
310 #[case(Status::INDEX_MODIFIED, Some(IndexState::Modified))]
311 #[case(Status::INDEX_DELETED, Some(IndexState::Deleted))]
312 #[case(Status::INDEX_RENAMED, Some(IndexState::Renamed))]
313 #[case(Status::INDEX_TYPECHANGE, Some(IndexState::Typechange))]
314 #[case(Status::WT_MODIFIED, None)]
315 fn test_index_state_new_maps_each_flag(#[case] input: Status, #[case] expected: Option<IndexState>) {
316 assert_eq!(IndexState::new(&input), expected);
317 }
318
319 #[rstest]
320 #[case(Status::WT_NEW, Some(WorktreeState::New))]
321 #[case(Status::WT_MODIFIED, Some(WorktreeState::Modified))]
322 #[case(Status::WT_DELETED, Some(WorktreeState::Deleted))]
323 #[case(Status::WT_RENAMED, Some(WorktreeState::Renamed))]
324 #[case(Status::WT_TYPECHANGE, Some(WorktreeState::Typechange))]
325 #[case(Status::WT_UNREADABLE, Some(WorktreeState::Unreadable))]
326 #[case(Status::INDEX_MODIFIED, None)]
327 fn test_worktree_state_new_maps_each_flag(#[case] input: Status, #[case] expected: Option<WorktreeState>) {
328 assert_eq!(WorktreeState::new(&input), expected);
329 }
330
331 fn entry(index_state: Option<IndexState>, worktree_state: Option<WorktreeState>) -> GitStatusEntry {
332 GitStatusEntry {
333 path: "p".into(),
334 repo_root: Arc::new(".".into()),
335 conflicted: false,
336 ignored: false,
337 index_state,
338 worktree_state,
339 }
340 }
341
342 pub fn init_test_repo(time: Option<Time>) -> (TempDir, Repository) {
343 let temp_dir = TempDir::new().unwrap();
344 let repo = Repository::init(temp_dir.path()).unwrap();
345
346 let mut index = repo.index().unwrap();
348 let oid = index.write_tree().unwrap();
349 let tree = repo.find_tree(oid).unwrap();
350 let sig = time.map_or_else(
351 || Signature::now("test", "test@example.com").unwrap(),
352 |time| Signature::new("test", "test@example.com", &time).unwrap(),
353 );
354 repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[]).unwrap();
355
356 drop(tree);
357
358 (temp_dir, repo)
359 }
360}