From 5aae29999d91f359effdef8b345e6e4b7583a90c Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sun, 26 Jul 2026 13:07:42 -0700 Subject: [PATCH] feat(config): deprecate list.task-timeout-ms The per-task timeout killed any git command that outlived the budget, on every collect worker, through a thread-local that `Cmd::run` consulted on each invocation and clamped an explicit `.timeout()` against. Progressive rendering removed the reason for it: `wt list` and the picker paint from local data and stream results in behind the frame, so no single git command can hold up the first paint. What it bounded instead was completion, which `[list] timeout-ms` already bounds directly, and the drain falls back to a hardcoded 120s when that is unset. A config carrying the key loads and warns; `wt config update` strips it. Sibling keys, including `timeout-ms`, are untouched. The env and `--config-set` layers migrate through the same rule, silently, as they have no file to materialize. With the thread-local gone, `Cmd::run` reads only the command's own `self.timeout`, so every explicit `.timeout()` caller (remote detection, fsmonitor teardown, the version check) keeps its bound unclamped. Co-Authored-By: Claude Opus 5 (1M context) --- dev/config.example.toml | 3 +- docs/content/config.md | 3 +- .../skills/worktrunk/reference/config.md | 3 +- skills/worktrunk/reference/config.md | 3 +- src/cli/mod.rs | 3 +- src/commands/list/collect/mod.rs | 27 +--- src/commands/picker/mod.rs | 9 -- src/config/deprecation.rs | 140 ++++++++++++++++++ src/config/user/sections.rs | 16 -- src/config/user/tests.rs | 15 +- src/shell_exec.rs | 109 +------------- tests/integration_tests/list_config.rs | 80 ---------- ...ation_tests__help__help_config_create.snap | 4 +- ...gration_tests__help__help_config_long.snap | 4 +- 14 files changed, 157 insertions(+), 262 deletions(-) diff --git a/dev/config.example.toml b/dev/config.example.toml index b22a62a251..3ec1732019 100644 --- a/dev/config.example.toml +++ b/dev/config.example.toml @@ -103,8 +103,7 @@ # # columns = ["branch", "status", "ci", "path"] # Columns to show, in order — built-ins or custom headers (omit for the default set) # -# task-timeout-ms = 0 # Kill individual git commands after N ms; 0 disables -# timeout-ms = 0 # Wall-clock budget for the entire collect phase; 0 disables +# timeout-ms = 0 # Wall-clock budget for the entire collect phase; 0 disables # # `columns` selects and orders the columns to render; omit it for the default set. # It is meant to drive a per-invocation alias (https://worktrunk.dev/extending/#aliases) diff --git a/docs/content/config.md b/docs/content/config.md index 4aaa63c298..3184b2175f 100644 --- a/docs/content/config.md +++ b/docs/content/config.md @@ -190,8 +190,7 @@ json-schema = 2 # JSON output schema: 2 (envelope) or 1 (bare array, the curr columns = ["branch", "status", "ci", "path"] # Columns to show, in order — built-ins or custom headers (omit for the default set) -task-timeout-ms = 0 # Kill individual git commands after N ms; 0 disables -timeout-ms = 0 # Wall-clock budget for the entire collect phase; 0 disables +timeout-ms = 0 # Wall-clock budget for the entire collect phase; 0 disables ``` `columns` selects and orders the columns to render; omit it for the default set. diff --git a/plugins/worktrunk/skills/worktrunk/reference/config.md b/plugins/worktrunk/skills/worktrunk/reference/config.md index 5116d8a0f0..aa83b13a81 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/config.md +++ b/plugins/worktrunk/skills/worktrunk/reference/config.md @@ -189,8 +189,7 @@ json-schema = 2 # JSON output schema: 2 (envelope) or 1 (bare array, the curr columns = ["branch", "status", "ci", "path"] # Columns to show, in order — built-ins or custom headers (omit for the default set) -task-timeout-ms = 0 # Kill individual git commands after N ms; 0 disables -timeout-ms = 0 # Wall-clock budget for the entire collect phase; 0 disables +timeout-ms = 0 # Wall-clock budget for the entire collect phase; 0 disables ``` `columns` selects and orders the columns to render; omit it for the default set. diff --git a/skills/worktrunk/reference/config.md b/skills/worktrunk/reference/config.md index 5116d8a0f0..aa83b13a81 100644 --- a/skills/worktrunk/reference/config.md +++ b/skills/worktrunk/reference/config.md @@ -189,8 +189,7 @@ json-schema = 2 # JSON output schema: 2 (envelope) or 1 (bare array, the curr columns = ["branch", "status", "ci", "path"] # Columns to show, in order — built-ins or custom headers (omit for the default set) -task-timeout-ms = 0 # Kill individual git commands after N ms; 0 disables -timeout-ms = 0 # Wall-clock budget for the entire collect phase; 0 disables +timeout-ms = 0 # Wall-clock budget for the entire collect phase; 0 disables ``` `columns` selects and orders the columns to render; omit it for the default set. diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0daf30c4ac..127c5610b3 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2055,8 +2055,7 @@ json-schema = 2 # JSON output schema: 2 (envelope) or 1 (bare array, the curr columns = ["branch", "status", "ci", "path"] # Columns to show, in order — built-ins or custom headers (omit for the default set) -task-timeout-ms = 0 # Kill individual git commands after N ms; 0 disables -timeout-ms = 0 # Wall-clock budget for the entire collect phase; 0 disables +timeout-ms = 0 # Wall-clock budget for the entire collect phase; 0 disables ``` `columns` selects and orders the columns to render; omit it for the default set. diff --git a/src/commands/list/collect/mod.rs b/src/commands/list/collect/mod.rs index 04701f5426..32ae1a614a 100644 --- a/src/commands/list/collect/mod.rs +++ b/src/commands/list/collect/mod.rs @@ -597,7 +597,6 @@ pub enum ShowConfig { Resolved { show_branches: bool, show_remotes: bool, - command_timeout: Option, /// Wall-clock deadline for the collect phase. `None` uses the default /// [`DRAIN_TIMEOUT`](results::DRAIN_TIMEOUT) and shows a warning on timeout. collect_deadline: Option, @@ -612,7 +611,7 @@ pub enum ShowConfig { }, /// Raw CLI flags; config resolution deferred to collect's parallel phase /// so project_identifier runs concurrently with other git operations. - /// Timeouts are resolved from config internally. + /// The collect deadline is resolved from config internally. DeferredToParallel { cli_branches: bool, cli_remotes: bool, @@ -908,7 +907,6 @@ pub fn collect( show_branches, show_remotes, show_full, - command_timeout, collect_deadline, list_width, progressive_handler, @@ -917,7 +915,6 @@ pub fn collect( ShowConfig::Resolved { show_branches, show_remotes, - command_timeout, collect_deadline, list_width, progressive_handler, @@ -930,7 +927,6 @@ pub fn collect( // opts out of the untracked-inclusive working diff — the last tuple // field — so the two `show_full`-shaped values aren't the same bucket. true, - command_timeout, collect_deadline, list_width, progressive_handler, @@ -945,19 +941,16 @@ pub fn collect( let show_branches = cli_branches || config.list.branches(); let show_remotes = cli_remotes || config.list.remotes(); let show_full = cli_full || config.list.full(); - // Resolve timeouts from merged config (--full disables both) - let (command_timeout, collect_deadline) = if show_full { - (None, None) + // Resolve the collect budget from merged config (--full disables it) + let collect_deadline = if show_full { + None } else { - let task_timeout = config.list.task_timeout(); - let deadline = config.list.timeout().map(|d| std::time::Instant::now() + d); - (task_timeout, deadline) + config.list.timeout().map(|d| std::time::Instant::now() + d) }; ( show_branches, show_remotes, show_full, - command_timeout, collect_deadline, None, None, @@ -1592,15 +1585,6 @@ pub fn collect( if let Some(snap_arc) = snap.as_ref() { let snap_for_primer = std::sync::Arc::clone(snap_arc); s.spawn(move |_| { - // Honor `list.task-timeout-ms` for the primer's git - // commands — these are the same `for-each-ref - // %(ahead-behind)` / `rev-list` invocations that - // used to run inside `UpstreamTask`, where the - // worker loop sets the per-thread timeout. Without - // this, `wt list` could sit at the skeleton on a - // pathologically slow git until the (untimed) batch - // returned. - worktrunk::shell_exec::set_command_timeout(command_timeout); let all_locals = snap_for_primer.local_branches(); let filtered_locals: Vec; let candidates: &[LocalBranch] = if show_branches { @@ -1780,7 +1764,6 @@ pub fn collect( // when the picker is open. See `COLLECT_POOL`. COLLECT_POOL.install(|| { all_work_items.into_par_iter().for_each(|item| { - worktrunk::shell_exec::set_command_timeout(command_timeout); let result = item.execute(); let _ = tx_worker.send(result); }); diff --git a/src/commands/picker/mod.rs b/src/commands/picker/mod.rs index 9f220e57b7..b5b4a93ee2 100644 --- a/src/commands/picker/mod.rs +++ b/src/commands/picker/mod.rs @@ -1377,7 +1377,6 @@ struct PipelineFactory { header_flash: Arc, preview_dims: (usize, usize), skim_list_width: usize, - command_timeout: Option, llm_command: Option, summary_hint: Option, show_branches: bool, @@ -1507,7 +1506,6 @@ impl PipelineFactory { let bg_repo = spawn_repo.clone(); let show_branches = self.show_branches; let show_remotes = self.show_remotes; - let command_timeout = self.command_timeout; let skim_list_width = self.skim_list_width; let collect_handle = std::thread::Builder::new() .name("picker-collect".into()) @@ -1517,7 +1515,6 @@ impl PipelineFactory { collect::ShowConfig::Resolved { show_branches, show_remotes, - command_timeout, collect_deadline: None, list_width: Some(skim_list_width), progressive_handler: Some(bg_handler), @@ -1726,10 +1723,6 @@ pub fn handle_picker( // the same live status. `--prs` rows carry their own number from the explicit // `--prs` forge call. - // Per-task command timeout (bounds any single git invocation) from - // shared `[list]` config. Still applies in progressive mode. - let command_timeout = config.list.task_timeout(); - // Progressive rendering means the picker never blocks waiting for // collect — so there's no UI-freeze budget to bound. The drain runs // until its results channel closes or the fallback DRAIN_TIMEOUT @@ -1872,7 +1865,6 @@ summary = true header_flash: Arc::new(items::HeaderFlash::default()), preview_dims, skim_list_width, - command_timeout, llm_command, summary_hint, show_branches, @@ -3158,7 +3150,6 @@ pub mod tests { header_flash: Arc::new(super::items::HeaderFlash::default()), preview_dims: (80, 24), skim_list_width: 80, - command_timeout: None, llm_command: None, summary_hint: None, show_branches: false, diff --git a/src/config/deprecation.rs b/src/config/deprecation.rs index 8e2ff3d75f..99da933009 100644 --- a/src/config/deprecation.rs +++ b/src/config/deprecation.rs @@ -484,6 +484,9 @@ pub enum DeprecationKind { NoCd, /// `timeout-ms` under `[switch.picker]` (removed — picker renders progressively). SwitchPickerTimeout, + /// `task-timeout-ms` under `[list]` (removed — `[list] timeout-ms` bounds + /// the collect phase). + ListTaskTimeout, /// `[list] json-schema` unset while the default is scheduled to switch to /// schema 2 — `wt config update` writes the upcoming `json-schema = 2`. /// Warns at the JSON-emitting surface (`resolve_json_schema`), not at @@ -647,6 +650,17 @@ const DEPRECATION_RULES: &[DeprecationRule] = &[ Vec::new() } }), + // list.task-timeout-ms — removed; `[list] timeout-ms` bounds the collect + // phase, and the drain has its own fallback bound. + DeprecationRule::Structural(|doc| { + if for_each_config_table_mut(doc, |_, table| { + remove_section_key_in(table, "list", "task-timeout-ms") + }) { + vec![DeprecationKind::ListTaskTimeout] + } else { + Vec::new() + } + }), // [list] json-schema unset → write json-schema = 2, adopting the default // ahead of the release that switches it. User config only: the key isn't // valid in project config, and the top-level write covers every repo @@ -1203,6 +1217,21 @@ fn remove_switch_picker_timeout_in(table: &mut toml_edit::Table) -> bool { } } +/// Remove `key` from a top-level `section` in a table (top-level or project). +/// An emptied section is left in place — it round-trips harmlessly. +/// +/// A section can be written as a section table (`[list]`) or inline +/// (`list = { … }`); `toml_edit` surfaces these as different node types, so +/// each shape gets its own branch — matching the inline-aware `no-cd`/`no-ff` +/// rules and the two-level [`remove_switch_picker_timeout_in`]. +fn remove_section_key_in(table: &mut toml_edit::Table, section: &str, key: &str) -> bool { + match table.get_mut(section) { + Some(toml_edit::Item::Table(t)) => t.remove(key).is_some(), + Some(toml_edit::Item::Value(toml_edit::Value::InlineTable(it))) => it.remove(key).is_some(), + _ => false, + } +} + fn migrate_content_from_doc(content: &str, mut doc: toml_edit::DocumentMut) -> String { if migrate_content_doc(&mut doc) { doc.to_string() @@ -1677,6 +1706,15 @@ fn format_warning_lines<'a>( )) ); } + DeprecationKind::ListTaskTimeout => { + let _ = writeln!( + out, + "{}", + warning_message(cformat!( + "{label}: list.task-timeout-ms is no longer used — list.timeout-ms bounds the collect phase" + )) + ); + } DeprecationKind::JsonSchemaUnset => { let _ = writeln!( out, @@ -3265,6 +3303,10 @@ json-schema = 1 // timeout-ms under an inline `switch` is stripped like the section form "switch = { picker = { timeout-ms = 500 } }\n", "[select]\ntimeout-ms = 500\n", + // list.task-timeout-ms, section and inline forms (project-scoped so + // the appended `[list]` below isn't a duplicate table) + "[projects.\"github.com/u/r\".list]\ntask-timeout-ms = 500\n", + "[projects.\"github.com/u/r\"]\nlist = { task-timeout-ms = 500 }\n", "worktree-path = \"../{{ repo_root }}.{{ branch }}\"\n", "[projects.\"github.com/u/r\"]\napproved-commands = [\"npm test\"]\n", ]; @@ -4302,6 +4344,104 @@ pager = "delta" ); } + #[test] + fn test_detect_list_task_timeout_top_level() { + let content = r#" +[list] +branches = true +task-timeout-ms = 500 +"#; + let deprecations = detect_deprecations(content, ConfigFileKind::User); + assert!(has_kind(&deprecations, |k| matches!( + k, + DeprecationKind::ListTaskTimeout + ))); + } + + #[test] + fn test_detect_list_task_timeout_project_level() { + let content = r#" +[projects."github.com/user/repo".list] +task-timeout-ms = 300 +"#; + let deprecations = detect_deprecations(content, ConfigFileKind::User); + assert!(has_kind(&deprecations, |k| matches!( + k, + DeprecationKind::ListTaskTimeout + ))); + } + + #[test] + fn test_detect_list_task_timeout_absent() { + let content = r#" +[list] +timeout-ms = 500 +"#; + let deprecations = detect_deprecations(content, ConfigFileKind::User); + assert!(!has_kind(&deprecations, |k| matches!( + k, + DeprecationKind::ListTaskTimeout + ))); + } + + #[test] + fn test_migrate_list_task_timeout_removes_key() { + let content = r#" +[list] +branches = true +task-timeout-ms = 500 +timeout-ms = 2000 +"#; + let result = migrate_content(content); + assert!( + !result.contains("task-timeout-ms"), + "Should strip task-timeout-ms: {result}" + ); + assert!( + result.contains("timeout-ms = 2000") && result.contains("branches"), + "Should preserve sibling keys: {result}" + ); + } + + #[test] + fn test_migrate_list_task_timeout_inline_table() { + let content = r#" +list = { branches = true, task-timeout-ms = 500 } +"#; + let result = migrate_content(content); + assert!(!result.contains("task-timeout-ms")); + assert!(result.contains("branches")); + } + + #[test] + fn test_migrate_list_task_timeout_noop_when_absent() { + let content = r#" +[list] +timeout-ms = 500 +"#; + let result = migrate_content(content); + assert_eq!(result, content); + } + + #[test] + fn test_format_deprecation_warnings_list_task_timeout() { + let info = DeprecationInfo { + config_path: std::path::PathBuf::from("/tmp/test-config.toml"), + deprecations: vec![DeprecationKind::ListTaskTimeout], + kind: ConfigFileKind::User, + main_worktree_path: None, + }; + let output = format_deprecation_warnings(&info); + assert!( + output.contains("list.task-timeout-ms"), + "Should mention the field: {output}" + ); + assert!( + output.contains("list.timeout-ms"), + "Should point at the surviving budget: {output}" + ); + } + // ==================== negated bool format + migration tests ==================== #[test] diff --git a/src/config/user/sections.rs b/src/config/user/sections.rs index b8b072c11e..a3a7c9c8ed 100644 --- a/src/config/user/sections.rs +++ b/src/config/user/sections.rs @@ -201,13 +201,6 @@ pub struct ListConfig { #[serde(rename = "json-schema", skip_serializing_if = "Option::is_none")] pub json_schema: Option, - /// Per-task timeout in milliseconds. - /// Kills individual git commands that exceed this duration. Applies to both - /// `wt list` and the `wt switch` picker. Set to 0 to explicitly disable - /// (useful to override a global setting). Disabled when --full is used. - #[serde(rename = "task-timeout-ms", skip_serializing_if = "Option::is_none")] - pub task_timeout_ms: Option, - /// Wall-clock budget for the entire collect phase in milliseconds. /// Tasks that complete within the budget contribute data; tasks still /// running when it expires are abandoned silently. Set to 0 to disable. @@ -265,14 +258,6 @@ impl ListConfig { self.summary.unwrap_or(false) } - /// Per-task command timeout (default: None — no per-command timeout). - /// Returns `None` when disabled (task_timeout_ms = 0 or unset). - pub fn task_timeout(&self) -> Option { - self.task_timeout_ms - .filter(|&ms| ms > 0) - .map(std::time::Duration::from_millis) - } - /// Wall-clock budget for the collect phase (default: None — no budget). /// Returns `None` when disabled (timeout_ms = 0 or unset). pub fn timeout(&self) -> Option { @@ -309,7 +294,6 @@ impl Merge for ListConfig { remotes: other.remotes.or(self.remotes), summary: other.summary.or(self.summary), json_schema: other.json_schema.or(self.json_schema), - task_timeout_ms: other.task_timeout_ms.or(self.task_timeout_ms), timeout_ms: other.timeout_ms.or(self.timeout_ms), columns, custom_columns, diff --git a/src/config/user/tests.rs b/src/config/user/tests.rs index 8a58c59fa3..75f2378764 100644 --- a/src/config/user/tests.rs +++ b/src/config/user/tests.rs @@ -277,7 +277,6 @@ fn test_list_config_serde() { remotes: None, summary: None, json_schema: None, - task_timeout_ms: Some(500), timeout_ms: None, columns: vec!["branch".into(), "ci".into(), "path".into()], custom_columns: Default::default(), @@ -288,7 +287,6 @@ fn test_list_config_serde() { assert_eq!(parsed.branches, Some(false)); assert_eq!(parsed.remotes, None); assert_eq!(parsed.summary, None); - assert_eq!(parsed.task_timeout_ms, Some(500)); assert_eq!(parsed.timeout_ms, None); assert_eq!(parsed.columns, vec!["branch", "ci", "path"]); } @@ -630,7 +628,6 @@ fn test_merge_list_config() { remotes: None, summary: Some(true), json_schema: None, - task_timeout_ms: Some(1000), timeout_ms: Some(2000), columns: vec!["branch".into(), "ci".into()], custom_columns: Default::default(), @@ -641,9 +638,8 @@ fn test_merge_list_config() { remotes: Some(true), // Should override (base was None) summary: None, // Should fall back to base json_schema: None, - task_timeout_ms: None, // Should fall back to base - timeout_ms: None, // Should fall back to base - columns: Vec::new(), // Empty → fall back to base + timeout_ms: None, // Should fall back to base + columns: Vec::new(), // Empty → fall back to base custom_columns: Default::default(), }; @@ -652,7 +648,6 @@ fn test_merge_list_config() { assert_eq!(merged.branches, Some(true)); // From override assert_eq!(merged.remotes, Some(true)); // From override assert_eq!(merged.summary, Some(true)); // From base - assert_eq!(merged.task_timeout_ms, Some(1000)); // From base assert_eq!(merged.timeout_ms, Some(2000)); // From base assert_eq!(merged.columns, vec!["branch", "ci"]); // From base (override empty) } @@ -1092,7 +1087,6 @@ fn test_list_config_accessor_methods_defaults() { assert!(!config.full()); assert!(!config.branches()); assert!(!config.remotes()); - assert!(config.task_timeout().is_none()); assert!(config.timeout().is_none()); } @@ -1104,7 +1098,6 @@ fn test_list_config_accessor_methods_with_values() { remotes: Some(false), summary: Some(true), json_schema: None, - task_timeout_ms: Some(5000), timeout_ms: Some(3000), columns: Vec::new(), custom_columns: Default::default(), @@ -1113,10 +1106,6 @@ fn test_list_config_accessor_methods_with_values() { assert!(config.branches()); assert!(!config.remotes()); assert!(config.summary()); - assert_eq!( - config.task_timeout(), - Some(std::time::Duration::from_millis(5000)) - ); assert_eq!( config.timeout(), Some(std::time::Duration::from_millis(3000)) diff --git a/src/shell_exec.rs b/src/shell_exec.rs index 5ed36d356b..090c2865fe 100644 --- a/src/shell_exec.rs +++ b/src/shell_exec.rs @@ -666,32 +666,6 @@ pub fn shell_escape_for(mode: ShellEscapeMode, s: &str) -> String { } } -// ============================================================================ -// Thread-Local Command Timeout -// ============================================================================ - -use std::cell::Cell; - -thread_local! { - /// Per-thread ceiling for commands run via `run()`, set from - /// `[list] task-timeout-ms` on each Rayon collect worker so a pathologically - /// slow git can't hold up the first paint of `wt list` or the `wt switch` - /// picker. Unset by default. See [`set_command_timeout`]. - static COMMAND_TIMEOUT: Cell> = const { Cell::new(None) }; -} - -/// Set the command timeout for the current thread. -/// -/// When set, every command executed via `run()` on this thread is killed if it -/// exceeds the specified duration; a command carrying its own [`Cmd::timeout`] -/// is bound by whichever of the two is tighter. `None` disables it. -/// -/// This is typically called at the start of a Rayon worker task to apply timeout -/// to all git operations within that task. -pub fn set_command_timeout(timeout: Option) { - COMMAND_TIMEOUT.with(|t| t.set(timeout)); -} - /// Maximum lines of the bounded subprocess preview per stream. Exceeded /// content is elided with a `… (N more lines, M bytes elided)` marker; the /// full output is still written to `subprocess.log` via @@ -1318,9 +1292,6 @@ impl Cmd { /// Set a timeout for command execution (only applies to `.run()`). /// - /// Under a thread-local timeout ([`set_command_timeout`]) the tighter of the - /// two bounds the command, so this cannot widen a caller's budget. - /// /// Note: Timeout is not supported by `.stream()` since streaming commands /// are interactive and should not be time-limited. /// @@ -1529,14 +1500,6 @@ impl Cmd { let mut cmd = self.direct_command(); self.apply_common_settings(&mut cmd); - // Both timeouts are ceilings rather than a precedence chain: one bounds - // this command, the other bounds every command on the thread, so a - // command carrying both is bound by the tighter of the two. - let effective_timeout = match (self.timeout, COMMAND_TIMEOUT.with(|t| t.get())) { - (Some(own), Some(per_thread)) => Some(own.min(per_thread)), - (own, per_thread) => own.or(per_thread), - }; - // Execute with or without stdin. Every branch produces a single // `Result` so spawn/write failures resolve the trace through // `record_captured` rather than `?`-ing past it (which would leave the @@ -1566,7 +1529,7 @@ impl Cmd { } Err(e) => Err(e), } - } else if let Some(timeout_duration) = effective_timeout { + } else if let Some(timeout_duration) = self.timeout { // Timeout handling uses the existing impl run_with_timeout_impl(&mut cmd, timeout_duration) } else { @@ -2577,76 +2540,6 @@ mod tests { assert!(String::from_utf8_lossy(&output.stdout).contains("hello from stdin")); } - #[test] - fn test_thread_local_timeout_setting() { - // Initially no timeout (or whatever was set by previous test) - let initial = COMMAND_TIMEOUT.with(|t| t.get()); - - // Set a timeout - set_command_timeout(Some(Duration::from_millis(100))); - let after_set = COMMAND_TIMEOUT.with(|t| t.get()); - assert_eq!(after_set, Some(Duration::from_millis(100))); - - // Clear the timeout - set_command_timeout(initial); - let after_clear = COMMAND_TIMEOUT.with(|t| t.get()); - assert_eq!(after_clear, initial); - } - - #[test] - fn test_cmd_uses_thread_local_timeout() { - // Set no timeout (ensure fast completion) - set_command_timeout(None); - - let result = Cmd::new("echo").arg("thread local test").run(); - assert!(result.is_ok()); - - // Clean up - set_command_timeout(None); - } - - #[test] - #[cfg(unix)] - fn test_cmd_thread_local_timeout_kills_slow_command() { - // Set a short thread-local timeout - set_command_timeout(Some(Duration::from_millis(50))); - - // Command that would take too long - let result = Cmd::new("sleep").arg("10").run(); - - // Should be killed by the thread-local timeout - assert!(result.is_err()); - assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::TimedOut); - - // Clean up - set_command_timeout(None); - } - - #[test] - #[cfg(unix)] - fn test_cmd_timeout_cannot_widen_the_thread_local_budget() { - // `wt list` gives each collect task a budget via the thread-local, and a - // command inside one can carry a longer `.timeout()` of its own (the - // 10s bound on remote default-branch detection). The tighter budget has - // to win, or one command spends the whole task's allowance. - set_command_timeout(Some(Duration::from_millis(50))); - - let start = std::time::Instant::now(); - let result = Cmd::new("sleep") - .arg("10") - .timeout(Duration::from_secs(30)) - .run(); - let elapsed = start.elapsed(); - - set_command_timeout(None); - - assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::TimedOut); - assert!( - elapsed < Duration::from_secs(5), - "the explicit timeout widened the thread-local budget: {elapsed:?}" - ); - } - // ======================================================================== // Cmd::stream() tests // ======================================================================== diff --git a/tests/integration_tests/list_config.rs b/tests/integration_tests/list_config.rs index a00cfa184b..cd190ad390 100644 --- a/tests/integration_tests/list_config.rs +++ b/tests/integration_tests/list_config.rs @@ -287,59 +287,6 @@ url = "http://localhost:8080/{{ branch }}" assert_eq!(url, "http://localhost:8080/main"); } -/// Test that task-timeout-ms config option is parsed correctly. -/// We use a very short timeout (1ms) to trigger timeouts. -#[rstest] -fn test_list_config_timeout_triggers_timeouts(repo: TestRepo) { - fs::write( - repo.test_config_path(), - r#"[list] -task-timeout-ms = 1 -"#, - ) - .unwrap(); - - let mut cmd = wt_command(); - repo.configure_wt_cmd(&mut cmd); - cmd.arg("list").current_dir(repo.root_path()); - - let output = cmd.output().unwrap(); - let stderr = String::from_utf8_lossy(&output.stderr); - - // With a 1ms timeout, some tasks should time out - // The footer should show the timeout count - assert!( - stderr.contains("timed out") || output.status.success(), - "Expected either timeout message in footer or success (if git was fast enough)" - ); -} - -/// Test that task-timeout-ms = 0 explicitly disables timeout. -#[rstest] -fn test_list_config_timeout_zero_means_no_timeout(repo: TestRepo) { - fs::write( - repo.test_config_path(), - r#"[list] -task-timeout-ms = 0 -"#, - ) - .unwrap(); - - let mut cmd = wt_command(); - repo.configure_wt_cmd(&mut cmd); - cmd.arg("list").current_dir(repo.root_path()); - - let output = cmd.output().unwrap(); - let stderr = String::from_utf8_lossy(&output.stderr); - - // With task-timeout-ms = 0, there should be no timeout - assert!( - !stderr.contains("timed out"), - "Expected no timeout message with task-timeout-ms = 0, but got: {}", - stderr - ); -} - /// Regression: setting a typed env-var override (e.g. `WORKTRUNK__LIST__TIMEOUT_MS`) /// must not wipe unrelated fields in the same section. /// @@ -710,33 +657,6 @@ fn test_list_config_malformed_system_config_non_section_field(repo: TestRepo) { }); } -/// Test that --full disables the task timeout. -#[rstest] -fn test_list_config_timeout_disabled_with_full(repo: TestRepo) { - fs::write( - repo.test_config_path(), - r#"[list] -task-timeout-ms = 1 -"#, - ) - .unwrap(); - - let mut cmd = wt_command(); - repo.configure_wt_cmd(&mut cmd); - cmd.args(["list", "--full"]).current_dir(repo.root_path()); - - let output = cmd.output().unwrap(); - let stderr = String::from_utf8_lossy(&output.stderr); - - // With --full, the timeout is disabled so we shouldn't see timeout messages - // (though tasks may still fail for other reasons) - assert!( - !stderr.contains("timed out"), - "Expected no timeout message with --full flag, but got: {}", - stderr - ); -} - #[rstest] fn test_list_custom_columns(repo: TestRepo) { // A vars-backed column (only feature-a has the key; other rows render diff --git a/tests/snapshots/integration__integration_tests__help__help_config_create.snap b/tests/snapshots/integration__integration_tests__help__help_config_create.snap index 258786968a..f2d749fb92 100644 --- a/tests/snapshots/integration__integration_tests__help__help_config_create.snap +++ b/tests/snapshots/integration__integration_tests__help__help_config_create.snap @@ -9,6 +9,7 @@ info: env: CLICOLOR_FORCE: "1" COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file LANG: C LC_ALL: C LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" @@ -170,8 +171,7 @@ Creates ~/.config/worktrunk/config.toml with the following content:   #   # columns = ["branch", "status", "ci", "path"] # Columns to show, in order — built-ins or custom headers (omit for the default set)   # -  # task-timeout-ms = 0 # Kill individual git commands after N ms; 0 disables -  # timeout-ms = 0 # Wall-clock budget for the entire collect phase; 0 disables +  # timeout-ms = 0 # Wall-clock budget for the entire collect phase; 0 disables   #   # `columns` selects and orders the columns to render; omit it for the default set.   # It is meant to drive a per-invocation alias (https://worktrunk.dev/extending/#aliases) diff --git a/tests/snapshots/integration__integration_tests__help__help_config_long.snap b/tests/snapshots/integration__integration_tests__help__help_config_long.snap index 5bd6917840..6ac5b87eee 100644 --- a/tests/snapshots/integration__integration_tests__help__help_config_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_config_long.snap @@ -8,6 +8,7 @@ info: env: CLICOLOR_FORCE: "1" COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file LANG: C LC_ALL: C LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" @@ -218,8 +219,7 @@ Persistent flag values for wt list. Override on command line as needed.     columns = ["branch", "status", "ci", "path"] # Columns to show, in order — built-ins or custom headers (omit for the default set)   -  task-timeout-ms = 0 # Kill individual git commands after N ms; 0 disables -  timeout-ms = 0 # Wall-clock budget for the entire collect phase; 0 disables +  timeout-ms = 0 # Wall-clock budget for the entire collect phase; 0 disables columns selects and orders the columns to render; omit it for the default set. It is meant to drive a per-invocation alias