use crate::common::{
DAY, HOUR, MINUTE, TestRepo, list_snapshots, make_snapshot_cmd,
mock_commands::create_mock_llm_quickstart, repo, repo_with_remote,
setup_snapshot_settings_for_paths, wt_command,
};
use insta_cmd::assert_cmd_snapshot;
use path_slash::PathExt as _;
use rstest::rstest;
fn setup_timestamped_worktrees(repo: &mut TestRepo) -> std::path::PathBuf {
repo.commit("Initial commit on main");
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();
}
let current_path = repo.add_worktree("feature-current");
commit_at_time(
repo,
¤t_path,
"current.txt",
"2025-01-01T01:00:00Z",
"01:00",
);
let newest_path = repo.add_worktree("feature-newest");
commit_at_time(
repo,
&newest_path,
"newest.txt",
"2025-01-01T03:00:00Z",
"03:00",
);
let middle_path = repo.add_worktree("feature-middle");
commit_at_time(
repo,
&middle_path,
"middle.txt",
"2025-01-01T02:00:00Z",
"02:00",
);
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_single_worktree(repo: TestRepo) {
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[rstest]
fn test_list_multiple_worktrees(mut repo: TestRepo) {
repo.add_worktree("feature-a");
repo.add_worktree("feature-b");
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[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()
));
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["switch", "feature"])
.current_dir(repo.root_path());
cmd.output().unwrap();
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.args(["switch", "main"]).current_dir(&feature_path);
cmd.output().unwrap();
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) {
repo.add_worktree("feature");
repo.detach_head_in_worktree("feature");
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[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) {
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) {
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) {
repo.add_worktree("short");
repo.add_worktree("medium-name");
repo.add_worktree("very-long-branch-name-here");
repo.add_worktree("with-changes");
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[rstest]
fn test_list_json_with_metadata(mut repo: TestRepo) {
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
});
}
#[rstest]
fn test_list_json_tree_matches_main_after_merge(mut repo: TestRepo) {
let feature_path = repo.add_worktree_with_commit(
"feature-merged",
"feature.txt",
"feature content",
"Feature commit",
);
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"]);
repo.run_git_in(
&feature_path,
&["merge", "main", "-m", "Merge main into feature"],
);
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) {
repo.create_branch("feature-without-worktree");
repo.create_branch("another-branch");
repo.create_branch("fix-bug");
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) {
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) {
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) {
repo.create_branch("remote-feature-1");
repo.create_branch("remote-feature-2");
repo.push_branch("remote-feature-1");
repo.push_branch("remote-feature-2");
repo.run_git(&["branch", "-D", "remote-feature-1", "remote-feature-2"]);
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) {
repo.create_branch("local-only-1");
repo.create_branch("local-only-2");
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"]);
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) {
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"],
);
repo.create_branch("remote-only");
repo.push_branch("remote-only");
repo.run_git(&["branch", "-D", "remote-only"]);
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) {
repo.create_branch("tracked-branch");
repo.run_git(&["push", "-u", "origin", "tracked-branch"]);
repo.create_branch("remote-only");
repo.push_branch("remote-only");
repo.run_git(&["branch", "-D", "remote-only"]);
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) {
repo.create_branch("feature-remote");
repo.push_branch("feature-remote");
repo.run_git(&["branch", "-D", "feature-remote"]);
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
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) {
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,
]);
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"
);
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}"
);
}
#[rstest]
fn test_list_json_with_display_fields(mut repo: TestRepo) {
repo.commit("Initial commit on main");
repo.add_worktree("feature-ahead");
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"],
);
std::fs::write(feature_path.join("uncommitted.txt"), "uncommitted").unwrap();
std::fs::write(feature_path.join("feature.txt"), "modified content").unwrap();
repo.add_worktree("feature-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);
assert_cmd_snapshot!(list_snapshots::command(&repo, ¤t_path));
}
#[rstest]
fn test_list_with_upstream_tracking(mut repo: TestRepo) {
repo.commit("Initial commit on main");
repo.setup_remote("main");
let in_sync_wt = repo.add_worktree("in-sync");
repo.run_git_in(&in_sync_wt, &["push", "-u", "origin", "in-sync"]);
let ahead_wt = repo.add_worktree("ahead");
repo.run_git_in(&ahead_wt, &["push", "-u", "origin", "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"]);
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"]);
repo.run_git_in(&behind_wt, &["reset", "--hard", "HEAD~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"]);
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"]);
let no_upstream_wt = repo.add_worktree("no-upstream");
repo.run_git_in(&no_upstream_wt, &["status", "--porcelain"]);
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
});
}
#[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()));
}
#[rstest]
fn test_list_with_user_marker(mut repo: TestRepo) {
for branch in &["feature-a", "feature-b", "feature-c"] {
let worktree_path = repo
.root_path()
.parent()
.unwrap()
.join(format!("repo.{}", branch));
if worktree_path.exists() {
let _ = repo
.git_command()
.args([
"worktree",
"remove",
"--force",
worktree_path.to_str().unwrap(),
])
.run();
}
let _ = repo.git_command().args(["branch", "-D", branch]).run();
}
repo.commit_with_age("Initial commit", DAY);
let _feature_wt = repo.add_worktree_with_commit(
"feature-api",
"api.rs",
"// API implementation",
"Add REST API endpoints",
);
repo.set_marker("feature-api", "🤖");
let review_wt = repo.add_worktree_with_commit(
"review-ui",
"component.tsx",
"// UI component",
"Add dashboard component",
);
std::fs::write(review_wt.join("styles.css"), "/* pending styles */").unwrap();
repo.set_marker("review-ui", "💬");
let wip_wt = repo.add_worktree("wip-docs");
std::fs::write(wip_wt.join("README.md"), "# Documentation").unwrap();
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);
repo.add_worktree("with-status");
repo.set_marker("with-status", "🔧");
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) {
std::fs::write(
repo.root_path().join("conflict.txt"),
"original line 1\noriginal line 2\n",
)
.unwrap();
repo.commit("Initial commit");
let feature = repo.add_worktree_with_commit(
"feature",
"conflict.txt",
"feature line 1\nfeature line 2\n",
"Feature 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"]);
let rebase_output = repo
.git_command()
.current_dir(&feature)
.args(["rebase", "main"])
.run()
.unwrap();
assert!(
!rebase_output.status.success(),
"Rebase should fail with conflicts"
);
assert_cmd_snapshot!({
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) {
repo.run_git(&["branch", "branch-only"]);
repo.set_marker("branch-only", "🌿");
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) {
repo.add_worktree("emoji");
repo.set_marker("emoji", "🔄");
repo.add_worktree("multi");
repo.set_marker("multi", "👨💻");
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
fn remove_fixture_worktrees(repo: &mut TestRepo) {
for branch in &["feature-a", "feature-b", "feature-c"] {
let worktree_path = repo
.root_path()
.parent()
.unwrap()
.join(format!("repo.{}", branch));
if worktree_path.exists() {
let _ = repo
.git_command()
.args([
"worktree",
"remove",
"--force",
worktree_path.to_str().unwrap(),
])
.run();
}
let _ = repo.git_command().args(["branch", "-D", branch]).run();
}
}
fn setup_quickstart_base(repo: &mut TestRepo) {
remove_fixture_worktrees(repo);
repo.run_git(&["config", "worktrunk.hints.worktree-path", "true"]);
std::fs::write(
repo.root_path().join("README.md"),
"# My Project\n\nA Rust application.\n",
)
.unwrap();
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");
}
fn setup_quickstart_repo(repo: &mut TestRepo) -> std::path::PathBuf {
setup_quickstart_base(repo);
let feature_auth = repo.add_worktree("feature-auth");
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);
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();
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
}
fn setup_readme_example_repo(repo: &mut TestRepo) -> std::path::PathBuf {
remove_fixture_worktrees(repo);
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");
repo.commit_with_age("Fix production timeout issue", 2 * HOUR);
repo.run_git(&["push", "origin", "main"]);
repo.run_git(&["reset", "--hard", "HEAD~1"]);
let fix_auth = repo.add_worktree("fix-auth");
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);
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);
repo.run_git_in(&fix_auth, &["push", "-u", "origin", "fix-auth"]);
let feature_api = repo.add_worktree("feature-api");
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"]);
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);
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();
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"],
);
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);
repo.run_git(&["worktree", "remove", exp_wt.to_str().unwrap()]);
let wip_base = {
let output = repo
.git_command()
.args(["rev-parse", "HEAD"])
.run()
.unwrap();
String::from_utf8_lossy(&output.stdout).trim().to_string()
};
repo.commit_with_age("Merge fix-auth: hardened token validation", 4 * DAY);
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);
repo.run_git(&["worktree", "remove", wip_wt.to_str().unwrap()]);
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", "pull-request", false);
mock_ci_status(repo, "main", "passed", "pull-request", false);
mock_ci_status(repo, "fix-auth", "passed", "pull-request", false);
mock_ci_status(repo, "feature-api", "running", "pull-request", true);
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
}
fn mock_ci_status(repo: &TestRepo, branch: &str, status: &str, source: &str, is_stale: bool) {
let output = repo
.git_command()
.args(["rev-parse", branch])
.run()
.unwrap();
let head = String::from_utf8_lossy(&output.stdout).trim().to_string();
let cache_json = format!(
r#"{{"status":{{"ci_status":"{}","source":"{}","is_stale":{}}},"checked_at":{},"head":"{}","branch":"{}"}}"#,
status,
source,
is_stale,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
head,
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 cache_dir = git_path.join("wt").join("cache").join("ci-status");
std::fs::create_dir_all(&cache_dir).unwrap();
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();
}
fn mock_summary_cache(
repo: &TestRepo,
branch: &str,
worktree_path: Option<&std::path::Path>,
summary: &str,
) {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut diff = String::new();
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);
}
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);
}
}
}
let mut hasher = DefaultHasher::new();
diff.hash(&mut hasher);
let diff_hash = hasher.finish();
let cache_json = format!(
r#"{{"summary":"{}","diff_hash":{},"branch":"{}"}}"#,
summary, diff_hash, 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 cache_dir = git_path.join("wt").join("cache").join("summaries");
std::fs::create_dir_all(&cache_dir).unwrap();
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();
}
#[rstest]
fn test_quickstart_switch(mut repo: TestRepo) {
setup_quickstart_base(&mut repo);
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_FILE", &directive_file);
cmd
});
}
#[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)
);
}
#[rstest]
fn test_quickstart_merge(mut repo: TestRepo) {
setup_quickstart_base(&mut repo);
repo.run_git(&["checkout", "--", "."]);
repo.run_git(&["clean", "-fd"]);
let feature_auth = repo.add_worktree("feature-auth");
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();
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"]);
let directive_file = repo
.root_path()
.parent()
.unwrap()
.join(".wt-directive-temp");
std::fs::write(&directive_file, "").unwrap();
let mock_bin_dir = repo.root_path().parent().unwrap().join("mock-bin");
create_mock_llm_quickstart(&mock_bin_dir);
let llm_name = if cfg!(windows) { "llm.exe" } else { "llm" };
let llm_path = mock_bin_dir.join(llm_name);
assert_cmd_snapshot!("quickstart_merge", {
let mut cmd = make_snapshot_cmd(&repo, "merge", &["main"], Some(&feature_auth));
cmd.env("WORKTRUNK_DIRECTIVE_FILE", &directive_file);
cmd.env("MOCK_CONFIG_DIR", &mock_bin_dir);
cmd.env(
"WORKTRUNK_COMMIT__GENERATION__COMMAND",
llm_path.to_slash_lossy().as_ref(),
);
cmd
});
}
#[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)
);
}
#[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", "130");
cmd
});
}
#[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", "130");
cmd
});
}
#[rstest]
fn test_readme_example_list_marker(mut repo: TestRepo) {
remove_fixture_worktrees(&mut repo);
repo.commit_with_age("Initial commit", DAY);
let _feature_wt = repo.add_worktree_with_commit(
"feature-api",
"api.rs",
"// API implementation",
"Add REST API endpoints",
);
repo.set_marker("feature-api", "🤖");
let review_wt = repo.add_worktree_with_commit(
"review-ui",
"component.tsx",
"// UI component",
"Add dashboard component",
);
std::fs::write(review_wt.join("styles.css"), "/* pending styles */").unwrap();
repo.set_marker("review-ui", "💬");
let wip_wt = repo.add_worktree("wip-docs");
std::fs::write(wip_wt.join("README.md"), "# Documentation").unwrap();
assert_cmd_snapshot!(
"readme_example_list_marker",
list_snapshots::command_readme(&repo, repo.root_path())
);
}
#[rstest]
fn test_tips_dev_server_workflow(mut repo: TestRepo) {
let _feature_api = setup_readme_example_repo(&mut repo);
repo.write_project_config(
r#"[post-start]
server = "npm run dev -- --port {{ branch | hash_port }} &"
[list]
url = "http://localhost:{{ branch | hash_port }}"
"#,
);
assert_cmd_snapshot!(
"tips_dev_server_workflow",
list_snapshots::command_readme(&repo, repo.root_path())
);
}
#[rstest]
fn test_list_progressive_flag(mut repo: TestRepo) {
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("--progressive");
cmd
});
}
#[rstest]
fn test_list_no_progressive_flag(mut repo: TestRepo) {
repo.add_worktree("feature");
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) {
repo.add_worktree("feature-a");
repo.create_branch("orphan-1");
repo.create_branch("orphan-2");
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.args(["--progressive", "--branches"]);
cmd
});
}
#[rstest]
fn test_list_task_dag_single_worktree(repo: TestRepo) {
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--progressive");
cmd
});
}
#[rstest]
fn test_list_task_dag_multiple_worktrees(mut repo: TestRepo) {
repo.add_worktree("feature-a");
repo.add_worktree("feature-b");
repo.add_worktree("feature-c");
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--progressive");
cmd
});
}
#[rstest]
fn test_list_task_dag_full_with_diffs(mut repo: TestRepo) {
let feature_a = repo.add_worktree("feature-a");
std::fs::write(feature_a.join("new.txt"), "content").unwrap();
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");
let in_sync = repo.add_worktree("in-sync");
repo.run_git_in(&in_sync, &["push", "-u", "origin", "in-sync"]);
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) {
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) {
let current_path = setup_timestamped_worktrees(&mut repo);
assert_cmd_snapshot!("task_dag_ordering_stability", {
let mut cmd = list_snapshots::command(&repo, ¤t_path);
cmd.arg("--progressive");
cmd
});
}
#[rstest]
fn test_list_progressive_vs_buffered_identical_data(mut repo: TestRepo) {
repo.add_worktree("feature-a");
repo.add_worktree("feature-b");
let feature_a_path = repo.worktree_path("feature-a");
std::fs::write(feature_a_path.join("changes.txt"), "test").unwrap();
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()
};
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)
);
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();
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) {
repo.add_worktree("feature-a");
repo.add_worktree("feature-b");
assert_cmd_snapshot!("list_with_c_flag", {
let mut cmd = wt_command();
cmd.args(["-C", repo.root_path().to_str().unwrap(), "list"]);
cmd.current_dir(std::env::temp_dir());
cmd
});
}
#[rstest]
fn test_list_large_diffs_alignment(mut repo: TestRepo) {
let large_wt = repo.add_worktree("feature-changes");
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();
repo.run_git_in(&large_wt, &["add", "."]);
repo.run_git_in(&large_wt, &["commit", "-m", "Add 100 lines"]);
std::fs::write(large_wt.join("uncommitted.txt"), &large_content).unwrap();
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();
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();
repo.set_marker("feature-changes", "🤖");
let short_wt = repo.add_worktree("fix");
std::fs::write(short_wt.join("quick.txt"), "quick fix").unwrap();
repo.set_marker("fix", "💬");
let diverged_wt = repo.add_worktree("diverged");
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"]);
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();
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) {
let wli_seq = repo.add_worktree("wli-sequence");
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"]);
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();
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();
repo.set_marker("wli-sequence", "🤖");
let pr_link = repo.add_worktree("pr-link");
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"]);
repo.set_marker("pr-link", "🤖");
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) {
let feature = repo.add_worktree("feature");
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"]);
std::fs::write(feature.join("untracked.txt"), "new file").unwrap();
std::fs::write(feature.join("file-a.txt"), "modified content").unwrap();
std::fs::write(feature.join("file-b.txt"), "staged changes").unwrap();
repo.run_git_in(&feature, &["add", "file-b.txt"]);
repo.run_git_in(&feature, &["mv", "file-c.txt", "renamed-c.txt"]);
repo.run_git_in(&feature, &["rm", "file-d.txt"]);
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[rstest]
fn test_list_maximum_status_with_git_operation(mut repo: TestRepo) {
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");
let feature = repo.add_worktree("feature");
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"]);
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"]);
let rebase_output = repo
.git_command()
.args(["rebase", "main"])
.current_dir(&feature)
.run()
.unwrap();
assert!(
!rebase_output.status.success(),
"Rebase should fail with conflicts"
);
std::fs::write(feature.join("untracked.txt"), "untracked during rebase").unwrap();
std::fs::write(feature.join("feature.txt"), "modified during rebase").unwrap();
std::fs::write(feature.join("new-staged.txt"), "staged during rebase").unwrap();
repo.run_git_in(&feature, &["add", "new-staged.txt"]);
repo.run_git(&["worktree", "lock", feature.to_str().unwrap()]);
repo.set_marker("feature", "🤖");
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) {
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");
let feature = repo.add_worktree("feature");
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"]);
repo.setup_remote("main");
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()
};
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()
};
repo.git_command()
.args(["reset", "--hard", &base_sha])
.current_dir(&feature)
.run()
.unwrap();
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();
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();
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"]);
std::fs::write(feature.join("untracked.txt"), "untracked").unwrap();
std::fs::write(feature.join("feature.txt"), "modified").unwrap();
std::fs::write(feature.join("new-staged.txt"), "staged content").unwrap();
repo.run_git_in(&feature, &["add", "new-staged.txt"]);
repo.run_git_in(&feature, &["mv", "file-c.txt", "renamed-c.txt"]);
repo.run_git_in(&feature, &["rm", "file-d.txt"]);
repo.run_git(&["worktree", "lock", feature.to_str().unwrap()]);
repo.set_marker("feature", "🤖");
assert_cmd_snapshot!("maximum_status_symbols", {
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--full");
cmd
});
}
#[rstest]
fn test_list_full_working_tree_conflicts(mut repo: TestRepo) {
std::fs::write(repo.root_path().join("shared.txt"), "original content").unwrap();
repo.commit("Initial commit");
let feature = repo.add_worktree("feature");
std::fs::write(repo.root_path().join("shared.txt"), "main's version").unwrap();
repo.commit("Main changes shared.txt");
std::fs::write(feature.join("shared.txt"), "feature's uncommitted version").unwrap();
assert_cmd_snapshot!(
"working_tree_conflicts_without_full",
list_snapshots::command(&repo, repo.root_path())
);
assert_cmd_snapshot!("working_tree_conflicts_with_full", {
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--full");
cmd
});
}
#[rstest]
fn test_list_full_clean_working_tree_uses_commit_conflicts(mut repo: TestRepo) {
std::fs::write(repo.root_path().join("shared.txt"), "original content").unwrap();
repo.commit("Initial commit");
let feature = repo.add_worktree("feature");
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"]);
std::fs::write(repo.root_path().join("shared.txt"), "main's version").unwrap();
repo.commit("Main changes shared.txt");
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) {
repo.switch_primary_to("develop");
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[rstest]
fn test_list_shows_warning_on_git_error(mut repo: TestRepo) {
repo.add_worktree("feature");
let git_dir = repo.root_path().join(".git");
let ref_path = git_dir.join("refs/heads/feature");
std::fs::write(&ref_path, "0000000000000000000000000000000000000000\n").unwrap();
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[rstest]
fn test_list_handles_orphan_branch(repo: TestRepo) {
repo.git_command()
.args(["checkout", "--orphan", "assets"])
.run()
.unwrap();
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();
repo.git_command().args(["checkout", "main"]).run().unwrap();
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
});
}
#[rstest]
fn test_list_skips_operations_for_prunable_worktrees(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature");
std::fs::remove_dir_all(&worktree_path).unwrap();
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"
);
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[rstest]
fn test_list_skips_expensive_for_stale_branches(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
repo.commit("Second commit on main");
repo.commit("Third commit on main");
std::fs::write(feature_path.join("feature.txt"), "feature content").unwrap();
repo.git_command()
.args(["add", "feature.txt"])
.current_dir(&feature_path)
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Feature work"])
.current_dir(&feature_path)
.run()
.unwrap();
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.arg("--full"); cmd.env("WORKTRUNK_TEST_SKIP_EXPENSIVE_THRESHOLD", "1");
cmd
});
}
#[rstest]
fn test_list_skips_expensive_for_stale_branches_only(repo: TestRepo) {
repo.create_branch("stale-branch");
repo.commit("Second commit on main");
repo.commit("Third commit on main");
assert_cmd_snapshot!({
let mut cmd = list_snapshots::command(&repo, repo.root_path());
cmd.args(["--branches", "--full"]);
cmd.env("WORKTRUNK_TEST_SKIP_EXPENSIVE_THRESHOLD", "1");
cmd
});
}
#[rstest]
fn test_list_with_nonexistent_default_branch(repo: TestRepo) {
repo.run_git(&["config", "worktrunk.default-branch", "nonexistent"]);
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[rstest]
fn test_list_full_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("--full");
cmd
});
}
#[rstest]
fn test_list_nested_worktree_current_indicator(mut repo: TestRepo) {
let nested_path = repo.root_path().join(".worktrees").join("feature");
let nested_worktree = repo.add_worktree_at_path("feature", &nested_path);
assert_cmd_snapshot!(list_snapshots::command(&repo, &nested_worktree));
}
#[rstest]
fn test_list_nested_worktree_json_is_current(mut repo: TestRepo) {
let nested_path = repo.root_path().join(".worktrees").join("feature");
let nested_worktree = repo.add_worktree_at_path("feature", &nested_path);
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();
let main_wt = json.iter().find(|w| w["branch"] == "main").unwrap();
let feature_wt = json.iter().find(|w| w["branch"] == "feature").unwrap();
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"
);
}
#[test]
fn test_list_empty_repo() {
let repo = TestRepo::empty();
let guard =
setup_snapshot_settings_for_paths(repo.root_path(), &repo.worktrees).bind_to_scope();
std::mem::forget(guard);
repo.run_git(&["config", "worktrunk.default-branch", "main"]);
assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path()));
}
#[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"], "");
}