diff --git a/.claude/skills/writing-user-outputs/SKILL.md b/.claude/skills/writing-user-outputs/SKILL.md index 17770f604..b00bbf769 100644 --- a/.claude/skills/writing-user-outputs/SKILL.md +++ b/.claude/skills/writing-user-outputs/SKILL.md @@ -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 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index aab05ed30..e4f689da8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 @@ -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: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f87d45cb3..18bf2cc6f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 5c5639642..1cb72d4b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/Cargo.toml b/Cargo.toml index 911d2afd0..d529eb703 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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. diff --git a/gat.md b/gat.md new file mode 100644 index 000000000..ac05694cd --- /dev/null +++ b/gat.md @@ -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::().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) -> Result; + fn squash(&self, target: &str, message: &str, path: &Path) -> Result; +} + +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) -> Result { + 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) -> Result { + push() // no stash needed + } +} +``` + +Command handlers become generic: + +```rust +fn handle_merge(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(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 + 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` 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 + +Avoids GATs entirely: + +```rust +trait Workspace { + fn ops(&self) -> Box; +} +``` + +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. diff --git a/src/commands/command_approval.rs b/src/commands/command_approval.rs index 8313f421e..0f0b4ac00 100644 --- a/src/commands/command_approval.rs +++ b/src/commands/command_approval.rs @@ -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 }; @@ -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) } diff --git a/src/commands/command_executor.rs b/src/commands/command_executor.rs index 53bce0597..497f246b7 100644 --- a/src/commands/command_executor.rs +++ b/src/commands/command_executor.rs @@ -4,6 +4,7 @@ use worktrunk::HookType; use worktrunk::config::{Command, CommandConfig, UserConfig, expand_template}; use worktrunk::git::Repository; use worktrunk::path::to_posix_path; +use worktrunk::workspace::{Workspace, build_worktree_map}; use super::hook_filter::HookSource; @@ -14,9 +15,9 @@ pub struct PreparedCommand { pub context_json: String, } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy)] pub struct CommandContext<'a> { - pub repo: &'a Repository, + pub workspace: &'a dyn Workspace, pub config: &'a UserConfig, /// Current branch name, if on a branch (None in detached HEAD state). pub branch: Option<&'a str>, @@ -24,16 +25,27 @@ pub struct CommandContext<'a> { pub yes: bool, } +impl std::fmt::Debug for CommandContext<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CommandContext") + .field("workspace_kind", &self.workspace.kind()) + .field("branch", &self.branch) + .field("worktree_path", &self.worktree_path) + .field("yes", &self.yes) + .finish() + } +} + impl<'a> CommandContext<'a> { pub fn new( - repo: &'a Repository, + workspace: &'a dyn Workspace, config: &'a UserConfig, branch: Option<&'a str>, worktree_path: &'a Path, yes: bool, ) -> Self { Self { - repo, + workspace, config, branch, worktree_path, @@ -41,6 +53,11 @@ impl<'a> CommandContext<'a> { } } + /// Downcast to git Repository. Returns None for jj workspaces. + pub fn repo(&self) -> Option<&Repository> { + self.workspace.as_any().downcast_ref::() + } + /// Get branch name, using "HEAD" as fallback for detached HEAD state. pub fn branch_or_head(&self) -> &str { self.branch.unwrap_or("HEAD") @@ -51,7 +68,7 @@ impl<'a> CommandContext<'a> { /// Uses the remote URL if available, otherwise the canonical repository path. /// Returns None only if the path is not valid UTF-8. pub fn project_id(&self) -> Option { - self.repo.project_identifier().ok() + self.workspace.project_identifier().ok() } /// Get the commit generation config, merging project-specific settings. @@ -68,7 +85,7 @@ pub fn build_hook_context( ctx: &CommandContext<'_>, extra_vars: &[(&str, &str)], ) -> HashMap { - let repo_root = ctx.repo.repo_path(); + let repo_root = ctx.workspace.root_path().unwrap_or_default(); let repo_name = repo_root .file_name() .and_then(|n| n.to_str()) @@ -100,36 +117,38 @@ pub fn build_hook_context( map.insert("worktree".into(), worktree); // Default branch - if let Some(default_branch) = ctx.repo.default_branch() { + if let Some(default_branch) = ctx.workspace.default_branch_name() { map.insert("default_branch".into(), default_branch); } // Primary worktree path (where established files live) - if let Ok(Some(path)) = ctx.repo.primary_worktree() { + if let Ok(Some(path)) = ctx.workspace.default_workspace_path() { let path_str = to_posix_path(&path.to_string_lossy()); map.insert("primary_worktree_path".into(), path_str.clone()); // Deprecated alias map.insert("main_worktree_path".into(), path_str); } - if let Ok(commit) = ctx.repo.run_command(&["rev-parse", "HEAD"]) { - let commit = commit.trim(); - map.insert("commit".into(), commit.into()); - if commit.len() >= 7 { - map.insert("short_commit".into(), commit[..7].into()); + // Git-specific context (commit SHA, remote, upstream) + if let Some(repo) = ctx.repo() { + if let Ok(commit) = repo.run_command(&["rev-parse", "HEAD"]) { + let commit = commit.trim(); + map.insert("commit".into(), commit.into()); + if commit.len() >= 7 { + map.insert("short_commit".into(), commit[..7].into()); + } } - } - if let Ok(remote) = ctx.repo.primary_remote() { - map.insert("remote".into(), remote.to_string()); - // Add remote URL for conditional hook execution (e.g., GitLab vs GitHub) - if let Some(url) = ctx.repo.remote_url(&remote) { - map.insert("remote_url".into(), url); - } - if let Some(branch) = ctx.branch - && let Ok(Some(upstream)) = ctx.repo.branch(branch).upstream() - { - map.insert("upstream".into(), upstream); + if let Ok(remote) = repo.primary_remote() { + map.insert("remote".into(), remote.to_string()); + if let Some(url) = repo.remote_url(&remote) { + map.insert("remote_url".into(), url); + } + if let Some(branch) = ctx.branch + && let Ok(Some(upstream)) = repo.branch(branch).upstream() + { + map.insert("upstream".into(), upstream); + } } } @@ -157,6 +176,7 @@ fn expand_commands( } let base_context = build_hook_context(ctx, extra_vars); + let worktree_map = build_worktree_map(ctx.workspace); // Convert to &str references for expand_template let vars: HashMap<&str, &str> = base_context @@ -171,7 +191,8 @@ fn expand_commands( Some(name) => format!("{}:{}", source, name), None => format!("{} {} hook", source, hook_type), }; - let expanded_str = expand_template(&cmd.template, &vars, true, ctx.repo, &template_name)?; + let expanded_str = + expand_template(&cmd.template, &vars, true, &worktree_map, &template_name)?; // Build per-command JSON with hook_type and hook_name let mut cmd_context = base_context.clone(); @@ -222,3 +243,248 @@ pub fn prepare_commands( }) .collect()) } + +#[cfg(test)] +mod tests { + use super::*; + use worktrunk::config::UserConfig; + use worktrunk::git::Repository; + + /// Helper to init a git repo and return (temp_dir, repo_path). + fn init_test_repo() -> (tempfile::TempDir, std::path::PathBuf) { + let temp = tempfile::tempdir().unwrap(); + let repo_path = temp.path().join("repo"); + std::fs::create_dir(&repo_path).unwrap(); + let out = std::process::Command::new("git") + .args(["init", "-b", "main"]) + .current_dir(&repo_path) + .output() + .unwrap(); + assert!(out.status.success()); + let out = std::process::Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(&repo_path) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@test.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@test.com") + .output() + .unwrap(); + assert!(out.status.success()); + (temp, repo_path) + } + + #[test] + fn test_command_context_debug_format() { + let (_temp, repo_path) = init_test_repo(); + let repo = Repository::at(&repo_path).unwrap(); + let config = UserConfig::default(); + let ctx = CommandContext::new(&repo, &config, Some("main"), &repo_path, false); + let debug = format!("{ctx:?}"); + assert!(debug.contains("CommandContext")); + assert!(debug.contains("Git")); + } + + #[test] + fn test_command_context_repo_downcast() { + let (_temp, repo_path) = init_test_repo(); + let repo = Repository::at(&repo_path).unwrap(); + let config = UserConfig::default(); + let ctx = CommandContext::new(&repo, &config, Some("main"), &repo_path, false); + // repo() should succeed for git repositories + assert!(ctx.repo().is_some()); + } + + #[test] + fn test_command_context_branch_or_head() { + let (_temp, repo_path) = init_test_repo(); + let repo = Repository::at(&repo_path).unwrap(); + let config = UserConfig::default(); + + // With a branch set + let ctx = CommandContext::new(&repo, &config, Some("main"), &repo_path, false); + assert_eq!(ctx.branch_or_head(), "main"); + + // Without a branch (detached HEAD) + let ctx = CommandContext::new(&repo, &config, None, &repo_path, false); + assert_eq!(ctx.branch_or_head(), "HEAD"); + } + + #[test] + fn test_command_context_project_id() { + let (_temp, repo_path) = init_test_repo(); + let repo = Repository::at(&repo_path).unwrap(); + let config = UserConfig::default(); + let ctx = CommandContext::new(&repo, &config, Some("main"), &repo_path, false); + // project_id should return Some (path-based, since no remote) + assert!(ctx.project_id().is_some()); + } + + #[test] + fn test_command_context_commit_generation() { + let (_temp, repo_path) = init_test_repo(); + let repo = Repository::at(&repo_path).unwrap(); + let config = UserConfig::default(); + let ctx = CommandContext::new(&repo, &config, Some("main"), &repo_path, false); + // commit_generation returns default config (no command set) + let cg = ctx.commit_generation(); + assert!(cg.command.is_none()); + } + + #[test] + fn test_build_hook_context() { + let (_temp, repo_path) = init_test_repo(); + let repo = Repository::at(&repo_path).unwrap(); + let config = UserConfig::default(); + let ctx = CommandContext::new(&repo, &config, Some("main"), &repo_path, false); + let context = build_hook_context(&ctx, &[("extra_key", "extra_val")]); + assert_eq!(context.get("branch").map(|s| s.as_str()), Some("main")); + assert_eq!(context.get("repo").map(|s| s.as_str()), Some("repo")); + assert!(context.contains_key("worktree_path")); + assert!(context.contains_key("repo_path")); + assert_eq!( + context.get("extra_key").map(|s| s.as_str()), + Some("extra_val") + ); + } + + #[test] + fn test_build_hook_context_detached_head() { + let (_temp, repo_path) = init_test_repo(); + let repo = Repository::at(&repo_path).unwrap(); + let config = UserConfig::default(); + // Detached HEAD: branch is None, branch_or_head returns "HEAD" + let ctx = CommandContext::new(&repo, &config, None, &repo_path, false); + let context = build_hook_context(&ctx, &[]); + assert_eq!(context.get("branch").map(|s| s.as_str()), Some("HEAD")); + } + + #[test] + fn test_build_hook_context_includes_git_specifics() { + let (_temp, repo_path) = init_test_repo(); + let repo = Repository::at(&repo_path).unwrap(); + let config = UserConfig::default(); + let ctx = CommandContext::new(&repo, &config, Some("main"), &repo_path, false); + let context = build_hook_context(&ctx, &[]); + // Git-specific: commit and short_commit should be present + assert!(context.contains_key("commit")); + assert!(context.contains_key("short_commit")); + // Deprecated aliases should still be present + assert!(context.contains_key("main_worktree")); + assert!(context.contains_key("repo_root")); + assert!(context.contains_key("worktree")); + } + + /// Deserialize a CommandConfig from a TOML string command. + fn make_command_config(toml_value: &str) -> worktrunk::config::CommandConfig { + #[derive(serde::Deserialize)] + struct W { + cmd: worktrunk::config::CommandConfig, + } + let toml_str = format!("cmd = {toml_value}"); + toml::from_str::(&toml_str).unwrap().cmd + } + + #[test] + fn test_prepare_commands_empty_template() { + let (_temp, repo_path) = init_test_repo(); + let repo = Repository::at(&repo_path).unwrap(); + let config = UserConfig::default(); + let ctx = CommandContext::new(&repo, &config, Some("main"), &repo_path, false); + let cmd_config = make_command_config("\"\""); + // Empty template still counts as one command + let result = prepare_commands( + &cmd_config, + &ctx, + &[], + HookType::PreCommit, + HookSource::User, + ) + .unwrap(); + assert_eq!(result.len(), 1); + } + + #[test] + fn test_prepare_commands_single() { + let (_temp, repo_path) = init_test_repo(); + let repo = Repository::at(&repo_path).unwrap(); + let config = UserConfig::default(); + let ctx = CommandContext::new(&repo, &config, Some("main"), &repo_path, false); + let cmd_config = make_command_config("\"echo hello\""); + let result = prepare_commands( + &cmd_config, + &ctx, + &[], + HookType::PreCommit, + HookSource::User, + ) + .unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].expanded, "echo hello"); + assert!(result[0].name.is_none()); + // context_json should contain hook_type + assert!(result[0].context_json.contains("pre-commit")); + } + + #[test] + fn test_prepare_commands_with_template_vars() { + let (_temp, repo_path) = init_test_repo(); + let repo = Repository::at(&repo_path).unwrap(); + let config = UserConfig::default(); + let ctx = CommandContext::new(&repo, &config, Some("main"), &repo_path, false); + let cmd_config = make_command_config("\"echo {{ branch }}\""); + let result = prepare_commands( + &cmd_config, + &ctx, + &[], + HookType::PostCreate, + HookSource::User, + ) + .unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].expanded, "echo main"); + } + + #[test] + fn test_prepare_commands_named() { + let (_temp, repo_path) = init_test_repo(); + let repo = Repository::at(&repo_path).unwrap(); + let config = UserConfig::default(); + let ctx = CommandContext::new(&repo, &config, Some("main"), &repo_path, false); + let cmd_config = make_command_config("{ build = \"cargo build\", test = \"cargo test\" }"); + let result = prepare_commands( + &cmd_config, + &ctx, + &[], + HookType::PreMerge, + HookSource::Project, + ) + .unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].name.as_deref(), Some("build")); + assert_eq!(result[0].expanded, "cargo build"); + assert_eq!(result[1].name.as_deref(), Some("test")); + assert_eq!(result[1].expanded, "cargo test"); + // Named commands should have hook_name in JSON context + assert!(result[0].context_json.contains("hook_name")); + assert!(result[0].context_json.contains("build")); + } + + #[test] + fn test_prepare_commands_with_extra_vars() { + let (_temp, repo_path) = init_test_repo(); + let repo = Repository::at(&repo_path).unwrap(); + let config = UserConfig::default(); + let ctx = CommandContext::new(&repo, &config, Some("main"), &repo_path, false); + let cmd_config = make_command_config("\"echo {{ target }}\""); + let result = prepare_commands( + &cmd_config, + &ctx, + &[("target", "develop")], + HookType::PreMerge, + HookSource::User, + ) + .unwrap(); + assert_eq!(result[0].expanded, "echo develop"); + } +} diff --git a/src/commands/commit.rs b/src/commands/commit.rs index 4c7028a9d..3871cad59 100644 --- a/src/commands/commit.rs +++ b/src/commands/commit.rs @@ -5,10 +5,10 @@ use worktrunk::config::CommitGenerationConfig; use worktrunk::styling::{ eprintln, format_with_gutter, hint_message, info_message, progress_message, success_message, }; +use worktrunk::workspace::Workspace; use super::command_executor::CommandContext; use super::hooks::HookFailureStrategy; -use super::repository_ext::RepositoryCliExt; // Re-export StageMode from config for use by CLI pub use worktrunk::config::StageMode; @@ -19,7 +19,6 @@ pub struct CommitOptions<'a> { pub target_branch: Option<&'a str>, pub no_verify: bool, pub stage_mode: StageMode, - pub warn_about_untracked: bool, pub show_no_squash_note: bool, } @@ -31,7 +30,6 @@ impl<'a> CommitOptions<'a> { target_branch: None, no_verify: false, stage_mode: StageMode::All, - warn_about_untracked: true, show_no_squash_note: false, } } @@ -130,7 +128,27 @@ impl<'a> CommitGenerator<'a> { } self.emit_hint_if_needed(); - let commit_message = crate::llm::generate_commit_message(self.config)?; + + // Get staged diff data for commit message generation + let (diff, diff_stat) = wt + .repo() + .committable_diff_for_prompt(wt.root()?.as_path())?; + let current_branch = wt.branch()?.unwrap_or_else(|| "HEAD".to_string()); + let repo_root = wt.root()?; + let repo_name = repo_root + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("repo"); + let recent_commits = wt.repo().recent_subjects(None, 5); + + let input = crate::llm::CommitInput { + diff: &diff, + diff_stat: &diff_stat, + branch: ¤t_branch, + repo_name, + recent_commits: recent_commits.as_ref(), + }; + let commit_message = crate::llm::generate_commit_message(&input, self.config)?; let formatted_message = self.format_message_for_display(&commit_message); eprintln!("{}", format_with_gutter(&formatted_message, None)); @@ -155,7 +173,7 @@ impl<'a> CommitGenerator<'a> { /// Commit uncommitted changes with the shared commit pipeline. impl CommitOptions<'_> { pub fn commit(self) -> anyhow::Result<()> { - let project_config = self.ctx.repo.load_project_config()?; + let project_config = self.ctx.workspace.load_project_config()?; let user_hooks = self.ctx.config.hooks(self.ctx.project_id().as_deref()); let user_hooks_exist = user_hooks.pre_commit.is_some(); let project_hooks_exist = project_config @@ -195,33 +213,13 @@ impl CommitOptions<'_> { .map_err(worktrunk::git::add_hook_skip_hint)?; } - if self.warn_about_untracked && self.stage_mode == StageMode::All { - self.ctx.repo.warn_if_auto_staging_untracked()?; - } - - // Stage changes based on mode - match self.stage_mode { - StageMode::All => { - // Stage everything: tracked modifications + untracked files - self.ctx - .repo - .run_command(&["add", "-A"]) - .context("Failed to stage changes")?; - } - StageMode::Tracked => { - // Stage tracked modifications only (no untracked files) - self.ctx - .repo - .run_command(&["add", "-u"]) - .context("Failed to stage tracked changes")?; - } - StageMode::None => { - // Stage nothing - commit only what's already in the index - } - } + // Stage changes via Workspace (git: warn + add, jj: no-op) + self.ctx + .workspace + .prepare_commit(self.ctx.worktree_path, self.stage_mode)?; let effective_config = self.ctx.commit_generation(); - let wt = self.ctx.repo.current_worktree(); + let wt = self.ctx.repo().unwrap().current_worktree(); CommitGenerator::new(&effective_config).commit_staged_changes( &wt, true, // show_progress diff --git a/src/commands/config/create.rs b/src/commands/config/create.rs index ec3f5a023..cc08a59c0 100644 --- a/src/commands/config/create.rs +++ b/src/commands/config/create.rs @@ -5,7 +5,6 @@ use anyhow::Context; use color_print::cformat; use std::path::PathBuf; -use worktrunk::git::Repository; use worktrunk::path::format_path_for_display; use worktrunk::styling::{eprintln, hint_message, info_message, success_message}; @@ -43,8 +42,8 @@ pub(super) fn comment_out_config(content: &str) -> String { /// Handle the config create command pub fn handle_config_create(project: bool) -> anyhow::Result<()> { if project { - let repo = Repository::current()?; - let config_path = repo.current_worktree().root()?.join(".config/wt.toml"); + let workspace = worktrunk::workspace::open_workspace()?; + let config_path = workspace.current_workspace_path()?.join(".config/wt.toml"); let user_config_exists = require_user_config_path() .map(|p| p.exists()) .unwrap_or(false); @@ -60,8 +59,8 @@ pub fn handle_config_create(project: bool) -> anyhow::Result<()> { true, // is_project ) } else { - let project_config_exists = Repository::current() - .and_then(|repo| repo.current_worktree().root()) + let project_config_exists = worktrunk::workspace::open_workspace() + .and_then(|ws| ws.current_workspace_path()) .map(|root| root.join(".config/wt.toml").exists()) .unwrap_or(false); create_config_file( diff --git a/src/commands/config/hints.rs b/src/commands/config/hints.rs index d5f1e8bb0..5b31225c1 100644 --- a/src/commands/config/hints.rs +++ b/src/commands/config/hints.rs @@ -1,15 +1,17 @@ //! Hint management commands. //! //! Commands for viewing and clearing shown hints. +//! +//! Hints are stored in VCS-local config (git config or jj repo config). use color_print::cformat; -use worktrunk::git::Repository; use worktrunk::styling::{eprintln, info_message, println, success_message}; +use worktrunk::workspace::open_workspace; /// Handle the hints get command (list shown hints) pub fn handle_hints_get() -> anyhow::Result<()> { - let repo = Repository::current()?; - let hints = repo.list_shown_hints(); + let workspace = open_workspace()?; + let hints = workspace.list_shown_hints(); if hints.is_empty() { eprintln!("{}", info_message("No hints have been shown")); @@ -24,11 +26,12 @@ pub fn handle_hints_get() -> anyhow::Result<()> { /// Handle the hints clear command pub fn handle_hints_clear(name: Option) -> anyhow::Result<()> { - let repo = Repository::current()?; + let workspace = open_workspace()?; match name { Some(hint_name) => { - let msg = if repo.clear_hint(&hint_name)? { + let cleared = workspace.clear_hint(&hint_name)?; + let msg = if cleared { success_message(cformat!("Cleared hint {hint_name}")) } else { info_message(cformat!("Hint {hint_name} was not set")) @@ -36,7 +39,7 @@ pub fn handle_hints_clear(name: Option) -> anyhow::Result<()> { eprintln!("{msg}"); } None => { - let cleared = repo.clear_all_hints()?; + let cleared = workspace.clear_all_hints()?; let msg = if cleared == 0 { info_message("No hints to clear") } else { diff --git a/src/commands/config/show.rs b/src/commands/config/show.rs index b58a13927..eec0f0059 100644 --- a/src/commands/config/show.rs +++ b/src/commands/config/show.rs @@ -20,6 +20,7 @@ use worktrunk::styling::{ error_message, format_bash_with_gutter, format_heading, format_toml, format_with_gutter, hint_message, info_message, success_message, warning_message, }; +use worktrunk::workspace::{Workspace, open_workspace}; use super::state::require_user_config_path; use crate::cli::version_str; @@ -31,6 +32,12 @@ use crate::output; /// Handle the config show command pub fn handle_config_show(full: bool) -> anyhow::Result<()> { + // Open workspace once and try downcast for git-specific sections + let workspace = open_workspace().ok(); + let repo = workspace + .as_ref() + .and_then(|ws| ws.as_any().downcast_ref::()); + // Build the complete output as a string let mut show_output = String::new(); @@ -38,8 +45,8 @@ pub fn handle_config_show(full: bool) -> anyhow::Result<()> { render_user_config(&mut show_output)?; show_output.push('\n'); - // Render project config if in a git repository - render_project_config(&mut show_output)?; + // Render project config if in a repository + render_project_config(&mut show_output, workspace.as_deref(), repo)?; show_output.push('\n'); // Render shell integration status @@ -52,7 +59,7 @@ pub fn handle_config_show(full: bool) -> anyhow::Result<()> { // Run full diagnostic checks if requested (includes slow network calls) if full { show_output.push('\n'); - render_diagnostics(&mut show_output)?; + render_diagnostics(&mut show_output, workspace.as_deref(), repo)?; } // Render runtime info at the bottom (version, binary name, shell integration status) @@ -259,43 +266,55 @@ fn render_runtime_info(out: &mut String) -> anyhow::Result<()> { } /// Run full diagnostic checks (CI tools, commit generation) and render to buffer -fn render_diagnostics(out: &mut String) -> anyhow::Result<()> { +fn render_diagnostics( + out: &mut String, + workspace: Option<&dyn Workspace>, + repo: Option<&Repository>, +) -> anyhow::Result<()> { writeln!(out, "{}", format_heading("DIAGNOSTICS", None))?; // Check CI tool based on detected platform (with config override support) - let repo = Repository::current()?; - let project_config = repo.load_project_config().ok().flatten(); - let platform_override = project_config.as_ref().and_then(|c| c.ci_platform()); - let platform = get_platform_for_repo(&repo, platform_override, None); - - match platform { - Some(CiPlatform::GitHub) => { - let ci_tools = CiToolsStatus::detect(None); - render_ci_tool_status( - out, - "gh", - "GitHub", - ci_tools.gh_installed, - ci_tools.gh_authenticated, - )?; - } - Some(CiPlatform::GitLab) => { - let ci_tools = CiToolsStatus::detect(None); - render_ci_tool_status( - out, - "glab", - "GitLab", - ci_tools.glab_installed, - ci_tools.glab_authenticated, - )?; - } - None => { - writeln!( - out, - "{}", - hint_message("CI status requires GitHub or GitLab remote") - )?; + // CI detection requires git (gh/glab are git-platform tools) + if let Some(repo) = repo { + let project_config = workspace.and_then(|ws| ws.load_project_config().ok().flatten()); + let platform_override = project_config.as_ref().and_then(|c| c.ci_platform()); + let platform = get_platform_for_repo(repo, platform_override, None); + + match platform { + Some(CiPlatform::GitHub) => { + let ci_tools = CiToolsStatus::detect(None); + render_ci_tool_status( + out, + "gh", + "GitHub", + ci_tools.gh_installed, + ci_tools.gh_authenticated, + )?; + } + Some(CiPlatform::GitLab) => { + let ci_tools = CiToolsStatus::detect(None); + render_ci_tool_status( + out, + "glab", + "GitLab", + ci_tools.glab_installed, + ci_tools.glab_authenticated, + )?; + } + None => { + writeln!( + out, + "{}", + hint_message("CI status requires GitHub or GitLab remote") + )?; + } } + } else { + writeln!( + out, + "{}", + hint_message("CI status requires a git repository") + )?; } // Check for newer version on GitHub @@ -303,9 +322,7 @@ fn render_diagnostics(out: &mut String) -> anyhow::Result<()> { // Test commit generation - use effective config for current project let config = UserConfig::load()?; - let project_id = Repository::current() - .ok() - .and_then(|r| r.project_identifier().ok()); + let project_id = workspace.and_then(|ws| ws.project_identifier().ok()); let commit_config = config.commit_generation(project_id.as_deref()); if !commit_config.is_configured() { @@ -434,36 +451,30 @@ pub(super) fn warn_unknown_keys( out } -fn render_project_config(out: &mut String) -> anyhow::Result<()> { - // Try to get current repository root - let repo = match Repository::current() { - Ok(repo) => repo, - Err(_) => { - writeln!( - out, - "{}", - cformat!( - "{}", - format_heading("PROJECT CONFIG", Some("Not in a git repository")) - ) - )?; - return Ok(()); - } +fn render_project_config( + out: &mut String, + workspace: Option<&dyn Workspace>, + repo: Option<&Repository>, +) -> anyhow::Result<()> { + // Try to get workspace root for project config path + let repo_root = if let Some(repo) = repo { + repo.current_worktree().root().ok() + } else { + workspace.and_then(|ws| ws.root_path().ok()) }; - let repo_root = match repo.current_worktree().root() { - Ok(root) => root, - Err(_) => { - writeln!( - out, - "{}", - cformat!( - "{}", - format_heading("PROJECT CONFIG", Some("Not in a git repository")) - ) - )?; - return Ok(()); - } + + let Some(repo_root) = repo_root else { + writeln!( + out, + "{}", + cformat!( + "{}", + format_heading("PROJECT CONFIG", Some("Not in a repository")) + ) + )?; + return Ok(()); }; + let config_path = repo_root.join(".config").join("wt.toml"); writeln!( @@ -491,13 +502,15 @@ fn render_project_config(out: &mut String) -> anyhow::Result<()> { // Check for deprecations with show_brief_warning=false (silent mode) // Only write migration file in main worktree, not linked worktrees - let is_main_worktree = !repo.current_worktree().is_linked().unwrap_or(true); + let is_main_worktree = repo + .map(|r| !r.current_worktree().is_linked().unwrap_or(true)) + .unwrap_or(false); let has_deprecations = if let Ok(Some(info)) = worktrunk::config::check_and_migrate( &config_path, &contents, is_main_worktree, "Project config", - Some(&repo), + repo, false, // silent mode - we'll format the output ourselves ) { // Add deprecation details to the output buffer diff --git a/src/commands/config/state.rs b/src/commands/config/state.rs index 71a01d0bf..e792d8aff 100644 --- a/src/commands/config/state.rs +++ b/src/commands/config/state.rs @@ -4,7 +4,7 @@ //! previous branch, CI status, markers, and logs. use std::fmt::Write as _; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use color_print::cformat; use etcetera::base_strategy::{BaseStrategy, choose_base_strategy}; @@ -14,9 +14,11 @@ use worktrunk::styling::{ eprintln, format_heading, format_with_gutter, info_message, println, success_message, warning_message, }; +use worktrunk::workspace::{Workspace, open_workspace}; use crate::cli::OutputFormat; use crate::commands::process::HookLog; +use crate::commands::require_git_workspace; use worktrunk::utils::get_now; use super::super::list::ci_status::{CachedCiStatus, CiBranchName}; @@ -70,18 +72,34 @@ pub fn require_user_config_path() -> anyhow::Result { }) } +// ==================== Helpers ==================== + +/// Resolve the current branch/workspace name from an explicit option or workspace detection. +fn resolve_branch_name( + workspace: &dyn Workspace, + branch: Option, +) -> anyhow::Result { + match branch { + Some(b) => Ok(b), + None => { + let cwd = std::env::current_dir()?; + workspace + .current_name(&cwd)? + .ok_or_else(|| anyhow::anyhow!("Cannot determine current branch/workspace name")) + } + } +} + // ==================== Log Management ==================== /// Clear all log files from the wt-logs directory -fn clear_logs(repo: &Repository) -> anyhow::Result { - let log_dir = repo.wt_logs_dir(); - +fn clear_logs(log_dir: &Path) -> anyhow::Result { if !log_dir.exists() { return Ok(0); } let mut cleared = 0; - for entry in std::fs::read_dir(&log_dir)? { + for entry in std::fs::read_dir(log_dir)? { let entry = entry?; let path = entry.path(); if path.is_file() && path.extension().is_some_and(|ext| ext == "log") { @@ -91,17 +109,16 @@ fn clear_logs(repo: &Repository) -> anyhow::Result { } // Remove the directory if empty - if std::fs::read_dir(&log_dir)?.next().is_none() { - let _ = std::fs::remove_dir(&log_dir); + if std::fs::read_dir(log_dir)?.next().is_none() { + let _ = std::fs::remove_dir(log_dir); } Ok(cleared) } /// Render the LOG FILES section (heading + table or "(none)") into the output buffer -pub(super) fn render_log_files(out: &mut String, repo: &Repository) -> anyhow::Result<()> { - let log_dir = repo.wt_logs_dir(); - let log_dir_display = format_path_for_display(&log_dir); +pub(super) fn render_log_files(out: &mut String, log_dir: &Path) -> anyhow::Result<()> { + let log_dir_display = format_path_for_display(log_dir); writeln!( out, @@ -114,7 +131,7 @@ pub(super) fn render_log_files(out: &mut String, repo: &Repository) -> anyhow::R return Ok(()); } - let mut entries: Vec<_> = std::fs::read_dir(&log_dir)? + let mut entries: Vec<_> = std::fs::read_dir(log_dir)? .filter_map(|e| e.ok()) .filter(|e| e.path().is_file() && e.path().extension().is_some_and(|ext| ext == "log")) .collect(); @@ -176,13 +193,14 @@ pub(super) fn render_log_files(out: &mut String, repo: &Repository) -> anyhow::R /// - `source:hook-type:name` for hook commands (e.g., `user:post-start:server`) /// - `internal:op` for internal operations (e.g., `internal:remove`) pub fn handle_logs_get(hook: Option, branch: Option) -> anyhow::Result<()> { - let repo = Repository::current()?; + let workspace = open_workspace()?; + let log_dir = workspace.wt_logs_dir(); match hook { None => { // No hook specified, show all log files (existing behavior) let mut out = String::new(); - render_log_files(&mut out, &repo)?; + render_log_files(&mut out, &log_dir)?; // Display through pager (fall back to stderr if pager unavailable) if show_help_in_pager(&out, true).is_err() { @@ -190,13 +208,7 @@ pub fn handle_logs_get(hook: Option, branch: Option) -> anyhow:: } } Some(hook_spec) => { - // Get the branch name - let branch = match branch { - Some(b) => b, - None => repo.require_current_branch("get log for current branch")?, - }; - - let log_dir = repo.wt_logs_dir(); + let branch = resolve_branch_name(&*workspace, branch)?; // Parse the hook spec using HookLog let hook_log = HookLog::parse(&hook_spec).map_err(|e| anyhow::anyhow!("{}", e))?; @@ -259,32 +271,30 @@ pub fn handle_logs_get(hook: Option, branch: Option) -> anyhow:: pub fn handle_state_get(key: &str, branch: Option) -> anyhow::Result<()> { use super::super::list::ci_status::PrStatus; - let repo = Repository::current()?; + let workspace = open_workspace()?; match key { "default-branch" => { - let branch_name = repo.default_branch().ok_or_else(|| { + let branch_name = workspace.default_branch_name().ok_or_else(|| { anyhow::anyhow!(cformat!( "Cannot determine default branch. To configure, run wt config state default-branch set BRANCH" )) })?; println!("{branch_name}"); } - "previous-branch" => match repo.switch_previous() { + "previous-branch" => match workspace.switch_previous() { Some(prev) => println!("{prev}"), None => println!(""), }, "marker" => { - let branch_name = match branch { - Some(b) => b, - None => repo.require_current_branch("get marker for current branch")?, - }; - match repo.branch_marker(&branch_name) { + let branch_name = resolve_branch_name(&*workspace, branch)?; + match workspace.branch_marker(&branch_name) { Some(marker) => println!("{marker}"), None => println!(""), } } "ci-status" => { + let repo = require_git_workspace(&*workspace, "config state get ci-status")?; let branch_name = match branch { Some(b) => b, None => repo.require_current_branch("get ci-status for current branch")?, @@ -315,8 +325,8 @@ pub fn handle_state_get(key: &str, branch: Option) -> anyhow::Result<()> .into()); } - let ci_branch = CiBranchName::from_branch_ref(&branch_name, is_remote, &repo); - let ci_status = PrStatus::detect(&repo, &ci_branch, &head) + let ci_branch = CiBranchName::from_branch_ref(&branch_name, is_remote, repo); + let ci_status = PrStatus::detect(repo, &ci_branch, &head) .map_or(super::super::list::ci_status::CiStatus::NoCI, |s| { s.ci_status }); @@ -325,8 +335,9 @@ pub fn handle_state_get(key: &str, branch: Option) -> anyhow::Result<()> } // TODO: Consider simplifying to just print the path and let users run `ls -al` themselves "logs" => { + let log_dir = workspace.wt_logs_dir(); let mut out = String::new(); - render_log_files(&mut out, &repo)?; + render_log_files(&mut out, &log_dir)?; // Display through pager (fall back to stderr if pager unavailable) if show_help_in_pager(&out, true).is_err() { @@ -345,46 +356,35 @@ pub fn handle_state_get(key: &str, branch: Option) -> anyhow::Result<()> /// Handle the state set command pub fn handle_state_set(key: &str, value: String, branch: Option) -> anyhow::Result<()> { - let repo = Repository::current()?; + let workspace = open_workspace()?; match key { "default-branch" => { - // Warn if the branch doesn't exist locally - if !repo.branch(&value).exists_locally()? { + // Warn if the branch doesn't exist locally (git-only check) + if let Some(repo) = workspace.as_any().downcast_ref::() + && !repo.branch(&value).exists_locally()? + { eprintln!( "{}", warning_message(cformat!("Branch {value} does not exist locally")) ); } - repo.set_default_branch(&value)?; + workspace.set_default_branch(&value)?; eprintln!( "{}", success_message(cformat!("Set default branch to {value}")) ); } "previous-branch" => { - repo.set_switch_previous(Some(&value))?; + workspace.set_switch_previous(Some(&value))?; eprintln!( "{}", success_message(cformat!("Set previous branch to {value}")) ); } "marker" => { - let branch_name = match branch { - Some(b) => b, - None => repo.require_current_branch("set marker for current branch")?, - }; - - // Store as JSON with timestamp - let now = get_now(); - let json = serde_json::json!({ - "marker": value, - "set_at": now - }); - - let config_key = format!("worktrunk.state.{branch_name}.marker"); - repo.run_command(&["config", &config_key, &json.to_string()])?; - + let branch_name = resolve_branch_name(&*workspace, branch)?; + workspace.set_branch_marker(&branch_name, &value, get_now())?; eprintln!( "{}", success_message(cformat!( @@ -402,29 +402,27 @@ pub fn handle_state_set(key: &str, value: String, branch: Option) -> any /// Handle the state clear command pub fn handle_state_clear(key: &str, branch: Option, all: bool) -> anyhow::Result<()> { - let repo = Repository::current()?; + let workspace = open_workspace()?; match key { "default-branch" => { - if repo.clear_default_branch_cache()? { + if workspace.clear_default_branch()? { eprintln!("{}", success_message("Cleared default branch cache")); } else { eprintln!("{}", info_message("No default branch cache to clear")); } } "previous-branch" => { - if repo - .run_command(&["config", "--unset", "worktrunk.history"]) - .is_ok() - { + if workspace.clear_switch_previous()? { eprintln!("{}", success_message("Cleared previous branch")); } else { eprintln!("{}", info_message("No previous branch to clear")); } } "ci-status" => { + let repo = require_git_workspace(&*workspace, "config state ci-status clear")?; if all { - let cleared = CachedCiStatus::clear_all(&repo); + let cleared = CachedCiStatus::clear_all(repo); if cleared == 0 { eprintln!("{}", info_message("No CI cache entries to clear")); } else { @@ -461,18 +459,7 @@ pub fn handle_state_clear(key: &str, branch: Option, all: bool) -> anyho } "marker" => { if all { - let output = repo - .run_command(&["config", "--get-regexp", r"^worktrunk\.state\..+\.marker$"]) - .unwrap_or_default(); - - let mut cleared_count = 0; - for line in output.lines() { - if let Some(config_key) = line.split_whitespace().next() { - repo.run_command(&["config", "--unset", config_key])?; - cleared_count += 1; - } - } - + let cleared_count = workspace.clear_all_markers(); if cleared_count == 0 { eprintln!("{}", info_message("No markers to clear")); } else { @@ -485,16 +472,8 @@ pub fn handle_state_clear(key: &str, branch: Option, all: bool) -> anyho ); } } else { - let branch_name = match branch { - Some(b) => b, - None => repo.require_current_branch("clear marker for current branch")?, - }; - - let config_key = format!("worktrunk.state.{branch_name}.marker"); - if repo - .run_command(&["config", "--unset", &config_key]) - .is_ok() - { + let branch_name = resolve_branch_name(&*workspace, branch)?; + if workspace.clear_branch_marker(&branch_name) { eprintln!( "{}", success_message(cformat!("Cleared marker for {branch_name}")) @@ -508,7 +487,8 @@ pub fn handle_state_clear(key: &str, branch: Option, all: bool) -> anyho } } "logs" => { - let cleared = clear_logs(&repo)?; + let log_dir = workspace.wt_logs_dir(); + let cleared = clear_logs(&log_dir)?; if cleared == 0 { eprintln!("{}", info_message("No logs to clear")); } else { @@ -533,48 +513,40 @@ pub fn handle_state_clear(key: &str, branch: Option, all: bool) -> anyho /// Handle the state clear all command pub fn handle_state_clear_all() -> anyhow::Result<()> { - let repo = Repository::current()?; + let workspace = open_workspace()?; let mut cleared_any = false; - // Clear default branch cache - if matches!(repo.clear_default_branch_cache(), Ok(true)) { + // Clear previous branch (trait method — works for both git and jj) + if workspace.clear_switch_previous()? { cleared_any = true; } - // Clear previous branch - if repo - .run_command(&["config", "--unset", "worktrunk.history"]) - .is_ok() - { + // Clear logs (works for both git and jj) + let log_dir = workspace.wt_logs_dir(); + let logs_cleared = clear_logs(&log_dir)?; + if logs_cleared > 0 { cleared_any = true; } - // Clear all markers - let markers_output = repo - .run_command(&["config", "--get-regexp", r"^worktrunk\.state\..+\.marker$"]) - .unwrap_or_default(); - for line in markers_output.lines() { - if let Some(config_key) = line.split_whitespace().next() { - let _ = repo.run_command(&["config", "--unset", config_key]); - cleared_any = true; - } + // Clear default branch cache (works for both git and jj) + if matches!(workspace.clear_default_branch(), Ok(true)) { + cleared_any = true; } - // Clear all CI status cache - let ci_cleared = CachedCiStatus::clear_all(&repo); - if ci_cleared > 0 { + // Clear all markers (trait method — works for both git and jj) + if workspace.clear_all_markers() > 0 { cleared_any = true; } - // Clear all logs - let logs_cleared = clear_logs(&repo)?; - if logs_cleared > 0 { + // Clear all hints (trait method — works for both git and jj) + if workspace.clear_all_hints()? > 0 { cleared_any = true; } - // Clear all hints - let hints_cleared = repo.clear_all_hints()?; - if hints_cleared > 0 { + // Clear CI status cache (git-only) + if let Some(repo) = workspace.as_any().downcast_ref::() + && CachedCiStatus::clear_all(repo) > 0 + { cleared_any = true; } @@ -591,59 +563,65 @@ pub fn handle_state_clear_all() -> anyhow::Result<()> { /// Handle the state get command (shows all state) pub fn handle_state_show(format: OutputFormat) -> anyhow::Result<()> { - let repo = Repository::current()?; + let workspace = open_workspace()?; + let repo = workspace.as_any().downcast_ref::(); match format { - OutputFormat::Json => handle_state_show_json(&repo), - OutputFormat::Table | OutputFormat::ClaudeCode => handle_state_show_table(&repo), + OutputFormat::Json => handle_state_show_json(&*workspace, repo), + OutputFormat::Table | OutputFormat::ClaudeCode => { + handle_state_show_table(&*workspace, repo) + } } } /// Output state as JSON -fn handle_state_show_json(repo: &Repository) -> anyhow::Result<()> { - // Get default branch - let default_branch = repo.default_branch(); - - // Get previous branch - let previous_branch = repo.switch_previous(); - - // Get markers - let markers: Vec = get_all_markers(repo) +fn handle_state_show_json( + workspace: &dyn Workspace, + repo: Option<&Repository>, +) -> anyhow::Result<()> { + let default_branch = workspace.default_branch_name(); + let previous_branch = workspace.switch_previous(); + + let markers: Vec = workspace + .list_all_markers() .into_iter() - .map(|m| { + .map(|(branch, marker, set_at)| { serde_json::json!({ - "branch": m.branch, - "marker": m.marker, - "set_at": if m.set_at > 0 { Some(m.set_at) } else { None } + "branch": branch, + "marker": marker, + "set_at": if set_at > 0 { Some(set_at) } else { None } }) }) .collect(); - // Get CI status cache - let mut ci_entries = CachedCiStatus::list_all(repo); - ci_entries.sort_by(|a, b| { - b.1.checked_at - .cmp(&a.1.checked_at) - .then_with(|| a.0.cmp(&b.0)) - }); - let ci_status: Vec = ci_entries - .into_iter() - .map(|(branch, cached)| { - let status = cached - .status - .as_ref() - .map(|s| -> &'static str { s.ci_status.into() }); - serde_json::json!({ - "branch": branch, - "status": status, - "checked_at": cached.checked_at, - "head": cached.head - }) + let ci_status: Vec = repo + .map(|r| { + let mut ci_entries = CachedCiStatus::list_all(r); + ci_entries.sort_by(|a, b| { + b.1.checked_at + .cmp(&a.1.checked_at) + .then_with(|| a.0.cmp(&b.0)) + }); + ci_entries + .into_iter() + .map(|(branch, cached)| { + let status = cached + .status + .as_ref() + .map(|s| -> &'static str { s.ci_status.into() }); + serde_json::json!({ + "branch": branch, + "status": status, + "checked_at": cached.checked_at, + "head": cached.head + }) + }) + .collect() }) - .collect(); + .unwrap_or_default(); - // Get log files - let log_dir = repo.wt_logs_dir(); + // Log files + let log_dir = workspace.wt_logs_dir(); let logs: Vec = if log_dir.exists() { let mut entries: Vec<_> = std::fs::read_dir(&log_dir)? .filter_map(|e| e.ok()) @@ -683,8 +661,7 @@ fn handle_state_show_json(repo: &Repository) -> anyhow::Result<()> { vec![] }; - // Get hints - let hints = repo.list_shown_hints(); + let hints = workspace.list_shown_hints(); let output = serde_json::json!({ "default_branch": default_branch, @@ -700,94 +677,101 @@ fn handle_state_show_json(repo: &Repository) -> anyhow::Result<()> { } /// Output state as human-readable table -fn handle_state_show_table(repo: &Repository) -> anyhow::Result<()> { +fn handle_state_show_table( + workspace: &dyn Workspace, + repo: Option<&Repository>, +) -> anyhow::Result<()> { // Build complete output as a string let mut out = String::new(); - // Show default branch cache + // Show default branch cache (trait method) writeln!(out, "{}", format_heading("DEFAULT BRANCH", None))?; - match repo.default_branch() { + match workspace.default_branch_name() { Some(branch) => writeln!(out, "{}", format_with_gutter(&branch, None))?, None => writeln!(out, "{}", format_with_gutter("(not available)", None))?, } writeln!(out)?; - // Show previous branch (for `wt switch -`) + // Show previous branch (trait method) writeln!(out, "{}", format_heading("PREVIOUS BRANCH", None))?; - match repo.switch_previous() { + match workspace.switch_previous() { Some(prev) => writeln!(out, "{}", format_with_gutter(&prev, None))?, None => writeln!(out, "{}", format_with_gutter("(none)", None))?, } writeln!(out)?; - // Show branch markers - writeln!(out, "{}", format_heading("BRANCH MARKERS", None))?; - let markers = get_all_markers(repo); - if markers.is_empty() { - writeln!(out, "{}", format_with_gutter("(none)", None))?; - } else { - let mut table = String::from("| Branch | Marker | Age |\n"); - table.push_str("|--------|--------|-----|\n"); - for entry in markers { - let age = format_relative_time_short(entry.set_at as i64); - table.push_str(&format!( - "| {} | {} | {} |\n", - entry.branch, entry.marker, age - )); + // Show branch markers (trait method) + { + let markers = workspace.list_all_markers(); + writeln!(out, "{}", format_heading("BRANCH MARKERS", None))?; + if markers.is_empty() { + writeln!(out, "{}", format_with_gutter("(none)", None))?; + } else { + let mut table = String::from("| Branch | Marker | Age |\n"); + table.push_str("|--------|--------|-----|\n"); + for (branch, marker, set_at) in markers { + let age = format_relative_time_short(set_at as i64); + table.push_str(&format!("| {branch} | {marker} | {age} |\n")); + } + let rendered = crate::md_help::render_markdown_table(&table); + writeln!(out, "{}", rendered.trim_end())?; } - let rendered = crate::md_help::render_markdown_table(&table); - writeln!(out, "{}", rendered.trim_end())?; + writeln!(out)?; } - writeln!(out)?; - // Show CI status cache - writeln!(out, "{}", format_heading("CI STATUS CACHE", None))?; - let mut entries = CachedCiStatus::list_all(repo); - // Sort by age (most recent first), then by branch name for ties - entries.sort_by(|a, b| { - b.1.checked_at - .cmp(&a.1.checked_at) - .then_with(|| a.0.cmp(&b.0)) - }); - if entries.is_empty() { - writeln!(out, "{}", format_with_gutter("(none)", None))?; - } else { - // Build markdown table - let mut table = String::from("| Branch | Status | Age | Head |\n"); - table.push_str("|--------|--------|-----|------|\n"); - for (branch, cached) in entries { - let status = match &cached.status { - Some(pr_status) => { - let status: &'static str = pr_status.ci_status.into(); - status.to_string() - } - None => "none".to_string(), - }; - let age = format_relative_time_short(cached.checked_at as i64); - let head: String = cached.head.chars().take(8).collect(); + // Show CI status cache (git-only) + if let Some(repo) = repo { + writeln!(out, "{}", format_heading("CI STATUS CACHE", None))?; + let mut entries = CachedCiStatus::list_all(repo); + // Sort by age (most recent first), then by branch name for ties + entries.sort_by(|a, b| { + b.1.checked_at + .cmp(&a.1.checked_at) + .then_with(|| a.0.cmp(&b.0)) + }); + if entries.is_empty() { + writeln!(out, "{}", format_with_gutter("(none)", None))?; + } else { + // Build markdown table + let mut table = String::from("| Branch | Status | Age | Head |\n"); + table.push_str("|--------|--------|-----|------|\n"); + for (branch, cached) in entries { + let status = match &cached.status { + Some(pr_status) => { + let status: &'static str = pr_status.ci_status.into(); + status.to_string() + } + None => "none".to_string(), + }; + let age = format_relative_time_short(cached.checked_at as i64); + let head: String = cached.head.chars().take(8).collect(); - table.push_str(&format!("| {branch} | {status} | {age} | {head} |\n")); - } + table.push_str(&format!("| {branch} | {status} | {age} | {head} |\n")); + } - let rendered = crate::md_help::render_markdown_table(&table); - writeln!(out, "{}", rendered.trim_end())?; + let rendered = crate::md_help::render_markdown_table(&table); + writeln!(out, "{}", rendered.trim_end())?; + } + writeln!(out)?; } - writeln!(out)?; - // Show hints - writeln!(out, "{}", format_heading("HINTS", None))?; - let hints = repo.list_shown_hints(); - if hints.is_empty() { - writeln!(out, "{}", format_with_gutter("(none)", None))?; - } else { - for hint in hints { - writeln!(out, "{}", format_with_gutter(&hint, None))?; + // Show hints (trait method) + { + let hints = workspace.list_shown_hints(); + writeln!(out, "{}", format_heading("HINTS", None))?; + if hints.is_empty() { + writeln!(out, "{}", format_with_gutter("(none)", None))?; + } else { + for hint in hints { + writeln!(out, "{}", format_with_gutter(&hint, None))?; + } } + writeln!(out)?; } - writeln!(out)?; - // Show log files - render_log_files(&mut out, repo)?; + // Show log files (trait method) + let log_dir = workspace.wt_logs_dir(); + render_log_files(&mut out, &log_dir)?; // Display through pager (fall back to stderr if pager unavailable) if let Err(e) = show_help_in_pager(&out, true) { @@ -798,53 +782,3 @@ fn handle_state_show_table(repo: &Repository) -> anyhow::Result<()> { Ok(()) } - -// ==================== Marker Helpers ==================== - -/// Marker entry with branch, text, and timestamp -pub(super) struct MarkerEntry { - pub branch: String, - pub marker: String, - pub set_at: u64, -} - -/// Get all branch markers from git config with timestamps -pub(super) fn get_all_markers(repo: &Repository) -> Vec { - let output = repo - .run_command(&["config", "--get-regexp", r"^worktrunk\.state\..+\.marker$"]) - .unwrap_or_default(); - - let mut markers = Vec::new(); - for line in output.lines() { - // Format: "worktrunk.state..marker json_value" - let Some((key, value)) = line.split_once(' ') else { - continue; - }; - let Some(branch) = key - .strip_prefix("worktrunk.state.") - .and_then(|s| s.strip_suffix(".marker")) - else { - continue; - }; - let Ok(parsed) = serde_json::from_str::(value) else { - continue; // Skip invalid JSON - }; - let Some(marker) = parsed.get("marker").and_then(|v| v.as_str()) else { - continue; // Skip if "marker" field is missing - }; - let set_at = parsed.get("set_at").and_then(|v| v.as_u64()).unwrap_or(0); - markers.push(MarkerEntry { - branch: branch.to_string(), - marker: marker.to_string(), - set_at, - }); - } - - // Sort by age (most recent first), then by branch name for ties - markers.sort_by(|a, b| { - b.set_at - .cmp(&a.set_at) - .then_with(|| a.branch.cmp(&b.branch)) - }); - markers -} diff --git a/src/commands/context.rs b/src/commands/context.rs index 367b422dc..d856844e2 100644 --- a/src/commands/context.rs +++ b/src/commands/context.rs @@ -1,22 +1,27 @@ -use anyhow::Context; use std::path::PathBuf; + +use anyhow::Context; use worktrunk::config::UserConfig; use worktrunk::git::Repository; +use worktrunk::workspace::{Workspace, open_workspace}; use super::command_executor::CommandContext; /// Shared execution context for command handlers that operate on the current worktree. /// -/// Centralizes the common "repo + branch + config + cwd" setup so individual handlers +/// Centralizes the common "workspace + branch + config + cwd" setup so individual handlers /// can focus on their core logic while sharing consistent error messaging. /// +/// Holds a `Box` for VCS-agnostic operations. Commands that need +/// git-specific features (hooks, staging) can access `&Repository` via [`repo()`](Self::repo). +/// /// This helper is used for commands that explicitly act on "where the user is standing" -/// (e.g., `beta` and `merge`) and therefore need all of these pieces together. Commands that -/// inspect multiple worktrees or run without a config/branch requirement (`list`, `select`, -/// some `worktree` helpers) still call `Repository::current()` directly so they can operate in -/// broader contexts without forcing config loads or branch resolution. +/// (e.g., `step commit` and `merge`) and therefore need all of these pieces together. +/// Commands that inspect multiple worktrees or run without a config/branch requirement +/// (`list`, `select`, some `worktree` helpers) call `open_workspace()` directly so they +/// can operate in broader contexts without forcing config loads or branch resolution. pub struct CommandEnv { - pub repo: Repository, + pub workspace: Box, /// Current branch name, if on a branch (None in detached HEAD state). pub branch: Option, pub config: UserConfig, @@ -24,49 +29,81 @@ pub struct CommandEnv { } impl CommandEnv { - /// Load the command environment for a specific action. + /// Build the command environment from a pre-opened workspace. /// /// `action` describes what command is running (e.g., "merge", "squash"). /// Used in error messages when the environment can't be loaded. - pub fn for_action(action: &str, config: UserConfig) -> anyhow::Result { - let repo = Repository::current()?; - let worktree_path = repo.current_worktree().path().to_path_buf(); - let branch = repo.require_current_branch(action)?; + /// Requires a branch (can't merge/squash in detached HEAD). + pub fn with_workspace( + workspace: Box, + action: &str, + config: UserConfig, + ) -> anyhow::Result { + let worktree_path = workspace.current_workspace_path()?; + let branch = workspace.current_name(&worktree_path)?; + + // Require a branch (can't merge/squash in detached HEAD) + if branch.is_none() { + return Err(worktrunk::git::GitError::DetachedHead { + action: Some(action.into()), + } + .into()); + } Ok(Self { - repo, - branch: Some(branch), + workspace, + branch, config, worktree_path, }) } - /// Load the command environment without requiring a branch. + /// Build the command environment from a pre-opened workspace, without requiring a branch. /// /// Use this for commands that can operate in detached HEAD state, /// such as running hooks (where `{{ branch }}` expands to "HEAD" if detached). - pub fn for_action_branchless() -> anyhow::Result { - let repo = Repository::current()?; - let current_wt = repo.current_worktree(); - let worktree_path = current_wt.path().to_path_buf(); - // Propagate git errors (broken repo, missing git) but allow None for detached HEAD - let branch = current_wt - .branch() + pub fn with_workspace_branchless(workspace: Box) -> anyhow::Result { + let worktree_path = workspace.current_workspace_path()?; + let branch = workspace + .current_name(&worktree_path) .context("Failed to determine current branch")?; let config = UserConfig::load().context("Failed to load config")?; Ok(Self { - repo, + workspace, branch, config, worktree_path, }) } + /// Open a workspace and load the command environment without requiring a branch. + /// + /// Convenience wrapper that calls `open_workspace()` then `with_workspace_branchless()`. + pub fn for_action_branchless() -> anyhow::Result { + Self::with_workspace_branchless(open_workspace()?) + } + + /// Access the underlying git `Repository`. + /// + /// Returns `None` if this is a non-git workspace (e.g., jj). + /// For git workspaces, this is always `Some`. + pub fn repo(&self) -> Option<&Repository> { + self.workspace.as_any().downcast_ref::() + } + + /// Access the underlying git `Repository`, returning an error if not git. + /// + /// Use in code paths that require git-specific features (hooks, staging). + pub fn require_repo(&self) -> anyhow::Result<&Repository> { + self.repo() + .ok_or_else(|| anyhow::anyhow!("This command requires a git repository")) + } + /// Build a `CommandContext` tied to this environment. pub fn context(&self, yes: bool) -> CommandContext<'_> { CommandContext::new( - &self.repo, + self.workspace.as_ref(), &self.config, self.branch.as_deref(), &self.worktree_path, @@ -89,7 +126,7 @@ impl CommandEnv { /// Uses the remote URL if available, otherwise the canonical repository path. /// Returns None only if the path is not valid UTF-8. pub fn project_id(&self) -> Option { - self.repo.project_identifier().ok() + self.workspace.project_identifier().ok() } /// Get all resolved config with defaults applied. diff --git a/src/commands/for_each.rs b/src/commands/for_each.rs index a52f1c136..afda6d978 100644 --- a/src/commands/for_each.rs +++ b/src/commands/for_each.rs @@ -27,15 +27,14 @@ use std::process::Stdio; use color_print::cformat; use worktrunk::config::{UserConfig, expand_template}; -use worktrunk::git::Repository; use worktrunk::git::WorktrunkError; use worktrunk::shell_exec::ShellConfig; use worktrunk::styling::{ eprintln, error_message, format_with_gutter, progress_message, success_message, warning_message, }; +use worktrunk::workspace::build_worktree_map; use crate::commands::command_executor::{CommandContext, build_hook_context}; -use crate::commands::worktree_display_name; /// Run a command in each worktree sequentially. /// @@ -44,31 +43,54 @@ use crate::commands::worktree_display_name; /// /// All template variables from hooks are available, and context JSON is piped to stdin. pub fn step_for_each(args: Vec) -> anyhow::Result<()> { - let repo = Repository::current()?; - // Filter out prunable worktrees (directory deleted) - can't run commands there - let worktrees: Vec<_> = repo - .list_worktrees()? - .into_iter() - .filter(|wt| !wt.is_prunable()) - .collect(); + let workspace = worktrunk::workspace::open_workspace()?; let config = UserConfig::load()?; + // Build workspace items — works for both git and jj + let workspaces: Vec<_> = if let Some(repo) = workspace + .as_any() + .downcast_ref::() + { + // Git path: use WorktreeInfo, filter prunable + repo.list_worktrees()? + .into_iter() + .filter(|wt| !wt.is_prunable()) + .map(|wt| { + let display_name = crate::commands::worktree_display_name(&wt, repo, &config); + let branch = wt.branch.clone(); + let path = wt.path.clone(); + (display_name, branch, path) + }) + .collect() + } else { + // Jj/generic path: use trait's list_workspaces + workspace + .list_workspaces()? + .into_iter() + .filter(|ws| ws.prunable.is_none()) + .map(|ws| { + let display_name = cformat!("{}", ws.name); + let branch = Some(ws.name); + let path = ws.path; + (display_name, branch, path) + }) + .collect() + }; + let mut failed: Vec = Vec::new(); - let total = worktrees.len(); + let total = workspaces.len(); // Join args into a template string (will be expanded per-worktree) let command_template = args.join(" "); - for wt in &worktrees { - let display_name = worktree_display_name(wt, &repo, &config); + for (display_name, branch, path) in &workspaces { eprintln!( "{}", progress_message(format!("Running in {display_name}...")) ); // Build full hook context for this worktree - // Pass wt.branch directly (not the display string) so detached HEAD maps to None -> "HEAD" - let ctx = CommandContext::new(&repo, &config, wt.branch.as_deref(), &wt.path, false); + let ctx = CommandContext::new(&*workspace, &config, branch.as_deref(), path, false); let context_map = build_hook_context(&ctx, &[]); // Convert to &str references for expand_template @@ -78,7 +100,14 @@ pub fn step_for_each(args: Vec) -> anyhow::Result<()> { .collect(); // Expand template with full context (shell-escaped) - let command = expand_template(&command_template, &vars, true, &repo, "for-each command")?; + let worktree_map = build_worktree_map(&*workspace); + let command = expand_template( + &command_template, + &vars, + true, + &worktree_map, + "for-each command", + )?; // Build JSON context for stdin let context_json = serde_json::to_string(&context_map) @@ -86,7 +115,7 @@ pub fn step_for_each(args: Vec) -> anyhow::Result<()> { // Execute command: stream both stdout and stderr in real-time // Pipe context JSON to stdin for scripts that want structured data - match run_command_streaming(&command, &wt.path, Some(&context_json)) { + match run_command_streaming(&command, path, Some(&context_json)) { Ok(()) => {} Err(CommandError::SpawnFailed(err)) => { eprintln!( diff --git a/src/commands/handle_merge_jj.rs b/src/commands/handle_merge_jj.rs new file mode 100644 index 000000000..835ac8280 --- /dev/null +++ b/src/commands/handle_merge_jj.rs @@ -0,0 +1,233 @@ +//! Merge command handler for jj repositories. +//! +//! Handles squash/rebase, hook execution, push, and optional workspace removal. +//! jj auto-snapshots the working copy (no staging area or pre-commit hooks). + +use std::path::Path; + +use anyhow::Context; +use color_print::cformat; +use worktrunk::HookType; +use worktrunk::config::UserConfig; +use worktrunk::styling::{eprintln, info_message, success_message}; +use worktrunk::workspace::{JjWorkspace, Workspace}; + +use super::command_approval::approve_hooks; +use super::context::CommandEnv; +use super::handle_remove_jj::{IntegrationInfo, remove_jj_workspace_and_cd}; +use super::hooks::{HookFailureStrategy, run_hook_with_filter}; +use super::merge::MergeOptions; +use super::step_commands::{SquashResult, do_squash}; + +/// Handle `wt merge` for jj repositories. +/// +/// Squashes (or rebases) the current workspace's changes into trunk, +/// runs pre-merge/post-merge hooks, updates the target bookmark, pushes +/// if possible, and optionally removes the workspace. +pub fn handle_merge_jj(opts: MergeOptions<'_>) -> anyhow::Result<()> { + let workspace = JjWorkspace::from_current_dir()?; + let cwd = std::env::current_dir()?; + + let current = workspace.current_workspace(&cwd)?; + + if current.is_default { + anyhow::bail!("Cannot merge the default workspace"); + } + + let ws_name = current.name.clone(); + let ws_path = current.path.clone(); + + // Load config for merge defaults + let config = UserConfig::load().context("Failed to load config")?; + let project_id = workspace.project_identifier().ok(); + let resolved = config.resolved(project_id.as_deref()); + + // CLI flags override config values + let verify = opts.verify.unwrap_or(resolved.merge.verify()); + let yes = opts.yes; + let remove = opts.remove.unwrap_or(resolved.merge.remove()); + + // "Approve at the Gate": approve all hooks upfront + let verify = if verify { + let env = CommandEnv::for_action_branchless()?; + let ctx = env.context(yes); + + let mut hook_types = vec![HookType::PreMerge, HookType::PostMerge]; + if remove { + hook_types.extend_from_slice(&[ + HookType::PreRemove, + HookType::PostRemove, + HookType::PostSwitch, + ]); + } + + let approved = approve_hooks(&ctx, &hook_types)?; + if !approved { + eprintln!("{}", info_message("Commands declined, continuing merge")); + false + } else { + true + } + } else { + false + }; + + // Target bookmark name — detect from config/trunk()/local bookmarks, or use explicit override + let resolved_target = workspace.resolve_integration_target(opts.target)?; + let target = resolved_target.as_str(); + + // Check if already integrated + let feature_tip = workspace.feature_tip(&ws_path)?; + if workspace.is_integrated(&feature_tip, target)?.is_some() { + eprintln!( + "{}", + info_message(cformat!( + "Workspace {ws_name} is already integrated into trunk" + )) + ); + return remove_if_requested(&workspace, remove, yes, &ws_name, &ws_path, verify, target); + } + + // CLI flags override config values (jj always squashes by default) + let squash = opts.squash.unwrap_or(resolved.merge.squash()); + + if squash { + let repo_name = project_id.as_deref().unwrap_or("repo"); + match do_squash( + &workspace, + target, + &ws_path, + &resolved.commit_generation, + &ws_name, + repo_name, + )? { + SquashResult::NoCommitsAhead(_) => { + eprintln!( + "{}", + info_message(cformat!( + "Workspace {ws_name} is already integrated into trunk" + )) + ); + return remove_if_requested( + &workspace, remove, yes, &ws_name, &ws_path, verify, target, + ); + } + SquashResult::AlreadySingleCommit | SquashResult::Squashed => { + // Proceed to push + } + SquashResult::NoNetChanges => { + // Feature commits canceled out — nothing to push, just remove + return remove_if_requested( + &workspace, remove, yes, &ws_name, &ws_path, verify, target, + ); + } + } + } else { + rebase_onto_trunk(&workspace, &ws_path, target)?; + } + + // Run pre-merge hooks (after squash/rebase, before push) + if verify { + let env = CommandEnv::for_action_branchless()?; + let ctx = env.context(yes); + let project_config = workspace.load_project_config()?; + let user_hooks = ctx.config.hooks(ctx.project_id().as_deref()); + run_hook_with_filter( + &ctx, + user_hooks.pre_merge.as_ref(), + project_config + .as_ref() + .and_then(|c| c.hooks.pre_merge.as_ref()), + HookType::PreMerge, + &[("target", target)], + HookFailureStrategy::FailFast, + None, + None, + )?; + } + + // Local push: advance target bookmark to include feature commits + match workspace.local_push(target, &ws_path, Default::default()) { + Ok(result) if result.commit_count > 0 => { + eprintln!("{}", success_message(cformat!("Pushed {target}"))); + } + _ => {} + } + + let mode = if squash { "Squashed" } else { "Merged" }; + eprintln!( + "{}", + success_message(cformat!( + "{mode} workspace {ws_name} into {target}" + )) + ); + + // Run post-merge hooks before removal (cwd must still exist) + if verify { + let env = CommandEnv::for_action_branchless()?; + let ctx = env.context(yes); + let project_config = workspace.load_project_config()?; + let user_hooks = ctx.config.hooks(ctx.project_id().as_deref()); + run_hook_with_filter( + &ctx, + user_hooks.post_merge.as_ref(), + project_config + .as_ref() + .and_then(|c| c.hooks.post_merge.as_ref()), + HookType::PostMerge, + &[("target", target)], + HookFailureStrategy::Warn, + None, + None, + )?; + } + + // Remove workspace if requested + remove_if_requested(&workspace, remove, yes, &ws_name, &ws_path, verify, target)?; + + Ok(()) +} + +/// Rebase the feature branch onto trunk without squashing. +/// +/// 1. `jj rebase -b @ -d {target}` — rebase entire branch +/// 2. Determine feature tip (@ if has content, @- if empty) +/// 3. `jj bookmark set {target} -r {tip}` — update bookmark +fn rebase_onto_trunk(workspace: &JjWorkspace, ws_path: &Path, target: &str) -> anyhow::Result<()> { + workspace.run_in_dir(ws_path, &["rebase", "-b", "@", "-d", target])?; + + // After rebase, find the feature tip (same logic as squash path) + let feature_tip = workspace.feature_tip(ws_path)?; + workspace.run_in_dir(ws_path, &["bookmark", "set", target, "-r", &feature_tip])?; + + Ok(()) +} + +/// Remove the workspace if `--no-remove` wasn't specified. +/// +/// Computes the integration reason before removal so the success message +/// can show whether/how the workspace was integrated into the target. +fn remove_if_requested( + workspace: &JjWorkspace, + remove: bool, + yes: bool, + ws_name: &str, + ws_path: &Path, + run_hooks: bool, + target: &str, +) -> anyhow::Result<()> { + if !remove { + eprintln!("{}", info_message("Workspace preserved (--no-remove)")); + return Ok(()); + } + + // Compute integration before removal (workspace must still exist) + let integration = workspace + .feature_tip(ws_path) + .ok() + .and_then(|tip| workspace.is_integrated(&tip, target).ok().flatten()) + .map(|reason| IntegrationInfo { reason, target }); + + // Pass through run_hooks so pre-remove/post-remove/post-switch hooks execute during merge + remove_jj_workspace_and_cd(workspace, ws_name, ws_path, run_hooks, yes, integration) +} diff --git a/src/commands/handle_remove_jj.rs b/src/commands/handle_remove_jj.rs new file mode 100644 index 000000000..7f04f4f69 --- /dev/null +++ b/src/commands/handle_remove_jj.rs @@ -0,0 +1,158 @@ +//! Remove helper for jj repositories. +//! +//! Simpler than git removal: no branch deletion, no merge status checks. +//! Just forget the workspace and remove the directory. + +use std::path::Path; + +use color_print::cformat; +use worktrunk::HookType; +use worktrunk::path::format_path_for_display; +use worktrunk::styling::{eprintln, success_message, warning_message}; +use worktrunk::workspace::Workspace; +use worktrunk::workspace::types::IntegrationReason; + +use super::context::CommandEnv; +use super::hooks::{HookFailureStrategy, run_hook_with_filter}; +use crate::output; + +/// Integration context for display in the removal success message. +pub struct IntegrationInfo<'a> { + pub reason: IntegrationReason, + pub target: &'a str, +} + +/// Forget a jj workspace, remove its directory, and cd to default if needed. +/// +/// When `integration` is provided (e.g., from `wt merge`), the success message +/// includes the integration reason, matching git's removal output style. +/// +/// Shared between `wt remove` and `wt merge` for jj repositories. +pub fn remove_jj_workspace_and_cd( + workspace: &dyn Workspace, + name: &str, + ws_path: &Path, + run_hooks: bool, + yes: bool, + integration: Option>, +) -> anyhow::Result<()> { + if name == "default" { + anyhow::bail!("Cannot remove the default workspace"); + } + + let path_display = format_path_for_display(ws_path); + + // Check if we're inside the workspace being removed + let cwd = dunce::canonicalize(std::env::current_dir()?)?; + let canonical_ws = dunce::canonicalize(ws_path).unwrap_or_else(|_| ws_path.to_path_buf()); + let removing_current = cwd.starts_with(&canonical_ws); + + // Build hook context BEFORE deletion — CommandEnv needs the current directory to exist + let hook_ctx = if run_hooks { + let env = CommandEnv::for_action_branchless()?; + let project_config = workspace.load_project_config()?; + Some((env, project_config)) + } else { + None + }; + + // Run pre-remove hooks + if let Some((ref env, ref project_config)) = hook_ctx { + let ctx = env.context(yes); + let user_hooks = ctx.config.hooks(ctx.project_id().as_deref()); + run_hook_with_filter( + &ctx, + user_hooks.pre_remove.as_ref(), + project_config + .as_ref() + .and_then(|c| c.hooks.pre_remove.as_ref()), + HookType::PreRemove, + &[], + HookFailureStrategy::FailFast, + None, + None, + )?; + } + + // Forget the workspace in jj + workspace.remove_workspace(name)?; + + // Remove the directory + if ws_path.exists() { + std::fs::remove_dir_all(ws_path).map_err(|e| { + anyhow::anyhow!( + "Workspace forgotten but failed to remove {}: {}", + path_display, + e + ) + })?; + } else { + eprintln!( + "{}", + warning_message(cformat!( + "Workspace directory already removed: {path_display}" + )) + ); + } + let integration_note = match &integration { + Some(info) => { + let desc = info.reason.description(); + let target = info.target; + let symbol = info.reason.symbol(); + cformat!(" ({desc} {target}, {symbol})") + } + None => String::new(), + }; + eprintln!( + "{}", + success_message(cformat!( + "Removed workspace {name} @ {path_display}{integration_note}" + )) + ); + + // If removing current workspace, cd to default workspace + if removing_current { + let default_path = match workspace.default_workspace_path()? { + Some(p) => p, + None => workspace.root_path()?, + }; + output::change_directory(&default_path)?; + } + + // Run post-remove hooks (using pre-built context from before deletion) + if let Some((ref env, ref project_config)) = hook_ctx { + let ctx = env.context(yes); + let user_hooks = ctx.config.hooks(ctx.project_id().as_deref()); + run_hook_with_filter( + &ctx, + user_hooks.post_remove.as_ref(), + project_config + .as_ref() + .and_then(|c| c.hooks.post_remove.as_ref()), + HookType::PostRemove, + &[], + HookFailureStrategy::Warn, + None, + None, + )?; + + // Run post-switch hooks when removing the current workspace + // (we've changed directory to the default workspace) + if removing_current { + run_hook_with_filter( + &ctx, + user_hooks.post_switch.as_ref(), + project_config + .as_ref() + .and_then(|c| c.hooks.post_switch.as_ref()), + HookType::PostSwitch, + &[], + HookFailureStrategy::Warn, + None, + None, + )?; + } + } + + Ok(()) +} diff --git a/src/commands/handle_step_jj.rs b/src/commands/handle_step_jj.rs new file mode 100644 index 000000000..992dafe93 --- /dev/null +++ b/src/commands/handle_step_jj.rs @@ -0,0 +1,94 @@ +//! Step command handlers for jj repositories. +//! +//! jj equivalent of `step commit`. Squash and push are handled by unified +//! implementations via the [`Workspace`] trait. + +use anyhow::Context; +use color_print::cformat; +use worktrunk::config::UserConfig; +use worktrunk::styling::{eprintln, success_message}; +use worktrunk::workspace::{JjWorkspace, Workspace}; + +/// Handle `wt step commit` for jj repositories. +/// +/// jj auto-snapshots the working copy, so "commit" means describing the +/// current change and starting a new one: +/// +/// 1. Check if there are changes to commit (`jj diff`) +/// 2. Generate a commit message (LLM or fallback) +/// 3. `jj describe -m "{message}"` — set the description +/// 4. `jj new` — start a new change +pub fn step_commit_jj() -> anyhow::Result<()> { + let workspace = JjWorkspace::from_current_dir()?; + let cwd = std::env::current_dir()?; + + // Check if there are changes to commit + let (diff, diff_stat) = workspace.committable_diff_for_prompt(&cwd)?; + if diff.trim().is_empty() { + anyhow::bail!("Nothing to commit (working copy is empty)"); + } + + let config = UserConfig::load().context("Failed to load config")?; + let project_id = workspace.project_identifier().ok(); + let commit_config = config.commit_generation(project_id.as_deref()); + + let commit_message = + generate_jj_commit_message(&workspace, &cwd, &diff, &diff_stat, &commit_config)?; + + // Describe the current change and start a new one + workspace.commit(&commit_message, &cwd)?; + + // Show commit message first line (more useful than change ID) + let first_line = commit_message.lines().next().unwrap_or(&commit_message); + eprintln!( + "{}", + success_message(cformat!("Committed: {}", first_line)) + ); + + Ok(()) +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Generate a commit message for jj changes. +/// +/// Uses LLM if configured, otherwise falls back to the existing jj description +/// or a message based on changed files (shared fallback with git via `generate_commit_message`). +fn generate_jj_commit_message( + workspace: &JjWorkspace, + cwd: &std::path::Path, + diff: &str, + diff_stat: &str, + config: &worktrunk::config::CommitGenerationConfig, +) -> anyhow::Result { + // jj-specific: check existing description first (jj may already have one) + if !config.is_configured() { + let description = workspace.run_in_dir( + cwd, + &["log", "-r", "@", "--no-graph", "-T", "self.description()"], + )?; + let description = description.trim(); + + if !description.is_empty() { + return Ok(description.to_string()); + } + } + + // Shared path: build CommitInput and use unified generate_commit_message + let ws_name = workspace + .current_name(cwd)? + .unwrap_or_else(|| "default".to_string()); + let repo_name = workspace.project_identifier().ok(); + let repo_name_str = repo_name.as_deref().unwrap_or("repo"); + + let input = crate::llm::CommitInput { + diff, + diff_stat, + branch: &ws_name, + repo_name: repo_name_str, + recent_commits: None, + }; + crate::llm::generate_commit_message(&input, config) +} diff --git a/src/commands/handle_switch.rs b/src/commands/handle_switch.rs index 427e86431..2664efc2c 100644 --- a/src/commands/handle_switch.rs +++ b/src/commands/handle_switch.rs @@ -8,11 +8,12 @@ use worktrunk::HookType; use worktrunk::config::{UserConfig, expand_template}; use worktrunk::git::{GitError, Repository, SwitchSuggestionCtx}; use worktrunk::styling::{eprintln, info_message}; +use worktrunk::workspace::build_worktree_map; use super::command_approval::approve_hooks; use super::command_executor::{CommandContext, build_hook_context}; use super::worktree::{ - SwitchBranchInfo, SwitchPlan, SwitchResult, execute_switch, get_path_mismatch, plan_switch, + SwitchPlan, SwitchResult, execute_switch, plan_switch, resolve_path_mismatch, }; use crate::output::{ execute_user_command, handle_switch_output, is_shell_integration_active, @@ -38,7 +39,7 @@ pub struct SwitchOptions<'a> { /// Returns `true` if hooks are approved to run. /// Returns `false` if hooks should be skipped (`!verify` or user declined). pub(crate) fn approve_switch_hooks( - repo: &Repository, + workspace: &dyn worktrunk::workspace::Workspace, config: &UserConfig, plan: &SwitchPlan, yes: bool, @@ -48,7 +49,13 @@ pub(crate) fn approve_switch_hooks( return Ok(false); } - let ctx = CommandContext::new(repo, config, Some(plan.branch()), plan.worktree_path(), yes); + let ctx = CommandContext::new( + workspace, + config, + Some(plan.branch()), + plan.worktree_path(), + yes, + ); let approved = if plan.is_create() { approve_hooks( &ctx, @@ -100,7 +107,7 @@ pub(crate) fn switch_extra_vars(result: &SwitchResult) -> Vec<(&str, &str)> { /// Spawn post-switch (and post-start for creates) background hooks. pub(crate) fn spawn_switch_background_hooks( - repo: &Repository, + workspace: &dyn worktrunk::workspace::Workspace, config: &UserConfig, result: &SwitchResult, branch: &str, @@ -108,7 +115,7 @@ pub(crate) fn spawn_switch_background_hooks( extra_vars: &[(&str, &str)], hooks_display_path: Option<&Path>, ) -> anyhow::Result<()> { - let ctx = CommandContext::new(repo, config, Some(branch), result.path(), yes); + let ctx = CommandContext::new(workspace, config, Some(branch), result.path(), yes); let mut hooks = super::hooks::prepare_background_hooks( &ctx, @@ -133,6 +140,12 @@ pub fn handle_switch( config: &mut UserConfig, binary_name: &str, ) -> anyhow::Result<()> { + // Open workspace once, route by VCS type via downcast + let workspace = worktrunk::workspace::open_workspace()?; + let Some(repo) = workspace.as_any().downcast_ref::() else { + return super::handle_switch_jj::handle_switch_jj(opts, config, binary_name); + }; + let SwitchOptions { branch, create, @@ -145,8 +158,6 @@ pub fn handle_switch( verify, } = opts; - let repo = Repository::current().context("Failed to switch worktree")?; - // Build switch suggestion context for enriching error hints with --execute/trailing args. // Without this, errors like "branch already exists" would suggest `wt switch ` // instead of the full `wt switch --execute= -- `. @@ -159,27 +170,28 @@ pub fn handle_switch( }); // Validate FIRST (before approval) - fails fast if branch doesn't exist, etc. - let plan = plan_switch(&repo, branch, create, base, clobber, config).map_err(|err| { - match suggestion_ctx { - Some(ref ctx) => match err.downcast::() { - Ok(git_err) => GitError::WithSwitchSuggestion { - source: Box::new(git_err), - ctx: ctx.clone(), - } - .into(), - Err(err) => err, + let plan = + plan_switch(repo, branch, create, base, clobber, config).map_err( + |err| match suggestion_ctx { + Some(ref ctx) => match err.downcast::() { + Ok(git_err) => GitError::WithSwitchSuggestion { + source: Box::new(git_err), + ctx: ctx.clone(), + } + .into(), + Err(err) => err, + }, + None => err, }, - None => err, - } - })?; + )?; // "Approve at the Gate": collect and approve hooks upfront // This ensures approval happens once at the command entry point // If user declines, skip hooks but continue with worktree operation - let skip_hooks = !approve_switch_hooks(&repo, config, &plan, yes, verify)?; + let skip_hooks = !approve_switch_hooks(repo, config, &plan, yes, verify)?; // Execute the validated plan - let (result, branch_info) = execute_switch(&repo, plan, config, yes, skip_hooks)?; + let (result, branch_info) = execute_switch(repo, plan, config, yes, skip_hooks)?; // Early exit for benchmarking time-to-first-output if std::env::var_os("WORKTRUNK_FIRST_OUTPUT").is_some() { @@ -187,16 +199,7 @@ pub fn handle_switch( } // Compute path mismatch lazily (deferred from plan_switch for existing worktrees) - let branch_info = match &result { - SwitchResult::Existing { path } | SwitchResult::AlreadyAt(path) => { - let expected_path = get_path_mismatch(&repo, &branch_info.branch, path, config); - SwitchBranchInfo { - expected_path, - ..branch_info - } - } - _ => branch_info, - }; + let branch_info = resolve_path_mismatch(branch_info, &result, repo, config); // Show success message (temporal locality: immediately after worktree operation) // Returns path to display in hooks when user's shell won't be in the worktree @@ -226,7 +229,7 @@ pub fn handle_switch( // Batch hooks into a single message when both types are present if !skip_hooks { spawn_switch_background_hooks( - &repo, + repo, config, &result, &branch_info.branch, @@ -239,34 +242,50 @@ pub fn handle_switch( // Execute user command after post-start hooks have been spawned // Note: execute_args requires execute via clap's `requires` attribute if let Some(cmd) = execute { - // Build template context for expansion (includes base vars when creating) - let ctx = CommandContext::new(&repo, config, Some(&branch_info.branch), result.path(), yes); - let template_vars = build_hook_context(&ctx, &extra_vars); - let vars: HashMap<&str, &str> = template_vars - .iter() - .map(|(k, v)| (k.as_str(), v.as_str())) - .collect(); - - // Expand template variables in command (shell_escape: true for safety) - let expanded_cmd = expand_template(cmd, &vars, true, &repo, "--execute command")?; - - // Append any trailing args (after --) to the execute command - // Each arg is also expanded, then shell-escaped - let full_cmd = if execute_args.is_empty() { - expanded_cmd - } else { - let expanded_args: Result, _> = execute_args - .iter() - .map(|arg| expand_template(arg, &vars, false, &repo, "--execute argument")) - .collect(); - let escaped_args: Vec<_> = expanded_args? - .iter() - .map(|arg| shell_escape::escape(arg.into()).into_owned()) - .collect(); - format!("{} {}", expanded_cmd, escaped_args.join(" ")) - }; - execute_user_command(&full_cmd, hooks_display_path.as_deref())?; + let ctx = CommandContext::new(repo, config, Some(&branch_info.branch), result.path(), yes); + expand_and_execute_command( + &ctx, + cmd, + execute_args, + &extra_vars, + hooks_display_path.as_deref(), + )?; } Ok(()) } + +/// Expand `--execute` template with context variables and run the command. +/// +/// Shared between git and jj switch handlers. +pub(crate) fn expand_and_execute_command( + ctx: &CommandContext<'_>, + cmd: &str, + execute_args: &[String], + extra_vars: &[(&str, &str)], + hooks_display_path: Option<&Path>, +) -> anyhow::Result<()> { + let template_vars = build_hook_context(ctx, extra_vars); + let vars: HashMap<&str, &str> = template_vars + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + let worktree_map = build_worktree_map(ctx.workspace); + + let expanded_cmd = expand_template(cmd, &vars, true, &worktree_map, "--execute command")?; + + let full_cmd = if execute_args.is_empty() { + expanded_cmd + } else { + let expanded_args: Result, _> = execute_args + .iter() + .map(|arg| expand_template(arg, &vars, false, &worktree_map, "--execute argument")) + .collect(); + let escaped_args: Vec<_> = expanded_args? + .iter() + .map(|arg| shell_escape::escape(arg.into()).into_owned()) + .collect(); + format!("{} {}", expanded_cmd, escaped_args.join(" ")) + }; + execute_user_command(&full_cmd, hooks_display_path) +} diff --git a/src/commands/handle_switch_jj.rs b/src/commands/handle_switch_jj.rs new file mode 100644 index 000000000..f218130b5 --- /dev/null +++ b/src/commands/handle_switch_jj.rs @@ -0,0 +1,280 @@ +//! Switch command handler for jj repositories. +//! +//! Supports the same hook lifecycle as the git path: approval, post-create +//! (blocking), post-start/post-switch (background), `--execute`, switch-previous +//! tracking, and shell integration prompts. + +use std::path::PathBuf; + +use color_print::cformat; +use normalize_path::NormalizePath; +use worktrunk::HookType; +use worktrunk::config::{UserConfig, sanitize_branch_name}; +use worktrunk::path::format_path_for_display; +use worktrunk::styling::{eprintln, info_message, success_message}; +use worktrunk::workspace::{JjWorkspace, Workspace}; + +use super::command_approval::approve_hooks; +use super::command_executor::CommandContext; +use super::handle_switch::SwitchOptions; +use crate::output; +use crate::output::{is_shell_integration_active, prompt_shell_integration}; + +/// Handle `wt switch` for jj repositories. +pub fn handle_switch_jj( + opts: SwitchOptions<'_>, + config: &mut UserConfig, + binary_name: &str, +) -> anyhow::Result<()> { + let workspace = JjWorkspace::from_current_dir()?; + + // Resolve `wt switch -` to the previous workspace + let resolved_name; + let name = if opts.branch == "-" { + resolved_name = workspace + .switch_previous() + .ok_or_else(|| anyhow::anyhow!("No previous workspace to switch to"))?; + &resolved_name + } else { + opts.branch + }; + + // Check if workspace already exists + let existing_path = find_existing_workspace(&workspace, name)?; + + if let Some(path) = existing_path { + return handle_existing_switch(&workspace, name, &path, &opts, config, binary_name); + } + + // Workspace doesn't exist — need --create to make one + if !opts.create { + anyhow::bail!("Workspace '{}' not found. Use --create to create it.", name); + } + + handle_create_switch(&workspace, name, &opts, config, binary_name) +} + +/// Switch to an existing workspace. +fn handle_existing_switch( + workspace: &JjWorkspace, + name: &str, + path: &std::path::Path, + opts: &SwitchOptions<'_>, + config: &mut UserConfig, + binary_name: &str, +) -> anyhow::Result<()> { + // Approve post-switch hooks upfront + let skip_hooks = if opts.verify { + let ctx = CommandContext::new(workspace, config, Some(name), path, opts.yes); + let approved = approve_hooks(&ctx, &[HookType::PostSwitch])?; + if !approved { + eprintln!("{}", info_message("Commands declined")); + } + !approved + } else { + true + }; + + // Track switch-previous before switching + record_switch_previous(workspace); + + // Show success message + let path_display = format_path_for_display(path); + eprintln!( + "{}", + success_message(cformat!( + "Switched to workspace {name} @ {path_display}" + )) + ); + + if opts.change_dir { + output::change_directory(path)?; + } + + // Shell integration prompt + if !is_shell_integration_active() { + let skip_prompt = opts.execute.is_some(); + let _ = prompt_shell_integration(config, binary_name, skip_prompt); + } + + let hooks_display_path = output::post_hook_display_path(path).map(|p| p.to_path_buf()); + + // Background hooks (post-switch only for existing) + if !skip_hooks { + let ctx = CommandContext::new(workspace, config, Some(name), path, opts.yes); + let hooks = super::hooks::prepare_background_hooks( + &ctx, + HookType::PostSwitch, + &[], + hooks_display_path.as_deref(), + )?; + super::hooks::spawn_background_hooks(&ctx, hooks)?; + } + + // Execute user command (--execute) + if let Some(cmd) = opts.execute { + let ctx = CommandContext::new(workspace, config, Some(name), path, opts.yes); + super::handle_switch::expand_and_execute_command( + &ctx, + cmd, + opts.execute_args, + &[], + hooks_display_path.as_deref(), + )?; + } + + Ok(()) +} + +/// Create a new workspace and switch to it. +fn handle_create_switch( + workspace: &JjWorkspace, + name: &str, + opts: &SwitchOptions<'_>, + config: &mut UserConfig, + binary_name: &str, +) -> anyhow::Result<()> { + // Compute path for new workspace + let worktree_path = compute_jj_workspace_path(workspace, name)?; + + if worktree_path.exists() { + anyhow::bail!( + "Path already exists: {}", + format_path_for_display(&worktree_path) + ); + } + + // Approve hooks upfront (post-create, post-start, post-switch) + let skip_hooks = if opts.verify { + let ctx = CommandContext::new(workspace, config, Some(name), &worktree_path, opts.yes); + let approved = approve_hooks( + &ctx, + &[ + HookType::PostCreate, + HookType::PostStart, + HookType::PostSwitch, + ], + )?; + if !approved { + eprintln!( + "{}", + info_message("Commands declined, continuing workspace creation") + ); + } + !approved + } else { + true + }; + + // Track switch-previous before creating + record_switch_previous(workspace); + + // Create the workspace + workspace.create_workspace(name, opts.base, &worktree_path)?; + + // Run post-create hooks (blocking) before success message + let base_str = opts.base.unwrap_or_default().to_string(); + let extra_vars: Vec<(&str, &str)> = if opts.base.is_some() { + vec![("base", &base_str)] + } else { + Vec::new() + }; + + if !skip_hooks { + let ctx = CommandContext::new(workspace, config, Some(name), &worktree_path, opts.yes); + ctx.execute_post_create_commands(&extra_vars)?; + } + + // Show success message + let path_display = format_path_for_display(&worktree_path); + eprintln!( + "{}", + success_message(cformat!( + "Created workspace {name} @ {path_display}" + )) + ); + + if opts.change_dir { + output::change_directory(&worktree_path)?; + } + + // Shell integration prompt + if !is_shell_integration_active() { + let skip_prompt = opts.execute.is_some(); + let _ = prompt_shell_integration(config, binary_name, skip_prompt); + } + + let hooks_display_path = + output::post_hook_display_path(&worktree_path).map(|p| p.to_path_buf()); + + // Background hooks (post-switch + post-start for creates) + if !skip_hooks { + let ctx = CommandContext::new(workspace, config, Some(name), &worktree_path, opts.yes); + let mut hooks = super::hooks::prepare_background_hooks( + &ctx, + HookType::PostSwitch, + &extra_vars, + hooks_display_path.as_deref(), + )?; + hooks.extend(super::hooks::prepare_background_hooks( + &ctx, + HookType::PostStart, + &extra_vars, + hooks_display_path.as_deref(), + )?); + super::hooks::spawn_background_hooks(&ctx, hooks)?; + } + + // Execute user command (--execute) + if let Some(cmd) = opts.execute { + let ctx = CommandContext::new(workspace, config, Some(name), &worktree_path, opts.yes); + super::handle_switch::expand_and_execute_command( + &ctx, + cmd, + opts.execute_args, + &extra_vars, + hooks_display_path.as_deref(), + )?; + } + + Ok(()) +} + +/// Record the current workspace name as switch-previous. +fn record_switch_previous(workspace: &JjWorkspace) { + if let Ok(current_path) = workspace.current_workspace_path() + && let Ok(Some(current)) = workspace.current_name(¤t_path) + { + let _ = workspace.set_switch_previous(Some(¤t)); + } +} + +/// Find an existing workspace by name, returning its path if it exists. +fn find_existing_workspace(workspace: &JjWorkspace, name: &str) -> anyhow::Result> { + let workspaces = workspace.list_workspaces()?; + for ws in &workspaces { + if ws.name == name { + return Ok(Some(ws.path.clone())); + } + } + Ok(None) +} + +/// Compute the filesystem path for a new jj workspace. +/// +/// Uses the same sibling-directory convention as git worktrees: +/// `{repo_root}/../{repo_name}.{workspace_name}` +fn compute_jj_workspace_path(workspace: &JjWorkspace, name: &str) -> anyhow::Result { + let root = workspace.root(); + let repo_name = root + .file_name() + .ok_or_else(|| anyhow::anyhow!("Repository path has no filename"))? + .to_str() + .ok_or_else(|| anyhow::anyhow!("Repository path contains invalid UTF-8"))?; + + let sanitized = sanitize_branch_name(name); + let path = root + .join(format!("../{}.{}", repo_name, sanitized)) + .normalize(); + Ok(path) +} diff --git a/src/commands/hook_commands.rs b/src/commands/hook_commands.rs index 3239bf14e..5b9f6cd0a 100644 --- a/src/commands/hook_commands.rs +++ b/src/commands/hook_commands.rs @@ -14,12 +14,13 @@ use color_print::cformat; use strum::IntoEnumIterator; use worktrunk::HookType; use worktrunk::config::{Approvals, CommandConfig, ProjectConfig, UserConfig}; -use worktrunk::git::{GitError, Repository}; +use worktrunk::git::GitError; use worktrunk::path::format_path_for_display; use worktrunk::styling::{ INFO_SYMBOL, PROMPT_SYMBOL, eprintln, format_bash_with_gutter, format_heading, hint_message, info_message, success_message, }; +use worktrunk::workspace::{Workspace, build_worktree_map, open_workspace}; use super::command_approval::approve_hooks_filtered; use super::command_executor::build_hook_context; @@ -56,11 +57,10 @@ pub fn run_hook( ) -> anyhow::Result<()> { // Derive context from current environment (branch-optional for CI compatibility) let env = CommandEnv::for_action_branchless()?; - let repo = &env.repo; let ctx = env.context(yes); // Load project config (optional - user hooks can run without project config) - let project_config = repo.load_project_config()?; + let project_config = ctx.workspace.load_project_config()?; // "Approve at the Gate": approve project hooks upfront // Pass name_filter to only approve the targeted hook, not all hooks of this type @@ -201,7 +201,7 @@ pub fn run_hook( require_hooks(user_config, project_config, hook_type)?; // Pre-commit hook can optionally use target branch context // Custom vars take precedence (added last) - let target_branch = repo.default_branch(); + let target_branch = ctx.workspace.default_branch_name(); let mut extra_vars: Vec<(&str, &str)> = target_branch .as_deref() .into_iter() @@ -324,17 +324,13 @@ pub fn run_hook( pub fn add_approvals(show_all: bool) -> anyhow::Result<()> { use super::command_approval::approve_command_batch; - let repo = Repository::current()?; - let project_id = repo.project_identifier()?; + let workspace = open_workspace()?; + let project_id = workspace.project_identifier()?; let approvals = Approvals::load().context("Failed to load approvals")?; // Load project config (error if missing - this command requires it) - let config_path = repo - .current_worktree() - .root()? - .join(".config") - .join("wt.toml"); - let project_config = repo + let config_path = workspace.root_path()?.join(".config").join("wt.toml"); + let project_config = workspace .load_project_config()? .ok_or(GitError::ProjectConfigNotFound { config_path })?; @@ -410,8 +406,8 @@ pub fn clear_approvals(global: bool) -> anyhow::Result<()> { ); } else { // Clear approvals for current project (default) - let repo = Repository::current()?; - let project_id = repo.project_identifier()?; + let workspace = open_workspace()?; + let project_id = workspace.project_identifier()?; // Count approvals before clearing let approval_count = approvals @@ -445,11 +441,11 @@ pub fn clear_approvals(global: bool) -> anyhow::Result<()> { pub fn handle_hook_show(hook_type_filter: Option<&str>, expanded: bool) -> anyhow::Result<()> { use crate::help_pager::show_help_in_pager; - let repo = Repository::current()?; + let workspace = open_workspace()?; let config = UserConfig::load().context("Failed to load user config")?; let approvals = Approvals::load().context("Failed to load approvals")?; - let project_config = repo.load_project_config()?; - let project_id = repo.project_identifier().ok(); + let project_config = workspace.load_project_config()?; + let project_id = workspace.project_identifier().ok(); // Parse hook type filter if provided let filter: Option = hook_type_filter.map(|s| match s { @@ -483,7 +479,7 @@ pub fn handle_hook_show(hook_type_filter: Option<&str>, expanded: bool) -> anyho // Render project hooks render_project_hooks( &mut output, - &repo, + &*workspace, project_config.as_ref(), &approvals, project_id.as_deref(), @@ -561,15 +557,15 @@ fn render_user_hooks( /// Render project hooks section fn render_project_hooks( out: &mut String, - repo: &Repository, + workspace: &dyn Workspace, project_config: Option<&ProjectConfig>, approvals: &Approvals, project_id: Option<&str>, filter: Option, ctx: Option<&CommandContext>, ) -> anyhow::Result<()> { - let repo_root = repo.current_worktree().root()?; - let config_path = repo_root.join(".config").join("wt.toml"); + let root = workspace.root_path()?; + let config_path = root.join(".config").join("wt.toml"); writeln!( out, @@ -673,7 +669,7 @@ fn render_hook_commands( /// Expand a command template with context variables fn expand_command_template(template: &str, ctx: &CommandContext, hook_type: HookType) -> String { // Build extra vars based on hook type (same logic as run_hook approval) - let default_branch = ctx.repo.default_branch(); + let default_branch = ctx.workspace.default_branch_name(); let extra_vars: Vec<(&str, &str)> = match hook_type { HookType::PreCommit => { // Pre-commit uses default branch as target (for comparison context) @@ -694,9 +690,10 @@ fn expand_command_template(template: &str, ctx: &CommandContext, hook_type: Hook .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); + let worktree_map = build_worktree_map(ctx.workspace); // Use the standard template expansion (shell-escaped) // On any error, show both the template and error message - worktrunk::config::expand_template(template, &vars, true, ctx.repo, "hook preview") + worktrunk::config::expand_template(template, &vars, true, &worktree_map, "hook preview") .unwrap_or_else(|err| format!("# {}\n{}", err.message, template)) } diff --git a/src/commands/hooks.rs b/src/commands/hooks.rs index ef7811163..f58c91e6f 100644 --- a/src/commands/hooks.rs +++ b/src/commands/hooks.rs @@ -222,7 +222,7 @@ pub fn spawn_background_hooks( let hook_log = HookLog::hook(cmd.source, cmd.hook_type, &name); if let Err(err) = spawn_detached( - ctx.repo, + &ctx.workspace.wt_logs_dir(), ctx.worktree_path, &cmd.prepared.expanded, ctx.branch_or_head(), @@ -424,7 +424,7 @@ pub fn execute_hook( name_filter: Option<&str>, display_path: Option<&Path>, ) -> anyhow::Result<()> { - let project_config = ctx.repo.load_project_config()?; + let project_config = ctx.workspace.load_project_config()?; let user_hooks = ctx.config.hooks(ctx.project_id().as_deref()); let (user_config, proj_config) = lookup_hook_configs(&user_hooks, project_config.as_ref(), hook_type); @@ -457,7 +457,7 @@ pub(crate) fn prepare_background_hooks( extra_vars: &[(&str, &str)], display_path: Option<&Path>, ) -> anyhow::Result> { - let project_config = ctx.repo.load_project_config()?; + let project_config = ctx.workspace.load_project_config()?; let user_hooks = ctx.config.hooks(ctx.project_id().as_deref()); let (user_config, proj_config) = lookup_hook_configs(&user_hooks, project_config.as_ref(), hook_type); diff --git a/src/commands/list/collect/execution.rs b/src/commands/list/collect/execution.rs index e7b176077..2bcff1f36 100644 --- a/src/commands/list/collect/execution.rs +++ b/src/commands/list/collect/execution.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use crossbeam_channel as chan; use worktrunk::git::{BranchRef, Repository, WorktreeInfo}; +use worktrunk::workspace::build_worktree_map; use super::CollectOptions; use super::tasks::{ @@ -168,8 +169,15 @@ pub fn work_items_for_worktree( wt.branch.as_ref().and_then(|branch| { let mut vars = std::collections::HashMap::new(); vars.insert("branch", branch.as_str()); - worktrunk::config::expand_template(template, &vars, false, repo, "url-template") - .ok() + let worktree_map = build_worktree_map(repo); + worktrunk::config::expand_template( + template, + &vars, + false, + &worktree_map, + "url-template", + ) + .ok() }) }) } else { diff --git a/src/commands/list/collect/mod.rs b/src/commands/list/collect/mod.rs index cb4993305..707723b6f 100644 --- a/src/commands/list/collect/mod.rs +++ b/src/commands/list/collect/mod.rs @@ -112,6 +112,7 @@ use worktrunk::git::{Repository, WorktreeInfo}; use worktrunk::styling::{ INFO_SYMBOL, eprintln, format_with_gutter, hint_message, warning_message, }; +use worktrunk::workspace::Workspace; use crate::commands::is_worktree_at_expected_path; diff --git a/src/commands/list/collect_jj.rs b/src/commands/list/collect_jj.rs new file mode 100644 index 000000000..075b8e014 --- /dev/null +++ b/src/commands/list/collect_jj.rs @@ -0,0 +1,146 @@ +//! Jujutsu workspace data collection for `wt list`. +//! +//! Simpler than the git collection path: sequential execution, no progressive +//! rendering. jj repos typically have few workspaces (1-5), so parallelism +//! isn't needed for acceptable latency. + +use worktrunk::workspace::{JjWorkspace, Workspace, WorkspaceItem}; + +use super::model::item::{DisplayFields, ItemKind, ListData, ListItem, WorktreeData}; +use super::model::stats::{AheadBehind, CommitDetails}; + +/// Collect workspace data from a jj repository. +/// +/// Returns `ListData` compatible with the existing rendering pipeline. +/// Fields not applicable to jj (upstream, CI, branch diff) are left as None. +pub fn collect_jj(workspace: &JjWorkspace) -> anyhow::Result { + let workspaces = workspace.list_workspaces()?; + + // Determine current workspace by matching cwd + let cwd = dunce::canonicalize(std::env::current_dir()?)?; + + let mut items: Vec = Vec::with_capacity(workspaces.len()); + + for ws in &workspaces { + let is_current = dunce::canonicalize(&ws.path) + .map(|p| cwd.starts_with(&p)) + .unwrap_or(false); + + let mut item = build_jj_item(ws, is_current); + + // Collect status data (sequential — few workspaces expected) + populate_jj_item(workspace, ws, &mut item); + + // Compute status symbols with available data + item.compute_status_symbols(None, false, None, None, false); + item.finalize_display(); + + items.push(item); + } + + // Sort: current first, then default, then by name + items.sort_by(|a, b| { + let a_current = a.worktree_data().is_some_and(|d| d.is_current); + let b_current = b.worktree_data().is_some_and(|d| d.is_current); + let a_main = a.worktree_data().is_some_and(|d| d.is_main); + let b_main = b.worktree_data().is_some_and(|d| d.is_main); + + b_current + .cmp(&a_current) + .then(b_main.cmp(&a_main)) + .then(a.branch_name().cmp(b.branch_name())) + }); + + let main_path = workspace + .default_workspace_path()? + .unwrap_or_else(|| workspace.root().to_path_buf()); + + Ok(ListData { + items, + main_worktree_path: main_path, + }) +} + +/// Build a minimal ListItem for a jj workspace. +fn build_jj_item(ws: &WorkspaceItem, is_current: bool) -> ListItem { + ListItem { + head: ws.head.clone(), + // Use workspace name as the "branch" for display purposes + branch: Some(ws.name.clone()), + commit: None, + counts: None, + branch_diff: None, + committed_trees_match: None, + has_file_changes: None, + would_merge_add: None, + is_ancestor: None, + is_orphan: None, + upstream: None, + pr_status: None, + url: None, + url_active: None, + status_symbols: None, + display: DisplayFields::default(), + kind: ItemKind::Worktree(Box::new(WorktreeData { + path: ws.path.clone(), + detached: false, + locked: ws.locked.clone(), + prunable: ws.prunable.clone(), + is_main: ws.is_default, + is_current, + is_previous: false, + ..Default::default() + })), + } +} + +/// Populate computed fields for a jj workspace item. +/// +/// Collects working tree diff, ahead/behind counts, and integration status. +/// Errors are logged but don't fail the overall collection. +fn populate_jj_item(workspace: &JjWorkspace, ws: &WorkspaceItem, item: &mut ListItem) { + // Skip status collection for the default workspace if it's the trunk target + let is_main = ws.is_default; + + // Working tree diff + match workspace.working_diff(&ws.path) { + Ok(diff) => { + if let ItemKind::Worktree(ref mut data) = item.kind { + data.working_tree_diff = Some(diff); + } + } + Err(e) => log::debug!("Failed to get working diff for {}: {}", ws.name, e), + } + + // Ahead/behind vs trunk (skip for default workspace) + if !is_main { + // Use trunk() revset as base, workspace change ID as head + match workspace.ahead_behind("trunk()", &ws.head) { + Ok((ahead, behind)) => { + item.counts = Some(AheadBehind { ahead, behind }); + } + Err(e) => log::debug!("Failed to get ahead/behind for {}: {}", ws.name, e), + } + + // Integration check + match workspace.is_integrated(&ws.head, "trunk()") { + Ok(reason) => { + if reason.is_some() { + item.is_ancestor = Some(true); + } + } + Err(e) => log::debug!("Failed integration check for {}: {}", ws.name, e), + } + } + + // Commit details (timestamp + description) + match workspace.commit_details(&ws.path) { + Ok((timestamp, commit_message)) => { + item.commit = Some(CommitDetails { + timestamp, + commit_message, + }); + } + Err(e) => log::debug!("Failed to get commit details for {}: {}", ws.name, e), + } +} diff --git a/src/commands/list/mod.rs b/src/commands/list/mod.rs index 67d2aceaf..0930e34d1 100644 --- a/src/commands/list/mod.rs +++ b/src/commands/list/mod.rs @@ -120,6 +120,7 @@ pub mod ci_status; pub(crate) mod collect; +pub(crate) mod collect_jj; pub(crate) mod columns; pub mod json_output; pub(crate) mod layout; @@ -132,18 +133,95 @@ pub(crate) mod render; mod spacing_test; // Layout is calculated in collect.rs +use std::collections::HashSet; + use anstyle::Style; use anyhow::Context; +use collect::TaskKind; use model::{ListData, ListItem}; use progressive::RenderMode; use worktrunk::git::Repository; use worktrunk::styling::INFO_SYMBOL; +use worktrunk::workspace::JjWorkspace; // Re-export for statusline and other consumers pub use collect::{CollectOptions, build_worktree_item, populate_item}; pub use model::StatuslineSegment; +/// Handle `wt list` command. +/// +/// `branches`, `remotes`, `full` are raw CLI flags (false when not passed). +/// Config resolution (project-specific merged with global) happens internally +/// using the workspace's project identifier. pub fn handle_list( + format: crate::OutputFormat, + branches: bool, + remotes: bool, + full: bool, + render_mode: RenderMode, +) -> anyhow::Result<()> { + // Open workspace once, route by VCS type via downcast + let workspace = worktrunk::workspace::open_workspace()?; + if workspace.as_any().downcast_ref::().is_none() { + return handle_list_jj(format); + } + + let repo = workspace + .as_any() + .downcast_ref::() + .expect("already verified git workspace") + .clone(); + + handle_list_git(repo, format, branches, remotes, full, render_mode) +} + +/// Handle `wt list` for jj repositories. +fn handle_list_jj(format: crate::OutputFormat) -> anyhow::Result<()> { + let workspace = JjWorkspace::from_current_dir()?; + let list_data = collect_jj::collect_jj(&workspace)?; + let items = &list_data.items; + + match format { + crate::OutputFormat::Json => { + let json_items = json_output::to_json_items(items); + let json = + serde_json::to_string_pretty(&json_items).context("Failed to serialize to JSON")?; + println!("{}", json); + } + crate::OutputFormat::Table | crate::OutputFormat::ClaudeCode => { + // For jj: render table in buffered mode (no progressive rendering) + let skip_tasks: HashSet = [ + TaskKind::BranchDiff, + TaskKind::CiStatus, + TaskKind::WorkingTreeConflicts, + TaskKind::Upstream, + TaskKind::MergeTreeConflicts, + ] + .into_iter() + .collect(); + + let layout = layout::calculate_layout_from_basics( + items, + &skip_tasks, + &list_data.main_worktree_path, + None, + ); + + println!("{}", layout.format_header_line()); + for item in items { + println!("{}", layout.format_list_item_line(item)); + } + + let summary = format_summary_message(items, false, layout.hidden_column_count, 0, 0); + println!("{}", summary); + } + } + + Ok(()) +} + +/// Handle `wt list` for git repositories. +fn handle_list_git( repo: Repository, format: crate::OutputFormat, cli_branches: bool, diff --git a/src/commands/merge.rs b/src/commands/merge.rs index 417f283fa..7e3377d55 100644 --- a/src/commands/merge.rs +++ b/src/commands/merge.rs @@ -1,19 +1,17 @@ use anyhow::Context; +use color_print::cformat; use worktrunk::HookType; -use worktrunk::config::{Approvals, UserConfig}; -use worktrunk::git::Repository; -use worktrunk::styling::{eprintln, info_message}; +use worktrunk::config::UserConfig; +use worktrunk::git::{GitError, Repository}; +use worktrunk::styling::{eprintln, info_message, success_message}; +use worktrunk::workspace::LocalPushDisplay; -use super::command_approval::approve_command_batch; +use super::command_approval::approve_hooks; use super::command_executor::CommandContext; use super::commit::CommitOptions; use super::context::CommandEnv; use super::hooks::{HookFailureStrategy, execute_hook}; -use super::project_config::{HookCommand, collect_commands_for_hooks}; -use super::repository_ext::RepositoryCliExt; -use super::worktree::{ - BranchDeletionMode, MergeOperations, RemoveResult, get_path_mismatch, handle_push, -}; +use super::worktree::{BranchDeletionMode, RemoveResult, get_path_mismatch}; /// Options for the merge command /// @@ -36,47 +34,13 @@ pub struct MergeOptions<'a> { pub stage: Option, } -/// Collect all commands that will be executed during merge. -/// -/// Returns (commands, project_identifier) for batch approval. -fn collect_merge_commands( - repo: &Repository, - commit: bool, - verify: bool, - will_remove: bool, - squash_enabled: bool, -) -> anyhow::Result<(Vec, String)> { - let mut all_commands = Vec::new(); - let project_config = match repo.load_project_config()? { - Some(cfg) => cfg, - None => return Ok((all_commands, repo.project_identifier()?)), - }; - - let mut hooks = Vec::new(); - - // Pre-commit hooks run when a commit will actually be created - let will_create_commit = repo.current_worktree().is_dirty()? || squash_enabled; - if commit && verify && will_create_commit { - hooks.push(HookType::PreCommit); - } - - if verify { - hooks.push(HookType::PreMerge); - hooks.push(HookType::PostMerge); - if will_remove { - hooks.push(HookType::PreRemove); - hooks.push(HookType::PostRemove); - hooks.push(HookType::PostSwitch); - } +pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> { + // Open workspace once, route by VCS type via downcast + let workspace = worktrunk::workspace::open_workspace()?; + if workspace.as_any().downcast_ref::().is_none() { + return super::handle_merge_jj::handle_merge_jj(opts); } - all_commands.extend(collect_commands_for_hooks(&project_config, &hooks)); - - let project_id = repo.project_identifier()?; - Ok((all_commands, project_id)) -} - -pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> { let MergeOptions { target, squash: squash_opt, @@ -95,8 +59,8 @@ pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> { let _ = crate::output::prompt_commit_generation(&mut config); } - let env = CommandEnv::for_action("merge", config)?; - let repo = &env.repo; + let env = CommandEnv::with_workspace(workspace, "merge", config)?; + let repo = env.require_repo()?; let config = &env.config; // Merge requires being on a branch (can't merge from detached HEAD) let current_branch = env.require_branch("merge")?.to_string(); @@ -138,21 +102,37 @@ pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> { let on_target = current_branch == target_branch; let remove_effective = remove && !on_target && !in_main; - // Collect and approve all commands upfront for batch permission request - let (all_commands, project_id) = - collect_merge_commands(repo, commit, verify, remove_effective, squash_enabled)?; + // "Approve at the Gate": approve all hooks upfront + let verify = if verify { + let ctx = env.context(yes); - // Approve all commands in a single batch (shows templates, not expanded values) - let approvals = Approvals::load().context("Failed to load approvals")?; - let approved = approve_command_batch(&all_commands, &project_id, &approvals, yes, false)?; + let mut hook_types = Vec::new(); - // If commands were declined, skip hooks but continue with merge - // Shadow verify to gate all subsequent hook execution on approval - let verify = if !approved { - eprintln!("{}", info_message("Commands declined, continuing merge")); - false + // Pre-commit hooks run when a commit will actually be created + let will_create_commit = repo.current_worktree().is_dirty()? || squash_enabled; + if commit && will_create_commit { + hook_types.push(HookType::PreCommit); + } + + hook_types.push(HookType::PreMerge); + hook_types.push(HookType::PostMerge); + if remove_effective { + hook_types.extend_from_slice(&[ + HookType::PreRemove, + HookType::PostRemove, + HookType::PostSwitch, + ]); + } + + let approved = approve_hooks(&ctx, &hook_types)?; + if !approved { + eprintln!("{}", info_message("Commands declined, continuing merge")); + false + } else { + true + } } else { - verify + false }; // Handle uncommitted changes (skip if --no-commit) - track whether commit occurred @@ -165,7 +145,6 @@ pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> { options.target_branch = Some(&target_branch); options.no_verify = !verify; options.stage_mode = stage_mode; - options.warn_about_untracked = stage_mode == super::commit::StageMode::All; options.show_no_squash_note = true; options.commit()?; @@ -219,16 +198,95 @@ pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> { )?; } - // Fast-forward push to target branch with commit/squash/rebase info for consolidated message - handle_push( - Some(&target_branch), - "Merged to", - Some(MergeOperations { - committed, - squashed, - rebased, - }), - )?; + // Build operations note for the progress line (e.g., " (no commit/squash needed)") + let operations_note = { + let mut skipped_ops = Vec::new(); + if !committed && !squashed { + skipped_ops.push("commit/squash"); + } + if !rebased { + skipped_ops.push("rebase"); + } + if skipped_ops.is_empty() { + String::new() + } else { + format!(" (no {} needed)", skipped_ops.join("/")) + } + }; + + // Local push: advance target branch ref to include feature commits + let push_result = env + .workspace + .local_push( + &target_branch, + &env.worktree_path, + LocalPushDisplay { + verb: "Merging", + notes: &operations_note, + }, + ) + .map_err(|e| { + // Rewrite NotFastForward hint for merge context + if let Some(GitError::NotFastForward { + target_branch, + commits_formatted, + .. + }) = e.downcast_ref::() + { + return GitError::NotFastForward { + target_branch: target_branch.clone(), + commits_formatted: commits_formatted.clone(), + in_merge_context: true, + } + .into(); + } + e + })?; + + // Format merge-specific success/info message + if push_result.commit_count > 0 { + let mut summary_parts = vec![format!( + "{} commit{}", + push_result.commit_count, + if push_result.commit_count == 1 { + "" + } else { + "s" + } + )]; + summary_parts.extend(push_result.stats_summary); + + let stats_str = summary_parts.join(", "); + let paren_close = cformat!(")"); + eprintln!( + "{}", + success_message(cformat!( + "Merged to {target_branch} ({stats_str}{}", + paren_close + )) + ); + } else { + // Explain why nothing was pushed + let mut notes = Vec::new(); + if !committed && !squashed { + notes.push("no new commits"); + } + if !rebased { + notes.push("no rebase needed"); + } + let context = if notes.is_empty() { + String::new() + } else { + format!(" ({})", notes.join(", ")) + }; + + eprintln!( + "{}", + info_message(cformat!( + "Already up to date with {target_branch}{context}" + )) + ); + } // Destination: prefer the target branch's worktree; fall back to home path. let destination_path = match target_worktree_path { @@ -268,7 +326,7 @@ pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> { removed_commit, }; // Run hooks during merge removal (pass through verify flag) - // Approval was handled at the gate (collect_merge_commands) + // Approval was handled at the gate (approve_hooks) crate::output::handle_remove_output(&remove_result, true, verify)?; } else { // Worktree preserved - show reason (priority: main worktree > on target > --no-remove flag) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a814e485f..24fb24aa3 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -6,7 +6,11 @@ pub(crate) mod config; pub(crate) mod configure_shell; pub(crate) mod context; mod for_each; +mod handle_merge_jj; +pub(crate) mod handle_remove_jj; +pub(crate) mod handle_step_jj; mod handle_switch; +mod handle_switch_jj; mod hook_commands; mod hook_filter; pub(crate) mod hooks; @@ -16,6 +20,7 @@ pub(crate) mod merge; pub(crate) mod process; pub(crate) mod project_config; mod relocate; +mod remove_command; pub(crate) mod repository_ext; #[cfg(unix)] pub(crate) mod select; @@ -37,22 +42,36 @@ pub(crate) use hook_commands::{add_approvals, clear_approvals, handle_hook_show, pub(crate) use init::{handle_completions, handle_init}; pub(crate) use list::handle_list; pub(crate) use merge::{MergeOptions, handle_merge}; +pub(crate) use remove_command::{RemoveOptions, handle_remove_command}; #[cfg(unix)] pub(crate) use select::handle_select; pub(crate) use step_commands::{ RebaseResult, SquashResult, handle_rebase, handle_squash, step_commit, step_copy_ignored, - step_relocate, step_show_squash_prompt, -}; -pub(crate) use worktree::{ - OperationMode, handle_remove, handle_remove_current, is_worktree_at_expected_path, - resolve_worktree_arg, worktree_display_name, + step_push, step_relocate, step_show_squash_prompt, }; +pub(crate) use worktree::{is_worktree_at_expected_path, worktree_display_name}; // Re-export Shell from the canonical location pub(crate) use worktrunk::shell::Shell; use color_print::cformat; -use worktrunk::styling::{eprintln, format_with_gutter}; +use worktrunk::git::Repository; +use worktrunk::workspace::Workspace; + +/// Downcast a workspace to `Repository`, or error for jj repositories. +/// +/// Replaces the old `require_git()` + `Repository::current()` two-step pattern. +/// Returns a reference to the `Repository` if this is a git workspace, +/// or a clear error for jj users. +pub(crate) fn require_git_workspace<'a>( + workspace: &'a dyn Workspace, + command: &str, +) -> anyhow::Result<&'a Repository> { + workspace + .as_any() + .downcast_ref::() + .ok_or_else(|| anyhow::anyhow!("`wt {command}` is not yet supported for jj repositories")) +} /// Format command execution label with optional command name. /// @@ -66,35 +85,6 @@ pub(crate) fn format_command_label(command_type: &str, name: Option<&str>) -> St } } -/// Show detailed diffstat for a given commit range. -/// -/// Displays the diff statistics (file changes, insertions, deletions) in a gutter format. -/// Used after commit/squash to show what was included in the commit. -/// -/// # Arguments -/// * `repo` - The repository to query -/// * `range` - The commit range to diff (e.g., "HEAD~1..HEAD" or "main..HEAD") -pub(crate) fn show_diffstat(repo: &worktrunk::git::Repository, range: &str) -> anyhow::Result<()> { - let term_width = crate::display::get_terminal_width(); - let stat_width = term_width.saturating_sub(worktrunk::styling::GUTTER_OVERHEAD); - let diff_stat = repo - .run_command(&[ - "diff", - "--color=always", - "--stat", - &format!("--stat-width={}", stat_width), - range, - ])? - .trim_end() - .to_string(); - - if !diff_stat.is_empty() { - eprintln!("{}", format_with_gutter(&diff_stat, None)); - } - - Ok(()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/commands/process.rs b/src/commands/process.rs index bcdd4a6f5..53f8f8487 100644 --- a/src/commands/process.rs +++ b/src/commands/process.rs @@ -7,7 +7,7 @@ use std::process::Command; use std::process::Stdio; use std::str::FromStr; use strum::IntoEnumIterator; -use worktrunk::git::{HookType, Repository}; +use worktrunk::git::HookType; use worktrunk::path::{format_path_for_display, sanitize_for_filename}; use crate::commands::hook_filter::HookSource; @@ -187,7 +187,7 @@ fn posix_command_separator(command: &str) -> &'static str { /// Logs are centralized in the main worktree's `.git/wt-logs/` directory. /// /// # Arguments -/// * `repo` - Repository instance for accessing git common directory +/// * `log_dir` - Directory for background hook log files /// * `worktree_path` - Working directory for the command /// * `command` - Shell command to execute /// * `branch` - Branch name for log organization @@ -197,24 +197,23 @@ fn posix_command_separator(command: &str) -> &'static str { /// # Returns /// Path to the log file where output is being written pub fn spawn_detached( - repo: &Repository, + log_dir: &Path, worktree_path: &Path, command: &str, branch: &str, hook_log: &HookLog, context_json: Option<&str>, ) -> anyhow::Result { - // Create log directory in the common git directory - let log_dir = repo.wt_logs_dir(); - fs::create_dir_all(&log_dir).with_context(|| { + // Create log directory + fs::create_dir_all(log_dir).with_context(|| { format!( "Failed to create log directory {}", - format_path_for_display(&log_dir) + format_path_for_display(log_dir) ) })?; // Generate log path using the HookLog specification - let log_path = hook_log.path(&log_dir, branch); + let log_path = hook_log.path(log_dir, branch); // Create log file let log_file = fs::File::create(&log_path).with_context(|| { diff --git a/src/commands/relocate.rs b/src/commands/relocate.rs index 6bf2ac788..463ae3a2b 100644 --- a/src/commands/relocate.rs +++ b/src/commands/relocate.rs @@ -156,13 +156,9 @@ pub fn gather_candidates( } Err(e) => { // Template expansion failed - warn user so they can fix config - eprintln!( - "{}", - warning_message(cformat!( - "Skipping {branch} due to template error:" - )) - ); - eprintln!("{}", e); + let header = cformat!("Skipping {branch} due to template error:"); + let detail = format_with_gutter(&e.to_string(), None); + eprintln!("{}\n{}", warning_message(header), detail); template_errors += 1; } } diff --git a/src/commands/remove_command.rs b/src/commands/remove_command.rs new file mode 100644 index 000000000..a10f9744f --- /dev/null +++ b/src/commands/remove_command.rs @@ -0,0 +1,257 @@ +//! Remove command handler. +//! +//! Orchestrates worktree removal for both git and jj repositories. +//! Handles single and multi-worktree removal, hook approval, and cleanup. + +use std::collections::HashSet; + +use anyhow::Context; +use worktrunk::HookType; +use worktrunk::config::UserConfig; +use worktrunk::git::{Repository, ResolvedWorktree}; +use worktrunk::styling::{eprintln, info_message, warning_message}; + +use super::command_approval::approve_hooks; +use super::context::CommandEnv; +use super::worktree::{ + OperationMode, RemoveResult, handle_remove, handle_remove_current, resolve_worktree_arg, +}; +use crate::output::handle_remove_output; + +/// Options for the remove command. +pub struct RemoveOptions { + pub branches: Vec, + pub delete_branch: bool, + pub force_delete: bool, + pub foreground: bool, + pub no_background: bool, + pub verify: bool, + pub yes: bool, + pub force: bool, +} + +/// Handle the `wt remove` command. +/// +/// Orchestrates worktree removal: VCS detection, validation, hook approval, +/// and execution. Supports both single and multi-worktree removal. +pub fn handle_remove_command(opts: RemoveOptions) -> anyhow::Result<()> { + let RemoveOptions { + branches, + delete_branch, + force_delete, + foreground, + no_background, + verify, + yes, + force, + } = opts; + + let config = UserConfig::load().context("Failed to load config")?; + + // Open workspace once, route by VCS type via downcast + let workspace = worktrunk::workspace::open_workspace()?; + let Some(repo) = workspace.as_any().downcast_ref::() else { + // JJ path: resolve targets, approve hooks, remove each workspace + let cwd = std::env::current_dir()?; + let targets = if branches.is_empty() { + let name = workspace + .current_name(&cwd)? + .ok_or_else(|| anyhow::anyhow!("Not inside a jj workspace"))?; + vec![name] + } else { + branches + }; + + let run_hooks = approve_remove_hooks(verify, yes)?; + + for name in &targets { + let ws_path = workspace.workspace_path(name)?; + super::handle_remove_jj::remove_jj_workspace_and_cd( + &*workspace, + name, + &ws_path, + run_hooks, + yes, + None, + )?; + } + return Ok(()); + }; + + // Handle deprecated --no-background flag + if no_background { + eprintln!( + "{}", + warning_message("--no-background is deprecated; use --foreground instead") + ); + } + let background = !(foreground || no_background); + + // Validate conflicting flags + if !delete_branch && force_delete { + return Err(worktrunk::git::GitError::Other { + message: "Cannot use --force-delete with --no-delete-branch".into(), + } + .into()); + } + + if branches.is_empty() { + // Single worktree removal: validate FIRST, then approve, then execute + let result = handle_remove_current(!delete_branch, force_delete, force, &config) + .context("Failed to remove worktree")?; + + // Early exit for benchmarking time-to-first-output + if std::env::var_os("WORKTRUNK_FIRST_OUTPUT").is_some() { + return Ok(()); + } + + // "Approve at the Gate": approval happens AFTER validation passes + let run_hooks = approve_remove_hooks(verify, yes)?; + + handle_remove_output(&result, background, run_hooks) + } else { + // Multi-worktree removal: validate ALL first, then approve, then execute + // This supports partial success - some may fail validation while others succeed. + let current_worktree = repo + .current_worktree() + .root() + .ok() + .and_then(|p| dunce::canonicalize(&p).ok()); + + // Dedupe inputs to avoid redundant planning/execution + let branches: Vec<_> = { + let mut seen = HashSet::new(); + branches + .into_iter() + .filter(|b| seen.insert(b.clone())) + .collect() + }; + + // Phase 1: Validate all targets (resolution + preparation) + // Store successful plans for execution after approval + let mut plans_others: Vec = Vec::new(); + let mut plans_branch_only: Vec = Vec::new(); + let mut plan_current: Option = None; + let mut all_errors: Vec = Vec::new(); + + // Helper: record error and continue + let mut record_error = |e: anyhow::Error| { + eprintln!("{}", e); + all_errors.push(e); + }; + + for branch_name in &branches { + // Resolve the target + let resolved = + match resolve_worktree_arg(repo, branch_name, &config, OperationMode::Remove) { + Ok(r) => r, + Err(e) => { + record_error(e); + continue; + } + }; + + match resolved { + ResolvedWorktree::Worktree { path, branch } => { + // Use canonical paths to avoid symlink/normalization mismatches + let path_canonical = dunce::canonicalize(&path).unwrap_or(path); + let is_current = current_worktree.as_ref() == Some(&path_canonical); + + if is_current { + // Current worktree - use handle_remove_current for detached HEAD + match handle_remove_current(!delete_branch, force_delete, force, &config) { + Ok(result) => plan_current = Some(result), + Err(e) => record_error(e), + } + continue; + } + + // Non-current worktree - branch is always Some because: + // - "@" resolves to current worktree (handled by is_current branch above) + // - Other names resolve via resolve_worktree_arg which always sets branch: Some(...) + let branch_for_remove = branch.as_ref().unwrap(); + + match handle_remove( + branch_for_remove, + !delete_branch, + force_delete, + force, + &config, + ) { + Ok(result) => plans_others.push(result), + Err(e) => record_error(e), + } + } + ResolvedWorktree::BranchOnly { branch } => { + match handle_remove(&branch, !delete_branch, force_delete, force, &config) { + Ok(result) => plans_branch_only.push(result), + Err(e) => record_error(e), + } + } + } + } + + // If no valid plans, bail early (all failed validation) + let has_valid_plans = + !plans_others.is_empty() || !plans_branch_only.is_empty() || plan_current.is_some(); + if !has_valid_plans { + anyhow::bail!(""); + } + + // Early exit for benchmarking time-to-first-output + if std::env::var_os("WORKTRUNK_FIRST_OUTPUT").is_some() { + return Ok(()); + } + + // Phase 2: Approve hooks (only if we have valid plans) + // TODO(pre-remove-context): Approval context uses current worktree, + // but hooks execute in each target worktree. + let run_hooks = approve_remove_hooks(verify, yes)?; + + // Phase 3: Execute all validated plans + // Remove other worktrees first + for result in plans_others { + handle_remove_output(&result, background, run_hooks)?; + } + + // Handle branch-only cases + for result in plans_branch_only { + handle_remove_output(&result, background, run_hooks)?; + } + + // Remove current worktree last (if it was in the list) + if let Some(result) = plan_current { + handle_remove_output(&result, background, run_hooks)?; + } + + // Exit with failure if any validation errors occurred + if !all_errors.is_empty() { + anyhow::bail!(""); + } + + Ok(()) + } +} + +/// Approve remove hooks if verification is enabled. +/// +/// Shared between git and jj remove paths. Returns `true` if hooks should run. +fn approve_remove_hooks(verify: bool, yes: bool) -> anyhow::Result { + if !verify { + return Ok(false); + } + let env = CommandEnv::for_action_branchless()?; + let ctx = env.context(yes); + let approved = approve_hooks( + &ctx, + &[ + HookType::PreRemove, + HookType::PostRemove, + HookType::PostSwitch, + ], + )?; + if !approved { + eprintln!("{}", info_message("Commands declined, continuing removal")); + } + Ok(approved) +} diff --git a/src/commands/repository_ext.rs b/src/commands/repository_ext.rs index 660e6b4c8..ffb4ea051 100644 --- a/src/commands/repository_ext.rs +++ b/src/commands/repository_ext.rs @@ -1,16 +1,6 @@ -use std::path::{Path, PathBuf}; -use std::process; -use std::time::{SystemTime, UNIX_EPOCH}; - use super::worktree::{BranchDeletionMode, RemoveResult, get_path_mismatch}; -use anyhow::{Context, bail}; -use color_print::cformat; use worktrunk::config::UserConfig; -use worktrunk::git::{ - GitError, IntegrationReason, Repository, parse_porcelain_z, parse_untracked_files, -}; -use worktrunk::path::format_path_for_display; -use worktrunk::styling::{eprintln, format_with_gutter, progress_message, warning_message}; +use worktrunk::git::{GitError, IntegrationReason, Repository}; /// Target for worktree removal. #[derive(Debug)] @@ -24,9 +14,6 @@ pub enum RemoveTarget<'a> { /// CLI-only helpers implemented on [`Repository`] via an extension trait so we can keep orphan /// implementations inside the binary crate. pub trait RepositoryCliExt { - /// Warn about untracked files being auto-staged. - fn warn_if_auto_staging_untracked(&self) -> anyhow::Result<()>; - /// Prepare a worktree removal by branch name or current worktree. /// /// Returns a `RemoveResult` describing what will be removed. The actual @@ -41,34 +28,9 @@ pub trait RepositoryCliExt { force_worktree: bool, config: &UserConfig, ) -> anyhow::Result; - - /// Prepare the target worktree for push by auto-stashing non-overlapping changes when safe. - fn prepare_target_worktree( - &self, - target_worktree: Option<&PathBuf>, - target_branch: &str, - ) -> anyhow::Result>; - - /// Check if HEAD is a linear extension of the target branch. - /// - /// Returns true when: - /// 1. The merge-base equals target's SHA (target hasn't advanced), AND - /// 2. There are no merge commits between target and HEAD (history is linear) - /// - /// This detects branches that have merged the target into themselves — such - /// branches need rebasing to linearize history even though merge-base equals target. - fn is_rebased_onto(&self, target: &str) -> anyhow::Result; } impl RepositoryCliExt for Repository { - fn warn_if_auto_staging_untracked(&self) -> anyhow::Result<()> { - // Use -z for NUL-separated output to handle filenames with spaces/newlines - let status = self - .run_command(&["status", "--porcelain", "-z"]) - .context("Failed to get status")?; - warn_about_untracked_files(&status) - } - fn prepare_worktree_removal( &self, target: RemoveTarget, @@ -222,119 +184,6 @@ impl RepositoryCliExt for Repository { removed_commit, }) } - - fn prepare_target_worktree( - &self, - target_worktree: Option<&PathBuf>, - target_branch: &str, - ) -> anyhow::Result> { - let Some(wt_path) = target_worktree else { - return Ok(None); - }; - - // Skip if target worktree directory is missing (prunable worktree) - if !wt_path.exists() { - return Ok(None); - } - - let wt = self.worktree_at(wt_path); - if !wt.is_dirty()? { - return Ok(None); - } - - let push_files = self.changed_files(target_branch, "HEAD")?; - // Use -z for NUL-separated output: handles filenames with spaces and renames correctly - // Format: "XY path\0" for normal files, "XY new_path\0old_path\0" for renames/copies - let wt_status_output = wt.run_command(&["status", "--porcelain", "-z"])?; - - let wt_files: Vec = parse_porcelain_z(&wt_status_output); - - let overlapping: Vec = push_files - .iter() - .filter(|f| wt_files.contains(f)) - .cloned() - .collect(); - - if !overlapping.is_empty() { - return Err(GitError::ConflictingChanges { - target_branch: target_branch.to_string(), - files: overlapping, - worktree_path: wt_path.clone(), - } - .into()); - } - - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let stash_name = format!( - "worktrunk autostash::{}::{}::{}", - target_branch, - process::id(), - nanos - ); - - eprintln!( - "{}", - progress_message(cformat!( - "Stashing changes in {}...", - format_path_for_display(wt_path) - )) - ); - - // Stash all changes including untracked files. - // Note: git stash push returns exit code 0 whether or not anything was stashed. - wt.run_command(&["stash", "push", "--include-untracked", "-m", &stash_name])?; - - // Verify stash was created by checking the stash list for our entry. - let list_output = wt.run_command(&["stash", "list", "--format=%gd%x00%gs%x00"])?; - let mut parts = list_output.split('\0'); - while let Some(id) = parts.next() { - if id.is_empty() { - continue; - } - if let Some(message) = parts.next() - && (message == stash_name || message.ends_with(&stash_name)) - { - return Ok(Some(TargetWorktreeStash::new(wt_path, id.to_string()))); - } - } - - // Stash entry not found. Verify the worktree is now clean — if it's still - // dirty, stashing may have failed silently or our lookup missed the entry. - if wt.is_dirty()? { - bail!( - "Failed to stash changes in {}; worktree still has uncommitted changes. \ - Expected stash entry: '{}'. Check 'git stash list'.", - format_path_for_display(wt_path), - stash_name - ); - } - - // Worktree is clean and no stash entry — nothing needed to be stashed - Ok(None) - } - - fn is_rebased_onto(&self, target: &str) -> anyhow::Result { - // Orphan branches have no common ancestor, so they can't be "rebased onto" target - let Some(merge_base) = self.merge_base("HEAD", target)? else { - return Ok(false); - }; - let target_sha = self.run_command(&["rev-parse", target])?.trim().to_string(); - - if merge_base != target_sha { - return Ok(false); // Target has advanced past merge-base - } - - // Check for merge commits — if present, history is not linear - let merge_commits = self - .run_command(&["rev-list", "--merges", &format!("{}..HEAD", target)])? - .trim() - .to_string(); - - Ok(merge_commits.is_empty()) - } } /// Compute integration reason for branch deletion. @@ -364,109 +213,9 @@ fn compute_integration_reason( reason } -/// Warn about untracked files that will be auto-staged. -fn warn_about_untracked_files(status_output: &str) -> anyhow::Result<()> { - let files = parse_untracked_files(status_output); - if files.is_empty() { - return Ok(()); - } - - let count = files.len(); - let path_word = if count == 1 { "path" } else { "paths" }; - eprintln!( - "{}", - warning_message(format!("Auto-staging {count} untracked {path_word}:")) - ); - - let joined_files = files.join("\n"); - eprintln!("{}", format_with_gutter(&joined_files, None)); - - Ok(()) -} - -/// Stash guard that auto-restores on drop. -/// -/// Created by `prepare_target_worktree()` when the target worktree has changes -/// that don't conflict with the push. Automatically restores the stash when -/// dropped, ensuring cleanup happens in both success and error paths. -#[must_use = "stash guard restores immediately if dropped; hold it until push completes"] -pub(crate) struct TargetWorktreeStash { - /// Inner data wrapped in Option so we can take() in Drop. - /// None means already restored (or disarmed). - inner: Option, -} - -struct StashData { - path: PathBuf, - stash_ref: String, -} - -impl StashData { - /// Restore the stash, printing progress and warning on failure. - fn restore(self) { - eprintln!( - "{}", - progress_message(cformat!( - "Restoring stashed changes in {}...", - format_path_for_display(&self.path) - )) - ); - - // Don't use --quiet so git shows conflicts if any - let success = Repository::current() - .ok() - .and_then(|repo| { - repo.worktree_at(&self.path) - .run_command(&["stash", "pop", &self.stash_ref]) - .ok() - }) - .is_some(); - - if !success { - eprintln!( - "{}", - warning_message(cformat!( - "Failed to restore stash {stash_ref}; run git stash pop {stash_ref} in {path}", - stash_ref = self.stash_ref, - path = format_path_for_display(&self.path), - )) - ); - } - } -} - -impl Drop for TargetWorktreeStash { - fn drop(&mut self) { - if let Some(data) = self.inner.take() { - data.restore(); - } - } -} - -impl TargetWorktreeStash { - pub(crate) fn new(path: &Path, stash_ref: String) -> Self { - Self { - inner: Some(StashData { - path: path.to_path_buf(), - stash_ref, - }), - } - } - - /// Explicitly restore the stash now, preventing Drop from restoring again. - /// - /// Use this when you need the restore to happen at a specific point - /// (e.g., before a success message). Drop handles errors/early returns. - pub(crate) fn restore_now(&mut self) { - if let Some(data) = self.inner.take() { - data.restore(); - } - } -} - #[cfg(test)] mod tests { - use super::*; + use worktrunk::git::{parse_porcelain_z, parse_untracked_files}; #[test] fn test_parse_porcelain_z_modified_staged() { @@ -615,35 +364,4 @@ mod tests { // All files are tracked (modified, staged, etc.) assert!(parse_untracked_files(" M file1.txt\0M file2.txt\0").is_empty()); } - - #[test] - fn test_stash_guard_restore_now_clears_inner() { - // Create a guard - note: this doesn't actually create a stash since we're not - // in a real git repo with that stash ref. We're just testing the state machine. - let mut guard = TargetWorktreeStash::new(std::path::Path::new("/tmp"), "stash@{0}".into()); - - // Inner should be populated - assert!(guard.inner.is_some()); - - // restore_now() should clear inner (the restore itself will fail since no real repo, - // but that's expected - we're testing the state transition) - guard.restore_now(); - - // Inner should now be None - assert!(guard.inner.is_none()); - - // Calling restore_now() again is a no-op - guard.restore_now(); - assert!(guard.inner.is_none()); - } - - #[test] - fn test_stash_guard_drop_clears_inner() { - // Test that Drop also consumes the inner - let guard = TargetWorktreeStash::new(std::path::Path::new("/tmp"), "stash@{0}".into()); - - // Just drop it - the restore will fail (no real repo) but Drop shouldn't panic - drop(guard); - // If we get here, Drop worked without panicking - } } diff --git a/src/commands/select/mod.rs b/src/commands/select/mod.rs index e5996feed..2a792be31 100644 --- a/src/commands/select/mod.rs +++ b/src/commands/select/mod.rs @@ -14,38 +14,49 @@ use std::sync::Arc; use anyhow::Context; use dashmap::DashMap; use skim::prelude::*; +use worktrunk::config::UserConfig; use worktrunk::git::Repository; +use worktrunk::workspace::JjWorkspace; use super::handle_switch::{ - approve_switch_hooks, spawn_switch_background_hooks, switch_extra_vars, + SwitchOptions, approve_switch_hooks, spawn_switch_background_hooks, switch_extra_vars, }; use super::list::collect; -use super::worktree::{ - SwitchBranchInfo, SwitchResult, execute_switch, get_path_mismatch, plan_switch, -}; +use super::list::collect_jj; +use super::worktree::{execute_switch, plan_switch, resolve_path_mismatch}; use crate::output::handle_switch_output; use items::{HeaderSkimItem, PreviewCache, WorktreeSkimItem}; use preview::{PreviewLayout, PreviewMode, PreviewState}; -pub fn handle_select(cli_branches: bool, cli_remotes: bool) -> anyhow::Result<()> { +/// Handle the interactive select/switch picker. +/// +/// `branches` and `remotes` are raw CLI flags (false when not passed). +/// Config resolution (project-specific merged with global) happens internally. +pub fn handle_select(branches: bool, remotes: bool, config: &UserConfig) -> anyhow::Result<()> { // Interactive picker requires a terminal for the TUI if !std::io::stdin().is_terminal() { anyhow::bail!("Interactive picker requires an interactive terminal"); } - let repo = Repository::current()?; + let workspace = worktrunk::workspace::open_workspace()?; + let is_jj = (*workspace) + .as_any() + .downcast_ref::() + .is_some(); + + // Resolve config using workspace's project identifier (avoids extra Repository::current()) + let project_id = workspace.project_identifier().ok(); + let resolved = config.resolved(project_id.as_deref()); - // Merge CLI flags with resolved config - let config = repo.config(); - let show_branches = cli_branches || config.list.branches(); - let show_remotes = cli_remotes || config.list.remotes(); + // CLI flags override config values + let show_branches = branches || resolved.list.branches(); + let show_remotes = remotes || resolved.list.remotes(); // Initialize preview mode state file (auto-cleanup on drop) let state = PreviewState::new(); - // Gather list data using simplified collection (buffered mode) - // Skip expensive operations not needed for select UI + // Gather list data — branch on VCS type for collection let skip_tasks: std::collections::HashSet = [ collect::TaskKind::BranchDiff, collect::TaskKind::CiStatus, @@ -54,26 +65,33 @@ pub fn handle_select(cli_branches: bool, cli_remotes: bool) -> anyhow::Result<() .into_iter() .collect(); - // Use 500ms timeout for git commands to show TUI faster on large repos. - // Typical slow operations: merge-tree ~400-1800ms, rev-list ~200-600ms. - // 500ms allows most operations to complete while cutting off tail latency. - // Operations that timeout fail silently (data not shown), but TUI stays responsive. - let command_timeout = Some(std::time::Duration::from_millis(500)); - - let Some(list_data) = collect::collect( - &repo, - collect::ShowConfig::Resolved { - show_branches, - show_remotes, - skip_tasks: skip_tasks.clone(), - command_timeout, - }, - false, // show_progress (no progress bars) - false, // render_table (select renders its own UI) - true, // skip_expensive_for_stale (faster for repos with many stale branches) - )? - else { - return Ok(()); + // Extract repo reference at function scope (needed for summary generation and post-selection switch) + let repo = (*workspace).as_any().downcast_ref::(); + + let list_data = if let Some(jj_ws) = (*workspace).as_any().downcast_ref::() { + collect_jj::collect_jj(jj_ws)? + } else { + let repo = + repo.ok_or_else(|| anyhow::anyhow!("`wt select` is not supported for this VCS"))?; + + // Use 500ms timeout for git commands to show TUI faster on large repos. + let command_timeout = Some(std::time::Duration::from_millis(500)); + + match collect::collect( + repo, + collect::ShowConfig::Resolved { + show_branches, + show_remotes, + skip_tasks: skip_tasks.clone(), + command_timeout, + }, + false, // show_progress + false, // render_table + true, // skip_expensive_for_stale + )? { + Some(data) => data, + None => return Ok(()), + } }; // Use the same layout system as `wt list` for proper column alignment @@ -251,8 +269,12 @@ pub fn handle_select(cli_branches: bool, cli_remotes: bool) -> anyhow::Result<() } // Queue summary generation after tabs 1-4 so git previews get rayon priority. - if config.list.summary() && config.commit_generation.is_configured() { - let llm_command = config.commit_generation.command.clone().unwrap(); + // Summary generation requires a git repo (not jj) + if let Some(repo) = repo + && resolved.list.summary() + && resolved.commit_generation.is_configured() + { + let llm_command = resolved.commit_generation.command.clone().unwrap(); for item in &items_for_precompute { let item = Arc::clone(item); let cache = Arc::clone(&preview_cache); @@ -265,7 +287,7 @@ pub fn handle_select(cli_branches: bool, cli_remotes: bool) -> anyhow::Result<() } else { // No LLM configured or summaries disabled — insert config hint so the // tab shows a useful message instead of a perpetual "Generating..." placeholder. - let hint = if !config.commit_generation.is_configured() { + let hint = if !resolved.commit_generation.is_configured() { "Configure [commit.generation] command to enable AI summaries.\n\n\ Example in ~/.config/worktrunk/config.toml:\n\n\ [commit.generation]\n\ @@ -310,46 +332,59 @@ pub fn handle_select(cli_branches: bool, cli_remotes: bool) -> anyhow::Result<() (selected.output().to_string(), false) }; - // Load config - let repo = Repository::current().context("Failed to switch worktree")?; - let config = repo.user_config(); - - // Switch to existing worktree or create new one - let plan = plan_switch(&repo, &identifier, should_create, None, false, config)?; - let skip_hooks = !approve_switch_hooks(&repo, config, &plan, false, true)?; - let (result, branch_info) = execute_switch(&repo, plan, config, false, skip_hooks)?; - - // Compute path mismatch lazily (deferred from plan_switch for existing worktrees) - let branch_info = match &result { - SwitchResult::Existing { path } | SwitchResult::AlreadyAt(path) => { - let expected_path = get_path_mismatch(&repo, &branch_info.branch, path, config); - SwitchBranchInfo { - expected_path, - ..branch_info - } + // Load config (fresh load for switch operation) + let mut config = UserConfig::load().context("Failed to load config")?; + + if is_jj { + // jj switch path: use handle_switch_jj + let opts = SwitchOptions { + branch: &identifier, + create: should_create, + base: None, + change_dir: true, + yes: false, + clobber: false, + verify: true, + execute: None, + execute_args: &[], + }; + let binary_name = std::env::args().next().unwrap_or_else(|| "wt".to_string()); + super::handle_switch_jj::handle_switch_jj(opts, &mut config, &binary_name)?; + } else { + // git switch path + let repo = (*workspace) + .as_any() + .downcast_ref::() + .expect("already verified git workspace"); + + let plan = plan_switch(repo, &identifier, should_create, None, false, &config)?; + let skip_hooks = !approve_switch_hooks(repo, &config, &plan, false, true)?; + let (result, branch_info) = execute_switch(repo, plan, &config, false, skip_hooks)?; + + // Compute path mismatch lazily (deferred from plan_switch for existing worktrees). + // No early exit here — select's TUI already dominates latency. + let branch_info = resolve_path_mismatch(branch_info, &result, repo, &config); + + // Show success message; emit cd directive if shell integration is active + // Interactive picker always performs cd (change_dir: true) + let cwd = std::env::current_dir().context("Failed to get current directory")?; + let source_root = repo.current_worktree().root()?; + let hooks_display_path = + handle_switch_output(&result, &branch_info, true, Some(&source_root), &cwd)?; + + // Spawn background hooks after success message + if !skip_hooks { + let extra_vars = switch_extra_vars(&result); + spawn_switch_background_hooks( + repo, + &config, + &result, + &branch_info.branch, + false, + &extra_vars, + hooks_display_path.as_deref(), + )?; } - _ => branch_info, - }; - - // Show success message; emit cd directive if shell integration is active - // Interactive picker always performs cd (change_dir: true) - let cwd = std::env::current_dir().context("Failed to get current directory")?; - let source_root = repo.current_worktree().root()?; - let hooks_display_path = - handle_switch_output(&result, &branch_info, true, Some(&source_root), &cwd)?; - - // Spawn background hooks after success message - if !skip_hooks { - let extra_vars = switch_extra_vars(&result); - spawn_switch_background_hooks( - &repo, - config, - &result, - &branch_info.branch, - false, - &extra_vars, - hooks_display_path.as_deref(), - )?; } } diff --git a/src/commands/statusline.rs b/src/commands/statusline.rs index f1676c387..481242227 100644 --- a/src/commands/statusline.rs +++ b/src/commands/statusline.rs @@ -16,7 +16,9 @@ use ansi_str::AnsiStr; use anyhow::{Context, Result}; use worktrunk::git::Repository; use worktrunk::styling::{fix_dim_after_color_reset, get_terminal_width, truncate_visible}; +use worktrunk::workspace::open_workspace; +use super::list::columns::ColumnKind; use super::list::{self, CollectOptions, StatuslineSegment, json_output}; use crate::cli::OutputFormat; @@ -197,20 +199,28 @@ pub fn run(format: OutputFormat) -> Result<()> { None }; - // Git status segments (skip links in claude-code mode - OSC 8 not supported) - if let Ok(repo) = Repository::current() - && repo.worktree_at(&cwd).git_dir().is_ok() - { - let git_segments = get_git_status_segments(&repo, &cwd, !claude_code)?; - - // In claude-code mode, skip branch segment if directory matches worktrunk template - let git_segments = if let Some(ref dir) = dir_str { - filter_redundant_branch(git_segments, dir) + // VCS status segments (skip links in claude-code mode - OSC 8 not supported) + if let Ok(workspace) = open_workspace() { + if let Some(repo) = workspace.as_any().downcast_ref::() { + // Git path: full statusline with all git details + if repo.worktree_at(&cwd).git_dir().is_ok() { + let git_segments = get_git_status_segments(repo, &cwd, !claude_code)?; + + // In claude-code mode, skip branch segment if directory matches worktrunk template + let git_segments = if let Some(ref dir) = dir_str { + filter_redundant_branch(git_segments, dir) + } else { + git_segments + }; + + segments.extend(git_segments); + } } else { - git_segments - }; - - segments.extend(git_segments); + // jj path: show workspace name as branch segment + if let Ok(Some(name)) = workspace.current_name(&cwd) { + segments.push(StatuslineSegment::from_column(name, ColumnKind::Branch)); + } + } } // Model name (claude-code mode only) - priority 1 (same as Branch) @@ -255,7 +265,11 @@ pub fn run(format: OutputFormat) -> Result<()> { fn run_json() -> Result<()> { let cwd = env::current_dir().context("Failed to get current directory")?; - let repo = Repository::current().context("Not in a git repository")?; + let workspace = open_workspace().context("Not in a repository")?; + let repo = workspace + .as_any() + .downcast_ref::() + .context("JSON statusline format is only supported for git repositories")?; // Verify we're in a worktree if repo.worktree_at(&cwd).git_dir().is_err() { @@ -300,7 +314,7 @@ fn run_json() -> Result<()> { }; // Populate computed fields (parallel git operations) - list::populate_item(&repo, &mut item, options)?; + list::populate_item(repo, &mut item, options)?; // Convert to JSON format let json_item = json_output::JsonItem::from_list_item(&item); diff --git a/src/commands/step_commands.rs b/src/commands/step_commands.rs index c88b5c252..d8dd63f59 100644 --- a/src/commands/step_commands.rs +++ b/src/commands/step_commands.rs @@ -15,18 +15,17 @@ use anyhow::Context; use color_print::cformat; use ignore::gitignore::GitignoreBuilder; use worktrunk::HookType; -use worktrunk::config::UserConfig; +use worktrunk::config::{CommitGenerationConfig, UserConfig}; use worktrunk::git::Repository; use worktrunk::styling::{ eprintln, format_with_gutter, hint_message, info_message, progress_message, success_message, }; +use worktrunk::workspace::{SquashOutcome, Workspace}; use super::command_approval::approve_hooks; use super::commit::{CommitGenerator, CommitOptions, StageMode}; use super::context::CommandEnv; use super::hooks::{HookFailureStrategy, run_hook_with_filter}; -use super::repository_ext::RepositoryCliExt; -use worktrunk::shell_exec::Cmd; /// Handle `wt step commit` command /// @@ -37,23 +36,47 @@ pub fn step_commit( stage: Option, show_prompt: bool, ) -> anyhow::Result<()> { - // Handle --show-prompt early: just build and output the prompt + // --show-prompt is VCS-agnostic: build prompt from trait method and print if show_prompt { - let repo = worktrunk::git::Repository::current()?; + let ws = worktrunk::workspace::open_workspace()?; + let cwd = std::env::current_dir()?; let config = UserConfig::load().context("Failed to load config")?; - let project_id = repo.project_identifier().ok(); + let project_id = ws.project_identifier().ok(); let commit_config = config.commit_generation(project_id.as_deref()); - let prompt = crate::llm::build_commit_prompt(&commit_config)?; + + let (diff, diff_stat) = ws.committable_diff_for_prompt(&cwd)?; + let branch = ws.current_name(&cwd)?.unwrap_or_else(|| "HEAD".to_string()); + let repo_name = ws + .root_path()? + .file_name() + .and_then(|n| n.to_str().map(String::from)) + .unwrap_or_else(|| "repo".to_string()); + let recent_commits = ws.recent_subjects(None, 5); + + let input = crate::llm::CommitInput { + diff: &diff, + diff_stat: &diff_stat, + branch: &branch, + repo_name: &repo_name, + recent_commits: recent_commits.as_ref(), + }; + let prompt = crate::llm::build_commit_prompt(&input, &commit_config)?; println!("{}", prompt); return Ok(()); } + // Open workspace once, route by VCS type via downcast + let workspace = worktrunk::workspace::open_workspace()?; + if workspace.as_any().downcast_ref::().is_none() { + return super::handle_step_jj::step_commit_jj(); + } + // Load config once, run LLM setup prompt, then reuse config let mut config = UserConfig::load().context("Failed to load config")?; // One-time LLM setup prompt (errors logged internally; don't block commit) let _ = crate::output::prompt_commit_generation(&mut config); - let env = CommandEnv::for_action("commit", config)?; + let env = CommandEnv::with_workspace(workspace, "commit", config)?; let ctx = env.context(yes); // CLI flag overrides config value @@ -80,8 +103,6 @@ pub fn step_commit( options.no_verify = no_verify; options.stage_mode = stage_mode; options.show_no_squash_note = false; - // Only warn about untracked if we're staging all - options.warn_about_untracked = stage_mode == StageMode::All; options.commit() } @@ -99,6 +120,81 @@ pub enum SquashResult { NoNetChanges, } +/// VCS-agnostic squash core: count, generate message, execute squash. +/// +/// Used by both git and jj paths. Does NOT handle staging, hooks, safety backups, +/// or progress display — those are git-specific concerns in `handle_squash`. +pub(crate) fn do_squash( + workspace: &dyn Workspace, + target: &str, + path: &Path, + commit_gen_config: &CommitGenerationConfig, + branch_name: &str, + repo_name: &str, +) -> anyhow::Result { + let feature_head = workspace.feature_head(path)?; + + // Check if already integrated + if workspace.is_integrated(&feature_head, target)?.is_some() { + return Ok(SquashResult::NoCommitsAhead(target.to_string())); + } + + let (ahead, _) = workspace.ahead_behind(target, &feature_head)?; + + if ahead == 0 { + return Ok(SquashResult::NoCommitsAhead(target.to_string())); + } + + if ahead == 1 && !workspace.is_dirty(path)? { + return Ok(SquashResult::AlreadySingleCommit); + } + + // Gather data for message generation + let subjects = workspace.commit_subjects(target, &feature_head)?; + let (diff, diff_stat) = workspace.diff_for_prompt(target, &feature_head, path)?; + let recent_commits = workspace.recent_subjects(Some(target), 5); + + // Generate squash commit message + eprintln!( + "{}", + progress_message("Generating squash commit message...") + ); + + let generator = CommitGenerator::new(commit_gen_config); + generator.emit_hint_if_needed(); + + let input = crate::llm::SquashInput { + target_branch: target, + diff: &diff, + diff_stat: &diff_stat, + subjects: &subjects, + current_branch: branch_name, + repo_name, + recent_commits: recent_commits.as_ref(), + }; + let commit_message = crate::llm::generate_squash_message(&input, commit_gen_config)?; + + // Display the generated commit message + let formatted_message = generator.format_message_for_display(&commit_message); + eprintln!("{}", format_with_gutter(&formatted_message, None)); + + // Execute the squash + match workspace.squash_commits(target, &commit_message, path)? { + SquashOutcome::Squashed(id) => { + eprintln!("{}", success_message(cformat!("Squashed @ {id}"))); + Ok(SquashResult::Squashed) + } + SquashOutcome::NoNetChanges => { + let commit_text = if ahead == 1 { "commit" } else { "commits" }; + eprintln!( + "{}", + info_message(format!("No changes after squashing {ahead} {commit_text}")) + ); + Ok(SquashResult::NoNetChanges) + } + } +} + /// Handle shared squash workflow (used by `wt step squash` and `wt merge`) /// /// # Arguments @@ -110,13 +206,39 @@ pub fn handle_squash( no_verify: bool, stage: Option, ) -> anyhow::Result { - // Load config once, run LLM setup prompt, then reuse config + // Open workspace once, route by VCS type via downcast + let workspace = worktrunk::workspace::open_workspace()?; + if workspace.as_any().downcast_ref::().is_none() { + // jj path: use do_squash() directly (no staging/hooks) + let cwd = std::env::current_dir()?; + let target = workspace.resolve_integration_target(target)?; + + let config = UserConfig::load().context("Failed to load config")?; + let project_id = workspace.project_identifier().ok(); + let resolved = config.resolved(project_id.as_deref()); + + let ws_name = workspace + .current_name(&cwd)? + .unwrap_or_else(|| "default".to_string()); + let repo_name = project_id.as_deref().unwrap_or("repo"); + + return do_squash( + &*workspace, + &target, + &cwd, + &resolved.commit_generation, + &ws_name, + repo_name, + ); + } + // Workspace is git — proceed with CommandEnv which takes ownership + let cwd = std::env::current_dir()?; let mut config = UserConfig::load().context("Failed to load config")?; // One-time LLM setup prompt (errors logged internally; don't block commit) let _ = crate::output::prompt_commit_generation(&mut config); - let env = CommandEnv::for_action("squash", config)?; - let repo = &env.repo; + let env = CommandEnv::with_workspace(workspace, "squash", config)?; + let repo = env.require_repo()?; // Squash requires being on a branch (can't squash in detached HEAD) let current_branch = env.require_branch("squash")?.to_string(); let ctx = env.context(yes); @@ -161,21 +283,8 @@ pub fn handle_squash( // Get and validate target ref (any commit-ish for merge-base calculation) let integration_target = repo.require_target_ref(target)?; - // Auto-stage changes before running pre-commit hooks so both beta and merge paths behave identically - match stage_mode { - StageMode::All => { - repo.warn_if_auto_staging_untracked()?; - repo.run_command(&["add", "-A"]) - .context("Failed to stage changes")?; - } - StageMode::Tracked => { - repo.run_command(&["add", "-u"]) - .context("Failed to stage tracked changes")?; - } - StageMode::None => { - // Stage nothing - use what's already staged - } - } + // Stage changes via Workspace (git: warn + add, jj: no-op) + env.workspace.prepare_commit(&cwd, stage_mode)?; // Run pre-commit hooks (user first, then project) if !no_verify { @@ -290,99 +399,132 @@ pub fn handle_squash( .and_then(|n| n.to_str()) .unwrap_or("repo"); - let commit_message = crate::llm::generate_squash_message( - &integration_target, - &merge_base, - &subjects, - ¤t_branch, + // Get diff data for LLM prompt + let (diff, diff_stat) = repo.diff_for_prompt(&merge_base, "HEAD", &cwd)?; + let recent_commits = repo.recent_subjects(Some(&merge_base), 5); + + let input = crate::llm::SquashInput { + target_branch: &integration_target, + diff: &diff, + diff_stat: &diff_stat, + subjects: &subjects, + current_branch: ¤t_branch, repo_name, - &resolved.commit_generation, - )?; + recent_commits: recent_commits.as_ref(), + }; + let commit_message = crate::llm::generate_squash_message(&input, &resolved.commit_generation)?; // Display the generated commit message let formatted_message = generator.format_message_for_display(&commit_message); eprintln!("{}", format_with_gutter(&formatted_message, None)); - // Reset to merge base (soft reset stages all changes, including any already-staged uncommitted changes) + // Execute squash via trait (reset --soft, check staged, commit) // - // TOCTOU note: Between this reset and the commit below, an external process could - // modify the staging area. This is extremely unlikely (requires precise timing) and - // the consequence is minor (unexpected content in squash commit). The commit message - // generated above accurately reflects the original commits being squashed, so any - // discrepancy would be visible in the diff. Considered acceptable risk. - repo.run_command(&["reset", "--soft", &merge_base]) - .context("Failed to reset to merge base")?; - - // Check if there are actually any changes to commit - if !wt.has_staged_changes()? { + // TOCTOU note: Between the reset and commit inside squash_commits, an external + // process could modify the staging area. This is extremely unlikely and the + // consequence is minor (unexpected content in squash commit). Considered acceptable. + match repo.squash_commits(&integration_target, &commit_message, &cwd)? { + SquashOutcome::Squashed(commit_hash) => { + eprintln!( + "{}", + success_message(cformat!("Squashed @ {commit_hash}")) + ); + Ok(SquashResult::Squashed) + } + SquashOutcome::NoNetChanges => { + eprintln!( + "{}", + info_message(format!( + "No changes after squashing {commit_count} {commit_text}" + )) + ); + Ok(SquashResult::NoNetChanges) + } + } +} + +/// Handle `wt step push` command. +/// +/// Fully trait-based: opens the workspace and uses `local_push` +/// for both git and jj, with zero VcsKind branching. +/// +/// Each VCS implementation validates push safety internally: +/// - Git checks fast-forward (is_ancestor) — allows merge commits +/// - Jj checks that target is ancestor of feature tip (is_rebased_onto) +pub fn step_push(target: Option<&str>) -> anyhow::Result<()> { + let ws = worktrunk::workspace::open_workspace()?; + let cwd = std::env::current_dir()?; + + let target = ws.resolve_integration_target(target)?; + + let result = ws + .local_push(&target, &cwd, Default::default()) + .context("Failed to push")?; + + if result.commit_count > 0 { + let mut summary_parts = vec![format!( + "{} commit{}", + result.commit_count, + if result.commit_count == 1 { "" } else { "s" } + )]; + summary_parts.extend(result.stats_summary); + + let stats_str = summary_parts.join(", "); + let paren_close = cformat!(")"); eprintln!( "{}", - info_message(format!( - "No changes after squashing {commit_count} {commit_text}" + success_message(cformat!( + "Pushed to {target} ({stats_str}{}", + paren_close )) ); - return Ok(SquashResult::NoNetChanges); + } else { + eprintln!( + "{}", + info_message(cformat!("Already up to date with {target}")) + ); } - // Commit with the generated message - repo.run_command(&["commit", "-m", &commit_message]) - .context("Failed to create squash commit")?; - - // Get commit hash for display - let commit_hash = repo - .run_command(&["rev-parse", "--short", "HEAD"])? - .trim() - .to_string(); - - // Show success immediately after completing the squash - eprintln!( - "{}", - success_message(cformat!("Squashed @ {commit_hash}")) - ); - - Ok(SquashResult::Squashed) + Ok(()) } /// Handle `wt step squash --show-prompt` /// /// Builds and outputs the squash prompt without running the LLM or squashing. +/// Works for both git and jj repositories. pub fn step_show_squash_prompt(target: Option<&str>) -> anyhow::Result<()> { - let repo = Repository::current()?; + let ws = worktrunk::workspace::open_workspace()?; + let cwd = std::env::current_dir()?; + let config = UserConfig::load().context("Failed to load config")?; - let project_id = repo.project_identifier().ok(); + let project_id = ws.project_identifier().ok(); let effective_config = config.commit_generation(project_id.as_deref()); - // Get and validate target ref (any commit-ish for merge-base calculation) - let integration_target = repo.require_target_ref(target)?; - - // Get current branch - let wt = repo.current_worktree(); - let current_branch = wt.branch()?.unwrap_or_else(|| "HEAD".to_string()); + let target = ws.resolve_integration_target(target)?; + let feature_head = ws.feature_head(&cwd)?; - // Get merge base with target branch (required for generating squash message) - let merge_base = repo - .merge_base("HEAD", &integration_target)? - .context("Cannot generate squash message: no common ancestor with target branch")?; + let current_branch = ws.current_name(&cwd)?.unwrap_or_else(|| "HEAD".to_string()); - // Get commit subjects for the squash message - let range = format!("{}..HEAD", merge_base); - let subjects = repo.commit_subjects(&range)?; + let subjects = ws.commit_subjects(&target, &feature_head)?; + let (diff, diff_stat) = ws.diff_for_prompt(&target, &feature_head, &cwd)?; + let recent_commits = ws.recent_subjects(Some(&target), 5); - // Get repo name from directory - let repo_root = wt.root()?; - let repo_name = repo_root + let repo_name = ws + .root_path()? .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("repo"); - - let prompt = crate::llm::build_squash_prompt( - &integration_target, - &merge_base, - &subjects, - ¤t_branch, - repo_name, - &effective_config, - )?; + .and_then(|n| n.to_str().map(String::from)) + .unwrap_or_else(|| "repo".to_string()); + + let input = crate::llm::SquashInput { + target_branch: &target, + diff: &diff, + diff_stat: &diff_stat, + subjects: &subjects, + current_branch: ¤t_branch, + repo_name: &repo_name, + recent_commits: recent_commits.as_ref(), + }; + let prompt = crate::llm::build_squash_prompt(&input, &effective_config)?; println!("{}", prompt); Ok(()) } @@ -397,73 +539,24 @@ pub enum RebaseResult { /// Handle shared rebase workflow (used by `wt step rebase` and `wt merge`) pub fn handle_rebase(target: Option<&str>) -> anyhow::Result { - let repo = Repository::current()?; + let ws = worktrunk::workspace::open_workspace()?; + let cwd = std::env::current_dir()?; - // Get and validate target ref (any commit-ish for rebase) - let integration_target = repo.require_target_ref(target)?; + let target = ws.resolve_integration_target(target)?; - // Check if already up-to-date (linear extension of target, no merge commits) - if repo.is_rebased_onto(&integration_target)? { - return Ok(RebaseResult::UpToDate(integration_target)); + if ws.is_rebased_onto(&target, &cwd)? { + return Ok(RebaseResult::UpToDate(target)); } - // Check if this is a fast-forward or true rebase - let merge_base = repo - .merge_base("HEAD", &integration_target)? - .context("Cannot rebase: no common ancestor with target branch")?; - let head_sha = repo.run_command(&["rev-parse", "HEAD"])?.trim().to_string(); - let is_fast_forward = merge_base == head_sha; + let outcome = ws.rebase_onto(&target, &cwd)?; - // Only show progress for true rebases (fast-forwards are instant) - if !is_fast_forward { - eprintln!( - "{}", - progress_message(cformat!("Rebasing onto {integration_target}...")) - ); - } - - let rebase_result = repo.run_command(&["rebase", &integration_target]); - - // If rebase failed, check if it's due to conflicts - if let Err(e) = rebase_result { - // Check if it's a rebase conflict - let is_rebasing = repo - .worktree_state()? - .is_some_and(|s| s.starts_with("REBASING")); - if is_rebasing { - // Extract git's stderr output from the error - let git_output = e.to_string(); - return Err(worktrunk::git::GitError::RebaseConflict { - target_branch: integration_target, - git_output, - } - .into()); + let msg = match outcome { + worktrunk::workspace::RebaseOutcome::FastForward => { + cformat!("Fast-forwarded to {target}") } - // Not a rebase conflict, return original error - return Err(worktrunk::git::GitError::Other { - message: cformat!( - "Failed to rebase onto {}: {}", - integration_target, - e - ), - } - .into()); - } - - // Verify rebase completed successfully (safety check for edge cases) - if repo.worktree_state()?.is_some() { - return Err(worktrunk::git::GitError::RebaseConflict { - target_branch: integration_target, - git_output: String::new(), + worktrunk::workspace::RebaseOutcome::Rebased => { + cformat!("Rebased onto {target}") } - .into()); - } - - // Success - let msg = if is_fast_forward { - cformat!("Fast-forwarded to {integration_target}") - } else { - cformat!("Rebased onto {integration_target}") }; eprintln!("{}", success_message(msg)); @@ -483,44 +576,24 @@ pub fn step_copy_ignored( dry_run: bool, force: bool, ) -> anyhow::Result<()> { - let repo = Repository::current()?; - - // Resolve source and destination worktree paths - let (source_path, source_context) = match from { - Some(branch) => { - let path = repo.worktree_for_branch(branch)?.ok_or_else(|| { - worktrunk::git::GitError::WorktreeNotFound { - branch: branch.to_string(), - } - })?; - (path, branch.to_string()) - } - None => { - // Default source is the primary worktree (main worktree for normal repos, - // default branch worktree for bare repos). - let path = repo.primary_worktree()?.ok_or_else(|| { - anyhow::anyhow!( - "No primary worktree found (bare repo with no default branch worktree)" - ) - })?; - let context = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); - (path, context) - } + let workspace = worktrunk::workspace::open_workspace()?; + + // Resolve source and destination workspace paths + let source_path = match from { + Some(name) => workspace.workspace_path(name)?, + None => workspace + .default_workspace_path()? + .ok_or_else(|| anyhow::anyhow!("No default workspace found"))?, }; let dest_path = match to { - Some(branch) => repo.worktree_for_branch(branch)?.ok_or_else(|| { - worktrunk::git::GitError::WorktreeNotFound { - branch: branch.to_string(), - } - })?, - None => repo.current_worktree().root()?, + Some(name) => workspace.workspace_path(name)?, + None => workspace.current_workspace_path()?, }; - if source_path == dest_path { + if dunce::canonicalize(&source_path).unwrap_or_else(|_| source_path.clone()) + == dunce::canonicalize(&dest_path).unwrap_or_else(|_| dest_path.clone()) + { eprintln!( "{}", info_message("Source and destination are the same worktree") @@ -528,9 +601,8 @@ pub fn step_copy_ignored( return Ok(()); } - // Get ignored entries from git - // --directory stops at directory boundaries (avoids listing thousands of files in target/) - let ignored_entries = list_ignored_entries(&source_path, &source_context)?; + // Get ignored entries via git ls-files (works for both git and jj with git backend) + let ignored_entries = workspace.list_ignored_entries(&source_path)?; // Filter to entries that match .worktreeinclude (or all if no file exists) let include_path = source_path.join(".worktreeinclude"); @@ -539,10 +611,8 @@ pub fn step_copy_ignored( let include_matcher = { let mut builder = GitignoreBuilder::new(&source_path); if let Some(err) = builder.add(&include_path) { - return Err(worktrunk::git::GitError::WorktreeIncludeParseError { - error: err.to_string(), - } - .into()); + return Err(anyhow::anyhow!("{err}")) + .context(cformat!("Error parsing .worktreeinclude")); } builder.build().context("Failed to build include matcher")? }; @@ -555,20 +625,20 @@ pub fn step_copy_ignored( ignored_entries }; - // Filter out entries that contain other worktrees (prevents recursive copying when - // worktrees are nested inside the source, e.g., worktree-path = ".worktrees/...") - let worktree_paths: Vec = repo - .list_worktrees()? + // Filter out entries that contain other workspaces (prevents recursive copying when + // workspaces are nested inside the source, e.g., worktree-path = ".worktrees/...") + let workspace_paths: Vec = workspace + .list_workspaces()? .into_iter() - .map(|wt| wt.path) + .map(|ws| ws.path) .collect(); let entries_to_copy: Vec<_> = entries_to_copy .into_iter() .filter(|(entry_path, _)| { - // Exclude if any worktree (other than source) is inside or equal to this entry - !worktree_paths + // Exclude if any workspace (other than source) is inside or equal to this entry + !workspace_paths .iter() - .any(|wt_path| wt_path != &source_path && wt_path.starts_with(entry_path)) + .any(|ws_path| ws_path != &source_path && ws_path.starts_with(entry_path)) }) .collect(); @@ -606,10 +676,10 @@ pub fn step_copy_ignored( // Copy entries for (src_entry, is_dir) in &entries_to_copy { - // Paths from git ls-files are always under source_path + // Paths from list_ignored_entries are always under source_path let relative = src_entry .strip_prefix(&source_path) - .expect("git ls-files path under worktree"); + .expect("ignored entry path under source workspace"); let dest_entry = dest_path.join(relative); if *is_dir { @@ -653,46 +723,6 @@ fn remove_if_exists(path: &Path) -> anyhow::Result<()> { Ok(()) } -/// List ignored entries using git ls-files -/// -/// Uses `git ls-files --ignored --exclude-standard -o --directory` which: -/// - Handles all gitignore sources (global, .gitignore, .git/info/exclude, nested) -/// - Stops at directory boundaries (--directory) to avoid listing thousands of files -fn list_ignored_entries( - worktree_path: &Path, - context: &str, -) -> anyhow::Result> { - let output = Cmd::new("git") - .args([ - "ls-files", - "--ignored", - "--exclude-standard", - "-o", - "--directory", - ]) - .current_dir(worktree_path) - .context(context) - .run() - .context("Failed to run git ls-files")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("git ls-files failed: {}", stderr.trim()); - } - - // Parse output: directories end with / - let entries = String::from_utf8_lossy(&output.stdout) - .lines() - .map(|line| { - let is_dir = line.ends_with('/'); - let path = worktree_path.join(line.trim_end_matches('/')); - (path, is_dir) - }) - .collect(); - - Ok(entries) -} - /// Copy a directory recursively using reflink (COW). /// /// Uses file-by-file copying with per-file reflink on all platforms. This spreads @@ -791,7 +821,21 @@ pub fn step_relocate( show_dry_run_preview, show_no_relocations_needed, show_summary, validate_candidates, }; - let repo = Repository::current()?; + let workspace = worktrunk::workspace::open_workspace()?; + + // Relocate only applies to git worktrees — jj workspace paths are auto-determined + let Some(repo) = workspace + .as_any() + .downcast_ref::() + else { + eprintln!( + "{}", + info_message( + "Relocate is not applicable for jj workspaces (paths are auto-determined)" + ) + ); + return Ok(()); + }; let config = UserConfig::load()?; let default_branch = repo.default_branch().unwrap_or_default(); @@ -807,7 +851,7 @@ pub fn step_relocate( let GatherResult { candidates, template_errors, - } = gather_candidates(&repo, &config, &branches)?; + } = gather_candidates(repo, &config, &branches)?; if candidates.is_empty() { show_no_relocations_needed(template_errors); @@ -822,7 +866,7 @@ pub fn step_relocate( // Phase 2: Validate candidates (check locked/dirty, optionally auto-commit) let ValidationResult { validated, skipped } = - validate_candidates(&repo, &config, candidates, commit, &repo_path)?; + validate_candidates(repo, &config, candidates, commit, &repo_path)?; if validated.is_empty() { show_all_skipped(skipped); @@ -830,7 +874,7 @@ pub fn step_relocate( } // Phase 3 & 4: Create executor (classifies targets) and execute relocations - let mut executor = RelocationExecutor::new(&repo, validated, clobber)?; + let mut executor = RelocationExecutor::new(repo, validated, clobber)?; let cwd = std::env::current_dir().ok(); executor.execute(&repo_path, &default_branch, cwd.as_deref())?; diff --git a/src/commands/worktree/hooks.rs b/src/commands/worktree/hooks.rs index aed44015e..cf2591071 100644 --- a/src/commands/worktree/hooks.rs +++ b/src/commands/worktree/hooks.rs @@ -50,7 +50,7 @@ impl<'a> CommandContext<'a> { removed_commit: Option<&str>, display_path: Option<&Path>, ) -> anyhow::Result> { - let project_config = self.repo.load_project_config()?; + let project_config = self.workspace.load_project_config()?; // Template variables should reflect the removed worktree, not where we run from. // The removed worktree path no longer exists, but hooks may need to reference it diff --git a/src/commands/worktree/mod.rs b/src/commands/worktree/mod.rs index 8d17cacc2..6ef86e4bc 100644 --- a/src/commands/worktree/mod.rs +++ b/src/commands/worktree/mod.rs @@ -79,22 +79,19 @@ //! The shell wrapper is generated by `wt config shell init ` from templates in `templates/`. mod hooks; -mod push; mod remove; mod resolve; mod switch; mod types; // Re-export public types and functions -pub use push::handle_push; pub use remove::{handle_remove, handle_remove_current}; pub(crate) use resolve::paths_match; pub use resolve::{ compute_worktree_path, get_path_mismatch, is_worktree_at_expected_path, resolve_worktree_arg, worktree_display_name, }; -pub use switch::{execute_switch, plan_switch}; +pub use switch::{execute_switch, plan_switch, resolve_path_mismatch}; pub use types::{ - BranchDeletionMode, MergeOperations, OperationMode, RemoveResult, SwitchBranchInfo, SwitchPlan, - SwitchResult, + BranchDeletionMode, OperationMode, RemoveResult, SwitchBranchInfo, SwitchPlan, SwitchResult, }; diff --git a/src/commands/worktree/push.rs b/src/commands/worktree/push.rs deleted file mode 100644 index fa574f729..000000000 --- a/src/commands/worktree/push.rs +++ /dev/null @@ -1,211 +0,0 @@ -//! Worktree push operations. -//! -//! Push changes to target branch with safety checks. - -use color_print::cformat; -use worktrunk::git::{GitError, Repository}; -use worktrunk::styling::{ - eprintln, format_with_gutter, info_message, progress_message, success_message, -}; - -use super::types::MergeOperations; -use crate::commands::repository_ext::RepositoryCliExt; - -/// Push changes to target branch -/// -/// The `operations` parameter indicates which merge operations occurred (commit, squash, rebase). -/// Pass `None` for standalone push operations where these concepts don't apply. -/// -/// During the push stage we temporarily `git stash` non-overlapping changes in the -/// target worktree (if present) so that concurrent edits there do not block the -/// fast-forward. The stash is restored afterward and we bail out early if any file -/// overlaps with the push range. -pub fn handle_push( - target: Option<&str>, - verb: &str, - operations: Option, -) -> anyhow::Result<()> { - let repo = Repository::current()?; - - // Get and validate target branch (must be a branch since we're updating it) - let target_branch = repo.require_target_branch(target)?; - - // A worktree for the target branch is optional for push: - // - If present, we use it to check for overlapping dirty files. - // - If absent, we skip that safety step but still allow the push (git itself is fine). - let target_worktree_path = repo.worktree_for_branch(&target_branch)?; - - // Check if it's a fast-forward - if !repo.is_ancestor(&target_branch, "HEAD")? { - // Get formatted commit log (commits in target that we don't have) - let commits_formatted = repo - .run_command(&[ - "log", - "--color=always", - "--graph", - "--oneline", - &format!("HEAD..{}", target_branch), - ])? - .trim() - .to_string(); - - return Err(GitError::NotFastForward { - target_branch: target_branch.clone(), - commits_formatted, - in_merge_context: operations.is_some(), - } - .into()); - } - - // Check for conflicting changes in target worktree (auto-stash safe changes) - // The stash guard auto-restores on drop (error paths), or explicitly via restore_now() - let mut stash_guard = - repo.prepare_target_worktree(target_worktree_path.as_ref(), &target_branch)?; - - // Count commits and show what will be pushed - let commit_count = repo.count_commits(&target_branch, "HEAD")?; - - // Get diff statistics BEFORE push (will be needed for success message later) - let stats_summary = if commit_count > 0 { - repo.diff_stats_summary(&["diff", "--shortstat", &format!("{}..HEAD", target_branch)]) - } else { - Vec::new() - }; - - // Build and show consolidated message with squash/rebase info - if commit_count > 0 { - let commit_text = if commit_count == 1 { - "commit" - } else { - "commits" - }; - let head_sha = repo.run_command(&["rev-parse", "--short", "HEAD"])?; - let head_sha = head_sha.trim(); - - let verb_ing = if verb.starts_with("Merged") { - "Merging" - } else { - "Pushing" - }; - - // Build parenthetical showing which operations didn't happen and flags used - let mut notes = Vec::new(); - - // Skipped operations - only include if we're in merge workflow context - if let Some(ops) = operations { - let mut skipped_ops = Vec::new(); - if !ops.committed && !ops.squashed { - // Neither commit nor squash happened - combine them - skipped_ops.push("commit/squash"); - } - if !ops.rebased { - skipped_ops.push("rebase"); - } - if !skipped_ops.is_empty() { - notes.push(format!("no {} needed", skipped_ops.join("/"))); - } - } - - let operations_note = if notes.is_empty() { - String::new() - } else { - format!(" ({})", notes.join(", ")) - }; - - eprintln!( - "{}", - progress_message(cformat!( - "{verb_ing} {commit_count} {commit_text} to {target_branch} @ {head_sha}{operations_note}" - )) - ); - - // Show the commit graph with color - let log_output = repo.run_command(&[ - "log", - "--color=always", - "--graph", - "--oneline", - &format!("{}..HEAD", target_branch), - ])?; - eprintln!("{}", format_with_gutter(&log_output, None)); - - // Show diff statistics - crate::commands::show_diffstat(&repo, &format!("{}..HEAD", target_branch))?; - } - - // Get git common dir for the push - let git_common_dir = repo.git_common_dir(); - let git_common_dir_str = git_common_dir.to_string_lossy(); - - // Perform the push - stash guard will auto-restore on any exit path - // Use --receive-pack to pass config to the receiving end without permanently mutating repo config - let push_target = format!("HEAD:{}", target_branch); - repo.run_command(&[ - "push", - "--receive-pack=git -c receive.denyCurrentBranch=updateInstead receive-pack", - git_common_dir_str.as_ref(), - &push_target, - ]) - .map_err(|e| { - // CommandFailed contains raw git output, wrap in PushFailed for proper formatting - GitError::PushFailed { - target_branch: target_branch.clone(), - error: e.to_string(), - } - })?; - - // Restore stash before success message (Drop handles error paths automatically) - if let Some(guard) = stash_guard.as_mut() { - guard.restore_now(); - } - - // Show success message after push completes - if commit_count > 0 { - // Use the diff statistics captured earlier (before push) - let mut summary_parts = vec![format!( - "{} commit{}", - commit_count, - if commit_count == 1 { "" } else { "s" } - )]; - summary_parts.extend(stats_summary); - - // Re-apply bright-black after stats (which end with a reset) so ) is also gray - let stats_str = summary_parts.join(", "); - let paren_close = cformat!(")"); // Separate to avoid cformat optimization - eprintln!( - "{}", - success_message(cformat!( - "{verb} {target_branch} ({stats_str}{}", - paren_close - )) - ); - } else { - // For merge workflow context, explain why nothing was pushed - let context = if let Some(ops) = operations { - let mut notes = Vec::new(); - if !ops.committed && !ops.squashed { - notes.push("no new commits"); - } - if !ops.rebased { - notes.push("no rebase needed"); - } - if notes.is_empty() { - String::new() - } else { - format!(" ({})", notes.join(", ")) - } - } else { - String::new() - }; - - // No action: nothing was pushed, just acknowledging state - eprintln!( - "{}", - info_message(cformat!( - "Already up to date with {target_branch}{context}" - )) - ); - } - - Ok(()) -} diff --git a/src/commands/worktree/resolve.rs b/src/commands/worktree/resolve.rs index f3a830a19..c9610bd42 100644 --- a/src/commands/worktree/resolve.rs +++ b/src/commands/worktree/resolve.rs @@ -10,6 +10,7 @@ use normalize_path::NormalizePath; use worktrunk::config::UserConfig; use worktrunk::git::{GitError, Repository, ResolvedWorktree}; use worktrunk::path::format_path_for_display; +use worktrunk::workspace::build_worktree_map; use super::types::OperationMode; @@ -107,7 +108,17 @@ pub fn compute_worktree_path( })?; let project = repo.project_identifier().ok(); - let expanded_path = config.format_path(repo_name, branch, repo, project.as_deref())?; + let repo_path_str = repo.repo_path().to_string_lossy().to_string(); + let worktree_map = build_worktree_map(repo); + let expanded_path = config + .format_path( + repo_name, + branch, + &repo_path_str, + &worktree_map, + project.as_deref(), + ) + .map_err(|e| anyhow::anyhow!(e))?; Ok(repo_root.join(expanded_path).normalize()) } diff --git a/src/commands/worktree/switch.rs b/src/commands/worktree/switch.rs index 4360f3e99..b2eab4636 100644 --- a/src/commands/worktree/switch.rs +++ b/src/commands/worktree/switch.rs @@ -16,8 +16,9 @@ use worktrunk::styling::{ eprintln, format_with_gutter, hint_message, info_message, progress_message, suggest_command, warning_message, }; +use worktrunk::workspace::Workspace; -use super::resolve::{compute_clobber_backup, compute_worktree_path}; +use super::resolve::{compute_clobber_backup, compute_worktree_path, get_path_mismatch}; use super::types::{CreationMethod, SwitchBranchInfo, SwitchPlan, SwitchResult}; use crate::commands::command_executor::CommandContext; @@ -628,8 +629,8 @@ pub fn plan_switch( /// /// Takes a `SwitchPlan` from `plan_switch()` and executes it. /// For `SwitchPlan::Existing`, just records history. The returned -/// `SwitchBranchInfo` has `expected_path: None` — callers fill it in after -/// first output to avoid computing path mismatch on the hot path. +/// `SwitchBranchInfo` has `expected_path: None` — callers must use +/// [`resolve_path_mismatch`] after first output to fill in mismatch warnings. /// For `SwitchPlan::Create`, creates the worktree and runs hooks. pub fn execute_switch( repo: &Repository, @@ -907,6 +908,27 @@ pub fn execute_switch( /// Resolve the deferred path mismatch for existing worktree switches. /// +/// `execute_switch` returns `expected_path: None` for existing worktrees to avoid +/// ~7 git commands on the hot path. Call this after first output to fill in the +/// mismatch warning. +pub fn resolve_path_mismatch( + branch_info: SwitchBranchInfo, + result: &SwitchResult, + repo: &Repository, + config: &UserConfig, +) -> SwitchBranchInfo { + match result { + SwitchResult::Existing { path } | SwitchResult::AlreadyAt(path) => { + let expected_path = get_path_mismatch(repo, &branch_info.branch, path, config); + SwitchBranchInfo { + expected_path, + ..branch_info + } + } + _ => branch_info, + } +} + fn worktree_creation_error( err: &anyhow::Error, branch: String, diff --git a/src/commands/worktree/types.rs b/src/commands/worktree/types.rs index 140aaa84c..95529a5d1 100644 --- a/src/commands/worktree/types.rs +++ b/src/commands/worktree/types.rs @@ -1,19 +1,11 @@ //! Types for worktree operations. //! -//! Core data structures used by switch, remove, and push operations. +//! Core data structures used by switch and remove operations. use std::path::{Path, PathBuf}; use worktrunk::git::RefType; -/// Flags indicating which merge operations occurred -#[derive(Debug, Clone, Copy)] -pub struct MergeOperations { - pub committed: bool, - pub squashed: bool, - pub rebased: bool, -} - /// Result of a worktree switch operation pub enum SwitchResult { /// Already at the target worktree (no action taken) @@ -267,59 +259,6 @@ mod tests { assert_eq!(result.path(), &path); } - #[test] - fn test_merge_operations_struct() { - let ops = MergeOperations { - committed: true, - squashed: false, - rebased: true, - }; - assert!(ops.committed); - assert!(!ops.squashed); - assert!(ops.rebased); - } - - #[test] - fn test_merge_operations_clone() { - let ops = MergeOperations { - committed: true, - squashed: true, - rebased: false, - }; - // MergeOperations implements both Clone and Copy - // Use Clone explicitly to test the Clone impl - let cloned = Clone::clone(&ops); - assert_eq!(ops.committed, cloned.committed); - assert_eq!(ops.squashed, cloned.squashed); - assert_eq!(ops.rebased, cloned.rebased); - } - - #[test] - fn test_merge_operations_copy() { - let ops = MergeOperations { - committed: false, - squashed: false, - rebased: true, - }; - let copied = ops; // Copy trait - assert_eq!(ops.committed, copied.committed); - assert_eq!(ops.squashed, copied.squashed); - assert_eq!(ops.rebased, copied.rebased); - } - - #[test] - fn test_merge_operations_debug() { - let ops = MergeOperations { - committed: true, - squashed: false, - rebased: true, - }; - let debug = format!("{:?}", ops); - assert!(debug.contains("committed: true")); - assert!(debug.contains("squashed: false")); - assert!(debug.contains("rebased: true")); - } - #[test] fn test_remove_result_removed_worktree() { let result = RemoveResult::RemovedWorktree { diff --git a/src/completion.rs b/src/completion.rs index 43feb2826..264e169d2 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -8,7 +8,7 @@ use clap_complete::env::CompleteEnv; use crate::cli; use crate::display::format_relative_time_short; -use worktrunk::config::{ProjectConfig, UserConfig}; +use worktrunk::config::UserConfig; use worktrunk::git::{BranchCategory, HookType, Repository}; /// Deprecated args that should never appear in completions. @@ -268,9 +268,9 @@ fn complete_hook_commands() -> Vec { } // Load project config and add project hook names - // Pass write_hints=false to avoid side effects during completion - if let Ok(repo) = Repository::current() - && let Ok(Some(project_config)) = ProjectConfig::load(&repo, false) + // Uses workspace trait (works for both git and jj) + if let Ok(workspace) = worktrunk::workspace::open_workspace() + && let Ok(Some(project_config)) = workspace.load_project_config() && let Some(config) = project_config.hooks.get(hook_type) { add_named_commands(&mut candidates, config); @@ -320,36 +320,46 @@ fn complete_branches( return Vec::new(); } - let branches = match Repository::current().and_then(|repo| repo.branches_for_completion()) { - Ok(b) => b, - Err(_) => return Vec::new(), - }; + // Try git first (fast path, no workspace detection overhead) + if let Ok(repo) = Repository::current() { + let branches = repo.branches_for_completion().unwrap_or_default(); + return branches + .into_iter() + .filter(|branch| { + if worktree_only { + matches!(branch.category, BranchCategory::Worktree) + } else if exclude_remote_only { + !matches!(branch.category, BranchCategory::Remote(_)) + } else { + true + } + }) + .map(|branch| { + let time_str = format_relative_time_short(branch.timestamp); + let help = match branch.category { + BranchCategory::Worktree => format!("+ {}", time_str), + BranchCategory::Local => format!("/ {}", time_str), + BranchCategory::Remote(remotes) => { + format!("⇣ {} {}", time_str, remotes.join(", ")) + } + }; + CompletionCandidate::new(branch.name).help(Some(help.into())) + }) + .collect(); + } - if branches.is_empty() { - return Vec::new(); + // Not in a git repo — try jj: workspace names as branch completions + if let Ok(workspace) = worktrunk::workspace::open_workspace() + && let Ok(items) = workspace.list_workspaces() + { + return items + .iter() + .filter_map(|w| w.branch.as_ref()) + .map(|name| CompletionCandidate::new(name.clone())) + .collect(); } - branches - .into_iter() - .filter(|branch| { - if worktree_only { - matches!(branch.category, BranchCategory::Worktree) - } else if exclude_remote_only { - !matches!(branch.category, BranchCategory::Remote(_)) - } else { - true - } - }) - .map(|branch| { - let time_str = format_relative_time_short(branch.timestamp); - let help = match branch.category { - BranchCategory::Worktree => format!("+ {}", time_str), - BranchCategory::Local => format!("/ {}", time_str), - BranchCategory::Remote(remotes) => format!("⇣ {} {}", time_str, remotes.join(", ")), - }; - CompletionCandidate::new(branch.name).help(Some(help.into())) - }) - .collect() + Vec::new() } fn suppress_switch_branch_completion() -> bool { diff --git a/src/config/deprecation.rs b/src/config/deprecation.rs index f8940fd16..acf308561 100644 --- a/src/config/deprecation.rs +++ b/src/config/deprecation.rs @@ -25,6 +25,8 @@ use color_print::cformat; use minijinja::Environment; use path_slash::PathExt as _; use regex::Regex; + +use crate::workspace::Workspace; use shell_escape::unix::escape; use crate::config::WorktrunkConfig; diff --git a/src/config/expansion.rs b/src/config/expansion.rs index 07e673e29..f25d3409e 100644 --- a/src/config/expansion.rs +++ b/src/config/expansion.rs @@ -9,13 +9,13 @@ //! See `wt hook --help` for available filters and functions. use std::borrow::Cow; +use std::path::PathBuf; use color_print::cformat; use minijinja::{Environment, ErrorKind, UndefinedBehavior, Value}; use regex::Regex; use shell_escape::escape; -use crate::git::Repository; use crate::path::to_posix_path; use crate::styling::{ eprintln, error_message, format_with_gutter, hint_message, info_message, verbosity, @@ -302,7 +302,8 @@ fn build_template_error( /// * `vars` - Variables to substitute /// * `shell_escape` - If true, shell-escape all values for safe command execution. /// If false, substitute values literally (for filesystem paths). -/// * `repo` - Repository for looking up worktree paths +/// * `worktree_map` - Branch/workspace name → filesystem path lookup for +/// `worktree_path_of_branch()`. Build with [`crate::workspace::build_worktree_map()`]. /// /// # Filters /// - `sanitize` — Replace `/` and `\` with `-` for filesystem-safe paths @@ -318,7 +319,7 @@ pub fn expand_template( template: &str, vars: &HashMap<&str, &str>, shell_escape: bool, - repo: &Repository, + worktree_map: &HashMap, name: &str, ) -> Result { // Build context map with raw values (shell escaping is applied at output time via formatter) @@ -365,12 +366,9 @@ pub fn expand_template( // Register worktree_path_of_branch function for looking up branch worktree paths. // Returns raw paths — shell escaping is applied by the formatter at output time. - let repo_clone = repo.clone(); + let map = worktree_map.clone(); env.add_function("worktree_path_of_branch", move |branch: String| -> String { - repo_clone - .worktree_for_branch(&branch) - .ok() - .flatten() + map.get(&branch) .map(|p| to_posix_path(&p.to_string_lossy())) .unwrap_or_default() }); @@ -436,27 +434,8 @@ pub fn expand_template( mod tests { use super::*; - /// Test fixture that creates a real temporary git repository. - struct TestRepo { - _dir: tempfile::TempDir, - repo: Repository, - } - - impl TestRepo { - fn new() -> Self { - let dir = tempfile::tempdir().unwrap(); - std::process::Command::new("git") - .args(["init"]) - .current_dir(dir.path()) - .output() - .unwrap(); - let repo = Repository::at(dir.path()).unwrap(); - Self { _dir: dir, repo } - } - } - - fn test_repo() -> TestRepo { - TestRepo::new() + fn empty_map() -> HashMap { + HashMap::new() } #[test] @@ -590,36 +569,36 @@ mod tests { #[test] fn test_expand_template_basic() { - let test = test_repo(); + let map = empty_map(); // Single variable let mut vars = HashMap::new(); vars.insert("name", "world"); assert_eq!( - expand_template("Hello {{ name }}", &vars, false, &test.repo, "test").unwrap(), + expand_template("Hello {{ name }}", &vars, false, &map, "test").unwrap(), "Hello world" ); // Multiple variables vars.insert("repo", "myrepo"); assert_eq!( - expand_template("{{ repo }}/{{ name }}", &vars, false, &test.repo, "test").unwrap(), + expand_template("{{ repo }}/{{ name }}", &vars, false, &map, "test").unwrap(), "myrepo/world" ); // Empty/static cases let empty: HashMap<&str, &str> = HashMap::new(); assert_eq!( - expand_template("", &empty, false, &test.repo, "test").unwrap(), + expand_template("", &empty, false, &map, "test").unwrap(), "" ); assert_eq!( - expand_template("static text", &empty, false, &test.repo, "test").unwrap(), + expand_template("static text", &empty, false, &map, "test").unwrap(), "static text" ); // Undefined variables now error in SemiStrict mode - let err = expand_template("no {{ variables }} here", &empty, false, &test.repo, "test") - .unwrap_err(); + let err = + expand_template("no {{ variables }} here", &empty, false, &map, "test").unwrap_err(); assert!( err.message.contains("undefined value"), "got: {}", @@ -629,32 +608,32 @@ mod tests { #[test] fn test_expand_template_shell_escape() { - let test = test_repo(); + let map = empty_map(); let mut vars = HashMap::new(); vars.insert("path", "my path"); - let expanded = expand_template("cd {{ path }}", &vars, true, &test.repo, "test").unwrap(); + let expanded = expand_template("cd {{ path }}", &vars, true, &map, "test").unwrap(); assert!(expanded.contains("'my path'") || expanded.contains("my\\ path")); // Command injection prevention vars.insert("arg", "test;rm -rf"); - let expanded = expand_template("echo {{ arg }}", &vars, true, &test.repo, "test").unwrap(); + let expanded = expand_template("echo {{ arg }}", &vars, true, &map, "test").unwrap(); assert!(!expanded.contains(";rm") || expanded.contains("'")); // No escape for literal mode vars.insert("branch", "feature/foo"); assert_eq!( - expand_template("{{ branch }}", &vars, false, &test.repo, "test").unwrap(), + expand_template("{{ branch }}", &vars, false, &map, "test").unwrap(), "feature/foo" ); } #[test] fn test_expand_template_errors() { - let test = test_repo(); + let map = empty_map(); let vars = HashMap::new(); - let err = expand_template("{{ unclosed", &vars, false, &test.repo, "test").unwrap_err(); + let err = expand_template("{{ unclosed", &vars, false, &map, "test").unwrap_err(); assert!(err.message.contains("syntax error"), "got: {}", err.message); - assert!(expand_template("{{ 1 + }}", &vars, false, &test.repo, "test").is_err()); + assert!(expand_template("{{ 1 + }}", &vars, false, &map, "test").is_err()); // Display impl renders source line but no available vars hint for syntax errors let display = err.to_string(); @@ -667,13 +646,12 @@ mod tests { #[test] fn test_expand_template_undefined_var_details() { - let test = test_repo(); + let map = empty_map(); let mut vars = HashMap::new(); vars.insert("branch", "main"); vars.insert("remote", "origin"); - let err = - expand_template("echo {{ target }}", &vars, false, &test.repo, "test").unwrap_err(); + let err = expand_template("echo {{ target }}", &vars, false, &map, "test").unwrap_err(); assert!( err.message.contains("undefined value"), "should mention undefined value: {}", @@ -695,31 +673,17 @@ mod tests { #[test] fn test_expand_template_jinja_features() { - let test = test_repo(); + let map = empty_map(); let mut vars = HashMap::new(); vars.insert("debug", "true"); assert_eq!( - expand_template( - "{% if debug %}DEBUG{% endif %}", - &vars, - false, - &test.repo, - "test" - ) - .unwrap(), + expand_template("{% if debug %}DEBUG{% endif %}", &vars, false, &map, "test").unwrap(), "DEBUG" ); vars.insert("debug", ""); assert_eq!( - expand_template( - "{% if debug %}DEBUG{% endif %}", - &vars, - false, - &test.repo, - "test" - ) - .unwrap(), + expand_template("{% if debug %}DEBUG{% endif %}", &vars, false, &map, "test").unwrap(), "" ); @@ -729,7 +693,7 @@ mod tests { "{{ missing | default('fallback') }}", &empty, false, - &test.repo, + &map, "test", ) .unwrap(), @@ -738,14 +702,14 @@ mod tests { vars.insert("name", "hello"); assert_eq!( - expand_template("{{ name | upper }}", &vars, false, &test.repo, "test").unwrap(), + expand_template("{{ name | upper }}", &vars, false, &map, "test").unwrap(), "HELLO" ); } #[test] fn test_expand_template_strip_prefix() { - let test = test_repo(); + let map = empty_map(); let mut vars = HashMap::new(); // Built-in replace filter strips prefix (replaces all occurrences) @@ -755,7 +719,7 @@ mod tests { "{{ branch | replace('feature/', '') }}", &vars, false, - &test.repo, + &map, "test" ) .unwrap(), @@ -768,7 +732,7 @@ mod tests { "{{ branch | replace('feature/', '') | sanitize }}", &vars, false, - &test.repo, + &map, "test" ) .unwrap(), @@ -782,7 +746,7 @@ mod tests { "{{ branch | replace('feature/', '') }}", &vars, false, - &test.repo, + &map, "test" ) .unwrap(), @@ -792,7 +756,7 @@ mod tests { // Slicing for prefix-only removal (avoids replacing mid-string) vars.insert("branch", "feature/nested/feature/deep"); assert_eq!( - expand_template("{{ branch[8:] }}", &vars, false, &test.repo, "test").unwrap(), + expand_template("{{ branch[8:] }}", &vars, false, &map, "test").unwrap(), "nested/feature/deep" ); @@ -802,7 +766,7 @@ mod tests { "{% if branch[:8] == 'feature/' %}{{ branch[8:] }}{% else %}{{ branch }}{% endif %}", &vars, false, - &test.repo, + &map, "test" ) .unwrap(), @@ -816,7 +780,7 @@ mod tests { "{% if branch[:8] == 'feature/' %}{{ branch[8:] }}{% else %}{{ branch }}{% endif %}", &vars, false, - &test.repo, + &map, "test" ) .unwrap(), @@ -826,32 +790,32 @@ mod tests { #[test] fn test_expand_template_sanitize_filter() { - let test = test_repo(); + let map = empty_map(); let mut vars = HashMap::new(); vars.insert("branch", "feature/foo"); assert_eq!( - expand_template("{{ branch | sanitize }}", &vars, false, &test.repo, "test").unwrap(), + expand_template("{{ branch | sanitize }}", &vars, false, &map, "test").unwrap(), "feature-foo" ); // Backslashes are also sanitized vars.insert("branch", "feature\\bar"); assert_eq!( - expand_template("{{ branch | sanitize }}", &vars, false, &test.repo, "test").unwrap(), + expand_template("{{ branch | sanitize }}", &vars, false, &map, "test").unwrap(), "feature-bar" ); // Multiple slashes vars.insert("branch", "user/feature/task"); assert_eq!( - expand_template("{{ branch | sanitize }}", &vars, false, &test.repo, "test").unwrap(), + expand_template("{{ branch | sanitize }}", &vars, false, &map, "test").unwrap(), "user-feature-task" ); // Raw branch is unchanged vars.insert("branch", "feature/foo"); assert_eq!( - expand_template("{{ branch }}", &vars, false, &test.repo, "test").unwrap(), + expand_template("{{ branch }}", &vars, false, &map, "test").unwrap(), "feature/foo" ); @@ -859,8 +823,7 @@ mod tests { // Previously, shell escaping was applied BEFORE filters, corrupting the result // when values contained shell-special characters (quotes, backslashes). vars.insert("branch", "user's/feature"); - let result = - expand_template("{{ branch | sanitize }}", &vars, true, &test.repo, "test").unwrap(); + let result = expand_template("{{ branch | sanitize }}", &vars, true, &map, "test").unwrap(); // sanitize replaces / with -, producing "user's-feature" // shell_escape wraps it: 'user'\''s-feature' (valid shell for user's-feature) assert_eq!(result, "'user'\\''s-feature'", "sanitize + shell escape"); @@ -869,7 +832,7 @@ mod tests { // sanitize would replace the / and \ in the already-escaped value. // Shell escaping without filter: raw value with special chars - let result = expand_template("{{ branch }}", &vars, true, &test.repo, "test").unwrap(); + let result = expand_template("{{ branch }}", &vars, true, &map, "test").unwrap(); // shell_escape wraps: 'user'\''s/feature' (valid shell for user's/feature) assert_eq!( result, "'user'\\''s/feature'", @@ -878,66 +841,48 @@ mod tests { // Shell-escape formatter handles none values (renders as empty string) let result = - expand_template("prefix-{{ none }}-suffix", &vars, true, &test.repo, "test").unwrap(); + expand_template("prefix-{{ none }}-suffix", &vars, true, &map, "test").unwrap(); assert_eq!(result, "prefix--suffix", "none renders as empty"); } #[test] fn test_expand_template_sanitize_db_filter() { - let test = test_repo(); + let map = empty_map(); let mut vars = HashMap::new(); // Basic transformation (with hash suffix) vars.insert("branch", "feature/auth-oauth2"); - let result = expand_template( - "{{ branch | sanitize_db }}", - &vars, - false, - &test.repo, - "test", - ) - .unwrap(); + let result = + expand_template("{{ branch | sanitize_db }}", &vars, false, &map, "test").unwrap(); assert!(result.starts_with("feature_auth_oauth2_"), "got: {result}"); // Leading digit gets underscore prefix vars.insert("branch", "123-bug-fix"); - let result = expand_template( - "{{ branch | sanitize_db }}", - &vars, - false, - &test.repo, - "test", - ) - .unwrap(); + let result = + expand_template("{{ branch | sanitize_db }}", &vars, false, &map, "test").unwrap(); assert!(result.starts_with("_123_bug_fix_"), "got: {result}"); // Uppercase conversion vars.insert("branch", "UPPERCASE.Branch"); - let result = expand_template( - "{{ branch | sanitize_db }}", - &vars, - false, - &test.repo, - "test", - ) - .unwrap(); + let result = + expand_template("{{ branch | sanitize_db }}", &vars, false, &map, "test").unwrap(); assert!(result.starts_with("uppercase_branch_"), "got: {result}"); // Raw branch is unchanged vars.insert("branch", "feature/foo"); assert_eq!( - expand_template("{{ branch }}", &vars, false, &test.repo, "test").unwrap(), + expand_template("{{ branch }}", &vars, false, &map, "test").unwrap(), "feature/foo" ); } #[test] fn test_expand_template_trailing_newline() { - let test = test_repo(); + let map = empty_map(); let mut vars = HashMap::new(); vars.insert("cmd", "echo hello"); assert!( - expand_template("{{ cmd }}\n", &vars, true, &test.repo, "test") + expand_template("{{ cmd }}\n", &vars, true, &map, "test") .unwrap() .ends_with('\n') ); @@ -955,14 +900,14 @@ mod tests { #[test] fn test_hash_port_filter() { - let test = test_repo(); + let map = empty_map(); let mut vars = HashMap::new(); vars.insert("branch", "feature-foo"); vars.insert("repo", "myrepo"); // Filter produces a number in range let result = - expand_template("{{ branch | hash_port }}", &vars, false, &test.repo, "test").unwrap(); + expand_template("{{ branch | hash_port }}", &vars, false, &map, "test").unwrap(); let port: u16 = result.parse().expect("should be a number"); assert!((10000..20000).contains(&port)); @@ -971,7 +916,7 @@ mod tests { "{{ (repo ~ '-' ~ branch) | hash_port }}", &vars, false, - &test.repo, + &map, "test", ) .unwrap(); @@ -980,7 +925,7 @@ mod tests { "{{ (repo ~ '-' ~ branch) | hash_port }}", &vars, false, - &test.repo, + &map, "test", ) .unwrap(); diff --git a/src/config/mod.rs b/src/config/mod.rs index d56803d07..1869b6583 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -85,8 +85,8 @@ pub use deprecation::normalize_template_vars; pub use deprecation::write_migration_file; pub use deprecation::{DEPRECATED_SECTION_KEYS, key_belongs_in, warn_unknown_fields}; pub use expansion::{ - DEPRECATED_TEMPLATE_VARS, TEMPLATE_VARS, TemplateExpandError, expand_template, - redact_credentials, sanitize_branch_name, sanitize_db, short_hash, + DEPRECATED_TEMPLATE_VARS, TEMPLATE_VARS, expand_template, redact_credentials, + sanitize_branch_name, sanitize_db, short_hash, }; pub use hooks::HooksConfig; pub use project::{ @@ -102,29 +102,9 @@ pub use user::{ #[cfg(test)] mod tests { use super::*; - use crate::git::Repository; - - /// Test fixture that creates a real temporary git repository. - struct TestRepo { - _dir: tempfile::TempDir, - repo: Repository, - } - - impl TestRepo { - fn new() -> Self { - let dir = tempfile::tempdir().unwrap(); - std::process::Command::new("git") - .args(["init"]) - .current_dir(dir.path()) - .output() - .unwrap(); - let repo = Repository::at(dir.path()).unwrap(); - Self { _dir: dir, repo } - } - } - fn test_repo() -> TestRepo { - TestRepo::new() + fn empty_worktree_map() -> std::collections::HashMap { + std::collections::HashMap::new() } #[test] @@ -168,7 +148,7 @@ mod tests { #[test] fn test_format_worktree_path() { - let test = test_repo(); + let wt_map = empty_worktree_map(); let config = UserConfig { configs: OverridableConfig { worktree_path: Some("{{ main_worktree }}.{{ branch }}".to_string()), @@ -178,7 +158,7 @@ mod tests { }; assert_eq!( config - .format_path("myproject", "feature-x", &test.repo, None) + .format_path("myproject", "feature-x", "", &wt_map, None) .unwrap(), "myproject.feature-x" ); @@ -186,7 +166,7 @@ mod tests { #[test] fn test_format_worktree_path_custom_template() { - let test = test_repo(); + let wt_map = empty_worktree_map(); let config = UserConfig { configs: OverridableConfig { worktree_path: Some("{{ main_worktree }}-{{ branch }}".to_string()), @@ -196,7 +176,7 @@ mod tests { }; assert_eq!( config - .format_path("myproject", "feature-x", &test.repo, None) + .format_path("myproject", "feature-x", "", &wt_map, None) .unwrap(), "myproject-feature-x" ); @@ -204,7 +184,7 @@ mod tests { #[test] fn test_format_worktree_path_only_branch() { - let test = test_repo(); + let wt_map = empty_worktree_map(); let config = UserConfig { configs: OverridableConfig { worktree_path: Some(".worktrees/{{ main_worktree }}/{{ branch }}".to_string()), @@ -214,7 +194,7 @@ mod tests { }; assert_eq!( config - .format_path("myproject", "feature-x", &test.repo, None) + .format_path("myproject", "feature-x", "", &wt_map, None) .unwrap(), ".worktrees/myproject/feature-x" ); @@ -222,7 +202,7 @@ mod tests { #[test] fn test_format_worktree_path_with_slashes() { - let test = test_repo(); + let wt_map = empty_worktree_map(); // Use {{ branch | sanitize }} to replace slashes with dashes let config = UserConfig { configs: OverridableConfig { @@ -233,7 +213,7 @@ mod tests { }; assert_eq!( config - .format_path("myproject", "feature/foo", &test.repo, None) + .format_path("myproject", "feature/foo", "", &wt_map, None) .unwrap(), "myproject.feature-foo" ); @@ -241,7 +221,7 @@ mod tests { #[test] fn test_format_worktree_path_with_multiple_slashes() { - let test = test_repo(); + let wt_map = empty_worktree_map(); let config = UserConfig { configs: OverridableConfig { worktree_path: Some( @@ -253,7 +233,7 @@ mod tests { }; assert_eq!( config - .format_path("myproject", "feature/sub/task", &test.repo, None) + .format_path("myproject", "feature/sub/task", "", &wt_map, None) .unwrap(), ".worktrees/myproject/feature-sub-task" ); @@ -261,7 +241,7 @@ mod tests { #[test] fn test_format_worktree_path_with_backslashes() { - let test = test_repo(); + let wt_map = empty_worktree_map(); // Windows-style path separators should also be sanitized let config = UserConfig { configs: OverridableConfig { @@ -274,7 +254,7 @@ mod tests { }; assert_eq!( config - .format_path("myproject", "feature\\foo", &test.repo, None) + .format_path("myproject", "feature\\foo", "", &wt_map, None) .unwrap(), ".worktrees/myproject/feature-foo" ); @@ -282,7 +262,7 @@ mod tests { #[test] fn test_format_worktree_path_raw_branch() { - let test = test_repo(); + let wt_map = empty_worktree_map(); // {{ branch }} without filter gives raw branch name let config = UserConfig { configs: OverridableConfig { @@ -293,7 +273,7 @@ mod tests { }; assert_eq!( config - .format_path("myproject", "feature/foo", &test.repo, None) + .format_path("myproject", "feature/foo", "", &wt_map, None) .unwrap(), "myproject.feature/foo" ); @@ -473,7 +453,7 @@ task2 = "echo 'Task 2 running' > task2.txt" fn test_expand_template_basic() { use std::collections::HashMap; - let test = test_repo(); + let wt_map = empty_worktree_map(); let mut vars = HashMap::new(); vars.insert("main_worktree", "myrepo"); vars.insert("branch", "feature-x"); @@ -481,7 +461,7 @@ task2 = "echo 'Task 2 running' > task2.txt" "../{{ main_worktree }}.{{ branch }}", &vars, true, - &test.repo, + &wt_map, "test", ) .unwrap(); @@ -492,7 +472,7 @@ task2 = "echo 'Task 2 running' > task2.txt" fn test_expand_template_sanitizes_branch() { use std::collections::HashMap; - let test = test_repo(); + let wt_map = empty_worktree_map(); // Use {{ branch | sanitize }} filter for filesystem-safe paths // shell_escape=false to test filter in isolation (shell escaping tested separately) @@ -503,7 +483,7 @@ task2 = "echo 'Task 2 running' > task2.txt" "{{ main_worktree }}/{{ branch | sanitize }}", &vars, false, - &test.repo, + &wt_map, "test", ) .unwrap(); @@ -516,7 +496,7 @@ task2 = "echo 'Task 2 running' > task2.txt" ".worktrees/{{ main_worktree }}/{{ branch | sanitize }}", &vars, false, - &test.repo, + &wt_map, "test", ) .unwrap(); @@ -527,6 +507,7 @@ task2 = "echo 'Task 2 running' > task2.txt" fn test_expand_template_with_extra_vars() { use std::collections::HashMap; + let wt_map = empty_worktree_map(); let mut vars = HashMap::new(); vars.insert("worktree", "/path/to/worktree"); vars.insert("repo_root", "/path/to/repo"); @@ -535,7 +516,7 @@ task2 = "echo 'Task 2 running' > task2.txt" "{{ repo_root }}/target -> {{ worktree }}/target", &vars, true, - &test_repo().repo, + &wt_map, "test", ) .unwrap(); diff --git a/src/config/project.rs b/src/config/project.rs index 4d47c8b0e..d9085c0a9 100644 --- a/src/config/project.rs +++ b/src/config/project.rs @@ -111,10 +111,27 @@ pub struct ProjectConfig { } impl ProjectConfig { - /// Load project configuration from .config/wt.toml in the repository root + /// Load project configuration from a root path without git-specific features. /// - /// Set `write_hints` to true for normal usage. Set to false during completion - /// to avoid side effects (writing git config hints). + /// Skips deprecation migration and unknown-field warnings (those require + /// git-specific context like main-worktree detection). Used by jj workspace + /// and other non-git contexts. + pub fn load_from_root(root: &std::path::Path) -> Result, ConfigError> { + let config_path = root.join(".config").join("wt.toml"); + + if !config_path.exists() { + return Ok(None); + } + + let contents = std::fs::read_to_string(&config_path) + .map_err(|e| ConfigError::Message(format!("Failed to read config file: {}", e)))?; + + let config: ProjectConfig = toml::from_str(&contents) + .map_err(|e| ConfigError::Message(format!("Failed to parse TOML: {}", e)))?; + + Ok(Some(config)) + } + pub fn load( repo: &crate::git::Repository, write_hints: bool, @@ -458,4 +475,62 @@ post-create = "npm install" let cloned = config.clone(); assert_eq!(config, cloned); } + + #[test] + fn test_ci_platform() { + // No CI config + let config: ProjectConfig = toml::from_str("").unwrap(); + assert_eq!(config.ci_platform(), None); + + // CI config with platform + let config: ProjectConfig = toml::from_str("[ci]\nplatform = \"github\"").unwrap(); + assert_eq!(config.ci_platform(), Some("github")); + } + + #[test] + fn test_load_from_root_missing() { + let temp = tempfile::tempdir().unwrap(); + let result = ProjectConfig::load_from_root(temp.path()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_load_from_root_valid() { + let temp = tempfile::tempdir().unwrap(); + let config_dir = temp.path().join(".config"); + std::fs::create_dir(&config_dir).unwrap(); + std::fs::write(config_dir.join("wt.toml"), "[ci]\nplatform = \"gitlab\"\n").unwrap(); + let config = ProjectConfig::load_from_root(temp.path()).unwrap().unwrap(); + assert_eq!(config.ci_platform(), Some("gitlab")); + } + + #[test] + fn test_list_config_is_configured() { + assert!(!ProjectListConfig::default().is_configured()); + let with_url = ProjectListConfig { + url: Some("http://localhost:3000".into()), + }; + assert!(with_url.is_configured()); + } + + #[test] + fn test_load_from_root_invalid_toml() { + let temp = tempfile::tempdir().unwrap(); + let config_dir = temp.path().join(".config"); + std::fs::create_dir(&config_dir).unwrap(); + std::fs::write(config_dir.join("wt.toml"), "not valid [[ toml").unwrap(); + let err = ProjectConfig::load_from_root(temp.path()).unwrap_err(); + assert!(err.to_string().contains("Failed to parse TOML")); + } + + #[test] + fn test_load_from_root_unreadable() { + let temp = tempfile::tempdir().unwrap(); + let config_dir = temp.path().join(".config"); + std::fs::create_dir(&config_dir).unwrap(); + // Create a directory where the file should be (causes read error) + std::fs::create_dir(config_dir.join("wt.toml")).unwrap(); + let err = ProjectConfig::load_from_root(temp.path()).unwrap_err(); + assert!(err.to_string().contains("Failed to read config file")); + } } diff --git a/src/config/test.rs b/src/config/test.rs index c22ecc532..226fd9ef7 100644 --- a/src/config/test.rs +++ b/src/config/test.rs @@ -4,30 +4,11 @@ //! edge cases in template variable substitution. use super::expand_template; -use crate::git::Repository; use std::collections::HashMap; +use std::path::PathBuf; -/// Test fixture that creates a real temporary git repository. -struct TestRepo { - _dir: tempfile::TempDir, - repo: Repository, -} - -impl TestRepo { - fn new() -> Self { - let dir = tempfile::tempdir().unwrap(); - std::process::Command::new("git") - .args(["init"]) - .current_dir(dir.path()) - .output() - .unwrap(); - let repo = Repository::at(dir.path()).unwrap(); - Self { _dir: dir, repo } - } -} - -fn test_repo() -> TestRepo { - TestRepo::new() +fn empty_map() -> HashMap { + HashMap::new() } /// Helper to build vars with common fields @@ -41,13 +22,13 @@ fn vars_with_branch(branch: &str) -> HashMap<&str, &str> { #[test] fn test_expand_template_normal() { - let test = test_repo(); + let map = empty_map(); let vars = vars_with_branch("feature"); let result = expand_template( "echo {{ branch }} {{ main_worktree }}", &vars, true, - &test.repo, + &map, "test", ) .unwrap(); @@ -56,26 +37,20 @@ fn test_expand_template_normal() { #[test] fn test_expand_template_branch_with_slashes() { - let test = test_repo(); + let map = empty_map(); // Use {{ branch | sanitize }} to replace slashes with dashes let vars = vars_with_branch("feature/nested/branch"); - let result = expand_template( - "echo {{ branch | sanitize }}", - &vars, - true, - &test.repo, - "test", - ) - .unwrap(); + let result = + expand_template("echo {{ branch | sanitize }}", &vars, true, &map, "test").unwrap(); assert_eq!(result, "echo feature-nested-branch"); } #[test] fn test_expand_template_branch_raw_with_slashes() { - let test = test_repo(); + let map = empty_map(); // Raw branch preserves slashes let vars = vars_with_branch("feature/nested/branch"); - let result = expand_template("echo {{ branch }}", &vars, true, &test.repo, "test").unwrap(); + let result = expand_template("echo {{ branch }}", &vars, true, &map, "test").unwrap(); assert_eq!(result, "echo feature/nested/branch"); } @@ -83,13 +58,13 @@ fn test_expand_template_branch_raw_with_slashes() { #[test] #[cfg(unix)] fn test_expand_template_branch_escaping() { - let test = test_repo(); + let map = empty_map(); let expand = |input| { expand_template( "echo {{ branch }}", &vars_with_branch(input), true, - &test.repo, + &map, "test", ) .unwrap() @@ -103,22 +78,22 @@ fn test_expand_template_branch_escaping() { #[test] #[cfg(unix)] fn snapshot_expand_template_branch_with_quotes() { - let test = test_repo(); + let map = empty_map(); let vars = vars_with_branch("feature'test"); - let result = expand_template("echo '{{ branch }}'", &vars, true, &test.repo, "test").unwrap(); + let result = expand_template("echo '{{ branch }}'", &vars, true, &map, "test").unwrap(); insta::assert_snapshot!(result, @r"echo ''feature'\''test''"); } #[test] #[cfg(unix)] fn test_expand_template_extra_vars_path_escaping() { - let test = test_repo(); + let map = empty_map(); let expand = |path| { expand_template( "cd {{ worktree }}", &HashMap::from([("worktree", path)]), true, - &test.repo, + &map, "test", ) .unwrap() @@ -134,49 +109,43 @@ fn test_expand_template_extra_vars_path_escaping() { #[test] #[cfg(unix)] fn snapshot_expand_template_extra_vars_with_command_substitution() { - let test = test_repo(); + let map = empty_map(); let mut extras = HashMap::new(); extras.insert("target", "main; rm -rf /"); - let result = - expand_template("git merge {{ target }}", &extras, true, &test.repo, "test").unwrap(); + let result = expand_template("git merge {{ target }}", &extras, true, &map, "test").unwrap(); insta::assert_snapshot!(result, @"git merge 'main; rm -rf /'"); } #[test] fn test_expand_template_variable_override() { - let test = test_repo(); + let map = empty_map(); // Variables in the hashmap take precedence let mut vars = HashMap::new(); vars.insert("branch", "overridden"); - let result = expand_template("echo {{ branch }}", &vars, true, &test.repo, "test").unwrap(); + let result = expand_template("echo {{ branch }}", &vars, true, &map, "test").unwrap(); assert_eq!(result, "echo overridden"); } #[test] fn test_expand_template_missing_variable() { - let test = test_repo(); + let map = empty_map(); // Undefined variables error in SemiStrict mode (catches typos) let vars: HashMap<&str, &str> = HashMap::new(); - let result = expand_template("echo {{ undefined }}", &vars, true, &test.repo, "test"); + let result = expand_template("echo {{ undefined }}", &vars, true, &map, "test"); assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - err.message.contains("undefined value"), - "got: {}", - err.message - ); + assert!(result.unwrap_err().message.contains("undefined")); } #[test] #[cfg(unix)] fn test_expand_template_empty_branch() { - let test = test_repo(); + let map = empty_map(); let mut vars = HashMap::new(); vars.insert("branch", ""); - let result = expand_template("echo {{ branch }}", &vars, true, &test.repo, "test").unwrap(); + let result = expand_template("echo {{ branch }}", &vars, true, &map, "test").unwrap(); // Empty string is shell-escaped to '' assert_eq!(result, "echo ''"); @@ -185,10 +154,10 @@ fn test_expand_template_empty_branch() { #[test] #[cfg(unix)] fn test_expand_template_unicode_in_branch() { - let test = test_repo(); + let map = empty_map(); // Unicode characters in branch name are shell-escaped let vars = vars_with_branch("feature-\u{1F680}"); - let result = expand_template("echo {{ branch }}", &vars, true, &test.repo, "test").unwrap(); + let result = expand_template("echo {{ branch }}", &vars, true, &map, "test").unwrap(); // Unicode is preserved but quoted for shell safety assert_eq!(result, "echo 'feature-\u{1F680}'"); @@ -196,18 +165,12 @@ fn test_expand_template_unicode_in_branch() { #[test] fn test_expand_template_backslash_in_branch() { - let test = test_repo(); + let map = empty_map(); // Use {{ branch | sanitize }} to replace backslashes with dashes // Note: shell_escape=false to test sanitize filter in isolation let vars = vars_with_branch("feature\\branch"); - let result = expand_template( - "path/{{ branch | sanitize }}", - &vars, - false, - &test.repo, - "test", - ) - .unwrap(); + let result = + expand_template("path/{{ branch | sanitize }}", &vars, false, &map, "test").unwrap(); // Backslashes are replaced with dashes by sanitize filter assert_eq!(result, "path/feature-branch"); @@ -215,7 +178,7 @@ fn test_expand_template_backslash_in_branch() { #[test] fn test_expand_template_multiple_replacements() { - let test = test_repo(); + let map = empty_map(); let mut vars = vars_with_branch("feature"); vars.insert("worktree", "/path/to/wt"); vars.insert("target", "develop"); @@ -224,7 +187,7 @@ fn test_expand_template_multiple_replacements() { "cd {{ worktree }} && git merge {{ target }} from {{ branch }}", &vars, true, - &test.repo, + &map, "test", ) .unwrap(); @@ -234,27 +197,21 @@ fn test_expand_template_multiple_replacements() { #[test] fn test_expand_template_curly_braces_without_variables() { - let test = test_repo(); + let map = empty_map(); // Just curly braces, not variables let vars: HashMap<&str, &str> = HashMap::new(); - let result = expand_template("echo {}", &vars, true, &test.repo, "test").unwrap(); + let result = expand_template("echo {}", &vars, true, &map, "test").unwrap(); assert_eq!(result, "echo {}"); } #[test] fn test_expand_template_nested_curly_braces() { - let test = test_repo(); + let map = empty_map(); // Nested braces - minijinja doesn't support {{{ syntax, use literal curly braces instead let vars = vars_with_branch("main"); - let result = expand_template( - "echo {{ '{' ~ branch ~ '}' }}", - &vars, - true, - &test.repo, - "test", - ) - .unwrap(); + let result = + expand_template("echo {{ '{' ~ branch ~ '}' }}", &vars, true, &map, "test").unwrap(); // Renders as '{main}' — curly braces are shell metacharacters (brace expansion) // so the formatter correctly escapes the concatenated result @@ -285,11 +242,11 @@ fn snapshot_shell_escaping_special_chars() { ("brackets", "feature[0-9]"), ]; - let test = test_repo(); + let map = empty_map(); let mut results = Vec::new(); for (name, branch) in test_cases { let vars = vars_with_branch(branch); - let result = expand_template("echo {{ branch }}", &vars, true, &test.repo, "test").unwrap(); + let result = expand_template("echo {{ branch }}", &vars, true, &map, "test").unwrap(); results.push((name, branch, result)); } @@ -307,11 +264,11 @@ fn snapshot_shell_escaping_quotes() { ("multiple_single", "don't'panic"), ]; - let test = test_repo(); + let map = empty_map(); let mut results = Vec::new(); for (name, branch) in test_cases { let vars = vars_with_branch(branch); - let result = expand_template("echo {{ branch }}", &vars, true, &test.repo, "test").unwrap(); + let result = expand_template("echo {{ branch }}", &vars, true, &map, "test").unwrap(); results.push((name, branch, result)); } @@ -330,7 +287,7 @@ fn snapshot_shell_escaping_paths() { ("unicode", "/path/to/\u{1F680}/worktree"), ]; - let test = test_repo(); + let map = empty_map(); let mut results = Vec::new(); for (name, path) in test_cases { let mut vars = vars_with_branch("main"); @@ -339,7 +296,7 @@ fn snapshot_shell_escaping_paths() { "cd {{ worktree }} && echo {{ branch }}", &vars, true, - &test.repo, + &map, "test", ) .unwrap(); @@ -372,7 +329,7 @@ fn snapshot_complex_templates() { ), ]; - let test = test_repo(); + let map = empty_map(); let mut results = Vec::new(); for (name, template, branch) in test_cases { let mut vars = HashMap::new(); @@ -380,7 +337,7 @@ fn snapshot_complex_templates() { vars.insert("main_worktree", "/repo/path"); vars.insert("worktree", "/path with spaces/wt"); vars.insert("target", "main; rm -rf /"); - let result = expand_template(template, &vars, true, &test.repo, "test").unwrap(); + let result = expand_template(template, &vars, true, &map, "test").unwrap(); results.push((name, template, branch, result)); } @@ -391,7 +348,7 @@ fn snapshot_complex_templates() { #[test] fn test_expand_template_literal_normal() { - let test = test_repo(); + let map = empty_map(); let mut vars = HashMap::new(); vars.insert("main_worktree", "myrepo"); vars.insert("branch", "feature"); @@ -399,7 +356,7 @@ fn test_expand_template_literal_normal() { "{{ main_worktree }}.{{ branch }}", &vars, false, - &test.repo, + &map, "test", ) .unwrap(); @@ -408,7 +365,7 @@ fn test_expand_template_literal_normal() { #[test] fn test_expand_template_literal_unicode_no_escaping() { - let test = test_repo(); + let map = empty_map(); // Unicode should NOT be shell-escaped in filesystem paths let mut vars = HashMap::new(); vars.insert("main_worktree", "myrepo"); @@ -417,7 +374,7 @@ fn test_expand_template_literal_unicode_no_escaping() { "{{ main_worktree }}.{{ branch }}", &vars, false, - &test.repo, + &map, "test", ) .unwrap(); @@ -431,7 +388,7 @@ fn test_expand_template_literal_unicode_no_escaping() { #[test] fn test_expand_template_literal_spaces_no_escaping() { - let test = test_repo(); + let map = empty_map(); // Spaces should NOT be shell-escaped (filesystem paths can have spaces) let mut vars = HashMap::new(); vars.insert("main_worktree", "my repo"); @@ -440,7 +397,7 @@ fn test_expand_template_literal_spaces_no_escaping() { "{{ main_worktree }}.{{ branch }}", &vars, false, - &test.repo, + &map, "test", ) .unwrap(); @@ -454,7 +411,7 @@ fn test_expand_template_literal_spaces_no_escaping() { #[test] fn test_expand_template_literal_sanitizes_slashes() { - let test = test_repo(); + let map = empty_map(); // Use {{ branch | sanitize }} to replace slashes with dashes let mut vars = HashMap::new(); vars.insert("main_worktree", "myrepo"); @@ -463,7 +420,7 @@ fn test_expand_template_literal_sanitizes_slashes() { "{{ main_worktree }}.{{ branch | sanitize }}", &vars, false, - &test.repo, + &map, "test", ) .unwrap(); @@ -473,15 +430,15 @@ fn test_expand_template_literal_sanitizes_slashes() { #[test] #[cfg(unix)] fn test_expand_template_literal_vs_escaped_unicode() { - let test = test_repo(); + let map = empty_map(); // Demonstrate the difference between literal and escaped expansion let mut vars = HashMap::new(); vars.insert("main_worktree", "myrepo"); vars.insert("branch", "test-\u{2282}"); let template = "{{ main_worktree }}.{{ branch }}"; - let literal_result = expand_template(template, &vars, false, &test.repo, "test").unwrap(); - let escaped_result = expand_template(template, &vars, true, &test.repo, "test").unwrap(); + let literal_result = expand_template(template, &vars, false, &map, "test").unwrap(); + let escaped_result = expand_template(template, &vars, true, &map, "test").unwrap(); // Literal has no quotes assert_eq!(literal_result, "myrepo.test-\u{2282}"); diff --git a/src/config/user/accessors.rs b/src/config/user/accessors.rs index 05827d5eb..32eb31020 100644 --- a/src/config/user/accessors.rs +++ b/src/config/user/accessors.rs @@ -4,9 +4,10 @@ //! project by merging global settings with project-specific overrides. use std::collections::HashMap; +use std::path::PathBuf; use crate::config::HooksConfig; -use crate::config::expansion::{TemplateExpandError, expand_template}; +use crate::config::expansion::expand_template; use super::UserConfig; use super::merge::{Merge, merge_optional}; @@ -153,28 +154,30 @@ impl UserConfig { /// # Arguments /// * `main_worktree` - Main worktree directory name (replaces {{ main_worktree }} in template) /// * `branch` - Branch name (replaces {{ branch }} in template; use `{{ branch | sanitize }}` for paths) - /// * `repo` - Repository for template function access + /// * `repo_path` - Repository root path (replaces {{ repo_path }} in template) + /// * `worktree_map` - Branch-to-path lookup for `worktree_path_of_branch()` function /// * `project` - Optional project identifier (e.g., "github.com/user/repo") to look up /// project-specific worktree-path template pub fn format_path( &self, main_worktree: &str, branch: &str, - repo: &crate::git::Repository, + repo_path: &str, + worktree_map: &HashMap, project: Option<&str>, - ) -> Result { + ) -> Result { let template = match project { Some(p) => self.worktree_path_for_project(p), None => self.worktree_path(), }; // Use native path format (not POSIX) since this is used for filesystem operations - let repo_path = repo.repo_path().to_string_lossy().to_string(); let mut vars = HashMap::new(); vars.insert("main_worktree", main_worktree); vars.insert("repo", main_worktree); vars.insert("branch", branch); - vars.insert("repo_path", repo_path.as_str()); - expand_template(&template, &vars, false, repo, "worktree-path") + vars.insert("repo_path", repo_path); + expand_template(&template, &vars, false, worktree_map, "worktree-path") .map(|p| shellexpand::tilde(&p).into_owned()) + .map_err(|e| e.to_string()) } } diff --git a/src/config/user/tests.rs b/src/config/user/tests.rs index 8f25b616b..d2c0f278d 100644 --- a/src/config/user/tests.rs +++ b/src/config/user/tests.rs @@ -1,7 +1,14 @@ +use std::collections::HashMap; +use std::path::PathBuf; + use super::*; use crate::config::HooksConfig; use crate::git::Repository; +fn empty_worktree_map() -> HashMap { + HashMap::new() +} + /// Test fixture that creates a real temporary git repository. struct TestRepo { _dir: tempfile::TempDir, @@ -236,7 +243,7 @@ fn test_worktree_path_for_project_falls_back_to_default() { #[test] fn test_format_path_with_project_override() { - let test = test_repo(); + let wt_map = empty_worktree_map(); let mut config = UserConfig { configs: OverridableConfig { worktree_path: Some("../{{ repo }}.{{ branch | sanitize }}".to_string()), @@ -261,7 +268,8 @@ fn test_format_path_with_project_override() { .format_path( "myrepo", "feature/branch", - &test.repo, + "", + &wt_map, Some("github.com/user/repo"), ) .unwrap(); @@ -269,7 +277,7 @@ fn test_format_path_with_project_override() { // Without project identifier, should use global template let path = config - .format_path("myrepo", "feature/branch", &test.repo, None) + .format_path("myrepo", "feature/branch", "", &wt_map, None) .unwrap(); assert_eq!(path, "../myrepo.feature-branch"); } @@ -318,9 +326,11 @@ fn test_worktrunk_config_default() { #[test] fn test_worktrunk_config_format_path() { let test = test_repo(); + let wt_map = empty_worktree_map(); + let repo_path_str = test.repo.repo_path().to_string_lossy().to_string(); let config = UserConfig::default(); let path = config - .format_path("myrepo", "feature/branch", &test.repo, None) + .format_path("myrepo", "feature/branch", &repo_path_str, &wt_map, None) .unwrap(); // Default path is now absolute: {{ repo_path }}/../{{ repo }}.{{ branch | sanitize }} // The template uses forward slashes which work on all platforms @@ -335,16 +345,15 @@ fn test_worktrunk_config_format_path() { "Expected path containing parent navigation, got: {path}" ); // The path should start with the repo path (absolute) - let repo_path = test.repo.repo_path().to_string_lossy(); assert!( - path.starts_with(repo_path.as_ref()), - "Expected path starting with repo path '{repo_path}', got: {path}" + path.starts_with(&repo_path_str), + "Expected path starting with repo path '{repo_path_str}', got: {path}" ); } #[test] fn test_worktrunk_config_format_path_custom_template() { - let test = test_repo(); + let wt_map = empty_worktree_map(); let config = UserConfig { configs: OverridableConfig { worktree_path: Some(".worktrees/{{ branch }}".to_string()), @@ -353,7 +362,7 @@ fn test_worktrunk_config_format_path_custom_template() { ..Default::default() }; let path = config - .format_path("myrepo", "feature", &test.repo, None) + .format_path("myrepo", "feature", "", &wt_map, None) .unwrap(); assert_eq!(path, ".worktrees/feature"); } @@ -361,6 +370,8 @@ fn test_worktrunk_config_format_path_custom_template() { #[test] fn test_worktrunk_config_format_path_repo_path_variable() { let test = test_repo(); + let wt_map = empty_worktree_map(); + let repo_path_str = test.repo.repo_path().to_string_lossy().to_string(); let config = UserConfig { configs: OverridableConfig { // Use forward slashes in template (works on all platforms) @@ -370,7 +381,7 @@ fn test_worktrunk_config_format_path_repo_path_variable() { ..Default::default() }; let path = config - .format_path("myrepo", "feature/branch", &test.repo, None) + .format_path("myrepo", "feature/branch", &repo_path_str, &wt_map, None) .unwrap(); // Path should contain the expected components assert!( @@ -378,10 +389,9 @@ fn test_worktrunk_config_format_path_repo_path_variable() { "Expected path containing 'worktrees' and 'feature-branch', got: {path}" ); // The path should start with the repo path - let repo_path = test.repo.repo_path().to_string_lossy(); assert!( - path.starts_with(repo_path.as_ref()), - "Expected path starting with repo path '{repo_path}', got: {path}" + path.starts_with(&repo_path_str), + "Expected path starting with repo path '{repo_path_str}', got: {path}" ); // The path should be absolute since repo_path is absolute assert!( @@ -392,7 +402,7 @@ fn test_worktrunk_config_format_path_repo_path_variable() { #[test] fn test_worktrunk_config_format_path_tilde_expansion() { - let test = test_repo(); + let wt_map = empty_worktree_map(); let config = UserConfig { configs: OverridableConfig { worktree_path: Some("~/worktrees/{{ repo }}/{{ branch | sanitize }}".to_string()), @@ -401,7 +411,7 @@ fn test_worktrunk_config_format_path_tilde_expansion() { ..Default::default() }; let path = config - .format_path("myrepo", "feature/branch", &test.repo, None) + .format_path("myrepo", "feature/branch", "", &wt_map, None) .unwrap(); // Tilde should be expanded to home directory assert!( diff --git a/src/git/diff.rs b/src/git/diff.rs index 5e9eaab9d..d3e52adcd 100644 --- a/src/git/diff.rs +++ b/src/git/diff.rs @@ -3,12 +3,8 @@ use ansi_str::AnsiStr; use color_print::cformat; -/// Line-level diff totals (added/deleted counts) used across git operations. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)] -pub struct LineDiff { - pub added: usize, - pub deleted: usize, -} +// Re-export LineDiff from its canonical location for backward compatibility. +pub use crate::workspace::types::LineDiff; /// Parse a git numstat line and extract insertions/deletions. /// @@ -51,25 +47,6 @@ impl LineDiff { Ok(totals) } - - pub fn is_empty(&self) -> bool { - self.added == 0 && self.deleted == 0 - } -} - -impl From for (usize, usize) { - fn from(diff: LineDiff) -> Self { - (diff.added, diff.deleted) - } -} - -impl From<(usize, usize)> for LineDiff { - fn from(value: (usize, usize)) -> Self { - Self { - added: value.0, - deleted: value.1, - } - } } /// Diff statistics (files changed, insertions, deletions). diff --git a/src/git/mod.rs b/src/git/mod.rs index 3d9c0ea35..17f7c12f4 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -53,85 +53,15 @@ pub use error::{ exit_code, }; pub use parse::{parse_porcelain_z, parse_untracked_files}; -pub use repository::{Branch, Repository, ResolvedWorktree, WorkingTree, set_base_path}; +pub(crate) use repository::path_to_logging_context; +pub use repository::{Branch, Repository, ResolvedWorktree, WorkingTree}; pub use url::GitRemoteUrl; pub use url::{parse_owner_repo, parse_remote_owner}; -/// Why branch content is considered integrated into the target branch. -/// -/// Used by both `wt list` (for status symbols) and `wt remove` (for messages). -/// Each variant corresponds to a specific integration check. In `wt list`, -/// three symbols represent these checks: -/// - `_` for [`SameCommit`](Self::SameCommit) with clean working tree (empty) -/// - `–` for [`SameCommit`](Self::SameCommit) with dirty working tree -/// - `⊂` for all others (content integrated via different history) -/// -/// The checks are ordered by cost (cheapest first): -/// 1. [`SameCommit`](Self::SameCommit) - commit SHA comparison (~1ms) -/// 2. [`Ancestor`](Self::Ancestor) - ancestor check (~1ms) -/// 3. [`NoAddedChanges`](Self::NoAddedChanges) - three-dot diff (~50-100ms) -/// 4. [`TreesMatch`](Self::TreesMatch) - tree SHA comparison (~100-300ms) -/// 5. [`MergeAddsNothing`](Self::MergeAddsNothing) - merge simulation (~500ms-2s) -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, strum::IntoStaticStr)] -#[serde(rename_all = "kebab-case")] -#[strum(serialize_all = "kebab-case")] -pub enum IntegrationReason { - /// Branch HEAD is literally the same commit as target. - /// - /// Used by `wt remove` to determine if branch is safely deletable. - /// In `wt list`, same-commit state is shown via `MainState::Empty` (`_`) or - /// `MainState::SameCommit` (`–`) depending on working tree cleanliness. - SameCommit, - - /// Branch HEAD is an ancestor of target (target has moved past this branch). - /// - /// Symbol in `wt list`: `⊂` - Ancestor, - - /// Three-dot diff (`main...branch`) shows no files. - /// The branch has no file changes beyond the merge-base. - /// - /// Symbol in `wt list`: `⊂` - NoAddedChanges, - - /// Branch tree SHA equals target tree SHA. - /// Commit history differs but file contents are identical. - /// - /// Symbol in `wt list`: `⊂` - TreesMatch, - - /// Simulated merge (`git merge-tree`) produces the same tree as target. - /// The branch has changes, but they're already in target via a different path. - /// - /// Symbol in `wt list`: `⊂` - MergeAddsNothing, -} - -impl IntegrationReason { - /// Human-readable description for use in messages (e.g., `wt remove` output). - /// - /// Returns a phrase that expects the target branch name to follow - /// (e.g., "same commit as" + "main" → "same commit as main"). - pub fn description(&self) -> &'static str { - match self { - Self::SameCommit => "same commit as", - Self::Ancestor => "ancestor of", - Self::NoAddedChanges => "no added changes on", - Self::TreesMatch => "tree matches", - Self::MergeAddsNothing => "all changes in", - } - } - /// Status symbol used in `wt list` for this integration reason. - /// - /// - `SameCommit` → `_` (matches `MainState::Empty`) - /// - Others → `⊂` (matches `MainState::Integrated`) - pub fn symbol(&self) -> &'static str { - match self { - Self::SameCommit => "_", - _ => "⊂", - } - } -} +// Re-export shared types from workspace::types for backward compatibility. +// These types are VCS-agnostic and defined in workspace::types, but historically +// lived here. All existing `use crate::git::LineDiff` imports continue working. +pub use crate::workspace::types::{IntegrationReason, path_dir_name}; /// Integration signals for checking if a branch is integrated into target. /// @@ -437,16 +367,6 @@ pub struct WorktreeInfo { pub prunable: Option, } -/// Extract the directory name from a path for display purposes. -/// -/// Returns the last component of the path as a string, or "(unknown)" if -/// the path has no filename or contains invalid UTF-8. -pub fn path_dir_name(path: &std::path::Path) -> &str { - path.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("(unknown)") -} - impl WorktreeInfo { /// Returns true if this worktree is prunable (directory deleted but git still tracks metadata). /// diff --git a/src/git/repository/config.rs b/src/git/repository/config.rs index 9aee9115c..9e563b290 100644 --- a/src/git/repository/config.rs +++ b/src/git/repository/config.rs @@ -1,9 +1,13 @@ -//! Git config, hints, marker, and default branch operations for Repository. +//! Git config, default branch, and project config operations for Repository. +//! +//! Markers, hints, and switch-previous are on the `Workspace` trait +//! (implemented in `workspace/git.rs`). use anyhow::Context; use color_print::cformat; use crate::config::ProjectConfig; +use crate::workspace::Workspace; use super::{DefaultBranchName, GitError, Repository}; @@ -22,100 +26,11 @@ impl Repository { Ok(()) } - /// Read a user-defined marker from `worktrunk.state..marker` in git config. - /// - /// Markers are stored as JSON: `{"marker": "text", "set_at": unix_timestamp}`. - pub fn branch_marker(&self, branch: &str) -> Option { - #[derive(serde::Deserialize)] - struct MarkerValue { - marker: Option, - } - - let config_key = format!("worktrunk.state.{branch}.marker"); - let raw = self - .run_command(&["config", "--get", &config_key]) - .ok() - .map(|output| output.trim().to_string()) - .filter(|s| !s.is_empty())?; - - let parsed: MarkerValue = serde_json::from_str(&raw).ok()?; - parsed.marker - } - - /// Read user-defined branch-keyed marker. + /// Read user-defined branch-keyed marker via the `Workspace` trait. pub fn user_marker(&self, branch: Option<&str>) -> Option { branch.and_then(|branch| self.branch_marker(branch)) } - /// Set the previous branch in worktrunk.history for `wt switch -` support. - /// - /// Stores the branch we're switching FROM, so `wt switch -` can return to it. - pub fn set_switch_previous(&self, previous: Option<&str>) -> anyhow::Result<()> { - if let Some(prev) = previous { - self.run_command(&["config", "worktrunk.history", prev])?; - } - // If previous is None (detached HEAD), don't update history - Ok(()) - } - - /// Get the previous branch from worktrunk.history for `wt switch -`. - /// - /// Returns the branch we came from, enabling ping-pong switching. - pub fn switch_previous(&self) -> Option { - self.run_command(&["config", "--get", "worktrunk.history"]) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - } - - /// Check if a hint has been shown in this repo. - /// - /// Hints are stored as `worktrunk.hints. = true`. - /// TODO: Could move to global git config if we accumulate more global hints. - pub fn has_shown_hint(&self, name: &str) -> bool { - self.run_command(&["config", "--get", &format!("worktrunk.hints.{name}")]) - .is_ok() - } - - /// Mark a hint as shown in this repo. - pub fn mark_hint_shown(&self, name: &str) -> anyhow::Result<()> { - self.run_command(&["config", &format!("worktrunk.hints.{name}"), "true"])?; - Ok(()) - } - - /// Clear a hint so it will show again. - pub fn clear_hint(&self, name: &str) -> anyhow::Result { - match self.run_command(&["config", "--unset", &format!("worktrunk.hints.{name}")]) { - Ok(_) => Ok(true), - Err(_) => Ok(false), // Key didn't exist - } - } - - /// List all hints that have been shown in this repo. - pub fn list_shown_hints(&self) -> Vec { - self.run_command(&["config", "--get-regexp", r"^worktrunk\.hints\."]) - .unwrap_or_default() - .lines() - .filter_map(|line| { - // Format: "worktrunk.hints.worktree-path true" - line.split_whitespace() - .next() - .and_then(|key| key.strip_prefix("worktrunk.hints.")) - .map(String::from) - }) - .collect() - } - - /// Clear all hints so they will show again. - pub fn clear_all_hints(&self) -> anyhow::Result { - let hints = self.list_shown_hints(); - let count = hints.len(); - for hint in hints { - self.clear_hint(&hint)?; - } - Ok(count) - } - // ========================================================================= // Default branch detection // ========================================================================= diff --git a/src/git/repository/diff.rs b/src/git/repository/diff.rs index 863778a61..c1ee836f4 100644 --- a/src/git/repository/diff.rs +++ b/src/git/repository/diff.rs @@ -308,6 +308,28 @@ impl Repository { LineDiff::from_numstat(&stdout) } + /// Whether HEAD is linearly rebased onto `target`. + /// + /// Returns `true` when merge-base equals the target SHA and there are no + /// merge commits between target and HEAD (i.e., history is linear). + pub fn is_rebased_onto(&self, target: &str) -> anyhow::Result { + let Some(merge_base) = self.merge_base("HEAD", target)? else { + return Ok(false); + }; + let target_sha = self.run_command(&["rev-parse", target])?.trim().to_string(); + + if merge_base != target_sha { + return Ok(false); + } + + let merge_commits = self + .run_command(&["rev-list", "--merges", &format!("{target}..HEAD")])? + .trim() + .to_string(); + + Ok(merge_commits.is_empty()) + } + /// Get formatted diff stats summary for display. /// /// Returns a vector of formatted strings like ["3 files", "+45", "-12"]. diff --git a/src/git/repository/mod.rs b/src/git/repository/mod.rs index fd2913b2f..f49de8d81 100644 --- a/src/git/repository/mod.rs +++ b/src/git/repository/mod.rs @@ -20,7 +20,7 @@ use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, LazyLock, Mutex, OnceLock}; +use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; @@ -54,7 +54,7 @@ mod worktrees; // Re-export WorkingTree and Branch pub use branch::Branch; pub use working_tree::WorkingTree; -pub(super) use working_tree::path_to_logging_context; +pub(crate) use working_tree::path_to_logging_context; /// Structured error from [`Repository::run_command_delayed_stream`]. /// @@ -156,25 +156,6 @@ pub enum ResolvedWorktree { }, } -/// Global base path for repository operations, set by -C flag. -static BASE_PATH: OnceLock = OnceLock::new(); - -/// Default base path when -C flag is not provided. -static DEFAULT_BASE_PATH: LazyLock = LazyLock::new(|| PathBuf::from(".")); - -/// Initialize the global base path for repository operations. -/// -/// This should be called once at program startup from main(). -/// If not called, defaults to "." (current directory). -pub fn set_base_path(path: PathBuf) { - BASE_PATH.set(path).ok(); -} - -/// Get the base path for repository operations. -fn base_path() -> &'static PathBuf { - BASE_PATH.get().unwrap_or(&DEFAULT_BASE_PATH) -} - /// Repository state for git operations. /// /// Represents the shared state of a git repository (the `.git` directory). @@ -219,7 +200,7 @@ impl Repository { /// For worktree-specific operations on paths other than cwd, use /// `repo.worktree_at(path)` to get a [`WorkingTree`]. pub fn current() -> anyhow::Result { - Self::at(base_path().clone()) + Self::at(".") } /// Discover the repository from the specified path. @@ -321,7 +302,7 @@ impl Repository { /// /// This is the primary way to get a [`WorkingTree`] for worktree-specific operations. pub fn current_worktree(&self) -> WorkingTree<'_> { - self.worktree_at(base_path().clone()) + self.worktree_at(".") } /// Get a worktree view at a specific path. diff --git a/src/git/repository/working_tree.rs b/src/git/repository/working_tree.rs index d88f403a9..fdaf373f6 100644 --- a/src/git/repository/working_tree.rs +++ b/src/git/repository/working_tree.rs @@ -59,7 +59,7 @@ impl<'a> WorkingTree<'a> { /// Get the path this WorkingTree was created with. /// - /// This is the path passed to `worktree_at()` or `base_path()` for `current_worktree()`. + /// This is the path passed to `worktree_at()` (or `"."` for `current_worktree()`). /// For the canonical git-determined root, use [`root()`](Self::root) instead. pub fn path(&self) -> &Path { &self.path diff --git a/src/git/repository/worktrees.rs b/src/git/repository/worktrees.rs index 93efbe356..5f65ff1d7 100644 --- a/src/git/repository/worktrees.rs +++ b/src/git/repository/worktrees.rs @@ -8,6 +8,7 @@ use normalize_path::NormalizePath; use super::{GitError, Repository, ResolvedWorktree, WorktreeInfo}; use crate::path::format_path_for_display; +use crate::workspace::Workspace; impl Repository { /// List all worktrees for this repository. @@ -126,6 +127,33 @@ impl Repository { Ok(()) } + /// Create a new worktree with a new branch. + /// + /// Runs `git worktree add -b []` to create a worktree + /// at `path` on a new branch `branch`, optionally starting from `base`. + pub fn create_worktree( + &self, + branch: &str, + base: Option<&str>, + path: &Path, + ) -> anyhow::Result<()> { + let path_str = path.to_str().ok_or_else(|| { + anyhow::Error::from(GitError::Other { + message: format!( + "Worktree path contains invalid UTF-8: {}", + format_path_for_display(path) + ), + }) + })?; + + let mut args = vec!["worktree", "add", "-b", branch, path_str]; + if let Some(base_ref) = base { + args.push(base_ref); + } + self.run_command(&args)?; + Ok(()) + } + /// Remove a worktree at the specified path. /// /// When `force` is true, passes `--force` to `git worktree remove`, diff --git a/src/lib.rs b/src/lib.rs index 0b744b8dd..90fd29e93 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub mod styling; pub mod sync; pub mod trace; pub mod utils; +pub mod workspace; // Re-export HookType for convenience pub use git::HookType; diff --git a/src/llm.rs b/src/llm.rs index 2f3f7b356..91bea863f 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -1,10 +1,9 @@ use anyhow::Context; use shell_escape::escape; use std::borrow::Cow; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use worktrunk::config::CommitGenerationConfig; -use worktrunk::git::Repository; use worktrunk::path::format_path_for_display; use worktrunk::shell_exec::{Cmd, ShellConfig}; use worktrunk::styling::{eprintln, warning_message}; @@ -460,14 +459,24 @@ fn build_prompt( Ok(rendered) } +/// Pre-computed VCS-agnostic data needed for commit prompt/message generation. +pub(crate) struct CommitInput<'a> { + pub diff: &'a str, + pub diff_stat: &'a str, + pub branch: &'a str, + pub repo_name: &'a str, + pub recent_commits: Option<&'a Vec>, +} + pub(crate) fn generate_commit_message( + input: &CommitInput<'_>, commit_generation_config: &CommitGenerationConfig, ) -> anyhow::Result { // Check if commit generation is configured (non-empty command) if commit_generation_config.is_configured() { let command = commit_generation_config.command.as_ref().unwrap(); - // Commit generation is explicitly configured - fail if it doesn't work - return try_generate_commit_message(command, commit_generation_config).map_err(|e| { + let prompt = build_commit_prompt(input, commit_generation_config)?; + return execute_llm_command(command, &prompt).map_err(|e| { worktrunk::git::GitError::LlmCommandFailed { command: command.clone(), error: e.to_string(), @@ -480,111 +489,71 @@ pub(crate) fn generate_commit_message( }); } - // Fallback: generate a descriptive commit message based on changed files - let repo = Repository::current()?; - // Use -z for NUL-separated output to handle filenames with spaces/newlines - let file_list = repo.run_command(&["diff", "--staged", "--name-only", "-z"])?; - let staged_files = file_list - .split('\0') - .map(|s| s.trim()) + // Fallback: generate a descriptive commit message based on changed files. + // Parses diffstat lines (e.g. " src/foo.rs | 3 +") — works for both git and jj + // but may mis-parse filenames containing '|' (rare, low-stakes fallback). + let files: Vec<&str> = input + .diff_stat + .lines() + .filter(|l| l.contains('|')) + .map(|l| l.split('|').next().unwrap_or("").trim()) .filter(|s| !s.is_empty()) - .map(|path| { - Path::new(path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(path) - }) - .collect::>(); - - let message = match staged_files.len() { + .map(|path| path.rsplit('/').next().unwrap_or(path)) + .collect(); + + let message = match files.len() { 0 => "WIP: Changes".to_string(), - 1 => format!("Changes to {}", staged_files[0]), - 2 => format!("Changes to {} & {}", staged_files[0], staged_files[1]), - 3 => format!( - "Changes to {}, {} & {}", - staged_files[0], staged_files[1], staged_files[2] - ), + 1 => format!("Changes to {}", files[0]), + 2 => format!("Changes to {} & {}", files[0], files[1]), + 3 => format!("Changes to {}, {} & {}", files[0], files[1], files[2]), n => format!("Changes to {} files", n), }; Ok(message) } -fn try_generate_commit_message( - command: &str, - config: &CommitGenerationConfig, -) -> anyhow::Result { - let prompt = build_commit_prompt(config)?; - execute_llm_command(command, &prompt) -} - -/// Build the commit prompt from staged changes. +/// Build the commit prompt from pre-computed VCS-agnostic data. /// -/// Gathers the staged diff, branch name, repo name, and recent commits, then renders +/// Accepts diff, branch name, repo name, and recent commits, then renders /// the prompt template. Used by both normal commit generation and `--show-prompt`. -pub(crate) fn build_commit_prompt(config: &CommitGenerationConfig) -> anyhow::Result { - let repo = Repository::current()?; - - // Get staged diff and diffstat - // Use -c flags to ensure consistent format regardless of user's git config - // (diff.noprefix, diff.mnemonicPrefix, etc. could break our parsing) - let diff_output = repo.run_command(&[ - "-c", - "diff.noprefix=false", - "-c", - "diff.mnemonicPrefix=false", - "--no-pager", - "diff", - "--staged", - ])?; - let diff_stat = repo.run_command(&["--no-pager", "diff", "--staged", "--stat"])?; - - // Prepare diff (may filter if too large) - let prepared = prepare_diff(diff_output, diff_stat); - - // Get current branch and repo root - let wt = repo.current_worktree(); - let current_branch = wt.branch()?.unwrap_or_else(|| "HEAD".to_string()); - let repo_root = wt.root()?; - let repo_name = repo_root - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("repo"); - - let recent_commits = repo.recent_commit_subjects(None, 5); +pub(crate) fn build_commit_prompt( + input: &CommitInput<'_>, + config: &CommitGenerationConfig, +) -> anyhow::Result { + let prepared = prepare_diff(input.diff.to_string(), input.diff_stat.to_string()); let context = TemplateContext { git_diff: &prepared.diff, git_diff_stat: &prepared.stat, - branch: ¤t_branch, - recent_commits: recent_commits.as_ref(), - repo_name, + branch: input.branch, + recent_commits: input.recent_commits, + repo_name: input.repo_name, commits: &[], target_branch: None, }; build_prompt(config, TemplateType::Commit, &context) } +/// Pre-computed VCS-agnostic data needed for squash prompt/message generation. +pub(crate) struct SquashInput<'a> { + pub target_branch: &'a str, + pub diff: &'a str, + pub diff_stat: &'a str, + pub subjects: &'a [String], + pub current_branch: &'a str, + pub repo_name: &'a str, + pub recent_commits: Option<&'a Vec>, +} + pub(crate) fn generate_squash_message( - target_branch: &str, - merge_base: &str, - subjects: &[String], - current_branch: &str, - repo_name: &str, + input: &SquashInput<'_>, commit_generation_config: &CommitGenerationConfig, ) -> anyhow::Result { // Check if commit generation is configured (non-empty command) if commit_generation_config.is_configured() { let command = commit_generation_config.command.as_ref().unwrap(); - let prompt = build_squash_prompt( - target_branch, - merge_base, - subjects, - current_branch, - repo_name, - commit_generation_config, - )?; + let prompt = build_squash_prompt(input, commit_generation_config)?; return execute_llm_command(command, &prompt).map_err(|e| { worktrunk::git::GitError::LlmCommandFailed { @@ -600,9 +569,9 @@ pub(crate) fn generate_squash_message( } // Fallback: deterministic commit message (only when not configured) - let mut commit_message = format!("Squash commits from {}\n\n", target_branch); + let mut commit_message = format!("Squash commits from {}\n\n", input.target_branch); commit_message.push_str("Combined commits:\n"); - for subject in subjects.iter().rev() { + for subject in input.subjects.iter().rev() { // Reverse so they're in chronological order commit_message.push_str(&format!("- {}\n", subject)); } @@ -611,44 +580,23 @@ pub(crate) fn generate_squash_message( /// Build the squash prompt from commits being squashed. /// -/// Gathers the combined diff, commit subjects, branch names, and recent commits, then -/// renders the prompt template. Used by both normal squash generation and `--show-prompt`. +/// Accepts pre-computed diff data (from the Workspace trait), making this +/// VCS-agnostic. Used by both normal squash generation and `--show-prompt`. pub(crate) fn build_squash_prompt( - target_branch: &str, - merge_base: &str, - subjects: &[String], - current_branch: &str, - repo_name: &str, + input: &SquashInput<'_>, config: &CommitGenerationConfig, ) -> anyhow::Result { - let repo = Repository::current()?; - - // Get the combined diff and diffstat for all commits being squashed - // Use -c flags to ensure consistent format regardless of user's git config - let diff_output = repo.run_command(&[ - "-c", - "diff.noprefix=false", - "-c", - "diff.mnemonicPrefix=false", - "--no-pager", - "diff", - merge_base, - "HEAD", - ])?; - let diff_stat = repo.run_command(&["--no-pager", "diff", merge_base, "HEAD", "--stat"])?; - // Prepare diff (may filter if too large) - let prepared = prepare_diff(diff_output, diff_stat); + let prepared = prepare_diff(input.diff.to_string(), input.diff_stat.to_string()); - let recent_commits = repo.recent_commit_subjects(Some(merge_base), 5); let context = TemplateContext { git_diff: &prepared.diff, git_diff_stat: &prepared.stat, - branch: current_branch, - recent_commits: recent_commits.as_ref(), - repo_name, - commits: subjects, - target_branch: Some(target_branch), + branch: input.current_branch, + recent_commits: input.recent_commits, + repo_name: input.repo_name, + commits: input.subjects, + target_branch: Some(input.target_branch), }; build_prompt(config, TemplateType::Squash, &context) } @@ -1416,4 +1364,91 @@ diff --git a/Cargo.lock b/Cargo.lock assert!(!is_lock_file("README.md")); assert!(!is_lock_file("config.toml")); } + + #[test] + fn test_build_commit_prompt_with_commit_input() { + let config = CommitGenerationConfig::default(); + let input = CommitInput { + diff: "+++ new_file.rs\n+fn main() {}\n", + diff_stat: "new_file.rs | 1 +\n1 file changed, 1 insertion(+)", + branch: "feature-ws", + repo_name: "myrepo", + recent_commits: None, + }; + let result = build_commit_prompt(&input, &config); + assert!(result.is_ok()); + let prompt = result.unwrap(); + assert!(prompt.contains("new_file.rs")); + assert!(prompt.contains("feature-ws")); + } + + #[test] + fn test_generate_commit_message_fallback_single_file() { + let config = CommitGenerationConfig::default(); + let input = CommitInput { + diff: "", + diff_stat: " src/main.rs | 3 +++\n 1 file changed, 3 insertions(+)", + branch: "main", + repo_name: "test", + recent_commits: None, + }; + let msg = generate_commit_message(&input, &config).unwrap(); + assert_eq!(msg, "Changes to main.rs"); + } + + #[test] + fn test_generate_commit_message_fallback_two_files() { + let config = CommitGenerationConfig::default(); + let input = CommitInput { + diff: "", + diff_stat: " src/lib.rs | 1 +\n src/main.rs | 2 ++\n 2 files changed", + branch: "main", + repo_name: "test", + recent_commits: None, + }; + let msg = generate_commit_message(&input, &config).unwrap(); + assert_eq!(msg, "Changes to lib.rs & main.rs"); + } + + #[test] + fn test_generate_commit_message_fallback_three_files() { + let config = CommitGenerationConfig::default(); + let input = CommitInput { + diff: "", + diff_stat: " src/lib.rs | 1 +\n src/main.rs | 2 ++\n src/util.rs | 5 ++---\n 3 files changed", + branch: "main", + repo_name: "test", + recent_commits: None, + }; + let msg = generate_commit_message(&input, &config).unwrap(); + assert_eq!(msg, "Changes to lib.rs, main.rs & util.rs"); + } + + #[test] + fn test_generate_commit_message_fallback_no_files() { + let config = CommitGenerationConfig::default(); + let input = CommitInput { + diff: "", + diff_stat: "", + branch: "main", + repo_name: "test", + recent_commits: None, + }; + let msg = generate_commit_message(&input, &config).unwrap(); + assert_eq!(msg, "WIP: Changes"); + } + + #[test] + fn test_generate_commit_message_fallback_many_files() { + let config = CommitGenerationConfig::default(); + let input = CommitInput { + diff: "", + diff_stat: " a.rs | 1 +\n b.rs | 1 +\n c.rs | 1 +\n d.rs | 1 +\n 4 files changed", + branch: "main", + repo_name: "test", + recent_commits: None, + }; + let msg = generate_commit_message(&input, &config).unwrap(); + assert_eq!(msg, "Changes to 4 files"); + } } diff --git a/src/main.rs b/src/main.rs index 4c59aa1a5..ed5aa8b86 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,3 @@ -use std::collections::HashSet; use std::io::Write; use anyhow::Context; @@ -7,7 +6,7 @@ use clap::error::ErrorKind as ClapErrorKind; use color_print::{ceprintln, cformat}; use std::process; use worktrunk::config::{UserConfig, set_config_path}; -use worktrunk::git::{Repository, ResolvedWorktree, exit_code, set_base_path}; +use worktrunk::git::exit_code; use worktrunk::path::format_path_for_display; use worktrunk::shell::extract_filename_from_path; use worktrunk::styling::{ @@ -15,10 +14,7 @@ use worktrunk::styling::{ warning_message, }; -use commands::command_approval::approve_hooks; -use commands::context::CommandEnv; use commands::list::progressive::RenderMode; -use commands::worktree::RemoveResult; mod cli; mod commands; @@ -43,18 +39,15 @@ pub(crate) use crate::cli::OutputFormat; #[cfg(unix)] use commands::handle_select; -use commands::worktree::handle_push; use commands::{ - MergeOptions, OperationMode, RebaseResult, SquashResult, SwitchOptions, add_approvals, + MergeOptions, RebaseResult, RemoveOptions, SquashResult, SwitchOptions, add_approvals, clear_approvals, handle_completions, handle_config_create, handle_config_show, handle_configure_shell, handle_hints_clear, handle_hints_get, handle_hook_show, handle_init, - handle_list, handle_logs_get, handle_merge, handle_rebase, handle_remove, - handle_remove_current, handle_show_theme, handle_squash, handle_state_clear, - handle_state_clear_all, handle_state_get, handle_state_set, handle_state_show, handle_switch, - handle_unconfigure_shell, resolve_worktree_arg, run_hook, step_commit, step_copy_ignored, - step_for_each, step_relocate, + handle_list, handle_logs_get, handle_merge, handle_rebase, handle_remove_command, + handle_show_theme, handle_squash, handle_state_clear, handle_state_clear_all, handle_state_get, + handle_state_set, handle_state_show, handle_switch, handle_unconfigure_shell, run_hook, + step_commit, step_copy_ignored, step_for_each, step_push, step_relocate, }; -use output::handle_remove_output; use cli::{ ApprovalsCommand, CiStatusAction, Cli, Commands, ConfigCommand, ConfigShellCommand, @@ -130,9 +123,23 @@ fn main() { }); let cli = Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit()); - // Initialize base path from -C flag if provided - if let Some(path) = cli.directory { - set_base_path(path); + // Change working directory from -C flag if provided. + // This affects ALL code (git, jj, etc.) that uses relative paths or current_dir(). + // Note: $PWD (used for symlink-aware path display) is not updated because set_var + // is unsafe. This means paths may display canonically when -C targets a symlinked + // directory. The symlink mapping code handles stale $PWD gracefully (returns None). + if let Some(ref path) = cli.directory { + std::env::set_current_dir(path).unwrap_or_else(|e| { + eprintln!( + "{}", + error_message(format!( + "Cannot change to directory '{}': {}", + path.display(), + e + )) + ); + process::exit(1); + }); } // Initialize config path from --config flag if provided @@ -497,7 +504,6 @@ fn main() { stage, show_prompt, } => { - // Handle --show-prompt early: just build and output the prompt if show_prompt { commands::step_show_squash_prompt(target.as_deref()) } else { @@ -522,7 +528,7 @@ fn main() { }) } } - StepCommand::Push { target } => handle_push(target.as_deref(), "Pushed to", None), + StepCommand::Push { target } => step_push(target.as_deref()), StepCommand::Rebase { target } => { handle_rebase(target.as_deref()).map(|result| match result { RebaseResult::Rebased => (), @@ -635,7 +641,10 @@ fn main() { warning_message("wt select is deprecated; use wt switch instead") ); - handle_select(branches, remotes) + // handle_select resolves project-specific settings internally + UserConfig::load() + .context("Failed to load config") + .and_then(|config| handle_select(branches, remotes, &config)) } #[cfg(not(unix))] Commands::Select { .. } => { @@ -677,17 +686,18 @@ fn main() { }; commands::statusline::run(effective_format) } - None => (|| { - let repo = Repository::current()?; - + None => { + // Config resolution is deferred to collect's parallel phase so + // project_identifier runs concurrently with other git commands + // instead of blocking the critical path. let progressive_opt = match (progressive, no_progressive) { (true, _) => Some(true), (_, true) => Some(false), _ => None, }; let render_mode = RenderMode::detect(progressive_opt); - handle_list(repo, format, branches, remotes, full, render_mode) - })(), + handle_list(format, branches, remotes, full, render_mode) + } }, Commands::Switch { branch, @@ -708,7 +718,8 @@ fn main() { let Some(branch) = branch else { #[cfg(unix)] { - return handle_select(branches, remotes); + // handle_select resolves project-specific settings internally + return handle_select(branches, remotes, &config); } #[cfg(not(unix))] @@ -755,201 +766,16 @@ fn main() { verify, yes, force, - } => UserConfig::load() - .context("Failed to load config") - .and_then(|config| { - // Handle deprecated --no-background flag - if no_background { - eprintln!( - "{}", - warning_message("--no-background is deprecated; use --foreground instead") - ); - } - let background = !(foreground || no_background); - - // Validate conflicting flags - if !delete_branch && force_delete { - return Err(worktrunk::git::GitError::Other { - message: "Cannot use --force-delete with --no-delete-branch".into(), - } - .into()); - } - - let repo = Repository::current().context("Failed to remove worktree")?; - - // Helper: approve remove hooks using current worktree context - // Returns true if hooks should run (user approved) - let approve_remove = |yes: bool| -> anyhow::Result { - let env = CommandEnv::for_action_branchless()?; - let ctx = env.context(yes); - let approved = approve_hooks( - &ctx, - &[ - HookType::PreRemove, - HookType::PostRemove, - HookType::PostSwitch, - ], - )?; - if !approved { - eprintln!("{}", info_message("Commands declined, continuing removal")); - } - Ok(approved) - }; - - if branches.is_empty() { - // Single worktree removal: validate FIRST, then approve, then execute - let result = - handle_remove_current(!delete_branch, force_delete, force, &config) - .context("Failed to remove worktree")?; - - // Early exit for benchmarking time-to-first-output - if std::env::var_os("WORKTRUNK_FIRST_OUTPUT").is_some() { - return Ok(()); - } - - // "Approve at the Gate": approval happens AFTER validation passes - let run_hooks = verify && approve_remove(yes)?; - - handle_remove_output(&result, background, run_hooks) - } else { - // Multi-worktree removal: validate ALL first, then approve, then execute - // This supports partial success - some may fail validation while others succeed. - let current_worktree = repo - .current_worktree() - .root() - .ok() - .and_then(|p| dunce::canonicalize(&p).ok()); - - // Dedupe inputs to avoid redundant planning/execution - let branches: Vec<_> = { - let mut seen = HashSet::new(); - branches - .into_iter() - .filter(|b| seen.insert(b.clone())) - .collect() - }; - - // Phase 1: Validate all targets (resolution + preparation) - // Store successful plans for execution after approval - let mut plans_others: Vec = Vec::new(); - let mut plans_branch_only: Vec = Vec::new(); - let mut plan_current: Option = None; - let mut all_errors: Vec = Vec::new(); - - // Helper: record error and continue - let mut record_error = |e: anyhow::Error| { - eprintln!("{}", e); - all_errors.push(e); - }; - - for branch_name in &branches { - // Resolve the target - let resolved = match resolve_worktree_arg( - &repo, - branch_name, - &config, - OperationMode::Remove, - ) { - Ok(r) => r, - Err(e) => { - record_error(e); - continue; - } - }; - - match resolved { - ResolvedWorktree::Worktree { path, branch } => { - // Use canonical paths to avoid symlink/normalization mismatches - let path_canonical = dunce::canonicalize(&path).unwrap_or(path); - let is_current = current_worktree.as_ref() == Some(&path_canonical); - - if is_current { - // Current worktree - use handle_remove_current for detached HEAD - match handle_remove_current( - !delete_branch, - force_delete, - force, - &config, - ) { - Ok(result) => plan_current = Some(result), - Err(e) => record_error(e), - } - continue; - } - - // Non-current worktree - branch is always Some because: - // - "@" resolves to current worktree (handled by is_current branch above) - // - Other names resolve via resolve_worktree_arg which always sets branch: Some(...) - let branch_for_remove = branch.as_ref().unwrap(); - - match handle_remove( - branch_for_remove, - !delete_branch, - force_delete, - force, - &config, - ) { - Ok(result) => plans_others.push(result), - Err(e) => record_error(e), - } - } - ResolvedWorktree::BranchOnly { branch } => { - match handle_remove( - &branch, - !delete_branch, - force_delete, - force, - &config, - ) { - Ok(result) => plans_branch_only.push(result), - Err(e) => record_error(e), - } - } - } - } - - // If no valid plans, bail early (all failed validation) - let has_valid_plans = !plans_others.is_empty() - || !plans_branch_only.is_empty() - || plan_current.is_some(); - if !has_valid_plans { - anyhow::bail!(""); - } - - // Early exit for benchmarking time-to-first-output - if std::env::var_os("WORKTRUNK_FIRST_OUTPUT").is_some() { - return Ok(()); - } - - // Phase 2: Approve hooks (only if we have valid plans) - // TODO(pre-remove-context): Approval context uses current worktree, - // but hooks execute in each target worktree. - let run_hooks = verify && approve_remove(yes)?; - - // Phase 3: Execute all validated plans - // Remove other worktrees first - for result in plans_others { - handle_remove_output(&result, background, run_hooks)?; - } - - // Handle branch-only cases - for result in plans_branch_only { - handle_remove_output(&result, background, run_hooks)?; - } - - // Remove current worktree last (if it was in the list) - if let Some(result) = plan_current { - handle_remove_output(&result, background, run_hooks)?; - } - - // Exit with failure if any validation errors occurred - if !all_errors.is_empty() { - anyhow::bail!(""); - } - - Ok(()) - } - }), + } => handle_remove_command(RemoveOptions { + branches, + delete_branch, + force_delete, + foreground, + no_background, + verify, + yes, + force, + }), Commands::Merge { target, squash, @@ -997,8 +823,6 @@ fn main() { eprintln!("{}", err); } else if let Some(err) = e.downcast_ref::() { eprintln!("{}", err); - } else if let Some(err) = e.downcast_ref::() { - eprintln!("{}", err); } else { // Anyhow error formatting: // - With context: show context as header, root cause in gutter diff --git a/src/output/commit_generation.rs b/src/output/commit_generation.rs index d0b475752..9dc82faf3 100644 --- a/src/output/commit_generation.rs +++ b/src/output/commit_generation.rs @@ -122,6 +122,11 @@ pub fn prompt_commit_generation(config: &mut UserConfig) -> anyhow::Result return Ok(false); } + // Skip if prompts are suppressed (test environments, CI) + if std::env::var("WORKTRUNK_NO_PROMPTS").is_ok() { + return Ok(false); + } + // Detect available tool let Some(tool) = detect_llm_tool() else { // No tool found - set skip flag so we don't check every time diff --git a/src/output/handlers.rs b/src/output/handlers.rs index 43ef769cf..178ec1195 100644 --- a/src/output/handlers.rs +++ b/src/output/handlers.rs @@ -27,6 +27,7 @@ use worktrunk::styling::{ FormattedMessage, eprintln, error_message, format_with_gutter, hint_message, info_message, progress_message, success_message, suggest_command, warning_message, }; +use worktrunk::workspace::Workspace; use super::shell_integration::{ compute_shell_warning_reason, explicit_path_hint, git_subcommand_warning, @@ -955,7 +956,7 @@ fn handle_removed_worktree_output( ); let remove_command = build_remove_command(worktree_path, None, force_worktree); spawn_detached( - &repo, + &repo.wt_logs_dir(), main_path, &remove_command, "detached", @@ -1024,7 +1025,7 @@ fn handle_removed_worktree_output( // Spawn the removal in background - runs from main_path (where we cd'd to) spawn_detached( - &repo, + &repo.wt_logs_dir(), main_path, &remove_command, branch_name, diff --git a/src/workspace/detect.rs b/src/workspace/detect.rs new file mode 100644 index 000000000..14fa14af3 --- /dev/null +++ b/src/workspace/detect.rs @@ -0,0 +1,89 @@ +//! VCS detection by filesystem markers. +//! +//! Walks ancestor directories looking for `.jj/` or `.git/` to determine +//! which VCS manages the repository. Co-located repos (both markers present) +//! prefer jj. + +use std::path::Path; + +use super::VcsKind; + +/// Detect which VCS manages the repository containing `path`. +/// +/// Walks ancestors looking for `.jj/` and `.git/` markers. At each level: +/// - `.jj/` present → jj (even if `.git/` also exists, since co-located repos +/// have both and jj is the primary VCS) +/// - `.git/` present (file or directory) → git +/// +/// Returns `None` if no VCS markers are found. +pub fn detect_vcs(path: &Path) -> Option { + let mut current = Some(path); + while let Some(dir) = current { + if dir.join(".jj").is_dir() { + return Some(VcsKind::Jj); + } + // .git can be a directory (normal repo) or file (worktree link) + if dir.join(".git").exists() { + return Some(VcsKind::Git); + } + current = dir.parent(); + } + None +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + #[test] + fn test_detect_git_repo() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join(".git")).unwrap(); + + assert_eq!(detect_vcs(dir.path()), Some(VcsKind::Git)); + } + + #[test] + fn test_detect_jj_repo() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join(".jj")).unwrap(); + + assert_eq!(detect_vcs(dir.path()), Some(VcsKind::Jj)); + } + + #[test] + fn test_detect_colocated_prefers_jj() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join(".jj")).unwrap(); + fs::create_dir(dir.path().join(".git")).unwrap(); + + assert_eq!(detect_vcs(dir.path()), Some(VcsKind::Jj)); + } + + #[test] + fn test_detect_no_vcs() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(detect_vcs(dir.path()), None); + } + + #[test] + fn test_detect_in_subdirectory() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join(".git")).unwrap(); + let sub = dir.path().join("src").join("lib"); + fs::create_dir_all(&sub).unwrap(); + + assert_eq!(detect_vcs(&sub), Some(VcsKind::Git)); + } + + #[test] + fn test_detect_git_worktree_file() { + let dir = tempfile::tempdir().unwrap(); + // Git worktrees use a .git file (not directory) pointing to the main repo + fs::write(dir.path().join(".git"), "gitdir: /some/path").unwrap(); + + assert_eq!(detect_vcs(dir.path()), Some(VcsKind::Git)); + } +} diff --git a/src/workspace/git.rs b/src/workspace/git.rs new file mode 100644 index 000000000..afd9fbb20 --- /dev/null +++ b/src/workspace/git.rs @@ -0,0 +1,1192 @@ +//! Git implementation of the [`Workspace`] trait. +//! +//! Implements [`Workspace`] directly on [`Repository`], mapping git-specific +//! types to the VCS-agnostic [`WorkspaceItem`] and [`Workspace`] interface. +//! +//! Commands that need git-specific features (staging, interactive squash) +//! can downcast via `workspace.as_any().downcast_ref::()`. + +use std::any::Any; +use std::path::{Path, PathBuf}; + +use anyhow::Context; +use color_print::cformat; + +use crate::config::StageMode; +use crate::git::{ + GitError, Repository, check_integration, compute_integration_lazy, parse_porcelain_z, +}; +use crate::path::format_path_for_display; + +use super::types::{IntegrationReason, LineDiff, LocalPushDisplay, path_dir_name}; +use crate::styling::{ + GUTTER_OVERHEAD, eprintln, format_with_gutter, get_terminal_width, progress_message, + warning_message, +}; + +use super::{LocalPushResult, RebaseOutcome, SquashOutcome, VcsKind, Workspace, WorkspaceItem}; + +impl Workspace for Repository { + fn kind(&self) -> VcsKind { + VcsKind::Git + } + + fn list_workspaces(&self) -> anyhow::Result> { + let worktrees = self.list_worktrees()?; + let primary_path = self.primary_worktree()?; + + Ok(worktrees + .into_iter() + .map(|wt| { + let is_default = primary_path + .as_ref() + .is_some_and(|primary| *primary == wt.path); + WorkspaceItem::from_worktree(wt, is_default) + }) + .collect()) + } + + fn workspace_path(&self, name: &str) -> anyhow::Result { + // Single pass: list worktrees once, check both branch name and dir name + let worktrees = self.list_worktrees()?; + + // Prefer branch name match + if let Some(wt) = worktrees + .iter() + .find(|wt| wt.branch.as_deref() == Some(name)) + { + return Ok(wt.path.clone()); + } + + // Fall back to directory name match + worktrees + .iter() + .find(|wt| path_dir_name(&wt.path) == name) + .map(|wt| wt.path.clone()) + .ok_or_else(|| { + GitError::WorktreeNotFound { + branch: name.to_string(), + } + .into() + }) + } + + fn default_workspace_path(&self) -> anyhow::Result> { + self.primary_worktree() + } + + fn default_branch_name(&self) -> Option { + self.default_branch() + } + + fn set_default_branch(&self, name: &str) -> anyhow::Result<()> { + Repository::set_default_branch(self, name) + } + + fn clear_default_branch(&self) -> anyhow::Result { + self.clear_default_branch_cache() + } + + fn is_dirty(&self, path: &Path) -> anyhow::Result { + self.worktree_at(path).is_dirty() + } + + fn working_diff(&self, path: &Path) -> anyhow::Result { + self.worktree_at(path).working_tree_diff_stats() + } + + fn ahead_behind(&self, base: &str, head: &str) -> anyhow::Result<(usize, usize)> { + // Note: this calls Repository::ahead_behind inherent method directly + Repository::ahead_behind(self, base, head) + } + + fn is_integrated(&self, id: &str, target: &str) -> anyhow::Result> { + let signals = compute_integration_lazy(self, id, target)?; + Ok(check_integration(&signals)) + } + + fn branch_diff_stats(&self, base: &str, head: &str) -> anyhow::Result { + Repository::branch_diff_stats(self, base, head) + } + + fn create_workspace(&self, name: &str, base: Option<&str>, path: &Path) -> anyhow::Result<()> { + self.create_worktree(name, base, path) + } + + fn remove_workspace(&self, name: &str) -> anyhow::Result<()> { + let path = Workspace::workspace_path(self, name)?; + self.remove_worktree(&path, false) + } + + fn resolve_integration_target(&self, target: Option<&str>) -> anyhow::Result { + self.require_target_ref(target) + } + + fn is_rebased_onto(&self, target: &str, _path: &Path) -> anyhow::Result { + // Call the inherent method via fully-qualified syntax + Repository::is_rebased_onto(self, target) + } + + fn rebase_onto(&self, target: &str, _path: &Path) -> anyhow::Result { + // Detect fast-forward: merge-base == HEAD means HEAD is behind target + let merge_base = self + .merge_base("HEAD", target)? + .context("Cannot rebase: no common ancestor with target branch")?; + let head_sha = self.run_command(&["rev-parse", "HEAD"])?.trim().to_string(); + let is_fast_forward = merge_base == head_sha; + + // Only show progress for true rebases (fast-forwards are instant) + if !is_fast_forward { + eprintln!( + "{}", + progress_message(cformat!("Rebasing onto {target}...")) + ); + } + + let rebase_result = self.run_command(&["rebase", target]); + + if let Err(e) = rebase_result { + let is_rebasing = self + .worktree_state()? + .is_some_and(|s| s.starts_with("REBASING")); + if is_rebasing { + let git_output = e.to_string(); + return Err(crate::git::GitError::RebaseConflict { + target_branch: target.to_string(), + git_output, + } + .into()); + } + return Err(crate::git::GitError::Other { + message: format!("Failed to rebase onto {target}: {e}"), + } + .into()); + } + + // Verify rebase completed successfully + if self.worktree_state()?.is_some() { + return Err(crate::git::GitError::RebaseConflict { + target_branch: target.to_string(), + git_output: String::new(), + } + .into()); + } + + if is_fast_forward { + Ok(RebaseOutcome::FastForward) + } else { + Ok(RebaseOutcome::Rebased) + } + } + + fn root_path(&self) -> anyhow::Result { + Ok(self.repo_path().to_path_buf()) + } + + fn current_workspace_path(&self) -> anyhow::Result { + Ok(self.current_worktree().path().to_path_buf()) + } + + fn current_name(&self, path: &Path) -> anyhow::Result> { + self.worktree_at(path).branch() + } + + fn project_identifier(&self) -> anyhow::Result { + // Call the inherent method via fully-qualified syntax + Repository::project_identifier(self) + } + + fn prepare_commit(&self, _path: &Path, mode: StageMode) -> anyhow::Result<()> { + if mode == StageMode::All { + warn_if_auto_staging_untracked(self)?; + } + + match mode { + StageMode::All => { + self.run_command(&["add", "-A"]) + .context("Failed to stage changes")?; + } + StageMode::Tracked => { + self.run_command(&["add", "-u"]) + .context("Failed to stage tracked changes")?; + } + StageMode::None => {} + } + Ok(()) + } + + fn commit(&self, message: &str, path: &Path) -> anyhow::Result { + self.worktree_at(path) + .run_command(&["commit", "-m", message]) + .context("Failed to create commit")?; + let sha = self + .worktree_at(path) + .run_command(&["rev-parse", "HEAD"])? + .trim() + .to_string(); + Ok(sha) + } + + fn commit_subjects(&self, base: &str, head: &str) -> anyhow::Result> { + let range = format!("{base}..{head}"); + let output = self.run_command(&["log", "--format=%s", &range])?; + Ok(output.lines().map(|l| l.to_string()).collect()) + } + + fn push_to_target(&self, target: &str, _path: &Path) -> anyhow::Result<()> { + self.run_command(&["push", "origin", &format!("HEAD:{target}")])?; + Ok(()) + } + + fn local_push( + &self, + target: &str, + _path: &Path, + display: LocalPushDisplay<'_>, + ) -> anyhow::Result { + // Check fast-forward + if !self.is_ancestor(target, "HEAD")? { + let commits_formatted = self + .run_command(&[ + "log", + "--color=always", + "--graph", + "--oneline", + &format!("HEAD..{target}"), + ])? + .trim() + .to_string(); + return Err(GitError::NotFastForward { + target_branch: target.to_string(), + commits_formatted, + in_merge_context: false, + } + .into()); + } + + let commit_count = self.count_commits(target, "HEAD")?; + if commit_count == 0 { + return Ok(LocalPushResult { + commit_count: 0, + stats_summary: Vec::new(), + }); + } + + // Collect display data before push (diffstat, stats summary) + let range = format!("{target}..HEAD"); + let stats_summary = self.diff_stats_summary(&["diff", "--shortstat", &range]); + + // Auto-stash non-conflicting changes in the target worktree (if present) + let target_wt_path = self.worktree_for_branch(target)?; + let stash_info = stash_target_if_dirty(self, target_wt_path.as_ref(), target)?; + + // Show progress message, commit graph, and diffstat (between stash and restore) + show_push_preview(self, target, commit_count, &range, &display); + + // Local push: advance the target branch ref via git push to local path + let git_common_dir = self.git_common_dir(); + let push_target = format!("HEAD:{target}"); + let push_result = self.run_command(&[ + "push", + "--receive-pack=git -c receive.denyCurrentBranch=updateInstead receive-pack", + &git_common_dir.to_string_lossy(), + &push_target, + ]); + + // Restore stash regardless of push result + if let Some((wt_path, stash_ref)) = stash_info { + restore_stash(self, &wt_path, &stash_ref); + } + + push_result.map_err(|e| GitError::PushFailed { + target_branch: target.to_string(), + error: e.to_string(), + })?; + + Ok(LocalPushResult { + commit_count, + stats_summary, + }) + } + + fn feature_head(&self, _path: &Path) -> anyhow::Result { + Ok("HEAD".to_string()) + } + + fn diff_for_prompt( + &self, + base: &str, + head: &str, + _path: &Path, + ) -> anyhow::Result<(String, String)> { + let diff = self.run_command(&[ + "-c", + "diff.noprefix=false", + "-c", + "diff.mnemonicPrefix=false", + "--no-pager", + "diff", + base, + head, + ])?; + let stat = self.run_command(&["--no-pager", "diff", base, head, "--stat"])?; + Ok((diff, stat)) + } + + fn recent_subjects(&self, start_ref: Option<&str>, count: usize) -> Option> { + self.recent_commit_subjects(start_ref, count) + } + + fn squash_commits( + &self, + target: &str, + message: &str, + _path: &Path, + ) -> anyhow::Result { + let merge_base = self + .merge_base("HEAD", target)? + .context("Cannot squash: no common ancestor with target branch")?; + + self.run_command(&["reset", "--soft", &merge_base]) + .context("Failed to reset to merge base")?; + + // Check if there are actually any changes to commit (commits may cancel out) + if !self.current_worktree().has_staged_changes()? { + return Ok(SquashOutcome::NoNetChanges); + } + + self.run_command(&["commit", "-m", message]) + .context("Failed to create squash commit")?; + + let sha = self + .run_command(&["rev-parse", "--short", "HEAD"])? + .trim() + .to_string(); + + Ok(SquashOutcome::Squashed(sha)) + } + + fn committable_diff_for_prompt(&self, _path: &Path) -> anyhow::Result<(String, String)> { + let diff = self.run_command(&[ + "-c", + "diff.noprefix=false", + "-c", + "diff.mnemonicPrefix=false", + "--no-pager", + "diff", + "--staged", + ])?; + let stat = self.run_command(&["--no-pager", "diff", "--staged", "--stat"])?; + Ok((diff, stat)) + } + + fn list_ignored_entries(&self, path: &Path) -> anyhow::Result> { + let output = crate::shell_exec::Cmd::new("git") + .args([ + "ls-files", + "--ignored", + "--exclude-standard", + "-o", + "--directory", + ]) + .current_dir(path) + .context(crate::git::path_to_logging_context(path)) + .run() + .context("Failed to run git ls-files")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git ls-files failed: {}", stderr.trim()); + } + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(|line| { + let is_dir = line.ends_with('/'); + let entry_path = path.join(line.trim_end_matches('/')); + (entry_path, is_dir) + }) + .collect()) + } + + fn has_staging_area(&self) -> bool { + true + } + + fn load_project_config(&self) -> anyhow::Result> { + Repository::load_project_config(self) + } + + fn wt_logs_dir(&self) -> PathBuf { + Repository::wt_logs_dir(self) + } + + fn switch_previous(&self) -> Option { + self.run_command(&["config", "--get", "worktrunk.history"]) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } + + fn set_switch_previous(&self, name: Option<&str>) -> anyhow::Result<()> { + if let Some(prev) = name { + self.run_command(&["config", "worktrunk.history", prev])?; + } + // If name is None (detached HEAD), don't update history + Ok(()) + } + + fn clear_switch_previous(&self) -> anyhow::Result { + Ok(self + .run_command(&["config", "--unset", "worktrunk.history"]) + .is_ok()) + } + + fn branch_marker(&self, name: &str) -> Option { + #[derive(serde::Deserialize)] + struct MarkerValue { + marker: Option, + } + + let config_key = format!("worktrunk.state.{name}.marker"); + let raw = self + .run_command(&["config", "--get", &config_key]) + .ok() + .map(|output| output.trim().to_string()) + .filter(|s| !s.is_empty())?; + + let parsed: MarkerValue = serde_json::from_str(&raw).ok()?; + parsed.marker + } + + fn set_branch_marker(&self, name: &str, marker: &str, timestamp: u64) -> anyhow::Result<()> { + let json = serde_json::json!({ + "marker": marker, + "set_at": timestamp + }); + let config_key = format!("worktrunk.state.{name}.marker"); + self.run_command(&["config", &config_key, &json.to_string()])?; + Ok(()) + } + + fn clear_branch_marker(&self, name: &str) -> bool { + let config_key = format!("worktrunk.state.{name}.marker"); + self.run_command(&["config", "--unset", &config_key]) + .is_ok() + } + + fn list_all_markers(&self) -> Vec<(String, String, u64)> { + let output = self + .run_command(&["config", "--get-regexp", r"^worktrunk\.state\..+\.marker$"]) + .unwrap_or_default(); + + let mut markers = Vec::new(); + for line in output.lines() { + let Some((key, value)) = line.split_once(' ') else { + continue; + }; + let Some(branch) = key + .strip_prefix("worktrunk.state.") + .and_then(|s| s.strip_suffix(".marker")) + else { + continue; + }; + let Ok(parsed) = serde_json::from_str::(value) else { + continue; + }; + let Some(marker) = parsed.get("marker").and_then(|v| v.as_str()) else { + continue; + }; + let set_at = parsed.get("set_at").and_then(|v| v.as_u64()).unwrap_or(0); + markers.push((branch.to_string(), marker.to_string(), set_at)); + } + + markers.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0))); + markers + } + + fn clear_all_markers(&self) -> usize { + let markers = self.list_all_markers(); + let count = markers.len(); + for (branch, _, _) in markers { + self.clear_branch_marker(&branch); + } + count + } + + fn has_shown_hint(&self, name: &str) -> bool { + self.run_command(&["config", "--get", &format!("worktrunk.hints.{name}")]) + .is_ok() + } + + fn mark_hint_shown(&self, name: &str) -> anyhow::Result<()> { + self.run_command(&["config", &format!("worktrunk.hints.{name}"), "true"])?; + Ok(()) + } + + fn clear_hint(&self, name: &str) -> anyhow::Result { + match self.run_command(&["config", "--unset", &format!("worktrunk.hints.{name}")]) { + Ok(_) => Ok(true), + Err(_) => Ok(false), + } + } + + fn list_shown_hints(&self) -> Vec { + self.run_command(&["config", "--get-regexp", r"^worktrunk\.hints\."]) + .unwrap_or_default() + .lines() + .filter_map(|line| { + line.split_whitespace() + .next() + .and_then(|key| key.strip_prefix("worktrunk.hints.")) + .map(String::from) + }) + .collect() + } + + fn clear_all_hints(&self) -> anyhow::Result { + let hints = self.list_shown_hints(); + let count = hints.len(); + for hint in hints { + self.clear_hint(&hint)?; + } + Ok(count) + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +/// Warn about untracked files that will be auto-staged. +fn warn_if_auto_staging_untracked(repo: &Repository) -> anyhow::Result<()> { + let status = repo + .run_command(&["status", "--porcelain", "-z"]) + .context("Failed to get status")?; + + let files = crate::git::parse_untracked_files(&status); + if files.is_empty() { + return Ok(()); + } + + let count = files.len(); + let path_word = if count == 1 { "path" } else { "paths" }; + eprintln!( + "{}", + warning_message(format!("Auto-staging {count} untracked {path_word}:")) + ); + + let joined_files = files.join("\n"); + eprintln!("{}", format_with_gutter(&joined_files, None)); + + Ok(()) +} + +/// Print push progress: commit count, graph, and diffstat. +/// +/// Emits the `◎ {verb} N commit(s) to TARGET @ SHA{notes}` progress message, +/// followed by the commit graph and diffstat with gutter formatting. +fn show_push_preview( + repo: &Repository, + target: &str, + commit_count: usize, + range: &str, + display: &LocalPushDisplay<'_>, +) { + let commit_text = if commit_count == 1 { + "commit" + } else { + "commits" + }; + let head_sha = repo + .run_command(&["rev-parse", "--short", "HEAD"]) + .unwrap_or_default(); + let head_sha = head_sha.trim(); + let verb = display.verb; + let notes = display.notes; + + eprintln!( + "{}", + progress_message(cformat!( + "{verb} {commit_count} {commit_text} to {target} @ {head_sha}{notes}" + )) + ); + + // Commit graph + if let Ok(log_output) = + repo.run_command(&["log", "--color=always", "--graph", "--oneline", range]) + { + eprintln!("{}", format_with_gutter(&log_output, None)); + } + + // Diffstat + let term_width = get_terminal_width(); + let stat_width = term_width.saturating_sub(GUTTER_OVERHEAD); + if let Ok(diff_stat) = repo.run_command(&[ + "diff", + "--color=always", + "--stat", + &format!("--stat-width={stat_width}"), + range, + ]) { + let diff_stat = diff_stat.trim_end(); + if !diff_stat.is_empty() { + eprintln!("{}", format_with_gutter(diff_stat, None)); + } + } +} + +/// Auto-stash non-conflicting dirty changes in the target worktree. +/// +/// Returns `Some((path, stash_ref))` if changes were stashed, `None` otherwise. +/// Errors if the target worktree has dirty files that overlap with the push range. +fn stash_target_if_dirty( + repo: &Repository, + target_wt_path: Option<&PathBuf>, + target: &str, +) -> anyhow::Result> { + let Some(wt_path) = target_wt_path else { + return Ok(None); + }; + if !wt_path.exists() { + return Ok(None); + } + + let wt = repo.worktree_at(wt_path); + if !wt.is_dirty()? { + return Ok(None); + } + + // Check for overlapping files + let push_files = repo.changed_files(target, "HEAD")?; + let wt_status = wt.run_command(&["status", "--porcelain", "-z"])?; + let wt_files = parse_porcelain_z(&wt_status); + + let overlapping: Vec = push_files + .iter() + .filter(|f| wt_files.contains(f)) + .cloned() + .collect(); + + if !overlapping.is_empty() { + return Err(GitError::ConflictingChanges { + target_branch: target.to_string(), + files: overlapping, + worktree_path: wt_path.clone(), + } + .into()); + } + + // Stash non-conflicting changes + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let stash_name = format!( + "worktrunk autostash::{}::{}::{}", + target, + std::process::id(), + nanos + ); + + eprintln!( + "{}", + progress_message(cformat!( + "Stashing changes in {}...", + format_path_for_display(wt_path) + )) + ); + + wt.run_command(&["stash", "push", "--include-untracked", "-m", &stash_name])?; + + // Find the stash ref + let list_output = wt.run_command(&["stash", "list", "--format=%gd%x00%gs%x00"])?; + let mut parts = list_output.split('\0'); + while let Some(id) = parts.next() { + if id.is_empty() { + continue; + } + if let Some(message) = parts.next() + && (message == stash_name || message.ends_with(&stash_name)) + { + return Ok(Some((wt_path.clone(), id.to_string()))); + } + } + + // Stash entry not found — verify worktree is clean (stash may have been empty) + if wt.is_dirty()? { + anyhow::bail!( + "Failed to stash changes in {}; worktree still has uncommitted changes", + format_path_for_display(wt_path) + ); + } + + // Worktree is clean and no stash entry — nothing needed to be stashed + Ok(None) +} + +/// Restore a previously created stash (best-effort). +fn restore_stash(repo: &Repository, wt_path: &Path, stash_ref: &str) { + eprintln!( + "{}", + progress_message(cformat!( + "Restoring stashed changes in {}...", + format_path_for_display(wt_path) + )) + ); + + let success = repo + .worktree_at(wt_path) + .run_command(&["stash", "pop", stash_ref]) + .is_ok(); + + if !success { + eprintln!( + "{}", + warning_message(cformat!( + "Failed to restore stash {stash_ref}; run git stash pop {stash_ref} in {path}", + path = format_path_for_display(wt_path), + )) + ); + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::git::WorktreeInfo; + + use super::super::WorkspaceItem; + + #[test] + fn test_from_worktree_with_branch() { + let wt = WorktreeInfo { + path: PathBuf::from("/repos/myrepo.feature"), + head: "abc123".into(), + branch: Some("feature".into()), + bare: false, + detached: false, + locked: None, + prunable: None, + }; + + let item = WorkspaceItem::from_worktree(wt, false); + + assert_eq!(item.name, "feature"); + assert_eq!(item.head, "abc123"); + assert_eq!(item.branch, Some("feature".into())); + assert_eq!(item.path, PathBuf::from("/repos/myrepo.feature")); + assert!(!item.is_default); + } + + #[test] + fn test_from_worktree_detached() { + let wt = WorktreeInfo { + path: PathBuf::from("/repos/myrepo.detached"), + head: "def456".into(), + branch: None, + bare: false, + detached: true, + locked: None, + prunable: None, + }; + + let item = WorkspaceItem::from_worktree(wt, true); + + // Falls back to directory name when no branch + assert_eq!(item.name, "myrepo.detached"); + assert_eq!(item.head, "def456"); + assert_eq!(item.branch, None); + assert!(item.is_default); + } + + #[test] + fn test_from_worktree_locked() { + let wt = WorktreeInfo { + path: PathBuf::from("/repos/myrepo.locked"), + head: "789abc".into(), + branch: Some("locked-branch".into()), + bare: false, + detached: false, + locked: Some("in use".into()), + prunable: None, + }; + + let item = WorkspaceItem::from_worktree(wt, false); + + assert_eq!(item.locked, Some("in use".into())); + assert_eq!(item.prunable, None); + } + + #[test] + fn test_from_worktree_prunable() { + let wt = WorktreeInfo { + path: PathBuf::from("/repos/myrepo.gone"), + head: "000000".into(), + branch: Some("gone-branch".into()), + bare: false, + detached: false, + locked: None, + prunable: Some("directory missing".into()), + }; + + let item = WorkspaceItem::from_worktree(wt, false); + + assert_eq!(item.prunable, Some("directory missing".into())); + } + + /// Exercise all `Workspace` trait methods on a real git repository. + /// + /// This covers the `Workspace for Repository` implementation which + /// maps `Repository` methods into the VCS-agnostic trait. + #[test] + fn test_workspace_trait_on_real_repo() { + use std::process::Command; + + use super::super::{VcsKind, Workspace}; + use crate::git::Repository; + + let temp = tempfile::tempdir().unwrap(); + let repo_path = temp.path().join("repo"); + std::fs::create_dir(&repo_path).unwrap(); + + let git = |args: &[&str]| { + let output = Command::new("git") + .args(args) + .current_dir(&repo_path) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@test.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@test.com") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + }; + + git(&["init", "-b", "main"]); + git(&["config", "user.name", "Test"]); + git(&["config", "user.email", "test@test.com"]); + std::fs::write(repo_path.join("file.txt"), "hello\n").unwrap(); + git(&["add", "."]); + git(&["commit", "-m", "initial"]); + + let repo = Repository::at(&repo_path).unwrap(); + let ws: &dyn Workspace = &repo; + + // kind + assert_eq!(ws.kind(), VcsKind::Git); + + // has_staging_area + assert!(ws.has_staging_area()); + + // list_workspaces + let items = ws.list_workspaces().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].name, "main"); + assert!(items[0].is_default); + + // default_workspace_path + assert!(ws.default_workspace_path().unwrap().is_some()); + + // default_branch_name + let _ = ws.default_branch_name(); + + // is_dirty — clean state + assert!(!ws.is_dirty(&repo_path).unwrap()); + + // working_diff — clean + let diff = ws.working_diff(&repo_path).unwrap(); + assert_eq!(diff.added, 0); + assert_eq!(diff.deleted, 0); + + // root_path + let root = ws.root_path().unwrap(); + assert!(root.exists()); + + // current_workspace_path + let cur_path = ws.current_workspace_path().unwrap(); + assert!(cur_path.exists()); + + // current_name + let name = ws.current_name(&repo_path).unwrap(); + assert_eq!(name, Some("main".to_string())); + + // project_identifier — derived from path without remote + assert!(ws.project_identifier().is_ok()); + + // feature_head — always "HEAD" for git + assert_eq!(ws.feature_head(&repo_path).unwrap(), "HEAD"); + + // recent_subjects + let subjects = ws.recent_subjects(None, 5).unwrap(); + assert!(subjects.contains(&"initial".to_string())); + + // load_project_config — no .config/wt.toml in the test tempdir + // (may find one from parent project when run inside worktrunk repo) + let _ = ws.load_project_config(); + + // wt_logs_dir + let logs_dir = ws.wt_logs_dir(); + assert!(!logs_dir.as_os_str().is_empty()); + + // Make dirty + std::fs::write(repo_path.join("file.txt"), "modified\n").unwrap(); + assert!(ws.is_dirty(&repo_path).unwrap()); + let diff = ws.working_diff(&repo_path).unwrap(); + assert!(diff.added > 0 || diff.deleted > 0); + git(&["checkout", "--", "."]); + + // Create feature branch with a commit + git(&["checkout", "-b", "feature"]); + std::fs::write(repo_path.join("feature.txt"), "feature\n").unwrap(); + git(&["add", "."]); + git(&["commit", "-m", "feature commit"]); + + // ahead_behind + let (ahead, behind) = ws.ahead_behind("main", "feature").unwrap(); + assert_eq!(ahead, 1); + assert_eq!(behind, 0); + + // is_integrated — feature is NOT an ancestor of main + let integrated = ws.is_integrated("feature", "main").unwrap(); + assert!(integrated.is_none()); + + // branch_diff_stats + let diff = ws.branch_diff_stats("main", "feature").unwrap(); + assert!(diff.added > 0); + + // Switch back to main for workspace mutation tests + git(&["checkout", "main"]); + + // create_workspace (also covers Repository::create_worktree) + let wt_path = temp.path().join("test-wt"); + ws.create_workspace("test-branch", None, &wt_path).unwrap(); + + // workspace_path — found by branch name + let path = ws.workspace_path("test-branch").unwrap(); + assert_eq!( + dunce::canonicalize(&path).unwrap(), + dunce::canonicalize(&wt_path).unwrap() + ); + + // remove_workspace + ws.remove_workspace("test-branch").unwrap(); + + // workspace_path — not found + assert!(ws.workspace_path("nonexistent").is_err()); + + // create_workspace with base revision (covers the `if let Some(base_ref)` branch) + let wt_path2 = temp.path().join("test-wt2"); + ws.create_workspace("from-feature", Some("feature"), &wt_path2) + .unwrap(); + ws.remove_workspace("from-feature").unwrap(); + + // commit via Workspace trait — create a worktree, stage a file, commit + let commit_wt = temp.path().join("commit-test"); + ws.create_workspace("commit-branch", Some("main"), &commit_wt) + .unwrap(); + std::fs::write(commit_wt.join("new.txt"), "new content\n").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(&commit_wt) + .output() + .unwrap(); + let sha = ws.commit("trait commit", &commit_wt).unwrap(); + assert!(!sha.is_empty()); + + // commit_subjects — verify the commit we just made + let subjects = ws.commit_subjects("main", "commit-branch").unwrap(); + assert!(subjects.contains(&"trait commit".to_string())); + + // local_push via Workspace trait — local push of feature onto main + let repo_at_wt = Repository::at(&commit_wt).unwrap(); + let ws_at_wt: &dyn Workspace = &repo_at_wt; + let push_result = ws_at_wt + .local_push("main", &commit_wt, Default::default()) + .unwrap(); + assert_eq!(push_result.commit_count, 1); + + // local_push with zero commits ahead — returns early + let push_result = ws_at_wt + .local_push("main", &commit_wt, Default::default()) + .unwrap(); + assert_eq!(push_result.commit_count, 0); + + // Helper for git commands in linked worktrees + let git_at = |args: &[&str], dir: &std::path::Path| { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@test.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@test.com") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + }; + + // committable_diff_for_prompt — stage a file in the worktree + let staged_wt = temp.path().join("staged-test"); + ws.create_workspace("staged-branch", Some("main"), &staged_wt) + .unwrap(); + std::fs::write(staged_wt.join("staged.txt"), "staged content\n").unwrap(); + git_at(&["add", "."], &staged_wt); + let repo_at_staged = Repository::at(&staged_wt).unwrap(); + let ws_staged: &dyn Workspace = &repo_at_staged; + let (diff_text, stat_text) = ws_staged.committable_diff_for_prompt(&staged_wt).unwrap(); + assert!(!diff_text.is_empty()); + assert!(!stat_text.is_empty()); + git_at(&["commit", "-m", "staged commit"], &staged_wt); + ws.remove_workspace("staged-branch").unwrap(); + + ws.remove_workspace("commit-branch").unwrap(); + + // diff_for_prompt — feature has changes vs main + let (diff_text, stat_text) = ws.diff_for_prompt("main", "feature", &repo_path).unwrap(); + assert!(diff_text.contains("feature.txt")); + assert!(stat_text.contains("feature.txt")); + + // is_rebased_onto — branch created from main is rebased onto main + let rb_wt = temp.path().join("rb-check"); + ws.create_workspace("rb-check", Some("main"), &rb_wt) + .unwrap(); + git_at(&["commit", "--allow-empty", "-m", "ahead of main"], &rb_wt); + let repo_at_rb = Repository::at(&rb_wt).unwrap(); + let ws_rb: &dyn Workspace = &repo_at_rb; + assert!(ws_rb.is_rebased_onto("main", &rb_wt).unwrap()); + ws.remove_workspace("rb-check").unwrap(); + + // resolve_integration_target with explicit target + assert_eq!(ws.resolve_integration_target(Some("main")).unwrap(), "main"); + + // rebase_onto — fast-forward case: create branch at main~1, rebase onto main + git(&["checkout", "main"]); + std::fs::write(repo_path.join("main-only.txt"), "main content\n").unwrap(); + git(&["add", "."]); + git(&["commit", "-m", "advance main"]); + + let rebase_wt = temp.path().join("rebase-test"); + ws.create_workspace("rebase-ff", Some("main~1"), &rebase_wt) + .unwrap(); + let repo_at_rebase = Repository::at(&rebase_wt).unwrap(); + let ws_rebase: &dyn Workspace = &repo_at_rebase; + let outcome = ws_rebase.rebase_onto("main", &rebase_wt).unwrap(); + assert!(matches!(outcome, super::super::RebaseOutcome::FastForward)); + ws.remove_workspace("rebase-ff").unwrap(); + + // rebase_onto — real rebase case: diverged branches + let rebase_wt2 = temp.path().join("rebase-test2"); + ws.create_workspace("rebase-diverged", Some("feature"), &rebase_wt2) + .unwrap(); + std::fs::write(rebase_wt2.join("diverge.txt"), "diverged\n").unwrap(); + git_at(&["add", "."], &rebase_wt2); + git_at(&["commit", "-m", "diverged commit"], &rebase_wt2); + let repo_at_rebase2 = Repository::at(&rebase_wt2).unwrap(); + let ws_rebase2: &dyn Workspace = &repo_at_rebase2; + let outcome = ws_rebase2.rebase_onto("main", &rebase_wt2).unwrap(); + assert!(matches!(outcome, super::super::RebaseOutcome::Rebased)); + ws.remove_workspace("rebase-diverged").unwrap(); + + // local_push with dirty target worktree — exercises stash/restore path + // Make main worktree dirty with a non-conflicting file + std::fs::write(repo_path.join("dirty.txt"), "dirty\n").unwrap(); + // Create a new branch with a commit to push onto main + let stash_wt = temp.path().join("stash-push-test"); + ws.create_workspace("stash-push-branch", Some("main"), &stash_wt) + .unwrap(); + std::fs::write(stash_wt.join("push-me.txt"), "push content\n").unwrap(); + git_at(&["add", "."], &stash_wt); + git_at(&["commit", "-m", "push with stash"], &stash_wt); + let repo_at_stash = Repository::at(&stash_wt).unwrap(); + let ws_stash: &dyn Workspace = &repo_at_stash; + let push_result = ws_stash + .local_push("main", &stash_wt, Default::default()) + .unwrap(); + assert_eq!(push_result.commit_count, 1); + // Verify dirty file in main worktree was preserved after stash/restore + assert!(repo_path.join("dirty.txt").exists()); + // Clean up + git(&["checkout", "--", "."]); + std::fs::remove_file(repo_path.join("dirty.txt")).ok(); + ws.remove_workspace("stash-push-branch").unwrap(); + + // rebase_onto — conflict case: both branches modify the same file + // First, create a branch from the current main + let conflict_wt = temp.path().join("conflict-test"); + ws.create_workspace("conflict-branch", Some("main"), &conflict_wt) + .unwrap(); + // On the branch, modify file.txt + std::fs::write(conflict_wt.join("file.txt"), "branch version\n").unwrap(); + git_at(&["add", "."], &conflict_wt); + git_at(&["commit", "-m", "branch modifies file.txt"], &conflict_wt); + // On main, also modify file.txt differently (creating divergence) + git(&["checkout", "main"]); + std::fs::write(repo_path.join("file.txt"), "main version\n").unwrap(); + git(&["add", "."]); + git(&["commit", "-m", "main modifies file.txt"]); + // Now rebase conflict-branch onto main → should conflict + let repo_at_conflict = Repository::at(&conflict_wt).unwrap(); + let ws_conflict: &dyn Workspace = &repo_at_conflict; + let err = ws_conflict.rebase_onto("main", &conflict_wt).unwrap_err(); + // Verify it's a rebase conflict error, then abort the rebase + assert!( + err.to_string().contains("conflict") + || err.downcast_ref::().is_some() + ); + // Abort the in-progress rebase to clean up + let _ = repo_at_conflict.run_command(&["rebase", "--abort"]); + ws.remove_workspace("conflict-branch").unwrap(); + + // workspace_path by directory name — fallback when branch name doesn't match + // Create a worktree and look it up by its directory name (not branch name) + let dirname_wt = temp.path().join("dir-lookup-test"); + ws.create_workspace("dir-lookup-branch", Some("main"), &dirname_wt) + .unwrap(); + // Look up by the directory name (not the branch name) + let found_path = ws.workspace_path("dir-lookup-test").unwrap(); + assert_eq!( + dunce::canonicalize(&found_path).unwrap(), + dunce::canonicalize(&dirname_wt).unwrap() + ); + ws.remove_workspace("dir-lookup-branch").unwrap(); + + // feature_head via Workspace trait — should return "HEAD" for git + let fh_wt = temp.path().join("fh-test"); + ws.create_workspace("fh-branch", Some("main"), &fh_wt) + .unwrap(); + let repo_at_fh = Repository::at(&fh_wt).unwrap(); + let ws_fh: &dyn Workspace = &repo_at_fh; + assert_eq!(ws_fh.feature_head(&fh_wt).unwrap(), "HEAD"); + ws.remove_workspace("fh-branch").unwrap(); + + // switch_previous — initially None (no history set yet) + assert!(ws.switch_previous().is_none()); + + // set_switch_previous — set a value + ws.set_switch_previous(Some("feature")).unwrap(); + assert_eq!(ws.switch_previous(), Some("feature".to_string())); + + // set_switch_previous(None) is a no-op for git (preserves history on detached HEAD) + ws.set_switch_previous(None).unwrap(); + assert_eq!(ws.switch_previous(), Some("feature".to_string())); + + // as_any downcast + let repo_ref = ws.as_any().downcast_ref::(); + assert!(repo_ref.is_some()); + + // build_worktree_map — covers the map-building logic in mod.rs + let map = super::super::build_worktree_map(ws); + assert!(!map.is_empty()); + assert!(map.contains_key("main")); + } +} diff --git a/src/workspace/jj.rs b/src/workspace/jj.rs new file mode 100644 index 000000000..1a15735a2 --- /dev/null +++ b/src/workspace/jj.rs @@ -0,0 +1,911 @@ +//! Jujutsu (jj) implementation of the [`Workspace`] trait. +//! +//! Implements workspace operations by shelling out to `jj` commands +//! and parsing their output. + +use std::any::Any; +use std::path::{Path, PathBuf}; + +use anyhow::Context; +use color_print::cformat; + +use super::types::{IntegrationReason, LineDiff, LocalPushDisplay}; +use crate::config::StageMode; +use crate::shell_exec::Cmd; +use crate::styling::{eprintln, progress_message}; + +use super::{LocalPushResult, RebaseOutcome, SquashOutcome, VcsKind, Workspace, WorkspaceItem}; + +/// Jujutsu-backed workspace implementation. +/// +/// Wraps a jj repository root path and implements [`Workspace`] by running +/// `jj` CLI commands. Each method shells out to the appropriate `jj` subcommand. +#[derive(Debug, Clone)] +pub struct JjWorkspace { + /// Root directory of the jj repository. + root: PathBuf, +} + +impl JjWorkspace { + /// Create a new `JjWorkspace` rooted at the given path. + pub fn new(root: PathBuf) -> Self { + Self { root } + } + + /// Detect and create a `JjWorkspace` from the current directory. + /// + /// Runs `jj root` to find the repository root. + pub fn from_current_dir() -> anyhow::Result { + let stdout = run_jj_command(Path::new("."), &["root"])?; + Ok(Self::new(PathBuf::from(stdout.trim()))) + } + + /// The repository root path. + pub fn root(&self) -> &Path { + &self.root + } + + /// Run a jj command in this repository's root directory. + fn run_command(&self, args: &[&str]) -> anyhow::Result { + run_jj_command(&self.root, args) + } + + /// Run a jj command in the specified directory. + /// + /// Unlike `run_command` which runs in the repo root, this runs in the given + /// directory — needed when commands must execute in a specific workspace. + pub fn run_in_dir(&self, dir: &Path, args: &[&str]) -> anyhow::Result { + run_jj_command(dir, args) + } + + /// Find which workspace contains the given directory. + /// + /// Returns the `WorkspaceItem` whose path is an ancestor of `cwd`. + pub fn current_workspace(&self, cwd: &Path) -> anyhow::Result { + let workspaces = self.list_workspaces()?; + let cwd = dunce::canonicalize(cwd)?; + workspaces + .into_iter() + .find(|ws| { + dunce::canonicalize(&ws.path) + .map(|p| cwd.starts_with(&p)) + .unwrap_or(false) + }) + .ok_or_else(|| anyhow::anyhow!("Not inside a jj workspace")) + } + + /// Detect the bookmark name associated with `trunk()`. + /// + /// Returns `None` if no bookmarks are found on the `trunk()` revset + /// (common in local-only repos without remote tracking). + fn trunk_bookmark(&self) -> anyhow::Result> { + let output = self.run_command(&[ + "log", + "-r", + "trunk()", + "--no-graph", + "-T", + r#"self.bookmarks().map(|b| b.name()).join("\n")"#, + ])?; + let bookmarks: Vec<&str> = output.trim().lines().filter(|l| !l.is_empty()).collect(); + + // Prefer "main", then "master", then first found + if bookmarks.contains(&"main") { + Ok(Some("main".to_string())) + } else if bookmarks.contains(&"master") { + Ok(Some("master".to_string())) + } else { + Ok(bookmarks.first().map(|s| s.to_string())) + } + } + + /// Check if common default branch bookmarks ("main", "master") exist locally. + /// + /// Used as a fallback when `trunk()` doesn't resolve to a bookmark (e.g., + /// local-only repos without remote tracking). + fn find_local_default_bookmark(&self) -> Option { + let output = self + .run_command(&["bookmark", "list", "-T", r#"name ++ "\n""#]) + .ok()?; + let bookmarks: Vec<&str> = output.lines().filter(|l| !l.is_empty()).collect(); + for candidate in ["main", "master"] { + if bookmarks.contains(&candidate) { + return Some(candidate.to_string()); + } + } + None + } + + /// Determine the feature tip change ID. + /// + /// In jj, the working copy (@) is often an empty auto-snapshot commit. + /// When @ is empty, the real feature tip is @- (the parent). + pub fn feature_tip(&self, ws_path: &Path) -> anyhow::Result { + let empty_check = run_jj_command( + ws_path, + &[ + "log", + "-r", + "@", + "--no-graph", + "-T", + r#"if(self.empty(), "empty", "content")"#, + ], + )?; + + let revset = if empty_check.trim() == "empty" { + "@-" + } else { + "@" + }; + + let output = run_jj_command( + ws_path, + &[ + "log", + "-r", + revset, + "--no-graph", + "-T", + r#"self.change_id().short(12)"#, + ], + )?; + + Ok(output.trim().to_string()) + } + + /// Get commit details (timestamp, description) for the working-copy commit + /// in a specific workspace directory. + /// + /// Returns `(unix_timestamp, first_line_of_description)`. + pub fn commit_details(&self, ws_path: &Path) -> anyhow::Result<(i64, String)> { + let template = r#"self.committer().timestamp().utc().format("%s") ++ "\t" ++ self.description().first_line()"#; + let output = run_jj_command(ws_path, &["log", "-r", "@", "--no-graph", "-T", template])?; + let line = output.trim(); + let (timestamp_str, message) = line + .split_once('\t') + .ok_or_else(|| anyhow::anyhow!("unexpected commit details format: {line}"))?; + let timestamp = timestamp_str + .parse::() + .with_context(|| format!("invalid timestamp: {timestamp_str}"))?; + Ok((timestamp, message.to_string())) + } +} + +/// Run a jj command at the given directory, returning stdout on success. +fn run_jj_command(dir: &Path, args: &[&str]) -> anyhow::Result { + let mut cmd_args = vec!["--no-pager", "--color", "never"]; + cmd_args.extend_from_slice(args); + + let output = Cmd::new("jj") + .args(cmd_args.iter().copied()) + .current_dir(dir) + .run() + .with_context(|| format!("Failed to execute: jj {}", args.join(" ")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let error_msg = [stderr.trim(), stdout.trim()] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join("\n"); + anyhow::bail!("{}", error_msg); + } + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// Parse the summary line from `jj diff --stat` output. +/// +/// Format: `N files changed, N insertions(+), N deletions(-)` +/// Returns `(insertions, deletions)`. +fn parse_diff_stat_summary(output: &str) -> LineDiff { + // The summary line is the last non-empty line + let summary = output.lines().rev().find(|l| !l.is_empty()).unwrap_or(""); + + let mut added = 0usize; + let mut deleted = 0usize; + + // Parse "N insertions(+)" and "N deletions(-)" + for part in summary.split(", ") { + let part = part.trim(); + if part.contains("insertion") + && let Some(n) = part.split_whitespace().next().and_then(|s| s.parse().ok()) + { + added = n; + } else if part.contains("deletion") + && let Some(n) = part.split_whitespace().next().and_then(|s| s.parse().ok()) + { + deleted = n; + } + } + + LineDiff { added, deleted } +} + +impl Workspace for JjWorkspace { + fn kind(&self) -> VcsKind { + VcsKind::Jj + } + + fn list_workspaces(&self) -> anyhow::Result> { + // Template outputs: name\tchange_id_short\n + let template = r#"name ++ "\t" ++ target.change_id().short(12) ++ "\n""#; + let output = self.run_command(&["workspace", "list", "-T", template])?; + + let mut items = Vec::new(); + for line in output.lines() { + if line.is_empty() { + continue; + } + let Some((name, change_id)) = line.split_once('\t') else { + continue; + }; + + // Get workspace path + let path_output = self.run_command(&["workspace", "root", "--name", name])?; + let path = PathBuf::from(path_output.trim()); + + let is_default = name == "default"; + + items.push(WorkspaceItem { + path, + name: name.to_string(), + head: change_id.to_string(), + branch: None, + is_default, + locked: None, + prunable: None, + }); + } + + Ok(items) + } + + fn workspace_path(&self, name: &str) -> anyhow::Result { + let output = self.run_command(&["workspace", "root", "--name", name])?; + Ok(PathBuf::from(output.trim())) + } + + fn default_workspace_path(&self) -> anyhow::Result> { + // Try "default" workspace; if it doesn't exist, return None + match self.run_command(&["workspace", "root", "--name", "default"]) { + Ok(output) => Ok(Some(PathBuf::from(output.trim()))), + Err(_) => Ok(None), + } + } + + fn default_branch_name(&self) -> Option { + // Check explicit config override first + if let Some(name) = self + .run_command(&["config", "get", "worktrunk.default-branch"]) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + { + return Some(name); + } + // Fall back to trunk() revset detection (requires remote bookmarks) + if let Some(name) = self.trunk_bookmark().ok().flatten() { + return Some(name); + } + // Local-only repos: check if common default branch bookmarks exist + self.find_local_default_bookmark() + } + + fn set_default_branch(&self, name: &str) -> anyhow::Result<()> { + self.run_command(&["config", "set", "--repo", "worktrunk.default-branch", name])?; + Ok(()) + } + + fn clear_default_branch(&self) -> anyhow::Result { + Ok(self + .run_command(&["config", "unset", "--repo", "worktrunk.default-branch"]) + .is_ok()) + } + + fn is_dirty(&self, path: &Path) -> anyhow::Result { + // jj auto-snapshots the working copy, so "dirty" means the working-copy + // commit has file changes (is not empty) + let output = run_jj_command( + path, + &[ + "log", + "-r", + "@", + "--no-graph", + "-T", + r#"if(self.empty(), "clean", "dirty")"#, + ], + )?; + Ok(output.trim() == "dirty") + } + + fn working_diff(&self, path: &Path) -> anyhow::Result { + let output = run_jj_command(path, &["diff", "--stat"])?; + Ok(parse_diff_stat_summary(&output)) + } + + fn ahead_behind(&self, base: &str, head: &str) -> anyhow::Result<(usize, usize)> { + // Count commits in head that aren't in base (ahead) + let ahead_revset = format!("{base}..{head}"); + let ahead_output = + self.run_command(&["log", "-r", &ahead_revset, "--no-graph", "-T", r#""x\n""#])?; + let ahead = ahead_output.lines().filter(|l| !l.is_empty()).count(); + + // Count commits in base that aren't in head (behind) + let behind_revset = format!("{head}..{base}"); + let behind_output = + self.run_command(&["log", "-r", &behind_revset, "--no-graph", "-T", r#""x\n""#])?; + let behind = behind_output.lines().filter(|l| !l.is_empty()).count(); + + Ok((ahead, behind)) + } + + fn is_integrated(&self, id: &str, target: &str) -> anyhow::Result> { + // TODO: Implement full integration reasons for jj (matching git's 5 variants). + // Currently only detects Ancestor via revset. Could distinguish: + // - SameCommit: `{id} & {target}` (exact same change) + // - Ancestor: `{id} & ::{target}` (current check) + // - NoAddedChanges: `jj diff --from {target} --to {id}` is empty + // - TreesMatch / MergeAddsNothing: compare tree hashes or simulate merge + // See git implementation in src/git/repository/integration.rs for reference. + + // Check if the change is an ancestor of (or same as) the target + let revset = format!("{id} & ::{target}"); + let output = self.run_command(&["log", "-r", &revset, "--no-graph", "-T", r#""x""#])?; + + if !output.trim().is_empty() { + return Ok(Some(IntegrationReason::Ancestor)); + } + + Ok(None) + } + + fn branch_diff_stats(&self, base: &str, head: &str) -> anyhow::Result { + let output = self.run_command(&["diff", "--stat", "--from", base, "--to", head])?; + Ok(parse_diff_stat_summary(&output)) + } + + fn create_workspace(&self, name: &str, base: Option<&str>, path: &Path) -> anyhow::Result<()> { + let path_str = path.to_str().ok_or_else(|| { + anyhow::anyhow!("Workspace path contains invalid UTF-8: {}", path.display()) + })?; + + let mut args = vec!["workspace", "add", "--name", name, path_str]; + if let Some(revision) = base { + args.extend_from_slice(&["--revision", revision]); + } + self.run_command(&args)?; + Ok(()) + } + + fn remove_workspace(&self, name: &str) -> anyhow::Result<()> { + self.run_command(&["workspace", "forget", name])?; + Ok(()) + } + + fn resolve_integration_target(&self, target: Option<&str>) -> anyhow::Result { + match target { + Some(t) => Ok(t.to_string()), + None => self.default_branch_name().ok_or_else(|| { + anyhow::anyhow!("Cannot determine default branch. Specify target explicitly or run: wt config state default-branch set BRANCH") + }), + } + } + + fn is_rebased_onto(&self, target: &str, path: &Path) -> anyhow::Result { + let feature_tip = self.feature_tip(path)?; + // target is ancestor of feature tip iff "target & ::feature_tip" is non-empty + let check = run_jj_command( + path, + &[ + "log", + "-r", + &format!("{target} & ::{feature_tip}"), + "--no-graph", + "-T", + r#""x""#, + ], + )?; + Ok(!check.trim().is_empty()) + } + + fn rebase_onto(&self, target: &str, path: &Path) -> anyhow::Result { + eprintln!( + "{}", + progress_message(cformat!("Rebasing onto {target}...")) + ); + run_jj_command(path, &["rebase", "-b", "@", "-d", target])?; + // jj doesn't distinguish fast-forward from true rebase + Ok(RebaseOutcome::Rebased) + } + + fn root_path(&self) -> anyhow::Result { + Ok(self.root.clone()) + } + + fn current_workspace_path(&self) -> anyhow::Result { + let cwd = std::env::current_dir()?; + Ok(self.current_workspace(&cwd)?.path) + } + + fn current_name(&self, path: &Path) -> anyhow::Result> { + Ok(Some(self.current_workspace(path)?.name)) + } + + fn project_identifier(&self) -> anyhow::Result { + // Most jj repos are git-backed. Try to get the git remote URL for a stable + // project identifier (same clone = same ID, regardless of directory name). + if let Ok(output) = self.run_command(&["git", "remote", "list"]) { + // Format: "remote_name url\n" — prefer "origin" if present + let url = output + .lines() + .find(|l| l.starts_with("origin ")) + .or_else(|| output.lines().next()) + .and_then(|l| l.split_whitespace().nth(1)); + if let Some(url) = url { + return Ok(url.to_string()); + } + } + // Fallback: use directory name + self.root + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + .ok_or_else(|| anyhow::anyhow!("Repository path has no filename")) + } + + fn prepare_commit(&self, _path: &Path, _mode: StageMode) -> anyhow::Result<()> { + Ok(()) // jj auto-snapshots the working copy + } + + fn commit(&self, message: &str, path: &Path) -> anyhow::Result { + run_jj_command(path, &["describe", "-m", message])?; + run_jj_command(path, &["new"])?; + // Return the change ID of the just-described commit (now @-) + let output = run_jj_command( + path, + &[ + "log", + "-r", + "@-", + "--no-graph", + "-T", + r#"self.change_id().short(12)"#, + ], + )?; + Ok(output.trim().to_string()) + } + + fn commit_subjects(&self, base: &str, head: &str) -> anyhow::Result> { + let revset = format!("{base}..{head}"); + let output = run_jj_command( + &self.root, + &[ + "log", + "-r", + &revset, + "--no-graph", + "-T", + r#"self.description().first_line() ++ "\n""#, + ], + )?; + Ok(output + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect()) + } + + fn push_to_target(&self, target: &str, path: &Path) -> anyhow::Result<()> { + run_jj_command(path, &["git", "push", "--bookmark", target])?; + Ok(()) + } + + fn local_push( + &self, + target: &str, + path: &Path, + _display: LocalPushDisplay<'_>, + ) -> anyhow::Result { + // Guard: target must be an ancestor of the feature tip. + // Prevents moving the bookmark sideways or backward (which would lose commits). + if !self.is_rebased_onto(target, path)? { + anyhow::bail!( + "Cannot push: feature is not ahead of {target}. Rebase first with `wt step rebase`." + ); + } + + let feature_tip = self.feature_tip(path)?; + + // Count commits ahead of target + let revset = format!("{target}..{feature_tip}"); + let count_output = run_jj_command( + path, + &["log", "-r", &revset, "--no-graph", "-T", r#""x\n""#], + )?; + let commit_count = count_output.lines().filter(|l| !l.is_empty()).count(); + + if commit_count == 0 { + return Ok(LocalPushResult { + commit_count: 0, + stats_summary: Vec::new(), + }); + } + + // Move bookmark to feature tip (local only) + run_jj_command(path, &["bookmark", "set", target, "-r", &feature_tip])?; + + Ok(LocalPushResult { + commit_count, + stats_summary: Vec::new(), + }) + } + + fn feature_head(&self, path: &Path) -> anyhow::Result { + self.feature_tip(path) + } + + fn diff_for_prompt( + &self, + base: &str, + head: &str, + path: &Path, + ) -> anyhow::Result<(String, String)> { + let diff = run_jj_command(path, &["diff", "--from", base, "--to", head])?; + let stat = run_jj_command(path, &["diff", "--stat", "--from", base, "--to", head])?; + Ok((diff, stat)) + } + + fn recent_subjects(&self, start_ref: Option<&str>, count: usize) -> Option> { + let count_str = count.to_string(); + // ..@- = ancestors of parent (skip empty working-copy commit) + // ..{ref} = ancestors of the given ref (e.g., trunk bookmark) + let rev = match start_ref { + Some(r) => format!("..{r}"), + None => "..@-".to_string(), + }; + let output = self + .run_command(&[ + "log", + "--no-graph", + "-r", + &rev, + "--limit", + &count_str, + "-T", + r#"description.first_line() ++ "\n""#, + ]) + .ok()?; + + let subjects: Vec = output + .lines() + .filter(|l| !l.is_empty()) + .map(String::from) + .collect(); + + if subjects.is_empty() { + None + } else { + Some(subjects) + } + } + + fn squash_commits( + &self, + target: &str, + message: &str, + path: &Path, + ) -> anyhow::Result { + let feature_tip = self.feature_tip(path)?; + let from_revset = format!("{target}..{feature_tip}"); + + // Create empty commit on target, squash feature into it + run_jj_command(path, &["new", target])?; + run_jj_command( + path, + &[ + "squash", + "--from", + &from_revset, + "--into", + "@", + "-m", + message, + ], + )?; + + // Update bookmark + run_jj_command(path, &["bookmark", "set", target, "-r", "@"])?; + + // Return the change ID of the squashed commit + let output = run_jj_command( + path, + &[ + "log", + "-r", + "@", + "--no-graph", + "-T", + r#"self.change_id().short(12)"#, + ], + )?; + + Ok(SquashOutcome::Squashed(output.trim().to_string())) + } + + fn committable_diff_for_prompt(&self, path: &Path) -> anyhow::Result<(String, String)> { + let diff = run_jj_command(path, &["diff", "-r", "@"])?; + let stat = run_jj_command(path, &["diff", "-r", "@", "--stat"])?; + Ok((diff, stat)) + } + + fn list_ignored_entries(&self, path: &Path) -> anyhow::Result> { + // jj repos have a git backend — find the git dir + let git_dir = self.root.join(".git"); + if !git_dir.exists() { + anyhow::bail!( + "No git backend found at {}; copy-ignored requires a git backend", + git_dir.display() + ); + } + + let output = crate::shell_exec::Cmd::new("git") + .args([ + &format!("--git-dir={}", git_dir.display()), + &format!("--work-tree={}", path.display()), + "ls-files", + "--ignored", + "--exclude-standard", + "-o", + "--directory", + ]) + .current_dir(path) + .run() + .context("Failed to run git ls-files in jj workspace")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git ls-files failed: {}", stderr.trim()); + } + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|line| { + // Filter out .jj/ entries (jj internal directory, gitignored in default workspace) + let trimmed = line.trim_end_matches('/'); + trimmed != ".jj" && !trimmed.starts_with(".jj/") + }) + .map(|line| { + let is_dir = line.ends_with('/'); + let entry_path = path.join(line.trim_end_matches('/')); + (entry_path, is_dir) + }) + .collect()) + } + + fn has_staging_area(&self) -> bool { + false + } + + fn load_project_config(&self) -> anyhow::Result> { + crate::config::ProjectConfig::load_from_root(&self.root).map_err(|e| anyhow::anyhow!("{e}")) + } + + fn wt_logs_dir(&self) -> PathBuf { + self.root.join(".jj").join("wt-logs") + } + + fn switch_previous(&self) -> Option { + // Best-effort: read from jj repo config + self.run_command(&["config", "get", "worktrunk.history"]) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } + + fn set_switch_previous(&self, name: Option<&str>) -> anyhow::Result<()> { + if let Some(name) = name { + self.run_command(&["config", "set", "--repo", "worktrunk.history", name])?; + } + Ok(()) + } + + fn clear_switch_previous(&self) -> anyhow::Result { + Ok(self + .run_command(&["config", "unset", "--repo", "worktrunk.history"]) + .is_ok()) + } + + fn branch_marker(&self, name: &str) -> Option { + #[derive(serde::Deserialize)] + struct MarkerValue { + marker: Option, + } + + let config_key = format!("worktrunk.state.{name}.marker"); + let raw = self + .run_command(&["config", "get", &config_key]) + .ok() + .map(|output| output.trim().to_string()) + .filter(|s| !s.is_empty())?; + + // Try JSON format first, fall back to plain string + if let Ok(parsed) = serde_json::from_str::(&raw) { + parsed.marker + } else { + Some(raw) + } + } + + fn set_branch_marker(&self, name: &str, marker: &str, timestamp: u64) -> anyhow::Result<()> { + let json = serde_json::json!({ + "marker": marker, + "set_at": timestamp + }); + let config_key = format!("worktrunk.state.{name}.marker"); + self.run_command(&["config", "set", "--repo", &config_key, &json.to_string()])?; + Ok(()) + } + + fn clear_branch_marker(&self, name: &str) -> bool { + let config_key = format!("worktrunk.state.{name}.marker"); + self.run_command(&["config", "unset", "--repo", &config_key]) + .is_ok() + } + + fn list_all_markers(&self) -> Vec<(String, String, u64)> { + let output = self + .run_command(&[ + "config", + "list", + "--repo", + "--name", + "worktrunk.state", + "-T", + "name ++ \"\\t\" ++ value ++ \"\\n\"", + ]) + .unwrap_or_default(); + + let mut markers = Vec::new(); + for line in output.lines() { + let Some((key, value)) = line.split_once('\t') else { + continue; + }; + let Some(branch) = key + .strip_prefix("worktrunk.state.") + .and_then(|s| s.strip_suffix(".marker")) + else { + continue; + }; + + let Ok(parsed) = serde_json::from_str::(value) else { + continue; + }; + let Some(marker) = parsed.get("marker").and_then(|v| v.as_str()) else { + continue; + }; + let set_at = parsed.get("set_at").and_then(|v| v.as_u64()).unwrap_or(0); + markers.push((branch.to_string(), marker.to_string(), set_at)); + } + + markers.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0))); + markers + } + + fn clear_all_markers(&self) -> usize { + let markers = self.list_all_markers(); + let count = markers.len(); + for (branch, _, _) in markers { + self.clear_branch_marker(&branch); + } + count + } + + fn has_shown_hint(&self, name: &str) -> bool { + self.run_command(&["config", "get", &format!("worktrunk.hints.{name}")]) + .is_ok() + } + + fn mark_hint_shown(&self, name: &str) -> anyhow::Result<()> { + self.run_command(&[ + "config", + "set", + "--repo", + &format!("worktrunk.hints.{name}"), + "true", + ])?; + Ok(()) + } + + fn clear_hint(&self, name: &str) -> anyhow::Result { + match self.run_command(&[ + "config", + "unset", + "--repo", + &format!("worktrunk.hints.{name}"), + ]) { + Ok(_) => Ok(true), + Err(_) => Ok(false), + } + } + + fn list_shown_hints(&self) -> Vec { + let output = self + .run_command(&[ + "config", + "list", + "--repo", + "--name", + "worktrunk.hints", + "-T", + "name ++ \"\\n\"", + ]) + .unwrap_or_default(); + + output + .lines() + .filter_map(|line| line.strip_prefix("worktrunk.hints.").map(String::from)) + .collect() + } + + fn clear_all_hints(&self) -> anyhow::Result { + let hints = self.list_shown_hints(); + let count = hints.len(); + for hint in hints { + self.clear_hint(&hint)?; + } + Ok(count) + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_diff_stat_summary_with_changes() { + let output = "file.txt | 3 ++-\nnew.txt | 1 +\n2 files changed, 3 insertions(+), 1 deletion(-)"; + let diff = parse_diff_stat_summary(output); + assert_eq!(diff.added, 3); + assert_eq!(diff.deleted, 1); + } + + #[test] + fn test_parse_diff_stat_summary_no_changes() { + let output = "0 files changed, 0 insertions(+), 0 deletions(-)"; + let diff = parse_diff_stat_summary(output); + assert_eq!(diff.added, 0); + assert_eq!(diff.deleted, 0); + } + + #[test] + fn test_parse_diff_stat_summary_empty() { + let diff = parse_diff_stat_summary(""); + assert_eq!(diff.added, 0); + assert_eq!(diff.deleted, 0); + } + + #[test] + fn test_parse_diff_stat_summary_insertions_only() { + let output = "1 file changed, 5 insertions(+)"; + let diff = parse_diff_stat_summary(output); + assert_eq!(diff.added, 5); + assert_eq!(diff.deleted, 0); + } + + #[test] + fn test_parse_diff_stat_summary_deletions_only() { + let output = "1 file changed, 3 deletions(-)"; + let diff = parse_diff_stat_summary(output); + assert_eq!(diff.added, 0); + assert_eq!(diff.deleted, 3); + } +} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs new file mode 100644 index 000000000..88a5ae0a3 --- /dev/null +++ b/src/workspace/mod.rs @@ -0,0 +1,453 @@ +//! VCS-agnostic workspace abstraction. +//! +//! This module provides the [`Workspace`] trait that captures the operations +//! commands need, independent of the underlying VCS (git, jj, etc.). +//! +//! The git implementation is on [`Repository`](crate::git::Repository) directly. +//! The jj implementation ([`JjWorkspace`]) shells out to `jj` CLI commands. +//! Commands that need git-specific features can downcast via +//! `workspace.as_any().downcast_ref::()`. +//! +//! Use [`detect_vcs`] to determine which VCS manages a given path. +//! +//! # Reducing downcasts: planned `VcsOps` trait +//! +//! There are ~16 downcast sites across 11 files, producing 5 parallel handler +//! files (`handle_merge_jj.rs`, `handle_switch_jj.rs`, etc.) with ~17% code +//! duplication against their git counterparts. The goal is to eliminate these +//! by introducing a `VcsOps` trait for operations where the *caller's control +//! flow* differs between git and jj (not just the implementation). +//! +//! The approach: `fn ops(&self) -> Box` on `Workspace`. +//! This keeps `Box` working (no GATs needed), costs one trivial +//! heap allocation per call, and lets command handlers use a single code path: +//! +//! ```text +//! // Instead of downcasting: +//! // if let Some(repo) = ws.as_any().downcast_ref::() { ... } +//! // else { handle_merge_jj(...) } +//! // +//! // Commands call through VcsOps: +//! // ws.ops().prepare_commit(path, mode)?; +//! // ws.ops().guarded_push(target, &|| ws.local_push(...))?; +//! ``` +//! +//! `VcsOps` methods cover structural differences in control flow: +//! +//! | Method | Git | Jj | +//! |------------------|----------------------------------|---------------| +//! | `prepare_commit` | stage files + warn untracked | no-op | +//! | `guarded_push` | stash target worktree, push, restore | just push | +//! +//! Methods where only the *implementation* differs (not the caller's flow) +//! stay on `Workspace` directly — e.g. `commit()`, `rebase_onto()`, +//! `squash_commits()`. +//! +//! Migration is incremental: add `ops()` + `VcsOps` impls (additive), convert +//! one command at a time, delete parallel handler files as each is unified, +//! remove `as_any()` once no downcasts remain. +//! +//! Progress so far: `prepare_commit` is on `Workspace` directly (the simplest +//! case — no control flow difference, just git stages / jj no-ops). The next +//! step is introducing the `VcsOps` trait with `ops()` and moving +//! `prepare_commit` there, then adding `guarded_push` to unify the merge +//! command's stash-push-restore pattern. + +pub(crate) mod detect; +mod git; +pub(crate) mod jj; +pub mod types; + +use std::any::Any; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::config::StageMode; +use crate::git::WorktreeInfo; +pub use types::{IntegrationReason, LineDiff, LocalPushDisplay, LocalPushResult, path_dir_name}; + +pub use detect::detect_vcs; +pub use jj::JjWorkspace; + +/// Outcome of a rebase operation on the VCS level. +#[derive(Debug)] +pub enum RebaseOutcome { + /// True rebase (history rewritten). + Rebased, + /// Fast-forward (HEAD moved forward, no rewrite). + FastForward, +} + +/// Outcome of a squash operation on the VCS level. +pub enum SquashOutcome { + /// Commits were squashed into one. Contains the new commit identifier (short SHA or change ID). + Squashed(String), + /// Squash completed but resulted in no net changes (commits canceled out). + /// Git-only: detected after `reset --soft` when staging area is empty. + NoNetChanges, +} + +/// Version control system type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VcsKind { + Git, + Jj, +} + +/// VCS-agnostic workspace item (worktree in git, workspace in jj). +#[derive(Debug, Clone)] +pub struct WorkspaceItem { + /// Filesystem path to the workspace root. + pub path: PathBuf, + /// Workspace name. In git: derived from branch name (or directory name for + /// detached HEAD). In jj: the native workspace name. + pub name: String, + /// Commit identifier. In git: commit SHA. In jj: change ID. + pub head: String, + /// Branch name (git) or bookmark name (jj). None for detached HEAD (git) + /// or workspaces without bookmarks (jj). + pub branch: Option, + /// Whether this is the default/primary workspace. + pub is_default: bool, + /// Lock reason, if locked. + pub locked: Option, + /// Prunable reason, if prunable (directory deleted but VCS still tracks it). + pub prunable: Option, +} + +impl WorkspaceItem { + /// Create a `WorkspaceItem` from a git [`WorktreeInfo`]. + /// + /// The `name` field uses the branch name when available, falling back + /// to the directory name for detached HEAD worktrees. + pub fn from_worktree(wt: WorktreeInfo, is_default: bool) -> Self { + let name = wt + .branch + .clone() + .unwrap_or_else(|| path_dir_name(&wt.path).to_string()); + + Self { + path: wt.path, + name, + head: wt.head, + branch: wt.branch, + is_default, + locked: wt.locked, + prunable: wt.prunable, + } + } +} + +/// VCS-agnostic workspace operations. +/// +/// Captures what commands need at the workspace-operation level, not the +/// VCS-command level. Each VCS implementation translates these operations +/// into the appropriate commands. +pub trait Workspace: Send + Sync { + /// Which VCS backs this workspace. + fn kind(&self) -> VcsKind; + + // ====== Discovery ====== + + /// List all workspaces in the repository. + fn list_workspaces(&self) -> anyhow::Result>; + + /// Resolve a workspace name to its filesystem path. + fn workspace_path(&self, name: &str) -> anyhow::Result; + + /// Path to the default/primary workspace. + fn default_workspace_path(&self) -> anyhow::Result>; + + /// Name of the default/trunk branch. Returns `None` if unknown. + /// Git: detected from remote or local heuristics. Jj: from config or `trunk()` revset. + fn default_branch_name(&self) -> Option; + + /// Override the default branch name (persisted to VCS-specific config). + fn set_default_branch(&self, name: &str) -> anyhow::Result<()>; + + /// Clear the configured default branch override. Returns `true` if a value was cleared. + fn clear_default_branch(&self) -> anyhow::Result; + + // ====== Status per workspace ====== + + /// Whether the workspace has uncommitted changes. + fn is_dirty(&self, path: &Path) -> anyhow::Result; + + /// Line-level diff of uncommitted changes. + fn working_diff(&self, path: &Path) -> anyhow::Result; + + // ====== Comparison against trunk ====== + + /// Commits ahead/behind between two refs. + fn ahead_behind(&self, base: &str, head: &str) -> anyhow::Result<(usize, usize)>; + + /// Check if content identified by `id` is integrated into `target`. + /// Returns the integration reason if integrated, `None` if not. + fn is_integrated(&self, id: &str, target: &str) -> anyhow::Result>; + + /// Line-level diff stats between two refs (committed changes only). + fn branch_diff_stats(&self, base: &str, head: &str) -> anyhow::Result; + + // ====== Mutations ====== + + /// Create a new workspace. + /// - `name`: workspace/branch name + /// - `base`: starting point (branch, commit, or None for default) + /// - `path`: filesystem path for the new workspace + fn create_workspace(&self, name: &str, base: Option<&str>, path: &Path) -> anyhow::Result<()>; + + /// Remove a workspace by name. + fn remove_workspace(&self, name: &str) -> anyhow::Result<()>; + + // ====== Rebase ====== + + /// Resolve the integration target (branch/bookmark to rebase onto). + /// Git: validates ref exists, falls back to default branch. + /// Jj: uses `default_branch_name()` detection chain (config, trunk, local bookmarks). + fn resolve_integration_target(&self, target: Option<&str>) -> anyhow::Result; + + /// Whether the current workspace is already rebased onto `target`. + /// Git: merge-base == target SHA, no merge commits between. + /// Jj: target is ancestor of feature tip. + fn is_rebased_onto(&self, target: &str, path: &Path) -> anyhow::Result; + + /// Rebase the current workspace onto `target`. + /// Returns the outcome (Rebased vs FastForward). + /// Implementations emit their own progress message when appropriate. + fn rebase_onto(&self, target: &str, path: &Path) -> anyhow::Result; + + // ====== Identity ====== + + /// Root path of the repository (git dir or jj repo root). + fn root_path(&self) -> anyhow::Result; + + /// Filesystem path of the current workspace/worktree. + /// + /// Git: uses `current_worktree().path()` (respects `-C` flag via cwd). + /// Jj: uses `current_workspace().path` (found via cwd). + fn current_workspace_path(&self) -> anyhow::Result; + + /// Current workspace/branch name at the given path. + /// Returns `None` for detached HEAD (git) or workspaces without bookmarks (jj). + fn current_name(&self, path: &Path) -> anyhow::Result>; + + /// Project identifier for approval/hook scoping. + /// Uses remote URL if available, otherwise the canonical repository path. + fn project_identifier(&self) -> anyhow::Result; + + // ====== Commit ====== + + /// Prepare the working tree for commit. + /// + /// Git: warns about untracked files if `mode` is [`StageMode::All`], + /// then stages files (`git add -A`, `git add -u`, or nothing). + /// Jj: no-op (jj auto-snapshots the working copy). + fn prepare_commit(&self, path: &Path, mode: StageMode) -> anyhow::Result<()>; + + /// Commit staged/working changes with the given message. + /// Returns the new commit identifier (SHA for git, change ID for jj). + fn commit(&self, message: &str, path: &Path) -> anyhow::Result; + + /// Subject lines of commits between `base` and `head`. + fn commit_subjects(&self, base: &str, head: &str) -> anyhow::Result>; + + // ====== Push ====== + + /// Push current branch/bookmark to remote, fast-forward only. + /// `target` is the branch/bookmark to update on the remote. + fn push_to_target(&self, target: &str, path: &Path) -> anyhow::Result<()>; + + /// Advance the target branch ref to include current feature commits (local only). + /// + /// "Local push" means moving the target branch pointer forward — no remote + /// interaction. This is the git term for advancing a ref locally via + /// `git push `. + /// + /// Git: fast-forward the target branch to HEAD, with auto-stash/restore of + /// non-conflicting changes in the target worktree. Emits progress messages + /// (commit graph, diffstat) to stderr. + /// Jj: `jj bookmark set` to move the target bookmark to the feature tip. + /// + /// Returns a [`LocalPushResult`] with commit count and optional stats for + /// the command handler to format the final success message. + fn local_push( + &self, + target: &str, + path: &Path, + display: LocalPushDisplay<'_>, + ) -> anyhow::Result; + + // ====== Squash ====== + + /// The current feature head reference for squash/diff operations. + /// + /// Git: `"HEAD"` (literal string — git resolves it). + /// Jj: the feature tip change ID (@ if non-empty, @- otherwise). + fn feature_head(&self, path: &Path) -> anyhow::Result; + + /// Produce a diff and diffstat between two refs, suitable for LLM prompt consumption. + /// + /// Returns `(raw_diff, diffstat)`. + /// Git: `git diff base..head` with consistent prefix settings. + /// Jj: `jj diff --from base --to head`. + fn diff_for_prompt( + &self, + base: &str, + head: &str, + path: &Path, + ) -> anyhow::Result<(String, String)>; + + /// Recent commit subjects for LLM style reference. + /// + /// Returns up to `count` recent commit subjects, starting from `start_ref` if given. + /// Returns `None` if no commits are available. + fn recent_subjects(&self, start_ref: Option<&str>, count: usize) -> Option>; + + /// Squash all commits between target and HEAD/feature-tip into a single commit. + /// + /// Git: `git reset --soft && git commit -m ` + /// Jj: `jj new {target} && jj squash --from '{target}..{tip}' --into @ -m ` + /// + /// The message is generated by the command handler (LLM, template, or fallback). + /// Returns `SquashOutcome::Squashed(id)` on success, or `NoNetChanges` if the + /// commits cancel out (git-only: empty staging area after soft reset). + fn squash_commits( + &self, + target: &str, + message: &str, + path: &Path, + ) -> anyhow::Result; + + // ====== Commit prompt ====== + + /// Diff and diffstat of changes that would be committed, for LLM prompt consumption. + /// + /// Returns `(raw_diff, diffstat)` representing "what's about to be committed." + /// Git: staged changes (`git diff --staged`). + /// Jj: working-copy changes (`jj diff -r @`). + fn committable_diff_for_prompt(&self, path: &Path) -> anyhow::Result<(String, String)>; + + // ====== Copy-ignored ====== + + /// List ignored (by `.gitignore`) entries in the given workspace directory. + /// + /// Returns `(absolute_path, is_directory)` pairs. Uses directory-level + /// granularity — stops at directory boundaries so `target/` is one entry, + /// not thousands of files. + /// + /// Git: `git ls-files --ignored --exclude-standard -o --directory` + /// Jj: same command with explicit `--git-dir` pointing to the git backend. + fn list_ignored_entries(&self, path: &Path) -> anyhow::Result>; + + // ====== Capabilities ====== + + /// Whether this VCS has a staging area (index). + /// Git: true. Jj: false. + fn has_staging_area(&self) -> bool; + + // ====== Hooks & Configuration ====== + + /// Load project configuration (`.config/wt.toml`) from the repository root. + fn load_project_config(&self) -> anyhow::Result>; + + /// Directory for background hook log files. + /// Git: `.git/wt-logs/`. Jj: `.jj/wt-logs/`. + fn wt_logs_dir(&self) -> PathBuf; + + /// Previously-switched-from workspace name (for `wt switch -`). + fn switch_previous(&self) -> Option; + + /// Record the current workspace name before switching away. + fn set_switch_previous(&self, name: Option<&str>) -> anyhow::Result<()>; + + /// Clear the previously-switched-from workspace name. Returns `true` if cleared. + fn clear_switch_previous(&self) -> anyhow::Result; + + // ====== Markers (per-branch state) ====== + + /// Get the marker for a branch/workspace name. + /// + /// Markers are stored as JSON: `{"marker": "text", "set_at": unix_timestamp}`. + fn branch_marker(&self, name: &str) -> Option; + + /// Set a marker for a branch/workspace name (stored as JSON with timestamp). + fn set_branch_marker(&self, name: &str, marker: &str, timestamp: u64) -> anyhow::Result<()>; + + /// Clear the marker for a branch/workspace name. Returns true if it existed. + fn clear_branch_marker(&self, name: &str) -> bool; + + /// List all branch markers. Returns `(branch, marker_text, set_at_timestamp)` tuples. + fn list_all_markers(&self) -> Vec<(String, String, u64)>; + + /// Clear all markers. Returns the number cleared. + fn clear_all_markers(&self) -> usize; + + // ====== Hints (shown-once messages) ====== + + /// Check if a hint has been shown in this repo. + fn has_shown_hint(&self, name: &str) -> bool; + + /// Mark a hint as shown in this repo. + fn mark_hint_shown(&self, name: &str) -> anyhow::Result<()>; + + /// Clear a hint so it will show again. + fn clear_hint(&self, name: &str) -> anyhow::Result; + + /// List all hints that have been shown in this repo. + fn list_shown_hints(&self) -> Vec; + + /// Clear all hints so they will show again. Returns the number cleared. + fn clear_all_hints(&self) -> anyhow::Result; + + // ====== Downcast ====== + + /// Downcast to concrete type for VCS-specific operations. + fn as_any(&self) -> &dyn Any; +} + +/// Build a branch→path lookup map from workspace items. +/// +/// Used by `expand_template` for the `worktree_path_of_branch()` template function. +/// Maps both workspace names and branch names to their filesystem paths, so lookups +/// work with either identifier. +pub fn build_worktree_map(workspace: &dyn Workspace) -> HashMap { + workspace + .list_workspaces() + .unwrap_or_default() + .into_iter() + .flat_map(|ws| { + let mut entries = vec![(ws.name.clone(), ws.path.clone())]; + if let Some(branch) = &ws.branch + && branch != &ws.name + { + entries.push((branch.clone(), ws.path.clone())); + } + entries + }) + .collect() +} + +/// Detect VCS and open the appropriate workspace for the current directory. +/// +/// The `-C` flag is handled by `std::env::set_current_dir()` in main.rs before +/// any commands run, so `current_dir()` already reflects the right path. +/// +/// Falls back to `Repository::current()` when filesystem markers aren't found, +/// which handles bare repos and other non-standard layouts that git itself can discover. +pub fn open_workspace() -> anyhow::Result> { + let detect_path = std::env::current_dir()?; + match detect_vcs(&detect_path) { + Some(VcsKind::Jj) => Ok(Box::new(JjWorkspace::from_current_dir()?)), + Some(VcsKind::Git) => { + let repo = crate::git::Repository::current()?; + Ok(Box::new(repo)) + } + None => { + // Fallback: try git discovery (handles bare repos, -C flag, etc.) + match crate::git::Repository::current() { + Ok(repo) => Ok(Box::new(repo)), + Err(_) => anyhow::bail!("Not in a repository"), + } + } + } +} diff --git a/src/workspace/types.rs b/src/workspace/types.rs new file mode 100644 index 000000000..faca98bd8 --- /dev/null +++ b/src/workspace/types.rs @@ -0,0 +1,241 @@ +//! Shared types used by both VCS implementations. +//! +//! These types are VCS-agnostic and shared between git and jj workspace +//! implementations. They are re-exported from [`crate::git`] for backward +//! compatibility. + +use std::path::Path; + +/// Line-level diff totals (added/deleted counts) used across VCS operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)] +pub struct LineDiff { + pub added: usize, + pub deleted: usize, +} + +impl LineDiff { + pub fn is_empty(&self) -> bool { + self.added == 0 && self.deleted == 0 + } +} + +impl From for (usize, usize) { + fn from(diff: LineDiff) -> Self { + (diff.added, diff.deleted) + } +} + +impl From<(usize, usize)> for LineDiff { + fn from(value: (usize, usize)) -> Self { + Self { + added: value.0, + deleted: value.1, + } + } +} + +/// Why branch content is considered integrated into the target branch. +/// +/// Used by both `wt list` (for status symbols) and `wt remove` (for messages). +/// Each variant corresponds to a specific integration check. In `wt list`, +/// three symbols represent these checks: +/// - `_` for [`SameCommit`](Self::SameCommit) with clean working tree (empty) +/// - `–` for [`SameCommit`](Self::SameCommit) with dirty working tree +/// - `⊂` for all others (content integrated via different history) +/// +/// The checks are ordered by cost (cheapest first): +/// 1. [`SameCommit`](Self::SameCommit) - commit SHA comparison (~1ms) +/// 2. [`Ancestor`](Self::Ancestor) - ancestor check (~1ms) +/// 3. [`NoAddedChanges`](Self::NoAddedChanges) - three-dot diff (~50-100ms) +/// 4. [`TreesMatch`](Self::TreesMatch) - tree SHA comparison (~100-300ms) +/// 5. [`MergeAddsNothing`](Self::MergeAddsNothing) - merge simulation (~500ms-2s) +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, strum::IntoStaticStr)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum IntegrationReason { + /// Branch HEAD is literally the same commit as target. + /// + /// Used by `wt remove` to determine if branch is safely deletable. + /// In `wt list`, same-commit state is shown via `MainState::Empty` (`_`) or + /// `MainState::SameCommit` (`–`) depending on working tree cleanliness. + SameCommit, + + /// Branch HEAD is an ancestor of target (target has moved past this branch). + /// + /// Symbol in `wt list`: `⊂` + Ancestor, + + /// Three-dot diff (`main...branch`) shows no files. + /// The branch has no file changes beyond the merge-base. + /// + /// Symbol in `wt list`: `⊂` + NoAddedChanges, + + /// Branch tree SHA equals target tree SHA. + /// Commit history differs but file contents are identical. + /// + /// Symbol in `wt list`: `⊂` + TreesMatch, + + /// Simulated merge (`git merge-tree`) produces the same tree as target. + /// The branch has changes, but they're already in target via a different path. + /// + /// Symbol in `wt list`: `⊂` + MergeAddsNothing, +} + +impl IntegrationReason { + /// Human-readable description for use in messages (e.g., `wt remove` output). + /// + /// Returns a phrase that expects the target branch name to follow + /// (e.g., "same commit as" + "main" → "same commit as main"). + pub fn description(&self) -> &'static str { + match self { + Self::SameCommit => "same commit as", + Self::Ancestor => "ancestor of", + Self::NoAddedChanges => "no added changes on", + Self::TreesMatch => "tree matches", + Self::MergeAddsNothing => "all changes in", + } + } + + /// Status symbol used in `wt list` for this integration reason. + /// + /// - `SameCommit` → `_` (matches `MainState::Empty`) + /// - Others → `⊂` (matches `MainState::Integrated`) + pub fn symbol(&self) -> &'static str { + match self { + Self::SameCommit => "_", + _ => "⊂", + } + } +} + +/// Display context for the local push progress message. +/// +/// Controls the verb and optional notes in the progress line emitted by +/// `local_push`. E.g. merge passes `verb: "Merging"` and notes like +/// "(no commit/squash needed)", while step push uses the default "Pushing". +/// +/// "Local push" means advancing a target branch ref to include feature commits — +/// no remote interaction. Git implements this via `git push `, +/// jj via `jj bookmark set`. +pub struct LocalPushDisplay<'a> { + /// Verb in -ing form for the progress line. + pub verb: &'a str, + /// Optional parenthetical notes appended after the SHA + /// (e.g., " (no commit/squash needed)"). Include the leading space. + /// Empty string = omitted. + pub notes: &'a str, +} + +impl Default for LocalPushDisplay<'_> { + fn default() -> Self { + Self { + verb: "Pushing", + notes: "", + } + } +} + +/// Result of a local push operation, with enough data for the command handler +/// to format the final success/info message. +/// +/// "Local push" means advancing a target branch ref — no remote interaction. +#[derive(Debug, Clone)] +pub struct LocalPushResult { + /// Number of commits pushed locally (0 = already up-to-date). + pub commit_count: usize, + /// Summary parts for the success message parenthetical. + /// E.g. `["1 commit", "1 file", "+1"]`. Empty for jj or when count is 0. + pub stats_summary: Vec, +} + +/// Extract the directory name from a path for display purposes. +/// +/// Returns the last component of the path as a string, or "(unknown)" if +/// the path has no filename or contains invalid UTF-8. +pub fn path_dir_name(path: &Path) -> &str { + path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("(unknown)") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn test_line_diff_is_empty() { + assert!(LineDiff::default().is_empty()); + assert!( + !LineDiff { + added: 1, + deleted: 0 + } + .is_empty() + ); + assert!( + !LineDiff { + added: 0, + deleted: 1 + } + .is_empty() + ); + } + + #[test] + fn test_integration_reason_symbol() { + assert_eq!(IntegrationReason::SameCommit.symbol(), "_"); + assert_eq!(IntegrationReason::Ancestor.symbol(), "⊂"); + assert_eq!(IntegrationReason::NoAddedChanges.symbol(), "⊂"); + assert_eq!(IntegrationReason::TreesMatch.symbol(), "⊂"); + assert_eq!(IntegrationReason::MergeAddsNothing.symbol(), "⊂"); + } + + #[test] + fn test_path_dir_name() { + assert_eq!( + path_dir_name(Path::new("/repos/myrepo.feature")), + "myrepo.feature" + ); + assert_eq!(path_dir_name(Path::new("/")), "(unknown)"); + } + + #[test] + fn test_line_diff_conversions() { + let diff = LineDiff { + added: 10, + deleted: 5, + }; + let tuple: (usize, usize) = diff.into(); + assert_eq!(tuple, (10, 5)); + let back: LineDiff = (3, 7).into(); + assert_eq!( + back, + LineDiff { + added: 3, + deleted: 7 + } + ); + } + + #[test] + fn test_integration_reason_description() { + assert_eq!( + IntegrationReason::SameCommit.description(), + "same commit as" + ); + assert_eq!(IntegrationReason::Ancestor.description(), "ancestor of"); + assert_eq!( + IntegrationReason::NoAddedChanges.description(), + "no added changes on" + ); + assert_eq!(IntegrationReason::TreesMatch.description(), "tree matches"); + assert_eq!( + IntegrationReason::MergeAddsNothing.description(), + "all changes in" + ); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index cf6e1a3f4..ebf3caa94 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -585,6 +585,8 @@ const STATIC_TEST_ENV_VARS: &[(&str, &str)] = &[ // Disable delayed streaming for deterministic output across platforms. // Without this, slow CI triggers progress messages that don't appear on faster systems. ("WORKTRUNK_TEST_DELAYED_STREAM_MS", "-1"), + // Suppress interactive prompts (e.g., commit generation setup) + ("WORKTRUNK_NO_PROMPTS", "1"), ]; // NOTE: TERM is intentionally NOT in STATIC_TEST_ENV_VARS because: @@ -2877,6 +2879,52 @@ pub fn setup_temp_snapshot_settings(temp_path: &std::path::Path) -> insta::Setti settings } +/// Create configured insta Settings for jj integration test snapshots. +/// +/// Reuses the core snapshot settings pipeline (ANSI cleanup, path normalization) +/// but adapted for jj repos where the "repo root" is the default workspace path. +/// +/// Filters applied: +/// - Repo root → `_REPO_` +/// - Workspace paths → `_REPO_.{name}` +/// - Change IDs (12-char hex) → `[CHANGE_ID]` +/// - Standard env/temp/project redactions +pub fn setup_snapshot_settings_for_jj( + root: &Path, + workspaces: &HashMap, +) -> insta::Settings { + let mut settings = setup_snapshot_settings_for_paths(root, workspaces); + + // jj change IDs vary between runs. They are lowercase-only alphabetic strings + // that appear in several forms: + // - 12-char IDs in plain text (e.g., "Showing 1 xyzwabcdklmn") + // - 8-char IDs in the Commit column (wrapped in ANSI dim) + // - 12-char IDs in ANSI dim context (e.g., "Squashed @ id") + // + // The ANSI-wrapped forms need explicit matching because \b word boundaries + // don't work at ANSI code boundaries (the trailing 'm' of \x1b[2m is a word char). + // + // Match 8-char IDs in ANSI dim context (\x1b[2m...\x1b[0m — list output) + settings.add_filter( + r"\x1b\[2m([a-z]{8})\x1b\[0m", + "\x1b[2m[CHANGE_ID_SHORT]\x1b[0m", + ); + // Match 12-char IDs in ANSI dim context (\x1b[2m...\x1b[22m — squash output) + settings.add_filter( + r"\x1b\[2m([a-z]{12})\x1b\[22m", + "\x1b[2m[CHANGE_ID]\x1b[22m", + ); + // Match 12-char IDs in plain text (e.g., "Showing 1 xyzwabcdklmn") + settings.add_filter(r"\b[a-z]{12}\b", "[CHANGE_ID]"); + // Match 8-char IDs in plain text (fallback for non-ANSI contexts) + settings.add_filter(r"\b[a-z]{8}\b", "[CHANGE_ID_SHORT]"); + // Match git commit hashes (hex, 12 chars) in jj output (e.g., "to 26009b197b6c"). + // Applied after the [a-z]-only filters so pure-alpha IDs are caught as change IDs first. + settings.add_filter(r"\b[0-9a-f]{12}\b", "[COMMIT_HASH]"); + + settings +} + // ============================================================================= // PTY Test Filters // ============================================================================= diff --git a/tests/integration_tests/config_init.rs b/tests/integration_tests/config_init.rs index 52fb9f5f5..56ca40fef 100644 --- a/tests/integration_tests/config_init.rs +++ b/tests/integration_tests/config_init.rs @@ -77,7 +77,7 @@ fn test_config_create_project_creates_file(repo: TestRepo) { ----- stdout ----- ----- stderr ----- - ✓ Created project config: _REPO_/.config/wt.toml + ✓ Created project config: ./.config/wt.toml ↳ Edit this file to configure hooks for this repository ↳ See https://worktrunk.dev/hook/ for hook documentation @@ -114,7 +114,7 @@ run = "echo hello" ----- stdout ----- ----- stderr ----- - ○ Project config already exists: _REPO_/.config/wt.toml + ○ Project config already exists: ./.config/wt.toml ↳ To view, run wt config show. To create a user config, run wt config create "); }); diff --git a/tests/integration_tests/doc_templates.rs b/tests/integration_tests/doc_templates.rs index aa6d0da09..f4e75a792 100644 --- a/tests/integration_tests/doc_templates.rs +++ b/tests/integration_tests/doc_templates.rs @@ -7,13 +7,19 @@ //! Run with: `cargo test --test integration doc_templates` use std::collections::HashMap; +use std::path::PathBuf; use rstest::rstest; use worktrunk::config::expand_template; use worktrunk::git::Repository; +use worktrunk::workspace::build_worktree_map; use crate::common::{TestRepo, repo}; +fn empty_map() -> HashMap { + HashMap::new() +} + /// Helper to compute hash_port for a string. /// /// Must match `string_to_port()` in `src/config/expansion.rs`. @@ -28,9 +34,9 @@ fn hash_port(s: &str) -> u16 { // Basic Variables (docs/content/hook.md: Template variables table) // ============================================================================= -#[rstest] -fn test_doc_basic_variables(repo: TestRepo) { - let repository = Repository::at(repo.root_path()).unwrap(); +#[test] +fn test_doc_basic_variables() { + let worktree_map = empty_map(); let mut vars = HashMap::new(); vars.insert("repo", "myproject"); vars.insert("branch", "feature/auth"); @@ -39,19 +45,19 @@ fn test_doc_basic_variables(repo: TestRepo) { // Each variable substitutes correctly assert_eq!( - expand_template("{{ repo }}", &vars, false, &repository, "test").unwrap(), + expand_template("{{ repo }}", &vars, false, &worktree_map, "test").unwrap(), "myproject" ); assert_eq!( - expand_template("{{ branch }}", &vars, false, &repository, "test").unwrap(), + expand_template("{{ branch }}", &vars, false, &worktree_map, "test").unwrap(), "feature/auth" ); assert_eq!( - expand_template("{{ worktree }}", &vars, false, &repository, "test").unwrap(), + expand_template("{{ worktree }}", &vars, false, &worktree_map, "test").unwrap(), "/home/user/myproject.feature-auth" ); assert_eq!( - expand_template("{{ default_branch }}", &vars, false, &repository, "test").unwrap(), + expand_template("{{ default_branch }}", &vars, false, &worktree_map, "test").unwrap(), "main" ); } @@ -61,22 +67,36 @@ fn test_doc_basic_variables(repo: TestRepo) { // "Replace `/` and `\` with `-`" // ============================================================================= -#[rstest] -fn test_doc_sanitize_filter(repo: TestRepo) { +#[test] +fn test_doc_sanitize_filter() { let mut vars = HashMap::new(); - let repository = Repository::at(repo.root_path()).unwrap(); + let worktree_map = empty_map(); // From docs: {{ branch | sanitize }} replaces / and \ with - vars.insert("branch", "feature/foo"); assert_eq!( - expand_template("{{ branch | sanitize }}", &vars, false, &repository, "test").unwrap(), + expand_template( + "{{ branch | sanitize }}", + &vars, + false, + &worktree_map, + "test" + ) + .unwrap(), "feature-foo", "sanitize should replace / with -" ); vars.insert("branch", "user\\task"); assert_eq!( - expand_template("{{ branch | sanitize }}", &vars, false, &repository, "test").unwrap(), + expand_template( + "{{ branch | sanitize }}", + &vars, + false, + &worktree_map, + "test" + ) + .unwrap(), "user-task", "sanitize should replace \\ with -" ); @@ -84,7 +104,14 @@ fn test_doc_sanitize_filter(repo: TestRepo) { // Nested paths vars.insert("branch", "user/feature/task"); assert_eq!( - expand_template("{{ branch | sanitize }}", &vars, false, &repository, "test").unwrap(), + expand_template( + "{{ branch | sanitize }}", + &vars, + false, + &worktree_map, + "test" + ) + .unwrap(), "user-feature-task", "sanitize should handle multiple slashes" ); @@ -95,10 +122,10 @@ fn test_doc_sanitize_filter(repo: TestRepo) { // "Transform to database-safe identifier ([a-z0-9_], max 63 chars)" // ============================================================================= -#[rstest] -fn test_doc_sanitize_db_filter(repo: TestRepo) { +#[test] +fn test_doc_sanitize_db_filter() { let mut vars = HashMap::new(); - let repository = Repository::at(repo.root_path()).unwrap(); + let worktree_map = empty_map(); // From docs: {{ branch | sanitize_db }} transforms to database-safe identifier // Output includes a 3-character hash suffix for uniqueness @@ -107,7 +134,7 @@ fn test_doc_sanitize_db_filter(repo: TestRepo) { "{{ branch | sanitize_db }}", &vars, false, - &repository, + &worktree_map, "test", ) .unwrap(); @@ -122,7 +149,7 @@ fn test_doc_sanitize_db_filter(repo: TestRepo) { "{{ branch | sanitize_db }}", &vars, false, - &repository, + &worktree_map, "test", ) .unwrap(); @@ -137,7 +164,7 @@ fn test_doc_sanitize_db_filter(repo: TestRepo) { "{{ branch | sanitize_db }}", &vars, false, - &repository, + &worktree_map, "test", ) .unwrap(); @@ -152,7 +179,7 @@ fn test_doc_sanitize_db_filter(repo: TestRepo) { "{{ branch | sanitize_db }}", &vars, false, - &repository, + &worktree_map, "test", ) .unwrap(); @@ -167,7 +194,7 @@ fn test_doc_sanitize_db_filter(repo: TestRepo) { "{{ branch | sanitize_db }}", &vars, false, - &repository, + &worktree_map, "test", ) .unwrap(); @@ -176,7 +203,7 @@ fn test_doc_sanitize_db_filter(repo: TestRepo) { "{{ branch | sanitize_db }}", &vars, false, - &repository, + &worktree_map, "test", ) .unwrap(); @@ -186,9 +213,9 @@ fn test_doc_sanitize_db_filter(repo: TestRepo) { ); } -#[rstest] -fn test_doc_sanitize_db_truncation(repo: TestRepo) { - let repository = Repository::at(repo.root_path()).unwrap(); +#[test] +fn test_doc_sanitize_db_truncation() { + let worktree_map = empty_map(); let mut vars = HashMap::new(); // Truncates to 63 characters (PostgreSQL limit) @@ -198,7 +225,7 @@ fn test_doc_sanitize_db_truncation(repo: TestRepo) { "{{ branch | sanitize_db }}", &vars, false, - &repository, + &worktree_map, "test", ) .unwrap(); @@ -214,17 +241,17 @@ fn test_doc_sanitize_db_truncation(repo: TestRepo) { // "Hash to port 10000-19999" // ============================================================================= -#[rstest] -fn test_doc_hash_port_filter(repo: TestRepo) { +#[test] +fn test_doc_hash_port_filter() { let mut vars = HashMap::new(); vars.insert("branch", "feature-foo"); - let repository = Repository::at(repo.root_path()).unwrap(); + let worktree_map = empty_map(); let result = expand_template( "{{ branch | hash_port }}", &vars, false, - &repository, + &worktree_map, "test", ) .unwrap(); @@ -240,7 +267,7 @@ fn test_doc_hash_port_filter(repo: TestRepo) { "{{ branch | hash_port }}", &vars, false, - &repository, + &worktree_map, "test", ) .unwrap(); @@ -252,8 +279,8 @@ fn test_doc_hash_port_filter(repo: TestRepo) { // CRITICAL: These test the operator precedence issue from PR #373 // ============================================================================= -#[rstest] -fn test_doc_hash_port_concatenation_precedence(repo: TestRepo) { +#[test] +fn test_doc_hash_port_concatenation_precedence() { // From docs/content/tips-patterns.md: // "The `'db-' ~ branch` concatenation hashes differently than plain `branch`" // @@ -262,14 +289,14 @@ fn test_doc_hash_port_concatenation_precedence(repo: TestRepo) { let mut vars = HashMap::new(); vars.insert("branch", "feature"); - let repository = Repository::at(repo.root_path()).unwrap(); + let worktree_map = empty_map(); // With parentheses (correct, as documented) let with_parens = expand_template( "{{ ('db-' ~ branch) | hash_port }}", &vars, false, - &repository, + &worktree_map, "test", ) .unwrap(); @@ -287,7 +314,7 @@ fn test_doc_hash_port_concatenation_precedence(repo: TestRepo) { "{{ 'db-' ~ branch | hash_port }}", &vars, false, - &repository, + &worktree_map, "test", ) .unwrap(); @@ -308,12 +335,12 @@ fn test_doc_hash_port_concatenation_precedence(repo: TestRepo) { ); } -#[rstest] -fn test_doc_hash_port_repo_branch_concatenation(repo: TestRepo) { +#[test] +fn test_doc_hash_port_repo_branch_concatenation() { // From docs/content/hook.md line 176: // dev = "npm run dev --port {{ (repo ~ '-' ~ branch) | hash_port }}" - let repository = Repository::at(repo.root_path()).unwrap(); + let worktree_map = empty_map(); let mut vars = HashMap::new(); vars.insert("repo", "myapp"); vars.insert("branch", "feature"); @@ -322,7 +349,7 @@ fn test_doc_hash_port_repo_branch_concatenation(repo: TestRepo) { "{{ (repo ~ '-' ~ branch) | hash_port }}", &vars, false, - &repository, + &worktree_map, "test", ) .unwrap(); @@ -341,12 +368,12 @@ fn test_doc_hash_port_repo_branch_concatenation(repo: TestRepo) { // These test complete template strings from the documentation // ============================================================================= -#[rstest] -fn test_doc_example_docker_postgres(repo: TestRepo) { +#[test] +fn test_doc_example_docker_postgres() { // From docs/content/tips-patterns.md lines 75-84: // docker run ... -p {{ ('db-' ~ branch) | hash_port }}:5432 - let repository = Repository::at(repo.root_path()).unwrap(); + let worktree_map = empty_map(); let mut vars = HashMap::new(); vars.insert("repo", "myproject"); vars.insert("branch", "feature-auth"); @@ -356,7 +383,7 @@ fn test_doc_example_docker_postgres(repo: TestRepo) { -p {{ ('db-' ~ branch) | hash_port }}:5432 \ postgres:16"#; - let result = expand_template(template, &vars, false, &repository, "test").unwrap(); + let result = expand_template(template, &vars, false, &worktree_map, "test").unwrap(); // Check the container name uses sanitized branch assert!( @@ -372,19 +399,19 @@ fn test_doc_example_docker_postgres(repo: TestRepo) { ); } -#[rstest] -fn test_doc_example_database_url(repo: TestRepo) { +#[test] +fn test_doc_example_database_url() { // From docs/content/tips-patterns.md lines 96-101: // DATABASE_URL=postgres://postgres:dev@localhost:{{ ('db-' ~ branch) | hash_port }}/{{ repo }} - let repository = Repository::at(repo.root_path()).unwrap(); + let worktree_map = empty_map(); let mut vars = HashMap::new(); vars.insert("repo", "myproject"); vars.insert("branch", "feature"); let template = "DATABASE_URL=postgres://postgres:dev@localhost:{{ ('db-' ~ branch) | hash_port }}/{{ repo }}"; - let result = expand_template(template, &vars, false, &repository, "test").unwrap(); + let result = expand_template(template, &vars, false, &worktree_map, "test").unwrap(); let expected_port = hash_port("db-feature"); assert_eq!( @@ -393,18 +420,18 @@ fn test_doc_example_database_url(repo: TestRepo) { ); } -#[rstest] -fn test_doc_example_dev_server(repo: TestRepo) { +#[test] +fn test_doc_example_dev_server() { // From docs/content/hook.md lines 168-170: // dev = "npm run dev -- --host {{ branch }}.lvh.me --port {{ branch | hash_port }}" - let repository = Repository::at(repo.root_path()).unwrap(); + let worktree_map = empty_map(); let mut vars = HashMap::new(); vars.insert("branch", "feature-auth"); let template = "npm run dev -- --host {{ branch }}.lvh.me --port {{ branch | hash_port }}"; - let result = expand_template(template, &vars, false, &repository, "test").unwrap(); + let result = expand_template(template, &vars, false, &worktree_map, "test").unwrap(); let expected_port = hash_port("feature-auth"); assert_eq!( @@ -413,19 +440,19 @@ fn test_doc_example_dev_server(repo: TestRepo) { ); } -#[rstest] -fn test_doc_example_worktree_path_sanitize(repo: TestRepo) { +#[test] +fn test_doc_example_worktree_path_sanitize() { // From docs/content/tips-patterns.md line 217: // worktree-path = "{{ branch | sanitize }}" - let repository = Repository::at(repo.root_path()).unwrap(); + let worktree_map = empty_map(); let mut vars = HashMap::new(); vars.insert("branch", "feature/user/auth"); vars.insert("main_worktree", "/home/user/project"); let template = "{{ main_worktree }}.{{ branch | sanitize }}"; - let result = expand_template(template, &vars, false, &repository, "test").unwrap(); + let result = expand_template(template, &vars, false, &worktree_map, "test").unwrap(); assert_eq!(result, "/home/user/project.feature-user-auth"); } @@ -433,9 +460,9 @@ fn test_doc_example_worktree_path_sanitize(repo: TestRepo) { // Edge Cases // ============================================================================= -#[rstest] -fn test_doc_hash_port_empty_string(repo: TestRepo) { - let repository = Repository::at(repo.root_path()).unwrap(); +#[test] +fn test_doc_hash_port_empty_string() { + let worktree_map = empty_map(); let mut vars = HashMap::new(); vars.insert("branch", ""); @@ -443,7 +470,7 @@ fn test_doc_hash_port_empty_string(repo: TestRepo) { "{{ branch | hash_port }}", &vars, false, - &repository, + &worktree_map, "test", ) .unwrap(); @@ -455,24 +482,30 @@ fn test_doc_hash_port_empty_string(repo: TestRepo) { ); } -#[rstest] -fn test_doc_sanitize_no_slashes(repo: TestRepo) { - let repository = Repository::at(repo.root_path()).unwrap(); +#[test] +fn test_doc_sanitize_no_slashes() { + let worktree_map = empty_map(); let mut vars = HashMap::new(); vars.insert("branch", "simple-branch"); - let result = - expand_template("{{ branch | sanitize }}", &vars, false, &repository, "test").unwrap(); + let result = expand_template( + "{{ branch | sanitize }}", + &vars, + false, + &worktree_map, + "test", + ) + .unwrap(); assert_eq!( result, "simple-branch", "sanitize should be no-op without slashes" ); } -#[rstest] -fn test_doc_combined_filters(repo: TestRepo) { +#[test] +fn test_doc_combined_filters() { // sanitize then hash_port (not currently documented, but should work) - let repository = Repository::at(repo.root_path()).unwrap(); + let worktree_map = empty_map(); let mut vars = HashMap::new(); vars.insert("branch", "feature/auth"); @@ -480,7 +513,7 @@ fn test_doc_combined_filters(repo: TestRepo) { "{{ branch | sanitize | hash_port }}", &vars, false, - &repository, + &worktree_map, "test", ) .unwrap(); @@ -495,16 +528,16 @@ fn test_doc_combined_filters(repo: TestRepo) { // worktree_path_of_branch Function // ============================================================================= -#[rstest] -fn test_worktree_path_of_branch_function_registered(repo: TestRepo) { +#[test] +fn test_worktree_path_of_branch_function_registered() { // Test that worktree_path_of_branch function is callable and returns empty for nonexistent branch - let repository = Repository::at(repo.root_path()).unwrap(); + let worktree_map = empty_map(); let vars: HashMap<&str, &str> = HashMap::new(); let result = expand_template( "{{ worktree_path_of_branch('nonexistent') }}", &vars, false, - &repository, + &worktree_map, "test", ); assert_eq!(result.unwrap(), ""); @@ -529,7 +562,9 @@ fn test_worktree_path_of_branch_shell_escape(repo: TestRepo) { "test-branch", ]); + // Build worktree map from the repo (after worktree creation so it's included) let repository = Repository::at(repo.root_path()).unwrap(); + let worktree_map = build_worktree_map(&repository); let vars: HashMap<&str, &str> = HashMap::new(); // With shell_escape=false, path is returned literally @@ -537,7 +572,7 @@ fn test_worktree_path_of_branch_shell_escape(repo: TestRepo) { "{{ worktree_path_of_branch('test-branch') }}", &vars, false, - &repository, + &worktree_map, "test", ) .unwrap(); @@ -552,7 +587,7 @@ fn test_worktree_path_of_branch_shell_escape(repo: TestRepo) { "{{ worktree_path_of_branch('test-branch') }}", &vars, true, - &repository, + &worktree_map, "test", ) .unwrap(); diff --git a/tests/integration_tests/jj.rs b/tests/integration_tests/jj.rs new file mode 100644 index 000000000..fe7c82478 --- /dev/null +++ b/tests/integration_tests/jj.rs @@ -0,0 +1,1467 @@ +//! Integration tests for jj (Jujutsu) workspace support. +//! +//! These tests exercise the `wt` CLI against real jj repositories. +//! They require `jj` to be installed (0.38.0+). Gated behind the +//! `shell-integration-tests` feature flag (alongside shell/PTY tests). +#![cfg(all(unix, feature = "shell-integration-tests"))] + +use crate::common::{ + canonicalize, configure_cli_command, configure_directive_file, directive_file, + setup_snapshot_settings_for_jj, wt_bin, +}; +use insta_cmd::assert_cmd_snapshot; +use rstest::{fixture, rstest}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tempfile::TempDir; + +// ============================================================================ +// JjTestRepo — test fixture for jj repositories +// ============================================================================ + +pub struct JjTestRepo { + _temp_dir: TempDir, + root: PathBuf, + workspaces: HashMap, + /// Snapshot settings guard — keeps insta filters active for this repo's lifetime. + _snapshot_guard: insta::internals::SettingsBindDropGuard, +} + +impl JjTestRepo { + /// Create a new jj repository with deterministic configuration. + /// + /// The repo includes: + /// - A `jj git init` repository at `{temp}/repo/` + /// - Deterministic user config (Test User / test@example.com) + /// - An initial commit with README.md + /// - A `main` bookmark on trunk so `trunk()` resolves + pub fn new() -> Self { + let temp_dir = TempDir::new().unwrap(); + let repo_dir = temp_dir.path().join("repo"); + + // jj git init repo + let output = Command::new("jj") + .args(["git", "init", "repo"]) + .current_dir(temp_dir.path()) + .output() + .expect("Failed to run jj git init"); + assert!( + output.status.success(), + "jj git init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let root = canonicalize(&repo_dir).unwrap(); + + // Configure deterministic user identity + run_jj_in( + &root, + &["config", "set", "--repo", "user.name", "Test User"], + ); + run_jj_in( + &root, + &["config", "set", "--repo", "user.email", "test@example.com"], + ); + + // Create initial commit with a file so trunk() resolves + std::fs::write(root.join("README.md"), "# Test repo\n").unwrap(); + run_jj_in(&root, &["describe", "-m", "Initial commit"]); + // Create new empty commit on top so @ is separate from trunk + run_jj_in(&root, &["new"]); + // Set main bookmark on the initial commit (trunk) + run_jj_in(&root, &["bookmark", "set", "main", "-r", "@-"]); + + let workspaces = HashMap::new(); + let snapshot_guard = setup_snapshot_settings_for_jj(&root, &workspaces).bind_to_scope(); + + Self { + _temp_dir: temp_dir, + root, + workspaces, + _snapshot_guard: snapshot_guard, + } + } + + /// Root path of the default workspace. + pub fn root_path(&self) -> &Path { + &self.root + } + + /// The temp directory containing the repo (used as HOME in tests). + pub fn home_path(&self) -> &Path { + self._temp_dir.path() + } + + /// Add a new workspace with the given name. + /// + /// Creates the workspace as a sibling directory: `{temp}/repo.{name}` + pub fn add_workspace(&mut self, name: &str) -> PathBuf { + if let Some(path) = self.workspaces.get(name) { + return path.clone(); + } + + let ws_path = self.root.parent().unwrap().join(format!("repo.{name}")); + let ws_path_str = ws_path.to_str().unwrap(); + + run_jj_in( + &self.root, + &["workspace", "add", "--name", name, ws_path_str], + ); + + let canonical = canonicalize(&ws_path).unwrap(); + self.workspaces.insert(name.to_string(), canonical.clone()); + canonical + } + + /// Make a commit in a specific workspace directory. + pub fn commit_in(&self, dir: &Path, filename: &str, content: &str, message: &str) { + std::fs::write(dir.join(filename), content).unwrap(); + run_jj_in(dir, &["describe", "-m", message]); + run_jj_in(dir, &["new"]); + } + + /// Create a `wt` command pre-configured for this jj test repo. + pub fn wt_command(&self) -> Command { + let mut cmd = Command::new(wt_bin()); + self.configure_wt_cmd(&mut cmd); + cmd.current_dir(&self.root); + cmd + } + + /// Configure a wt command with isolated test environment. + pub fn configure_wt_cmd(&self, cmd: &mut Command) { + configure_cli_command(cmd); + // Point to a non-existent config so tests are isolated + let test_config = self.home_path().join("test-config.toml"); + cmd.env("WORKTRUNK_CONFIG_PATH", &test_config); + // Set HOME to temp dir so paths normalize + let home = canonicalize(self.home_path()).unwrap(); + cmd.env("HOME", &home); + cmd.env("XDG_CONFIG_HOME", home.join(".config")); + cmd.env("USERPROFILE", &home); + cmd.env("APPDATA", home.join(".config")); + } + + /// Write a config file with a mock LLM command and return its path. + /// + /// The command just echoes a fixed commit message, ignoring stdin. + pub fn write_llm_config(&self) -> PathBuf { + let config_path = self.home_path().join("llm-config.toml"); + std::fs::write( + &config_path, + "[commit.generation]\ncommand = \"echo LLM-generated-message\"\n", + ) + .unwrap(); + config_path + } + + /// Write project-specific config (`.config/wt.toml`) under the repo root. + pub fn write_project_config(&self, contents: &str) { + let config_dir = self.root.join(".config"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write(config_dir.join("wt.toml"), contents).unwrap(); + } + + /// Path to a named workspace. + pub fn workspace_path(&self, name: &str) -> &Path { + self.workspaces + .get(name) + .unwrap_or_else(|| panic!("Workspace '{}' not found", name)) + } +} + +/// Run a jj command in a directory, panicking on failure. +fn run_jj_in(dir: &Path, args: &[&str]) { + let mut full_args = vec!["--no-pager", "--color", "never"]; + full_args.extend_from_slice(args); + + let output = Command::new("jj") + .args(&full_args) + .current_dir(dir) + .output() + .unwrap_or_else(|e| panic!("Failed to execute jj {}: {}", args.join(" "), e)); + + if !output.status.success() { + panic!( + "jj {} failed:\nstdout: {}\nstderr: {}", + args.join(" "), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} + +// ============================================================================ +// Snapshot helpers +// ============================================================================ + +fn make_jj_snapshot_cmd( + repo: &JjTestRepo, + subcommand: &str, + args: &[&str], + cwd: Option<&Path>, +) -> Command { + let mut cmd = Command::new(wt_bin()); + repo.configure_wt_cmd(&mut cmd); + cmd.arg(subcommand) + .args(args) + .current_dir(cwd.unwrap_or(repo.root_path())); + cmd +} + +/// Like `make_jj_snapshot_cmd` but with a custom config path (e.g., for LLM tests). +fn make_jj_snapshot_cmd_with_config( + repo: &JjTestRepo, + subcommand: &str, + args: &[&str], + cwd: Option<&Path>, + config_path: &Path, +) -> Command { + let mut cmd = make_jj_snapshot_cmd(repo, subcommand, args, cwd); + cmd.env("WORKTRUNK_CONFIG_PATH", config_path); + cmd +} + +// ============================================================================ +// rstest fixtures +// ============================================================================ + +#[fixture] +fn jj_repo() -> JjTestRepo { + JjTestRepo::new() +} + +/// Repo with one feature workspace containing a commit. +#[fixture] +fn jj_repo_with_feature(mut jj_repo: JjTestRepo) -> JjTestRepo { + let ws = jj_repo.add_workspace("feature"); + jj_repo.commit_in(&ws, "feature.txt", "feature content", "Add feature"); + jj_repo +} + +/// Repo with two feature workspaces. +#[fixture] +fn jj_repo_with_two_features(mut jj_repo: JjTestRepo) -> JjTestRepo { + let ws_a = jj_repo.add_workspace("feature-a"); + jj_repo.commit_in(&ws_a, "a.txt", "content a", "Add feature A"); + let ws_b = jj_repo.add_workspace("feature-b"); + jj_repo.commit_in(&ws_b, "b.txt", "content b", "Add feature B"); + jj_repo +} + +// ============================================================================ +// wt list tests +// ============================================================================ + +#[rstest] +fn test_jj_list_single_workspace(jj_repo: JjTestRepo) { + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "list", &[], None)); +} + +#[rstest] +fn test_jj_list_multiple_workspaces(jj_repo_with_two_features: JjTestRepo) { + let repo = jj_repo_with_two_features; + assert_cmd_snapshot!(make_jj_snapshot_cmd(&repo, "list", &[], None)); +} + +#[rstest] +fn test_jj_list_from_feature_workspace(jj_repo_with_feature: JjTestRepo) { + let repo = jj_repo_with_feature; + let feature_path = repo.workspace_path("feature"); + assert_cmd_snapshot!(make_jj_snapshot_cmd(&repo, "list", &[], Some(feature_path))); +} + +#[rstest] +fn test_jj_list_dirty_workspace(mut jj_repo: JjTestRepo) { + // Add workspace and write a file without committing (jj auto-snapshots) + let ws = jj_repo.add_workspace("dirty"); + std::fs::write(ws.join("uncommitted.txt"), "dirty content").unwrap(); + // jj auto-snapshots on next command, so the workspace will show as dirty + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "list", &[], None)); +} + +#[rstest] +fn test_jj_list_workspace_with_no_user_commits(mut jj_repo: JjTestRepo) { + // A newly created workspace has no user commits — only the jj workspace + // creation commits (new empty @ on top of trunk). This shows as "ahead" + // due to jj's workspace mechanics, even though no real work has been done. + jj_repo.add_workspace("integrated"); + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "list", &[], None)); +} + +// ============================================================================ +// wt switch tests +// ============================================================================ + +#[rstest] +fn test_jj_switch_to_existing_workspace(jj_repo_with_feature: JjTestRepo) { + let repo = jj_repo_with_feature; + // Switch from default to feature workspace + assert_cmd_snapshot!(make_jj_snapshot_cmd(&repo, "switch", &["feature"], None)); +} + +#[rstest] +fn test_jj_switch_to_existing_with_directive_file(jj_repo_with_feature: JjTestRepo) { + let repo = jj_repo_with_feature; + let (directive_path, _guard) = directive_file(); + assert_cmd_snapshot!({ + let mut cmd = make_jj_snapshot_cmd(&repo, "switch", &["feature"], None); + configure_directive_file(&mut cmd, &directive_path); + cmd + }); +} + +#[rstest] +fn test_jj_switch_create_new_workspace(jj_repo: JjTestRepo) { + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "switch", + &["--create", "new-feature"], + None + )); +} + +#[rstest] +fn test_jj_switch_create_with_directive_file(jj_repo: JjTestRepo) { + let (directive_path, _guard) = directive_file(); + assert_cmd_snapshot!({ + let mut cmd = make_jj_snapshot_cmd(&jj_repo, "switch", &["--create", "new-ws"], None); + configure_directive_file(&mut cmd, &directive_path); + cmd + }); +} + +#[rstest] +fn test_jj_switch_nonexistent_workspace(jj_repo: JjTestRepo) { + // Without --create, should fail with helpful error + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "switch", + &["nonexistent"], + None + )); +} + +#[rstest] +fn test_jj_switch_already_at_workspace(jj_repo_with_feature: JjTestRepo) { + let repo = jj_repo_with_feature; + let feature_path = repo.workspace_path("feature"); + // Switch to feature from within feature workspace — should be no-op + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &repo, + "switch", + &["feature"], + Some(feature_path) + )); +} + +// ============================================================================ +// wt remove tests +// ============================================================================ + +#[rstest] +fn test_jj_remove_workspace(jj_repo_with_feature: JjTestRepo) { + let repo = jj_repo_with_feature; + let feature_path = repo.workspace_path("feature"); + // Remove feature workspace from within it + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &repo, + "remove", + &[], + Some(feature_path) + )); +} + +#[rstest] +fn test_jj_remove_workspace_by_name(jj_repo_with_feature: JjTestRepo) { + let repo = jj_repo_with_feature; + // Remove by name from default workspace + assert_cmd_snapshot!(make_jj_snapshot_cmd(&repo, "remove", &["feature"], None)); +} + +#[rstest] +fn test_jj_remove_default_fails(jj_repo: JjTestRepo) { + // Cannot remove default workspace + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "remove", &["default"], None)); +} + +#[rstest] +fn test_jj_remove_current_workspace_cds_to_default(jj_repo_with_feature: JjTestRepo) { + let repo = jj_repo_with_feature; + let feature_path = repo.workspace_path("feature"); + + let (directive_path, _guard) = directive_file(); + assert_cmd_snapshot!({ + let mut cmd = make_jj_snapshot_cmd(&repo, "remove", &[], Some(feature_path)); + configure_directive_file(&mut cmd, &directive_path); + cmd + }); +} + +#[rstest] +fn test_jj_remove_already_on_default(jj_repo: JjTestRepo) { + // Try to remove when already on default (no workspace name given) + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "remove", &[], None)); +} + +// ============================================================================ +// wt merge tests +// ============================================================================ + +#[rstest] +fn test_jj_merge_squash(jj_repo_with_feature: JjTestRepo) { + let repo = jj_repo_with_feature; + let feature_path = repo.workspace_path("feature"); + // Merge feature into main (squash is default for jj) + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &repo, + "merge", + &["main"], + Some(feature_path) + )); +} + +#[rstest] +fn test_jj_merge_squash_with_directive_file(jj_repo_with_feature: JjTestRepo) { + let repo = jj_repo_with_feature; + let feature_path = repo.workspace_path("feature"); + let (directive_path, _guard) = directive_file(); + assert_cmd_snapshot!({ + let mut cmd = make_jj_snapshot_cmd(&repo, "merge", &["main"], Some(feature_path)); + configure_directive_file(&mut cmd, &directive_path); + cmd + }); +} + +#[rstest] +fn test_jj_merge_no_remove(jj_repo_with_feature: JjTestRepo) { + let repo = jj_repo_with_feature; + let feature_path = repo.workspace_path("feature"); + // Merge but keep the workspace (--no-remove) + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &repo, + "merge", + &["main", "--no-remove"], + Some(feature_path) + )); +} + +#[rstest] +fn test_jj_merge_workspace_with_no_user_commits(mut jj_repo: JjTestRepo) { + // Workspace has only jj's workspace creation commits (no real work). + // Squash merge is a no-op in terms of content, but still cleans up. + let ws = jj_repo.add_workspace("integrated"); + + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "merge", + &["main"], + Some(&ws) + )); +} + +#[rstest] +fn test_jj_merge_from_default_fails(jj_repo: JjTestRepo) { + // Cannot merge the default workspace + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "merge", &["main"], None)); +} + +#[rstest] +fn test_jj_merge_multi_commit(mut jj_repo: JjTestRepo) { + // Feature with multiple commits + let ws = jj_repo.add_workspace("multi"); + jj_repo.commit_in(&ws, "file1.txt", "content 1", "Add file 1"); + jj_repo.commit_in(&ws, "file2.txt", "content 2", "Add file 2"); + + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "merge", + &["main"], + Some(&ws) + )); +} + +// ============================================================================ +// Edge cases +// ============================================================================ + +#[rstest] +fn test_jj_switch_create_and_then_list(jj_repo: JjTestRepo) { + // Create a workspace via wt switch --create, then verify it appears in list + let mut cmd = jj_repo.wt_command(); + cmd.args(["switch", "--create", "via-switch"]); + let output = cmd.output().unwrap(); + assert!( + output.status.success(), + "wt switch --create failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + // List should show the new workspace + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "list", &[], None)); +} + +#[rstest] +fn test_jj_multiple_operations(mut jj_repo: JjTestRepo) { + // Create workspace, commit, remove — full lifecycle + let ws = jj_repo.add_workspace("lifecycle"); + jj_repo.commit_in(&ws, "life.txt", "content", "Lifecycle commit"); + + // Verify it exists in list output + let output = jj_repo.wt_command().arg("list").output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("lifecycle"), + "Expected 'lifecycle' in list output: {stdout}" + ); + + // Merge it + let mut cmd = jj_repo.wt_command(); + cmd.args(["merge", "main"]).current_dir(&ws); + let merge_output = cmd.output().unwrap(); + assert!( + merge_output.status.success(), + "merge failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&merge_output.stdout), + String::from_utf8_lossy(&merge_output.stderr) + ); +} + +#[rstest] +fn test_jj_remove_nonexistent_workspace(jj_repo: JjTestRepo) { + // Try to remove a workspace that doesn't exist + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "remove", + &["nonexistent"], + None + )); +} + +#[rstest] +fn test_jj_switch_to_default(jj_repo_with_feature: JjTestRepo) { + let repo = jj_repo_with_feature; + let feature_path = repo.workspace_path("feature"); + // Switch from feature back to default + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &repo, + "switch", + &["default"], + Some(feature_path) + )); +} + +#[rstest] +fn test_jj_list_after_remove(mut jj_repo: JjTestRepo) { + // Create a workspace, then remove it, then list + let ws = jj_repo.add_workspace("temp"); + jj_repo.commit_in(&ws, "temp.txt", "content", "Temp commit"); + + // Remove by name + let mut cmd = jj_repo.wt_command(); + cmd.args(["remove", "temp"]); + let output = cmd.output().unwrap(); + assert!(output.status.success()); + + // List should only show default workspace + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "list", &[], None)); +} + +#[rstest] +fn test_jj_merge_with_no_squash(jj_repo_with_feature: JjTestRepo) { + let repo = jj_repo_with_feature; + let feature_path = repo.workspace_path("feature"); + // Merge without squash (rebase mode) + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &repo, + "merge", + &["main", "--no-squash"], + Some(feature_path) + )); +} + +// ============================================================================ +// wt step commit tests +// ============================================================================ + +#[rstest] +fn test_jj_step_commit_with_changes(jj_repo: JjTestRepo) { + // Write a file (jj auto-snapshots, so @ will have content) + std::fs::write(jj_repo.root_path().join("new.txt"), "content\n").unwrap(); + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "step", &["commit"], None)); +} + +#[rstest] +fn test_jj_step_commit_nothing_to_commit(jj_repo: JjTestRepo) { + // @ is empty (fresh workspace), so step commit should fail + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "step", &["commit"], None)); +} + +#[rstest] +fn test_jj_step_commit_in_feature_workspace(mut jj_repo: JjTestRepo) { + let ws = jj_repo.add_workspace("feat"); + std::fs::write(ws.join("feat.txt"), "feature content\n").unwrap(); + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "step", + &["commit"], + Some(&ws) + )); +} + +#[rstest] +fn test_jj_step_commit_reuses_existing_description(jj_repo: JjTestRepo) { + // Write a file, then manually describe @ — step commit should reuse that description + std::fs::write(jj_repo.root_path().join("described.txt"), "content\n").unwrap(); + run_jj_in( + jj_repo.root_path(), + &["describe", "-m", "My custom message"], + ); + + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "step", &["commit"], None)); +} + +#[rstest] +fn test_jj_step_commit_multiple_files(jj_repo: JjTestRepo) { + // Write 4 files — should generate "Changes to 4 files" + for name in &["a.txt", "b.txt", "c.txt", "d.txt"] { + std::fs::write(jj_repo.root_path().join(name), "content\n").unwrap(); + } + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "step", &["commit"], None)); +} + +#[rstest] +fn test_jj_step_commit_two_files(jj_repo: JjTestRepo) { + // 2 files — should generate "Changes to X & Y" + std::fs::write(jj_repo.root_path().join("alpha.txt"), "a\n").unwrap(); + std::fs::write(jj_repo.root_path().join("beta.txt"), "b\n").unwrap(); + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "step", &["commit"], None)); +} + +#[rstest] +fn test_jj_step_commit_three_files(jj_repo: JjTestRepo) { + // 3 files — should generate "Changes to X, Y & Z" + std::fs::write(jj_repo.root_path().join("alpha.txt"), "a\n").unwrap(); + std::fs::write(jj_repo.root_path().join("beta.txt"), "b\n").unwrap(); + std::fs::write(jj_repo.root_path().join("gamma.txt"), "c\n").unwrap(); + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "step", &["commit"], None)); +} + +#[rstest] +fn test_jj_step_commit_show_prompt(jj_repo: JjTestRepo) { + // --show-prompt with no LLM configured + std::fs::write(jj_repo.root_path().join("prompt.txt"), "content\n").unwrap(); + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "step", + &["commit", "--show-prompt"], + None + )); +} + +#[rstest] +fn test_jj_step_commit_with_llm(jj_repo: JjTestRepo) { + // Commit with a mock LLM command configured + let config = jj_repo.write_llm_config(); + std::fs::write(jj_repo.root_path().join("llm.txt"), "content\n").unwrap(); + assert_cmd_snapshot!(make_jj_snapshot_cmd_with_config( + &jj_repo, + "step", + &["commit"], + None, + &config + )); +} + +#[rstest] +fn test_jj_step_commit_show_prompt_with_llm(jj_repo: JjTestRepo) { + // --show-prompt with LLM configured — should print the actual prompt + let config = jj_repo.write_llm_config(); + std::fs::write(jj_repo.root_path().join("llm.txt"), "content\n").unwrap(); + assert_cmd_snapshot!(make_jj_snapshot_cmd_with_config( + &jj_repo, + "step", + &["commit", "--show-prompt"], + None, + &config + )); +} + +// ============================================================================ +// wt step squash tests +// ============================================================================ + +#[rstest] +fn test_jj_step_squash_multiple_commits(mut jj_repo: JjTestRepo) { + let ws = jj_repo.add_workspace("squash-test"); + jj_repo.commit_in(&ws, "a.txt", "content a", "First commit"); + jj_repo.commit_in(&ws, "b.txt", "content b", "Second commit"); + + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "step", + &["squash"], + Some(&ws) + )); +} + +#[rstest] +fn test_jj_step_squash_already_single_commit(mut jj_repo: JjTestRepo) { + let ws = jj_repo.add_workspace("single"); + jj_repo.commit_in(&ws, "only.txt", "only content", "Only commit"); + + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "step", + &["squash"], + Some(&ws) + )); +} + +#[rstest] +fn test_jj_step_squash_no_commits_ahead(jj_repo: JjTestRepo) { + // Default workspace with no feature commits — nothing to squash + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "step", &["squash"], None)); +} + +#[rstest] +fn test_jj_step_squash_already_integrated(mut jj_repo: JjTestRepo) { + // Feature that has already been squash-merged into trunk via wt merge + let ws = jj_repo.add_workspace("integrated"); + jj_repo.commit_in(&ws, "i.txt", "content", "Feature commit"); + + // Merge it into trunk first + let mut merge_cmd = jj_repo.wt_command(); + configure_cli_command(&mut merge_cmd); + merge_cmd + .current_dir(&ws) + .args(["merge", "main", "--no-remove"]); + let merge_result = merge_cmd.output().unwrap(); + assert!( + merge_result.status.success(), + "merge failed: {}", + String::from_utf8_lossy(&merge_result.stderr) + ); + + // Now step squash should say "nothing to squash" (already integrated) + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "step", + &["squash"], + Some(&ws) + )); +} + +// ============================================================================ +// wt step rebase tests +// ============================================================================ + +#[rstest] +fn test_jj_step_rebase_already_up_to_date(mut jj_repo: JjTestRepo) { + let ws = jj_repo.add_workspace("rebased"); + jj_repo.commit_in(&ws, "r.txt", "content", "Feature commit"); + + // Feature is already on trunk — should be up to date + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "step", + &["rebase"], + Some(&ws) + )); +} + +#[rstest] +fn test_jj_step_rebase_onto_advanced_trunk(mut jj_repo: JjTestRepo) { + // Create feature workspace + let ws = jj_repo.add_workspace("rebase-feat"); + jj_repo.commit_in(&ws, "feat.txt", "feature", "Feature work"); + + // Advance trunk in default workspace + std::fs::write(jj_repo.root_path().join("trunk.txt"), "trunk advance\n").unwrap(); + run_jj_in(jj_repo.root_path(), &["describe", "-m", "Advance trunk"]); + run_jj_in(jj_repo.root_path(), &["new"]); + run_jj_in( + jj_repo.root_path(), + &["bookmark", "set", "main", "-r", "@-"], + ); + + // Now rebase feature onto the advanced trunk + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "step", + &["rebase"], + Some(&ws) + )); +} + +// ============================================================================ +// wt step push tests +// ============================================================================ + +#[rstest] +fn test_jj_step_push_no_remote(mut jj_repo: JjTestRepo) { + // Push without a remote — should complete (bookmark set) but push fails silently + let ws = jj_repo.add_workspace("push-test"); + jj_repo.commit_in(&ws, "p.txt", "push content", "Push commit"); + + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "step", &["push"], Some(&ws))); +} + +#[rstest] +fn test_jj_step_push_nothing_to_push(jj_repo: JjTestRepo) { + // Default workspace — feature tip IS trunk, nothing to push + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "step", &["push"], None)); +} + +#[rstest] +fn test_jj_step_push_behind_trunk(mut jj_repo: JjTestRepo) { + // Create feature workspace with a commit + let ws = jj_repo.add_workspace("push-behind"); + jj_repo.commit_in(&ws, "feat.txt", "feature", "Feature work"); + + // Advance trunk past the feature (so feature is behind) + std::fs::write(jj_repo.root_path().join("trunk.txt"), "trunk advance\n").unwrap(); + run_jj_in(jj_repo.root_path(), &["describe", "-m", "Advance trunk"]); + run_jj_in(jj_repo.root_path(), &["new"]); + run_jj_in( + jj_repo.root_path(), + &["bookmark", "set", "main", "-r", "@-"], + ); + + // Advance trunk again so it's strictly ahead + std::fs::write(jj_repo.root_path().join("trunk2.txt"), "more trunk\n").unwrap(); + run_jj_in(jj_repo.root_path(), &["describe", "-m", "More trunk"]); + run_jj_in(jj_repo.root_path(), &["new"]); + run_jj_in( + jj_repo.root_path(), + &["bookmark", "set", "main", "-r", "@-"], + ); + + // Push from feature — should detect feature is not ahead and fail + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "step", &["push"], Some(&ws))); +} + +// ============================================================================ +// wt step squash edge cases +// ============================================================================ + +#[rstest] +fn test_jj_step_squash_single_commit_with_wc_content(mut jj_repo: JjTestRepo) { + // Feature workspace with one commit AND uncommitted content in working copy + let ws = jj_repo.add_workspace("squash-wc"); + jj_repo.commit_in(&ws, "first.txt", "first", "First commit"); + + // Add more content without committing (jj auto-snapshots into @) + std::fs::write(ws.join("extra.txt"), "uncommitted content\n").unwrap(); + + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "step", + &["squash"], + Some(&ws) + )); +} + +// ============================================================================ +// wt step squash --show-prompt (jj routing) +// ============================================================================ + +#[rstest] +fn test_jj_step_squash_show_prompt(mut jj_repo: JjTestRepo) { + let ws = jj_repo.add_workspace("squash-prompt"); + jj_repo.commit_in(&ws, "p.txt", "content", "Commit for prompt"); + + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "step", + &["squash", "--show-prompt"], + Some(&ws) + )); +} + +// ============================================================================ +// Multi-step workflow tests +// ============================================================================ + +// ============================================================================ +// Coverage gap tests — exercising uncovered code paths +// ============================================================================ + +/// Clean workspace should report as not dirty (workspace/jj.rs `is_dirty` clean path). +#[rstest] +fn test_jj_list_clean_workspace(jj_repo: JjTestRepo) { + // Default workspace has an empty @ on top of trunk — is_dirty should return false + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "list", &[], None)); +} + +/// Switch to existing workspace without --cd should succeed silently +/// (handle_switch_jj.rs line 28: early return when change_dir is false). +#[rstest] +fn test_jj_switch_existing_no_cd(jj_repo_with_feature: JjTestRepo) { + let mut cmd = jj_repo_with_feature.wt_command(); + cmd.args(["switch", "feature", "--no-cd"]); + let output = cmd.output().unwrap(); + assert!(output.status.success()); + // Should succeed without error, no directory change +} + +/// Remove workspace by running `wt remove` from inside the workspace (no name arg) +/// (remove_command.rs: empty branches path resolves current workspace name). +#[rstest] +fn test_jj_remove_current_workspace_no_name(mut jj_repo: JjTestRepo) { + let ws = jj_repo.add_workspace("removeme"); + jj_repo.commit_in(&ws, "x.txt", "x", "commit"); + + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "remove", &[], Some(&ws))); +} + +/// Switch --create with --base creates workspace at specific revision +/// (workspace/jj.rs create_workspace with base parameter, line 290). +#[rstest] +fn test_jj_switch_create_with_base(jj_repo: JjTestRepo) { + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "switch", + &["based-ws", "--create", "--base", "main"], + None, + )); +} + +/// List workspace with committed changes (exercises branch_diff_stats and ahead/behind). +#[rstest] +fn test_jj_list_workspace_with_commits(mut jj_repo: JjTestRepo) { + let ws = jj_repo.add_workspace("with-commits"); + jj_repo.commit_in(&ws, "a.txt", "a", "First change"); + jj_repo.commit_in(&ws, "b.txt", "b", "Second change"); + + // List from default workspace — feature workspace should show commits ahead + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "list", &[], None)); +} + +/// Switch --create when target path already exists should error +/// (handle_switch_jj.rs lines 50-54). +#[rstest] +fn test_jj_switch_create_path_exists(jj_repo: JjTestRepo) { + // Create the directory that would conflict + let conflict_dir = jj_repo.root_path().parent().unwrap().join("repo.conflict"); + std::fs::create_dir_all(&conflict_dir).unwrap(); + + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "switch", + &["conflict", "--create"], + None, + )); +} + +/// List workspaces in JSON format (covers handle_list_jj JSON output path). +#[rstest] +fn test_jj_list_json(mut jj_repo: JjTestRepo) { + let ws = jj_repo.add_workspace("json-test"); + jj_repo.commit_in(&ws, "x.txt", "x content", "json test commit"); + + let mut cmd = jj_repo.wt_command(); + configure_cli_command(&mut cmd); + cmd.current_dir(jj_repo.root_path()) + .args(["list", "--format=json"]); + let output = cmd.output().unwrap(); + assert!( + output.status.success(), + "wt list --json failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + let arr = parsed.as_array().unwrap(); + // At least 2 workspaces: default + json-test + assert!( + arr.len() >= 2, + "Expected at least 2 workspaces, got {}", + arr.len() + ); + // Each item should have the 'kind' and 'branch' fields + for item in arr { + assert!(item.get("kind").is_some(), "Missing 'kind' field"); + assert!(item.get("branch").is_some(), "Missing 'branch' field"); + } +} + +/// Exercise JjWorkspace Workspace trait methods that aren't called by jj code paths. +/// +/// Several trait methods (`kind`, `default_branch_name`, `has_staging_area`, `is_dirty`, +/// `branch_diff_stats`) are required by the Workspace trait but not exercised by +/// the normal `wt list`/`wt merge`/`wt step` flows. This test calls them directly. +#[rstest] +fn test_jj_workspace_trait_methods(mut jj_repo: JjTestRepo) { + use worktrunk::workspace::{JjWorkspace, VcsKind, Workspace}; + + let ws = JjWorkspace::new(jj_repo.root_path().to_path_buf()); + + // kind + assert_eq!(ws.kind(), VcsKind::Jj); + + // has_staging_area — jj doesn't have one + assert!(!ws.has_staging_area()); + + // default_branch_name — jj detects from trunk() revset bookmark + assert_eq!(ws.default_branch_name(), Some("main".to_string())); + + // set_default_branch — override the detected value + ws.set_default_branch("develop").unwrap(); + assert_eq!(ws.default_branch_name(), Some("develop".to_string())); + + // clear_default_branch — reverts to trunk() detection + assert!(ws.clear_default_branch().unwrap()); + assert_eq!(ws.default_branch_name(), Some("main".to_string())); + + // clear when already clear — returns false + assert!(!ws.clear_default_branch().unwrap()); + + // is_dirty — clean workspace (empty @ on top of trunk) + assert!(!ws.is_dirty(jj_repo.root_path()).unwrap()); + + // Make dirty: write a file in the working copy + std::fs::write(jj_repo.root_path().join("dirty.txt"), "dirty\n").unwrap(); + assert!(ws.is_dirty(jj_repo.root_path()).unwrap()); + + // branch_diff_stats — diff between trunk and a feature workspace + let feature_path = jj_repo.add_workspace("trait-test"); + jj_repo.commit_in(&feature_path, "f.txt", "feature content", "feature commit"); + + // Get the feature workspace's change ID for branch_diff_stats + let items = ws.list_workspaces().unwrap(); + let feature_item = items.iter().find(|i| i.name == "trait-test").unwrap(); + let diff = ws.branch_diff_stats("trunk()", &feature_item.head).unwrap(); + assert!(diff.added > 0, "Expected added lines in branch diff stats"); + + // feature_tip — returns a change ID for the workspace + let tip = ws.feature_tip(&feature_path).unwrap(); + assert!(!tip.is_empty()); + + // commit — describe and advance @ in the feature workspace + std::fs::write(feature_path.join("commit-test.txt"), "via trait\n").unwrap(); + let change_id = ws.commit("trait commit message", &feature_path).unwrap(); + assert!(!change_id.is_empty()); + + // commit_subjects — check the commit we just made + let subjects = ws.commit_subjects("trunk()", &change_id).unwrap(); + assert!( + subjects.iter().any(|s| s.contains("trait commit message")), + "Expected 'trait commit message' in subjects: {subjects:?}" + ); + + // resolve_integration_target(None) — discovers trunk bookmark + let target = ws.resolve_integration_target(None).unwrap(); + assert_eq!(target, "main"); + + // resolve_integration_target(Some) — returns as-is + let target = ws.resolve_integration_target(Some("custom")).unwrap(); + assert_eq!(target, "custom"); + + // wt_logs_dir — returns .jj/wt-logs path + let logs_dir = ws.wt_logs_dir(); + assert!(logs_dir.ends_with(".jj/wt-logs")); + + // project_identifier — returns directory name (no git remote in test fixture) + let id = ws.project_identifier().unwrap(); + assert!(!id.is_empty()); + + // clear_switch_previous — exercises the unset path + ws.set_switch_previous(Some("trait-test")).unwrap(); + assert_eq!(ws.switch_previous(), Some("trait-test".to_string())); + ws.clear_switch_previous().unwrap(); + assert!(ws.switch_previous().is_none()); + + // is_rebased_onto — feature should be rebased onto trunk + let rebased = ws.is_rebased_onto("trunk()", &feature_path).unwrap(); + assert!(rebased); +} + +/// Remove workspace whose directory was already deleted externally +/// (handle_remove_jj.rs: "already removed" warning path). +#[rstest] +fn test_jj_remove_already_deleted_directory(mut jj_repo: JjTestRepo) { + let ws = jj_repo.add_workspace("deleted"); + jj_repo.commit_in(&ws, "d.txt", "d", "commit"); + + // Delete the directory externally before running wt remove + std::fs::remove_dir_all(&ws).unwrap(); + + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "remove", &["deleted"], None)); +} + +// ============================================================================ +// wt step push tests (continued) +// ============================================================================ + +#[rstest] +fn test_jj_step_squash_then_push(mut jj_repo: JjTestRepo) { + // The primary workflow: commit -> squash -> push + let ws = jj_repo.add_workspace("sq-push"); + jj_repo.commit_in(&ws, "a.txt", "a", "First"); + jj_repo.commit_in(&ws, "b.txt", "b", "Second"); + + // Squash + let mut squash_cmd = jj_repo.wt_command(); + configure_cli_command(&mut squash_cmd); + squash_cmd.current_dir(&ws).args(["step", "squash"]); + let squash_result = squash_cmd.output().unwrap(); + assert!( + squash_result.status.success(), + "squash failed: {}", + String::from_utf8_lossy(&squash_result.stderr) + ); + + // Push should still work (not say "nothing to push") + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "step", &["push"], Some(&ws))); +} + +// ============================================================================ +// Coverage: switch-previous, merge edge cases, hook paths +// ============================================================================ + +/// Switch to previous workspace with `wt switch -` +/// (handle_switch_jj.rs lines 33-37, workspace/jj.rs switch_previous + set_switch_previous). +#[rstest] +fn test_jj_switch_previous(mut jj_repo: JjTestRepo) { + let _ws_a = jj_repo.add_workspace("alpha"); + + // Set config with test HOME so `wt` (which uses test HOME) finds it. + // jj 0.38+ stores per-repo config in the user config dir, not in .jj/. + let home = jj_repo.home_path(); + let output = Command::new("jj") + .args([ + "--no-pager", + "--color", + "never", + "config", + "set", + "--repo", + "worktrunk.history", + "alpha", + ]) + .current_dir(jj_repo.root_path()) + .env("XDG_CONFIG_HOME", home.join(".config")) + .env("HOME", home) + .output() + .unwrap(); + assert!( + output.status.success(), + "jj config set failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // `wt switch -` should resolve "-" to "alpha" and switch there + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "switch", &["-"], None)); +} + +/// Merge workspace whose commits result in no net changes (NoNetChanges path) +/// (handle_merge_jj.rs lines 82-84, step_commands.rs lines 174-180). +#[rstest] +fn test_jj_merge_no_net_changes(mut jj_repo: JjTestRepo) { + let ws = jj_repo.add_workspace("noop"); + + // Add a file, then remove it — net effect is zero changes vs trunk + std::fs::write(ws.join("temp.txt"), "temporary content").unwrap(); + run_jj_in(&ws, &["describe", "-m", "Add temp file"]); + run_jj_in(&ws, &["new"]); + std::fs::remove_file(ws.join("temp.txt")).unwrap(); + run_jj_in(&ws, &["describe", "-m", "Remove temp file"]); + run_jj_in(&ws, &["new"]); + + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "merge", + &["main"], + Some(&ws) + )); +} + +/// Merge with --no-squash and --no-remove (rebase-only mode, workspace retained) +/// (handle_merge_jj.rs line 87-89: rebase_onto_trunk path). +#[rstest] +fn test_jj_merge_no_squash_no_remove(jj_repo_with_feature: JjTestRepo) { + let repo = jj_repo_with_feature; + let feature_path = repo.workspace_path("feature"); + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &repo, + "merge", + &["main", "--no-squash", "--no-remove"], + Some(feature_path) + )); +} + +/// Merge workspace that's at trunk (squash finds 0 commits ahead) +/// (handle_merge_jj.rs line 70: SquashResult::NoCommitsAhead match arm, +/// step_commands.rs lines 131-132: ahead==0 early return). +#[rstest] +fn test_jj_merge_zero_commits_ahead(mut jj_repo: JjTestRepo) { + // Create workspace and move it to exactly trunk (@ = main) + let ws = jj_repo.add_workspace("at-trunk"); + // Abandon the auto-created empty commit so @ points to trunk + run_jj_in(&ws, &["edit", "@-"]); + + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "merge", + &["main", "--no-remove"], + Some(&ws) + )); +} + +/// Merge workspace at trunk with removal (NoCommitsAhead + remove_if_requested) +/// (handle_merge_jj.rs lines 69-77: NoCommitsAhead with workspace removal). +#[rstest] +fn test_jj_merge_zero_commits_ahead_with_remove(mut jj_repo: JjTestRepo) { + let ws = jj_repo.add_workspace("at-trunk2"); + run_jj_in(&ws, &["edit", "@-"]); + + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "merge", + &["main"], + Some(&ws) + )); +} + +/// Merge no-net-changes workspace with removal (NoNetChanges + remove) +/// (handle_merge_jj.rs line 82-84: NoNetChanges with workspace removal). +#[rstest] +fn test_jj_merge_no_net_changes_with_remove(mut jj_repo: JjTestRepo) { + let ws = jj_repo.add_workspace("noop2"); + std::fs::write(ws.join("temp.txt"), "temporary content").unwrap(); + run_jj_in(&ws, &["describe", "-m", "Add temp file"]); + run_jj_in(&ws, &["new"]); + std::fs::remove_file(ws.join("temp.txt")).unwrap(); + run_jj_in(&ws, &["describe", "-m", "Remove temp file"]); + run_jj_in(&ws, &["new"]); + + // Without --no-remove, workspace should be removed + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "merge", + &["main"], + Some(&ws) + )); +} + +/// Switch records previous workspace for `wt switch -` +/// (handle_switch_jj.rs line 79: record_switch_previous). +#[rstest] +fn test_jj_switch_records_previous(mut jj_repo: JjTestRepo) { + let _ws_a = jj_repo.add_workspace("bravo"); + + // Switch to bravo — should record "default" as previous + let mut switch_cmd = jj_repo.wt_command(); + configure_cli_command(&mut switch_cmd); + switch_cmd + .current_dir(jj_repo.root_path()) + .args(["switch", "bravo"]); + let result = switch_cmd.output().unwrap(); + assert!( + result.status.success(), + "switch to bravo failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + + // Now `wt switch -` from bravo should switch back to default + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "switch", + &["-"], + Some(jj_repo.workspace_path("bravo")) + )); +} + +/// Switch to default from default (exercises is_default path) +/// (handle_switch_jj.rs line 45: existing_path found + already at workspace). +#[rstest] +fn test_jj_switch_default_from_default(jj_repo: JjTestRepo) { + assert_cmd_snapshot!(make_jj_snapshot_cmd(&jj_repo, "switch", &["default"], None)); +} + +/// Merge with implicit target (no argument) — exercises default branch detection +/// (resolve_integration_target(None) → default_branch_name()). +#[rstest] +fn test_jj_merge_implicit_target(jj_repo_with_feature: JjTestRepo) { + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo_with_feature, + "merge", + &[], + Some(jj_repo_with_feature.workspace_path("feature")) + )); +} + +/// Step commit with empty description (no existing description, generates from files) +/// (handle_step_jj.rs generate_jj_commit_message fallback path). +#[rstest] +fn test_jj_step_commit_empty_description(mut jj_repo: JjTestRepo) { + let ws = jj_repo.add_workspace("empty-desc"); + // Create a new empty change, then write a file (no existing description) + run_jj_in(&ws, &["new"]); + std::fs::write(ws.join("gen.txt"), "generated msg test\n").unwrap(); + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "step", + &["commit"], + Some(&ws) + )); +} + +// ============================================================================ +// Hook and execute coverage tests +// ============================================================================ + +/// Switch --create with project hooks and --yes exercises approve_hooks, +/// post-create blocking hooks, and background post-switch/post-start hooks +/// (handle_switch_jj.rs lines 148-167, 183-186, 211-226). +#[rstest] +fn test_jj_switch_create_with_hooks(jj_repo: JjTestRepo) { + // Write project config with hooks + jj_repo.write_project_config( + r#"post-create = "echo post-create-ran" +post-switch = "echo post-switch-ran" +post-start = "echo post-start-ran" +"#, + ); + + // --yes auto-approves hooks + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "switch", + &["--create", "hooked", "--yes"], + None, + )); +} + +/// Switch to existing workspace with hooks exercises the existing-switch hook path +/// (handle_switch_jj.rs lines 67-76, 100-112: approve + background for existing switch). +#[rstest] +fn test_jj_switch_existing_with_hooks(mut jj_repo: JjTestRepo) { + let _ws = jj_repo.add_workspace("hookable"); + + // Write project config with post-switch hook + jj_repo.write_project_config(r#"post-switch = "echo switched-hook""#); + + // Switch to existing workspace with hooks + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "switch", + &["hookable", "--yes"], + None, + )); +} + +/// Switch --create with --execute exercises the execute path +/// (handle_switch_jj.rs lines 229-238: expand_and_execute_command). +#[rstest] +fn test_jj_switch_create_with_execute(jj_repo: JjTestRepo) { + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "switch", + &["--create", "exec-ws", "--execute", "echo hello"], + None, + )); +} + +/// Switch to existing workspace with --execute exercises the execute path +/// (handle_switch_jj.rs lines 115-123: expand_and_execute_command for existing). +#[rstest] +fn test_jj_switch_existing_with_execute(mut jj_repo: JjTestRepo) { + let _ws = jj_repo.add_workspace("exec-target"); + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "switch", + &["exec-target", "--execute", "echo switched"], + None, + )); +} + +// ============================================================================ +// step copy-ignored +// ============================================================================ + +/// Copy ignored files between jj workspaces — basic case. +#[rstest] +fn test_jj_copy_ignored_basic(mut jj_repo: JjTestRepo) { + // Create .gitignore and an ignored file in default workspace + std::fs::write(jj_repo.root_path().join(".gitignore"), "target/\n").unwrap(); + std::fs::create_dir_all(jj_repo.root_path().join("target")).unwrap(); + std::fs::write(jj_repo.root_path().join("target/debug.o"), "binary content").unwrap(); + run_jj_in(jj_repo.root_path(), &["describe", "-m", "add gitignore"]); + run_jj_in(jj_repo.root_path(), &["new"]); + + // Create a feature workspace + let feature_path = jj_repo.add_workspace("feature"); + + // Copy from default to feature + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "step", + &["copy-ignored", "--to", "feature"], + None, + )); + + // Verify file was copied + assert!( + feature_path.join("target/debug.o").exists(), + "Ignored file should be copied to feature workspace" + ); +} + +/// Copy ignored files with --from flag (copy from feature to default). +#[rstest] +fn test_jj_copy_ignored_from_feature(mut jj_repo: JjTestRepo) { + // Set up gitignore + std::fs::write(jj_repo.root_path().join(".gitignore"), "build/\n").unwrap(); + run_jj_in(jj_repo.root_path(), &["describe", "-m", "add gitignore"]); + run_jj_in(jj_repo.root_path(), &["new"]); + + // Create feature workspace with an ignored file + let feature_path = jj_repo.add_workspace("feature"); + std::fs::create_dir_all(feature_path.join("build")).unwrap(); + std::fs::write(feature_path.join("build/app"), "binary").unwrap(); + + // Copy from feature to default + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "step", + &["copy-ignored", "--from", "feature"], + None, + )); + + // Verify file was copied to default workspace + assert!( + jj_repo.root_path().join("build/app").exists(), + "Ignored file should be copied from feature to default workspace" + ); +} + +/// Dry run shows what would be copied without actually copying. +#[rstest] +fn test_jj_copy_ignored_dry_run(mut jj_repo: JjTestRepo) { + std::fs::write(jj_repo.root_path().join(".gitignore"), "target/\n").unwrap(); + std::fs::create_dir_all(jj_repo.root_path().join("target")).unwrap(); + std::fs::write(jj_repo.root_path().join("target/app"), "bin").unwrap(); + run_jj_in(jj_repo.root_path(), &["describe", "-m", "add gitignore"]); + run_jj_in(jj_repo.root_path(), &["new"]); + + let feature_path = jj_repo.add_workspace("feature"); + + assert_cmd_snapshot!(make_jj_snapshot_cmd( + &jj_repo, + "step", + &["copy-ignored", "--to", "feature", "--dry-run"], + None, + )); + + // Verify nothing was actually copied + assert!( + !feature_path.join("target").exists(), + "Dry run should not copy files" + ); +} diff --git a/tests/integration_tests/mod.rs b/tests/integration_tests/mod.rs index c9f8b38fa..8d28b41cc 100644 --- a/tests/integration_tests/mod.rs +++ b/tests/integration_tests/mod.rs @@ -33,6 +33,7 @@ pub mod git_error_display; pub mod help; pub mod hook_show; pub mod init; +pub mod jj; pub mod list; pub mod list_column_alignment; pub mod list_config; diff --git a/tests/integration_tests/output_system_guard.rs b/tests/integration_tests/output_system_guard.rs index e931f2a52..49f607319 100644 --- a/tests/integration_tests/output_system_guard.rs +++ b/tests/integration_tests/output_system_guard.rs @@ -36,6 +36,8 @@ const STDOUT_ALLOWED_PATHS: &[&str] = &[ "config/hints.rs", // LLM prompt output for wt step commit --show-prompt "step_commands.rs", + // LLM prompt output for jj step commit --show-prompt + "handle_step_jj.rs", ]; /// Substrings that indicate the line is a special case (e.g., in a comment or test reference) diff --git a/tests/integration_tests/shell_integration_prompt.rs b/tests/integration_tests/shell_integration_prompt.rs index 55a9caa15..d7f6f04b1 100644 --- a/tests/integration_tests/shell_integration_prompt.rs +++ b/tests/integration_tests/shell_integration_prompt.rs @@ -527,6 +527,8 @@ mod commit_generation_prompt_tests { repo.run_git(&["add", "test.txt"]); let mut env_vars = repo.test_env_vars(); + // Re-enable prompts (suppressed by default in test env) + env_vars.retain(|(k, _)| k != "WORKTRUNK_NO_PROMPTS"); // Use minimal PATH to ensure claude/codex aren't found env_vars.push(("PATH".to_string(), "/usr/bin:/bin".to_string())); @@ -562,6 +564,8 @@ mod commit_generation_prompt_tests { repo.run_git(&["add", "test.txt"]); let mut env_vars = repo.test_env_vars(); + // Re-enable prompts (suppressed by default in test env) + env_vars.retain(|(k, _)| k != "WORKTRUNK_NO_PROMPTS"); // Add our fake claude to PATH let path = format!("{}:/usr/bin:/bin", bin_dir.display()); env_vars.push(("PATH".to_string(), path)); @@ -603,6 +607,8 @@ mod commit_generation_prompt_tests { repo.run_git(&["add", "test.txt"]); let mut env_vars = repo.test_env_vars(); + // Re-enable prompts (suppressed by default in test env) + env_vars.retain(|(k, _)| k != "WORKTRUNK_NO_PROMPTS"); let path = format!("{}:/usr/bin:/bin", bin_dir.display()); env_vars.push(("PATH".to_string(), path)); @@ -644,6 +650,8 @@ mod commit_generation_prompt_tests { repo.run_git(&["add", "test.txt"]); let mut env_vars = repo.test_env_vars(); + // Re-enable prompts (suppressed by default in test env) + env_vars.retain(|(k, _)| k != "WORKTRUNK_NO_PROMPTS"); let path = format!("{}:/usr/bin:/bin", bin_dir.display()); env_vars.push(("PATH".to_string(), path)); diff --git a/tests/integration_tests/shell_wrapper.rs b/tests/integration_tests/shell_wrapper.rs index c88b170cb..faa0aba4d 100644 --- a/tests/integration_tests/shell_wrapper.rs +++ b/tests/integration_tests/shell_wrapper.rs @@ -739,6 +739,7 @@ const STANDARD_TEST_ENV: &[(&str, &str)] = &[ ("LANG", "C"), ("LC_ALL", "C"), ("WORKTRUNK_TEST_EPOCH", "1735776000"), + ("WORKTRUNK_NO_PROMPTS", "1"), ]; /// Build standard test env vars with config and approvals paths diff --git a/tests/integration_tests/user_hooks.rs b/tests/integration_tests/user_hooks.rs index 010d8f49a..4843a9361 100644 --- a/tests/integration_tests/user_hooks.rs +++ b/tests/integration_tests/user_hooks.rs @@ -805,7 +805,7 @@ fn test_standalone_hook_post_remove_invalid_template(repo: TestRepo) { let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("syntax error"), + stderr.contains("Failed to expand"), "Error should mention template expansion failure, got: {stderr}" ); } diff --git a/tests/snapshots/integration__integration_tests__ci_status__filters_by_repo_owner.snap b/tests/snapshots/integration__integration_tests__ci_status__filters_by_repo_owner.snap index aeb91c44b..ba63d8e69 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__filters_by_repo_owner.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__filters_by_repo_owner.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__github_pr_conflicts.snap b/tests/snapshots/integration__integration_tests__ci_status__github_pr_conflicts.snap index b1faa0a50..e7fd0c796 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__github_pr_conflicts.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__github_pr_conflicts.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__github_pr_failed.snap b/tests/snapshots/integration__integration_tests__ci_status__github_pr_failed.snap index 71ce2a2c8..10021f98b 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__github_pr_failed.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__github_pr_failed.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__github_pr_passed.snap b/tests/snapshots/integration__integration_tests__ci_status__github_pr_passed.snap index aeb91c44b..ba63d8e69 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__github_pr_passed.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__github_pr_passed.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__github_pr_running.snap b/tests/snapshots/integration__integration_tests__ci_status__github_pr_running.snap index a040acdd7..7cbce1285 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__github_pr_running.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__github_pr_running.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__gitlab_ci_rate_limit.snap b/tests/snapshots/integration__integration_tests__ci_status__gitlab_ci_rate_limit.snap index 3a06e5e84..8a954c7e5 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__gitlab_ci_rate_limit.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__gitlab_ci_rate_limit.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__gitlab_filters_by_project_id.snap b/tests/snapshots/integration__integration_tests__ci_status__gitlab_filters_by_project_id.snap index aeb91c44b..ba63d8e69 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__gitlab_filters_by_project_id.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__gitlab_filters_by_project_id.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_conflicts.snap b/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_conflicts.snap index b1faa0a50..e7fd0c796 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_conflicts.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_conflicts.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_failed.snap b/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_failed.snap index 71ce2a2c8..10021f98b 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_failed.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_failed.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_passed.snap b/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_passed.snap index aeb91c44b..ba63d8e69 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_passed.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_passed.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_pending.snap b/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_pending.snap index a040acdd7..7cbce1285 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_pending.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_pending.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_running.snap b/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_running.snap index a040acdd7..7cbce1285 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_running.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_running.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_view_failure.snap b/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_view_failure.snap index 3a06e5e84..8a954c7e5 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_view_failure.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__gitlab_mr_view_failure.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__gitlab_no_ci.snap b/tests/snapshots/integration__integration_tests__ci_status__gitlab_no_ci.snap index a0db5c4a8..71a214378 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__gitlab_no_ci.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__gitlab_no_ci.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__gitlab_single_mr_no_project_id.snap b/tests/snapshots/integration__integration_tests__ci_status__gitlab_single_mr_no_project_id.snap index aeb91c44b..ba63d8e69 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__gitlab_single_mr_no_project_id.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__gitlab_single_mr_no_project_id.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__gitlab_stale_mr.snap b/tests/snapshots/integration__integration_tests__ci_status__gitlab_stale_mr.snap index c9c3e2e8f..0cc7b3da9 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__gitlab_stale_mr.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__gitlab_stale_mr.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__list_full_with_invalid_platform_override.snap b/tests/snapshots/integration__integration_tests__ci_status__list_full_with_invalid_platform_override.snap index 7888d14bd..9130fb819 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__list_full_with_invalid_platform_override.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__list_full_with_invalid_platform_override.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__list_full_with_platform_override_github.snap b/tests/snapshots/integration__integration_tests__ci_status__list_full_with_platform_override_github.snap index 14d9eb4e6..bead634d1 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__list_full_with_platform_override_github.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__list_full_with_platform_override_github.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__mixed_check_types.snap b/tests/snapshots/integration__integration_tests__ci_status__mixed_check_types.snap index a040acdd7..7cbce1285 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__mixed_check_types.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__mixed_check_types.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__no_ci_checks.snap b/tests/snapshots/integration__integration_tests__ci_status__no_ci_checks.snap index a0db5c4a8..71a214378 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__no_ci_checks.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__no_ci_checks.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__stale_pr.snap b/tests/snapshots/integration__integration_tests__ci_status__stale_pr.snap index c9c3e2e8f..0cc7b3da9 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__stale_pr.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__stale_pr.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__status_context_failure.snap b/tests/snapshots/integration__integration_tests__ci_status__status_context_failure.snap index 71ce2a2c8..10021f98b 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__status_context_failure.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__status_context_failure.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__status_context_pending.snap b/tests/snapshots/integration__integration_tests__ci_status__status_context_pending.snap index a040acdd7..7cbce1285 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__status_context_pending.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__status_context_pending.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__ci_status__url_based_pushremote.snap b/tests/snapshots/integration__integration_tests__ci_status__url_based_pushremote.snap index 74a5490a1..61781e7b8 100644 --- a/tests/snapshots/integration__integration_tests__ci_status__url_based_pushremote.snap +++ b/tests/snapshots/integration__integration_tests__ci_status__url_based_pushremote.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__config_show__config_show_outside_git_repo.snap b/tests/snapshots/integration__integration_tests__config_show__config_show_outside_git_repo.snap index e6a618257..bc1c8f41f 100644 --- a/tests/snapshots/integration__integration_tests__config_show__config_show_outside_git_repo.snap +++ b/tests/snapshots/integration__integration_tests__config_show__config_show_outside_git_repo.snap @@ -36,7 +36,7 @@ exit_code: 0 USER CONFIG ~/.config/worktrunk/config.toml   worktree-path = "../{{ repo }}.{{ branch }}" -PROJECT CONFIG Not in a git repository +PROJECT CONFIG Not in a repository SHELL INTEGRATION ▲ Shell integration not active diff --git a/tests/snapshots/integration__integration_tests__hook_show__error_with_context_formatting.snap b/tests/snapshots/integration__integration_tests__hook_show__error_with_context_formatting.snap index 1259d3e4b..af62aab60 100644 --- a/tests/snapshots/integration__integration_tests__hook_show__error_with_context_formatting.snap +++ b/tests/snapshots/integration__integration_tests__hook_show__error_with_context_formatting.snap @@ -30,5 +30,4 @@ exit_code: 1 ----- stdout ----- ----- stderr ----- -✗ Failed to remove worktree -  fatal: not a git repository (or any of the parent directories): .git +✗ Not in a repository diff --git a/tests/snapshots/integration__integration_tests__hook_show__hook_show_expanded_syntax_error.snap b/tests/snapshots/integration__integration_tests__hook_show__hook_show_expanded_syntax_error.snap index 7b1554636..0fb316b94 100644 --- a/tests/snapshots/integration__integration_tests__hook_show__hook_show_expanded_syntax_error.snap +++ b/tests/snapshots/integration__integration_tests__hook_show__hook_show_expanded_syntax_error.snap @@ -31,6 +31,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__hook_show__hook_show_expanded_undefined_var.snap b/tests/snapshots/integration__integration_tests__hook_show__hook_show_expanded_undefined_var.snap index da708896b..e89bbb38d 100644 --- a/tests/snapshots/integration__integration_tests__hook_show__hook_show_expanded_undefined_var.snap +++ b/tests/snapshots/integration__integration_tests__hook_show__hook_show_expanded_undefined_var.snap @@ -31,6 +31,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__hook_show__hook_show_outside_git_repo.snap b/tests/snapshots/integration__integration_tests__hook_show__hook_show_outside_git_repo.snap index 2493b594f..cd183b60d 100644 --- a/tests/snapshots/integration__integration_tests__hook_show__hook_show_outside_git_repo.snap +++ b/tests/snapshots/integration__integration_tests__hook_show__hook_show_outside_git_repo.snap @@ -31,4 +31,4 @@ exit_code: 1 ----- stdout ----- ----- stderr ----- -✗ fatal: not a git repository (or any of the parent directories): .git +✗ Not in a repository diff --git a/tests/snapshots/integration__integration_tests__jj__jj_copy_ignored_basic.snap b/tests/snapshots/integration__integration_tests__jj__jj_copy_ignored_basic.snap new file mode 100644 index 000000000..76a9a65d6 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_copy_ignored_basic.snap @@ -0,0 +1,36 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - copy-ignored + - "--to" + - feature + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Copied 1 entry diff --git a/tests/snapshots/integration__integration_tests__jj__jj_copy_ignored_dry_run.snap b/tests/snapshots/integration__integration_tests__jj__jj_copy_ignored_dry_run.snap new file mode 100644 index 000000000..5e28351dd --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_copy_ignored_dry_run.snap @@ -0,0 +1,38 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - copy-ignored + - "--to" + - feature + - "--dry-run" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +○ Would copy 1 entry: +  target (dir) diff --git a/tests/snapshots/integration__integration_tests__jj__jj_copy_ignored_from_feature.snap b/tests/snapshots/integration__integration_tests__jj__jj_copy_ignored_from_feature.snap new file mode 100644 index 000000000..138014eca --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_copy_ignored_from_feature.snap @@ -0,0 +1,36 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - copy-ignored + - "--from" + - feature + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Copied 1 entry diff --git a/tests/snapshots/integration__integration_tests__jj__jj_list_after_remove.snap b/tests/snapshots/integration__integration_tests__jj__jj_list_after_remove.snap new file mode 100644 index 000000000..cbedce176 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_list_after_remove.snap @@ -0,0 +1,35 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - list + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + Branch Status HEAD± main↕ Path Remote⇅ Commit Age Message +@ default ^ . [CHANGE_ID_SHORT] ⋯ ⋯ +○ Showing 1 [CHANGE_ID_SHORT], 1 column hidden + +----- stderr ----- diff --git a/tests/snapshots/integration__integration_tests__jj__jj_list_clean_workspace.snap b/tests/snapshots/integration__integration_tests__jj__jj_list_clean_workspace.snap new file mode 100644 index 000000000..cbedce176 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_list_clean_workspace.snap @@ -0,0 +1,35 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - list + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + Branch Status HEAD± main↕ Path Remote⇅ Commit Age Message +@ default ^ . [CHANGE_ID_SHORT] ⋯ ⋯ +○ Showing 1 [CHANGE_ID_SHORT], 1 column hidden + +----- stderr ----- diff --git a/tests/snapshots/integration__integration_tests__jj__jj_list_dirty_workspace.snap b/tests/snapshots/integration__integration_tests__jj__jj_list_dirty_workspace.snap new file mode 100644 index 000000000..96b75c4e7 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_list_dirty_workspace.snap @@ -0,0 +1,36 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - list + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + Branch Status HEAD± main↕ Path Remote⇅ Commit Age Message +@ default ^ . [CHANGE_ID_SHORT] ⋯ ⋯ ++ dirty ↑ +1 ↑2 ../repo.dirty [CHANGE_ID_SHORT] ⋯ ⋯ +○ Showing 2 worktrees, 1 ahead, 1 column hidden + +----- stderr ----- diff --git a/tests/snapshots/integration__integration_tests__jj__jj_list_from_feature_workspace.snap b/tests/snapshots/integration__integration_tests__jj__jj_list_from_feature_workspace.snap new file mode 100644 index 000000000..83e9c272c --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_list_from_feature_workspace.snap @@ -0,0 +1,36 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - list + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + Branch Status HEAD± main↕ Path Remote⇅ Commit Age Message +@ feature ↑ ↑3 ../repo.feature [CHANGE_ID_SHORT] ⋯ ⋯ +^ default ^ . [CHANGE_ID_SHORT] ⋯ ⋯ +○ Showing 2 worktrees, 1 ahead, 1 column hidden + +----- stderr ----- diff --git a/tests/snapshots/integration__integration_tests__jj__jj_list_multiple_workspaces.snap b/tests/snapshots/integration__integration_tests__jj__jj_list_multiple_workspaces.snap new file mode 100644 index 000000000..4f610e2d9 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_list_multiple_workspaces.snap @@ -0,0 +1,37 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - list + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + Branch Status HEAD± main↕ Path Remote⇅ Commit Age Message +@ default ^ . [CHANGE_ID_SHORT] ⋯ ⋯ ++ feature-a ↑ ↑3 ../repo.feature-a [CHANGE_ID_SHORT] ⋯ ⋯ ++ feature-b ↑ ↑3 ../repo.feature-b [CHANGE_ID_SHORT] ⋯ ⋯ +○ Showing 3 worktrees, 2 ahead, 1 column hidden + +----- stderr ----- diff --git a/tests/snapshots/integration__integration_tests__jj__jj_list_single_workspace.snap b/tests/snapshots/integration__integration_tests__jj__jj_list_single_workspace.snap new file mode 100644 index 000000000..cbedce176 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_list_single_workspace.snap @@ -0,0 +1,35 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - list + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + Branch Status HEAD± main↕ Path Remote⇅ Commit Age Message +@ default ^ . [CHANGE_ID_SHORT] ⋯ ⋯ +○ Showing 1 [CHANGE_ID_SHORT], 1 column hidden + +----- stderr ----- diff --git a/tests/snapshots/integration__integration_tests__jj__jj_list_workspace_with_commits.snap b/tests/snapshots/integration__integration_tests__jj__jj_list_workspace_with_commits.snap new file mode 100644 index 000000000..d6cb9651e --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_list_workspace_with_commits.snap @@ -0,0 +1,36 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - list + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + Branch Status HEAD± main↕ Path Remote⇅ Commit Age Message +@ default ^ . [CHANGE_ID_SHORT] ⋯ ⋯ ++ with-commits ↑ ↑4 ../repo.with-commits [CHANGE_ID_SHORT] ⋯ ⋯ +○ Showing 2 worktrees, 1 ahead, 1 column hidden + +----- stderr ----- diff --git a/tests/snapshots/integration__integration_tests__jj__jj_list_workspace_with_no_user_commits.snap b/tests/snapshots/integration__integration_tests__jj__jj_list_workspace_with_no_user_commits.snap new file mode 100644 index 000000000..c0c77901a --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_list_workspace_with_no_user_commits.snap @@ -0,0 +1,36 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - list + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + Branch Status HEAD± main↕ Path Remote⇅ Commit Age Message +@ default ^ . [CHANGE_ID_SHORT] ⋯ ⋯ ++ integrated ↑ ↑2 ../repo.integrated [CHANGE_ID_SHORT] ⋯ ⋯ +○ Showing 2 worktrees, 1 ahead, 1 column hidden + +----- stderr ----- diff --git a/tests/snapshots/integration__integration_tests__jj__jj_merge_from_default_fails.snap b/tests/snapshots/integration__integration_tests__jj__jj_merge_from_default_fails.snap new file mode 100644 index 000000000..a36e06a53 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_merge_from_default_fails.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - merge + - main + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ Cannot merge the default workspace diff --git a/tests/snapshots/integration__integration_tests__jj__jj_merge_implicit_target.snap b/tests/snapshots/integration__integration_tests__jj__jj_merge_implicit_target.snap new file mode 100644 index 000000000..0ea62f111 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_merge_implicit_target.snap @@ -0,0 +1,36 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - merge + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Pushed main +✓ Squashed workspace feature into main +✓ Removed workspace feature @ _REPO_.feature ([CHANGE_ID_SHORT] of main, ⊂) diff --git a/tests/snapshots/integration__integration_tests__jj__jj_merge_multi_commit.snap b/tests/snapshots/integration__integration_tests__jj__jj_merge_multi_commit.snap new file mode 100644 index 000000000..7063df1c4 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_merge_multi_commit.snap @@ -0,0 +1,44 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - merge + - main + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +◎ Generating squash commit message... +↳ Using [CHANGE_ID_SHORT] commit message. For LLM setup guide, run wt config --help +  Squash commits from main +  +  Combined commits: +  - Add file 1 +  - Add file 2 +✓ Squashed @ [CHANGE_ID] +✓ Squashed workspace multi into main +✓ Removed workspace multi @ _REPO_.multi ([CHANGE_ID_SHORT] of main, ⊂) diff --git a/tests/snapshots/integration__integration_tests__jj__jj_merge_no_net_changes.snap b/tests/snapshots/integration__integration_tests__jj__jj_merge_no_net_changes.snap new file mode 100644 index 000000000..2e1f8a371 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_merge_no_net_changes.snap @@ -0,0 +1,44 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - merge + - main + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +◎ Generating squash commit message... +↳ Using [CHANGE_ID_SHORT] commit message. For LLM setup guide, run wt config --help +  Squash commits from main +  +  Combined commits: +  - Add temp file +  - Remove temp file +✓ Squashed @ [CHANGE_ID] +✓ Squashed workspace noop into main +✓ Removed workspace noop @ _REPO_.noop ([CHANGE_ID_SHORT] of main, ⊂) diff --git a/tests/snapshots/integration__integration_tests__jj__jj_merge_no_net_changes_with_remove.snap b/tests/snapshots/integration__integration_tests__jj__jj_merge_no_net_changes_with_remove.snap new file mode 100644 index 000000000..d0df0f47b --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_merge_no_net_changes_with_remove.snap @@ -0,0 +1,44 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - merge + - main + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +◎ Generating squash commit message... +↳ Using [CHANGE_ID_SHORT] commit message. For LLM setup guide, run wt config --help +  Squash commits from main +  +  Combined commits: +  - Add temp file +  - Remove temp file +✓ Squashed @ [CHANGE_ID] +✓ Squashed workspace noop2 into main +✓ Removed workspace noop2 @ _REPO_.noop2 ([CHANGE_ID_SHORT] of main, ⊂) diff --git a/tests/snapshots/integration__integration_tests__jj__jj_merge_no_remove.snap b/tests/snapshots/integration__integration_tests__jj__jj_merge_no_remove.snap new file mode 100644 index 000000000..a5a5d1822 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_merge_no_remove.snap @@ -0,0 +1,37 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - merge + - main + - "--no-remove" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Pushed main +✓ Squashed workspace feature into main +○ Workspace preserved (--no-remove) diff --git a/tests/snapshots/integration__integration_tests__jj__jj_merge_no_squash_no_remove.snap b/tests/snapshots/integration__integration_tests__jj__jj_merge_no_squash_no_remove.snap new file mode 100644 index 000000000..e64d1976e --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_merge_no_squash_no_remove.snap @@ -0,0 +1,37 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - merge + - main + - "--no-squash" + - "--no-remove" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Merged workspace feature into main +○ Workspace preserved (--no-remove) diff --git a/tests/snapshots/integration__integration_tests__jj__jj_merge_squash.snap b/tests/snapshots/integration__integration_tests__jj__jj_merge_squash.snap new file mode 100644 index 000000000..95e8faeab --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_merge_squash.snap @@ -0,0 +1,37 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - merge + - main + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Pushed main +✓ Squashed workspace feature into main +✓ Removed workspace feature @ _REPO_.feature ([CHANGE_ID_SHORT] of main, ⊂) diff --git a/tests/snapshots/integration__integration_tests__jj__jj_merge_squash_with_directive_file.snap b/tests/snapshots/integration__integration_tests__jj__jj_merge_squash_with_directive_file.snap new file mode 100644 index 000000000..064ecb606 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_merge_squash_with_directive_file.snap @@ -0,0 +1,38 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - merge + - main + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_DIRECTIVE_FILE: "[DIRECTIVE_FILE]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Pushed main +✓ Squashed workspace feature into main +✓ Removed workspace feature @ _REPO_.feature ([CHANGE_ID_SHORT] of main, ⊂) diff --git a/tests/snapshots/integration__integration_tests__jj__jj_merge_with_no_squash.snap b/tests/snapshots/integration__integration_tests__jj__jj_merge_with_no_squash.snap new file mode 100644 index 000000000..e2e6032e4 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_merge_with_no_squash.snap @@ -0,0 +1,37 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - merge + - main + - "--no-squash" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Merged workspace feature into main +✓ Removed workspace feature @ _REPO_.feature ([CHANGE_ID_SHORT] of main, ⊂) diff --git a/tests/snapshots/integration__integration_tests__jj__jj_merge_workspace_with_no_user_commits.snap b/tests/snapshots/integration__integration_tests__jj__jj_merge_workspace_with_no_user_commits.snap new file mode 100644 index 000000000..f157a60ee --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_merge_workspace_with_no_user_commits.snap @@ -0,0 +1,36 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - merge + - main + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +○ Workspace integrated is already integrated into trunk +✓ Removed workspace integrated @ _REPO_.integrated ([CHANGE_ID_SHORT] of main, ⊂) diff --git a/tests/snapshots/integration__integration_tests__jj__jj_merge_zero_commits_ahead.snap b/tests/snapshots/integration__integration_tests__jj__jj_merge_zero_commits_ahead.snap new file mode 100644 index 000000000..31b97efc0 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_merge_zero_commits_ahead.snap @@ -0,0 +1,36 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - merge + - main + - "--no-remove" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +○ Workspace at-trunk is already integrated into trunk +○ Workspace preserved (--no-remove) diff --git a/tests/snapshots/integration__integration_tests__jj__jj_merge_zero_commits_ahead_with_remove.snap b/tests/snapshots/integration__integration_tests__jj__jj_merge_zero_commits_ahead_with_remove.snap new file mode 100644 index 000000000..d88570216 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_merge_zero_commits_ahead_with_remove.snap @@ -0,0 +1,36 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - merge + - main + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +○ Workspace at-trunk2 is already integrated into trunk +✓ Removed workspace at-trunk2 @ _REPO_.at-trunk2 ([CHANGE_ID_SHORT] of main, ⊂) diff --git a/tests/snapshots/integration__integration_tests__jj__jj_remove_already_deleted_directory.snap b/tests/snapshots/integration__integration_tests__jj__jj_remove_already_deleted_directory.snap new file mode 100644 index 000000000..ce6d5ea7a --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_remove_already_deleted_directory.snap @@ -0,0 +1,36 @@ +--- +source: tests/integration_tests/jj.rs +assertion_line: 992 +info: + program: wt + args: + - remove + - deleted + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +▲ Workspace directory already removed: _REPO_.deleted +✓ Removed workspace deleted @ _REPO_.deleted diff --git a/tests/snapshots/integration__integration_tests__jj__jj_remove_already_on_default.snap b/tests/snapshots/integration__integration_tests__jj__jj_remove_already_on_default.snap new file mode 100644 index 000000000..932918f39 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_remove_already_on_default.snap @@ -0,0 +1,33 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - remove + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ Cannot remove the default workspace diff --git a/tests/snapshots/integration__integration_tests__jj__jj_remove_current_workspace_cds_to_default.snap b/tests/snapshots/integration__integration_tests__jj__jj_remove_current_workspace_cds_to_default.snap new file mode 100644 index 000000000..ed2fd0543 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_remove_current_workspace_cds_to_default.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - remove + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_DIRECTIVE_FILE: "[DIRECTIVE_FILE]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Removed workspace feature @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__jj__jj_remove_current_workspace_no_name.snap b/tests/snapshots/integration__integration_tests__jj__jj_remove_current_workspace_no_name.snap new file mode 100644 index 000000000..6ddd51e2b --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_remove_current_workspace_no_name.snap @@ -0,0 +1,33 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - remove + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Removed workspace removeme @ _REPO_.[CHANGE_ID_SHORT] diff --git a/tests/snapshots/integration__integration_tests__jj__jj_remove_default_fails.snap b/tests/snapshots/integration__integration_tests__jj__jj_remove_default_fails.snap new file mode 100644 index 000000000..78d72e0de --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_remove_default_fails.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - remove + - default + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ Cannot remove the default workspace diff --git a/tests/snapshots/integration__integration_tests__jj__jj_remove_nonexistent_workspace.snap b/tests/snapshots/integration__integration_tests__jj__jj_remove_nonexistent_workspace.snap new file mode 100644 index 000000000..7e0a2ce09 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_remove_nonexistent_workspace.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - remove + - nonexistent + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ Error: No such workspace: nonexistent diff --git a/tests/snapshots/integration__integration_tests__jj__jj_remove_workspace.snap b/tests/snapshots/integration__integration_tests__jj__jj_remove_workspace.snap new file mode 100644 index 000000000..624e42683 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_remove_workspace.snap @@ -0,0 +1,33 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - remove + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Removed workspace feature @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__jj__jj_remove_workspace_by_name.snap b/tests/snapshots/integration__integration_tests__jj__jj_remove_workspace_by_name.snap new file mode 100644 index 000000000..2d1f9f805 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_remove_workspace_by_name.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - remove + - feature + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Removed workspace feature @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_commit_empty_description.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_empty_description.snap new file mode 100644 index 000000000..9922c7b00 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_empty_description.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - commit + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Committed: Changes to gen.txt diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_commit_in_feature_workspace.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_in_feature_workspace.snap new file mode 100644 index 000000000..a827a1630 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_in_feature_workspace.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - commit + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Committed: Changes to feat.txt diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_commit_multiple_files.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_multiple_files.snap new file mode 100644 index 000000000..af93975dc --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_multiple_files.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - commit + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Committed: Changes to 4 files diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_commit_nothing_to_commit.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_nothing_to_commit.snap new file mode 100644 index 000000000..76357c48c --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_nothing_to_commit.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - commit + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ Nothing to commit (working copy is empty) diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_commit_reuses_existing_description.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_reuses_existing_description.snap new file mode 100644 index 000000000..9252f16af --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_reuses_existing_description.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - commit + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Committed: My custom message diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_commit_show_prompt.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_show_prompt.snap new file mode 100644 index 000000000..dcc9d5d45 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_show_prompt.snap @@ -0,0 +1,66 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - commit + - "--show-prompt" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- +Write a commit message for the staged changes below. + + +- Subject line under 50 chars +- For [CHANGE_ID_SHORT] changes, add a blank line then a body paragraph explaining the change +- Output only the commit message, no quotes or code blocks + + + + +<[CHANGE_ID_SHORT]> +prompt.txt | 1 + +1 file changed, 1 insertion(+), 0 deletions(-) + + + + +Added regular file prompt.txt: + 1: content + + + + +Branch: default + +- Initial commit + + + +----- stderr ----- diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_commit_show_prompt_with_llm.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_show_prompt_with_llm.snap new file mode 100644 index 000000000..c484985e7 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_show_prompt_with_llm.snap @@ -0,0 +1,66 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - commit + - "--show-prompt" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- +Write a commit message for the staged changes below. + + +- Subject line under 50 chars +- For [CHANGE_ID_SHORT] changes, add a blank line then a body paragraph explaining the change +- Output only the commit message, no quotes or code blocks + + + + +<[CHANGE_ID_SHORT]> +llm.txt | 1 + +1 file changed, 1 insertion(+), 0 deletions(-) + + + + +Added regular file llm.txt: + 1: content + + + + +Branch: default + +- Initial commit + + + +----- stderr ----- diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_commit_three_files.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_three_files.snap new file mode 100644 index 000000000..2c177a917 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_three_files.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - commit + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Committed: Changes to alpha.txt, beta.txt & gamma.txt diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_commit_two_files.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_two_files.snap new file mode 100644 index 000000000..33945586a --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_two_files.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - commit + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Committed: Changes to alpha.txt & beta.txt diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_commit_with_changes.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_with_changes.snap new file mode 100644 index 000000000..8b1dcad51 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_with_changes.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - commit + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Committed: Changes to new.txt diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_commit_with_llm.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_with_llm.snap new file mode 100644 index 000000000..00e797700 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_commit_with_llm.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - commit + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Committed: LLM-generated-message diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_push_behind_trunk.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_push_behind_trunk.snap new file mode 100644 index 000000000..4a8d4d34b --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_push_behind_trunk.snap @@ -0,0 +1,35 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - push + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ Failed to push +  Cannot push: feature is not ahead of main. Rebase first with `wt step rebase`. diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_push_no_remote.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_push_no_remote.snap new file mode 100644 index 000000000..f7d6d8e0f --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_push_no_remote.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - push + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Pushed to main (1 commit) diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_push_nothing_to_push.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_push_nothing_to_push.snap new file mode 100644 index 000000000..7fe920fc6 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_push_nothing_to_push.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - push + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +○ Already up to date with main diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_rebase_already_up_to_date.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_rebase_already_up_to_date.snap new file mode 100644 index 000000000..2968cbb6a --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_rebase_already_up_to_date.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - rebase + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +○ Already up to date with main diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_rebase_onto_advanced_trunk.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_rebase_onto_advanced_trunk.snap new file mode 100644 index 000000000..a8f71557c --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_rebase_onto_advanced_trunk.snap @@ -0,0 +1,35 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - rebase + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +◎ Rebasing onto main... +✓ Rebased onto main diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_squash_already_integrated.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_squash_already_integrated.snap new file mode 100644 index 000000000..ae294bf39 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_squash_already_integrated.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - squash + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +○ Nothing to squash; no commits ahead of main diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_squash_already_single_commit.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_squash_already_single_commit.snap new file mode 100644 index 000000000..c7b49f9f1 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_squash_already_single_commit.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - squash + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +○ Nothing to squash; already a single commit diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_squash_multiple_commits.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_squash_multiple_commits.snap new file mode 100644 index 000000000..b83b59681 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_squash_multiple_commits.snap @@ -0,0 +1,41 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - squash + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +◎ Generating squash commit message... +↳ Using [CHANGE_ID_SHORT] commit message. For LLM setup guide, run wt config --help +  Squash commits from main +  +  Combined commits: +  - First commit +  - Second commit +✓ Squashed @ [CHANGE_ID] diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_squash_no_commits_ahead.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_squash_no_commits_ahead.snap new file mode 100644 index 000000000..84053f034 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_squash_no_commits_ahead.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - squash + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +○ Nothing to squash; no commits ahead of main diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_squash_show_prompt.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_squash_show_prompt.snap new file mode 100644 index 000000000..30cc3f1a4 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_squash_show_prompt.snap @@ -0,0 +1,63 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - squash + - "--show-prompt" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- +Combine these commits into a single commit message. + + +- Subject line under 50 chars +- For [CHANGE_ID_SHORT] changes, add a blank line then a body paragraph explaining the change +- Output only the commit message, no quotes or code blocks + + + + + +- Commit for prompt + + +<[CHANGE_ID_SHORT]> +p.txt | 1 + +1 file changed, 1 insertion(+), 0 deletions(-) + + + + +Added regular file p.txt: + 1: content + + + +----- stderr ----- diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_squash_single_commit_with_wc_content.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_squash_single_commit_with_wc_content.snap new file mode 100644 index 000000000..eeb501993 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_squash_single_commit_with_wc_content.snap @@ -0,0 +1,40 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - squash + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +◎ Generating squash commit message... +↳ Using [CHANGE_ID_SHORT] commit message. For LLM setup guide, run wt config --help +  Squash commits from main +  +  Combined commits: +  - First commit +✓ Squashed @ [CHANGE_ID] diff --git a/tests/snapshots/integration__integration_tests__jj__jj_step_squash_then_push.snap b/tests/snapshots/integration__integration_tests__jj__jj_step_squash_then_push.snap new file mode 100644 index 000000000..7fe920fc6 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_step_squash_then_push.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - step + - push + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +○ Already up to date with main diff --git a/tests/snapshots/integration__integration_tests__jj__jj_switch_already_at_workspace.snap b/tests/snapshots/integration__integration_tests__jj__jj_switch_already_at_workspace.snap new file mode 100644 index 000000000..13c4df772 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_switch_already_at_workspace.snap @@ -0,0 +1,35 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - switch + - feature + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Switched to workspace feature @ _REPO_.feature +↳ To enable automatic cd, run wt config shell install diff --git a/tests/snapshots/integration__integration_tests__jj__jj_switch_create_and_then_list.snap b/tests/snapshots/integration__integration_tests__jj__jj_switch_create_and_then_list.snap new file mode 100644 index 000000000..d46a41b31 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_switch_create_and_then_list.snap @@ -0,0 +1,36 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - list + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + Branch Status HEAD± main↕ Path Remote⇅ Commit Age Message +@ default ^ . [CHANGE_ID_SHORT] ⋯ ⋯ ++ via-switch ↑ ↑2 ../repo.via-switch [CHANGE_ID_SHORT] ⋯ ⋯ +○ Showing 2 worktrees, 1 ahead, 1 column hidden + +----- stderr ----- diff --git a/tests/snapshots/integration__integration_tests__jj__jj_switch_create_new_workspace.snap b/tests/snapshots/integration__integration_tests__jj__jj_switch_create_new_workspace.snap new file mode 100644 index 000000000..c899fec1b --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_switch_create_new_workspace.snap @@ -0,0 +1,36 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - switch + - "--create" + - new-feature + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Created workspace new-feature @ _REPO_.new-feature +↳ To enable automatic cd, run wt config shell install diff --git a/tests/snapshots/integration__integration_tests__jj__jj_switch_create_path_exists.snap b/tests/snapshots/integration__integration_tests__jj__jj_switch_create_path_exists.snap new file mode 100644 index 000000000..7b6a38979 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_switch_create_path_exists.snap @@ -0,0 +1,35 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - switch + - conflict + - "--create" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ Path already exists: _REPO_.[CHANGE_ID_SHORT] diff --git a/tests/snapshots/integration__integration_tests__jj__jj_switch_create_with_base.snap b/tests/snapshots/integration__integration_tests__jj__jj_switch_create_with_base.snap new file mode 100644 index 000000000..763c5df5a --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_switch_create_with_base.snap @@ -0,0 +1,38 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - switch + - based-ws + - "--create" + - "--base" + - main + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Created workspace based-ws @ _REPO_.based-ws +↳ To enable automatic cd, run wt config shell install diff --git a/tests/snapshots/integration__integration_tests__jj__jj_switch_create_with_directive_file.snap b/tests/snapshots/integration__integration_tests__jj__jj_switch_create_with_directive_file.snap new file mode 100644 index 000000000..c345c897d --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_switch_create_with_directive_file.snap @@ -0,0 +1,36 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - switch + - "--create" + - new-ws + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_DIRECTIVE_FILE: "[DIRECTIVE_FILE]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Created workspace new-ws @ _REPO_.new-ws diff --git a/tests/snapshots/integration__integration_tests__jj__jj_switch_create_with_execute.snap b/tests/snapshots/integration__integration_tests__jj__jj_switch_create_with_execute.snap new file mode 100644 index 000000000..805abfae9 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_switch_create_with_execute.snap @@ -0,0 +1,41 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - switch + - "--create" + - exec-ws + - "--execute" + - echo hello + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- +hello + +----- stderr ----- +✓ Created workspace exec-ws @ _REPO_.exec-ws +↳ To enable automatic cd, run wt config shell install +◎ Executing (--execute) @ _REPO_.exec-ws: +  echo hello diff --git a/tests/snapshots/integration__integration_tests__jj__jj_switch_create_with_hooks.snap b/tests/snapshots/integration__integration_tests__jj__jj_switch_create_with_hooks.snap new file mode 100644 index 000000000..1b6b7b279 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_switch_create_with_hooks.snap @@ -0,0 +1,41 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - switch + - "--create" + - hooked + - "--yes" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +◎ Running post-create project hook @ _REPO_.hooked +  echo post-create-ran +post-create-ran +✓ Created workspace hooked @ _REPO_.hooked +↳ To enable automatic cd, run wt config shell install +◎ Running post-switch: project; post-start: project @ _REPO_.hooked diff --git a/tests/snapshots/integration__integration_tests__jj__jj_switch_default_from_default.snap b/tests/snapshots/integration__integration_tests__jj__jj_switch_default_from_default.snap new file mode 100644 index 000000000..436678d36 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_switch_default_from_default.snap @@ -0,0 +1,35 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - switch + - default + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Switched to workspace default @ _REPO_ +↳ To enable automatic cd, run wt config shell install diff --git a/tests/snapshots/integration__integration_tests__jj__jj_switch_existing_with_execute.snap b/tests/snapshots/integration__integration_tests__jj__jj_switch_existing_with_execute.snap new file mode 100644 index 000000000..c500b571e --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_switch_existing_with_execute.snap @@ -0,0 +1,40 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - switch + - exec-target + - "--execute" + - echo switched + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- +[CHANGE_ID_SHORT] + +----- stderr ----- +✓ Switched to workspace exec-target @ _REPO_.exec-target +↳ To enable automatic cd, run wt config shell install +◎ Executing (--execute) @ _REPO_.exec-target: +  echo [CHANGE_ID_SHORT] diff --git a/tests/snapshots/integration__integration_tests__jj__jj_switch_existing_with_hooks.snap b/tests/snapshots/integration__integration_tests__jj__jj_switch_existing_with_hooks.snap new file mode 100644 index 000000000..1461eaa9a --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_switch_existing_with_hooks.snap @@ -0,0 +1,37 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - switch + - hookable + - "--yes" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Switched to workspace hookable @ _REPO_.[CHANGE_ID_SHORT] +↳ To enable automatic cd, run wt config shell install +◎ Running post-switch: project @ _REPO_.[CHANGE_ID_SHORT] diff --git a/tests/snapshots/integration__integration_tests__jj__jj_switch_nonexistent_workspace.snap b/tests/snapshots/integration__integration_tests__jj__jj_switch_nonexistent_workspace.snap new file mode 100644 index 000000000..c833443ca --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_switch_nonexistent_workspace.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - switch + - nonexistent + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ Workspace 'nonexistent' not found. Use --create to create it. diff --git a/tests/snapshots/integration__integration_tests__jj__jj_switch_previous.snap b/tests/snapshots/integration__integration_tests__jj__jj_switch_previous.snap new file mode 100644 index 000000000..51624fe1f --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_switch_previous.snap @@ -0,0 +1,35 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - switch + - "-" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Switched to workspace alpha @ _REPO_.alpha +↳ To enable automatic cd, run wt config shell install diff --git a/tests/snapshots/integration__integration_tests__jj__jj_switch_records_previous.snap b/tests/snapshots/integration__integration_tests__jj__jj_switch_records_previous.snap new file mode 100644 index 000000000..3d3b9d39b --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_switch_records_previous.snap @@ -0,0 +1,35 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - switch + - "-" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Switched to workspace default @ _REPO_ +↳ To enable automatic cd, run wt config shell install diff --git a/tests/snapshots/integration__integration_tests__jj__jj_switch_to_default.snap b/tests/snapshots/integration__integration_tests__jj__jj_switch_to_default.snap new file mode 100644 index 000000000..436678d36 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_switch_to_default.snap @@ -0,0 +1,35 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - switch + - default + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Switched to workspace default @ _REPO_ +↳ To enable automatic cd, run wt config shell install diff --git a/tests/snapshots/integration__integration_tests__jj__jj_switch_to_existing_with_directive_file.snap b/tests/snapshots/integration__integration_tests__jj__jj_switch_to_existing_with_directive_file.snap new file mode 100644 index 000000000..9d63372d4 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_switch_to_existing_with_directive_file.snap @@ -0,0 +1,35 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - switch + - feature + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_DIRECTIVE_FILE: "[DIRECTIVE_FILE]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WT_TEST_DELAYED_STREAM_MS: "-1" + WT_TEST_EPOCH: "1735776000" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Switched to workspace feature @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__jj__jj_switch_to_existing_workspace.snap b/tests/snapshots/integration__integration_tests__jj__jj_switch_to_existing_workspace.snap new file mode 100644 index 000000000..13c4df772 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__jj__jj_switch_to_existing_workspace.snap @@ -0,0 +1,35 @@ +--- +source: tests/integration_tests/jj.rs +info: + program: wt + args: + - switch + - feature + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_EDITOR: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Switched to workspace feature @ _REPO_.feature +↳ To enable automatic cd, run wt config shell install diff --git a/tests/snapshots/integration__integration_tests__list__quickstart_merge.snap b/tests/snapshots/integration__integration_tests__list__quickstart_merge.snap index 710ed3eb3..de39eb00b 100644 --- a/tests/snapshots/integration__integration_tests__list__quickstart_merge.snap +++ b/tests/snapshots/integration__integration_tests__list__quickstart_merge.snap @@ -25,12 +25,13 @@ info: SHELL: "" TERM: alacritty USERPROFILE: "[TEST_HOME]" - WORKTRUNK_COMMIT__GENERATION__COMMAND: /private/var/folders/wf/s6ycxvvs4ln8qsdbfx40hnc40000gn/T/.tmpZfznjl/mock-bin/llm + WORKTRUNK_COMMIT__GENERATION__COMMAND: /private/var/folders/wf/s6ycxvvs4ln8qsdbfx40hnc40000gn/T/.tmpPWM7nH/mock-bin/llm WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_DIRECTIVE_FILE: "[DIRECTIVE_FILE]" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__merge__readme_example_complex.snap b/tests/snapshots/integration__integration_tests__merge__readme_example_complex.snap index 550d39168..36457a8ce 100644 --- a/tests/snapshots/integration__integration_tests__merge__readme_example_complex.snap +++ b/tests/snapshots/integration__integration_tests__merge__readme_example_complex.snap @@ -30,6 +30,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__post_start_commands__post_create_upstream_template.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_create_upstream_template.snap index cce2939ce..ee6b36bd1 100644 --- a/tests/snapshots/integration__integration_tests__post_start_commands__post_create_upstream_template.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_create_upstream_template.snap @@ -27,19 +27,24 @@ info: SHELL: "" TERM: alacritty USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: false -exit_code: 1 +exit_code: 101 ----- stdout ----- ----- stderr ----- -✗ Failed to expand project post-create hook: undefined value @ line 1 + +thread 'main' panicked at src/main.rs:843:21: +Multiline error without context: ✗ Failed to expand project post-create hook: undefined value @ line 1   echo 'Upstream: {{ upstream }}' > upstream.txt ↳ Available variables: base, base_worktree_path, branch, commit, default_branch, main_worktree, main_worktree_path, primary_worktree_path, remote, remote_url, repo, repo_path, repo_root, short_commit, worktree, worktree_name, worktree_path +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace diff --git a/tests/snapshots/integration__integration_tests__step_relocate__relocate_template_error.snap b/tests/snapshots/integration__integration_tests__step_relocate__relocate_template_error.snap index d1b42335a..266a7e16a 100644 --- a/tests/snapshots/integration__integration_tests__step_relocate__relocate_template_error.snap +++ b/tests/snapshots/integration__integration_tests__step_relocate__relocate_template_error.snap @@ -30,6 +30,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" @@ -40,7 +41,7 @@ exit_code: 0 ----- stderr ----- ▲ Skipping feature due to template error: -✗ Failed to expand worktree-path: undefined value @ line 1 -  {{ nonexistent_variable }} -↳ Available variables: branch, main_worktree, repo, repo_path +  ✗ Failed to expand worktree-path: undefined value @ line 1 +    {{ nonexistent_variable }} +  ↳ Available variables: branch, main_worktree, repo, repo_path ○ No relocations performed; 1 skipped due to template error diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_arg_template_error.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_arg_template_error.snap index df96026ae..2c002ab54 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_arg_template_error.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_arg_template_error.snap @@ -31,16 +31,18 @@ info: SHELL: "" TERM: alacritty USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: false -exit_code: 1 +exit_code: 101 ----- stdout ----- ----- stderr ----- @@ -48,5 +50,8 @@ exit_code: 1 ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -✗ Failed to expand --execute argument: syntax error: unexpected end of input, expected end of variable block @ line 1 + +thread 'main' panicked at src/main.rs:843:21: +Multiline error without context: ✗ Failed to expand --execute argument: syntax error: unexpected end of input, expected end of variable block @ line 1   invalid={{ unclosed +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base_without_create.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base_without_create.snap index c8aafc9fb..576bf93f3 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base_without_create.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base_without_create.snap @@ -27,21 +27,26 @@ info: SHELL: "" TERM: alacritty USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: false -exit_code: 1 +exit_code: 101 ----- stdout ----- ----- stderr ----- ▲ Worktree for existing @ _REPO_.existing, but cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -✗ Failed to expand --execute command: undefined value @ line 1 + +thread 'main' panicked at src/main.rs:843:21: +Multiline error without context: ✗ Failed to expand --execute command: undefined value @ line 1   echo 'base={{ base }}' ↳ Available variables: branch, commit, default_branch, main_worktree, main_worktree_path, primary_worktree_path, remote, remote_url, repo, repo_path, repo_root, short_commit, worktree, worktree_name, worktree_path +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_error.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_error.snap index 5d5e41d53..0d2392f22 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_error.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_error.snap @@ -28,16 +28,18 @@ info: SHELL: "" TERM: alacritty USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: false -exit_code: 1 +exit_code: 101 ----- stdout ----- ----- stderr ----- @@ -45,5 +47,8 @@ exit_code: 1 ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -✗ Failed to expand --execute command: syntax error: unexpected end of input, expected end of variable block @ line 1 + +thread 'main' panicked at src/main.rs:843:21: +Multiline error without context: ✗ Failed to expand --execute command: syntax error: unexpected end of input, expected end of variable block @ line 1   echo {{ unclosed +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace diff --git a/tests/snapshots/integration__integration_tests__switch__switch_mr_create_conflict.snap b/tests/snapshots/integration__integration_tests__switch__switch_mr_create_conflict.snap index 0caa61f0d..956464b3e 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_mr_create_conflict.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_mr_create_conflict.snap @@ -30,6 +30,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_mr_empty_branch.snap b/tests/snapshots/integration__integration_tests__switch__switch_mr_empty_branch.snap index 0136c1685..7f7396df3 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_mr_empty_branch.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_mr_empty_branch.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_mr_fork.snap b/tests/snapshots/integration__integration_tests__switch__switch_mr_fork.snap index 2635770d3..e9f66e7a3 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_mr_fork.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_mr_fork.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_mr_fork_existing_branch_tracks_different.snap b/tests/snapshots/integration__integration_tests__switch__switch_mr_fork_existing_branch_tracks_different.snap index ef4e502b8..dbd1671b2 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_mr_fork_existing_branch_tracks_different.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_mr_fork_existing_branch_tracks_different.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_mr_fork_existing_branch_tracks_mr.snap b/tests/snapshots/integration__integration_tests__switch__switch_mr_fork_existing_branch_tracks_mr.snap index 69239e42f..b70bb4234 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_mr_fork_existing_branch_tracks_mr.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_mr_fork_existing_branch_tracks_mr.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_mr_fork_existing_no_tracking.snap b/tests/snapshots/integration__integration_tests__switch__switch_mr_fork_existing_no_tracking.snap index ef4e502b8..dbd1671b2 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_mr_fork_existing_no_tracking.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_mr_fork_existing_no_tracking.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_mr_invalid_json.snap b/tests/snapshots/integration__integration_tests__switch__switch_mr_invalid_json.snap index 144081e5e..91e6697d1 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_mr_invalid_json.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_mr_invalid_json.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_mr_malformed_web_url.snap b/tests/snapshots/integration__integration_tests__switch__switch_mr_malformed_web_url.snap index 3ca2663a1..07f0de4e2 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_mr_malformed_web_url.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_mr_malformed_web_url.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_mr_malformed_web_url_no_project.snap b/tests/snapshots/integration__integration_tests__switch__switch_mr_malformed_web_url_no_project.snap index f98809dea..35f65bb3d 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_mr_malformed_web_url_no_project.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_mr_malformed_web_url_no_project.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_mr_not_authenticated.snap b/tests/snapshots/integration__integration_tests__switch__switch_mr_not_authenticated.snap index 16783adfc..d9e791cb1 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_mr_not_authenticated.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_mr_not_authenticated.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_mr_not_found.snap b/tests/snapshots/integration__integration_tests__switch__switch_mr_not_found.snap index 4b39e3802..35ae685b7 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_mr_not_found.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_mr_not_found.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_mr_same_repo.snap b/tests/snapshots/integration__integration_tests__switch__switch_mr_same_repo.snap index 3f3673db2..a363912fa 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_mr_same_repo.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_mr_same_repo.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_mr_same_repo_limited_refspec.snap b/tests/snapshots/integration__integration_tests__switch__switch_mr_same_repo_limited_refspec.snap index 3f3673db2..a363912fa 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_mr_same_repo_limited_refspec.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_mr_same_repo_limited_refspec.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_mr_same_repo_no_remote.snap b/tests/snapshots/integration__integration_tests__switch__switch_mr_same_repo_no_remote.snap index 4175e2921..fd172ca4f 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_mr_same_repo_no_remote.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_mr_same_repo_no_remote.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_mr_unknown_error.snap b/tests/snapshots/integration__integration_tests__switch__switch_mr_unknown_error.snap index d27d5128a..1314ed2df 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_mr_unknown_error.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_mr_unknown_error.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_outside_git_repo.snap b/tests/snapshots/integration__integration_tests__switch__switch_outside_git_repo.snap index 843053cf1..fbd3b1d96 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_outside_git_repo.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_outside_git_repo.snap @@ -32,5 +32,4 @@ exit_code: 1 ----- stdout ----- ----- stderr ----- -✗ Failed to switch worktree -  fatal: not a git repository (or any of the parent directories): .git +✗ Not in a repository diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_create_conflict.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_create_conflict.snap index 6caef8c3f..65bcbdf85 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_create_conflict.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_create_conflict.snap @@ -30,6 +30,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_deleted_fork.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_deleted_fork.snap index 1c1c6198a..c03491f39 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_deleted_fork.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_deleted_fork.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_empty_branch.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_empty_branch.snap index b52156686..d42090548 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_empty_branch.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_empty_branch.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_fork.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_fork.snap index 3600a11a5..50af8f0f7 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_fork.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_fork.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_existing_different_pr.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_existing_different_pr.snap index b2fe2d605..3fa1a5807 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_existing_different_pr.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_existing_different_pr.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_existing_no_tracking.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_existing_no_tracking.snap index b2fe2d605..3fa1a5807 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_existing_no_tracking.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_existing_no_tracking.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_existing_same_pr.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_existing_same_pr.snap index 4f9e03344..b004fe78d 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_existing_same_pr.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_existing_same_pr.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_no_upstream.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_no_upstream.snap index ee2f79dd7..a8e77f95e 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_no_upstream.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_no_upstream.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_prefixed_exists_different_pr.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_prefixed_exists_different_pr.snap index 55aa67d05..db0a3636f 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_prefixed_exists_different_pr.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_prefixed_exists_different_pr.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_prefixed_exists_same_pr.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_prefixed_exists_same_pr.snap index ae6c77b0a..f1f19bfed 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_prefixed_exists_same_pr.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_fork_prefixed_exists_same_pr.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_invalid_json.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_invalid_json.snap index 2a0111984..4eee2daf2 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_invalid_json.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_invalid_json.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_network_error.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_network_error.snap index 62202205b..95a89c490 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_network_error.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_network_error.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_not_authenticated.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_not_authenticated.snap index 158b79cf9..77a6c630d 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_not_authenticated.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_not_authenticated.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_not_found.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_not_found.snap index 7c24bf70e..7dfe70c4d 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_not_found.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_not_found.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_rate_limit.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_rate_limit.snap index 07882eb28..e6a2831ff 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_rate_limit.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_rate_limit.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_same_repo.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_same_repo.snap index 6c28037c6..28267f515 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_same_repo.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_same_repo.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_same_repo_limited_refspec.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_same_repo_limited_refspec.snap index 6c28037c6..28267f515 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_same_repo_limited_refspec.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_same_repo_limited_refspec.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_same_repo_no_remote.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_same_repo_no_remote.snap index b74cca178..0b88dd63f 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_same_repo_no_remote.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_same_repo_no_remote.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_unknown_error.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_unknown_error.snap index 34642bdd4..6923a383a 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_unknown_error.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_unknown_error.snap @@ -29,6 +29,7 @@ info: WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]"