Skip to content

Commit 216c28e

Browse files
Copilotbashandbone
andauthored
feat: sparse checkout deny-all-by-default (modified cone pattern) with opt-out support (#48)
Co-authored-by: bashandbone <89049923+bashandbone@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 56a5a74 commit 216c28e

7 files changed

Lines changed: 397 additions & 22 deletions

File tree

sample_config/submod.toml

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,27 @@ ignore = "dirty" # Override default ignore setting for all submodules
4949
#
5050
# ## `sparse_paths`
5151
# A list of relative paths or glob patterns to include in the sparse checkout. If omitted, includes all files.
52-
# If set, git prepends:
53-
# ```git
54-
# /*
55-
# !/*/
52+
# **Deny-all by default:** `submod` automatically prepends `!/*` as the first pattern,
53+
# so _only_ the paths you list are checked out. You declare what you want; everything
54+
# else is excluded. No manual negation rules are needed for the common case.
55+
# Example — check out only `src/`, `include/`, and markdown files at any depth:
56+
# ```toml
57+
# sparse_paths = ["src/", "include/", "*.md"]
58+
# ```
59+
# This writes the following patterns to git's sparse-checkout file:
5660
# ```
57-
# This includes only files in the root directory, excluding subdirectories.
58-
# To exclude root files, prefix with `!` (e.g., `!/README.md`).
59-
# To include subdirectories, add paths like `/src/`.
61+
# !/* ← deny all by default (added automatically)
62+
# src/ ← include src/
63+
# include/ ← include include/
64+
# *.md ← include markdown files at any depth
65+
# ```
66+
# To match markdown files only at the repository root use `/*.md` instead.
67+
# Any `!/*` entries you add yourself are de-duplicated automatically.
68+
#
69+
# ## `use_git_default_sparse_checkout`
70+
# Set to `true` to opt out of submod's deny-all-by-default model for this submodule
71+
# and use git's standard sparse-checkout semantics instead (no automatic `!/*` prefix).
72+
# Can also be set globally under `[defaults]`.
6073
#
6174
# ## `shallow`
6275
#
@@ -73,7 +86,7 @@ url = "https://github.com/example/utils.git"
7386
sparse_paths = [
7487
"src/", # All files in src directory
7588
"include/", # All files in include directory
76-
"*.md" # All markdown files in submodule root
89+
"*.md" # All markdown files at any depth
7790
]
7891
ignore = "all" # Override default ignore setting
7992

src/commands.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,15 @@ pub enum Commands {
120120
)]
121121
sparse_paths: Option<Vec<String>>,
122122

123+
#[arg(
124+
long = "use-git-default-sparse-checkout",
125+
num_args = 0..=1,
126+
value_parser = clap::value_parser!(bool),
127+
default_missing_value = "true",
128+
help = "Opt out of submod's deny-all-by-default sparse-checkout model and use git's built-in behaviour instead. When set, the `!/*` prefix is NOT prepended automatically."
129+
)]
130+
use_git_default_sparse_checkout: Option<bool>,
131+
123132
#[arg(
124133
short = 'f',
125134
long = "fetch",
@@ -166,6 +175,15 @@ pub enum Commands {
166175
#[arg(requires("sparse_paths"), short = 'a', long = "append", value_parser = clap::value_parser!(bool), default_value = "false", default_missing_value = "true", help = "If given, appends the new sparse paths to the existing ones.")]
167176
append: bool,
168177

178+
#[arg(
179+
long = "use-git-default-sparse-checkout",
180+
num_args = 0..=1,
181+
value_parser = clap::value_parser!(bool),
182+
default_missing_value = "true",
183+
help = "Opt out of submod's deny-all-by-default sparse-checkout model and use git's built-in behaviour instead."
184+
)]
185+
use_git_default_sparse_checkout: Option<bool>,
186+
169187
#[arg(
170188
short = 'i',
171189
long = "ignore",
@@ -224,6 +242,15 @@ pub enum Commands {
224242
help = "Sets the default update behavior for all submodules in this repository. This will override any individual submodule settings."
225243
)]
226244
update: Option<Update>,
245+
246+
#[arg(
247+
long = "use-git-default-sparse-checkout",
248+
num_args = 0..=1,
249+
value_parser = clap::value_parser!(bool),
250+
default_missing_value = "true",
251+
help = "Set the global default for sparse-checkout mode. When true, all submodules use git's built-in behaviour instead of submod's deny-all-by-default model (unless overridden per-submodule)."
252+
)]
253+
use_git_default_sparse_checkout: Option<bool>,
227254
},
228255

229256
#[command(

src/config.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,11 @@ pub struct SubmoduleDefaults {
166166
pub fetch_recurse: Option<SerializableFetchRecurse>,
167167
/// [`Update`][SerializableUpdate] setting for submodules
168168
pub update: Option<SerializableUpdate>,
169+
/// When `true`, use git's built-in sparse-checkout behaviour (no `!/*` prefix is
170+
/// prepended). Defaults to `false`, which uses submod's deny-all-by-default model.
171+
/// Individual submodules can override this per-entry.
172+
#[serde(default, skip_serializing_if = "Option::is_none")]
173+
pub use_git_default_sparse_checkout: Option<bool>,
169174
}
170175

171176
impl Iterator for SubmoduleDefaults {
@@ -204,6 +209,7 @@ impl SubmoduleDefaults {
204209
.fetch_recurse
205210
.or_else(|| Some(SerializableFetchRecurse::default())),
206211
update: update.or_else(|| Some(SerializableUpdate::default())),
212+
use_git_default_sparse_checkout: mut_self.use_git_default_sparse_checkout,
207213
}
208214
}
209215
}
@@ -248,6 +254,7 @@ impl SubmoduleAddOptions {
248254
active: Some(!self.no_init), // we're adding so unless we have a 'no_init" flag, we can assume active
249255
no_init: Some(self.no_init),
250256
sparse_paths: None,
257+
use_git_default_sparse_checkout: None,
251258
}
252259
}
253260

@@ -460,6 +467,10 @@ pub struct SubmoduleEntry {
460467
/// Sparse checkout paths for this submodule (optional)
461468
#[serde(skip_serializing_if = "Option::is_none")]
462469
pub sparse_paths: Option<Vec<String>>,
470+
/// When `true`, use git's built-in sparse-checkout behaviour instead of submod's
471+
/// deny-all-by-default model. Overrides the global `[defaults]` setting.
472+
#[serde(default, skip_serializing_if = "Option::is_none")]
473+
pub use_git_default_sparse_checkout: Option<bool>,
463474
}
464475

465476
#[allow(dead_code)]
@@ -487,6 +498,7 @@ impl SubmoduleEntry {
487498
shallow: shallow,
488499
no_init: no_init,
489500
sparse_paths: None,
501+
use_git_default_sparse_checkout: None,
490502
}
491503
}
492504

@@ -666,6 +678,7 @@ impl From<OtherSubmoduleSettings> for SubmoduleEntry {
666678
update: default_git_options.update,
667679
no_init: Some(other.no_init),
668680
sparse_paths: None,
681+
use_git_default_sparse_checkout: None,
669682
}
670683
}
671684
}
@@ -1152,11 +1165,13 @@ mod tests {
11521165
ignore: Some(SerializableIgnore::All),
11531166
fetch_recurse: Some(SerializableFetchRecurse::Always),
11541167
update: Some(SerializableUpdate::Rebase),
1168+
use_git_default_sparse_checkout: None,
11551169
};
11561170
let other = SubmoduleDefaults {
11571171
ignore: Some(SerializableIgnore::Dirty),
11581172
fetch_recurse: None,
11591173
update: Some(SerializableUpdate::Merge),
1174+
use_git_default_sparse_checkout: None,
11601175
};
11611176
let merged = base.merge_from(other);
11621177
// other.ignore overrides
@@ -1173,6 +1188,7 @@ mod tests {
11731188
ignore: Some(SerializableIgnore::All),
11741189
fetch_recurse: Some(SerializableFetchRecurse::Never),
11751190
update: Some(SerializableUpdate::Checkout),
1191+
use_git_default_sparse_checkout: None,
11761192
};
11771193
let other = SubmoduleDefaults::default();
11781194
let merged = base.merge_from(other);
@@ -1189,6 +1205,7 @@ mod tests {
11891205
ignore: Some(SerializableIgnore::Dirty),
11901206
fetch_recurse: Some(SerializableFetchRecurse::Always),
11911207
update: Some(SerializableUpdate::Merge),
1208+
use_git_default_sparse_checkout: None,
11921209
};
11931210
let merged = base.merge_from(other);
11941211
assert_eq!(merged.ignore, Some(SerializableIgnore::Dirty));
@@ -1572,6 +1589,7 @@ mod tests {
15721589
shallow: None,
15731590
no_init: None,
15741591
sparse_paths: Some(vec!["src/".to_string()]),
1592+
use_git_default_sparse_checkout: None,
15751593
};
15761594
entries.update_entry("repo".to_string(), entry);
15771595

@@ -1597,6 +1615,7 @@ mod tests {
15971615
shallow: None,
15981616
no_init: None,
15991617
sparse_paths: Some(vec!["src/".to_string()]),
1618+
use_git_default_sparse_checkout: None,
16001619
};
16011620
entries.update_entry("repo".to_string(), entry_with_sparse);
16021621
assert!(entries.sparse_checkouts().unwrap().contains_key("repo"));
@@ -1613,6 +1632,7 @@ mod tests {
16131632
shallow: None,
16141633
no_init: None,
16151634
sparse_paths: None,
1635+
use_git_default_sparse_checkout: None,
16161636
};
16171637
entries.update_entry("repo".to_string(), entry_no_sparse);
16181638
assert!(!entries.sparse_checkouts().unwrap().contains_key("repo"));
@@ -1779,6 +1799,7 @@ mod tests {
17791799
ignore: Some(SerializableIgnore::Dirty),
17801800
fetch_recurse: Some(SerializableFetchRecurse::Always),
17811801
update: Some(SerializableUpdate::Rebase),
1802+
use_git_default_sparse_checkout: None,
17821803
};
17831804
let entry = SubmoduleEntry::new(
17841805
Some("url".to_string()),
@@ -1808,6 +1829,7 @@ mod tests {
18081829
ignore: Some(SerializableIgnore::Dirty),
18091830
fetch_recurse: Some(SerializableFetchRecurse::Always),
18101831
update: Some(SerializableUpdate::Rebase),
1832+
use_git_default_sparse_checkout: None,
18111833
};
18121834
let entry = SubmoduleEntry::new(
18131835
Some("url".to_string()),
@@ -1900,6 +1922,7 @@ mod tests {
19001922
shallow: None,
19011923
no_init: None,
19021924
sparse_paths: None,
1925+
use_git_default_sparse_checkout: None,
19031926
};
19041927
let opts = SubmoduleAddOptions::from_submodule_entries_tuple(("mymod".to_string(), entry));
19051928
// url fallback: path
@@ -1920,6 +1943,7 @@ mod tests {
19201943
shallow: None,
19211944
no_init: None,
19221945
sparse_paths: None,
1946+
use_git_default_sparse_checkout: None,
19231947
};
19241948
let opts = SubmoduleAddOptions::from_submodule_entries_tuple(("mymod".to_string(), entry));
19251949
// Falls back to name for both url and path
@@ -1972,6 +1996,7 @@ mod tests {
19721996
shallow: Some(false),
19731997
no_init: None,
19741998
sparse_paths: Some(vec!["src/".to_string()]),
1999+
use_git_default_sparse_checkout: None,
19752000
};
19762001
entries = entries.add_submodule("mymod".to_string(), entry);
19772002

0 commit comments

Comments
 (0)