History

feat: Add `Submodule::status()` method.

That way it's possible to obtain submodule status information, with enough information to implement `git status`-like commands.

Sebastian Thiel committed 2 years ago

Commit a29fa001 parent 434f5436 files · +363 −4Browse files

Unified · Split

6 files
  1. gix-submodule/src/config.rs +0 −2
  2. gix/src/status/platform.rs +3 −2
  3. gix/src/submodule/mod.rs +172 −0
  4. gix/tests/fixtures/generated-archives/make_submodules.tar.xz +0 −0
  5. gix/tests/fixtures/make_submodules.sh +30 −0
  6. gix/tests/submodule/mod.rs +159 −1
gix-submodule/src/config.rs+0−2
@@ -4,8 +4,6 @@ use bstr::{BStr, BString, ByteSlice}; · 3 unmodified lines
44 #[derive(Default, Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq, Hash)]
55 pub enum Ignore {
66 /// Submodule changes won't be considered at all, which is the fastest option.
7− ///
8− /// Note that changes to the submodule hash in the superproject will still be observable.
97 All,
108 /// Ignore any changes to the submodule working tree, only show committed differences between the `HEAD` of the submodule
119 /// compared to the recorded commit in the superproject.
gix/src/status/platform.rs+3−2
@@ -5,8 +5,9 @@ impl<'repo, Progress> Platform<'repo, Progress> · 4 unmodified lines
55 where
66 Progress: gix_features::progress::Progress,
77 {
8− /// Call `cb` on dirwalk options if these are set (which is the default). The directory walk is used to find
9− /// untracked files or ignored files.
8+ /// Call `cb` on dirwalk options if these are set (which is the default when created through [`Repository::status()`](crate::Repository::status())).
9+ /// The directory walk is used to find untracked files or ignored files.
10+ ///
1011 /// `cb` will be able to run builder-methods on the passed dirwalk options.
1112 pub fn dirwalk_options(mut self, cb: impl FnOnce(crate::dirwalk::Options) -> crate::dirwalk::Options) -> Self {
1213 if let Some(opts) = self.index_worktree_options.dirwalk_options.take() {
gix/src/submodule/mod.rs+172−0
@@ -275,6 +275,178 @@ impl<'repo> Submodule<'repo> { · 274 unmodified lines
275275 }
276276 }
277277
278+///
279+#[cfg(all(feature = "status", feature = "parallel"))]
280+pub mod status {
281+ use super::{head_id, index_id, open, Status};
282+ use crate::Submodule;
283+ use gix_submodule::config;
284+
285+ /// The error returned by [Submodule::status()].
286+ #[derive(Debug, thiserror::Error)]
287+ #[allow(missing_docs)]
288+ pub enum Error {
289+ #[error(transparent)]
290+ State(#[from] config::path::Error),
291+ #[error(transparent)]
292+ HeadId(#[from] head_id::Error),
293+ #[error(transparent)]
294+ IndexId(#[from] index_id::Error),
295+ #[error(transparent)]
296+ OpenRepository(#[from] open::Error),
297+ #[error(transparent)]
298+ IgnoreConfiguration(#[from] config::Error),
299+ #[error(transparent)]
300+ StatusPlatform(#[from] crate::config::boolean::Error),
301+ #[error(transparent)]
302+ Status(#[from] crate::status::index_worktree::iter::Error),
303+ }
304+
305+ impl<'repo> Submodule<'repo> {
306+ /// Return the status of the submodule.
307+ ///
308+ /// Use `ignore` to control the portion of the submodule status to ignore. It can be obtained from
309+ /// submodule configuration using the [`ignore()`](Submodule::ignore()) method.
310+ /// If `check_dirty` is `true`, the computation will stop once the first in a ladder operations
311+ /// ordered from cheap to expensive shows that the submodule is dirty.
312+ /// Thus, submodules that are clean will still impose the complete set of computation, as given.
313+ #[doc(alias = "submodule_status", alias = "git2")]
314+ pub fn status(
315+ &self,
316+ ignore: config::Ignore,
317+ check_dirty: bool,
318+ ) -> Result<crate::submodule::status::types::Status, Error> {
319+ self.status_opts(ignore, check_dirty, &mut |s| s)
320+ }
321+ /// Return the status of the submodule, just like [`status`](Self::status), but allows to adjust options
322+ /// for more control over how the status is performed.
323+ ///
324+ /// Use `&mut std::convert::identity` for `adjust_options` if no specific options are desired.
325+ /// A reason to change them might be to enable sorting to enjoy deterministic order of changes.
326+ ///
327+ /// The status allows to easily determine if a submodule [has changes](Status::is_dirty).
328+ ///
329+ /// ### Incomplete Implementation Warning
330+ ///
331+ /// Currently, changes between the head and the index aren't computed.
332+ // TODO: Run the full status, including tree->index once available.
333+ #[doc(alias = "submodule_status", alias = "git2")]
334+ pub fn status_opts(
335+ &self,
336+ ignore: config::Ignore,
337+ check_dirty: bool,
338+ adjust_options: &mut dyn for<'a> FnMut(
339+ crate::status::Platform<'a, gix_features::progress::Discard>,
340+ )
341+ -> crate::status::Platform<'a, gix_features::progress::Discard>,
342+ ) -> Result<Status, Error> {
343+ let mut state = self.state()?;
344+ if ignore == config::Ignore::All {
345+ return Ok(Status {
346+ state,
347+ ..Default::default()
348+ });
349+ }
350+
351+ let index_id = self.index_id()?;
352+ if !state.repository_exists {
353+ return Ok(Status {
354+ state,
355+ index_id,
356+ ..Default::default()
357+ });
358+ }
359+ let sm_repo = match self.open()? {
360+ None => {
361+ state.repository_exists = false;
362+ return Ok(Status {
363+ state,
364+ index_id,
365+ ..Default::default()
366+ });
367+ }
368+ Some(repo) => repo,
369+ };
370+
371+ let checked_out_head_id = sm_repo.head_id().ok().map(crate::Id::detach);
372+ let mut status = Status {
373+ state,
374+ index_id,
375+ checked_out_head_id,
376+ ..Default::default()
377+ };
378+ if ignore == config::Ignore::Dirty || check_dirty && status.is_dirty() == Some(true) {
379+ return Ok(status);
380+ }
381+
382+ status.changes = Some(
383+ adjust_options(sm_repo.status(gix_features::progress::Discard)?)
384+ .index_worktree_options_mut(|opts| {
385+ assert!(opts.dirwalk_options.is_some(), "BUG: it's supposed to be the default");
386+ if ignore == config::Ignore::Untracked {
387+ opts.dirwalk_options = None;
388+ }
389+ })
390+ .into_index_worktree_iter(Vec::new())?
391+ .filter_map(Result::ok)
392+ .collect(),
393+ );
394+
395+ Ok(status)
396+ }
397+ }
398+
399+ impl Status {
400+ /// Return `Some(true)` if the submodule status could be determined sufficiently and
401+ /// if there are changes that would render this submodule dirty.
402+ ///
403+ /// Return `Some(false)` if the submodule status could be determined and it has no changes
404+ /// at all.
405+ ///
406+ /// Return `None` if the repository clone or the worktree are missing entirely, which would leave
407+ /// it to the caller to determine if that's considered dirty or not.
408+ pub fn is_dirty(&self) -> Option<bool> {
409+ if !self.state.worktree_checkout && !self.state.repository_exists {
410+ return None;
411+ }
412+ let is_dirty =
413+ self.checked_out_head_id != self.index_id || self.changes.as_ref().map_or(false, |c| !c.is_empty());
414+ Some(is_dirty)
415+ }
416+ }
417+
418+ pub(super) mod types {
419+ use crate::submodule::State;
420+
421+ /// A simplified status of the Submodule.
422+ ///
423+ /// As opposed to the similar-sounding [`State`], it is more exhaustive and potentially expensive to compute,
424+ /// particularly for submodules without changes.
425+ ///
426+ /// It's produced by [Submodule::status()](crate::Submodule::status()).
427+ #[derive(Default, Clone, PartialEq, Debug)]
428+ pub struct Status {
429+ /// The cheapest part of the status that is always performed, to learn if the repository is cloned
430+ /// and if there is a worktree checkout.
431+ pub state: State,
432+ /// The commit at which the submodule is supposed to be according to the super-project's index.
433+ /// `None` means the computation wasn't performed, or the submodule didn't exist in the super-project's index anymore.
434+ pub index_id: Option<gix_hash::ObjectId>,
435+ /// The commit-id of the `HEAD` at which the submodule is currently checked out.
436+ /// `None` if the computation wasn't performed as it was skipped early, or if no repository was available or
437+ /// if the HEAD could not be obtained or wasn't born.
438+ pub checked_out_head_id: Option<gix_hash::ObjectId>,
439+ /// The set of changes obtained from running something akin to `git status` in the submodule working tree.
440+ ///
441+ /// `None` if the computation wasn't performed as the computation was skipped early, or if no working tree was
442+ /// available or repository was available.
443+ pub changes: Option<Vec<crate::status::index_worktree::iter::Item>>,
444+ }
445+ }
446+}
447+#[cfg(all(feature = "status", feature = "parallel"))]
448+pub use status::types::Status;
449+
278450 /// A summary of the state of all parts forming a submodule, which allows to answer various questions about it.
279451 ///
280452 /// Note that expensive questions about its presence in the `HEAD` or the `index` are left to the caller.
gix/tests/fixtures/generated-archives/make_submodules.tar.xz+0−0

Binary file not shown.

gix/tests/fixtures/make_submodules.sh+30−0
@@ -12,6 +12,36 @@ git init -q module1 · 11 unmodified lines
1212 git commit -q -am c2
1313 )
1414
15+git init submodule-head-changed
16+(cd submodule-head-changed
17+ git submodule add ../module1 m1
18+ git commit -m "add submodule"
19+
20+ cd m1 && git checkout @~1
21+)
22+
23+git init modified-and-untracked
24+(cd modified-and-untracked
25+ git submodule add ../module1 m1
26+ git commit -m "add submodule"
27+
28+ (cd m1
29+ echo change >> this
30+ touch new
31+ )
32+)
33+
34+git init submodule-head-changed-and-modified
35+(cd submodule-head-changed-and-modified
36+ git submodule add ../module1 m1
37+ git commit -m "add submodule"
38+
39+ (cd m1
40+ git checkout @~1
41+ echo change >> this
42+ )
43+)
44+
1545 git init with-submodules
1646 (cd with-submodules
1747 mkdir dir
gix/tests/submodule/mod.rs+159−1
@@ -26,6 +26,7 @@ mod open { · 25 unmodified lines
2626 worktree_checkout: true,
2727 superproject_configuration: true,
2828 },
29+ Some(false),
2930 ),
3031 (
3132 "dir/m1",
@@ -35,6 +36,7 @@ mod open { · 3 unmodified lines
3536 worktree_checkout: true,
3637 superproject_configuration: true,
3738 },
39+ Some(false),
3840 ),
3941 ] as &[_],
4042 ),
@@ -48,6 +50,7 @@ mod open { · 7 unmodified lines
4850 worktree_checkout: false,
4951 superproject_configuration: true,
5052 },
53+ None,
5154 )],
5255 ),
5356 (
@@ -60,11 +63,13 @@ mod open { · 6 unmodified lines
6063 worktree_checkout: false,
6164 superproject_configuration: true,
6265 },
66+ Some(true),
6367 )],
6468 ),
6569 ] {
6670 let repo = repo(name)?;
67− for (sm, (name, expected)) in repo.submodules()?.expect("modules present").zip(expected) {
71+ for (sm, (name, expected, _expected_is_dirty)) in repo.submodules()?.expect("modules present").zip(expected)
72+ {
6873 assert_eq!(sm.name(), name);
6974 let state = sm.state()?;
7075 assert_eq!(&state, expected);
@@ -87,6 +92,19 @@ mod open { · 16 unmodified lines
8792 "there is a way to check for indicators that a submodule worktree isn't checked out though"
8893 )
8994 }
95+ #[cfg(all(feature = "status", feature = "parallel"))]
96+ for check_dirty in [false, true] {
97+ let status = sm.status(gix::submodule::config::Ignore::None, check_dirty)?;
98+ assert_eq!(
99+ &status.state, expected,
100+ "no matter what status configuration, the state is always obtained"
101+ );
102+ assert_eq!(
103+ status.is_dirty(),
104+ *_expected_is_dirty,
105+ "none of these submodules are dirty, but some aren't checked out"
106+ )
107+ }
90108 }
91109 assert_eq!(
92110 repo.modules()?.expect("present").names().count(),
@@ -97,6 +115,146 @@ mod open { · 4 unmodified lines
97115 Ok(())
98116 }
99117
118+ #[cfg(all(feature = "status", feature = "parallel"))]
119+ mod status {
120+ use crate::submodule::repo;
121+ use crate::util::hex_to_id;
122+
123+ #[test]
124+ fn changed_head_compared_to_superproject_index() -> crate::Result {
125+ let repo = repo("submodule-head-changed")?;
126+ let sm = repo.submodules()?.into_iter().flatten().next().expect("one submodule");
127+ let mut status = sm.status(gix::submodule::config::Ignore::None, false)?;
128+ assert_eq!(
129+ status.is_dirty(),
130+ Some(true),
131+ "we could decide that the submodule is dirty"
132+ );
133+ assert_eq!(
134+ status.index_id,
135+ Some(hex_to_id("e046f3e51d955840619fc7d01fbd9a469663de22"))
136+ );
137+ assert_eq!(
138+ status.checked_out_head_id,
139+ Some(hex_to_id("362cb5539acbd3c8ca355471f97c6a68d3db0da7")),
140+ "the checked out head was reset to something else after the superproject commit"
141+ );
142+ assert_eq!(
143+ status.changes,
144+ Some(Vec::new()),
145+ "the status check ran, but there were no changes"
146+ );
147+ // make it easier to compare this as baseline
148+ status.changes.take();
149+
150+ let status_with_ignore = sm.status(gix::submodule::config::Ignore::Dirty, false)?;
151+ assert_eq!(
152+ status_with_ignore, status,
153+ "The lowest status that makes these changes observable"
154+ );
155+
156+ let status_with_ignore_check_only = sm.status(gix::submodule::config::Ignore::Dirty, true)?;
157+ assert_eq!(
158+ status_with_ignore_check_only, status,
159+ "dirty-check has no observable influence here yet as there no 'more expensive' changes"
160+ );
161+
162+ let status_with_ignore = sm.status(gix::submodule::config::Ignore::All, false)?;
163+ assert_eq!(
164+ status_with_ignore.is_dirty(),
165+ Some(false),
166+ "no dirty-information is retrieved, it seems clean"
167+ );
168+ assert_eq!(
169+ status_with_ignore.index_id, None,
170+ "to avoid false-positives, we don't retrieve the value"
171+ );
172+ assert_eq!(
173+ status_with_ignore.checked_out_head_id, None,
174+ "this check is ignored as it requires opening a repository"
175+ );
176+ Ok(())
177+ }
178+
179+ #[test]
180+ fn modified_and_untracked() -> crate::Result {
181+ let repo = repo("modified-and-untracked")?;
182+ let sm = repo.submodules()?.into_iter().flatten().next().expect("one submodule");
183+
184+ let status = sm.status(gix::submodule::config::Ignore::Dirty, false)?;
185+ assert_eq!(status.is_dirty(), Some(false), "Dirty skips worktree changes entirely");
186+
187+ let status = sm.status_opts(
188+ gix::submodule::config::Ignore::None,
189+ false,
190+ &mut |status: gix::status::Platform<'_, gix::progress::Discard>| {
191+ status.index_worktree_options_mut(|opts| {
192+ opts.sorting = Some(gix_status::index_as_worktree_with_renames::Sorting::ByPathCaseSensitive);
193+ })
194+ },
195+ )?;
196+ assert_eq!(
197+ status.is_dirty(),
198+ Some(true),
199+ "we could decide that the submodule is dirty"
200+ );
201+ assert_eq!(status.index_id, status.checked_out_head_id, "the head didn't change");
202+ assert_eq!(
203+ status.changes.as_ref().into_iter().flatten().count(),
204+ 2,
205+ "1 modified, 1 untracked"
206+ );
207+
208+ let status_with_dirty_check = sm.status_opts(
209+ gix::submodule::config::Ignore::None,
210+ true,
211+ &mut |status: gix::status::Platform<'_, gix::progress::Discard>| {
212+ status.index_worktree_options_mut(|opts| {
213+ opts.sorting = Some(gix_status::index_as_worktree_with_renames::Sorting::ByPathCaseSensitive);
214+ })
215+ },
216+ )?;
217+ assert_eq!(
218+ status_with_dirty_check, status,
219+ "it cannot abort early as the only change it sees is the modification check"
220+ );
221+
222+ let status = sm.status(gix::submodule::config::Ignore::Untracked, false)?;
223+ assert_eq!(
224+ status.is_dirty(),
225+ Some(true),
226+ "we could decide that the submodule is dirty, even though untracked files are missing"
227+ );
228+ assert_eq!(status.index_id, status.checked_out_head_id, "the head didn't change");
229+ assert_eq!(status.changes.as_ref().into_iter().flatten().count(), 1, "1 modified");
230+
231+ Ok(())
232+ }
233+
234+ #[test]
235+ fn is_dirty_skips_expensive_checks() -> crate::Result {
236+ let repo = repo("submodule-head-changed-and-modified")?;
237+ let sm = repo.submodules()?.into_iter().flatten().next().expect("one submodule");
238+
239+ let status = sm.status(gix::submodule::config::Ignore::None, true)?;
240+ assert_eq!(
241+ status.changes, None,
242+ "computation was stopped on the first detected change (the index/head)"
243+ );
244+ assert_eq!(
245+ status.index_id,
246+ Some(hex_to_id("e046f3e51d955840619fc7d01fbd9a469663de22")),
247+ "the index id was obtained"
248+ );
249+ assert_eq!(
250+ status.checked_out_head_id,
251+ Some(hex_to_id("362cb5539acbd3c8ca355471f97c6a68d3db0da7")),
252+ "the checked out head was also obtained to be able to se if it's dirty"
253+ );
254+ Ok(())
255+ }
256+ }
257+
100258 #[test]
101259 fn not_a_submodule() -> crate::Result {
102260 let repo = repo("not-a-submodule")?;