@@ -1787,6 +1787,29 @@ pub fn nested_key_belongs_in<C: WorktrunkConfig>(path: &str) -> Option<&'static
17871787 . then ( C :: Other :: description)
17881788}
17891789
1790+ /// Note appended to a "belongs in user config" warning: a key placed in
1791+ /// project config that really lives in user config is usually an attempt to
1792+ /// scope a personal setting to one repo, which the `[projects."<id>"]` table
1793+ /// in user config does directly. Returns `None` for any other destination.
1794+ ///
1795+ /// `key` is the misplaced key (`worktree-path`, or a dotted path like
1796+ /// `list.columns`); the note only fires when its top-level segment is a field
1797+ /// of that table (see [`is_user_project_override_key`](crate::config::is_user_project_override_key)).
1798+ /// Root-only user settings — `skip-shell-integration-prompt`,
1799+ /// `skip-commit-generation-prompt` — have no `[projects."<id>"]` form and no
1800+ /// per-repo semantics, so following the note would just produce a fresh
1801+ /// "unknown field"; they get no note.
1802+ ///
1803+ /// Keyed off the destination *description* rather than a config-type gate so
1804+ /// both warning formatters (load-time and `config show`) can share it — they
1805+ /// hold only the `other_description` string, not the config type.
1806+ pub fn scope_to_repo_note ( other_description : & str , key : & str ) -> Option < & ' static str > {
1807+ let top_level = key. split ( '.' ) . next ( ) . unwrap_or ( key) ;
1808+ ( other_description == crate :: config:: UserConfig :: description ( )
1809+ && crate :: config:: is_user_project_override_key ( top_level) )
1810+ . then_some ( r#"to scope it to this repo, add it under [projects."<id>"] in user config"# )
1811+ }
1812+
17901813/// Classification of an unknown config key for warning purposes.
17911814pub enum UnknownKeyKind {
17921815 /// Deprecated key in its correct config type — deprecation system handles it
@@ -1871,8 +1894,12 @@ fn format_load_warning(label: &str, warning: &crate::config::UnknownWarning) ->
18711894 UnknownWarning :: TopLevelWrongConfig {
18721895 key,
18731896 other_description,
1874- } => cformat ! (
1875- "{label} has key <bold>{key}</> which belongs in {other_description} (will be ignored)"
1897+ } => with_scope_note (
1898+ cformat ! (
1899+ "{label} has key <bold>{key}</> which belongs in {other_description} (will be ignored)"
1900+ ) ,
1901+ other_description,
1902+ key,
18761903 ) ,
18771904 UnknownWarning :: TopLevelDeprecatedWrongConfig {
18781905 key,
@@ -1884,15 +1911,32 @@ fn format_load_warning(label: &str, warning: &crate::config::UnknownWarning) ->
18841911 UnknownWarning :: NestedWrongConfig {
18851912 path,
18861913 other_description,
1887- } => cformat ! (
1888- "{label} has key <bold>{path}</> which belongs in {other_description} (will be ignored)"
1914+ } => with_scope_note (
1915+ cformat ! (
1916+ "{label} has key <bold>{path}</> which belongs in {other_description} (will be ignored)"
1917+ ) ,
1918+ other_description,
1919+ path,
18891920 ) ,
18901921 UnknownWarning :: NestedUnknown { path } => {
18911922 cformat ! ( "{label} has unknown field <bold>{path}</> (will be ignored)" )
18921923 }
18931924 }
18941925}
18951926
1927+ /// Append the project-scoped-user-config note to `message` when the misplaced
1928+ /// `key`'s destination is user config and it's a `[projects."<id>"]` field
1929+ /// (see [`scope_to_repo_note`]). Joined with a semicolon per the house style
1930+ /// for related clauses. The note is plain text so its `[projects."<id>"]`
1931+ /// placeholder isn't parsed as color-print markup. Shared with `config show`
1932+ /// via [`crate::config::with_scope_note`] so the caveat lives in one place.
1933+ pub fn with_scope_note ( message : String , other_description : & str , key : & str ) -> String {
1934+ match scope_to_repo_note ( other_description, key) {
1935+ Some ( note) => format ! ( "{message}; {note}" ) ,
1936+ None => message,
1937+ }
1938+ }
1939+
18961940#[ cfg( test) ]
18971941mod tests {
18981942 use super :: * ;
@@ -4491,6 +4535,107 @@ ff = true
44914535 ) ;
44924536 }
44934537
4538+ #[ test]
4539+ fn test_nested_user_only_key_redirects_generally ( ) {
4540+ use crate :: config:: { ProjectConfig , UnknownWarning , UserConfig , collect_unknown_warnings} ;
4541+
4542+ // `[list]` is a valid *shared* section (project config accepts `url`),
4543+ // but `columns` / `full` are user-config display settings. Placing them
4544+ // in project config redirects to user config rather than reading
4545+ // "unknown field" — the general "valid in the other config" check, not
4546+ // the hard-coded commit.generation list (#3469).
4547+ let warnings = collect_unknown_warnings :: < ProjectConfig > (
4548+ "[list]\n columns = [\" branch\" ]\n full = true\n " ,
4549+ ) ;
4550+ assert ! (
4551+ warnings. iter( ) . all( |w| matches!(
4552+ w,
4553+ UnknownWarning :: NestedWrongConfig { path, other_description }
4554+ if ( path == "list.columns" || path == "list.full" )
4555+ && * other_description == "user config"
4556+ ) ) && warnings. len( ) == 2 ,
4557+ "expected list.columns/list.full → user config, got {warnings:?}"
4558+ ) ;
4559+
4560+ // A key unknown in *both* configs stays "unknown field".
4561+ let warnings = collect_unknown_warnings :: < ProjectConfig > ( "[list]\n nonsense-typo = true\n " ) ;
4562+ assert ! (
4563+ matches!(
4564+ warnings. as_slice( ) ,
4565+ [ UnknownWarning :: NestedUnknown { path } ] if path == "list.nonsense-typo"
4566+ ) ,
4567+ "expected list.nonsense-typo → unknown, got {warnings:?}"
4568+ ) ;
4569+
4570+ // The reverse direction: `url` is project-only, so it redirects to
4571+ // project config when found in user config — no scope-to-repo note
4572+ // there (that only applies to user-config destinations).
4573+ let warnings = collect_unknown_warnings :: < UserConfig > ( "[list]\n url = \" x\" \n " ) ;
4574+ assert ! (
4575+ matches!(
4576+ warnings. as_slice( ) ,
4577+ [ UnknownWarning :: NestedWrongConfig { path, other_description } ]
4578+ if path == "list.url" && * other_description == "project config"
4579+ ) ,
4580+ "expected list.url → project config, got {warnings:?}"
4581+ ) ;
4582+ }
4583+
4584+ #[ test]
4585+ fn test_scope_to_repo_note_only_for_user_config ( ) {
4586+ // The note fires for user-config destinations whose top-level key is a
4587+ // `[projects."<id>"]` field, and nothing else.
4588+ assert ! ( scope_to_repo_note( "user config" , "list.columns" ) . is_some( ) ) ;
4589+ assert ! ( scope_to_repo_note( "user config" , "worktree-path" ) . is_some( ) ) ;
4590+ assert ! ( scope_to_repo_note( "project config" , "list.url" ) . is_none( ) ) ;
4591+
4592+ // Root-only user scalars have no `[projects."<id>"]` form, so following
4593+ // the note would just yield a fresh "unknown field" — no note.
4594+ assert ! ( scope_to_repo_note( "user config" , "skip-shell-integration-prompt" ) . is_none( ) ) ;
4595+ assert ! ( scope_to_repo_note( "user config" , "skip-commit-generation-prompt" ) . is_none( ) ) ;
4596+
4597+ // It reaches the rendered load-warning for a user-config redirect...
4598+ let note = "to scope it to this repo" ;
4599+ let msg = format_load_warning (
4600+ "Project config" ,
4601+ & crate :: config:: UnknownWarning :: NestedWrongConfig {
4602+ path : "list.columns" . to_string ( ) ,
4603+ other_description : "user config" ,
4604+ } ,
4605+ ) ;
4606+ assert ! (
4607+ msg. contains( note) ,
4608+ "user-config redirect should carry note: {msg}"
4609+ ) ;
4610+
4611+ // ...but not a project-config redirect.
4612+ let msg = format_load_warning (
4613+ "User config" ,
4614+ & crate :: config:: UnknownWarning :: NestedWrongConfig {
4615+ path : "list.url" . to_string ( ) ,
4616+ other_description : "project config" ,
4617+ } ,
4618+ ) ;
4619+ assert ! (
4620+ !msg. contains( note) ,
4621+ "project-config redirect should not carry note: {msg}"
4622+ ) ;
4623+
4624+ // ...and not a misplaced root-only user scalar, even though its
4625+ // destination is user config.
4626+ let msg = format_load_warning (
4627+ "Project config" ,
4628+ & crate :: config:: UnknownWarning :: TopLevelWrongConfig {
4629+ key : "skip-shell-integration-prompt" . to_string ( ) ,
4630+ other_description : "user config" ,
4631+ } ,
4632+ ) ;
4633+ assert ! (
4634+ !msg. contains( note) ,
4635+ "root-only user scalar should not carry note: {msg}"
4636+ ) ;
4637+ }
4638+
44944639 /// Every `DEPRECATION_RULES` row's migration fires on one config, pinning
44954640 /// cross-rule interactions the per-rule tests can't see — in particular
44964641 /// the inserted `[forge]` staying in the mid-file spot where the user
0 commit comments