asyncgit/sync/
status.rs

1//! sync git api for fetching a status
2
3use crate::{
4	error::Result,
5	sync::{
6		config::untracked_files_config_repo,
7		repository::{gix_repo, repo},
8	},
9};
10use git2::{Delta, Status, StatusOptions, StatusShow};
11use scopetime::scope_time;
12use std::path::Path;
13
14use super::{RepoPath, ShowUntrackedFilesConfig};
15
16///
17#[derive(Copy, Clone, Hash, PartialEq, Eq, Debug)]
18pub enum StatusItemType {
19	///
20	New,
21	///
22	Modified,
23	///
24	Deleted,
25	///
26	Renamed,
27	///
28	Typechange,
29	///
30	Conflicted,
31}
32
33impl From<gix::status::index_worktree::iter::Summary>
34	for StatusItemType
35{
36	fn from(
37		summary: gix::status::index_worktree::iter::Summary,
38	) -> Self {
39		use gix::status::index_worktree::iter::Summary;
40
41		match summary {
42			Summary::Removed => Self::Deleted,
43			Summary::Added
44			| Summary::Copied
45			| Summary::IntentToAdd => Self::New,
46			Summary::Modified => Self::Modified,
47			Summary::TypeChange => Self::Typechange,
48			Summary::Renamed => Self::Renamed,
49			Summary::Conflict => Self::Conflicted,
50		}
51	}
52}
53
54impl From<gix::diff::index::ChangeRef<'_, '_>> for StatusItemType {
55	fn from(change_ref: gix::diff::index::ChangeRef) -> Self {
56		use gix::diff::index::ChangeRef;
57
58		match change_ref {
59			ChangeRef::Addition { .. } => Self::New,
60			ChangeRef::Deletion { .. } => Self::Deleted,
61			ChangeRef::Modification { .. }
62			| ChangeRef::Rewrite { .. } => Self::Modified,
63		}
64	}
65}
66
67impl From<Status> for StatusItemType {
68	fn from(s: Status) -> Self {
69		if s.is_index_new() || s.is_wt_new() {
70			Self::New
71		} else if s.is_index_deleted() || s.is_wt_deleted() {
72			Self::Deleted
73		} else if s.is_index_renamed() || s.is_wt_renamed() {
74			Self::Renamed
75		} else if s.is_index_typechange() || s.is_wt_typechange() {
76			Self::Typechange
77		} else if s.is_conflicted() {
78			Self::Conflicted
79		} else {
80			Self::Modified
81		}
82	}
83}
84
85impl From<Delta> for StatusItemType {
86	fn from(d: Delta) -> Self {
87		match d {
88			Delta::Added => Self::New,
89			Delta::Deleted => Self::Deleted,
90			Delta::Renamed => Self::Renamed,
91			Delta::Typechange => Self::Typechange,
92			_ => Self::Modified,
93		}
94	}
95}
96
97///
98#[derive(Clone, Hash, PartialEq, Eq, Debug)]
99pub struct StatusItem {
100	///
101	pub path: String,
102	///
103	pub status: StatusItemType,
104}
105
106///
107#[derive(Copy, Clone, Default, Hash, PartialEq, Eq, Debug)]
108pub enum StatusType {
109	///
110	#[default]
111	WorkingDir,
112	///
113	Stage,
114	///
115	Both,
116}
117
118impl From<StatusType> for StatusShow {
119	fn from(s: StatusType) -> Self {
120		match s {
121			StatusType::WorkingDir => Self::Workdir,
122			StatusType::Stage => Self::Index,
123			StatusType::Both => Self::IndexAndWorkdir,
124		}
125	}
126}
127
128///
129pub fn is_workdir_clean(
130	repo_path: &RepoPath,
131	show_untracked: Option<ShowUntrackedFilesConfig>,
132) -> Result<bool> {
133	let repo = repo(repo_path)?;
134
135	if repo.is_bare() && !repo.is_worktree() {
136		return Ok(true);
137	}
138
139	let show_untracked = if let Some(config) = show_untracked {
140		config
141	} else {
142		untracked_files_config_repo(&repo)?
143	};
144
145	let mut options = StatusOptions::default();
146	options
147		.show(StatusShow::Workdir)
148		.update_index(true)
149		.include_untracked(show_untracked.include_untracked())
150		.renames_head_to_index(true)
151		.recurse_untracked_dirs(
152			show_untracked.recurse_untracked_dirs(),
153		);
154
155	let statuses = repo.statuses(Some(&mut options))?;
156
157	Ok(statuses.is_empty())
158}
159
160impl From<ShowUntrackedFilesConfig> for gix::status::UntrackedFiles {
161	fn from(value: ShowUntrackedFilesConfig) -> Self {
162		match value {
163			ShowUntrackedFilesConfig::All => Self::Files,
164			ShowUntrackedFilesConfig::Normal => Self::Collapsed,
165			ShowUntrackedFilesConfig::No => Self::None,
166		}
167	}
168}
169
170/// guarantees sorting
171pub fn get_status(
172	repo_path: &RepoPath,
173	status_type: StatusType,
174	show_untracked: Option<ShowUntrackedFilesConfig>,
175) -> Result<Vec<StatusItem>> {
176	scope_time!("get_status");
177
178	let repo: gix::Repository = gix_repo(repo_path)?;
179
180	let show_untracked = if let Some(config) = show_untracked {
181		config
182	} else {
183		let git2_repo = crate::sync::repository::repo(repo_path)?;
184
185		// Calling `untracked_files_config_repo` ensures compatibility with `gitui` <= 0.27.
186		// `untracked_files_config_repo` defaults to `All` while both `libgit2` and `gix` default to
187		// `Normal`. According to [show-untracked-files], `normal` is the default value that `git`
188		// chooses.
189		//
190		// [show-untracked-files]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-statusshowUntrackedFiles
191		untracked_files_config_repo(&git2_repo)?
192	};
193
194	let status = repo
195		.status(gix::progress::Discard)?
196		.untracked_files(show_untracked.into());
197
198	let mut res = Vec::new();
199
200	match status_type {
201		StatusType::WorkingDir => {
202			let iter = status.into_index_worktree_iter(Vec::new())?;
203
204			for item in iter {
205				let item = item?;
206
207				let status = item.summary().map(Into::into);
208
209				if let Some(status) = status {
210					let path = item.rela_path().to_string();
211
212					res.push(StatusItem { path, status });
213				}
214			}
215		}
216		StatusType::Stage => {
217			let tree_id: gix::ObjectId =
218				repo.head_tree_id_or_empty()?.into();
219			let worktree_index =
220				gix::worktree::IndexPersistedOrInMemory::Persisted(
221					repo.index_or_empty()?,
222				);
223
224			let mut pathspec = repo.pathspec(
225				false, /* empty patterns match prefix */
226				None::<&str>,
227				true, /* inherit ignore case */
228				&gix::index::State::new(repo.object_hash()),
229				gix::worktree::stack::state::attributes::Source::WorktreeThenIdMapping
230			)?;
231
232			let cb =
233				|change_ref: gix::diff::index::ChangeRef<'_, '_>,
234				 _: &gix::index::State,
235				 _: &gix::index::State|
236				 -> Result<gix::diff::index::Action> {
237					let path = change_ref.fields().0.to_string();
238					let status = change_ref.into();
239
240					res.push(StatusItem { path, status });
241
242					Ok(gix::diff::index::Action::Continue)
243				};
244
245			repo.tree_index_status(
246				&tree_id,
247				&worktree_index,
248				Some(&mut pathspec),
249				gix::status::tree_index::TrackRenames::default(),
250				cb,
251			)?;
252		}
253		StatusType::Both => {
254			let iter = status.into_iter(Vec::new())?;
255
256			for item in iter {
257				let item = item?;
258
259				let path = item.location().to_string();
260
261				let status = match item {
262					gix::status::Item::IndexWorktree(item) => {
263						item.summary().map(Into::into)
264					}
265					gix::status::Item::TreeIndex(change_ref) => {
266						Some(change_ref.into())
267					}
268				};
269
270				if let Some(status) = status {
271					res.push(StatusItem { path, status });
272				}
273			}
274		}
275	}
276
277	res.sort_by(|a, b| {
278		Path::new(a.path.as_str()).cmp(Path::new(b.path.as_str()))
279	});
280
281	Ok(res)
282}
283
284/// discard all changes in the working directory
285pub fn discard_status(repo_path: &RepoPath) -> Result<bool> {
286	let repo = repo(repo_path)?;
287	let commit = repo.head()?.peel_to_commit()?;
288
289	repo.reset(commit.as_object(), git2::ResetType::Hard, None)?;
290
291	Ok(true)
292}
293
294#[cfg(test)]
295mod tests {
296	use super::*;
297	use crate::{
298		sync::{
299			commit, stage_add_file,
300			status::{get_status, StatusType},
301			tests::{repo_init, repo_init_bare},
302			RepoPath,
303		},
304		StatusItem, StatusItemType,
305	};
306	use std::{fs::File, io::Write, path::Path};
307	use tempfile::TempDir;
308
309	#[test]
310	fn test_discard_status() {
311		let file_path = Path::new("README.md");
312		let (_td, repo) = repo_init().unwrap();
313		let root = repo.path().parent().unwrap();
314		let repo_path: &RepoPath =
315			&root.as_os_str().to_str().unwrap().into();
316
317		let mut file = File::create(root.join(file_path)).unwrap();
318
319		// initial commit
320		stage_add_file(repo_path, file_path).unwrap();
321		commit(repo_path, "commit msg").unwrap();
322
323		writeln!(file, "Test for discard_status").unwrap();
324
325		let statuses =
326			get_status(repo_path, StatusType::WorkingDir, None)
327				.unwrap();
328		assert_eq!(statuses.len(), 1);
329
330		discard_status(repo_path).unwrap();
331
332		let statuses =
333			get_status(repo_path, StatusType::WorkingDir, None)
334				.unwrap();
335		assert_eq!(statuses.len(), 0);
336	}
337
338	#[test]
339	fn test_get_status_with_workdir() {
340		let (git_dir, _repo) = repo_init_bare().unwrap();
341
342		let separate_workdir = TempDir::new().unwrap();
343
344		let file_path = Path::new("foo");
345		File::create(separate_workdir.path().join(file_path))
346			.unwrap()
347			.write_all(b"a")
348			.unwrap();
349
350		let repo_path = RepoPath::Workdir {
351			gitdir: git_dir.path().into(),
352			workdir: separate_workdir.path().into(),
353		};
354
355		let status =
356			get_status(&repo_path, StatusType::WorkingDir, None)
357				.unwrap();
358
359		assert_eq!(
360			status,
361			vec![StatusItem {
362				path: "foo".into(),
363				status: StatusItemType::New
364			}]
365		);
366	}
367}