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 | #[derive(Default, Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq, Hash)] | 4 | #[derive(Default, Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq, Hash)] |
| 5 | pub enum Ignore { | 5 | pub 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 submodule | 8 | /// 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. |
| @@ -5,8 +5,9 @@ impl<'repo, Progress> Platform<'repo, Progress> · 4 unmodified lines | |||
| 5 | where | 5 | where |
| 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 find | 8 | /// 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() { |
| @@ -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 | /// 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. |
Binary file not shown.
| @@ -12,6 +12,36 @@ git init -q module1 · 11 unmodified lines | |||
| 12 | git commit -q -am c2 | 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 | git init with-submodules | 45 | git init with-submodules |
| 16 | (cd with-submodules | 46 | (cd with-submodules |
| 17 | mkdir dir | 47 | mkdir dir |
| @@ -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")?; |