@@ -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