Skip to content

Commit af455ae

Browse files
bashandboneclaude
andauthored
fix(config): round-trip submod.toml — preserve [defaults] on load, persist edits on save (#62 P1) (#70)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 482e892 commit af455ae

2 files changed

Lines changed: 231 additions & 110 deletions

File tree

src/config.rs

Lines changed: 158 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,14 +1091,50 @@ impl Config {
10911091
Ok(())
10921092
}
10931093

1094+
/// Overlay CLI-supplied options onto this configuration.
1095+
///
1096+
/// Only fields explicitly set on the CLI override the current values: a
1097+
/// `None` `[defaults]` field (or an empty submodule set) leaves the existing
1098+
/// value intact, so an absent CLI flag never erases configuration the file
1099+
/// provided. Merging the whole `cli_options` struct through figment instead
1100+
/// would serialize its unset `[defaults]` fields as nulls and clobber the
1101+
/// file's values (#62 P1).
1102+
fn merge_cli_overrides(&mut self, cli: Self) {
1103+
let cli_defaults = cli.defaults;
1104+
if cli_defaults.ignore.is_some() {
1105+
self.defaults.ignore = cli_defaults.ignore;
1106+
}
1107+
if cli_defaults.fetch_recurse.is_some() {
1108+
self.defaults.fetch_recurse = cli_defaults.fetch_recurse;
1109+
}
1110+
if cli_defaults.update.is_some() {
1111+
self.defaults.update = cli_defaults.update;
1112+
}
1113+
if cli_defaults.use_git_default_sparse_checkout.is_some() {
1114+
self.defaults.use_git_default_sparse_checkout =
1115+
cli_defaults.use_git_default_sparse_checkout;
1116+
}
1117+
// CLI submodule entries override/extend by name (no-op when none given).
1118+
for (name, entry) in cli.submodules {
1119+
self.submodules.update_entry(name, entry);
1120+
}
1121+
}
1122+
10941123
/// Load configuration from a file, merging with CLI options
10951124
pub fn load(&self, path: impl AsRef<Path>, cli_options: Self) -> anyhow::Result<Self> {
1096-
let fig = Figment::from(Self::default()) // 1) start from Rust-side defaults
1097-
.merge(Toml::file(path)) // 2) file-based overrides
1098-
.merge(cli_options); // 3) CLI overrides file
1099-
1100-
// 4) extract into Config, then post-process submodules
1101-
let cfg: Self = fig.extract()?;
1125+
// 1) Read the file's values. NOTE: layering an empty `Config::default()`
1126+
// provider beneath the file (the previous approach) actively *erased*
1127+
// the file's `[defaults]` — that provider emits all-`None` defaults
1128+
// under its own figment profile (`REPO`), which then overrode the
1129+
// file's values. Rust-side defaults are filled by `apply_defaults()`
1130+
// below, not by a figment base layer (#62 P1).
1131+
let mut cfg: Self = Figment::from(Toml::file(path)).extract()?;
1132+
1133+
// 2) CLI overrides the file, but only where the CLI actually set a value
1134+
// (None-aware — see `merge_cli_overrides`).
1135+
cfg.merge_cli_overrides(cli_options);
1136+
1137+
// 3) post-process submodules
11021138
Ok(cfg.apply_defaults())
11031139
}
11041140

@@ -1108,9 +1144,10 @@ impl Config {
11081144
Some(ref p) => p,
11091145
None => &".",
11101146
};
1111-
let fig = Figment::from(Self::default()).merge(Toml::file(p));
1112-
// Extract the configuration from Figment
1113-
let cfg: Self = fig.extract()?;
1147+
// See `load`: an empty `Config::default()` base layer erases the file's
1148+
// `[defaults]`, so read the file directly and let `apply_defaults()`
1149+
// supply Rust-side defaults (#62 P1).
1150+
let cfg: Self = Figment::from(Toml::file(p)).extract()?;
11141151
Ok(cfg.apply_defaults())
11151152
}
11161153

@@ -2165,6 +2202,118 @@ active = true
21652202
);
21662203
}
21672204

2205+
// ================================================================
2206+
// Config::load — CLI-over-file precedence
2207+
// ================================================================
2208+
2209+
#[test]
2210+
fn test_config_load_cli_overrides_file_but_preserves_unspecified() {
2211+
// `Config::load` layers three sources: Rust-side defaults → submod.toml
2212+
// → cli_options. A value supplied via `cli_options` must override the
2213+
// same key from the file, while a key the CLI leaves unset must keep the
2214+
// file's value — an absent CLI flag must not erase file configuration.
2215+
// This precedence contract was previously untested (#62 P1).
2216+
figment::Jail::expect_with(|jail| {
2217+
jail.create_file(
2218+
"submod.toml",
2219+
r#"
2220+
[defaults]
2221+
ignore = "all"
2222+
update = "rebase"
2223+
"#,
2224+
)?;
2225+
2226+
// The CLI overrides `ignore` and leaves `update` unspecified.
2227+
let mut cli_options = Config::default();
2228+
cli_options.defaults.ignore = Some(SerializableIgnore::Dirty);
2229+
2230+
let cfg = Config::default()
2231+
.load("submod.toml", cli_options)
2232+
.expect("load should succeed");
2233+
2234+
assert_eq!(
2235+
cfg.defaults.ignore,
2236+
Some(SerializableIgnore::Dirty),
2237+
"CLI-supplied `ignore` must override the file's value"
2238+
);
2239+
assert_eq!(
2240+
cfg.defaults.update,
2241+
Some(SerializableUpdate::Rebase),
2242+
"file's `update` must survive when the CLI leaves it unspecified"
2243+
);
2244+
2245+
Ok(())
2246+
});
2247+
}
2248+
2249+
#[test]
2250+
fn test_config_load_default_cli_preserves_file_defaults() {
2251+
// The production callers (`GitManager::with_verbose`) load config with
2252+
// `cli_options = Config::default()`, whose `[defaults]` are all `None`.
2253+
// Those unset CLI fields must not erase the file's `[defaults]`; the
2254+
// whole `[defaults]` block was previously clobbered to `None` on every
2255+
// load (#62 P1).
2256+
figment::Jail::expect_with(|jail| {
2257+
jail.create_file(
2258+
"submod.toml",
2259+
r#"
2260+
[defaults]
2261+
ignore = "all"
2262+
update = "rebase"
2263+
fetchRecurse = "always"
2264+
"#,
2265+
)?;
2266+
2267+
let cfg = Config::default()
2268+
.load("submod.toml", Config::default())
2269+
.expect("load should succeed");
2270+
2271+
assert_eq!(
2272+
cfg.defaults.ignore,
2273+
Some(SerializableIgnore::All),
2274+
"file `[defaults] ignore` must survive a default-CLI load"
2275+
);
2276+
assert_eq!(
2277+
cfg.defaults.update,
2278+
Some(SerializableUpdate::Rebase),
2279+
"file `[defaults] update` must survive a default-CLI load"
2280+
);
2281+
assert_eq!(
2282+
cfg.defaults.fetch_recurse,
2283+
Some(SerializableFetchRecurse::Always),
2284+
"file `[defaults] fetchRecurse` must survive a default-CLI load"
2285+
);
2286+
2287+
Ok(())
2288+
});
2289+
}
2290+
2291+
#[test]
2292+
fn test_config_load_from_file_preserves_file_defaults() {
2293+
// `load_from_file` shares the figment-base hazard with `load`: an empty
2294+
// default provider layered beneath the file erased the file's
2295+
// `[defaults]`. The file's values must survive (#62 P1).
2296+
figment::Jail::expect_with(|jail| {
2297+
jail.create_file(
2298+
"submod.toml",
2299+
r#"
2300+
[defaults]
2301+
ignore = "all"
2302+
update = "rebase"
2303+
"#,
2304+
)?;
2305+
2306+
let cfg = Config::default()
2307+
.load_from_file(Some("submod.toml"))
2308+
.expect("load_from_file should succeed");
2309+
2310+
assert_eq!(cfg.defaults.ignore, Some(SerializableIgnore::All));
2311+
assert_eq!(cfg.defaults.update, Some(SerializableUpdate::Rebase));
2312+
2313+
Ok(())
2314+
});
2315+
}
2316+
21682317
// ================================================================
21692318
// SubmoduleEntries::from_gitmodules
21702319
// ================================================================

src/git_manager.rs

Lines changed: 73 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -206,108 +206,15 @@ impl GitManager {
206206
self.save_config()
207207
}
208208

209-
/// Save the current in-memory configuration to the config file
209+
/// Save the current in-memory configuration to the config file.
210+
///
211+
/// Delegates to [`write_full_config`], which rewrites the file
212+
/// section-by-section: it preserves the preamble, comments, `[defaults]`,
213+
/// and any unknown keys, *updates* the bodies of sections that already
214+
/// exist, and appends sections that are new. The previous implementation
215+
/// was append-only and silently dropped edits to existing sections (#62 P1).
210216
fn save_config(&self) -> Result<(), SubmoduleError> {
211-
// Read existing TOML to preserve content (defaults, comments, existing entries)
212-
let existing = if self.config_path.exists() {
213-
std::fs::read_to_string(&self.config_path)
214-
.map_err(|e| SubmoduleError::ConfigError(format!("Failed to read config: {e}")))?
215-
} else {
216-
String::new()
217-
};
218-
219-
let mut output = existing.clone();
220-
221-
// Append any new submodule sections not already in the file
222-
for (name, entry) in self.config.get_submodules() {
223-
// Determine whether this name needs quoting (contains TOML-special characters).
224-
// Simple names (alphanumeric, hyphens, underscores) can use the bare [name] form.
225-
let needs_quoting = name
226-
.chars()
227-
.any(|c| !c.is_alphanumeric() && c != '-' && c != '_');
228-
let escaped_name = name.replace('\\', "\\\\").replace('"', "\\\"");
229-
let section_header = if needs_quoting {
230-
format!("[\"{escaped_name}\"]")
231-
} else {
232-
format!("[{name}]")
233-
};
234-
// Check at line boundaries to avoid false positives from comments/values.
235-
// Accept either quoted or unquoted form so existing files written before this
236-
// change are recognised.
237-
let already_present = existing.lines().any(|line| {
238-
let trimmed = line.trim();
239-
trimmed == section_header
240-
|| trimmed == format!("[{name}]")
241-
|| trimmed == format!("[\"{escaped_name}\"]")
242-
});
243-
if !already_present {
244-
output.push('\n');
245-
output.push_str(&section_header);
246-
output.push('\n');
247-
if let Some(path) = &entry.path {
248-
output.push_str(&format!(
249-
"path = \"{}\"\n",
250-
path.replace('\\', "\\\\").replace('"', "\\\"")
251-
));
252-
}
253-
if let Some(url) = &entry.url {
254-
output.push_str(&format!(
255-
"url = \"{}\"\n",
256-
url.replace('\\', "\\\\").replace('"', "\\\"")
257-
));
258-
}
259-
if let Some(branch) = &entry.branch {
260-
let val = branch.to_string();
261-
if !val.is_empty() {
262-
output.push_str(&format!(
263-
"branch = \"{}\"\n",
264-
val.replace('\\', "\\\\").replace('"', "\\\"")
265-
));
266-
}
267-
}
268-
if let Some(ignore) = &entry.ignore {
269-
let val = ignore.to_string();
270-
if !val.is_empty() {
271-
output.push_str(&format!("ignore = \"{val}\"\n"));
272-
}
273-
}
274-
if let Some(fetch_recurse) = &entry.fetch_recurse {
275-
let val = fetch_recurse.as_config_value();
276-
if !val.is_empty() {
277-
output.push_str(&format!("fetchRecurse = \"{val}\"\n"));
278-
}
279-
}
280-
if let Some(update) = &entry.update {
281-
let val = update.to_string();
282-
if !val.is_empty() {
283-
output.push_str(&format!("update = \"{val}\"\n"));
284-
}
285-
}
286-
if let Some(active) = entry.active {
287-
output.push_str(&format!("active = {active}\n"));
288-
}
289-
if let Some(shallow) = entry.shallow
290-
&& shallow
291-
{
292-
output.push_str("shallow = true\n");
293-
}
294-
if let Some(sparse_paths) = &entry.sparse_paths
295-
&& !sparse_paths.is_empty()
296-
{
297-
let joined = sparse_paths
298-
.iter()
299-
.map(|p| format!("\"{}\"", p.replace('\\', "\\\\").replace('"', "\\\"")))
300-
.collect::<Vec<_>>()
301-
.join(", ");
302-
output.push_str(&format!("sparse_paths = [{joined}]\n"));
303-
}
304-
}
305-
}
306-
307-
std::fs::write(&self.config_path, &output).map_err(|e| {
308-
SubmoduleError::ConfigError(format!("Failed to write config file: {e}"))
309-
})?;
310-
Ok(())
217+
self.write_full_config()
311218
}
312219

313220
/// Creates a new `GitManager` by loading configuration from the given path
@@ -2080,6 +1987,71 @@ mod tests {
20801987
);
20811988
}
20821989

1990+
#[test]
1991+
fn test_save_config_persists_edits_to_existing_section() {
1992+
// `save_config` (the writer used by `add`) was append-only: once a
1993+
// submodule's section existed in submod.toml, later edits to that entry
1994+
// were silently not written back, because the section header was
1995+
// "already present". A load→modify→save→reload must reflect the edit
1996+
// (#62 P1).
1997+
let temp_dir = tempdir().unwrap();
1998+
let config_path = temp_dir.path().join("submod.toml");
1999+
let mut manager = create_test_manager(temp_dir.path(), config_path.clone());
2000+
2001+
// Seed an existing [mymod] section tracking branch = "main".
2002+
manager.config.add_submodule(
2003+
"mymod".to_string(),
2004+
SubmoduleEntry::new(
2005+
Some("https://example.com/repo.git".to_string()),
2006+
Some("libs/mymod".to_string()),
2007+
Some(SerializableBranch::Name("main".to_string())),
2008+
None,
2009+
None,
2010+
None,
2011+
Some(true),
2012+
None,
2013+
None,
2014+
),
2015+
);
2016+
manager.save_config().expect("initial save");
2017+
2018+
// Precondition (guards against a vacuous pass): the section was written
2019+
// with branch = "main".
2020+
let first = fs::read_to_string(&config_path).unwrap();
2021+
let parsed_first: Config = toml::from_str(&first).expect("initial save must be valid TOML");
2022+
assert_eq!(
2023+
parsed_first.submodules.get("mymod").unwrap().branch,
2024+
Some(SerializableBranch::Name("main".to_string())),
2025+
"precondition: initial save must record branch=main; file:\n{first}"
2026+
);
2027+
2028+
// Modify the existing entry: branch main → develop.
2029+
manager.config.add_submodule(
2030+
"mymod".to_string(),
2031+
SubmoduleEntry::new(
2032+
Some("https://example.com/repo.git".to_string()),
2033+
Some("libs/mymod".to_string()),
2034+
Some(SerializableBranch::Name("develop".to_string())),
2035+
None,
2036+
None,
2037+
None,
2038+
Some(true),
2039+
None,
2040+
None,
2041+
),
2042+
);
2043+
manager.save_config().expect("second save");
2044+
2045+
// The edit must be persisted, not dropped because the section pre-existed.
2046+
let second = fs::read_to_string(&config_path).unwrap();
2047+
let reloaded: Config = toml::from_str(&second).expect("second save must be valid TOML");
2048+
assert_eq!(
2049+
reloaded.submodules.get("mymod").unwrap().branch,
2050+
Some(SerializableBranch::Name("develop".to_string())),
2051+
"save_config must persist edits to an existing section; file:\n{second}"
2052+
);
2053+
}
2054+
20832055
#[test]
20842056
fn test_sparse_checkout_mismatch() {
20852057
let temp_dir = tempdir().unwrap();

0 commit comments

Comments
 (0)