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
4#[derive(Default, Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq, Hash)]4#[derive(Default, Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq, Hash)]
5pub enum Ignore {5pub enum Ignore {
6 /// Submodule changes won't be considered at all, which is the fastest option.6 /// 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.
9 All,7 All,
10 /// Ignore any changes to the submodule working tree, only show committed differences between the `HEAD` of the submodule8 /// Ignore any changes to the submodule working tree, only show committed differences between the `HEAD` of the submodule
11 /// compared to the recorded commit in the superproject.9 /// 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
5where5where
6 Progress: gix_features::progress::Progress,6 Progress: gix_features::progress::Progress,
7{7{
8 /// Call `cb` on dirwalk options if these are set (which is the default). The directory walk is used to find8 /// Call `cb` on dirwalk options if these are set (which is the default when created through [`Repository::status()`](crate::Repository::status())).
9 /// untracked files or ignored files.9 /// The directory walk is used to find untracked files or ignored files.
10 ///
10 /// `cb` will be able to run builder-methods on the passed dirwalk options.11 /// `cb` will be able to run builder-methods on the passed dirwalk options.
11 pub fn dirwalk_options(mut self, cb: impl FnOnce(crate::dirwalk::Options) -> crate::dirwalk::Options) -> Self {12 pub fn dirwalk_options(mut self, cb: impl FnOnce(crate::dirwalk::Options) -> crate::dirwalk::Options) -> Self {
12 if let Some(opts) = self.index_worktree_options.dirwalk_options.take() {13 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
275 }275 }
276}276}
277 277
278///
279#[cfg(all(feature = "status", feature = "parallel"))]
280pub 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"))]
448pub use status::types::Status;
449
278/// A summary of the state of all parts forming a submodule, which allows to answer various questions about it.450/// A summary of the state of all parts forming a submodule, which allows to answer various questions about it.
279///451///
280/// Note that expensive questions about its presence in the `HEAD` or the `index` are left to the caller.452/// 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
12 git commit -q -am c212 git commit -q -am c2
13)13)
14 14
15git 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
23git 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
34git 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
15git init with-submodules45git init with-submodules
16(cd with-submodules46(cd with-submodules
17 mkdir dir47 mkdir dir
gix/tests/submodule/mod.rs+159−1
@@ -26,6 +26,7 @@ mod open { · 25 unmodified lines
26 worktree_checkout: true,26 worktree_checkout: true,
27 superproject_configuration: true,27 superproject_configuration: true,
28 },28 },
29 Some(false),
29 ),30 ),
30 (31 (
31 "dir/m1",32 "dir/m1",
@@ -35,6 +36,7 @@ mod open { · 3 unmodified lines
35 worktree_checkout: true,36 worktree_checkout: true,
36 superproject_configuration: true,37 superproject_configuration: true,
37 },38 },
39 Some(false),
38 ),40 ),
39 ] as &[_],41 ] as &[_],
40 ),42 ),
@@ -48,6 +50,7 @@ mod open { · 7 unmodified lines
48 worktree_checkout: false,50 worktree_checkout: false,
49 superproject_configuration: true,51 superproject_configuration: true,
50 },52 },
53 None,
51 )],54 )],
52 ),55 ),
53 (56 (
@@ -60,11 +63,13 @@ mod open { · 6 unmodified lines
60 worktree_checkout: false,63 worktree_checkout: false,
61 superproject_configuration: true,64 superproject_configuration: true,
62 },65 },
66 Some(true),
63 )],67 )],
64 ),68 ),
65 ] {69 ] {
66 let repo = repo(name)?;70 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 {
68 assert_eq!(sm.name(), name);73 assert_eq!(sm.name(), name);
69 let state = sm.state()?;74 let state = sm.state()?;
70 assert_eq!(&state, expected);75 assert_eq!(&state, expected);
@@ -87,6 +92,19 @@ mod open { · 16 unmodified lines
87 "there is a way to check for indicators that a submodule worktree isn't checked out though"92 "there is a way to check for indicators that a submodule worktree isn't checked out though"
88 )93 )
89 }94 }
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 }
90 }108 }
91 assert_eq!(109 assert_eq!(
92 repo.modules()?.expect("present").names().count(),110 repo.modules()?.expect("present").names().count(),
@@ -97,6 +115,146 @@ mod open { · 4 unmodified lines
97 Ok(())115 Ok(())
98 }116 }
99 117
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
100 #[test]258 #[test]
101 fn not_a_submodule() -> crate::Result {259 fn not_a_submodule() -> crate::Result {
102 let repo = repo("not-a-submodule")?;260 let repo = repo("not-a-submodule")?;