Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
9041dc7
feat: introduce VCS-agnostic Workspace trait
max-sixty Feb 12, 2026
1a616dc
feat: add jj VCS detection and `wt list` support
max-sixty Feb 12, 2026
8227a44
feat: add `wt switch` support for jj workspaces
max-sixty Feb 12, 2026
5d670df
feat: add `wt remove` support for jj workspaces
max-sixty Feb 12, 2026
6038f1d
feat: add `wt merge` support for jj workspaces
max-sixty Feb 12, 2026
6e0edf3
Deduplicate jj workspace helpers and detect trunk bookmark
max-sixty Feb 12, 2026
068dfcd
Add integration tests for jj workspace support
max-sixty Feb 12, 2026
69c4965
Add jj support for step commands and fix trunk() revset usage
max-sixty Feb 13, 2026
f516af1
Gate jj tests behind feature flag for CI compatibility
max-sixty Feb 13, 2026
1314712
Install jj on Linux CI and conditionally enable jj tests
max-sixty Feb 13, 2026
9922938
Fix redundant explicit link targets in workspace doc comments
max-sixty Feb 13, 2026
99b6d08
Include jj integration tests in CI code coverage
max-sixty Feb 13, 2026
43c5a94
Consolidate jj tests under shell-integration-tests feature
max-sixty Feb 13, 2026
1ec322e
Add coverage gap tests for jj workspace operations
max-sixty Feb 13, 2026
9442e0b
Add GitWorkspace trait coverage test
max-sixty Feb 13, 2026
37fb67a
Add jj JSON list and directory-removed coverage tests
max-sixty Feb 13, 2026
cb492a0
Fix pre-commit hook and formatting issues
max-sixty Feb 13, 2026
46576df
Add unit test for build_jj_commit_prompt
max-sixty Feb 13, 2026
fd6e0e1
Add test exercising uncovered JjWorkspace Workspace trait methods
max-sixty Feb 13, 2026
f5e25dd
Merge branch 'main' into 926
max-sixty Feb 14, 2026
a31a3b8
feat: unify VCS abstractions with expanded Workspace trait
max-sixty Feb 14, 2026
e67ffe5
feat: move push and squash operations to Workspace trait
max-sixty Feb 14, 2026
16bb2cd
fix: restore commit graph and diffstat output in step push
max-sixty Feb 14, 2026
e7389d7
feat: generalize hook infrastructure from git to workspace-agnostic
max-sixty Feb 14, 2026
f5e0402
feat: enable `wt hook` for jj repositories
max-sixty Feb 14, 2026
be9fc58
Merge branch 'main' into 926
max-sixty Feb 14, 2026
404d6d6
fix: update jj test snapshots after merge from main
max-sixty Feb 14, 2026
1c3d058
Merge branch 'main' into 926
max-sixty Feb 14, 2026
fee146a
refactor: simplify Workspace::default_branch_name to return Option<St…
max-sixty Feb 14, 2026
a8e031c
docs: add VCS support section to CLAUDE.md
max-sixty Feb 14, 2026
889533a
fix: redact git commit hashes in jj snapshot tests
max-sixty Feb 14, 2026
76559ef
Refactor base path handling to use cwd
max-sixty Feb 14, 2026
b40e939
Consolidate jj squash into unified VCS-agnostic flow
max-sixty Feb 14, 2026
c83dd5d
Refactor VCS dispatch to single open_workspace() entry point
max-sixty Feb 14, 2026
5fbf2fc
fix: remove invalid --repo flag from jj config get + add jj coverage …
max-sixty Feb 14, 2026
46e6db7
test: add more jj integration tests for merge and switch coverage
max-sixty Feb 14, 2026
6f7c027
test: add coverage for Workspace trait methods and jj edge cases
max-sixty Feb 14, 2026
4c5f23e
test: add jj hook and execute coverage tests, fix CI git config
max-sixty Feb 15, 2026
c033b2a
style: fix cargo fmt in jj tests
max-sixty Feb 15, 2026
edc61b1
refactor: single VCS dispatch point via open_workspace() + downcast
max-sixty Feb 15, 2026
ec3f2f3
Unify step commit LLM path, add hooks to jj merge/remove
max-sixty Feb 15, 2026
42770df
Merge branch 'main' into 926
max-sixty Feb 15, 2026
793b46a
test: extend Workspace trait coverage for git implementation
max-sixty Feb 15, 2026
f5bef30
feat: jj parity for config create --project and recent_subjects
max-sixty Feb 15, 2026
a6edca8
Refactor remove command to unify git and jj paths
max-sixty Feb 15, 2026
e11e7d1
test: add stash/restore and rebase conflict coverage tests
max-sixty Feb 15, 2026
779c3ee
test: add unit tests for types.rs, project config, and CommandContext…
max-sixty Feb 15, 2026
8be5f3d
test: add coverage for CommandContext, build_hook_context, and worksp…
max-sixty Feb 15, 2026
905db90
test: add coverage for prepare_commands and commit message fallback
max-sixty Feb 15, 2026
4f5c2b7
Add copy-ignored support for jj workspaces
max-sixty Feb 15, 2026
ba0db5e
Refactor config/state commands to use workspace trait
max-sixty Feb 15, 2026
d0578de
Merge branch 'main' into 926
max-sixty Feb 15, 2026
46ffab6
fix: update jj hook snapshot after format change merge
max-sixty Feb 15, 2026
ed6ab38
Merge branch 'main' into 926
max-sixty Feb 15, 2026
7c91db7
Fix statusline branch diff and select picker screen artifacts
max-sixty Feb 15, 2026
cb67ced
Merge branch 'main' into 926
max-sixty Feb 15, 2026
865de3e
refactor: use approve_hooks in git merge for consistency with jj
max-sixty Feb 15, 2026
9d04481
Add integration context to jj workspace removal
max-sixty Feb 15, 2026
8fca306
refactor: unify commit staging via Workspace::prepare_commit
max-sixty Feb 15, 2026
a6f30d2
Refactor state management to workspace trait
max-sixty Feb 15, 2026
ceb4663
Merge branch 'main' into 926
max-sixty Feb 16, 2026
48c1789
Merge main and reapply jj integration
max-sixty Feb 16, 2026
39a82fe
Merge remote-tracking branch 'origin/main' into jj-gat
max-sixty Feb 16, 2026
809d6c4
Merge branch '926' into jj-gat
max-sixty Feb 16, 2026
bb1e3eb
Merge branch 'main' into 926
max-sixty Feb 16, 2026
92054a6
style: cargo fmt after merge conflict resolution
max-sixty Feb 16, 2026
9622174
docs: add VcsOps design notes to workspace module docstring
max-sixty Feb 16, 2026
333c929
Merge 926's latest (TemplateExpandError integration)
max-sixty Feb 16, 2026
8f389e7
fix: update tests for TemplateExpandError integration
max-sixty Feb 16, 2026
2c88792
Merge branch 'main' into 926
max-sixty Feb 16, 2026
980db45
fix: use text code block for pseudo-code in workspace doc
max-sixty Feb 16, 2026
558fd38
Merge branch 'main' into 926
max-sixty Feb 17, 2026
a18fb48
Merge branch 'main' into 926
max-sixty Feb 17, 2026
cd7b463
fix: add WORKTRUNK_NO_PROMPTS env var to suppress interactive prompts
max-sixty Feb 17, 2026
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
21 changes: 21 additions & 0 deletions .claude/skills/writing-user-outputs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,27 @@ information, not restate what's already said.
"Committing with default message... (3 files, +45, -12)"
```

**VCS-neutral messaging:** Don't mention specific VCS backends (git, jj) in
messages unless the context is already VCS-specific. Generic messages should work
for any backend. Only name a backend when the user is already in that backend's
context (e.g., a jj-specific limitation) or when contrasting backends.

```rust
// GOOD - generic, works for any VCS
"Not in a repository"
"Branch not found"
"Uncommitted changes"

// BAD - unnecessarily names backends the user may not be using
"Not in a git or jj repository"
"Not a git repository" // (unless in git-specific code path)

// OK - already in jj context, naming the limitation
"--show-prompt is not yet supported for jj squash"
// OK - command only works in git, telling user why
"`wt {command}` is not yet supported for jj repositories"
```

**Two types of parenthesized content with different styling:**

1. **Stats parentheses → Gray** (`[90m` bright-black): Supplementary numerical
Expand Down
15 changes: 13 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,16 @@ jobs:
packages: zsh fish
version: 1.0

- name: Install shells (fish) - macOS
- name: Install jj (Jujutsu) - Linux
if: runner.os == 'Linux'
uses: baptiste0928/cargo-install@v3
with:
crate: jj-cli

- name: Install shells and jj (Jujutsu) - macOS
if: runner.os == 'macOS'
run: |
brew install fish
brew install fish jj
# bash and zsh are pre-installed on macOS

- name: Install nushell
Expand Down Expand Up @@ -249,6 +255,11 @@ jobs:
packages: zsh fish
version: 1.0

- name: Install jj (Jujutsu)
uses: baptiste0928/cargo-install@v3
with:
crate: jj-cli

- name: Install nushell
uses: hustcer/setup-nu@v3
with:
Expand Down
4 changes: 3 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ repos:
# src/config/{test,expansion,mod}.rs: TestRepo test fixtures use git init directly
# src/config/user/tests.rs: TestRepo test fixtures use git init directly
# src/styling/mod.rs: terminal width detection probes should be silent/quick
exclude: '^(src/shell_exec\.rs|src/commands/select/|src/config/(test|expansion|mod)\.rs|src/config/user/tests\.rs|src/styling/mod\.rs|tests/|benches/)'
# src/workspace/git.rs: #[cfg(test)] fixtures use git init/commit directly
# src/commands/command_executor.rs: #[cfg(test)] fixtures use git init/commit directly
exclude: '^(src/shell_exec\.rs|src/commands/(select/|command_executor\.rs)|src/config/(test|expansion|mod)\.rs|src/config/user/tests\.rs|src/styling/mod\.rs|src/workspace/git\.rs|tests/|benches/)'

ci:
# pre-commit.ci doesn't have Rust toolchain, so skip Rust-specific hooks.
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,10 @@ Real repo benchmarks clone rust-lang/rust (~2-5 min first run, cached thereafter

Use `wt list --format=json` for structured data access. See `wt list --help` for complete field documentation, status variants, and query examples.

## VCS Support

Worktrunk supports **git and jj** (Jujutsu). The `Workspace` trait abstracts over both, but don't design for hypothetical third backends — keep trait signatures as simple as the two real implementations require. For example, if both git and jj implementations are infallible, the trait method should return a plain value, not `Result`.

## Worktree Model

- Worktrees are **addressed by branch name**, not by filesystem path.
Expand Down
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ normal = ["which"]
# This is optional to avoid C compilation issues on some platforms
default = ["syntax-highlighting"]
syntax-highlighting = ["dep:tree-sitter", "dep:tree-sitter-bash", "dep:tree-sitter-highlight"]
# Enable shell/PTY integration tests (requires bash, zsh, fish installed on system)
# Includes: shell wrapper tests, PTY-based approval prompts, TUI select, progressive rendering
# Enable shell/PTY integration tests (requires bash, zsh, fish, jj installed on system)
# Includes: shell wrapper tests, PTY-based approval prompts, TUI select, progressive rendering, jj tests
# These tests can cause nextest to suspend due to terminal foreground pgrp issues.
# When enabled, run with NEXTEST_NO_INPUT_HANDLER=1 to avoid suspension.
# See CLAUDE.md "Nextest Terminal Suspension" section for details.
Expand Down
183 changes: 183 additions & 0 deletions gat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# Proposal: Replace downcast pattern with GAT-based VcsOps

## Problem

Commands that need VCS-specific behavior use runtime downcasting:

```rust
let workspace = open_workspace()?;
if workspace.as_any().downcast_ref::<Repository>().is_none() {
return handle_merge_jj(opts);
}
```

This produces 5 parallel handler files (~876 lines of jj handlers) with
~17% code duplication against their git counterparts. 16 downcast sites
across 11 files.

## Proposal

Add a GAT `Ops` to the `Workspace` trait that carries VCS-specific
operations. Commands become generic over `W: Workspace` with one
implementation instead of two.

```rust
trait VcsOps {
fn prepare_commit(&self, path: &Path, mode: StageMode) -> Result<()>;
fn guarded_push(&self, target: &str, push: &dyn Fn() -> Result<PushResult>) -> Result<PushResult>;
fn squash(&self, target: &str, message: &str, path: &Path) -> Result<SquashOutcome>;
}

trait Workspace: Send + Sync {
type Ops<'a>: VcsOps where Self: 'a;

fn ops(&self) -> Self::Ops<'_>;

// ... existing 28 methods unchanged
}
```

Git implementation:

```rust
struct GitOps<'a> { repo: &'a Repository }

impl VcsOps for GitOps<'_> {
fn prepare_commit(&self, path: &Path, mode: StageMode) -> Result<()> {
stage_files(self.repo, path, mode)?;
run_pre_commit_hooks(self.repo, path)
}

fn guarded_push(&self, target: &str, push: &dyn Fn() -> Result<PushResult>) -> Result<PushResult> {
let stash = stash_target_if_dirty(self.repo, target)?;
let result = push();
if let Some(s) = stash { restore_stash(self.repo, s); }
result
}
}

impl Workspace for Repository {
type Ops<'a> = GitOps<'a>;
fn ops(&self) -> GitOps<'_> { GitOps { repo: self } }
}
```

Jj implementation:

```rust
struct JjOps<'a> { ws: &'a JjWorkspace }

impl VcsOps for JjOps<'_> {
fn prepare_commit(&self, _path: &Path, _mode: StageMode) -> Result<()> {
Ok(()) // jj auto-snapshots
}

fn guarded_push(&self, _target: &str, push: &dyn Fn() -> Result<PushResult>) -> Result<PushResult> {
push() // no stash needed
}
}
```

Command handlers become generic:

```rust
fn handle_merge<W: Workspace>(ws: &W, opts: MergeOptions) -> Result<()> {
let target = ws.resolve_integration_target(opts.target)?;

if ws.is_dirty(&path)? {
ws.ops().prepare_commit(&path, stage_mode)?;
ws.commit(message, &path)?;
}

ws.rebase_onto(&target, &path)?;

ws.ops().guarded_push(&target, &|| {
ws.advance_and_push(&target, &path, display)
})?;

// ... shared output, removal, hooks
}
```

Top-level dispatch (once, in main or cli):

```rust
fn dispatch<W: Workspace>(ws: &W, cmd: Cmd) -> Result<()> {
match cmd {
Cmd::Merge(opts) => handle_merge(ws, opts),
Cmd::Switch(opts) => handle_switch(ws, opts),
// ...
}
}

match detect_vcs(&path) {
Some(VcsKind::Git) => dispatch(&Repository::current()?, cmd),
Some(VcsKind::Jj) => dispatch(&JjWorkspace::from_current_dir()?, cmd),
None => // fallback
}
```

## What VcsOps would contain

~3-5 methods covering the structural differences between git and jj flows:

| Method | Git | Jj |
|--------|-----|----|
| `prepare_commit` | Stage files + pre-commit hooks | No-op |
| `guarded_push` | Stash target, push, restore | Just push |
| `squash` | `reset --soft` + commit | `jj squash --from` |
| `feature_tip` | `"HEAD"` | `@` or `@-` if empty |
| `committable_diff` | `git diff --staged` | `jj diff -r @` |

Some of these are already on the Workspace trait (`feature_head`,
`squash_commits`, `committable_diff_for_prompt`). They could stay there
or move to VcsOps — the distinction is whether the *caller's control
flow* differs (VcsOps) or just the implementation (Workspace).

## What this eliminates

- `as_any()` method on Workspace trait
- All 16 downcast sites
- 5 parallel handler files: `handle_merge_jj.rs`, `handle_switch_jj.rs`,
`handle_remove_jj.rs`, `handle_step_jj.rs`, `list/collect_jj.rs`
- `CommandEnv::require_repo()`, `CommandContext::repo()` downcast helpers

## What this costs

- GAT syntax in bounds: `W: Workspace` works, but if you need to name
the ops type explicitly, bounds get verbose
- `open_workspace()` can no longer return `Box<dyn Workspace>` — dyn
dispatch and GATs don't mix cleanly. The top-level dispatch must be
an enum match or generic function, not a trait object
- Monomorphization: generic commands are compiled twice (once per VCS).
Increases binary size slightly, irrelevant for a CLI

## Migration

Incremental, one command at a time:

1. Add `VcsOps` trait and `type Ops<'a>` to Workspace (backward
compatible — existing code still compiles)
2. Add `VcsOps` impls for `GitOps`/`JjOps` with the 3-5 methods
3. Convert one command (start with `remove` — simplest) to generic
4. Delete `handle_remove_jj.rs`
5. Repeat for step, merge, switch, list
6. Remove `as_any()` from Workspace trait once no downcasts remain
7. Replace `open_workspace() -> Box<dyn Workspace>` with enum dispatch

Steps 1-2 are additive. Steps 3-6 can be done one command per PR.
Step 7 is the final breaking change to the internal API.

## Alternative: Box<dyn VcsOps + '_>

Avoids GATs entirely:

```rust
trait Workspace {
fn ops(&self) -> Box<dyn VcsOps + '_>;
}
```

Simpler bounds, works with `dyn Workspace`. Costs one heap allocation
per `ops()` call (negligible). Loses monomorphization. Worth considering
if GAT ergonomics prove annoying in practice.
4 changes: 2 additions & 2 deletions src/commands/command_approval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ pub fn approve_hooks_filtered(
return Ok(true);
}

let project_config = match ctx.repo.load_project_config()? {
let project_config = match ctx.workspace.load_project_config()? {
Some(cfg) => cfg,
None => return Ok(true), // No project config = no commands to approve
};
Expand All @@ -215,7 +215,7 @@ pub fn approve_hooks_filtered(
return Ok(true);
}

let project_id = ctx.repo.project_identifier()?;
let project_id = ctx.workspace.project_identifier()?;
let approvals = Approvals::load().context("Failed to load approvals")?;
approve_command_batch(&commands, &project_id, &approvals, ctx.yes, false)
}
Loading
Loading