Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ Check `Cargo.toml` before hand-rolling a utility:

All config deprecation lives in one layer: pre-deserialization TOML migration in `src/config/deprecation.rs`. `migrate_content()` rewrites deprecated patterns into canonical form before serde parses; `check_and_migrate()` reuses it, and additionally detects patterns and emits per-process-deduped warnings (the user materializes migrations via `wt config update`). **Never silently drop an old config key** — that's a silent behavior change for users; migrate it.

Every deprecation is one row in the `DEPRECATION_RULES` table: a single idempotent function that rewrites the pattern AND returns the `DeprecationKind`s for what it changed — there is no separate detection function, so detection and migration share one predicate and cannot drift. Detection runs the same functions against a scratch copy of the document (progressively, so a rule sees earlier rules' rewrites); the invariant for warning rules is **a warning fires exactly when `wt config update` would change the file**, pinned by `test_warning_fires_iff_update_changes` — add new edge cases to its battery. The row variant decides when the rewrite applies: `Structural` rewrites on every load; `UpdateOnly` only via `wt config update`, for deprecated forms that still work at runtime; `Silent` rewrites on every load with no warning — its function signature has no channel for a kind, which is what scopes the invariant to the other two variants. Table order is both the warning-emission order and the migration order. Each `DeprecationKind` carries its own display payload, so `format_deprecation_warnings()` is one match over the kinds. A config that can't be rewritten safely (a malformed value, an occupied destination key) is left untouched and unwarned — serde's type or unknown-field error is the messaging; an empty deprecated section is also left alone, with no message at all (it contributes no config). Adding a deprecation: (1) one idempotent migrate-and-report function; (2) a `DeprecationKind` variant plus its match arm in `format_deprecation_warnings()`; (3) a `DEPRECATION_RULES` row; (4) for a removed top-level section, add a `DeprecatedSection` to `DEPRECATED_SECTION_KEYS` (canonical key plus display form) so `warn_unknown_fields` defers to the deprecation messaging and suggests the correct config file. A silently-migrated rename (e.g. `pre-create` → `pre-start`) is a `Silent` row with no variant. Renaming a field within a section follows the same shape via a TOML-level rename function (see `migrate_negated_bool`); the struct never needs the old field since migration precedes serde.
Every deprecation is one row in the `DEPRECATION_RULES` table: a single idempotent function that rewrites the pattern AND returns the `DeprecationKind`s for what it changed — there is no separate detection function, so detection and migration share one predicate and cannot drift. Detection runs the same functions against a scratch copy of the document (progressively, so a rule sees earlier rules' rewrites); the invariant for warning rules is **a warning fires exactly when `wt config update` would change the file**, pinned by `test_warning_fires_iff_update_changes` — add new edge cases to its battery. The row variant decides when the rewrite applies: `Structural` rewrites on every load; `UpdateOnly` only via `wt config update`, for deprecated forms that still work at runtime; `Silent` rewrites on every load with no warning — its function signature has no channel for a kind, which is what scopes the invariant to `Structural` and `UpdateOnly`; `PendingDefault` pins a default a future release switches (currently `[list] json-schema = 1`, inert while the system config layer defines the key) — update-pass only, scoped to the config kind that owns the key, and excluded from load warnings by `is_pending_default`: it satisfies the same iff at the surface that reads the setting, where the `wt list` JSON nag fires exactly when update would pin. Table order is both the warning-emission order and the migration order. Each `DeprecationKind` carries its own display payload, so `format_deprecation_warnings()` is one match over the kinds. A config that can't be rewritten safely (a malformed value, an occupied destination key) is left untouched and unwarned — serde's type or unknown-field error is the messaging; an empty deprecated section is also left alone, with no message at all (it contributes no config). Adding a deprecation: (1) one idempotent migrate-and-report function; (2) a `DeprecationKind` variant plus its match arm in `format_deprecation_warnings()`; (3) a `DEPRECATION_RULES` row; (4) for a removed top-level section, add a `DeprecatedSection` to `DEPRECATED_SECTION_KEYS` (canonical key plus display form) so `warn_unknown_fields` defers to the deprecation messaging and suggests the correct config file. A silently-migrated rename (e.g. `pre-create` → `pre-start`) is a `Silent` row with no variant. Renaming a field within a section follows the same shape via a TOML-level rename function (see `migrate_negated_bool`); the struct never needs the old field since migration precedes serde.

## Adding CLI Commands

Expand Down
5 changes: 3 additions & 2 deletions docs/content/list.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,9 @@ These appear across all columns while the table is loading:

`--format=json` emits structured data in one of two schemas while the format
migrates: `[list] json-schema = 2` selects the envelope format below, `= 1`
the original bare-array format. Unset emits schema 1 with a warning; a future
release flips the default to schema 2 and later removes schema 1.
the original bare-array format. Unset emits schema 1 with a warning
(`wt config update` pins `= 1`); a future release flips the default to
schema 2 and later removes schema 1.

### Schema 2

Expand Down
5 changes: 3 additions & 2 deletions skills/worktrunk/reference/list.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions src/cli/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -513,11 +513,12 @@ This tests:
/// Update deprecated config settings
#[command(
after_long_help = r#"Updates deprecated settings in user and project config files
to their current equivalents. Shows a diff and asks for confirmation.
to their current equivalents, and pins defaults that a future release
switches — currently `[list] json-schema = 1` — so upgrading doesn't change
behavior. Shows a diff and asks for confirmation.

Migrations are computed in memory on demand — worktrunk no longer writes
`.new` files as a side effect of loading config. Use `--print` to see the
migrated TOML without touching any file.
Migrations are computed in memory on demand; nothing is written outside this
command. Use `--print` to see the migrated TOML without touching any file.

## Examples

Expand Down
5 changes: 3 additions & 2 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -956,8 +956,9 @@ These appear across all columns while the table is loading:

`--format=json` emits structured data in one of two schemas while the format
migrates: `[list] json-schema = 2` selects the envelope format below, `= 1`
the original bare-array format. Unset emits schema 1 with a warning; a future
release flips the default to schema 2 and later removes schema 1.
the original bare-array format. Unset emits schema 1 with a warning
(`wt config update` pins `= 1`); a future release flips the default to
schema 2 and later removes schema 1.

### Schema 2

Expand Down
48 changes: 34 additions & 14 deletions src/commands/config/show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -664,18 +664,18 @@ fn render_user_config(out: &mut String, has_system_config: bool) -> anyhow::Resu
// Read and display the file contents
let contents = std::fs::read_to_string(&config_path).context("Failed to read config file")?;

if contents.trim().is_empty() {
writeln!(out, "{}", hint_message("Empty file (using defaults)"))?;
return Ok(());
}

// Check for deprecations with emit_inline_warnings=false (silent mode)
// User config is global, not tied to any repository
let has_deprecations = match worktrunk::config::check_and_migrate(
// Deprecated patterns supersede the TOML dump below (their diff covers
// the file); a pending-default pin is additive, so the dump stays. An
// empty file still gets the pending-pin details — `wt config update`
// would rewrite it — just no dump.
let mut details_shown = false;
let skip_dump = match worktrunk::config::check_and_migrate(
&config_path,
&contents,
true,
"User config",
worktrunk::config::ConfigFileKind::User,
None,
false, // silent mode - we'll format the output ourselves
) {
Expand All @@ -684,7 +684,8 @@ fn render_user_config(out: &mut String, has_system_config: bool) -> anyhow::Resu
out.push_str(&worktrunk::config::format_deprecation_details(
&info, &contents,
));
true
details_shown = true;
info.has_deprecated_patterns()
} else {
false
}
Expand All @@ -695,6 +696,11 @@ fn render_user_config(out: &mut String, has_system_config: bool) -> anyhow::Resu
}
};

if contents.trim().is_empty() {
writeln!(out, "{}", hint_message("Empty file (using defaults)"))?;
return Ok(());
}

// Validate config (syntax + schema) and warn if invalid
if let Err(e) = toml::from_str::<UserConfig>(&contents) {
// Use gutter for error details to avoid markup interpretation of user content
Expand All @@ -706,7 +712,11 @@ fn render_user_config(out: &mut String, has_system_config: bool) -> anyhow::Resu

// Display TOML with syntax highlighting (gutter at column 0).
// Skip when deprecations were shown — the proposed diff already covers it.
if !has_deprecations {
if !skip_dump {
if details_shown {
// Pending-pin details above end in their diff; separate phases.
out.push('\n');
}
writeln!(out, "{}", format_toml(&contents))?;
}

Expand Down Expand Up @@ -835,13 +845,18 @@ fn render_project_config(out: &mut String) -> anyhow::Result<()> {
}

// Check for deprecations with emit_inline_warnings=false (silent mode)
// Only write migration file in main worktree, not linked worktrees
// Only write migration file in main worktree, not linked worktrees.
// Deprecated patterns supersede the TOML dump below (their diff covers
// the file); a pending-default pin would be additive, so the dump stays —
// no pending-default rule targets project config today, but the shape
// mirrors render_user_config so the two stay interchangeable.
let is_main_worktree = !repo.current_worktree().is_linked().unwrap_or(true);
let has_deprecations = match worktrunk::config::check_and_migrate(
let mut details_shown = false;
let skip_dump = match worktrunk::config::check_and_migrate(
&config_path,
&contents,
is_main_worktree,
"Project config",
worktrunk::config::ConfigFileKind::Project,
Some(&repo),
false, // silent mode - we'll format the output ourselves
) {
Expand All @@ -850,7 +865,8 @@ fn render_project_config(out: &mut String) -> anyhow::Result<()> {
out.push_str(&worktrunk::config::format_deprecation_details(
&info, &contents,
));
true
details_shown = true;
info.has_deprecated_patterns()
} else {
false
}
Expand All @@ -872,7 +888,11 @@ fn render_project_config(out: &mut String) -> anyhow::Result<()> {

// Display TOML with syntax highlighting (gutter at column 0).
// Skip when deprecations were shown — the proposed diff already covers it.
if !has_deprecations {
if !skip_dump {
if details_shown {
// Pending-pin details above end in their diff; separate phases.
out.push('\n');
}
writeln!(out, "{}", format_toml(&contents))?;
}

Expand Down
16 changes: 8 additions & 8 deletions src/commands/config/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use std::path::PathBuf;
use anyhow::Context;
use color_print::cformat;
use worktrunk::config::{
DeprecationInfo, DeprecationKind, compute_migrated_content, config_path,
ConfigFileKind, DeprecationInfo, DeprecationKind, compute_migrated_content, config_path,
copy_approved_commands_to_approvals_file, format_deprecation_warnings, format_migration_diff,
};
use worktrunk::git::Repository;
Expand Down Expand Up @@ -65,7 +65,7 @@ pub fn handle_config_update(yes: bool, print: bool) -> anyhow::Result<()> {
}
println!(
"# {} ({})",
candidate.info.label,
candidate.info.label(),
candidate.config_path.display()
);
}
Expand Down Expand Up @@ -112,10 +112,10 @@ pub fn handle_config_update(yes: bool, print: bool) -> anyhow::Result<()> {
}

std::fs::write(&candidate.config_path, &candidate.migrated)
.with_context(|| format!("Failed to update {}", candidate.info.label))?;
.with_context(|| format!("Failed to update {}", candidate.info.label()))?;
eprintln!(
"{}",
success_message(format!("Updated {}", candidate.info.label.to_lowercase()))
success_message(format!("Updated {}", candidate.info.label().to_lowercase()))
);
}

Expand Down Expand Up @@ -159,7 +159,7 @@ fn check_user_config() -> anyhow::Result<Option<UpdateCandidate>> {
&config_path,
&original,
true, // warn_and_migrate — user config always actionable
"User config",
ConfigFileKind::User,
None, // no repo context for user config
false, // emit_inline_warnings — we render the diff ourselves
)?;
Expand All @@ -168,7 +168,7 @@ fn check_user_config() -> anyhow::Result<Option<UpdateCandidate>> {
return Ok(None);
};

let migrated = compute_migrated_content(&original);
let migrated = compute_migrated_content(&original, ConfigFileKind::User);
Ok(Some(UpdateCandidate {
config_path,
original,
Expand Down Expand Up @@ -200,7 +200,7 @@ fn check_project_config() -> anyhow::Result<Option<UpdateCandidate>> {
&config_path,
&original,
!is_linked, // only actionable from main worktree
"Project config",
ConfigFileKind::Project,
Some(&repo),
false,
)?;
Expand All @@ -216,7 +216,7 @@ fn check_project_config() -> anyhow::Result<Option<UpdateCandidate>> {
return Ok(None);
}

let migrated = compute_migrated_content(&original);
let migrated = compute_migrated_content(&original, ConfigFileKind::Project);
Ok(Some(UpdateCandidate {
config_path,
original,
Expand Down
31 changes: 28 additions & 3 deletions src/commands/list/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,18 @@ pub(crate) fn print_json<T: serde::Serialize>(value: &T) -> anyhow::Result<()> {
/// type error in the same key (warn and degrade, never brick a command).
/// Both messages honor warning suppression — on the statusline, stderr
/// would corrupt the consumer's prompt, and the same user sees the nag on
/// their next interactive run. The nag names both settings so the fix is
/// copyable from the warning itself.
/// their next interactive run.
///
/// The unset state is the `PendingDefault` row in `DEPRECATION_RULES`, but
/// its warning fires here rather than at config load: the setting only
/// matters to JSON consumers, so a load-time warning would nag every command
/// for every user without the key. `wt config update` pins the current
/// `json-schema = 1` (the behavior-preserving choice; adopting schema 2 is a
/// deliberate manual edit), so the nag's hint offers that command exactly
/// when running it would write the pin — decided by the same detection
/// update runs, so a missing, unreadable, or malformed user config falls
/// back to naming the manual setting instead (migration plan in
/// design/list-json-v2.md, reviewed in #3357).
pub(crate) fn resolve_json_schema(repo: &Repository) -> u8 {
use std::sync::Once;

Expand Down Expand Up @@ -241,10 +251,25 @@ pub(crate) fn resolve_json_schema(repo: &Repository) -> u8 {
"JSON output is schema 1; a future release switches the default to schema 2"
)
);
let update_would_pin = worktrunk::config::config_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.is_some_and(|content| {
worktrunk::config::detect_deprecations(
&content,
worktrunk::config::ConfigFileKind::User,
)
.iter()
.any(|k| matches!(k, worktrunk::config::DeprecationKind::JsonSchemaUnset))
});
let keep = if update_would_pin {
cformat!("run <underline>wt config update</>")
} else {
cformat!("<underline>json-schema = 1</>")
};
eprintln!(
"{}",
hint_message(cformat!(
"To keep this format set <underline>[list] json-schema = 1</>; to adopt the new one, <underline>json-schema = 2</>"
"To adopt the new schema set <underline>[list] json-schema = 2</>; to keep this format, {keep}"
))
);
});
Expand Down
Loading
Loading