Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
- `wtk new` creates a new branch in a linked worktree.
- `wtk checkout` checks out an existing branch or ref in a linked worktree.
- `wtk status` prints current repository/worktree status in YAML format.
- `wtk list` prints visible worktrees in YAML format.
- `wtk list` prints visible worktrees in a compact table.
- `wtk remove` removes a linked worktree.
- `wtk send-out` moves the current main-worktree branch to a linked worktree.
- `wtk bring-in` moves a linked worktree branch back into the main worktree.
Expand Down Expand Up @@ -72,13 +72,18 @@ wtk new feature/from-current --from-current
wtk checkout feature/existing
wtk status
wtk list
wtk list --json
wtk remove ../repo-wt-feature-foo
wtk send-out
wtk bring-in feature/foo
wtk workspace bootstrap /absolute/path/to/A /absolute/path/to/B
wtk upgrade
```

`wtk list` is optimized for scanning. The default output is a compact table with the worktree directory name, branch, relative HEAD commit time, state labels, and short HEAD. It does not print absolute paths by default. Rows are sorted by the current HEAD commit's committer time, newest first; dirty state is shown as a label but does not affect sorting.

Use `wtk list --json` for machine-readable output. JSON includes absolute paths, full HEADs, timestamps, labels, diagnostics, and Workspace Ref details.

## Workspace Mode

Bootstrap a new Workspace from an empty directory:
Expand Down Expand Up @@ -108,7 +113,7 @@ mode = "workspace"
repository = "/absolute/path/to/A"
```

In Workspace Mode, `wtk status` emits aggregate Workspace status for the current Workspace Worktree and validates generated refs without repairing them. `wtk new` creates a coordinated Workspace Worktree plus matching Linked Repository Worktrees for the same branch. `wtk remove` removes the coordinated set. Workspace operations fail fast on malformed manifest state, relative paths, missing or incorrect refs, branch mismatches, dirty worktrees, branch collisions, or target path collisions.
In Workspace Mode, `wtk status` emits aggregate Workspace status for the current Workspace Worktree and validates generated refs without repairing them. `wtk list` shows one row per Workspace Worktree and summarizes generated Workspace Ref health, such as `refs 2/2 ok` or `refs 1/2 broken`. `wtk new` creates a coordinated Workspace Worktree plus matching Linked Repository Worktrees for the same branch. `wtk remove` removes the coordinated set. Workspace operations fail fast on malformed manifest state, relative paths, missing or incorrect refs, branch mismatches, dirty worktrees, branch collisions, or target path collisions.

`wtk workspace bootstrap` must be run from the empty directory that will become the Workspace root. `wtk workspace init` and `wtk workspace add` must be run from the Workspace main worktree. `wtk checkout`, `wtk send-out`, and `wtk bring-in` are repository-mode-only commands and are rejected in Workspace Mode.

Expand Down
67 changes: 63 additions & 4 deletions e2e/test_repo_mode.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import json

import time

from conftest import linked_worktree_path, parse_yaml, run_git
Expand Down Expand Up @@ -36,29 +38,86 @@ def test_repo_mode_create_remove_send_out_bring_in_and_completion(run_wtk, repo_
assert "wtk" in run_wtk("completion", shell, cwd=repo).stdout


def test_repo_mode_status_and_list_yaml(run_wtk, repo_factory) -> None:
def test_repo_mode_status_and_list_readable(run_wtk, repo_factory) -> None:
repo = repo_factory.init_repo("repo")
run_git(repo, "branch", "feature/status")
run_wtk("checkout", "feature/status", "--no-clipboard", cwd=repo)
linked = linked_worktree_path(repo, "feature/status")
(linked / "dirty.txt").write_text("dirty\n", encoding="utf-8")

status = parse_yaml(run_wtk("status", cwd=linked).stdout)
assert status["current_is_main"] is False
assert status["main_root"] == str(repo.resolve())
assert status["current_root"] == str(linked.resolve())

listing = parse_yaml(run_wtk("list", cwd=linked).stdout)
listing = run_wtk("list", cwd=linked).stdout
lines = [line for line in listing.splitlines() if line.strip()]
assert lines[0].split() == ["worktree", "branch", "updated", "state", "head"]
assert "worktrees:" not in listing
assert str(repo.resolve()) not in listing
assert str(linked.resolve()) not in listing
assert any(line.startswith(" repo ") and " main " in line for line in lines[1:])
assert any(
line.startswith("* repo-wt-feature-status ")
and " feature/status " in line
and " current" in line
and " dirty" in line
for line in lines[1:]
)


def test_repo_mode_list_json(run_wtk, repo_factory) -> None:
repo = repo_factory.init_repo("repo")
run_git(repo, "branch", "feature/json")
run_wtk("checkout", "feature/json", "--no-clipboard", cwd=repo)
linked = linked_worktree_path(repo, "feature/json")

output = run_wtk("list", "--json", cwd=linked).stdout
assert "\x1b[" not in output
listing = json.loads(output)
assert listing["mode"] == "repository"
worktrees = listing["worktrees"]
assert len(worktrees) == 2
assert any(entry["path"] == str(repo.resolve()) and entry["is_main"] is True for entry in worktrees)
assert any(
entry["path"] == str(repo.resolve())
and entry["display_name"] == "repo"
and entry["is_main"] is True
and len(entry["head"]) == 40
for entry in worktrees
)
assert any(
entry["path"] == str(linked.resolve())
and entry["branch"] == "feature/status"
and entry["display_name"] == "repo-wt-feature-json"
and entry["branch"] == "feature/json"
and entry["is_current"] is True
for entry in worktrees
)


def test_repo_mode_list_sorts_by_head_commit_time(run_wtk, repo_factory) -> None:
repo = repo_factory.init_repo("repo")
run_git(repo, "branch", "feature/newer")
run_wtk("checkout", "feature/newer", "--no-clipboard", cwd=repo)
linked = linked_worktree_path(repo, "feature/newer")
(linked / "newer.txt").write_text("newer\n", encoding="utf-8")
run_git(linked, "add", ".")
run_git(
linked,
"commit",
"-m",
"newer",
env={
"GIT_AUTHOR_DATE": "2030-01-01T00:00:00Z",
"GIT_COMMITTER_DATE": "2030-01-01T00:00:00Z",
},
)

listing = run_wtk("list", cwd=repo).stdout
rows = [line for line in listing.splitlines()[1:] if line.strip()]
assert rows[0].startswith(" repo-wt-feature-newer ")
assert rows[1].startswith("* repo ")


def test_repo_mode_new_with_explicit_base_from_current_and_dirty_failures(run_wtk, repo_factory) -> None:
repo = repo_factory.init_repo("repo", branch="trunk")
run_wtk("new", "feature/new", "--base", "trunk", "--no-clipboard", cwd=repo)
Expand Down
124 changes: 124 additions & 0 deletions e2e/test_workspace_mode.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import json

from conftest import linked_worktree_path, parse_yaml, run_git


Expand Down Expand Up @@ -66,6 +68,128 @@ def test_workspace_mode_status_and_membership_failures(run_wtk, workspace_factor
assert "workspace add must be run from the Workspace main worktree" in not_main.output


def test_workspace_mode_list_shows_workspace_rows_and_ref_health(run_wtk, workspace_factory, repo_factory) -> None:
workspace, members = workspace_factory.create(member_names=("A", "B"))

run_wtk("workspace", "init", cwd=workspace)
run_wtk("workspace", "add", str(members["A"]), cwd=workspace)
run_wtk("workspace", "add", str(members["B"]), cwd=workspace)
repo_factory.commit_workspace_manifest(workspace)
run_wtk("new", "feature/list", "--base", "main", "--no-clipboard", cwd=workspace)
workspace_linked = linked_worktree_path(workspace, "feature/list")

listing = run_wtk("list", cwd=workspace).stdout
assert "worktree" in listing.splitlines()[0]
assert "workspace" in listing
assert "workspace-wt-feature-list" in listing
assert "refs 2/2 ok" in listing
assert str(workspace.resolve()) not in listing
assert str(workspace_linked.resolve()) not in listing

(workspace_linked / "refs" / "A").unlink()
broken = run_wtk("list", cwd=workspace).stdout
assert "workspace-wt-feature-list" in broken
assert "refs 1/2 broken" in broken

machine = json.loads(run_wtk("list", "--json", cwd=workspace).stdout)
assert machine["mode"] == "workspace"
assert all(not row["dirty"] for row in machine["worktrees"])
linked_row = next(row for row in machine["worktrees"] if row["display_name"] == "workspace-wt-feature-list")
assert linked_row["workspace_refs"]["total"] == 2
assert linked_row["workspace_refs"]["broken"] == 1
assert any(not detail["ok"] and detail["name"] == "A" for detail in linked_row["workspace_refs"]["details"])


def test_workspace_mode_list_marks_branch_mismatched_ref_targets_broken(
run_wtk, workspace_factory, repo_factory
) -> None:
workspace, members = workspace_factory.create(member_names=("A",))

run_wtk("workspace", "init", cwd=workspace)
run_wtk("workspace", "add", str(members["A"]), cwd=workspace)
repo_factory.commit_workspace_manifest(workspace)
run_wtk("new", "feature/list", "--base", "main", "--no-clipboard", cwd=workspace)

linked_a = linked_worktree_path(members["A"], "feature/list")
run_git(linked_a, "checkout", "-b", "other")

machine = json.loads(run_wtk("list", "--json", cwd=workspace).stdout)
linked_row = next(row for row in machine["worktrees"] if row["display_name"] == "workspace-wt-feature-list")
detail = next(detail for detail in linked_row["workspace_refs"]["details"] if detail["name"] == "A")

assert linked_row["workspace_refs"]["broken"] == 1
assert detail["ok"] is False
assert any("branch mismatch" in diagnostic for diagnostic in detail["diagnostics"])


def test_workspace_mode_list_marks_invalid_manifest_repository_paths_broken(
run_wtk, workspace_factory, repo_factory
) -> None:
workspace, members = workspace_factory.create(member_names=("A", "B"))

run_wtk("workspace", "init", cwd=workspace)
run_wtk("workspace", "add", str(members["A"]), cwd=workspace)
run_wtk("workspace", "add", str(members["B"]), cwd=workspace)
repo_factory.commit_workspace_manifest(workspace)
run_wtk("new", "feature/list", "--base", "main", "--no-clipboard", cwd=workspace)

manifest_path = workspace / ".wtk-workspace.toml"
manifest_text = manifest_path.read_text(encoding="utf-8")
manifest_text = manifest_text.replace(
f'repository = "{members["A"]}"',
'repository = "../A"',
)
manifest_text = manifest_text.replace(
f'repository = "{members["B"]}"',
f'repository = "{linked_worktree_path(members["B"], "feature/list")}"',
)
manifest_path.write_text(manifest_text, encoding="utf-8")

machine = json.loads(run_wtk("list", "--json", cwd=workspace).stdout)
linked_row = next(row for row in machine["worktrees"] if row["display_name"] == "workspace-wt-feature-list")
detail_a = next(detail for detail in linked_row["workspace_refs"]["details"] if detail["name"] == "A")
detail_b = next(detail for detail in linked_row["workspace_refs"]["details"] if detail["name"] == "B")

assert linked_row["workspace_refs"]["broken"] == 2
assert detail_a["ok"] is False
assert any("repository path must be absolute" in diagnostic for diagnostic in detail_a["diagnostics"])
assert detail_b["ok"] is False
assert any(
"configured repository does not resolve to its main worktree" in diagnostic
or "must match repository basename" in diagnostic
for diagnostic in detail_b["diagnostics"]
)


def test_workspace_mode_list_marks_mismatched_ref_names_broken(
run_wtk, workspace_factory, repo_factory
) -> None:
workspace, members = workspace_factory.create(member_names=("A",))

run_wtk("workspace", "init", cwd=workspace)
run_wtk("workspace", "add", str(members["A"]), cwd=workspace)
repo_factory.commit_workspace_manifest(workspace)
run_wtk("new", "feature/list", "--base", "main", "--no-clipboard", cwd=workspace)

manifest_path = workspace / ".wtk-workspace.toml"
manifest_text = manifest_path.read_text(encoding="utf-8")
manifest_text = manifest_text.replace('[workspace.refs.A]', '[workspace.refs.renamed]')
manifest_path.write_text(manifest_text, encoding="utf-8")

machine = json.loads(run_wtk("list", "--json", cwd=workspace).stdout)
linked_row = next(row for row in machine["worktrees"] if row["display_name"] == "workspace-wt-feature-list")
detail = next(
detail for detail in linked_row["workspace_refs"]["details"] if detail["name"] == "renamed"
)

assert linked_row["workspace_refs"]["broken"] == 1
assert detail["ok"] is False
assert any(
"workspace ref renamed must match repository basename A" in diagnostic
for diagnostic in detail["diagnostics"]
)


def test_workspace_mode_rejects_repo_only_commands(run_wtk, workspace_factory, repo_factory) -> None:
workspace, members = workspace_factory.create(member_names=("A",))

Expand Down
Loading
Loading