mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
e1745db105
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_
4136 lines
143 KiB
Rust
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,
|
|
¤t_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, ¤t_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, ¤t_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}");
|
|
}
|