Files
max-sixty__worktrunk/tests/integration_tests/statusline.rs
Maximilian Roos e1745db105 feat(list): abbreviate the table's SHA with git, not a fixed slice (#3676)
The Commit cell sliced `&head[..8]` while `--format=json`'s `short_sha`
carried git's `%h`, so one commit read `1b9f1d96` in the table and
`1b9f1d9` in JSON, and `core.abbrev` reached only the JSON. #3675 gave a
detached row's Branch cell the same slice, so the disagreement showed up
twice on one row.

`ListItem::short_sha` becomes the only abbreviation of `head` anywhere:
the Commit cell, a detached row's Branch cell, the statusline, and JSON
all render it. `abbreviated_head()` is gone.

## Column widths

`COMMIT_HASH_WIDTH = 8` is gone. The Commit column and the Branch
column's detached budget both measure the SHAs they will render, so
`core.abbrev = 12` no longer truncates mid-hash and the default 7 stops
reserving a column nothing fills — the freed character goes to Message.

## Latency

`collect()` folds `%h` onto the rows before layout instead of after the
skeleton. The batch carrying it already gates the skeleton for `%ct`
sort order, so this is a map lookup rather than new I/O, and both cells
are identity columns with no placeholder — they still paint in the first
frame.

Measured on a 40-worktree / 400-branch fixture: git subprocess counts
are identical (5 pre-skeleton, 108 for the full run). Pre-skeleton wall
time is unchanged; running both binaries in each order, the sign of the
difference follows run order rather than the binary (+1.5 ms with this
branch second, −0.5 ms with it first), so the residual sits inside
drift.

## Behavior change

Where the commit-details batch fails, the Commit cell is now empty
rather than a slice of a SHA git refused. Age and Message already report
that failure the same way, under the same warning, and two snapshots
show it. `render_text_cell` also stops styling empty text, so a blank
cell no longer emits an escape pair around nothing.

## Reading the diff

160 files, but the hand-written part is +91/−79 in `src/commands/list/`
plus a +71 test. The rest is generated. The docs mirrors and help
snapshots are symmetric. Of the snapshot lines, content is +799/−799 —
every changed line a 1-for-1 hash swap — while +1396 is insta `env:`
metadata refreshing on the 128 snapshots this happens to touch.

`test_list_abbreviated_sha_follows_git` pins the invariant: the table's
hash equals JSON's `short_sha` at git's default and at `core.abbrev =
12`, and a longer prefix is ruled out. It fails against the old fixed
slice.

> _This was written by Claude Code on behalf of max-sixty_
2026-07-30 18:33:30 -07:00

1136 lines
40 KiB
Rust
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Snapshot tests for `wt list statusline` command.
//!
//! Tests the statusline output for shell prompts and Claude Code integration.
use crate::common::{TestRepo, repo, wt_command};
use insta::assert_snapshot;
use rstest::rstest;
use serde_json::Value;
use std::io::Write;
use std::process::Stdio;
/// Run statusline command with optional JSON piped to stdin
fn run_statusline_from_dir(
repo: &TestRepo,
args: &[&str],
stdin_json: Option<&str>,
cwd: &std::path::Path,
) -> String {
let mut cmd = wt_command();
cmd.current_dir(cwd);
cmd.args(["list", "statusline"]);
cmd.args(args);
// Apply repo's git environment
repo.configure_wt_cmd(&mut cmd);
// Pin the timezone so any wall-clock segment (the claude-code statusline
// renders `chrono::Local`) is deterministic across machines — not only in
// the `run_statusline_with_locale` helper. `LC_ALL=C` is already pinned
// globally by the test harness.
cmd.env("TZ", "UTC");
if stdin_json.is_some() {
cmd.stdin(Stdio::piped());
}
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("failed to spawn command");
if let Some(json) = stdin_json {
// Take ownership of stdin so we can drop it after writing
let mut stdin = child.stdin.take().expect("failed to get stdin");
stdin
.write_all(json.as_bytes())
.expect("failed to write stdin");
// Explicitly close stdin by dropping it - this signals EOF to the child process.
// On Windows, not closing stdin can cause the child to hang waiting for more input.
drop(stdin);
}
let output = child.wait_with_output().expect("failed to wait for output");
// Statusline outputs to stdout in interactive mode
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
// Return whichever has content (stdout for interactive)
if !stdout.is_empty() {
stdout.to_string()
} else {
stderr.to_string()
}
}
fn run_statusline(repo: &TestRepo, args: &[&str], stdin_json: Option<&str>) -> String {
run_statusline_from_dir(repo, args, stdin_json, repo.root_path())
}
// --- Test Setup Helpers ---
fn add_uncommitted_changes(repo: &TestRepo) {
// Create uncommitted changes
std::fs::write(repo.root_path().join("modified.txt"), "modified content").unwrap();
}
fn add_commits_ahead(repo: &mut TestRepo) {
// Create feature branch with commits ahead
let feature_path = repo.add_worktree("feature");
// Add commits in the feature worktree
std::fs::write(feature_path.join("feature.txt"), "feature content").unwrap();
repo.git_command()
.args(["add", "."])
.current_dir(&feature_path)
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Feature commit 1"])
.current_dir(&feature_path)
.run()
.unwrap();
std::fs::write(feature_path.join("feature2.txt"), "more content").unwrap();
repo.git_command()
.args(["add", "."])
.current_dir(&feature_path)
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Feature commit 2"])
.current_dir(&feature_path)
.run()
.unwrap();
}
// --- Basic Tests ---
#[rstest]
fn test_statusline_basic(repo: TestRepo) {
let output = run_statusline(&repo, &[], None);
assert_snapshot!(output, @" main ^|");
}
#[rstest]
fn test_statusline_with_changes(repo: TestRepo) {
add_uncommitted_changes(&repo);
let output = run_statusline(&repo, &[], None);
assert_snapshot!(output, @" main ?^| @+1");
}
#[rstest]
fn test_statusline_ci_pr_number(repo: TestRepo) {
// Cached CI status with a PR number renders as the colored PR reference
super::list::mock_ci_status(&repo, "main", "passed", "pr", false, Some(3035));
let output = run_statusline(&repo, &[], None);
assert_snapshot!(output, @" main ^| #3035");
}
#[rstest]
fn test_statusline_commits_ahead(mut repo: TestRepo) {
add_commits_ahead(&mut repo);
// Run from the feature worktree to see commits ahead
let feature_path = repo.worktree_path("feature");
let output = run_statusline_from_dir(&repo, &[], None, feature_path);
assert_snapshot!(output, @" feature ↑ ↑2 ^+2");
}
/// `-C` selects the worktree, not the process cwd.
///
/// Every other test here sets the child's cwd, which reaches the worktree by a
/// different route: `-C` sets wt's discovery path without chdir'ing, so a
/// statusline built from `std::env::current_dir()` would describe the cwd's
/// worktree (`main`) and pass those tests while ignoring the flag.
#[rstest]
fn test_statusline_directory_flag(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
// cwd is the main worktree; only `-C` points at `feature`
let output = run_statusline_from_dir(
&repo,
&["-C", feature_path.to_str().unwrap()],
None,
repo.root_path(),
);
assert_snapshot!(output, @" feature _");
}
/// With no stdin JSON, claude-code mode falls back to the same worktree the
/// table format uses, so it honours `-C` too. A directory on stdin still wins:
/// it names the session's directory rather than the one wt was pointed at.
#[rstest]
fn test_statusline_claude_code_directory_flag(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let output = run_statusline_from_dir(
&repo,
&["--format=claude-code", "-C", feature_path.to_str().unwrap()],
None,
repo.root_path(),
);
claude_code_snapshot_settings().bind(|| {
assert_snapshot!(output, @" [PATH].feature _");
});
// stdin's directory outranks `-C`
let json = format!(
r#"{{"workspace": {{"current_dir": "{}"}}}}"#,
escape_path_for_json(repo.root_path())
);
let output = run_statusline_from_dir(
&repo,
&["--format=claude-code", "-C", feature_path.to_str().unwrap()],
Some(&json),
repo.root_path(),
);
claude_code_snapshot_settings().bind(|| {
assert_snapshot!(output, @" [PATH] main ^|");
});
}
/// A stdin directory in a *different* repository than the one wt discovered.
///
/// The worktree comes from stdin while `list_worktrees` comes from the
/// discovered repo, so the stdin directory matches none of them and only its
/// branch can be reported. That split is the known limit of taking the
/// directory from stdin: everything repo-wide still comes from wt's own
/// discovery path.
#[rstest]
fn test_statusline_claude_code_foreign_repo_on_stdin(repo: TestRepo) {
let foreign = repo.root_path().join("foreign");
std::fs::create_dir(&foreign).unwrap();
repo.git_command()
.args(["init", "--initial-branch=elsewhere"])
.current_dir(&foreign)
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "init", "--allow-empty"])
.current_dir(&foreign)
.run()
.unwrap();
let json = format!(
r#"{{"workspace": {{"current_dir": "{}"}}}}"#,
escape_path_for_json(&foreign)
);
let output = run_statusline(&repo, &["--format=claude-code"], Some(&json));
let mut settings = claude_code_snapshot_settings();
// A nested directory renders home-relative, which the platform-specific
// `[PATH]` filters don't reach; the branch is this test's subject.
settings.add_filter(r"[^ ]*foreign", "[FOREIGN]");
settings.bind(|| {
assert_snapshot!(output, @" [FOREIGN] elsewhere");
});
}
#[rstest]
fn test_statusline_json_directory_flag(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
// cwd is the main worktree; only `-C` points at `feature`
let output = run_statusline_from_dir(
&repo,
&["--format=json", "-C", feature_path.to_str().unwrap()],
None,
repo.root_path(),
);
let parsed: Value = serde_json::from_str(&output).expect("should be valid JSON");
let items = parsed.as_array().expect("should be an array");
assert_eq!(items.len(), 1, "should describe just the -C worktree");
assert_eq!(items[0]["branch"], "feature");
}
#[rstest]
fn test_statusline_does_not_run_repo_wide_ahead_behind_scan(repo: TestRepo) {
repo.create_branches(&(0..12).map(|i| format!("unused-{i}")).collect::<Vec<_>>());
let output = repo
.wt_command()
.args(["list", "statusline"])
.env("RUST_LOG", "debug")
.output()
.expect("statusline should run");
assert!(
output.status.success(),
"statusline failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("for-each-ref"),
"debug trace should include ref scans so this assertion is meaningful:\n{stderr}"
);
assert!(
!stderr.contains("%(ahead-behind:"),
"single-row statusline must not run repo-wide ahead/behind scans:\n{stderr}"
);
}
// --- Claude Code Mode Tests ---
/// Create snapshot settings that normalize path output for statusline tests.
///
/// The statusline output varies by platform:
/// - Linux: Raw path is filtered by auto-bound settings to `_REPO_`
/// - macOS: Fish-style abbreviation (e.g., `/p/v/f/.../repo`) bypasses auto-bound filters
///
/// This function normalizes both cases to a consistent `[PATH]` placeholder.
fn claude_code_snapshot_settings() -> insta::Settings {
let mut settings = insta::Settings::clone_current();
// Normalize _REPO_ (from auto-bound filters on Linux) to [PATH]
settings.add_filter(r"_REPO_", "[PATH]");
// Normalize fish-abbreviated paths (on macOS) to [PATH]
settings.add_filter(r"/[a-zA-Z0-9/._-]+/repo", "[PATH]");
settings
}
/// Escape a path for use in JSON strings.
/// On Windows, backslashes must be escaped as double backslashes.
fn escape_path_for_json(path: &std::path::Path) -> String {
path.display().to_string().replace('\\', r"\\")
}
#[rstest]
fn test_statusline_claude_code_full_context(repo: TestRepo) {
add_uncommitted_changes(&repo);
let escaped_path = escape_path_for_json(repo.root_path());
let json = format!(
r#"{{
"hook_event_name": "Status",
"session_id": "test-session",
"model": {{
"id": "claude-opus-4-1",
"display_name": "Opus"
}},
"workspace": {{
"current_dir": "{escaped_path}",
"project_dir": "{escaped_path}"
}},
"version": "1.0.80"
}}"#,
);
let output = run_statusline(&repo, &["--format=claude-code"], Some(&json));
claude_code_snapshot_settings().bind(|| {
assert_snapshot!(output, @" [PATH] main ?^| @+1 Opus");
});
}
#[rstest]
fn test_statusline_claude_code_minimal(repo: TestRepo) {
let escaped_path = escape_path_for_json(repo.root_path());
let json = format!(r#"{{"workspace": {{"current_dir": "{escaped_path}"}}}}"#,);
let output = run_statusline(&repo, &["--format=claude-code"], Some(&json));
claude_code_snapshot_settings().bind(|| {
assert_snapshot!(output, @" [PATH] main ^|");
});
}
#[rstest]
fn test_statusline_claude_code_with_model(repo: TestRepo) {
let escaped_path = escape_path_for_json(repo.root_path());
let json = format!(
r#"{{
"workspace": {{"current_dir": "{escaped_path}"}},
"model": {{"display_name": "Haiku"}}
}}"#,
);
let output = run_statusline(&repo, &["--format=claude-code"], Some(&json));
claude_code_snapshot_settings().bind(|| {
assert_snapshot!(output, @" [PATH] main ^| Haiku");
});
}
// --- Context Gauge Tests ---
#[rstest]
fn test_statusline_claude_code_with_context_gauge(repo: TestRepo) {
let escaped_path = escape_path_for_json(repo.root_path());
let json = format!(
r#"{{
"workspace": {{"current_dir": "{escaped_path}"}},
"model": {{"display_name": "Opus"}},
"context_window": {{"used_percentage": 42}}
}}"#,
);
let output = run_statusline(&repo, &["--format=claude-code"], Some(&json));
claude_code_snapshot_settings().bind(|| {
assert_snapshot!(output, @" [PATH] main ^| Opus 🌕 42%");
});
}
#[rstest]
fn test_statusline_claude_code_context_gauge_low(repo: TestRepo) {
let escaped_path = escape_path_for_json(repo.root_path());
let json = format!(
r#"{{
"workspace": {{"current_dir": "{escaped_path}"}},
"model": {{"display_name": "Opus"}},
"context_window": {{"used_percentage": 5}}
}}"#,
);
let output = run_statusline(&repo, &["--format=claude-code"], Some(&json));
claude_code_snapshot_settings().bind(|| {
assert_snapshot!(output, @" [PATH] main ^| Opus 🌕 5%");
});
}
#[rstest]
fn test_statusline_claude_code_context_gauge_high(repo: TestRepo) {
let escaped_path = escape_path_for_json(repo.root_path());
let json = format!(
r#"{{
"workspace": {{"current_dir": "{escaped_path}"}},
"model": {{"display_name": "Opus"}},
"context_window": {{"used_percentage": 98}}
}}"#,
);
let output = run_statusline(&repo, &["--format=claude-code"], Some(&json));
claude_code_snapshot_settings().bind(|| {
assert_snapshot!(output, @" [PATH] main ^| Opus 🌑 98%");
});
}
#[rstest]
fn test_statusline_claude_code_missing_context_window(repo: TestRepo) {
// When context_window is missing, no gauge should be displayed
let escaped_path = escape_path_for_json(repo.root_path());
let json = format!(
r#"{{
"workspace": {{"current_dir": "{escaped_path}"}},
"model": {{"display_name": "Opus"}}
}}"#,
);
let output = run_statusline(&repo, &["--format=claude-code"], Some(&json));
claude_code_snapshot_settings().bind(|| {
assert_snapshot!(output, @" [PATH] main ^| Opus");
});
}
// --- Directive Mode Tests ---
// Note: With the split directive file architecture, data output (like statusline)
// still goes to stdout. The directive files are only used for cd paths and exec
// commands. So this test is no longer needed - statusline behavior is the same
// regardless of whether directive env vars are set.
// --- Branch Display Tests ---
///
/// Git updates worktree metadata (`branch` field in `git worktree list`) when
/// you checkout a different branch. This test verifies that statusline correctly
/// shows the new branch name after such a checkout.
#[rstest]
fn test_statusline_reflects_checked_out_branch(mut repo: TestRepo) {
// Create a feature worktree
let feature_path = repo.add_worktree("feature");
// Verify statusline shows "feature" initially
let output = run_statusline_from_dir(&repo, &[], None, &feature_path);
assert!(
output.contains("feature"),
"statusline should show 'feature' for feature worktree, got: {output}"
);
// Create and checkout a different branch "other" in the feature worktree
repo.git_command().args(["branch", "other"]).run().unwrap();
let checkout_output = repo
.git_command()
.args(["checkout", "other"])
.current_dir(&feature_path)
.run()
.unwrap();
assert!(
checkout_output.status.success(),
"checkout should succeed: {}",
String::from_utf8_lossy(&checkout_output.stderr)
);
// Verify statusline now shows "other"
let output = run_statusline_from_dir(&repo, &[], None, &feature_path);
assert!(
output.contains("other"),
"statusline should show 'other' after checkout, got: {output}"
);
assert!(
!output.contains("feature"),
"statusline should not show 'feature' after checkout, got: {output}"
);
}
#[rstest]
fn test_statusline_detached_head(mut repo: TestRepo) {
// Create a feature worktree
let feature_path = repo.add_worktree("feature");
// Detach HEAD
repo.git_command()
.args(["checkout", "--detach"])
.current_dir(&feature_path)
.run()
.unwrap();
// Verify the statusline names a detached worktree the way its `wt list`
// row does — by the abbreviated HEAD — and not by the prior branch name.
// The SHA also tells two detached worktrees apart, which one shared
// "(detached)" label cannot.
let output = run_statusline_from_dir(&repo, &[], None, &feature_path);
// Ask git for the abbreviation instead of slicing a fixed width: the
// statusline prints git's own `%h`, so the length follows `core.abbrev` and
// stretches further still when a prefix is ambiguous.
let head = repo.git_output(&["rev-parse", "--short", "HEAD"]);
let short_head = head.trim();
assert!(
output.contains(short_head),
"statusline should show the abbreviated HEAD ({short_head}) for detached HEAD, got: {output}"
);
assert!(
!output.contains("feature"),
"statusline should not show the prior branch name, got: {output}"
);
}
/// With no default branch to compare against, the statusline is the branch
/// name alone — every other segment describes a relation to that branch.
///
/// Two branches, neither named main/master/develop/trunk, and no remote:
/// nothing is left for `default_branch()` to infer from.
#[rstest]
fn test_statusline_without_default_branch() {
let repo = TestRepo::with_initial_commit();
repo.run_git(&["branch", "-m", "main", "alpha"]);
repo.run_git(&["branch", "beta"]);
let output = run_statusline(&repo, &[], None);
assert_snapshot!(output, @" alpha");
}
// --- URL Display Tests ---
#[rstest]
fn test_statusline_with_url(repo: TestRepo) {
// Configure URL template with simple branch variable (no hash_port for deterministic output)
repo.write_project_config(
r#"[list]
url = "http://{{ branch }}.localhost:3000"
"#,
);
let output = run_statusline(&repo, &[], None);
// Shows `?` because writing project config creates uncommitted file
assert_snapshot!(output, @r" main ?^| @+2 ]8;;http://main.localhost:3000\:3000]8;;\");
}
#[rstest]
fn test_statusline_url_in_feature_worktree(mut repo: TestRepo) {
// Configure URL template with simple branch variable
repo.write_project_config(
r#"[list]
url = "http://{{ branch }}.localhost:3000"
"#,
);
// Commit the project config so it's visible in worktrees
repo.run_git(&["add", ".config/wt.toml"]);
repo.run_git(&["commit", "-m", "Add project config"]);
// Create feature worktree
let feature_path = repo.add_worktree("feature");
// Run statusline from feature worktree
let output = run_statusline_from_dir(&repo, &[], None, &feature_path);
assert_snapshot!(output, @r" feature _ ]8;;http://feature.localhost:3000\:3000]8;;\");
}
/// The URL segment lands after the CI reference and before the model in Claude
/// Code mode, where the directory and model segments share the line with it.
#[rstest]
fn test_statusline_claude_code_url_placement(repo: TestRepo) {
repo.write_project_config(
r#"[list]
url = "http://{{ branch }}.localhost:3000"
"#,
);
let escaped_path = escape_path_for_json(repo.root_path());
let json = format!(
r#"{{"model": {{"display_name": "Opus"}}, "workspace": {{"current_dir": "{escaped_path}"}}}}"#
);
let output = run_statusline(&repo, &["--format=claude-code"], Some(&json));
claude_code_snapshot_settings().bind(|| {
assert_snapshot!(output, @r" [PATH] main ?^| @+2 ]8;;http://main.localhost:3000\:3000]8;;\ Opus");
});
}
// --- JSON Format Tests ---
#[rstest]
fn test_statusline_json_basic(repo: TestRepo) {
let output = run_statusline(&repo, &["--format=json"], None);
let parsed: Value = serde_json::from_str(&output).expect("should be valid JSON");
// Should be an array with one item
let items = parsed.as_array().expect("should be an array");
assert_eq!(
items.len(),
1,
"should have exactly one item (current worktree)"
);
let item = &items[0];
// Check essential fields
assert_eq!(item["branch"], "main");
assert_eq!(item["kind"], "worktree");
assert!(item["is_current"].as_bool().unwrap());
assert!(item["is_main"].as_bool().unwrap());
// commit object should exist with sha, message, and non-zero timestamp
assert!(item["commit"]["sha"].is_string());
assert!(item["commit"]["short_sha"].is_string());
assert!(
!item["commit"]["message"].as_str().unwrap().is_empty(),
"commit.message should be populated from git log"
);
assert!(
item["commit"]["timestamp"].as_i64().unwrap() > 0,
"commit.timestamp should be populated from git log"
);
}
/// `[list] json-schema = 2` switches the statusline JSON to the envelope,
/// with the same single-item contract as the schema-1 array. An unset key
/// stays silent here — the statusline surface suppresses warnings.
#[rstest]
fn test_statusline_json_schema_2(repo: TestRepo) {
repo.write_test_config("[list]\njson-schema = 2\n");
let output = run_statusline(&repo, &["--format=json"], None);
let parsed: Value = serde_json::from_str(&output).expect("should be valid JSON");
assert_eq!(parsed["schema"], 2);
assert_eq!(
parsed["collected"],
serde_json::json!({"ci": true, "summary": false}),
"statusline collects with full gates, no summaries"
);
let items = parsed["items"].as_array().expect("envelope items");
assert_eq!(items.len(), 1, "one item: the current worktree");
let item = &items[0];
assert_eq!(item["branch"], "main");
assert!(item["worktree"]["current"].as_bool().unwrap());
assert!(item["worktree"]["main"].as_bool().unwrap());
assert!(
item.get("default_branch").is_none(),
"the default branch row carries no relation to itself"
);
assert!(item["head"]["sha"].is_string());
assert!(
item["display"]["statusline"].is_string(),
"pre-rendered statusline lives under display"
);
}
/// Outside a worktree the JSON surface emits an empty result in the
/// selected schema, and the schema-2 empty envelope skips default-branch
/// detection (no `default_branch` key — nothing to relate to).
#[rstest]
fn test_statusline_json_outside_worktree(repo: TestRepo) {
let git_dir = repo.root_path().join(".git");
let output = run_statusline_from_dir(&repo, &["--format=json"], None, &git_dir);
assert_eq!(output.trim(), "[]", "schema 1 empty result is a bare array");
repo.write_test_config("[list]\njson-schema = 2\n");
let output = run_statusline_from_dir(&repo, &["--format=json"], None, &git_dir);
let parsed: Value = serde_json::from_str(&output).expect("should be valid JSON");
assert_eq!(parsed["schema"], 2);
assert_eq!(parsed["items"], serde_json::json!([]));
assert!(
parsed["repo"].get("default_branch").is_none(),
"item-less path must not attempt default-branch detection"
);
}
/// An invalid `[list] json-schema` degrades to schema 1 silently here —
/// the statusline surface suppresses warnings, so a config typo must not
/// corrupt the prompt.
#[rstest]
fn test_statusline_json_invalid_schema_degrades_silently(repo: TestRepo) {
repo.write_test_config("[list]\njson-schema = 7\n");
let output = run_statusline(&repo, &["--format=json"], None);
let parsed: Value = serde_json::from_str(&output).expect("should be valid JSON");
assert!(parsed.is_array(), "degrades to the schema 1 array");
}
#[rstest]
fn test_statusline_json_with_changes(repo: TestRepo) {
// Create uncommitted changes
std::fs::write(repo.root_path().join("modified.txt"), "modified content").unwrap();
let output = run_statusline(&repo, &["--format=json"], None);
let parsed: Value = serde_json::from_str(&output).expect("should be valid JSON");
let item = &parsed[0];
assert_eq!(item["branch"], "main");
// Should have working_tree status
let working_tree = &item["working_tree"];
assert!(
working_tree["untracked"].as_bool().unwrap(),
"should show untracked file"
);
}
#[rstest]
fn test_statusline_json_feature_branch(mut repo: TestRepo) {
// Create feature worktree with commits
let feature_path = repo.add_worktree("feature");
std::fs::write(feature_path.join("feature.txt"), "content").unwrap();
repo.git_command()
.args(["add", "."])
.current_dir(&feature_path)
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Feature commit"])
.current_dir(&feature_path)
.run()
.unwrap();
let output = run_statusline_from_dir(&repo, &["--format=json"], None, &feature_path);
let parsed: Value = serde_json::from_str(&output).expect("should be valid JSON");
let item = &parsed[0];
assert_eq!(item["branch"], "feature");
assert!(item["is_current"].as_bool().unwrap());
assert!(!item["is_main"].as_bool().unwrap());
// Should have ahead/behind counts (commits ahead of main)
assert!(
item["main"]["ahead"].as_u64().unwrap() >= 1,
"should be ahead of main"
);
}
#[rstest]
fn test_statusline_json_ignores_claude_code(repo: TestRepo) {
// When --format=json is used, --claude-code should be ignored
let escaped_path = escape_path_for_json(repo.root_path());
let json = format!(
r#"{{
"workspace": {{"current_dir": "{escaped_path}"}},
"model": {{"display_name": "Opus"}}
}}"#,
);
let output = run_statusline(&repo, &["--format=json", "--claude-code"], Some(&json));
let parsed: Value = serde_json::from_str(&output).expect("should be valid JSON");
// Should still produce JSON output (not statusline format)
assert!(parsed.is_array(), "should produce JSON array output");
let item = &parsed[0];
assert_eq!(item["branch"], "main");
}
// --- Deprecated flags ---
#[rstest]
fn test_statusline_claude_code_flag_deprecated(repo: TestRepo) {
// The hidden `--claude-code` flag still maps to `--format=claude-code`,
// but emits a deprecation warning per invocation.
let escaped_path = escape_path_for_json(repo.root_path());
let json = format!(r#"{{"workspace": {{"current_dir": "{escaped_path}"}}}}"#);
let mut cmd = wt_command();
cmd.current_dir(repo.root_path());
cmd.args(["list", "statusline", "--claude-code"]);
repo.configure_wt_cmd(&mut cmd);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn");
child
.stdin
.as_mut()
.unwrap()
.write_all(json.as_bytes())
.unwrap();
let output = child.wait_with_output().expect("wait");
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("--claude-code is deprecated"),
"expected deprecation warning in stderr, got: {stderr}"
);
assert!(
stderr.contains("--format=claude-code"),
"expected migration hint in stderr, got: {stderr}"
);
}
/// Tests that statusline correctly identifies nested worktrees.
///
/// When worktrees are placed inside other worktrees (e.g., `.worktrees/` layout),
/// the detection must use git rev-parse --show-toplevel rather than prefix matching,
/// which would incorrectly match the parent worktree.
///
/// Regression test for: prefix matching with starts_with would incorrectly identify
/// the main worktree when running from a nested worktree.
#[rstest]
fn test_statusline_nested_worktree(mut repo: TestRepo) {
// Create a worktree nested inside the main repo (like .worktrees/ layout)
let nested_path = repo.root_path().join(".worktrees").join("feature");
let nested_worktree = repo.add_worktree_at_path("feature", &nested_path);
// Run statusline from inside the nested worktree - should show "feature", not "main"
let output = repo
.wt_command()
.current_dir(&nested_worktree)
.args(["list", "statusline"])
.output()
.expect("statusline should succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("feature"),
"Nested worktree should show 'feature' branch, got: {stdout}"
);
assert!(
!stdout.contains("main"),
"Nested worktree should NOT show 'main' branch, got: {stdout}"
);
}
/// Tests that JSON output correctly identifies nested worktrees.
#[rstest]
fn test_statusline_json_nested_worktree(mut repo: TestRepo) {
// Create a worktree nested inside the main repo
let nested_path = repo.root_path().join(".worktrees").join("feature");
let nested_worktree = repo.add_worktree_at_path("feature", &nested_path);
// Run statusline --format=json from inside the nested worktree
let output = repo
.wt_command()
.current_dir(&nested_worktree)
.args(["list", "statusline", "--format=json"])
.output()
.expect("statusline should succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: Value = serde_json::from_str(&stdout).expect("should be valid JSON");
assert!(parsed.is_array(), "should produce JSON array");
let items = parsed.as_array().unwrap();
assert_eq!(items.len(), 1, "should have exactly one item");
assert_eq!(
items[0]["branch"], "feature",
"Nested worktree should report 'feature' branch, not parent"
);
}
// --- Rate-limit segment snapshots ---
//
// These tests pin the "now" used by the rate-limit predictor (via
// `WORKTRUNK_TEST_EPOCH`), force `TZ=UTC` so the deadline string is
// deterministic across machines and timezones, and pin `LC_TIME=en_US.UTF-8`
// so the clock segment renders 12h (`3pm`) — matching the docs example and
// the US/macOS default. The 24h path has its own test that overrides
// `LC_TIME` to a non-US locale.
//
// All cases use Thursday 2025-01-02 13:00 UTC as the reference "now", and
// resets_at values relative to it.
use crate::common::TEST_EPOCH;
const TEST_NOW_THU_1PM: u64 = TEST_EPOCH + 13 * 3600;
/// Run statusline with a pinned "now" and timezone; optional COLUMNS override.
///
/// Defaults to `LC_TIME=en_US.UTF-8` (12h clock); pass a different locale
/// string to exercise the 24h path.
fn run_statusline_at_time(
repo: &TestRepo,
stdin_json: &str,
now_epoch: u64,
columns: Option<u32>,
) -> String {
run_statusline_with_locale(repo, stdin_json, now_epoch, columns, "en_US.UTF-8")
}
fn run_statusline_with_locale(
repo: &TestRepo,
stdin_json: &str,
now_epoch: u64,
columns: Option<u32>,
lc_time: &str,
) -> String {
let mut cmd = wt_command();
cmd.current_dir(repo.root_path());
cmd.args(["list", "statusline", "--format=claude-code"]);
repo.configure_wt_cmd(&mut cmd);
cmd.env("WORKTRUNK_TEST_EPOCH", now_epoch.to_string());
cmd.env("TZ", "UTC");
// The test harness pins `LC_ALL=C` globally for determinism; clear it
// so `LC_TIME` actually takes effect (POSIX precedence: LC_ALL beats
// LC_TIME). Then set `LC_TIME` per-test.
cmd.env_remove("LC_ALL");
cmd.env("LC_TIME", lc_time);
if let Some(c) = columns {
cmd.env("COLUMNS", c.to_string());
}
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("failed to spawn command");
let mut stdin = child.stdin.take().expect("failed to get stdin");
stdin
.write_all(stdin_json.as_bytes())
.expect("failed to write stdin");
drop(stdin);
let output = child.wait_with_output().expect("failed to wait for output");
String::from_utf8_lossy(&output.stdout).to_string()
}
/// Build a minimal Claude Code JSON with the given rate-limit windows.
///
/// `windows`: `[(key, used_percentage, seconds_until_reset_from_now)]`.
fn build_claude_code_json(
repo_path: &std::path::Path,
model: Option<&str>,
context_pct: Option<f64>,
now_epoch: u64,
windows: &[(&str, f64, i64)],
) -> String {
use std::fmt::Write;
let escaped = escape_path_for_json(repo_path);
let mut json = format!(r#"{{"workspace":{{"current_dir":"{escaped}"}}"#);
if let Some(m) = model {
write!(json, r#","model":{{"display_name":"{m}"}}"#).unwrap();
}
if let Some(p) = context_pct {
write!(json, r#","context_window":{{"used_percentage":{p}}}"#).unwrap();
}
if !windows.is_empty() {
json.push_str(r#","rate_limits":{"#);
for (i, (key, used, secs_to_reset)) in windows.iter().enumerate() {
if i > 0 {
json.push(',');
}
let resets_at = now_epoch as i64 + secs_to_reset;
write!(
json,
r#""{key}":{{"used_percentage":{used},"resets_at":{resets_at}}}"#
)
.unwrap();
}
json.push('}');
}
json.push('}');
json
}
#[rstest]
fn test_statusline_rate_limit_hidden_when_safe(repo: TestRepo) {
// Both windows well under threshold → segment absent.
let json = build_claude_code_json(
repo.root_path(),
Some("Opus"),
Some(42.0),
TEST_NOW_THU_1PM,
&[
("five_hour", 30.0, 2 * 3600 + 1800), // 50% elapsed, 30% used
("seven_day", 20.0, 4 * 86400), // ~43% elapsed, 20% used
],
);
let out = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, None);
claude_code_snapshot_settings().bind(|| {
assert_snapshot!("rate_limit_hidden_when_safe", out);
});
}
#[rstest]
fn test_statusline_rate_limit_5h_clock_time(repo: TestRepo) {
// 5h burning mid-window, reset in 2h → 3pm.
let json = build_claude_code_json(
repo.root_path(),
Some("Opus"),
Some(42.0),
TEST_NOW_THU_1PM,
&[("five_hour", 80.0, 2 * 3600)],
);
let out = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, None);
claude_code_snapshot_settings().bind(|| {
assert_snapshot!("rate_limit_5h_clock_time", out);
});
}
#[rstest]
fn test_statusline_rate_limit_5h_with_minutes(repo: TestRepo) {
// Reset 30 minutes from now → 1:30pm — exercises the `:mm` time spec.
let json = build_claude_code_json(
repo.root_path(),
Some("Opus"),
Some(42.0),
TEST_NOW_THU_1PM,
&[("five_hour", 95.0, 1800)],
);
let out = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, None);
claude_code_snapshot_settings().bind(|| {
assert_snapshot!("rate_limit_5h_with_minutes", out);
});
}
#[rstest]
fn test_statusline_rate_limit_5h_at_limit(repo: TestRepo) {
// 100% used — the throttled edge.
let json = build_claude_code_json(
repo.root_path(),
Some("Opus"),
Some(42.0),
TEST_NOW_THU_1PM,
&[("five_hour", 100.0, 2 * 3600)],
);
let out = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, None);
claude_code_snapshot_settings().bind(|| {
assert_snapshot!("rate_limit_5h_at_limit", out);
});
}
#[rstest]
fn test_statusline_rate_limit_7d_with_day_and_time(repo: TestRepo) {
// 7d binding, reset 4d 2h from Thu 1pm → Mon 3pm.
let json = build_claude_code_json(
repo.root_path(),
Some("Opus"),
Some(42.0),
TEST_NOW_THU_1PM,
&[
("five_hour", 15.0, 4 * 3600), // safe
("seven_day", 80.0, 4 * 86400 + 2 * 3600),
],
);
let out = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, None);
claude_code_snapshot_settings().bind(|| {
assert_snapshot!("rate_limit_7d_with_day_and_time", out);
});
}
#[rstest]
fn test_statusline_rate_limit_both_picks_worse(repo: TestRepo) {
// Both windows flagged; 7d projects worse, so 7d is the segment shown.
let json = build_claude_code_json(
repo.root_path(),
Some("Opus"),
Some(42.0),
TEST_NOW_THU_1PM,
&[
("five_hour", 85.0, 90 * 60), // 70% elapsed, 85% used → visible
("seven_day", 80.0, 4 * 86400 + 2 * 3600), // ~40% elapsed, 80% used → worse
],
);
let out = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, None);
claude_code_snapshot_settings().bind(|| {
assert_snapshot!("rate_limit_both_picks_worse", out);
});
}
#[rstest]
fn test_statusline_rate_limit_with_dirty_worktree_and_context(repo: TestRepo) {
// Uncommitted change + 95% context + 5h binding — checks segment
// composition with other state.
add_uncommitted_changes(&repo);
let json = build_claude_code_json(
repo.root_path(),
Some("Opus"),
Some(95.0),
TEST_NOW_THU_1PM,
&[("five_hour", 80.0, 2 * 3600)],
);
let out = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, None);
claude_code_snapshot_settings().bind(|| {
assert_snapshot!("rate_limit_with_dirty_worktree_and_context", out);
});
}
#[rstest]
fn test_statusline_rate_limit_5h_24h_locale(repo: TestRepo) {
// Same input as `5h_clock_time` but `LC_TIME=en_GB.UTF-8` → 24h clock.
// 12h `10am–3pm` becomes 24h `10:00–15:00`. Weekday in `Mon–Mon 15:00`
// is still English (out of scope for the clock-format follow-up).
let json = build_claude_code_json(
repo.root_path(),
Some("Opus"),
Some(42.0),
TEST_NOW_THU_1PM,
&[("five_hour", 80.0, 2 * 3600)],
);
let out = run_statusline_with_locale(&repo, &json, TEST_NOW_THU_1PM, None, "en_GB.UTF-8");
claude_code_snapshot_settings().bind(|| {
assert_snapshot!("rate_limit_5h_24h_locale", out);
});
}
#[rstest]
fn test_statusline_rate_limit_drops_at_narrow_width(repo: TestRepo) {
// Same input as `5h_clock_time` but COLUMNS=40 — the rate-limit
// segment is the lowest-priority Claude-Code segment, so it drops first.
let json = build_claude_code_json(
repo.root_path(),
Some("Opus"),
Some(42.0),
TEST_NOW_THU_1PM,
&[("five_hour", 80.0, 2 * 3600)],
);
// COLUMNS=40 is narrower than the rendered line with rate-limit, so
// the lowest-priority segments drop. The full line is ~60 chars wide
// even on the short test repo path.
let out = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, Some(40));
claude_code_snapshot_settings().bind(|| {
assert_snapshot!("rate_limit_drops_at_narrow_width", out);
});
}
#[rstest]
fn test_statusline_zero_columns_renders_untruncated(repo: TestRepo) {
// Regression: `COLUMNS=0` is an absurd width. It must be treated as "no
// detectable width" and fall through to the untruncated render — not
// collapse the content budget to zero (after the statusline subtracts its
// UI margin) and drop every segment. Compare against a wide terminal,
// which also renders untruncated: the two must be identical and non-empty.
let json = build_claude_code_json(
repo.root_path(),
Some("Opus"),
Some(42.0),
TEST_NOW_THU_1PM,
&[("five_hour", 80.0, 2 * 3600)],
);
let zero = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, Some(0));
let wide = run_statusline_at_time(&repo, &json, TEST_NOW_THU_1PM, Some(500));
assert!(
!zero.trim().is_empty(),
"COLUMNS=0 must not collapse the statusline to empty"
);
assert_eq!(
zero, wide,
"COLUMNS=0 should render untruncated, identical to a wide terminal"
);
}