That way it's possible to obtain submodule status information, with enough information to implement `git status`-like commands.
Unified · Split
| @@ -4,8 +4,6 @@ use bstr::{BStr, BString, ByteSlice}; · 3 unmodified lines | ||
| 4 | 4 | #[derive(Default, Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq, Hash)] |
| 5 | 5 | pub enum Ignore { |
| 6 | 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 | 7 | All, |
| 10 | 8 | /// Ignore any changes to the submodule working tree, only show committed differences between the `HEAD` of the submodule |
| 11 | 9 | /// compared to the recorded commit in the superproject. |
| @@ -5,8 +5,9 @@ impl<'repo, Progress> Platform<'repo, Progress> · 4 unmodified lines | ||
| 5 | 5 | where |
| 6 | 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 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 | + /// | |
| 10 | 11 | /// `cb` will be able to run builder-methods on the passed dirwalk options. |
| 11 | 12 | pub fn dirwalk_options(mut self, cb: impl FnOnce(crate::dirwalk::Options) -> crate::dirwalk::Options) -> Self { |
| 12 | 13 | if let Some(opts) = self.index_worktree_options.dirwalk_options.take() { |
| @@ -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"))] | |
| 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 | + | |
| 278 | 450 | /// A summary of the state of all parts forming a submodule, which allows to answer various questions about it. |
| 279 | 451 | /// |
| 280 | 452 | /// Note that expensive questions about its presence in the `HEAD` or the `index` are left to the caller. |
Binary file not shown.
| @@ -12,6 +12,36 @@ git init -q module1 · 11 unmodified lines | ||
| 12 | 12 | git commit -q -am c2 |
| 13 | 13 | ) |
| 14 | 14 | |
| 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 | + | |
| 15 | 45 | git init with-submodules |
| 16 | 46 | (cd with-submodules |
| 17 | 47 | mkdir dir |
| @@ -26,6 +26,7 @@ mod open { · 25 unmodified lines | ||
| 26 | 26 | worktree_checkout: true, |
| 27 | 27 | superproject_configuration: true, |
| 28 | 28 | }, |
| 29 | + Some(false), | |
| 29 | 30 | ), |
| 30 | 31 | ( |
| 31 | 32 | "dir/m1", |
| @@ -35,6 +36,7 @@ mod open { · 3 unmodified lines | ||
| 35 | 36 | worktree_checkout: true, |
| 36 | 37 | superproject_configuration: true, |
| 37 | 38 | }, |
| 39 | + Some(false), | |
| 38 | 40 | ), |
| 39 | 41 | ] as &[_], |
| 40 | 42 | ), |
| @@ -48,6 +50,7 @@ mod open { · 7 unmodified lines | ||
| 48 | 50 | worktree_checkout: false, |
| 49 | 51 | superproject_configuration: true, |
| 50 | 52 | }, |
| 53 | + None, | |
| 51 | 54 | )], |
| 52 | 55 | ), |
| 53 | 56 | ( |
| @@ -60,11 +63,13 @@ mod open { · 6 unmodified lines | ||
| 60 | 63 | worktree_checkout: false, |
| 61 | 64 | superproject_configuration: true, |
| 62 | 65 | }, |
| 66 | + Some(true), | |
| 63 | 67 | )], |
| 64 | 68 | ), |
| 65 | 69 | ] { |
| 66 | 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 | 73 | assert_eq!(sm.name(), name); |
| 69 | 74 | let state = sm.state()?; |
| 70 | 75 | assert_eq!(&state, expected); |
| @@ -87,6 +92,19 @@ mod open { · 16 unmodified lines | ||
| 87 | 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 | 109 | assert_eq!( |
| 92 | 110 | repo.modules()?.expect("present").names().count(), |
| @@ -97,6 +115,146 @@ mod open { · 4 unmodified lines | ||
| 97 | 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 | 258 | #[test] |
| 101 | 259 | fn not_a_submodule() -> crate::Result { |
| 102 | 260 | let repo = repo("not-a-submodule")?; |