Files
max-sixty__worktrunk/tests/integration_tests/list.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

4136 lines
143 KiB
Rust

use crate::common::{
DAY, HOUR, MINUTE, TestRepo, list_snapshots, make_snapshot_cmd, repo, repo_with_remote,
setup_snapshot_settings_for_paths, wt_command,
};
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
/// Creates worktrees with specific timestamps for ordering tests.
/// Returns the path to feature-current (the worktree to run tests from).
///
/// Expected order: main (^), feature-current (@), then by timestamp descending:
/// feature-newest (03:00), feature-middle (02:00), feature-oldest (00:30)
fn setup_timestamped_worktrees(repo: &mut TestRepo) -> std::path::PathBuf {
// Create main with earliest timestamp (00:00)
repo.commit("Initial commit on main");
// Helper to create a commit with a specific timestamp
fn commit_at_time(
repo: &TestRepo,
path: &std::path::Path,
filename: &str,
time: &str,
time_short: &str,
) {
let file_path = path.join(filename);
std::fs::write(
&file_path,
format!("{} content", filename.trim_end_matches(".txt")),
)
.unwrap();
repo.git_command()
.env("GIT_AUTHOR_DATE", time)
.env("GIT_COMMITTER_DATE", time)
.args(["add", "."])
.current_dir(path)
.run()
.unwrap();
repo.git_command()
.env("GIT_AUTHOR_DATE", time)
.env("GIT_COMMITTER_DATE", time)
.args(["commit", "-m", &format!("Commit at {}", time_short)])
.current_dir(path)
.run()
.unwrap();
}
// 1. Create feature-current (01:00) - we'll run test from here
let current_path = repo.add_worktree("feature-current");
commit_at_time(
repo,
&current_path,
"current.txt",
"2025-01-01T01:00:00Z",
"01:00",
);
// 2. Create feature-newest (03:00) - most recent, should be 3rd
let newest_path = repo.add_worktree("feature-newest");
commit_at_time(
repo,
&newest_path,
"newest.txt",
"2025-01-01T03:00:00Z",
"03:00",
);
// 3. Create feature-middle (02:00) - should be 4th
let middle_path = repo.add_worktree("feature-middle");
commit_at_time(
repo,
&middle_path,
"middle.txt",
"2025-01-01T02:00:00Z",
"02:00",
);
// 4. Create feature-oldest (00:30) - should be 5th
let oldest_path = repo.add_worktree("feature-oldest");
commit_at_time(
repo,
&oldest_path,
"oldest.txt",
"2025-01-01T00:30:00Z",
"00:30",
);
current_path
}
#[rstest]
fn test_list_multiple_worktrees(repo: TestRepo) {
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
///
/// Simulates realistic usage by running switch commands from the correct worktree directories.
#[rstest]
fn test_list_previous_worktree_gutter(mut repo: TestRepo) {
repo.add_worktree("feature");
let feature_path = repo.root_path().parent().unwrap().join(format!(
"{}.feature",
repo.root_path().file_name().unwrap().to_str().unwrap()
));
// Step 1: From main, switch to feature (history: current=feature, previous=main)
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["switch", "feature"])
.current_dir(repo.root_path());
cmd.output().unwrap();
// Step 2: From feature, switch back to main (history: current=main, previous=feature)
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["switch", "main"]).current_dir(&feature_path);
cmd.output().unwrap();
// List shows previous worktree with `+` (same as regular worktrees).
// The previous worktree is the target of `wt switch -`.
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[rstest]
fn test_list_detached_head(repo: TestRepo) {
repo.detach_head();
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[rstest]
fn test_list_detached_head_in_worktree(mut repo: TestRepo) {
// Non-main worktree in detached HEAD SHOULD show path mismatch flag
// (detached HEAD = "not at home", not on any branch)
repo.add_worktree("feature");
repo.detach_head_in_worktree("feature");
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
/// One commit, one rendering. The table's Commit cell and a detached row's
/// Branch cell both print git's `%h`, so they follow `core.abbrev` and agree
/// with the `short_sha` that `--format=json` carries. Sliced to a fixed width
/// instead, the table disagreed with its own JSON about the same commit and
/// ignored the setting entirely.
#[rstest]
fn test_list_abbreviated_sha_follows_git(mut repo: TestRepo) {
use ansi_str::AnsiStr;
repo.add_worktree("feature");
repo.detach_head_in_worktree("feature");
let run = |args: &[&str]| {
let output = repo
.wt_command()
.args(args)
.current_dir(repo.root_path())
.output()
.unwrap();
assert!(output.status.success(), "wt {args:?} should succeed");
output.stdout
};
// Git's default first, then a width nothing but `core.abbrev` explains —
// one anchor plus one single-factor neighbor.
for abbrev in [None, Some("12")] {
if let Some(value) = abbrev {
repo.run_git(&["config", "core.abbrev", value]);
}
let json: Vec<serde_json::Value> =
serde_json::from_slice(&run(&["list", "--format=json"])).unwrap();
let table = String::from_utf8_lossy(&run(&["list"]))
.ansi_strip()
.into_owned();
assert!(
json.iter().any(|item| item["branch"].is_null()),
"the detached worktree should be listed: {json:?}"
);
for item in &json {
let sha = item["commit"]["sha"].as_str().expect("full sha");
let short = item["commit"]["short_sha"].as_str().expect("short sha");
assert!(
table.contains(short),
"table should print the {short:?} that JSON reports \
(core.abbrev={abbrev:?}):\n{table}"
);
// `contains` alone passes on a longer prefix — precisely the fixed
// 8-character slice this replaced — so rule that out as well.
assert!(
!table.contains(&sha[..short.len() + 1]),
"table should stop abbreviating where git does \
(core.abbrev={abbrev:?}):\n{table}"
);
if item["branch"].is_null() {
assert!(
table.lines().any(|line| line.matches(short).count() == 2),
"a detached row has no branch name, so one line carries {short:?} \
in both Branch and Commit (core.abbrev={abbrev:?}):\n{table}"
);
}
}
}
}
/// Adds a second worktree on `branch` via `git worktree add --force`, which
/// bypasses git's "already used by worktree" guard. Worktrunk never creates
/// this state itself.
fn force_duplicate_worktree(repo: &TestRepo, branch: &str) -> std::path::PathBuf {
let dup_path = repo
.root_path()
.parent()
.unwrap()
.join(format!("repo.{branch}-dup"));
repo.run_git(&[
"worktree",
"add",
"--force",
dup_path.to_str().unwrap(),
branch,
]);
dup_path
}
/// A branch checked out in two worktrees flags both rows with `⚑`, the same
/// irregular-mapping flag an off-template path earns, and the Path column
/// earns its place: the branch name no longer identifies the row. Before
/// this, only the off-template duplicate was flagged, and the worktree `wt`
/// actually resolves to showed nothing.
#[rstest]
fn test_list_duplicate_branch(mut repo: TestRepo) {
repo.add_worktree("feature");
force_duplicate_worktree(&repo, "feature");
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
/// Both JSON schemas report the duplicate: schema 1 through the single
/// `worktree.state` value, schema 2 through its own orthogonal flag.
#[rstest]
fn test_list_duplicate_branch_json(mut repo: TestRepo) {
repo.add_worktree("feature");
force_duplicate_worktree(&repo, "feature");
repo.write_test_config("[list]\njson-schema = 1\n");
let output = repo
.wt_command()
.args(["list", "--format=json"])
.output()
.unwrap();
assert!(output.status.success());
let items: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).unwrap();
let flagged: Vec<_> = items
.iter()
.filter(|item| item["worktree"]["state"] == "duplicate_branch")
.collect();
assert_eq!(flagged.len(), 2, "both rows carry the state: {items:#?}");
assert!(
flagged.iter().all(|item| item["branch"] == "feature"),
"only the duplicated branch is flagged: {items:#?}"
);
repo.write_test_config("[list]\njson-schema = 2\n");
let output = repo
.wt_command()
.args(["list", "--format=json"])
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let items = json["items"].as_array().unwrap();
let flagged: Vec<_> = items
.iter()
.filter(|item| item["worktree"]["duplicate_branch"] == true)
.collect();
assert_eq!(flagged.len(), 2, "both rows carry the flag: {items:#?}");
assert!(
items
.iter()
.filter(|item| item["branch"] == "main")
.all(|item| item["worktree"]["duplicate_branch"] == false),
"the unduplicated branch stays clear: {items:#?}"
);
}
#[rstest]
fn test_list_locked_worktree(mut repo: TestRepo) {
repo.add_worktree("locked-feature");
repo.lock_worktree("locked-feature", Some("Testing lock functionality"));
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[rstest]
fn test_list_locked_no_reason(mut repo: TestRepo) {
repo.add_worktree("locked-no-reason");
repo.lock_worktree("locked-no-reason", None);
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[rstest]
fn test_list_long_commit_message(mut repo: TestRepo) {
// Create commit with very long message
repo.commit("This is a very long commit message that should test how the message column handles truncation and word boundary detection in the list output");
repo.add_worktree("feature-a");
repo.commit("Short message");
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[rstest]
fn test_list_unicode_commit_message(mut repo: TestRepo) {
// Create commit with Unicode message
repo.commit("Add support for 日本語 and émoji 🎉");
repo.add_worktree("feature-test");
repo.commit("Fix bug with café ☕ handling");
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[rstest]
fn test_list_many_worktrees_with_varied_stats(mut repo: TestRepo) {
// Create multiple worktrees with different characteristics
repo.add_worktree("short");
repo.add_worktree("medium-name");
repo.add_worktree("very-long-branch-name-here");
// Add some with files to create diff stats
repo.add_worktree("with-changes");
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
// Removed: test_list_json_single_worktree and test_list_json_multiple_worktrees
// Basic JSON serialization is covered by test_list_json_with_metadata
#[rstest]
fn test_list_json_with_metadata(mut repo: TestRepo) {
// Create worktree with detached head
repo.add_worktree("feature-detached");
// Create locked worktree
repo.add_worktree("locked-feature");
repo.lock_worktree("locked-feature", Some("Testing"));
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--format=json");
cmd
});
}
/// Schema 2 (`[list] json-schema = 2`): envelope with repo facts and
/// per-item orthogonal facts. Pins the full shape, including the absence
/// rule (locked reason present, integration null vs absent).
#[rstest]
fn test_list_json_schema_2_envelope(mut repo: TestRepo) {
repo.write_test_config("[list]\njson-schema = 2\n");
repo.add_worktree("feature-detached");
repo.add_worktree("locked-feature");
repo.lock_worktree("locked-feature", Some("Testing"));
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--format=json");
cmd
});
}
/// `[list] json-schema` selects the output schema: unset emits schema 1
/// plus a one-time nag, an explicit value is silent, and anything except
/// 1 or 2 is an error.
#[rstest]
fn test_list_json_schema_selection(repo: TestRepo) {
// Unset with no user config file → nag names both settings to write by
// hand; there is no file for `wt config update` to rewrite.
let output = repo
.wt_command()
.args(["list", "--format=json"])
.output()
.unwrap();
assert!(output.status.success());
let json: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).unwrap();
assert!(!json.is_empty(), "schema 1 root is a bare array");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("json-schema = 1") && stderr.contains("json-schema = 2"),
"unset key should nag with the manual settings: {stderr}"
);
assert!(
!stderr.contains("config update"),
"no update hint without a config file to update: {stderr}"
);
// Unset with a user config file present → the hint offers wt config
// update, which writes json-schema = 2.
repo.write_test_config("");
let output = repo
.wt_command()
.args(["list", "--format=json"])
.output()
.unwrap();
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("wt config update"),
"unset key with a config file should offer the update command: {stderr}"
);
// Explicit 1 → schema 1, no nag.
repo.write_test_config("[list]\njson-schema = 1\n");
let output = repo
.wt_command()
.args(["list", "--format=json"])
.output()
.unwrap();
assert!(output.status.success());
let json: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).unwrap();
assert!(!json.is_empty());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("json-schema"),
"explicit value should not nag: {stderr}"
);
// Explicit 2 → envelope, no nag.
repo.write_test_config("[list]\njson-schema = 2\n");
let output = repo
.wt_command()
.args(["list", "--format=json"])
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["schema"], 2);
assert_eq!(json["repo"]["default_branch"], "main");
assert!(json["items"].as_array().is_some_and(|i| !i.is_empty()));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!stderr.contains("json-schema"), "no nag with a value set");
// Invalid value → warn and degrade to schema 1, like a config type
// error (config problems never brick a command).
repo.write_test_config("[list]\njson-schema = 3\n");
let output = repo
.wt_command()
.args(["list", "--format=json"])
.output()
.unwrap();
assert!(output.status.success(), "invalid value must not fail");
let json: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).unwrap();
assert!(!json.is_empty(), "degrades to schema 1");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("expected 1 or 2"),
"invalid value should warn: {stderr}"
);
}
/// `repo_url` is derived locally from the primary remote, converting an SSH
/// remote to its HTTPS web URL without shelling out to a forge.
#[rstest]
fn test_list_json_repo_url_from_ssh_remote(repo: TestRepo) {
// A real forge SSH remote. The local-path remote the fixture configures
// doesn't parse as a remote URL, so `repo_url` would be absent for it.
repo.run_git(&[
"remote",
"set-url",
"origin",
"git@github.com:owner/repo.git",
]);
let output = repo
.wt_command()
.args(["list", "--format=json"])
.output()
.unwrap();
assert!(output.status.success(), "wt list should succeed");
let json: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).unwrap();
let row = json.first().expect("at least one row");
assert_eq!(
row["repo_url"].as_str(),
Some("https://github.com/owner/repo"),
"SSH remote should derive the HTTPS web URL"
);
assert_eq!(
row["repo"]["url"].as_str(),
Some("https://github.com/owner/repo")
);
assert_eq!(row["repo"]["provider"].as_str(), Some("github"));
assert_eq!(row["repo"]["host"].as_str(), Some("github.com"));
assert_eq!(row["repo"]["owner"].as_str(), Some("owner"));
assert_eq!(row["repo"]["name"].as_str(), Some("repo"));
assert_eq!(row["repo"]["remote"].as_str(), Some("origin"));
assert!(
row["repo"].get("project").is_none(),
"GitHub repo metadata should not include project"
);
}
/// A self-hosted instance names its forge however it likes — its own label, a
/// hyphenated one, inside a word, or a single-label SSH alias — and the
/// provider follows the name with no config. The statusline stays silent.
#[rstest]
fn test_list_json_provider_reads_branded_self_hosted_hosts(repo: TestRepo) {
for (remote, host, provider) in [
(
"https://github-enterprise.acme.com/owner/repo.git",
"github-enterprise.acme.com",
"github",
),
(
"https://gitlab-internal.company.com/owner/repo.git",
"gitlab-internal.company.com",
"gitlab",
),
(
"https://mygithub.com/owner/repo.git",
"mygithub.com",
"github",
),
(
"git@github-personal:owner/repo.git",
"github-personal",
"github",
),
] {
repo.run_git(&["remote", "set-url", "origin", remote]);
let output = repo
.wt_command()
.args(["list", "--format=json"])
.output()
.unwrap();
assert!(
output.status.success(),
"wt list should succeed for {remote}"
);
let json: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).unwrap();
let row = json.first().expect("at least one row");
assert_eq!(row["repo"]["provider"].as_str(), Some(provider), "{remote}");
assert_eq!(row["repo"]["host"].as_str(), Some(host), "{remote}");
}
}
#[rstest]
fn test_list_json_configured_azure_generic_remote_is_unknown(repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://git.example.com/owner/repo.git",
]);
repo.write_project_config("[forge]\nplatform = \"azure-devops\"\n");
let output = repo
.wt_command()
.args(["list", "--format=json"])
.output()
.unwrap();
assert!(output.status.success(), "wt list should succeed");
let json: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).unwrap();
let row = json.first().expect("at least one row");
assert_eq!(
row["repo_url"].as_str(),
Some("https://git.example.com/owner/repo")
);
assert_eq!(
row["repo"]["url"].as_str(),
Some("https://git.example.com/owner/repo")
);
assert_eq!(row["repo"]["provider"].as_str(), Some("unknown"));
assert_eq!(row["repo"]["host"].as_str(), Some("git.example.com"));
assert_eq!(row["repo"]["owner"].as_str(), Some("owner"));
assert_eq!(row["repo"]["name"].as_str(), Some("repo"));
assert_eq!(row["repo"]["remote"].as_str(), Some("origin"));
assert!(
row["repo"].get("project").is_none(),
"generic repo metadata should not include an Azure project"
);
}
/// This tests the merge commit scenario where content matches main even with different commit history.
#[rstest]
fn test_list_json_tree_matches_main_after_merge(mut repo: TestRepo) {
// Create feature branch with a worktree
let feature_path = repo.add_worktree_with_commit(
"feature-merged",
"feature.txt",
"feature content",
"Feature commit",
);
// Make the same commit on main (so trees will match after merge)
std::fs::write(repo.root_path().join("feature.txt"), "feature content").unwrap();
repo.run_git(&["add", "."]);
repo.run_git(&["commit", "-m", "Same content on main"]);
// Merge main into feature (creates merge commit, but tree matches main)
repo.run_git_in(
&feature_path,
&["merge", "main", "-m", "Merge main into feature"],
);
// Now feature-merged is ahead of main (has merge commit) but tree content matches main
// JSON output should show branch_op_state: "TreesMatch" with ahead > 0
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--format=json");
cmd
});
}
#[rstest]
fn test_list_with_branches_flag(mut repo: TestRepo) {
// Create some branches without worktrees
repo.create_branch("feature-without-worktree");
repo.create_branch("another-branch");
repo.create_branch("fix-bug");
// Create one branch with a worktree
repo.add_worktree("feature-with-worktree");
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--branches");
cmd
});
}
#[rstest]
fn test_list_with_branches_flag_no_available(mut repo: TestRepo) {
// All branches have worktrees (only main exists and has worktree)
repo.add_worktree("feature-a");
repo.add_worktree("feature-b");
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--branches");
cmd
});
}
#[rstest]
fn test_list_with_branches_flag_only_branches(repo: TestRepo) {
// Create several branches without worktrees
repo.create_branch("branch-alpha");
repo.create_branch("branch-beta");
repo.create_branch("branch-gamma");
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--branches");
cmd
});
}
#[rstest]
fn test_list_with_remotes_flag(#[from(repo_with_remote)] repo: TestRepo) {
// Create feature branches in the main repo and push them
repo.create_branch("remote-feature-1");
repo.create_branch("remote-feature-2");
repo.push_branch("remote-feature-1");
repo.push_branch("remote-feature-2");
// Delete the local branches - now they only exist as origin/remote-feature-*
repo.run_git(&["branch", "-D", "remote-feature-1", "remote-feature-2"]);
// Should show:
// - main worktree (primary)
// - origin/remote-feature-1 (remote branch without local worktree)
// - origin/remote-feature-2 (remote branch without local worktree)
// Should NOT show origin/main (main has a worktree)
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--remotes");
cmd
});
}
#[rstest]
fn test_list_with_remotes_and_branches(#[from(repo_with_remote)] repo: TestRepo) {
// Create local-only branches (not worktrees, not pushed)
repo.create_branch("local-only-1");
repo.create_branch("local-only-2");
// Create branches, push them, then delete locally to make them remote-only
repo.create_branch("remote-only-1");
repo.create_branch("remote-only-2");
repo.push_branch("remote-only-1");
repo.push_branch("remote-only-2");
repo.run_git(&["branch", "-D", "remote-only-1", "remote-only-2"]);
// Should show:
// - main worktree
// - local-only-1 branch (local, no worktree)
// - local-only-2 branch (local, no worktree)
// - origin/remote-only-1 (remote, no local)
// - origin/remote-only-2 (remote, no local)
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.args(["--branches", "--remotes"]);
cmd
});
}
#[rstest]
fn test_list_with_remotes_filters_tracked_worktrees(#[from(repo_with_remote)] mut repo: TestRepo) {
// Create a worktree and push with tracking
repo.add_worktree("feature-with-worktree");
let feature_path = repo.worktree_path("feature-with-worktree");
repo.run_git_in(
feature_path,
&["push", "-u", "origin", "feature-with-worktree"],
);
// Create a branch, push it, delete it locally (remote-only)
repo.create_branch("remote-only");
repo.push_branch("remote-only");
repo.run_git(&["branch", "-D", "remote-only"]);
// Should show:
// - main worktree
// - feature-with-worktree worktree
// - origin/remote-only (remote branch not tracked by any local branch)
// Should NOT show origin/main or origin/feature-with-worktree (both tracked)
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--remotes");
cmd
});
}
#[rstest]
fn test_list_with_remotes_filters_tracked_branches(#[from(repo_with_remote)] repo: TestRepo) {
// Create a local branch (no worktree) and push with tracking
repo.create_branch("tracked-branch");
repo.run_git(&["push", "-u", "origin", "tracked-branch"]);
// Create a branch, push it, then delete locally (remote-only)
repo.create_branch("remote-only");
repo.push_branch("remote-only");
repo.run_git(&["branch", "-D", "remote-only"]);
// Should show:
// - main worktree
// - origin/remote-only (remote branch not tracked by any local branch)
// Should NOT show origin/main (tracked by main + has worktree)
// Should NOT show origin/tracked-branch (tracked by local tracked-branch)
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--remotes");
cmd
});
}
#[rstest]
fn test_list_with_remotes_and_full(#[from(repo_with_remote)] repo: TestRepo) {
// Create remote-only branches (no local tracking)
repo.create_branch("feature-remote");
repo.push_branch("feature-remote");
repo.run_git(&["branch", "-D", "feature-remote"]);
// Set a GitHub-style URL AFTER pushing so platform detection works
// This exercises the remote_hint path in Repository::ci_platform
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
// Run with --full to trigger CiBranchName::from_branch_ref for remote branches
// This exercises the is_remote=true code path even without gh/glab auth
// (CI detection will return None, but the branch parsing is still covered)
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.args(["--remotes", "--full"]);
cmd
});
}
#[rstest]
fn test_list_with_orphaned_remote_ref(#[from(repo_with_remote)] repo: TestRepo) {
// Create a remote-tracking ref for a non-existent remote.
// This simulates a ref that remains after a remote is deleted.
let head_sha = repo
.git_command()
.args(["rev-parse", "HEAD"])
.run()
.unwrap()
.stdout;
let head_sha = String::from_utf8_lossy(&head_sha);
let head_sha = head_sha.trim();
repo.run_git(&[
"update-ref",
"refs/remotes/deleted-remote/orphaned-branch",
head_sha,
]);
// Verify the ref exists but the remote doesn't
let remotes = repo.git_command().args(["remote"]).run().unwrap().stdout;
let remotes = String::from_utf8_lossy(&remotes);
assert!(
!remotes.contains("deleted-remote"),
"deleted-remote should not exist"
);
// The orphaned ref should still appear — split_once('/') parses it correctly
// even though the remote no longer exists.
let output = repo
.wt_command()
.args(["list", "--remotes", "--full"])
.output()
.unwrap();
assert!(output.status.success(), "command should succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("deleted-remote/orphaned-branch"),
"should show orphaned remote branch in output: {stdout}"
);
}
/// A remote-row must resolve to `refs/remotes/<name>` even when a local branch
/// with the literal same short name exists. Git allows local branches like
/// `origin/foo`; without qualifying the ref, `git rev-parse origin/foo` picks
/// `refs/heads/origin/foo` and the remote row silently reports stats against
/// the local branch.
#[rstest]
fn test_list_remote_row_not_shadowed_by_same_named_local_branch(
#[from(repo_with_remote)] repo: TestRepo,
) {
// Remote `foo` = one commit ahead of main.
repo.create_branch("foo");
repo.run_git(&["checkout", "foo"]);
std::fs::write(repo.root_path().join("remote.txt"), "remote").unwrap();
repo.run_git(&["add", "."]);
repo.run_git(&["commit", "-m", "Remote foo commit"]);
repo.push_branch("foo");
// Drop local `foo` so only `refs/remotes/origin/foo` remains.
repo.run_git(&["checkout", "main"]);
repo.run_git(&["branch", "-D", "foo"]);
// Local branch literally named `origin/foo`, two commits ahead of main on a
// different history from the remote.
repo.run_git(&["checkout", "-b", "origin/foo"]);
std::fs::write(repo.root_path().join("local1.txt"), "local1").unwrap();
repo.run_git(&["add", "."]);
repo.run_git(&["commit", "-m", "Local origin/foo 1"]);
std::fs::write(repo.root_path().join("local2.txt"), "local2").unwrap();
repo.run_git(&["add", "."]);
repo.run_git(&["commit", "-m", "Local origin/foo 2"]);
repo.run_git(&["checkout", "main"]);
let output = repo
.wt_command()
.args(["list", "--remotes", "--format=json"])
.output()
.unwrap();
assert!(output.status.success(), "wt list should succeed");
let json: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).unwrap();
let remote_row = json
.iter()
.find(|w| w["branch"] == "origin/foo" && w["kind"] == "branch")
.expect("remote row origin/foo should be present in --remotes output");
assert_eq!(
remote_row["main"]["ahead"].as_u64(),
Some(1),
"remote row must report remote-branch ahead count (1), not local (2): {remote_row:#?}"
);
}
#[rstest]
fn test_list_json_with_display_fields(mut repo: TestRepo) {
repo.commit("Initial commit on main");
// Create feature branch with commits (ahead of main)
repo.add_worktree("feature-ahead");
// Make commits in the feature worktree
let feature_path = repo.worktree_path("feature-ahead");
std::fs::write(feature_path.join("feature.txt"), "feature content").unwrap();
repo.run_git_in(feature_path, &["add", "."]);
repo.run_git_in(feature_path, &["commit", "-m", "Feature commit 1"]);
repo.run_git_in(
feature_path,
&["commit", "--allow-empty", "-m", "Feature commit 2"],
);
// Add uncommitted changes to populate the working-diff column
std::fs::write(feature_path.join("uncommitted.txt"), "uncommitted").unwrap();
std::fs::write(feature_path.join("feature.txt"), "modified content").unwrap();
// Create another feature that will be behind after main advances
repo.add_worktree("feature-behind");
// Make more commits on main (so feature-behind is behind)
repo.commit("Main commit 1");
repo.commit("Main commit 2");
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--format=json");
cmd
});
}
#[rstest]
fn test_list_ordering_rules(mut repo: TestRepo) {
let current_path = setup_timestamped_worktrees(&mut repo);
// Run from feature-current worktree to test "current worktree" logic
assert_cmd_snapshot!(list_snapshots::command(&repo, &current_path));
}
#[rstest]
fn test_list_with_upstream_tracking(mut repo: TestRepo) {
repo.commit("Initial commit on main");
// Set up remote - this already pushes main
repo.setup_remote("main");
// Scenario 1: Branch in sync with remote (should show ↑0 ↓0)
let in_sync_wt = repo.add_worktree("in-sync");
repo.run_git_in(&in_sync_wt, &["push", "-u", "origin", "in-sync"]);
// Scenario 2: Branch ahead of remote (should show ↑2)
let ahead_wt = repo.add_worktree("ahead");
repo.run_git_in(&ahead_wt, &["push", "-u", "origin", "ahead"]);
// Make 2 commits ahead
std::fs::write(ahead_wt.join("ahead1.txt"), "ahead 1").unwrap();
repo.run_git_in(&ahead_wt, &["add", "."]);
repo.run_git_in(&ahead_wt, &["commit", "-m", "Ahead commit 1"]);
std::fs::write(ahead_wt.join("ahead2.txt"), "ahead 2").unwrap();
repo.run_git_in(&ahead_wt, &["add", "."]);
repo.run_git_in(&ahead_wt, &["commit", "-m", "Ahead commit 2"]);
// Scenario 3: Branch behind remote (should show ↓1)
let behind_wt = repo.add_worktree("behind");
std::fs::write(behind_wt.join("behind.txt"), "behind").unwrap();
repo.run_git_in(&behind_wt, &["add", "."]);
repo.run_git_in(&behind_wt, &["commit", "-m", "Behind commit"]);
repo.run_git_in(&behind_wt, &["push", "-u", "origin", "behind"]);
// Reset local to one commit behind
repo.run_git_in(&behind_wt, &["reset", "--hard", "HEAD~1"]);
// Scenario 4: Branch both ahead and behind (should show ↑1 ↓1)
let diverged_wt = repo.add_worktree("diverged");
std::fs::write(diverged_wt.join("diverged.txt"), "diverged").unwrap();
repo.run_git_in(&diverged_wt, &["add", "."]);
repo.run_git_in(&diverged_wt, &["commit", "-m", "Diverged remote commit"]);
repo.run_git_in(&diverged_wt, &["push", "-u", "origin", "diverged"]);
// Reset and make different commit
repo.run_git_in(&diverged_wt, &["reset", "--hard", "HEAD~1"]);
std::fs::write(diverged_wt.join("different.txt"), "different").unwrap();
repo.run_git_in(&diverged_wt, &["add", "."]);
repo.run_git_in(&diverged_wt, &["commit", "-m", "Diverged local commit"]);
// Scenario 5: Branch without upstream (should show blank)
let no_upstream_wt = repo.add_worktree("no-upstream");
// Run git status to ensure the worktree is fully initialized on Windows.
// Without this, Windows CI may show 55y (Unix epoch) because the worktree
// isn't ready when wt list tries to read commit timestamps.
repo.run_git_in(&no_upstream_wt, &["status", "--porcelain"]);
// Scenario 6: Upstream configured but remote ref deleted (git's [gone] state).
// Should be treated as no upstream — blank Remote column, no error.
let gone_wt = repo.add_worktree("gone-upstream");
repo.run_git_in(&gone_wt, &["push", "-u", "origin", "gone-upstream"]);
repo.run_git(&["push", "origin", "--delete", "gone-upstream"]);
repo.run_git_in(&gone_wt, &["fetch", "--prune", "origin"]);
// Run list --branches --full to show all columns including Remote
assert_cmd_snapshot!("with_upstream_tracking", {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
repo.configure_mock_commands(&mut cmd);
cmd.arg("list")
.arg("--branches")
.arg("--full")
.current_dir(repo.root_path());
cmd
});
}
/// When the local default branch lags its upstream (a fork whose `main` sits
/// behind `origin/main`), the `main↕`/`main…±` columns must measure each
/// branch against the upstream tip, not the stale local tip. Otherwise every
/// branch reads as ahead by every commit the local default is missing.
#[rstest]
fn test_list_branch_stats_use_upstream_when_local_default_lags(mut repo: TestRepo) {
repo.commit("c0");
repo.setup_remote("main");
// Persist the default branch so detection is deterministic.
repo.run_git(&["config", "worktrunk.default-branch", "main"]);
// Advance origin/main three commits ahead, then pin local main back so it
// lags its upstream by three commits (the stale-fork shape).
repo.commit("upstream 1");
repo.commit("upstream 2");
repo.commit("upstream 3");
repo.run_git(&["push", "origin", "main"]);
repo.run_git(&["reset", "--hard", "HEAD~3"]);
repo.run_git(&["fetch", "origin"]);
// Feature branch off the real upstream tip, two commits ahead.
let feature_wt = repo.add_worktree("feature");
repo.run_git_in(&feature_wt, &["reset", "--hard", "origin/main"]);
std::fs::write(feature_wt.join("feat.txt"), "a\n").unwrap();
repo.run_git_in(&feature_wt, &["add", "."]);
repo.run_git_in(&feature_wt, &["commit", "-m", "feature commit 1"]);
std::fs::write(feature_wt.join("feat.txt"), "a\nb\n").unwrap();
repo.run_git_in(&feature_wt, &["add", "."]);
repo.run_git_in(&feature_wt, &["commit", "-m", "feature commit 2"]);
let output = repo
.wt_command()
.args(["list", "--branches", "--format=json"])
.output()
.unwrap();
assert!(output.status.success(), "wt list should succeed");
let json: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).unwrap();
let feature = json
.iter()
.find(|w| w["branch"] == "feature")
.expect("feature row should be present");
// Two commits ahead of the upstream tip — not five (3 stale + 2 feature).
assert_eq!(
feature["main"]["ahead"].as_u64(),
Some(2),
"ahead must be measured against origin/main, not the stale local main: {feature:#?}"
);
assert_eq!(feature["main"]["behind"].as_u64(), Some(0));
// The diff reflects only the feature's own additions, not the three
// upstream commits the local default is missing.
assert_eq!(
feature["main"]["diff"]["added"].as_u64(),
Some(2),
"branch diff must be measured against origin/main: {feature:#?}"
);
}
/// The mirror of the lagging-fork case: when the local default branch is
/// *ahead* of its upstream (local commits not yet pushed, e.g. just after a
/// local `wt merge`), the base must stay local. Otherwise a sibling branch
/// would show those unpushed default-branch commits in its own counts. This
/// pins the contract that `comparison_base` reuses `integration_targets`'
/// superset selection rather than naively preferring the upstream ref.
#[rstest]
fn test_list_branch_stats_stay_local_when_default_ahead_of_upstream(mut repo: TestRepo) {
repo.commit("c0");
repo.setup_remote("main");
repo.run_git(&["config", "worktrunk.default-branch", "main"]);
// Advance local main three commits past origin/main, leaving them unpushed
// so the local default is ahead of its upstream.
repo.commit("local main 1");
repo.commit("local main 2");
repo.commit("local main 3");
repo.run_git(&["fetch", "origin"]);
// Feature off the local default tip, two commits ahead.
let feature_wt = repo.add_worktree("feature");
std::fs::write(feature_wt.join("feat.txt"), "a\n").unwrap();
repo.run_git_in(&feature_wt, &["add", "."]);
repo.run_git_in(&feature_wt, &["commit", "-m", "feature commit 1"]);
std::fs::write(feature_wt.join("feat.txt"), "a\nb\n").unwrap();
repo.run_git_in(&feature_wt, &["add", "."]);
repo.run_git_in(&feature_wt, &["commit", "-m", "feature commit 2"]);
let output = repo
.wt_command()
.args(["list", "--branches", "--format=json"])
.output()
.unwrap();
assert!(output.status.success(), "wt list should succeed");
let json: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).unwrap();
let feature = json
.iter()
.find(|w| w["branch"] == "feature")
.expect("feature row should be present");
// Two commits ahead of the LOCAL default tip — not five (which would
// wrongly fold in the three unpushed local-main commits by measuring
// against origin/main).
assert_eq!(
feature["main"]["ahead"].as_u64(),
Some(2),
"ahead must stay measured against local main when it leads origin/main: {feature:#?}"
);
assert_eq!(feature["main"]["behind"].as_u64(), Some(0));
}
#[rstest]
fn test_list_primary_on_different_branch(mut repo: TestRepo) {
repo.switch_primary_to("develop");
assert_eq!(repo.current_branch(), "develop");
repo.add_worktree("feature-a");
repo.add_worktree("feature-b");
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
/// Set up the clean worktree state shared by the user-marker documentation snapshots.
fn setup_user_marker_example(repo: &mut TestRepo) {
repo.remove_fixture_worktrees();
repo.commit_with_age("Initial commit", DAY);
// Branch ahead of main with commits and user marker 🤖
let _feature_wt = repo.add_worktree_with_commit(
"feature-api",
"api.rs",
"// API implementation",
"Add REST API endpoints",
);
// Set user marker
repo.set_marker("feature-api", "🤖");
// Branch with uncommitted changes and user marker 💬
let review_wt = repo.add_worktree_with_commit(
"review-ui",
"component.tsx",
"// UI component",
"Add dashboard component",
);
// Add uncommitted changes
std::fs::write(review_wt.join("styles.css"), "/* pending styles */").unwrap();
// Set user marker
repo.set_marker("review-ui", "💬");
// Branch with uncommitted changes only (no user marker)
let wip_wt = repo.add_worktree("wip-docs");
std::fs::write(wip_wt.join("README.md"), "# Documentation").unwrap();
}
#[rstest]
fn test_list_with_user_marker(mut repo: TestRepo) {
setup_user_marker_example(&mut repo);
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[rstest]
fn test_list_json_with_user_marker(mut repo: TestRepo) {
repo.commit_with_age("Initial commit", DAY);
// Worktree with user marker (emoji only)
repo.add_worktree("with-status");
// Set user marker (branch-keyed)
repo.set_marker("with-status", "🔧");
// Worktree without user marker
repo.add_worktree("without-status");
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--format=json");
cmd
});
}
#[rstest]
fn test_list_json_with_git_operation(mut repo: TestRepo) {
// Test JSON output includes git_operation field when worktree is in rebase state
// Create initial commit with a file that will conflict
std::fs::write(
repo.root_path().join("conflict.txt"),
"original line 1\noriginal line 2\n",
)
.unwrap();
repo.commit("Initial commit");
// Create feature worktree
let feature = repo.add_worktree_with_commit(
"feature",
"conflict.txt",
"feature line 1\nfeature line 2\n",
"Feature changes",
);
// Main makes conflicting changes
std::fs::write(
repo.root_path().join("conflict.txt"),
"main line 1\nmain line 2\n",
)
.unwrap();
repo.run_git(&["add", "."]);
repo.run_git(&["commit", "-m", "Main conflicting changes"]);
// Start rebase which will create conflicts and git operation state
let rebase_output = repo
.git_command()
.current_dir(&feature)
.args(["rebase", "main"])
.run()
.unwrap();
// Rebase should fail with conflicts - verify we're in rebase state
assert!(
!rebase_output.status.success(),
"Rebase should fail with conflicts"
);
// JSON output should show git_operation: "rebase" for the feature worktree
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--format=json");
cmd
});
}
/// A bisect is the plainest of the operations that had no symbol of their own
/// before every operation folded into `↻`: it leaves HEAD on the branch and the
/// working tree clean, so nothing else in the Status cell stands in for it.
/// JSON still names the operation the symbol no longer distinguishes.
#[rstest]
fn test_list_shows_symbol_for_bisect(mut repo: TestRepo) {
let feature = repo.add_worktree("bisecting");
repo.git_command()
.current_dir(&feature)
.args(["bisect", "start"])
.run()
.unwrap();
assert_cmd_snapshot!(
"list_bisect_symbol",
list_snapshots::command(&repo, repo.root_path())
);
assert_cmd_snapshot!("list_bisect_json", {
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--format=json");
cmd
});
}
#[rstest]
fn test_list_branch_only_with_status(repo: TestRepo) {
// Test that branch-only entries (no worktree) can display branch-keyed status
// Create a branch-only entry (no worktree)
repo.run_git(&["branch", "branch-only"]);
// Set branch-keyed status for the branch-only entry
repo.set_marker("branch-only", "🌿");
// Use --branches flag to show branch-only entries
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--branches");
cmd
});
}
#[rstest]
fn test_list_user_marker_with_special_characters(mut repo: TestRepo) {
// Test with single emoji
repo.add_worktree("emoji");
repo.set_marker("emoji", "🔄");
// Test with compound emoji (multi-codepoint)
repo.add_worktree("multi");
repo.set_marker("multi", "👨‍💻");
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
// =============================================================================
// Quick Start Examples
// =============================================================================
//
// These functions create minimal repos for Quick Start documentation.
// The examples show the simplest workflow: create → list → merge.
/// Set up a minimal repo with just main branch.
///
/// Creates a simple codebase:
/// - README.md with project description
/// - lib.rs with a simple function
/// - Remote configured and pushed
///
/// Used as base for both Quick Start and full README examples.
fn setup_quickstart_base(repo: &mut TestRepo) {
repo.remove_fixture_worktrees();
// Suppress the "customize worktree locations" hint for clean snapshots
repo.run_git(&["config", "worktrunk.hints.worktree-path", "true"]);
// Simple README
std::fs::write(
repo.root_path().join("README.md"),
"# My Project\n\nA Rust application.\n",
)
.unwrap();
// Simple lib.rs
std::fs::write(
repo.root_path().join("lib.rs"),
r#"/// Adds two numbers.
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
}
"#,
)
.unwrap();
repo.run_git(&["add", "README.md", "lib.rs"]);
repo.commit_staged_with_age("Initial commit", DAY, repo.root_path());
repo.setup_remote("main");
}
/// Set up a Quick Start example repo with main + feature-auth.
///
/// Creates a scenario with a committed feature branch plus staged WIP:
/// - main: Initial codebase (1 commit behind remote)
/// - feature-auth: 1 commit ahead of main + staged WIP changes (adds + removes)
///
/// The staged WIP extends auth.rs and restructures lib.rs, producing both
/// additions and deletions in HEAD± to showcase more of `wt list`.
///
/// Returns the feature-auth worktree path.
fn setup_quickstart_repo(repo: &mut TestRepo) -> std::path::PathBuf {
setup_quickstart_base(repo);
// Create feature-auth worktree
let feature_auth = repo.add_worktree("feature-auth");
// === Commit: Add authentication module (1 commit ahead of main) ===
std::fs::write(
feature_auth.join("auth.rs"),
r#"//! Authentication module for user session management.
use std::time::{Duration, SystemTime};
/// A user session with token and expiry.
pub struct Session {
token: String,
expires_at: SystemTime,
}
impl Session {
/// Creates a new session with the given token and TTL.
pub fn new(token: String, ttl: Duration) -> Self {
Self {
token,
expires_at: SystemTime::now() + ttl,
}
}
/// Returns true if the session has not expired.
pub fn is_valid(&self) -> bool {
SystemTime::now() < self.expires_at
}
/// Validates the token format.
pub fn validate_token(token: &str) -> bool {
token.len() >= 32 && token.chars().all(|c| c.is_ascii_alphanumeric())
}
}
"#,
)
.unwrap();
let lib_content = std::fs::read_to_string(feature_auth.join("lib.rs")).unwrap();
std::fs::write(
feature_auth.join("lib.rs"),
format!("mod auth;\n\n{}", lib_content),
)
.unwrap();
repo.run_git_in(&feature_auth, &["add", "auth.rs", "lib.rs"]);
repo.commit_staged_with_age("Add authentication module", 2 * HOUR, &feature_auth);
// === Staged WIP: extend auth + restructure lib (produces both +N and -N in HEAD±) ===
// Extend auth.rs with is_authenticated and tests
std::fs::write(
feature_auth.join("auth.rs"),
r#"//! Authentication module for user session management.
use std::time::{Duration, SystemTime};
/// A user session with token and expiry.
pub struct Session {
token: String,
expires_at: SystemTime,
}
impl Session {
/// Creates a new session with the given token and TTL.
pub fn new(token: String, ttl: Duration) -> Self {
Self {
token,
expires_at: SystemTime::now() + ttl,
}
}
/// Returns true if the session has not expired.
pub fn is_valid(&self) -> bool {
SystemTime::now() < self.expires_at
}
/// Validates the token format.
pub fn validate_token(token: &str) -> bool {
token.len() >= 32 && token.chars().all(|c| c.is_ascii_alphanumeric())
}
}
/// Checks if user is authenticated with a valid session.
pub fn is_authenticated(session: Option<&Session>) -> bool {
session.map(|s| s.is_valid()).unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_validity() {
let session = Session::new("a".repeat(32), Duration::from_secs(3600));
assert!(session.is_valid());
}
#[test]
fn test_validate_token() {
assert!(!Session::validate_token("short"));
assert!(Session::validate_token(&"x".repeat(32)));
}
}
"#,
)
.unwrap();
// Restructure lib.rs: remove test module, add pub use + init
std::fs::write(
feature_auth.join("lib.rs"),
r#"mod auth;
pub use auth::Session;
/// Adds two numbers.
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
/// Initializes the application with default settings.
pub fn init() -> bool {
true
}
"#,
)
.unwrap();
repo.run_git_in(&feature_auth, &["add", "auth.rs", "lib.rs"]);
feature_auth
}
// =============================================================================
// Full README Examples
// =============================================================================
/// Set up a repo for README examples showing realistic worktree states.
///
/// **Project context**: API modernization — migrating from legacy handlers to REST,
/// hardening auth before v2 launch.
///
/// ## Branch Narratives
///
/// **`@ feature-api`** — REST migration in progress
/// Midway through migrating from function-based handlers to a REST module.
/// Staged the new controller base class; still removing the legacy dispatcher.
/// Three commits ready to push once local tests pass.
/// - `+234 -24` main…± — Major refactoring: new Router, handlers, middleware (~250 LOC)
/// - `+` staged, `↑⇡` ahead of main and remote, `⇡3` unpushed commits
///
/// **`^ main`**
/// Teammate merged the auth hotfix while you were refactoring.
/// Need to pull and rebase feature-api before continuing.
/// - `⇣1` behind remote
///
/// **`+ fix-auth`** — Token validation hardening
/// Replaced manual token parsing with constant-time comparison and added rate limiting.
/// Pushed and CI green — waiting on security review before merge.
/// - `+25 -11` main…± — Deleted insecure validation, added proper checks
/// - `|` in sync with remote, ready for merge
///
/// **`exp`** — GraphQL spike
/// Spike branch exploring GraphQL for the subscription API. Added schema definitions
/// and proof-of-concept resolvers with Query, Mutation, and Subscription roots.
/// - `+137` main…± — Schema types, resolvers, pagination (~140 LOC)
/// - `⎇` branch without worktree
///
/// **`wip`** — REST docs (stale)
/// Started API docs last week but got pulled away. Main has since moved on
/// (fix-auth was merged). Needs rebase before continuing.
/// - `↓1` behind main — main advanced while branch was idle
/// - `+33` main…± — Doc skeleton with structure
/// - `⎇` branch without worktree
///
/// Returns feature_api_path for running commands from feature-api.
///
/// NOTE: This function is used for doc generation. It removes fixture worktrees
/// to produce clean output for README/docs. If you need the fixture worktrees,
/// use a different setup function.
fn setup_readme_example_repo(repo: &mut TestRepo) -> std::path::PathBuf {
// Start with clean base (removes fixture worktrees)
repo.remove_fixture_worktrees();
// === Set up main branch with initial codebase ===
// Main has a working API with security issues that fix-auth will harden
std::fs::write(
repo.root_path().join("api.rs"),
r#"//! API module - initial implementation
pub mod auth {
// INSECURE: Manual string comparison vulnerable to timing attacks
pub fn check_token(token: &str) -> bool {
if token.is_empty() { return false; }
// Just check format, no real validation
token.len() > 0 && token.starts_with("tk_")
}
// INSECURE: No rate limiting, no audit logging
pub fn validate_request(token: &str) -> bool {
check_token(token)
}
// INSECURE: Tokens stored in plain text
pub fn store_token(user_id: u32, token: &str) {
std::fs::write(format!("/tmp/tokens/{}", user_id), token).ok();
}
}
pub mod handlers {
pub fn health() -> &'static str { "ok" }
// Legacy endpoint - needs refactoring
pub fn get_user(id: u32) -> String { format!("user:{}", id) }
pub fn get_post(id: u32) -> String { format!("post:{}", id) }
}
"#,
)
.unwrap();
repo.run_git(&["add", "api.rs"]);
repo.commit_staged_with_age("Initial API implementation", DAY, repo.root_path());
repo.setup_remote("main");
// Make main behind its remote: push a teammate's commit, then reset local
// Story: A teammate pushed a hotfix while we were working on features
repo.commit_with_age("Fix production timeout issue", 2 * HOUR);
repo.run_git(&["push", "origin", "main"]);
repo.run_git(&["reset", "--hard", "HEAD~1"]);
// === Create fix-auth worktree ===
// Story: Security audit found the token validation was too weak.
// This branch fixes it by replacing the permissive check with proper validation.
let fix_auth = repo.add_worktree("fix-auth");
// First commit: Replace weak validation with constant-time comparison
std::fs::write(
fix_auth.join("api.rs"),
r#"//! API module - auth hardened
pub mod auth {
use constant_time_eq::constant_time_eq;
/// Validates token with constant-time comparison (timing attack resistant)
pub fn check_token(token: &str) -> bool {
if token.len() < 32 { return false; }
if !token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { return false; }
let prefix = token.as_bytes().get(..3).unwrap_or(&[]);
constant_time_eq(prefix, b"tk_")
}
/// Rate-limited request validation with audit logging
pub fn validate_request(token: &str, client_ip: &str) -> Result<(), AuthError> {
if is_rate_limited(client_ip) {
log_auth_attempt(client_ip, "rate_limited");
return Err(AuthError::RateLimited);
}
if !check_token(token) {
log_auth_attempt(client_ip, "invalid_token");
return Err(AuthError::InvalidToken);
}
Ok(())
}
}
pub mod handlers {
pub fn health() -> &'static str { "ok" }
// Legacy endpoint - needs refactoring
pub fn get_user(id: u32) -> String { format!("user:{}", id) }
pub fn get_post(id: u32) -> String { format!("post:{}", id) }
}
"#,
)
.unwrap();
repo.run_git_in(&fix_auth, &["add", "api.rs"]);
repo.commit_staged_with_age("Harden token validation", 6 * HOUR, &fix_auth);
// Second commit: Add secure token storage
std::fs::write(
fix_auth.join("api.rs"),
r#"//! API module - auth hardened
pub mod auth {
use constant_time_eq::constant_time_eq;
/// Validates token with constant-time comparison (timing attack resistant)
pub fn check_token(token: &str) -> bool {
if token.len() < 32 { return false; }
if !token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { return false; }
let prefix = token.as_bytes().get(..3).unwrap_or(&[]);
constant_time_eq(prefix, b"tk_")
}
/// Rate-limited request validation with audit logging
pub fn validate_request(token: &str, client_ip: &str) -> Result<(), AuthError> {
if is_rate_limited(client_ip) {
log_auth_attempt(client_ip, "rate_limited");
return Err(AuthError::RateLimited);
}
if !check_token(token) {
log_auth_attempt(client_ip, "invalid_token");
return Err(AuthError::InvalidToken);
}
Ok(())
}
/// Stores token hash with per-user salt (never stores plaintext)
pub fn store_token(user_id: u32, token: &str) -> Result<(), AuthError> {
let salt = generate_salt(user_id);
let hash = argon2_hash(token, &salt);
db::tokens().insert(user_id, hash)?;
Ok(())
}
}
pub mod handlers {
pub fn health() -> &'static str { "ok" }
// Legacy endpoint - needs refactoring
pub fn get_user(id: u32) -> String { format!("user:{}", id) }
pub fn get_post(id: u32) -> String { format!("post:{}", id) }
}
"#,
)
.unwrap();
repo.run_git_in(&fix_auth, &["add", "api.rs"]);
repo.commit_staged_with_age("Add secure token storage", 5 * HOUR, &fix_auth);
// Push fix-auth and sync with remote
repo.run_git_in(&fix_auth, &["push", "-u", "origin", "fix-auth"]);
// === Create feature-api worktree ===
// Story: Major API refactoring - replacing the legacy handlers with a proper
// REST structure. This involves deleting the old inline handlers and building
// a modular system with middleware, validation, and caching.
let feature_api = repo.add_worktree("feature-api");
// First commit: Refactor api.rs - remove legacy handlers, add module structure
// This replaces main's monolithic api.rs with a cleaner module layout
std::fs::write(
feature_api.join("api.rs"),
r#"//! API module - refactored for REST architecture
//!
//! This module provides the public interface for the REST API.
//! All handlers have been moved to dedicated route modules.
pub mod routes;
pub mod middleware;
pub mod errors;
// Re-export commonly used types
pub use routes::{Router, Route, Handler};
pub use middleware::{RequestContext, ResponseBuilder};
pub use errors::{ApiError, ApiResult};
"#,
)
.unwrap();
std::fs::write(
feature_api.join("routes.rs"),
r#"//! REST route definitions and handler implementations
use crate::middleware::{RequestContext, ResponseBuilder};
use crate::errors::{ApiError, ApiResult};
pub struct Router {
routes: Vec<Route>,
}
pub struct Route {
method: Method,
path: String,
handler: Box<dyn Handler>,
}
pub trait Handler: Send + Sync {
fn handle(&self, ctx: &RequestContext) -> ApiResult<ResponseBuilder>;
}
impl Router {
pub fn new() -> Self {
Self { routes: Vec::new() }
}
pub fn get<H: Handler + 'static>(&mut self, path: &str, handler: H) -> &mut Self {
self.routes.push(Route {
method: Method::Get,
path: path.to_string(),
handler: Box::new(handler),
});
self
}
pub fn post<H: Handler + 'static>(&mut self, path: &str, handler: H) -> &mut Self {
self.routes.push(Route {
method: Method::Post,
path: path.to_string(),
handler: Box::new(handler),
});
self
}
pub fn route(&self, method: Method, path: &str) -> Option<&dyn Handler> {
self.routes.iter()
.find(|r| r.method == method && r.path == path)
.map(|r| r.handler.as_ref())
}
}
// Health check endpoint
pub struct HealthHandler;
impl Handler for HealthHandler {
fn handle(&self, _ctx: &RequestContext) -> ApiResult<ResponseBuilder> {
Ok(ResponseBuilder::new().status(200).body("ok"))
}
}
// User endpoints
pub struct GetUserHandler;
impl Handler for GetUserHandler {
fn handle(&self, ctx: &RequestContext) -> ApiResult<ResponseBuilder> {
let user_id = ctx.param("id").ok_or(ApiError::BadRequest)?;
// Fetch user from database
Ok(ResponseBuilder::new().status(200).json(&user_id))
}
}
pub struct ListUsersHandler;
impl Handler for ListUsersHandler {
fn handle(&self, ctx: &RequestContext) -> ApiResult<ResponseBuilder> {
let limit = ctx.query("limit").unwrap_or(20);
let offset = ctx.query("offset").unwrap_or(0);
// Paginated user list
Ok(ResponseBuilder::new().status(200).json(&(limit, offset)))
}
}
// Post endpoints
pub struct GetPostHandler;
impl Handler for GetPostHandler {
fn handle(&self, ctx: &RequestContext) -> ApiResult<ResponseBuilder> {
let post_id = ctx.param("id").ok_or(ApiError::BadRequest)?;
Ok(ResponseBuilder::new().status(200).json(&post_id))
}
}
pub struct CreatePostHandler;
impl Handler for CreatePostHandler {
fn handle(&self, ctx: &RequestContext) -> ApiResult<ResponseBuilder> {
let body = ctx.body().ok_or(ApiError::BadRequest)?;
// Validate and create post
Ok(ResponseBuilder::new().status(201).json(&body))
}
}
#[derive(Clone, Copy, PartialEq)]
pub enum Method { Get, Post, Put, Delete }
"#,
)
.unwrap();
repo.run_git_in(&feature_api, &["add", "api.rs", "routes.rs"]);
repo.commit_staged_with_age("Refactor API to REST modules", 4 * HOUR, &feature_api);
repo.run_git_in(&feature_api, &["push", "-u", "origin", "feature-api"]);
// More commits (ahead of remote - unpushed local work)
std::fs::write(
feature_api.join("middleware.rs"),
r#"//! Middleware stack for request processing
use std::time::Instant;
use std::collections::HashMap;
/// Context passed through the middleware chain
pub struct RequestContext {
pub user_id: Option<u32>,
pub started_at: Instant,
pub headers: HashMap<String, String>,
pub params: HashMap<String, String>,
pub query: HashMap<String, String>,
body: Option<Vec<u8>>,
}
impl RequestContext {
pub fn new() -> Self {
Self {
user_id: None,
started_at: Instant::now(),
headers: HashMap::new(),
params: HashMap::new(),
query: HashMap::new(),
body: None,
}
}
pub fn param(&self, key: &str) -> Option<&str> {
self.params.get(key).map(|s| s.as_str())
}
pub fn query<T: std::str::FromStr>(&self, key: &str) -> Option<T> {
self.query.get(key).and_then(|s| s.parse().ok())
}
pub fn body(&self) -> Option<&[u8]> {
self.body.as_deref()
}
pub fn header(&self, key: &str) -> Option<&str> {
self.headers.get(key).map(|s| s.as_str())
}
}
/// Builder for HTTP responses
pub struct ResponseBuilder {
status: u16,
headers: HashMap<String, String>,
body: Option<Vec<u8>>,
}
impl ResponseBuilder {
pub fn new() -> Self {
Self {
status: 200,
headers: HashMap::new(),
body: None,
}
}
pub fn status(mut self, code: u16) -> Self {
self.status = code;
self
}
pub fn header(mut self, key: &str, value: &str) -> Self {
self.headers.insert(key.to_string(), value.to_string());
self
}
pub fn body(mut self, content: &str) -> Self {
self.body = Some(content.as_bytes().to_vec());
self
}
pub fn json<T: serde::Serialize>(mut self, value: &T) -> Self {
self.headers.insert("Content-Type".into(), "application/json".into());
self.body = serde_json::to_vec(value).ok();
self
}
}
/// Timing middleware for performance monitoring
pub fn timing<F, R>(name: &str, f: F) -> R where F: FnOnce() -> R {
let start = Instant::now();
let result = f();
log::debug!("{} completed in {:?}", name, start.elapsed());
result
}
/// Authentication middleware
pub fn authenticate(ctx: &mut RequestContext) -> Result<(), AuthError> {
let token = ctx.header("Authorization")
.and_then(|h| h.strip_prefix("Bearer "))
.ok_or(AuthError::MissingToken)?;
let user_id = validate_token(token)?;
ctx.user_id = Some(user_id);
Ok(())
}
fn validate_token(token: &str) -> Result<u32, AuthError> {
// Token validation logic
if token.len() < 32 { return Err(AuthError::InvalidToken); }
Ok(1) // Placeholder
}
pub enum AuthError { MissingToken, InvalidToken }
"#,
)
.unwrap();
repo.run_git_in(&feature_api, &["add", "middleware.rs"]);
repo.commit_staged_with_age("Add request middleware", 3 * HOUR, &feature_api);
std::fs::write(
feature_api.join("validation.rs"),
r#"//! Request validation
pub fn validate(body: &[u8], headers: &Headers) -> Result<(), Error> {
if body.is_empty() { return Err(Error::EmptyBody); }
if body.len() > MAX_SIZE { return Err(Error::TooLarge); }
if !headers.contains_key("Authorization") { return Err(Error::Unauthorized); }
Ok(())
}
"#,
)
.unwrap();
repo.run_git_in(&feature_api, &["add", "validation.rs"]);
repo.commit_staged_with_age("Add request validation", 2 * HOUR, &feature_api);
std::fs::write(
feature_api.join("tests.rs"),
r#"//! API tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_health() { assert_eq!(routes::health(), "ok"); }
#[test]
fn test_validation_empty() {
assert!(validation::validate(&[], &headers()).is_err());
}
}
"#,
)
.unwrap();
repo.run_git_in(&feature_api, &["add", "tests.rs"]);
repo.commit_staged_with_age("Add API tests", 30 * MINUTE, &feature_api);
// Staged changes: new files + refactor existing (creates mixed +/- for HEAD±)
// Adding caching and rate limiting, plus refactoring validation
std::fs::write(
feature_api.join("cache.rs"),
r#"//! Caching layer
use std::collections::HashMap;
use std::time::{Duration, Instant};
pub struct Cache<T> {
store: HashMap<String, (T, Instant)>,
ttl: Duration,
}
impl<T: Clone> Cache<T> {
pub fn new(ttl_secs: u64) -> Self {
Self { store: HashMap::new(), ttl: Duration::from_secs(ttl_secs) }
}
pub fn get(&self, key: &str) -> Option<T> {
self.store.get(key).and_then(|(v, t)| {
if t.elapsed() < self.ttl { Some(v.clone()) } else { None }
})
}
}
"#,
)
.unwrap();
std::fs::write(
feature_api.join("rate_limit.rs"),
r#"//! Rate limiting
use std::collections::HashMap;
use std::time::{Duration, Instant};
pub struct RateLimiter {
requests: HashMap<String, Vec<Instant>>,
window: Duration,
limit: u32,
}
impl RateLimiter {
pub fn check(&mut self, key: &str) -> bool {
let now = Instant::now();
let reqs = self.requests.entry(key.to_string()).or_default();
reqs.retain(|t| now.duration_since(*t) < self.window);
reqs.len() < self.limit as usize
}
}
"#,
)
.unwrap();
// Refactor validation.rs to use the new error types
std::fs::write(
feature_api.join("validation.rs"),
r#"//! Request validation (refactored)
use crate::error::ValidationError;
pub fn validate(body: &[u8], headers: &Headers) -> Result<(), ValidationError> {
validate_body(body)?;
validate_headers(headers)?;
Ok(())
}
fn validate_body(body: &[u8]) -> Result<(), ValidationError> {
if body.is_empty() { return Err(ValidationError::Empty); }
if body.len() > MAX_SIZE { return Err(ValidationError::TooLarge); }
Ok(())
}
fn validate_headers(h: &Headers) -> Result<(), ValidationError> {
h.get("Authorization").ok_or(ValidationError::Unauthorized)?;
Ok(())
}
"#,
)
.unwrap();
repo.run_git_in(
&feature_api,
&["add", "cache.rs", "rate_limit.rs", "validation.rs"],
);
// === Create branches without worktrees ===
// These demonstrate the --branches flag showing branch-only entries
// Create 'exp' branch with commits (experimental GraphQL work)
// Narrative: Someone explored GraphQL as an alternative to REST, got pretty far with
// schema design and resolvers, but the team decided to stick with REST for now.
let exp_wt = repo.root_path().parent().unwrap().join("temp-exp");
repo.run_git(&["worktree", "add", "-b", "exp", exp_wt.to_str().unwrap()]);
std::fs::write(
exp_wt.join("graphql.rs"),
r#"//! GraphQL schema exploration - evaluating GraphQL for real-time subscriptions
//!
//! This spike branch explores whether GraphQL could replace REST for the subscription
//! API. Key evaluation criteria:
//! - Real-time updates via subscriptions
//! - Efficient data fetching (avoid over-fetching)
//! - Type safety with code generation
use async_graphql::*;
/// Core user type with all fields exposed via GraphQL
#[derive(SimpleObject, Clone)]
pub struct User {
pub id: ID,
pub name: String,
pub email: String,
pub avatar_url: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Blog post with author relationship
#[derive(SimpleObject, Clone)]
pub struct Post {
pub id: ID,
pub title: String,
pub body: String,
pub author: User,
pub published_at: Option<DateTime<Utc>>,
pub tags: Vec<String>,
}
/// Comment on a post
#[derive(SimpleObject, Clone)]
pub struct Comment {
pub id: ID,
pub body: String,
pub author: User,
pub post_id: ID,
pub created_at: DateTime<Utc>,
}
/// Subscription events for real-time updates
#[derive(Clone)]
pub enum SubscriptionEvent {
PostCreated(Post),
PostUpdated(Post),
CommentAdded { post_id: ID, comment: Comment },
}
/// Pagination support
#[derive(InputObject)]
pub struct PaginationInput {
pub limit: Option<i32>,
pub offset: Option<i32>,
pub cursor: Option<String>,
}
#[derive(SimpleObject)]
pub struct PageInfo {
pub has_next_page: bool,
pub has_previous_page: bool,
pub start_cursor: Option<String>,
pub end_cursor: Option<String>,
}
"#,
)
.unwrap();
repo.run_git_in(&exp_wt, &["add", "graphql.rs"]);
repo.commit_staged_with_age("Explore GraphQL schema design", 2 * DAY, &exp_wt);
std::fs::write(
exp_wt.join("resolvers.rs"),
r#"//! GraphQL resolvers - Query, Mutation, and Subscription roots
use crate::graphql::*;
use async_graphql::*;
pub struct QueryRoot;
#[Object]
impl QueryRoot {
/// Fetch a single user by ID
async fn user(&self, ctx: &Context<'_>, id: ID) -> Result<Option<User>> {
let db = ctx.data::<Database>()?;
Ok(db.get_user(&id).await?)
}
/// List users with pagination
async fn users(&self, ctx: &Context<'_>, pagination: Option<PaginationInput>) -> Result<Vec<User>> {
let db = ctx.data::<Database>()?;
let page = pagination.unwrap_or_default();
Ok(db.list_users(page.limit.unwrap_or(20), page.offset.unwrap_or(0)).await?)
}
/// Fetch a single post by ID
async fn post(&self, ctx: &Context<'_>, id: ID) -> Result<Option<Post>> {
let db = ctx.data::<Database>()?;
Ok(db.get_post(&id).await?)
}
/// List posts with optional author filter
async fn posts(&self, ctx: &Context<'_>, author_id: Option<ID>) -> Result<Vec<Post>> {
let db = ctx.data::<Database>()?;
match author_id {
Some(id) => Ok(db.posts_by_author(&id).await?),
None => Ok(db.list_posts().await?),
}
}
}
pub struct MutationRoot;
#[Object]
impl MutationRoot {
/// Create a new post
async fn create_post(&self, ctx: &Context<'_>, title: String, body: String) -> Result<Post> {
let db = ctx.data::<Database>()?;
let user = ctx.data::<AuthenticatedUser>()?;
let post = db.create_post(user.id.clone(), title, body).await?;
Ok(post)
}
/// Add a comment to a post
async fn add_comment(&self, ctx: &Context<'_>, post_id: ID, body: String) -> Result<Comment> {
let db = ctx.data::<Database>()?;
let user = ctx.data::<AuthenticatedUser>()?;
let comment = db.add_comment(user.id.clone(), post_id, body).await?;
Ok(comment)
}
}
pub struct SubscriptionRoot;
#[Subscription]
impl SubscriptionRoot {
/// Subscribe to new comments on a post
async fn comment_added(&self, post_id: ID) -> impl Stream<Item = Comment> {
todo!("Implement subscription stream")
}
/// Subscribe to all post updates
async fn post_updates(&self) -> impl Stream<Item = Post> {
todo!("Implement subscription stream")
}
}
"#,
)
.unwrap();
repo.run_git_in(&exp_wt, &["add", "resolvers.rs"]);
repo.commit_staged_with_age("Add GraphQL resolvers scaffold", 2 * DAY, &exp_wt);
// Remove the worktree but keep the branch
repo.run_git(&["worktree", "remove", exp_wt.to_str().unwrap()]);
// Create 'wip' branch with commits (work-in-progress docs)
// Narrative: Someone started API docs last week. Main has since advanced
// (fix-auth was merged), so wip is now behind and needs a rebase.
// Save current main commit, then add a commit to main (simulating fix-auth merge)
let wip_base = {
let output = repo
.git_command()
.args(["rev-parse", "HEAD"])
.run()
.unwrap();
String::from_utf8_lossy(&output.stdout).trim().to_string()
};
// Add commit to main (simulating fix-auth being merged while wip was idle)
repo.commit_with_age("Merge fix-auth: hardened token validation", 4 * DAY);
// Create wip from the earlier commit (before main advanced)
let wip_wt = repo.root_path().parent().unwrap().join("temp-wip");
repo.run_git(&[
"worktree",
"add",
"-b",
"wip",
wip_wt.to_str().unwrap(),
&wip_base,
]);
std::fs::write(
wip_wt.join("API.md"),
r#"# API Documentation
## Overview
This document describes the REST API endpoints for the application.
## Authentication
All endpoints require a valid Bearer token in the `Authorization` header.
```
Authorization: Bearer <token>
```
## Endpoints
### Users
- `GET /users` - List all users (paginated)
- `GET /users/:id` - Get user by ID
- `POST /users` - Create new user
### Posts
- `GET /posts` - List all posts
- `GET /posts/:id` - Get post by ID
- `POST /posts` - Create new post
## Error Responses
All errors return JSON with `error` and `message` fields.
TODO: Add request/response examples for each endpoint
"#,
)
.unwrap();
repo.run_git_in(&wip_wt, &["add", "API.md"]);
repo.commit_staged_with_age("Start API documentation", 3 * DAY, &wip_wt);
// Remove the worktree but keep the branch
repo.run_git(&["worktree", "remove", wip_wt.to_str().unwrap()]);
// === Create fix-typos worktree (already merged — shows dimmed as removable) ===
// Story: A quick typo fix that was already squash-merged into main.
// The worktree is still around and can be removed. Shows dimmed in list output.
let fix_typos = repo.add_worktree("fix-typos");
repo.run_git_in(&fix_typos, &["push", "-u", "origin", "fix-typos"]);
mock_ci_status(repo, "fix-typos", "passed", "pr", false, Some(410));
// === Mock CI status ===
// CI requires --full flag, but we mock it so examples show realistic output
// Note: main's CI is mocked AFTER the merge commit so the hash matches
// main runs branch CI (no PR) — renders the bare `#`
mock_ci_status(repo, "main", "passed", "branch", false, None);
mock_ci_status(repo, "fix-auth", "passed", "pr", false, Some(408));
// feature-api has unpushed commits, so CI is stale (shows dimmed)
mock_ci_status(repo, "feature-api", "running", "pr", true, Some(412));
// === Mock LLM summaries ===
// Summary requires --full + [list] summary = true + [commit.generation] command
// Pre-populate the cache so tests don't need a real LLM
repo.write_test_config(
r#"
[list]
summary = true
[commit.generation]
command = "echo unused"
"#,
);
mock_summary_cache(
repo,
"fix-auth",
Some(&fix_auth),
"Harden auth with constant-time token validation",
);
mock_summary_cache(
repo,
"feature-api",
Some(&feature_api),
"Refactor API to REST architecture with middleware",
);
mock_summary_cache(repo, "exp", None, "Explore GraphQL schema and resolvers");
mock_summary_cache(repo, "wip", None, "Start API documentation");
feature_api
}
/// Mock CI status by writing to file-based cache.
///
/// `pr_number` also ratchets the `pr-number/max.json` width hint, so the CI
/// column renders at its steady-state width (as if a previous run had
/// already cached the number).
pub(crate) fn mock_ci_status(
repo: &TestRepo,
branch: &str,
status: &str,
source: &str,
is_stale: bool,
pr_number: Option<u64>,
) {
// Get HEAD commit for the branch
let output = repo
.git_command()
.args(["rev-parse", branch])
.run()
.unwrap();
let head = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Build the cache JSON (matches CachedCiStatus struct)
let number_json = pr_number
.map(|n| format!(r##","number":{{"number":{n},"sigil":"#"}}"##))
.unwrap_or_default();
let cache_json = format!(
r#"{{"status":{{"ci_status":"{}","source":"{}","is_stale":{}{}}},"checked_at":{},"head":"{}","branch":"{}"}}"#,
status,
source,
is_stale,
number_json,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
head,
branch
);
// Get git common dir for cache location
let output = repo
.git_command()
.args(["rev-parse", "--git-common-dir"])
.run()
.unwrap();
let git_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Resolve relative path if needed
let git_path = if std::path::Path::new(&git_dir).is_absolute() {
std::path::PathBuf::from(&git_dir)
} else {
repo.root_path().join(&git_dir)
};
// Create cache directory and write file
let cache_dir = git_path.join("wt").join("cache").join("ci-status");
std::fs::create_dir_all(&cache_dir).unwrap();
// Use the same sanitization as production code for cache filenames
let safe_branch = worktrunk::path::sanitize_for_filename(branch);
let cache_file = cache_dir.join(format!("{safe_branch}.json"));
std::fs::write(&cache_file, &cache_json).unwrap();
// Ratchet the width hint like a real fetch would
if let Some(n) = pr_number {
let max_file_dir = git_path.join("wt").join("cache").join("pr-number");
std::fs::create_dir_all(&max_file_dir).unwrap();
let max_file = max_file_dir.join("max.json");
let existing = std::fs::read_to_string(&max_file)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v["number"].as_u64())
.unwrap_or(0);
if n > existing {
std::fs::write(&max_file, format!(r#"{{"number":{n}}}"#)).unwrap();
}
}
}
/// Mock summary cache by computing the real diff hash and writing a cache entry.
///
/// Mirrors `summary::generate_summary_core` — computes the combined diff
/// (branch + working tree), SHA-256-hashes it, and writes
/// `summary/{branch}/{hash}.json` with a CachedSummary body.
fn mock_summary_cache(
repo: &TestRepo,
branch: &str,
worktree_path: Option<&std::path::Path>,
summary: &str,
) {
use sha2::{Digest, Sha256};
// Compute combined diff (matching compute_combined_diff in summary.rs)
let mut diff = String::new();
// Branch diff: main...<branch>
let head_output = repo
.git_command()
.args(["rev-parse", branch])
.run()
.unwrap();
let head = String::from_utf8_lossy(&head_output.stdout)
.trim()
.to_string();
let merge_base = format!("main...{}", head);
if let Ok(output) = repo.git_command().args(["diff", &merge_base]).run() {
let branch_diff = String::from_utf8_lossy(&output.stdout);
diff.push_str(&branch_diff);
}
// Working tree diff (only if worktree exists)
if let Some(wt_path) = worktree_path {
let wt_str = wt_path.display().to_string();
if let Ok(output) = repo
.git_command()
.args(["-C", &wt_str, "diff", "HEAD"])
.run()
{
let wt_diff = String::from_utf8_lossy(&output.stdout);
if !wt_diff.trim().is_empty() {
diff.push_str(&wt_diff);
}
}
}
// SHA-256 prefix (matches summary::hash_diff — first 16 hex chars).
let mut hasher = Sha256::new();
hasher.update(diff.as_bytes());
let digest = hasher.finalize();
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut diff_hash = String::with_capacity(16);
for &b in digest.iter().take(8) {
diff_hash.push(HEX[(b >> 4) as usize] as char);
diff_hash.push(HEX[(b & 0xf) as usize] as char);
}
// Write cache file
let cache_json = format!(
r#"{{"summary":"{}","branch":"{}","generated_at":0}}"#,
summary, branch
);
let output = repo
.git_command()
.args(["rev-parse", "--git-common-dir"])
.run()
.unwrap();
let git_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
let git_path = if std::path::Path::new(&git_dir).is_absolute() {
std::path::PathBuf::from(&git_dir)
} else {
repo.root_path().join(&git_dir)
};
let safe_branch = worktrunk::path::sanitize_for_filename(branch);
let branch_dir = git_path
.join("wt")
.join("cache")
.join("summary")
.join(&safe_branch);
std::fs::create_dir_all(&branch_dir).unwrap();
let cache_file = branch_dir.join(format!("{diff_hash}.json"));
std::fs::write(&cache_file, &cache_json).unwrap();
}
// =============================================================================
// Quick Start Snapshot Tests
// =============================================================================
/// Generate Quick Start example: `wt switch --create feature-auth` output
///
/// Shows the switch output when creating a new worktree from main.
/// Sets WORKTRUNK_DIRECTIVE_CD_FILE to simulate shell integration being active,
/// which suppresses the "Cannot change directory" warning.
/// Output: tests/snapshots/integration__integration_tests__list__quickstart_switch.snap
#[rstest]
fn test_quickstart_switch(mut repo: TestRepo) {
setup_quickstart_base(&mut repo);
// Create a temp file for the directive outside the repo to avoid making main dirty
let directive_file = repo
.root_path()
.parent()
.unwrap()
.join(".wt-directive-temp");
std::fs::write(&directive_file, "").unwrap();
assert_cmd_snapshot!("quickstart_switch", {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["--create", "feature-auth"], None);
cmd.env("WORKTRUNK_DIRECTIVE_CD_FILE", &directive_file);
cmd
});
}
/// Generate Quick Start example: Simple `wt list` output
///
/// Shows minimal 2-worktree scenario: main + feature-auth.
/// Output: tests/snapshots/integration__integration_tests__list__quickstart_list.snap
#[rstest]
fn test_quickstart_list(mut repo: TestRepo) {
let feature_auth = setup_quickstart_repo(&mut repo);
assert_cmd_snapshot!(
"quickstart_list",
list_snapshots::command_readme(&repo, &feature_auth)
);
}
/// Generate Quick Start example: `wt merge` output
///
/// Shows merge output when merging feature-auth into main.
/// Sets WORKTRUNK_DIRECTIVE_CD_FILE to simulate shell integration being active,
/// which suppresses the "Cannot change directory" warning.
/// Output: tests/snapshots/integration__integration_tests__list__quickstart_merge.snap
#[rstest]
fn test_quickstart_merge(mut repo: TestRepo) {
setup_quickstart_base(&mut repo);
// Ensure main worktree is completely clean (no staged or unstaged changes)
repo.run_git(&["checkout", "--", "."]);
repo.run_git(&["clean", "-fd"]);
// Create feature-auth worktree with one commit
let feature_auth = repo.add_worktree("feature-auth");
// Add authentication module (full WIP version — all staged, no commit, for wt merge)
std::fs::write(
feature_auth.join("auth.rs"),
r#"//! Authentication module for user session management.
use std::time::{Duration, SystemTime};
/// A user session with token and expiry.
pub struct Session {
token: String,
expires_at: SystemTime,
}
impl Session {
/// Creates a new session with the given token and TTL.
pub fn new(token: String, ttl: Duration) -> Self {
Self {
token,
expires_at: SystemTime::now() + ttl,
}
}
/// Returns true if the session has not expired.
pub fn is_valid(&self) -> bool {
SystemTime::now() < self.expires_at
}
/// Validates the token format.
pub fn validate_token(token: &str) -> bool {
token.len() >= 32 && token.chars().all(|c| c.is_ascii_alphanumeric())
}
}
/// Checks if user is authenticated with a valid session.
pub fn is_authenticated(session: Option<&Session>) -> bool {
session.map(|s| s.is_valid()).unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_validity() {
let session = Session::new("a".repeat(32), Duration::from_secs(3600));
assert!(session.is_valid());
}
#[test]
fn test_validate_token() {
assert!(!Session::validate_token("short"));
assert!(Session::validate_token(&"x".repeat(32)));
}
}
"#,
)
.unwrap();
// Update lib.rs to include the new module
let lib_content = std::fs::read_to_string(feature_auth.join("lib.rs")).unwrap();
std::fs::write(
feature_auth.join("lib.rs"),
format!("mod auth;\n\n{}", lib_content),
)
.unwrap();
// Stage files but don't commit - let wt merge do the committing
repo.run_git_in(&feature_auth, &["add", "auth.rs", "lib.rs"]);
// Create a temp file for the directive outside the repo to avoid making main dirty
let directive_file = repo
.root_path()
.parent()
.unwrap()
.join(".wt-directive-temp");
std::fs::write(&directive_file, "").unwrap();
// Merge feature-auth into main
assert_cmd_snapshot!("quickstart_merge", {
let mut cmd = make_snapshot_cmd(&repo, "merge", &["main"], Some(&feature_auth));
cmd.env("WORKTRUNK_DIRECTIVE_CD_FILE", &directive_file);
// Inline generation command (the suite's standard mock): a binary
// path here would bake the per-test temp dir into the snapshot's
// env block, which redactions don't cover for this key.
cmd.env(
"WORKTRUNK_COMMIT__GENERATION__COMMAND",
"cat >/dev/null && echo 'Add authentication module'",
);
cmd
});
}
// =============================================================================
// Full README Snapshot Tests
// =============================================================================
/// Generate README example: Basic `wt list` output
///
/// Shows worktree states with status symbols, divergence, and remote tracking.
/// Uses narrower width (100 cols) to fit in doc site code blocks.
/// Output: tests/snapshots/integration__integration_tests__list__readme_example_list.snap
#[rstest]
fn test_readme_example_list(mut repo: TestRepo) {
let feature_api = setup_readme_example_repo(&mut repo);
assert_cmd_snapshot!(
"readme_example_list",
list_snapshots::command_readme(&repo, &feature_api)
);
}
/// Generate README example: `wt list --full` output
///
/// Shows additional columns: main…± (line diffs), CI status, and LLM summaries.
/// Uses wider terminal (134 cols) than the base example to fit the Summary column.
/// Output: tests/snapshots/integration__integration_tests__list__readme_example_list_full.snap
#[rstest]
fn test_readme_example_list_full(mut repo: TestRepo) {
let feature_api = setup_readme_example_repo(&mut repo);
assert_cmd_snapshot!("readme_example_list_full", {
let mut cmd = list_snapshots::command_readme(&repo, &feature_api);
cmd.arg("--full");
cmd.env("COLUMNS", "134");
cmd
});
}
/// Generate README example: `wt list --branches --full` output
///
/// Shows branches without worktrees (⎇ symbol) alongside worktrees, plus CI status.
/// Uses wider terminal (134 cols) than the base example to fit the Summary column.
/// Output: tests/snapshots/integration__integration_tests__list__readme_example_list_branches.snap
#[rstest]
fn test_readme_example_list_branches(mut repo: TestRepo) {
let feature_api = setup_readme_example_repo(&mut repo);
assert_cmd_snapshot!("readme_example_list_branches", {
let mut cmd = list_snapshots::command_readme(&repo, &feature_api);
cmd.args(["--branches", "--full"]);
cmd.env("COLUMNS", "134");
cmd
});
}
/// Generate config state marker example: `wt list` with user markers
///
/// Shows how user markers appear in the Status column alongside git symbols.
/// Used by `wt config state marker --help` and docs via placeholder expansion.
/// Output: tests/snapshots/integration__integration_tests__list__readme_example_list_marker.snap
#[rstest]
fn test_readme_example_list_marker(mut repo: TestRepo) {
setup_user_marker_example(&mut repo);
assert_cmd_snapshot!(
"readme_example_list_marker",
list_snapshots::command_readme(&repo, repo.root_path())
);
}
/// Generate tips-patterns.md example: dev server per worktree workflow
///
/// Uses the realistic README example repo and adds URL config.
/// URLs appear dimmed (no servers running) - realistic for documentation.
#[rstest]
fn test_tips_dev_server_workflow(mut repo: TestRepo) {
// Set up the realistic README example repo
let _feature_api = setup_readme_example_repo(&mut repo);
// Add project config with URL template for dev servers
repo.write_project_config(
r#"[post-start]
server = "npm run dev -- --port {{ branch | hash_port }} &"
[list]
url = "http://localhost:{{ branch | hash_port }}"
"#,
);
// Run from main worktree (URLs dim since no servers running)
assert_cmd_snapshot!(
"tips_dev_server_workflow",
list_snapshots::command_readme(&repo, repo.root_path())
);
}
#[rstest]
fn test_list_no_progressive_flag(mut repo: TestRepo) {
repo.add_worktree("feature");
// Explicitly force buffered mode
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--no-progressive");
cmd
});
}
#[rstest]
fn test_list_progressive_with_branches(mut repo: TestRepo) {
// Create worktrees
repo.add_worktree("feature-a");
// Create branches without worktrees
repo.create_branch("orphan-1");
repo.create_branch("orphan-2");
// Critical: test that --branches works with --progressive
// This ensures progressive mode supports the --branches flag
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.args(["--progressive", "--branches"]);
cmd
});
}
#[rstest]
fn test_list_first_output_writes_buffered_stdout(mut repo: TestRepo) {
repo.add_worktree("feature-bench");
let output = list_snapshots::command(&repo, repo.root_path())
.env("WORKTRUNK_FIRST_OUTPUT", "1")
.output()
.unwrap();
assert!(
output.status.success(),
"WORKTRUNK_FIRST_OUTPUT should exit 0: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(
stdout.as_bytes().iter().filter(|&&b| b == b'\n').count(),
1,
"WORKTRUNK_FIRST_OUTPUT should emit exactly the first stdout line; stdout:\n{stdout}"
);
assert!(!stdout.trim().is_empty());
assert!(output.stderr.is_empty());
}
// ============================================================================
// Task DAG Mode Tests
// ============================================================================
#[rstest]
fn test_list_task_dag_full_with_diffs(mut repo: TestRepo) {
// Create worktree with changes
let feature_a = repo.add_worktree("feature-a");
std::fs::write(feature_a.join("new.txt"), "content").unwrap();
// Create another worktree with commits
let _feature_b = repo.add_worktree_with_commit("feature-b", "file.txt", "test", "Test commit");
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.args(["--progressive", "--full"]);
cmd
});
}
#[rstest]
fn test_list_task_dag_with_upstream(mut repo: TestRepo) {
repo.commit("Initial commit on main");
repo.setup_remote("main");
// Branch in sync
let in_sync = repo.add_worktree("in-sync");
repo.run_git_in(&in_sync, &["push", "-u", "origin", "in-sync"]);
// Branch ahead
let ahead = repo.add_worktree("ahead");
repo.run_git_in(&ahead, &["push", "-u", "origin", "ahead"]);
std::fs::write(ahead.join("ahead.txt"), "ahead").unwrap();
repo.run_git_in(&ahead, &["add", "."]);
repo.run_git_in(&ahead, &["commit", "-m", "Ahead commit"]);
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.args(["--progressive", "--full"]);
cmd
});
}
#[rstest]
fn test_list_task_dag_many_worktrees(mut repo: TestRepo) {
// Create 10 worktrees to test parallel processing
for i in 1..=10 {
repo.add_worktree(&format!("feature-{}", i));
}
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--progressive");
cmd
});
}
#[rstest]
fn test_list_task_dag_with_locked_worktree(mut repo: TestRepo) {
repo.add_worktree("normal");
repo.add_worktree("locked");
repo.lock_worktree("locked", Some("Testing task DAG with locked worktree"));
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--progressive");
cmd
});
}
#[rstest]
fn test_list_task_dag_detached_head(repo: TestRepo) {
repo.detach_head();
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--progressive");
cmd
});
}
#[rstest]
fn test_list_task_dag_ordering_stability(mut repo: TestRepo) {
// Test that task_dag mode produces same ordering as buffered mode
// Regression test for progressive rendering order instability
let current_path = setup_timestamped_worktrees(&mut repo);
// Run from feature-current worktree
// Expected order: main, feature-current, then by timestamp: feature-newest, feature-middle, feature-oldest
assert_cmd_snapshot!("task_dag_ordering_stability", {
let mut cmd = list_snapshots::command(&repo, &current_path);
cmd.arg("--progressive");
cmd
});
}
#[rstest]
fn test_list_progressive_vs_buffered_identical_data(mut repo: TestRepo) {
// Critical test: Verify that progressive and buffered modes collect identical data
// despite using different rendering strategies (real-time UI vs collect-then-print).
// This ensures consolidation on task DAG data collection works correctly.
//
// Note: We compare JSON output, not table output, because:
// - Progressive mode renders headers before knowing final column widths (uses estimates)
// - Buffered mode renders headers after data collection (uses actual widths)
// - The DATA must be identical, but table formatting may differ slightly
// Create varied worktrees to test multiple data points
repo.add_worktree("feature-a");
repo.add_worktree("feature-b");
// Modify a worktree to have uncommitted changes
let feature_a_path = repo.worktree_path("feature-a");
std::fs::write(feature_a_path.join("changes.txt"), "test").unwrap();
// Run both modes with JSON output to compare data (not formatting)
let progressive_output = {
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.args(["--progressive", "--format=json"]);
cmd.output().unwrap()
};
let buffered_output = {
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.args(["--no-progressive", "--format=json"]);
cmd.output().unwrap()
};
// Both should succeed
assert!(
progressive_output.status.success(),
"Progressive mode failed: {}",
String::from_utf8_lossy(&progressive_output.stderr)
);
assert!(
buffered_output.status.success(),
"Buffered mode failed: {}",
String::from_utf8_lossy(&buffered_output.stderr)
);
// Parse JSON outputs
let progressive_json: serde_json::Value =
serde_json::from_slice(&progressive_output.stdout).unwrap();
let buffered_json: serde_json::Value = serde_json::from_slice(&buffered_output.stdout).unwrap();
// The JSON data should be identical (ignoring display fields which may have formatting differences)
// Compare the structured data to ensure both modes collect the same information
assert_eq!(
progressive_json,
buffered_json,
"Progressive and buffered modes produced different data!\n\nProgressive:\n{}\n\nBuffered:\n{}",
serde_json::to_string_pretty(&progressive_json).unwrap(),
serde_json::to_string_pretty(&buffered_json).unwrap()
);
}
#[rstest]
fn test_list_with_c_flag(mut repo: TestRepo) {
// Create some worktrees
repo.add_worktree("feature-a");
repo.add_worktree("feature-b");
// Run wt -C <repo_path> list from a completely different directory
assert_cmd_snapshot!("list_with_c_flag", {
let mut cmd = wt_command();
cmd.args(["-C", repo.root_path().to_str().unwrap(), "list"]);
// Run from system temp dir to ensure -C is actually being used
cmd.current_dir(std::env::temp_dir());
cmd
});
}
#[rstest]
fn test_list_large_diffs_alignment(mut repo: TestRepo) {
// Worktree with large uncommitted changes and ahead commits
// Use a longer branch name similar to user's "wli-sequence" to trigger column width
let large_wt = repo.add_worktree("feature-changes");
// Create a file with many lines for large diff
let large_content = (1..=100)
.map(|i| format!("line {}", i))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(large_wt.join("large.txt"), &large_content).unwrap();
// Commit it
repo.run_git_in(&large_wt, &["add", "."]);
repo.run_git_in(&large_wt, &["commit", "-m", "Add 100 lines"]);
// Add large uncommitted changes (both added and deleted lines)
// Add a new file with many lines
std::fs::write(large_wt.join("uncommitted.txt"), &large_content).unwrap();
// Modify the existing file to create deletions
let modified_content = (1..=50)
.map(|i| format!("modified line {}", i))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(large_wt.join("large.txt"), &modified_content).unwrap();
// Add another new file with many lines
let another_large = (1..=80)
.map(|i| format!("another line {}", i))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(large_wt.join("another.txt"), &another_large).unwrap();
// Set user marker
repo.set_marker("feature-changes", "🤖");
// Worktree with short name to show gap before Status column
let short_wt = repo.add_worktree("fix");
std::fs::write(short_wt.join("quick.txt"), "quick fix").unwrap();
// Set user marker for short branch
repo.set_marker("fix", "💬");
// Worktree with diverged status and working tree changes
let diverged_wt = repo.add_worktree("diverged");
// Commit some changes
let diverged_content = (1..=60)
.map(|i| format!("diverged line {}", i))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(diverged_wt.join("test.txt"), &diverged_content).unwrap();
repo.run_git_in(&diverged_wt, &["add", "."]);
repo.run_git_in(&diverged_wt, &["commit", "-m", "Diverged commit"]);
// Add uncommitted changes
let modified_diverged = (1..=40)
.map(|i| format!("modified diverged line {}", i))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(diverged_wt.join("test.txt"), &modified_diverged).unwrap();
// Set user marker
repo.set_marker("diverged", "💬");
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[rstest]
fn test_list_status_column_padding_with_emoji(mut repo: TestRepo) {
// Create worktree matching user's exact scenario: "wli-sequence"
let wli_seq = repo.add_worktree("wli-sequence");
// Create large working tree changes: +164, -111
// Need ~164 added lines and ~111 deleted lines
let initial_content = (1..=200)
.map(|i| format!("original line {}", i))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(wli_seq.join("main.txt"), &initial_content).unwrap();
repo.run_git_in(&wli_seq, &["add", "."]);
repo.run_git_in(&wli_seq, &["commit", "-m", "Initial content"]);
// Modify to create desired diff: remove ~111 lines, add different content
let modified_content = (1..=89)
.map(|i| format!("original line {}", i))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(wli_seq.join("main.txt"), &modified_content).unwrap();
// Add new file with ~164 lines to get +164
let new_content = (1..=164)
.map(|i| format!("new line {}", i))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(wli_seq.join("new.txt"), &new_content).unwrap();
// Set user marker emoji 🤖
repo.set_marker("wli-sequence", "🤖");
// Create "pr-link" worktree with different status (fewer symbols, same emoji type)
let pr_link = repo.add_worktree("pr-link");
// Commit to make it ahead
std::fs::write(pr_link.join("pr.txt"), "pr content").unwrap();
repo.run_git_in(&pr_link, &["add", "."]);
repo.run_git_in(&pr_link, &["commit", "-m", "PR commit"]);
// Set same emoji type
repo.set_marker("pr-link", "🤖");
// Create "main-symbol" with different emoji
let main_sym = repo.add_worktree("main-symbol");
std::fs::write(main_sym.join("sym.txt"), "symbol").unwrap();
repo.run_git_in(&main_sym, &["add", "."]);
repo.run_git_in(&main_sym, &["commit", "-m", "Symbol commit"]);
repo.set_marker("main-symbol", "💬");
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[rstest]
fn test_list_maximum_working_tree_symbols(mut repo: TestRepo) {
// Test that all 5 working tree symbols can appear simultaneously:
// ? (untracked), ! (modified), + (staged), » (renamed), ✘ (deleted)
// This verifies the maximum width of the working_tree position (5 chars)
let feature = repo.add_worktree("feature");
// Create initial files to manipulate
std::fs::write(feature.join("file-a.txt"), "original a").unwrap();
std::fs::write(feature.join("file-b.txt"), "original b").unwrap();
std::fs::write(feature.join("file-c.txt"), "original c").unwrap();
std::fs::write(feature.join("file-d.txt"), "original d").unwrap();
repo.run_git_in(&feature, &["add", "."]);
repo.run_git_in(&feature, &["commit", "-m", "Add files"]);
// 1. Create untracked file (?)
std::fs::write(feature.join("untracked.txt"), "new file").unwrap();
// 2. Modify tracked file without staging (!)
std::fs::write(feature.join("file-a.txt"), "modified content").unwrap();
// 3. Stage some changes (+)
std::fs::write(feature.join("file-b.txt"), "staged changes").unwrap();
repo.run_git_in(&feature, &["add", "file-b.txt"]);
// 4. Rename a file and stage it (»)
repo.run_git_in(&feature, &["mv", "file-c.txt", "renamed-c.txt"]);
// 5. Delete a file in index (✘)
repo.run_git_in(&feature, &["rm", "file-d.txt"]);
// Result should show: ?!+»✘
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[rstest]
fn test_list_maximum_status_with_git_operation(mut repo: TestRepo) {
// Test maximum status symbols including an in-progress git operation:
// ?!+ (working_tree) + = (conflicts) + ↻ (operation) + ↕ (diverged) + ⊠ (locked) + 🤖 (user marker)
// This pushes the Status column to ~10-11 chars of actual content
// Create initial commit with a file that will conflict
std::fs::write(
repo.root_path().join("conflict.txt"),
"original line 1\noriginal line 2\n",
)
.unwrap();
std::fs::write(repo.root_path().join("shared.txt"), "shared content").unwrap();
repo.commit("Initial commit");
// Create feature worktree
let feature = repo.add_worktree("feature");
// Feature makes changes
std::fs::write(
feature.join("conflict.txt"),
"feature line 1\nfeature line 2\n",
)
.unwrap();
std::fs::write(feature.join("feature.txt"), "feature-specific content").unwrap();
repo.run_git_in(&feature, &["add", "."]);
repo.run_git_in(&feature, &["commit", "-m", "Feature changes"]);
// Main makes conflicting changes
std::fs::write(
repo.root_path().join("conflict.txt"),
"main line 1\nmain line 2\n",
)
.unwrap();
std::fs::write(repo.root_path().join("main-only.txt"), "main content").unwrap();
repo.run_git(&["add", "."]);
repo.run_git(&["commit", "-m", "Main conflicting changes"]);
// Start rebase which will create conflicts and git operation state
let rebase_output = repo
.git_command()
.args(["rebase", "main"])
.current_dir(&feature)
.run()
.unwrap();
// Rebase should fail with conflicts - verify we're in rebase state
assert!(
!rebase_output.status.success(),
"Rebase should fail with conflicts"
);
// Now add working tree symbols while in rebase state
// 1. Untracked file (?)
std::fs::write(feature.join("untracked.txt"), "untracked during rebase").unwrap();
// 2. Modified file (!) - modify a non-conflicting file
std::fs::write(feature.join("feature.txt"), "modified during rebase").unwrap();
// 3. Staged file (+) - stage the conflict resolution
std::fs::write(feature.join("new-staged.txt"), "staged during rebase").unwrap();
repo.run_git_in(&feature, &["add", "new-staged.txt"]);
// Lock the worktree (⊠)
repo.run_git(&["worktree", "lock", feature.to_str().unwrap()]);
// Set user marker emoji (🤖)
repo.set_marker("feature", "🤖");
// Result should show: ?!+ (working_tree) + = (conflicts) + ↻ (rebase) + ↕ (diverged) + ⊠ (locked) + 🤖 (user marker)
// Use --full to enable conflict detection
assert_cmd_snapshot!("maximum_status_with_git_operation", {
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--full");
cmd
});
}
#[rstest]
fn test_list_maximum_status_symbols(mut repo: TestRepo) {
// Test the maximum status symbols possible:
// ?!+»✘ (5) + ⚠ (1) + ⊠ (1) + ↕ (1) + ⇅ (1) + 🤖 (2) = 11 chars
// Missing: ✖ (actual conflicts), ↻ (git operation - can't have with divergence), ◇ (bare), ⚠ (prunable)
// Create initial commit on main with shared files
std::fs::write(repo.root_path().join("shared.txt"), "original").unwrap();
std::fs::write(repo.root_path().join("file-a.txt"), "a").unwrap();
std::fs::write(repo.root_path().join("file-b.txt"), "b").unwrap();
std::fs::write(repo.root_path().join("file-c.txt"), "c").unwrap();
std::fs::write(repo.root_path().join("file-d.txt"), "d").unwrap();
repo.commit("Initial commit");
// Create feature worktree
let feature = repo.add_worktree("feature");
// Make feature diverge from main (ahead) with conflicting change
std::fs::write(feature.join("shared.txt"), "feature version").unwrap();
std::fs::write(feature.join("feature.txt"), "feature content").unwrap();
repo.run_git_in(&feature, &["add", "."]);
repo.run_git_in(&feature, &["commit", "-m", "Feature work"]);
// Create a real bare remote so upstream exists, but keep all graph crafting local for determinism
repo.setup_remote("main");
// Remember the shared base (Feature work)
let base_sha = {
let output = repo
.git_command()
.args(["rev-parse", "HEAD"])
.current_dir(&feature)
.run()
.unwrap();
String::from_utf8_lossy(&output.stdout).trim().to_string()
};
// Remote-only commit
std::fs::write(feature.join("remote-file.txt"), "remote content").unwrap();
repo.git_command()
.args(["add", "remote-file.txt"])
.current_dir(&feature)
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Remote commit"])
.current_dir(&feature)
.run()
.unwrap();
let remote_sha = {
let output = repo
.git_command()
.args(["rev-parse", "HEAD"])
.current_dir(&feature)
.run()
.unwrap();
String::from_utf8_lossy(&output.stdout).trim().to_string()
};
// Reset back to base so the remote commit is not in the local branch history
repo.git_command()
.args(["reset", "--hard", &base_sha])
.current_dir(&feature)
.run()
.unwrap();
// Local-only commit (divergence on the local side)
std::fs::write(feature.join("local-file.txt"), "local content").unwrap();
repo.git_command()
.args(["add", "local-file.txt"])
.current_dir(&feature)
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Local commit"])
.current_dir(&feature)
.run()
.unwrap();
// Wire up upstream tracking deterministically: point origin/feature at the remote-only commit
repo.git_command()
.args(["update-ref", "refs/remotes/origin/feature", &remote_sha])
.current_dir(&feature)
.run()
.unwrap();
repo.git_command()
.args(["branch", "--set-upstream-to=origin/feature", "feature"])
.current_dir(&feature)
.run()
.unwrap();
// Make main advance with conflicting change (so feature is behind with conflicts)
std::fs::write(repo.root_path().join("shared.txt"), "main version").unwrap();
std::fs::write(repo.root_path().join("main2.txt"), "more main").unwrap();
repo.run_git(&["add", "."]);
repo.run_git(&["commit", "-m", "Main advances"]);
// Add all 5 working tree symbol types (without rebase, so we keep divergence)
// 1. Untracked (?)
std::fs::write(feature.join("untracked.txt"), "untracked").unwrap();
// 2. Modified (!)
std::fs::write(feature.join("feature.txt"), "modified").unwrap();
// 3. Staged (+)
std::fs::write(feature.join("new-staged.txt"), "staged content").unwrap();
repo.run_git_in(&feature, &["add", "new-staged.txt"]);
// 4. Renamed (»)
repo.run_git_in(&feature, &["mv", "file-c.txt", "renamed-c.txt"]);
// 5. Deleted (✘)
repo.run_git_in(&feature, &["rm", "file-d.txt"]);
// Lock the worktree (⊠)
repo.run_git(&["worktree", "lock", feature.to_str().unwrap()]);
// Set user marker emoji (🤖)
repo.set_marker("feature", "🤖");
// Result should show 11 chars: ?!+»✘=⊠↕⇅🤖
assert_cmd_snapshot!("maximum_status_symbols", {
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--full");
cmd
});
}
///
/// This specifically tests the WorkingTreeConflicts task which:
/// 1. Uses `git write-tree` to snapshot the index (with temp index for unstaged/untracked)
/// 2. Runs merge-tree against the default branch to detect conflicts
///
/// Both kinds of conflicts are checked in both `wt list` and `wt list --full`:
/// - Commit-level: HEAD conflicts with main
/// - Working tree: uncommitted changes conflict with main
#[rstest]
fn test_list_working_tree_conflicts(mut repo: TestRepo) {
// Create initial commit with a shared file
std::fs::write(repo.root_path().join("shared.txt"), "original content").unwrap();
repo.commit("Initial commit");
// Create feature worktree - at this point feature and main are identical
let feature = repo.add_worktree("feature");
// Advance main with a change to the shared file
std::fs::write(repo.root_path().join("shared.txt"), "main's version").unwrap();
repo.commit("Main changes shared.txt");
// Feature's HEAD is still at the original commit - no commit-level conflict
// because feature hasn't committed anything that conflicts
// Now add uncommitted changes to feature that would conflict with main
std::fs::write(feature.join("shared.txt"), "feature's uncommitted version").unwrap();
// Without --full: WorkingTreeConflicts runs and detects the uncommitted conflict.
assert_cmd_snapshot!(
"working_tree_conflicts_without_full",
list_snapshots::command(&repo, repo.root_path())
);
// With --full: same conflict detection, plus the wide status column and extra
// post-skeleton tasks that non-full mode skips.
assert_cmd_snapshot!("working_tree_conflicts_with_full", {
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--full");
cmd
});
}
///
/// Even with --full, if the working tree is clean, we skip the working-tree check
/// and just use the commit-level conflict detection.
#[rstest]
fn test_list_full_clean_working_tree_uses_commit_conflicts(mut repo: TestRepo) {
// Create initial commit with a shared file
std::fs::write(repo.root_path().join("shared.txt"), "original content").unwrap();
repo.commit("Initial commit");
// Create feature worktree
let feature = repo.add_worktree("feature");
// Make a conflicting commit on feature (different change to shared.txt)
std::fs::write(feature.join("shared.txt"), "feature's committed version").unwrap();
repo.run_git_in(&feature, &["add", "."]);
repo.run_git_in(&feature, &["commit", "-m", "Feature changes shared.txt"]);
// Advance main with a different change to the shared file
std::fs::write(repo.root_path().join("shared.txt"), "main's version").unwrap();
repo.commit("Main changes shared.txt");
// Feature has a committed conflict, working tree is clean
// Both with and without --full should show the conflict symbol
assert_cmd_snapshot!(
"commit_conflicts_without_full",
list_snapshots::command(&repo, repo.root_path())
);
assert_cmd_snapshot!("commit_conflicts_with_full", {
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--full");
cmd
});
}
#[rstest]
fn test_list_warns_when_default_branch_missing_worktree(repo: TestRepo) {
// Move primary worktree off the default branch so no worktree holds it
repo.switch_primary_to("develop");
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
///
/// Corrupts a branch ref to point to a non-existent commit, which causes
/// ahead_behind and other git operations to fail. Verifies the warning
/// message appears after the table.
///
/// The dummy SHA is `…001` (non-null) on purpose: a NULL_OID branch ref
/// reads as an unborn worktree and is handled by the commit-task skip path
/// (no warning, by design — see #2936). A non-null invalid SHA still flows
/// through the regular task path and surfaces the warning this test
/// validates.
#[rstest]
fn test_list_shows_warning_on_git_error(mut repo: TestRepo) {
repo.add_worktree("feature");
// Corrupt the feature branch ref to point to a non-existent commit.
// Branch refs are stored in the main repo's .git/refs/heads, not the worktree.
let git_dir = repo.root_path().join(".git");
let ref_path = git_dir.join("refs/heads/feature");
// Non-null invalid SHA so the worktree is not treated as unborn.
std::fs::write(&ref_path, "0000000000000000000000000000000000000001\n").unwrap();
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
///
/// Corrupts a detached worktree's HEAD file to a non-null but unresolvable
/// SHA. The `collect()` batch filters out `NULL_OID`, so a non-null invalid
/// SHA reaches `commit_details_many` and fails the whole batch — exercising
/// the `unwrap_or_else` warning path that reports "Failed to batch-fetch
/// commit details" on stderr.
#[rstest]
fn test_list_warns_when_commit_details_batch_fails(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature");
// Detach the worktree's HEAD and rewrite its HEAD file to a non-null but
// unresolvable SHA. `git worktree list --porcelain` happily reports the
// invalid SHA, which flows into the commit-details batch. Delete the
// branch ref so `for-each-ref refs/heads/` (branch inventory scan) doesn't
// choke on the now-dangling branch.
repo.run_git_in(&worktree_path, &["checkout", "--detach"]);
repo.run_git(&["branch", "-D", "feature"]);
let worktree_dir_name = worktree_path.file_name().unwrap().to_str().unwrap();
let head_path = repo
.root_path()
.join(".git/worktrees")
.join(worktree_dir_name)
.join("HEAD");
std::fs::write(&head_path, "0000000000000000000000000000000000000001\n").unwrap();
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
///
/// Creates a true orphan branch using `git checkout --orphan` which has no merge base
/// with main. Verifies no error warning appears and the branch shows as unmerged.
#[rstest]
fn test_list_handles_orphan_branch(repo: TestRepo) {
// Create an orphan branch (no common ancestor with main)
repo.git_command()
.args(["checkout", "--orphan", "assets"])
.run()
.unwrap();
// Clear working tree and create new content
repo.git_command().args(["rm", "-rf", "."]).run().unwrap();
std::fs::write(repo.root_path().join("asset.txt"), "asset content\n").unwrap();
repo.git_command().args(["add", "."]).run().unwrap();
repo.git_command()
.args(["commit", "-m", "Add asset"])
.run()
.unwrap();
// Go back to main
repo.git_command().args(["checkout", "main"]).run().unwrap();
// Verify no merge base exists (this confirms we have a true orphan branch)
let output = repo
.git_command()
.args(["merge-base", "main", "assets"])
.run()
.unwrap();
assert!(
!output.status.success(),
"Expected no merge base for orphan branch"
);
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--branches");
cmd
});
}
///
/// When a worktree directory is deleted but git still knows about it, the worktree
/// is marked as "prunable". We should skip git operations for these worktrees
/// rather than showing confusing "Failed to execute" errors.
#[rstest]
fn test_list_skips_operations_for_prunable_worktrees(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature");
// Delete the worktree directory (making it prunable)
std::fs::remove_dir_all(&worktree_path).unwrap();
// Verify git sees it as prunable
let output = repo
.git_command()
.args(["worktree", "list", "--porcelain"])
.run()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("prunable"),
"Expected worktree to be prunable after deleting directory"
);
// wt list should show the prunable worktree with ⊟ symbol but NO error warnings
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
/// Tests that wt list works correctly when the configured default branch doesn't exist.
///
/// When a user sets `wt config state default-branch set develop` but the `develop`
/// branch doesn't exist locally, `wt list` degrades gracefully — columns needing
/// default branch show empty cells rather than failing with git errors.
///
/// With `--branches`, the opportunistic stale-default check surfaces a
/// warning using the already-enumerated branch set (no extra fork). Plain
/// `wt list` (worktrees only) does not have the full branch set and so does
/// not emit the warning — the user sees a clearer `StaleDefaultBranch`
/// error the moment they run a default-branch-consuming command.
#[rstest]
fn test_list_with_nonexistent_default_branch(repo: TestRepo) {
// Set default branch to a non-existent branch
repo.run_git(&["config", "worktrunk.default-branch", "nonexistent"]);
// wt list should degrade gracefully (empty columns for main-related data)
// when configured default branch doesn't exist locally
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
/// Tests that wt list --full works correctly when the configured default branch doesn't exist.
///
/// The --full flag enables expensive tasks like BranchDiff.
/// These should also degrade gracefully when default_branch is None.
#[rstest]
fn test_list_full_with_nonexistent_default_branch(repo: TestRepo) {
// Set default branch to a non-existent branch
repo.run_git(&["config", "worktrunk.default-branch", "nonexistent"]);
// wt list --full should also work, with expensive tasks returning defaults
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--full");
cmd
});
}
/// With `--branches`, `wt list` emits the stale-default-branch warning via
/// the already-enumerated branch set — no extra fork.
#[rstest]
fn test_list_branches_with_nonexistent_default_branch(repo: TestRepo) {
repo.run_git(&["config", "worktrunk.default-branch", "nonexistent"]);
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--branches");
cmd
});
}
/// Tests that the current worktree indicator (@) is correct for nested worktrees.
///
/// When worktrees are placed inside other worktrees (e.g., `.worktrees/` layout),
/// the current detection must use git rev-parse --show-toplevel to correctly identify
/// which worktree contains the cwd, rather than prefix matching which would match
/// the parent worktree first.
///
/// Regression test for: prefix matching with starts_with would incorrectly match
/// the main worktree when running from a nested worktree.
#[rstest]
fn test_list_nested_worktree_current_indicator(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 wt list from inside the nested worktree
// The @ indicator should appear on "feature", not on "main"
assert_cmd_snapshot!(list_snapshots::command(&repo, &nested_worktree));
}
/// Tests JSON output for nested worktrees shows is_current on the correct worktree.
#[rstest]
fn test_list_nested_worktree_json_is_current(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 wt list --format=json from inside the nested worktree
let output = repo
.wt_command()
.current_dir(&nested_worktree)
.args(["list", "--format=json"])
.output()
.unwrap();
let json: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).unwrap();
// Find the worktree entries
let main_wt = json.iter().find(|w| w["branch"] == "main").unwrap();
let feature_wt = json.iter().find(|w| w["branch"] == "feature").unwrap();
// feature should be current, main should not
assert_eq!(
feature_wt["is_current"], true,
"Nested worktree 'feature' should be marked as current"
);
assert_eq!(
main_wt["is_current"], false,
"Parent worktree 'main' should NOT be marked as current"
);
}
/// Tests that `wt list` handles a freshly `git init`-ed repo with no commits.
///
/// Empty repos have the null OID for HEAD and no branches. Without proper handling,
/// this causes task failures (git operations on null OID), garbage data (00000000, 56y),
/// and spurious "default branch does not exist locally" warnings.
#[test]
fn test_list_empty_repo() {
let repo = TestRepo::empty();
let _settings_guard =
setup_snapshot_settings_for_paths(repo.root_path(), &repo.worktrees).bind_to_scope();
// Pre-set default branch so the unborn-HEAD case has something to resolve to
repo.run_git(&["config", "worktrunk.default-branch", "main"]);
// Should show the branch with empty commit columns and no errors
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
/// Tests JSON output for an empty repo (no commits).
#[test]
fn test_list_empty_repo_json() {
let repo = TestRepo::empty();
let output = repo
.wt_command()
.args(["list", "--format=json"])
.current_dir(repo.root_path())
.output()
.unwrap();
assert!(
output.status.success(),
"wt list --format=json should succeed"
);
let json: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json.len(), 1, "Should have one worktree entry");
let item = &json[0];
assert_eq!(item["branch"], "main");
assert_eq!(item["commit"]["sha"], "");
assert_eq!(item["commit"]["short_sha"], "");
}
/// Regression for #2936: a linked worktree on an unborn branch (`git worktree
/// add --orphan`) reports `HEAD 0000…` in `worktree list --porcelain`. The
/// working-tree-diff and working-tree-conflicts tasks both reference HEAD /
/// the null OID and fail with `fatal: ambiguous argument 'HEAD'` and
/// `fatal: 0000… is not a valid object`. Adding untracked content to the
/// orphan worktree forces both code paths to run.
#[rstest]
fn test_list_unborn_worktree_no_task_failures(repo: TestRepo) {
repo.commit("initial");
let orphan_path = repo.root_path().parent().unwrap().join("repo.orphan");
let out = repo
.git_command()
.args([
"worktree",
"add",
"--orphan",
"-b",
"orphan",
orphan_path.to_str().unwrap(),
])
.run()
.unwrap();
assert!(
out.status.success(),
"git worktree add --orphan failed: {}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
// Untracked content forces WorkingTreeConflictsTask to run merge-tree
// against the null OID rather than short-circuiting on a clean tree.
std::fs::write(orphan_path.join("untracked.txt"), "content\n").unwrap();
let output = repo
.wt_command()
.args(["list"])
.current_dir(repo.root_path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("working-tree diff") && !stderr.contains("working-tree conflict check"),
"wt list should not surface working-tree task failures for unborn worktrees, got stderr:\n{stderr}"
);
assert!(
!stderr.contains("ambiguous argument 'HEAD'")
&& !stderr.contains("0000000000000000000000000000000000000000"),
"wt list should not run HEAD/null-OID commands against unborn worktrees, got stderr:\n{stderr}"
);
assert!(
output.status.success(),
"wt list should succeed; stderr:\n{stderr}"
);
}
/// Regression: the `wt list` integration column ORs over local AND upstream,
/// matching `Repository::integration_reason`. A branch whose content lives in
/// LOCAL `main` (via a merge commit) must still render as integrated when
/// `main` and `origin/main` have diverged — symmetric to the merged-on-remote
/// case covered by `test_list_integrated_when_squash_merged_on_remote_with_local_diverged`
/// below. Mirrors `test_remove_merged_locally_when_upstream_diverged` in
/// `remove.rs`.
///
/// Uses `--no-ff` so the feature ref's HEAD differs from `main`'s HEAD: the
/// `IsAncestor` parallel task is the load-bearing signal here, exercising the
/// OR-over-targets fix. A `--ff-only` merge would collapse to "same commit"
/// (`MainState::Empty`), which already worked because counts are computed
/// against the default branch directly.
#[rstest]
fn test_list_integrated_when_merged_locally_with_upstream_diverged(
#[from(repo_with_remote)] mut repo: TestRepo,
) {
let remote_path = repo.remote_path().unwrap().to_path_buf();
// Advance origin/main with a remote-only commit so local and upstream diverge.
let github_sim = repo.home_path().join("github-sim-list-local-merge");
repo.run_git_in(
repo.home_path(),
&[
"clone",
remote_path.to_str().unwrap(),
"github-sim-list-local-merge",
],
);
std::fs::write(github_sim.join("remote-only.txt"), "remote only").unwrap();
repo.run_git_in(&github_sim, &["add", "remote-only.txt"]);
repo.run_git_in(&github_sim, &["commit", "-m", "Remote-only main commit"]);
repo.run_git_in(&github_sim, &["push", "origin", "main"]);
// Merge a feature into local main with --no-ff so feature HEAD is an
// ancestor of (but not equal to) main HEAD.
repo.add_worktree("feature-list-local");
let feature_path = repo.worktree_path("feature-list-local");
std::fs::write(feature_path.join("feature.txt"), "feature").unwrap();
repo.run_git_in(feature_path, &["add", "feature.txt"]);
repo.run_git_in(feature_path, &["commit", "-m", "Add feature"]);
repo.run_git(&[
"merge",
"--no-ff",
"-m",
"Merge feature",
"feature-list-local",
]);
repo.run_git(&["fetch", "origin"]);
let local_main = repo.git_output(&["rev-parse", "main"]);
let origin_main = repo.git_output(&["rev-parse", "origin/main"]);
assert_ne!(
local_main, origin_main,
"main and origin/main should differ"
);
assert!(
!repo
.git_command()
.args(["merge-base", "--is-ancestor", "main", "origin/main"])
.run()
.unwrap()
.status
.success(),
"local main must not be an ancestor of origin/main",
);
assert!(
!repo
.git_command()
.args(["merge-base", "--is-ancestor", "origin/main", "main"])
.run()
.unwrap()
.status
.success(),
"origin/main must not be an ancestor of local main",
);
let output = repo
.wt_command()
.args(["list", "--format=json"])
.output()
.unwrap();
assert!(output.status.success(), "wt list should succeed");
let json: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).unwrap();
let feature = json
.iter()
.find(|w| w["branch"] == "feature-list-local")
.expect("feature-list-local should appear in wt list output");
assert_eq!(
feature["main_state"], "integrated",
"main_state must be \"integrated\" when local main contains the feature \
even though origin/main has diverged — instead got entry: {feature}"
);
assert!(
!feature["integration_reason"].is_null(),
"integration_reason must be set — instead got entry: {feature}"
);
}
/// Regression companion to the test above: branch squash-merged on
/// `origin/main` must still render as integrated when local `main` has its own
/// unique commits (the diverged case where the safety path used to be the
/// only way to spot the merge). Mirrors
/// `test_remove_squash_merged_on_remote_when_local_main_diverged`.
#[rstest]
fn test_list_integrated_when_squash_merged_on_remote_with_local_diverged(
#[from(repo_with_remote)] repo: TestRepo,
) {
let remote_path = repo.remote_path().unwrap().to_path_buf();
// Build, push, and remote-squash-merge a feature branch.
repo.run_git(&["checkout", "-b", "feature-list-remote-squash"]);
std::fs::write(repo.root_path().join("feature-remote.txt"), "initial").unwrap();
repo.run_git(&["add", "feature-remote.txt"]);
repo.run_git(&["commit", "-m", "Add feature"]);
std::fs::write(repo.root_path().join("feature-remote.txt"), "final").unwrap();
repo.run_git(&["add", "feature-remote.txt"]);
repo.run_git(&["commit", "-m", "Finalize feature"]);
repo.run_git(&["push", "-u", "origin", "feature-list-remote-squash"]);
repo.run_git(&["checkout", "main"]);
let github_sim = repo.home_path().join("github-sim-list-remote-squash");
repo.run_git_in(
repo.home_path(),
&[
"clone",
remote_path.to_str().unwrap(),
"github-sim-list-remote-squash",
],
);
repo.run_git_in(
&github_sim,
&["merge", "--squash", "origin/feature-list-remote-squash"],
);
repo.run_git_in(&github_sim, &["commit", "-m", "Add feature (#1)"]);
repo.run_git_in(&github_sim, &["push", "origin", "main"]);
// Fetch the remote squash; then advance local main with a unique commit so
// local and upstream diverge.
repo.run_git(&["fetch", "origin"]);
std::fs::write(repo.root_path().join("local-only.txt"), "local only").unwrap();
repo.run_git(&["add", "local-only.txt"]);
repo.run_git(&["commit", "-m", "Local-only main commit"]);
let local_main = repo.git_output(&["rev-parse", "main"]);
let origin_main = repo.git_output(&["rev-parse", "origin/main"]);
assert_ne!(
local_main, origin_main,
"local main should diverge from origin/main"
);
let output = repo
.wt_command()
.args(["list", "--branches", "--format=json"])
.output()
.unwrap();
assert!(output.status.success(), "wt list should succeed");
let json: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).unwrap();
let feature = json
.iter()
.find(|w| w["branch"] == "feature-list-remote-squash")
.expect("feature-list-remote-squash should appear in wt list output");
assert!(
!feature["integration_reason"].is_null(),
"integration_reason must be set for a remotely-squash-merged branch even when \
local main has diverged — instead got entry: {feature}"
);
assert_eq!(
feature["main_state"], "integrated",
"main_state must be \"integrated\" — instead got entry: {feature}"
);
}
/// Regression: `wt list` against a worktree whose `<gitdir>/index` file is
/// absent must not surface `working-tree-conflicts (Failed to copy index
/// file)`. A missing index is semantically empty (nothing staged), so the
/// conflict probe should compute against the working tree as if the real
/// index were empty.
#[rstest]
fn test_list_tolerates_missing_index(mut repo: TestRepo) {
std::fs::write(repo.root_path().join("shared.txt"), "original").unwrap();
repo.commit("Initial commit");
let feature = repo.add_worktree("feature");
// Dirty the worktree so WorkingTreeConflictsTask exercises the temp-index path.
std::fs::write(feature.join("shared.txt"), "feature changes").unwrap();
std::fs::write(feature.join("untracked.txt"), "extra\n").unwrap();
// Delete the worktree's index file — the state the dispatching session
// observed across 37 of 38 user worktrees. The linked worktree's gitdir
// is recorded in its `.git` pointer file (`gitdir: <abs path>`).
let dot_git = std::fs::read_to_string(feature.join(".git")).unwrap();
let feature_gitdir = std::path::PathBuf::from(
dot_git
.trim()
.strip_prefix("gitdir: ")
.expect("worktree .git points at a gitdir")
.trim(),
);
let feature_index = feature_gitdir.join("index");
std::fs::remove_file(&feature_index).unwrap();
assert!(
!feature_index.exists(),
"precondition: feature index removed"
);
let output = repo
.wt_command()
.args(["list", "--full"])
.current_dir(repo.root_path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{stdout}{stderr}");
assert!(
!combined.contains("Failed to copy index file"),
"missing index must not surface as a copy error.\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
!combined.contains("working-tree conflict check ("),
"missing index must not produce a working-tree conflict task error.\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
!feature_index.exists(),
"wt list must not resurrect the real index"
);
}
/// Recursively strips write permission from a repository's object database and
/// restores the original modes on drop, so a test can exercise the read-only
/// sandbox path without leaving an unremovable directory behind.
#[cfg(unix)]
struct ReadOnlyObjectDirectory {
modes: Vec<(std::path::PathBuf, u32)>,
}
#[cfg(unix)]
impl ReadOnlyObjectDirectory {
fn new(repo: &TestRepo) -> Self {
use std::os::unix::fs::PermissionsExt;
fn collect_dirs(path: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
out.push(path.to_path_buf());
for entry in std::fs::read_dir(path).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
collect_dirs(&path, out);
}
}
}
let mut dirs = Vec::new();
collect_dirs(&repo.root_path().join(".git/objects"), &mut dirs);
let modes = dirs
.into_iter()
.map(|path| {
let original = std::fs::metadata(&path).unwrap().permissions().mode();
// Keep read + execute (traversal), drop every write bit.
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(original & !0o222))
.unwrap();
(path, original)
})
.collect();
Self { modes }
}
}
#[cfg(unix)]
impl Drop for ReadOnlyObjectDirectory {
fn drop(&mut self) {
use std::os::unix::fs::PermissionsExt;
// Restore deepest-first so a parent is never re-locked before its
// children are restored.
for (path, mode) in self.modes.iter().rev() {
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(*mode));
}
}
}
/// `wt list --full` must keep working when the Git object database is
/// read-only (a managed sandbox). Its merge and conflict probes write
/// ephemeral objects — `merge-tree --write-tree` for the integration diff and
/// `write-tree` against a temp index for the dirty-worktree conflict check —
/// which `Repository::redirect_objects_if_read_only` reroutes into a temporary
/// object database. The analysis stays complete instead of erroring with
/// "insufficient permission for adding an object".
#[cfg(unix)]
#[rstest]
fn test_list_full_survives_read_only_object_database(mut repo: TestRepo) {
// Explicit schema keeps stderr free of the unset-schema nag.
repo.write_test_config("[list]\njson-schema = 1\n");
// Diverged, cleanly-mergeable topology: `feature` adds one file, `main`
// adds another. Merging them yields a genuinely new tree, so the
// integration probe must write an object (unlike a fast-forward).
let feature =
repo.add_worktree_with_commit("feature", "feature.txt", "feature\n", "Add feature");
repo.commit("main diverges");
// Dirty the feature worktree so the conflict probe takes the temp-index
// `write-tree` path — the exact command the original report saw fail.
std::fs::write(feature.join("feature.txt"), "feature edited\n").unwrap();
// All object-writing setup is done; freeze the object database.
let _read_only = ReadOnlyObjectDirectory::new(&repo);
let output = repo
.wt_command()
.args(["list", "--full", "--format=json"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"wt list --full must succeed with a read-only object database.\nstderr:\n{stderr}"
);
for marker in [
"insufficient permission",
"unable to create",
"Operation not permitted",
"failed to write",
] {
assert!(
!stdout.contains(marker) && !stderr.contains(marker),
"a read-only object database leaked a write error ({marker}).\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
}
// The merge analysis actually ran (not silently degraded): the integration
// probe wrote to the temporary store and produced a concrete diverged
// classification with the would-merge diff.
let items: Vec<serde_json::Value> = serde_json::from_str(&stdout).unwrap();
let feature = items
.iter()
.find(|w| w["branch"] == "feature")
.expect("feature worktree present in list output");
assert_eq!(feature["main_state"], "diverged", "entry: {feature}");
assert_eq!(feature["main"]["ahead"], 1, "entry: {feature}");
assert_eq!(feature["main"]["behind"], 1, "entry: {feature}");
assert_eq!(feature["main"]["diff"]["added"], 1, "entry: {feature}");
}
/// `wt list statusline` is a separate entry point from `wt list` (it calls
/// `populate_item` directly, not `collect()`) and renders on every Claude Code
/// prompt inside the managed read-only sandbox this targets. Its merge/conflict
/// probes must redirect too, or the statusline silently drops `main_state` and
/// the integration symbols in a read-only object database.
#[cfg(unix)]
#[rstest]
fn test_list_statusline_survives_read_only_object_database(mut repo: TestRepo) {
repo.write_test_config("[list]\njson-schema = 1\n");
let feature =
repo.add_worktree_with_commit("feature", "feature.txt", "feature\n", "Add feature");
repo.commit("main diverges");
// All object-writing setup is done; freeze the object database.
let _read_only = ReadOnlyObjectDirectory::new(&repo);
// Statusline reports the current worktree, so run it from the feature tree.
let output = repo
.wt_command()
.current_dir(&feature)
.args(["list", "statusline", "--format", "json"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"wt list statusline must succeed with a read-only object database.\nstderr:\n{stderr}"
);
for marker in [
"insufficient permission",
"unable to create",
"Operation not permitted",
"failed to write",
] {
assert!(
!stdout.contains(marker) && !stderr.contains(marker),
"a read-only object database leaked a write error ({marker}).\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
}
// The integration classification comes from the object-writing probe; it
// must be present, not silently dropped to null.
let items: Vec<serde_json::Value> = serde_json::from_str(&stdout).unwrap();
let feature = &items[0];
assert_eq!(feature["branch"], "feature", "entry: {feature}");
assert_eq!(feature["main_state"], "diverged", "entry: {feature}");
}