├── CLAUDE.md ├── .gitignore ├── tests ├── snapshots │ ├── integration__list_empty.snap │ ├── integration__create_on_wrong_branch.snap │ ├── integration__add_existing_worktree.snap │ ├── integration__clean_invalid_worktrees.snap │ ├── integration__create_with_name.snap │ ├── integration__create_with_slash_in_branch_name.snap │ ├── integration__create_without_submodules.snap │ ├── integration__checkout_branch_creates_worktree.snap │ ├── integration__create_with_submodules.snap │ ├── integration__checkout_pull_request_creates_worktree.snap │ ├── integration__list_with_worktrees.snap │ ├── integration__add_existing_worktree-2.snap │ ├── integration__checkout_pull_request_creates_worktree-2.snap │ ├── integration__create_with_name-2.snap │ ├── integration__delete_clean_worktree.snap │ ├── integration__checkout_existing_worktree_prompts_open.snap │ └── integration__checkout_branch_creates_worktree-2.snap └── pipe_input.rs ├── src ├── commands │ ├── dashboard.rs │ ├── mod.rs │ ├── config.rs │ ├── rename.rs │ ├── dir.rs │ ├── complete.rs │ ├── clean.rs │ ├── add.rs │ ├── open.rs │ ├── list.rs │ ├── checkout.rs │ ├── create.rs │ └── delete.rs ├── main.rs ├── state.rs ├── input.rs ├── claude.rs ├── completions.rs ├── utils.rs ├── git.rs ├── codex.rs └── dashboard.rs ├── CHANGELOG.md ├── Cargo.toml ├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── AGENTS.md └── README.md /CLAUDE.md: -------------------------------------------------------------------------------- 1 | AGENTS.md -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /tests/snapshots/integration__list_empty.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/integration.rs 3 | expression: "String::from_utf8_lossy(&output.get_output().stdout)" 4 | --- 5 | 📭 No active worktrees 6 | -------------------------------------------------------------------------------- /src/commands/dashboard.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | use crate::dashboard; 4 | 5 | pub fn handle_dashboard(addr: Option, no_browser: bool) -> Result<()> { 6 | dashboard::run_dashboard(addr, !no_browser) 7 | } 8 | -------------------------------------------------------------------------------- /tests/snapshots/integration__create_on_wrong_branch.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/integration.rs 3 | expression: redacted 4 | --- 5 | Error: Must be on a base branch (main, master, or develop) to create a new worktree. Current branch: feature-branch 6 | -------------------------------------------------------------------------------- /tests/snapshots/integration__add_existing_worktree.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/integration.rs 3 | expression: redacted 4 | --- 5 | ➕ Adding worktree 'manual' to xlaude management... 6 | ✅ Worktree 'manual' added successfully 7 | Path: /tmp/TEST_DIR/test-repo-manual 8 | -------------------------------------------------------------------------------- /tests/snapshots/integration__clean_invalid_worktrees.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/integration.rs 3 | expression: redacted 4 | --- 5 | 🔍 Checking for invalid worktrees... 6 | ❌ Found invalid worktree: test-repo/invalid (/non/existent/path) 7 | ✅ Removed 1 invalid worktree 8 | -------------------------------------------------------------------------------- /tests/snapshots/integration__create_with_name.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/integration.rs 3 | expression: redacted 4 | --- 5 | ✨ Creating worktree 'feature-x' with new branch 'feature-x'... 6 | ✅ Worktree created at: /tmp/TEST_DIR/test-repo-feature-x 7 | 💡 To open it, run: xlaude open feature-x 8 | -------------------------------------------------------------------------------- /tests/snapshots/integration__create_with_slash_in_branch_name.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/integration.rs 3 | expression: redacted 4 | --- 5 | ✨ Creating worktree 'fix-bug' with new branch 'fix/bug'... 6 | ✅ Worktree created at: /tmp/TEST_DIR/test-repo-fix-bug 7 | 💡 To open it, run: xlaude open fix-bug 8 | -------------------------------------------------------------------------------- /tests/snapshots/integration__create_without_submodules.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/integration.rs 3 | expression: redacted 4 | --- 5 | ✨ Creating worktree 'no-submodule' with new branch 'no-submodule'... 6 | ✅ Worktree created at: /tmp/TEST_DIR/test-repo-no-submodule 7 | 💡 To open it, run: xlaude open no-submodule 8 | -------------------------------------------------------------------------------- /tests/snapshots/integration__checkout_branch_creates_worktree.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/integration.rs 3 | expression: redacted 4 | --- 5 | ✨ Checking out branch 'feature-checkout' into worktree 'feature-checkout'... 6 | ✅ Worktree created at: /tmp/TEST_DIR/test-repo-feature-checkout 7 | 💡 To open it later, run: xlaude open feature-checkout 8 | -------------------------------------------------------------------------------- /tests/snapshots/integration__create_with_submodules.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/integration.rs 3 | expression: redacted 4 | --- 5 | ✨ Creating worktree 'with-submodule' with new branch 'with-submodule'... 6 | 📦 Updated submodules 7 | ✅ Worktree created at: /tmp/TEST_DIR/test-repo-with-submodule 8 | 💡 To open it, run: xlaude open with-submodule 9 | -------------------------------------------------------------------------------- /tests/snapshots/integration__checkout_pull_request_creates_worktree.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/integration.rs 3 | expression: redacted 4 | --- 5 | 🌐 Fetching pull request #123 from origin... 6 | ✨ Checking out pull request #123 into worktree 'pr-123'... 7 | ✅ Worktree created at: /tmp/TEST_DIR/remote-pr-123 8 | 💡 To open it later, run: xlaude open pr-123 9 | -------------------------------------------------------------------------------- /tests/snapshots/integration__list_with_worktrees.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/integration.rs 3 | expression: redacted_stdout 4 | --- 5 | 📋 Active worktrees: 6 | 7 | 📦 test-repo 8 | • feature-a 9 | Path: /tmp/TEST_DIR/test-repo-feature-a 10 | Created: [TIMESTAMP] 11 | • feature-b 12 | Path: /tmp/TEST_DIR/test-repo-feature-b 13 | Created: [TIMESTAMP] 14 | -------------------------------------------------------------------------------- /tests/snapshots/integration__add_existing_worktree-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/integration.rs 3 | expression: state 4 | --- 5 | { 6 | "worktrees": { 7 | "test-repo/manual": { 8 | "branch": "manual-branch", 9 | "created_at": "[TIMESTAMP]", 10 | "name": "manual", 11 | "path": "/tmp/TEST_DIR/test-repo-manual", 12 | "repo_name": "test-repo" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/snapshots/integration__checkout_pull_request_creates_worktree-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/integration.rs 3 | expression: state 4 | --- 5 | { 6 | "worktrees": { 7 | "remote/pr-123": { 8 | "branch": "pr/123", 9 | "created_at": "[TIMESTAMP]", 10 | "name": "pr-123", 11 | "path": "/tmp/TEST_DIR/remote-pr-123", 12 | "repo_name": "remote" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/snapshots/integration__create_with_name-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/integration.rs 3 | expression: state 4 | --- 5 | { 6 | "worktrees": { 7 | "test-repo/feature-x": { 8 | "branch": "feature-x", 9 | "created_at": "[TIMESTAMP]", 10 | "name": "feature-x", 11 | "path": "/tmp/TEST_DIR/test-repo-feature-x", 12 | "repo_name": "test-repo" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/snapshots/integration__delete_clean_worktree.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/integration.rs 3 | expression: redacted 4 | --- 5 | 🔍 Checking worktree 'to-delete'... 6 | 🔍 Checking branch 'to-delete'... 7 | ⚠️ Branch 'to-delete' is not fully merged 8 | ℹ️ No merged PR found for this branch 9 | 🗑️ Removing worktree... 10 | 🗑️ Deleting branch 'to-delete'... 11 | ✅ Branch deleted 12 | ✅ Worktree 'to-delete' deleted successfully 13 | -------------------------------------------------------------------------------- /tests/snapshots/integration__checkout_existing_worktree_prompts_open.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/integration.rs 3 | expression: combined 4 | --- 5 | STDOUT: 6 | ⚠️ Worktree for branch 'checkout-existing' already exists at /tmp/TEST_DIR/test-repo-checkout-existing 7 | 💡 To open it manually, run: xlaude open checkout-existing 8 | 9 | --- 10 | STDERR: 11 | Error: Worktree 'checkout-existing' already exists for branch 'checkout-existing' 12 | -------------------------------------------------------------------------------- /tests/snapshots/integration__checkout_branch_creates_worktree-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/integration.rs 3 | expression: state 4 | --- 5 | { 6 | "worktrees": { 7 | "test-repo/feature-checkout": { 8 | "branch": "feature-checkout", 9 | "created_at": "[TIMESTAMP]", 10 | "name": "feature-checkout", 11 | "path": "/tmp/TEST_DIR/test-repo-feature-checkout", 12 | "repo_name": "test-repo" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod add; 2 | pub mod checkout; 3 | pub mod clean; 4 | pub mod complete; 5 | pub mod config; 6 | pub mod create; 7 | pub mod dashboard; 8 | pub mod delete; 9 | pub mod dir; 10 | pub mod list; 11 | pub mod open; 12 | pub mod rename; 13 | 14 | pub use add::handle_add; 15 | pub use checkout::handle_checkout; 16 | pub use clean::handle_clean; 17 | pub use complete::handle_complete_worktrees; 18 | pub use config::handle_config; 19 | pub use create::handle_create; 20 | pub use dashboard::handle_dashboard; 21 | pub use delete::handle_delete; 22 | pub use dir::handle_dir; 23 | pub use list::handle_list; 24 | pub use open::handle_open; 25 | pub use rename::handle_rename; 26 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.6.0] - 2025-10-27 4 | 5 | ### Added 6 | - Resume the latest Codex session automatically when launching the agent, making `xlaude open` pick up the previous conversation without manual steps. (#70) 7 | - Introduce `xlaude checkout` to create worktrees directly from existing branches or GitHub pull requests without duplicating branches. (#74) 8 | 9 | ### Fixed 10 | - Prevent duplicate worktree registration when adding existing instances. (#64) 11 | - Eliminate duplicate input characters in dashboard create mode. (#66) 12 | 13 | ### Documentation 14 | - Document how to configure and use Codex with xlaude. (#72) 15 | 16 | ### Maintenance 17 | - Bump the CLI version to 0.6.0 in preparation for release. 18 | - Refresh dependencies to the latest compatible releases, including `clap` 4.5.50, `clap_complete` 4.5.59, `serde` 1.0.228, `serde_json` 1.0.145, `dialoguer` 0.12.0, `directories` 6.0.0, `chrono` 0.4.42, `rand` 0.9.2, `bip39` 2.2.0, `anyhow` 1.0.100, `ratatui` 0.29.0, `crossterm` 0.29.0, `atty` 0.2.14, `insta` 1.43.2, `tempfile` 3.23.0, `assert_cmd` 2.0.17, and `regex` 1.12.2. 19 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "xlaude" 3 | version = "0.7.0" 4 | edition = "2024" 5 | description = "A CLI tool for managing Claude instances with git worktree" 6 | license = "Apache-2.0" 7 | 8 | [dependencies] 9 | clap = { version = "4.5.50", features = ["derive", "env"] } 10 | clap_complete = "4.5.59" 11 | serde = { version = "1.0.228", features = ["derive"] } 12 | serde_json = "1.0.145" 13 | colored = "3.0.0" 14 | dialoguer = "0.12.0" 15 | directories = "6.0.0" 16 | chrono = { version = "0.4.42", features = ["serde"] } 17 | rand = "0.9.2" 18 | bip39 = "2.2.0" 19 | anyhow = "1.0.100" 20 | atty = "0.2.14" 21 | shell-words = "1.1.0" 22 | axum = { version = "0.7.9", features = ["macros", "json", "ws"] } 23 | tokio = { version = "1.41.0", features = ["macros", "rt-multi-thread", "signal"] } 24 | webbrowser = "0.8.12" 25 | once_cell = "1.19.0" 26 | uuid = { version = "1.8.0", features = ["v4", "fast-rng"] } 27 | portable-pty = "0.8.1" 28 | futures-util = "0.3.31" 29 | 30 | [dev-dependencies] 31 | insta = { version = "1.43.2", features = ["json", "redactions"] } 32 | tempfile = "3.23.0" 33 | assert_cmd = "2.0.17" 34 | predicates = "3.1.3" 35 | regex = "1.12.2" 36 | temp-env = "0.3.6" 37 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | check: 14 | name: Check 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: dtolnay/rust-toolchain@stable 19 | - uses: Swatinem/rust-cache@v2 20 | - run: cargo check --all-features 21 | 22 | test: 23 | name: Test Suite 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: dtolnay/rust-toolchain@stable 28 | - uses: Swatinem/rust-cache@v2 29 | - run: cargo test --all-features 30 | 31 | fmt: 32 | name: Rustfmt 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | - uses: dtolnay/rust-toolchain@stable 37 | with: 38 | components: rustfmt 39 | - run: cargo fmt --all -- --check 40 | 41 | clippy: 42 | name: Clippy 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v4 46 | - uses: dtolnay/rust-toolchain@stable 47 | with: 48 | components: clippy 49 | - uses: Swatinem/rust-cache@v2 50 | - run: cargo clippy --all-targets --all-features -- -D warnings -------------------------------------------------------------------------------- /src/commands/config.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::process::Command; 3 | 4 | use anyhow::{Context, Result, anyhow, bail}; 5 | 6 | pub fn handle_config() -> Result<()> { 7 | let editor = std::env::var("EDITOR") 8 | .context("EDITOR environment variable is not set; please export your preferred editor")?; 9 | 10 | let parts = shell_words::split(&editor) 11 | .map_err(|e| anyhow!("Failed to parse EDITOR command: {editor} ({e})"))?; 12 | 13 | if parts.is_empty() { 14 | bail!("EDITOR command is empty"); 15 | } 16 | 17 | let state_path = crate::state::get_state_path()?; 18 | if let Some(parent) = state_path.parent() { 19 | fs::create_dir_all(parent) 20 | .with_context(|| format!("Failed to create config directory: {}", parent.display()))?; 21 | } 22 | 23 | let mut cmd = Command::new(&parts[0]); 24 | if parts.len() > 1 { 25 | cmd.args(&parts[1..]); 26 | } 27 | cmd.arg(&state_path); 28 | 29 | let status = cmd 30 | .status() 31 | .with_context(|| format!("Failed to launch editor: {}", parts[0]))?; 32 | 33 | if !status.success() { 34 | bail!( 35 | "Editor exited with status: {}", 36 | status 37 | .code() 38 | .map(|code| code.to_string()) 39 | .unwrap_or_else(|| "terminated by signal".to_string()) 40 | ); 41 | } 42 | 43 | Ok(()) 44 | } 45 | -------------------------------------------------------------------------------- /src/commands/rename.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result, bail}; 2 | use colored::Colorize; 3 | 4 | use crate::git; 5 | use crate::state::XlaudeState; 6 | 7 | pub fn handle_rename(old_name: String, new_name: String) -> Result<()> { 8 | let repo = git::get_repo_name()?; 9 | let mut state = XlaudeState::load()?; 10 | 11 | let old_key = XlaudeState::make_key(&repo, &old_name); 12 | let new_key = XlaudeState::make_key(&repo, &new_name); 13 | 14 | if !state.worktrees.contains_key(&old_key) { 15 | bail!("Worktree '{}' not found in repository '{}'", old_name, repo); 16 | } 17 | 18 | if state.worktrees.contains_key(&new_key) { 19 | bail!( 20 | "Worktree '{}' already exists in repository '{}'", 21 | new_name, 22 | repo 23 | ); 24 | } 25 | 26 | let mut worktree_data = state 27 | .worktrees 28 | .remove(&old_key) 29 | .context("Failed to get worktree data")?; 30 | 31 | // Update the name field in the worktree info 32 | worktree_data.name = new_name.clone(); 33 | 34 | state.worktrees.insert(new_key, worktree_data); 35 | state.save()?; 36 | 37 | println!( 38 | "{} {} {} {} {} {}", 39 | "✓".green(), 40 | "Renamed worktree".green(), 41 | old_name.cyan(), 42 | "to".green(), 43 | new_name.cyan(), 44 | format!("in repository '{repo}'").dimmed() 45 | ); 46 | 47 | Ok(()) 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to crates.io 2 | 3 | on: 4 | push: 5 | tags: ['v*'] # Triggers when pushing tags starting with 'v' 6 | 7 | permissions: 8 | id-token: write # Required for OIDC token exchange 9 | contents: read 10 | 11 | jobs: 12 | publish: 13 | name: Publish to crates.io 14 | runs-on: ubuntu-latest 15 | environment: crates.io # Using pre-configured environment 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: dtolnay/rust-toolchain@stable 20 | 21 | - uses: Swatinem/rust-cache@v2 22 | 23 | # Run tests one more time before publishing 24 | - name: Run tests 25 | run: cargo test --all-features 26 | 27 | # Verify package metadata 28 | - name: Check package 29 | run: cargo package --list 30 | 31 | # Authenticate with crates.io using trusted publishing 32 | - uses: rust-lang/crates-io-auth-action@v1 33 | id: auth 34 | 35 | # Publish to crates.io 36 | - name: Publish to crates.io 37 | run: cargo publish 38 | env: 39 | CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} 40 | 41 | # Setup instructions for trusted publishing: 42 | # 1. Go to https://crates.io/me 43 | # 2. Navigate to your crate's settings 44 | # 3. Add GitHub Actions as a trusted publisher: 45 | # - Repository: xuanwo/xlaude 46 | # - Workflow: publish.yml 47 | # - Environment: crates.io -------------------------------------------------------------------------------- /src/commands/dir.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | 3 | use crate::input::{get_command_arg, smart_select}; 4 | use crate::state::{WorktreeInfo, XlaudeState}; 5 | 6 | pub fn handle_dir(name: Option) -> Result<()> { 7 | let state = XlaudeState::load()?; 8 | 9 | if state.worktrees.is_empty() { 10 | anyhow::bail!("No worktrees found. Create one first with 'xlaude create'"); 11 | } 12 | 13 | // Get name from CLI args or pipe 14 | let target_name = get_command_arg(name)?; 15 | 16 | // Determine which worktree to get path for 17 | let (_key, worktree_info) = if let Some(n) = target_name { 18 | // Find worktree by name across all projects 19 | state 20 | .worktrees 21 | .iter() 22 | .find(|(_, w)| w.name == n) 23 | .map(|(k, w)| (k.clone(), w.clone())) 24 | .context(format!("Worktree '{n}' not found"))? 25 | } else { 26 | // Interactive selection - show repo/name format 27 | let worktree_list: Vec<(String, WorktreeInfo)> = state 28 | .worktrees 29 | .iter() 30 | .map(|(k, v)| (k.clone(), v.clone())) 31 | .collect(); 32 | 33 | let selection = smart_select("Select a worktree", &worktree_list, |(_, info)| { 34 | format!("{}/{}", info.repo_name, info.name) 35 | })?; 36 | 37 | match selection { 38 | Some(idx) => worktree_list[idx].clone(), 39 | None => anyhow::bail!( 40 | "Interactive selection not available in non-interactive mode. Please specify a worktree name." 41 | ), 42 | } 43 | }; 44 | 45 | // Output only the path - no decorations, no colors 46 | // This makes it easy to use in shell commands: cd $(xlaude dir name) 47 | println!("{}", worktree_info.path.display()); 48 | 49 | Ok(()) 50 | } 51 | -------------------------------------------------------------------------------- /src/commands/complete.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::path::Path; 3 | 4 | use crate::claude::get_claude_sessions; 5 | use crate::state::{WorktreeInfo, XlaudeState}; 6 | 7 | pub fn handle_complete_worktrees(format: &str) -> Result<()> { 8 | // Silently load state, return empty on any error 9 | let state = match XlaudeState::load() { 10 | Ok(s) => s, 11 | Err(_) => return Ok(()), // Silent failure for completions 12 | }; 13 | 14 | if state.worktrees.is_empty() { 15 | return Ok(()); 16 | } 17 | 18 | // Collect all worktrees and sort them 19 | // Primary sort: by repository name 20 | // Secondary sort: by worktree name within same repository 21 | let mut all_worktrees: Vec<&WorktreeInfo> = state.worktrees.values().collect(); 22 | all_worktrees.sort_by(|a, b| match a.repo_name.cmp(&b.repo_name) { 23 | std::cmp::Ordering::Equal => a.name.cmp(&b.name), 24 | other => other, 25 | }); 26 | 27 | match format { 28 | "simple" => { 29 | // Simple format: just worktree names, one per line, sorted 30 | for info in &all_worktrees { 31 | println!("{}", info.name); 32 | } 33 | } 34 | "detailed" => { 35 | // Detailed format: namerepopathsessions 36 | // Used by shell completions for rich descriptions 37 | for info in &all_worktrees { 38 | let session_count = count_sessions_safe(&info.path); 39 | let session_text = match session_count { 40 | 0 => "no sessions".to_string(), 41 | 1 => "1 session".to_string(), 42 | n => format!("{} sessions", n), 43 | }; 44 | 45 | // Use tab separator for easy parsing 46 | println!( 47 | "{}\t{}\t{}\t{}", 48 | info.name, 49 | info.repo_name, 50 | info.path.display(), 51 | session_text 52 | ); 53 | } 54 | } 55 | _ => { 56 | // Unknown format, fall back to simple 57 | for info in &all_worktrees { 58 | println!("{}", info.name); 59 | } 60 | } 61 | } 62 | 63 | Ok(()) 64 | } 65 | 66 | // Safe wrapper for counting sessions that won't fail 67 | fn count_sessions_safe(worktree_path: &Path) -> usize { 68 | get_claude_sessions(worktree_path).len() 69 | } 70 | -------------------------------------------------------------------------------- /src/commands/clean.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use colored::Colorize; 3 | use std::collections::HashSet; 4 | use std::path::PathBuf; 5 | 6 | use crate::git::list_worktrees; 7 | use crate::state::XlaudeState; 8 | use crate::utils::execute_in_dir; 9 | 10 | pub fn handle_clean() -> Result<()> { 11 | let mut state = XlaudeState::load()?; 12 | 13 | if state.worktrees.is_empty() { 14 | println!("{} No worktrees in state", "✨".green()); 15 | return Ok(()); 16 | } 17 | 18 | println!("{} Checking for invalid worktrees...", "🔍".cyan()); 19 | 20 | // Collect all actual worktrees from all repositories 21 | let actual_worktrees = collect_all_worktrees(&state)?; 22 | 23 | // Find and remove invalid worktrees 24 | let mut removed_count = 0; 25 | let worktrees_to_remove: Vec<_> = state 26 | .worktrees 27 | .iter() 28 | .filter_map(|(name, info)| { 29 | if !actual_worktrees.contains(&info.path) { 30 | println!( 31 | " {} Found invalid worktree: {} ({})", 32 | "❌".red(), 33 | name.yellow(), 34 | info.path.display() 35 | ); 36 | removed_count += 1; 37 | Some(name.clone()) 38 | } else { 39 | None 40 | } 41 | }) 42 | .collect(); 43 | 44 | // Remove invalid worktrees from state 45 | for name in worktrees_to_remove { 46 | state.worktrees.remove(&name); 47 | } 48 | 49 | if removed_count > 0 { 50 | state.save()?; 51 | println!( 52 | "{} Removed {} invalid worktree{}", 53 | "✅".green(), 54 | removed_count, 55 | if removed_count == 1 { "" } else { "s" } 56 | ); 57 | } else { 58 | println!("{} All worktrees are valid", "✨".green()); 59 | } 60 | 61 | Ok(()) 62 | } 63 | 64 | fn collect_all_worktrees(state: &XlaudeState) -> Result> { 65 | let mut all_worktrees = HashSet::new(); 66 | 67 | // Get unique repository paths 68 | let repo_paths: HashSet<_> = state 69 | .worktrees 70 | .values() 71 | .filter_map(|info| info.path.parent().map(|p| p.join(&info.repo_name))) 72 | .collect(); 73 | 74 | // Collect worktrees from each repository 75 | for repo_path in repo_paths { 76 | if repo_path.exists() 77 | && let Ok(worktrees) = execute_in_dir(&repo_path, list_worktrees) 78 | { 79 | all_worktrees.extend(worktrees); 80 | } 81 | } 82 | 83 | Ok(all_worktrees) 84 | } 85 | -------------------------------------------------------------------------------- /src/commands/add.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use chrono::Utc; 3 | use colored::Colorize; 4 | use std::fs; 5 | 6 | use crate::git::{get_current_branch, get_repo_name, is_in_worktree}; 7 | use crate::state::{WorktreeInfo, XlaudeState}; 8 | use crate::utils::sanitize_branch_name; 9 | 10 | pub fn handle_add(name: Option) -> Result<()> { 11 | // Check if we're in a git repository 12 | let repo_name = get_repo_name().context("Not in a git repository")?; 13 | 14 | // Check if we're in a worktree 15 | if !is_in_worktree()? { 16 | anyhow::bail!("Current directory is not a git worktree"); 17 | } 18 | 19 | // Get current branch name 20 | let current_branch = get_current_branch()?; 21 | 22 | // Use provided name or default to sanitized branch name 23 | let worktree_name = match name { 24 | Some(n) => n, 25 | None => sanitize_branch_name(¤t_branch), 26 | }; 27 | 28 | // Get current directory 29 | let current_dir = std::env::current_dir()?; 30 | 31 | // Load state 32 | let mut state = XlaudeState::load()?; 33 | 34 | let normalize_path = |path: &std::path::Path| -> std::path::PathBuf { 35 | fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) 36 | }; 37 | let current_dir_key = normalize_path(¤t_dir); 38 | 39 | // Check if this path is already managed under another worktree 40 | if let Some(existing) = state 41 | .worktrees 42 | .values() 43 | .find(|info| normalize_path(&info.path) == current_dir_key) 44 | { 45 | anyhow::bail!( 46 | "Current directory '{}' is already managed by xlaude as '{}/{}'", 47 | current_dir.display(), 48 | existing.repo_name, 49 | existing.name 50 | ); 51 | } 52 | 53 | // Check if already managed under the same name 54 | let key = XlaudeState::make_key(&repo_name, &worktree_name); 55 | if state.worktrees.contains_key(&key) { 56 | anyhow::bail!( 57 | "Worktree '{}/{}' is already managed by xlaude", 58 | repo_name, 59 | worktree_name 60 | ); 61 | } 62 | 63 | println!( 64 | "{} Adding worktree '{}' to xlaude management...", 65 | "➕".green(), 66 | worktree_name.cyan() 67 | ); 68 | 69 | // Add to state 70 | state.worktrees.insert( 71 | key, 72 | WorktreeInfo { 73 | name: worktree_name.clone(), 74 | branch: current_branch, 75 | path: current_dir.clone(), 76 | repo_name, 77 | created_at: Utc::now(), 78 | }, 79 | ); 80 | state.save()?; 81 | 82 | println!( 83 | "{} Worktree '{}' added successfully", 84 | "✅".green(), 85 | worktree_name.cyan() 86 | ); 87 | println!(" {} {}", "Path:".bright_black(), current_dir.display()); 88 | 89 | Ok(()) 90 | } 91 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::{Parser, Subcommand}; 3 | use clap_complete::Shell; 4 | 5 | mod claude; 6 | mod codex; 7 | mod commands; 8 | mod completions; 9 | mod dashboard; 10 | mod git; 11 | mod input; 12 | mod state; 13 | mod utils; 14 | 15 | use commands::{ 16 | handle_add, handle_checkout, handle_clean, handle_config, handle_create, handle_dashboard, 17 | handle_delete, handle_dir, handle_list, handle_open, handle_rename, 18 | }; 19 | 20 | #[derive(Parser)] 21 | #[command(name = "xlaude")] 22 | #[command(about = "Manage Claude instances with git worktrees", long_about = None)] 23 | struct Cli { 24 | #[command(subcommand)] 25 | command: Commands, 26 | } 27 | 28 | #[derive(Subcommand)] 29 | enum Commands { 30 | /// Create a new git worktree 31 | Create { 32 | /// Name for the worktree (random BIP39 word if not provided) 33 | name: Option, 34 | }, 35 | /// Checkout a branch or pull request into a worktree 36 | Checkout { 37 | /// Branch name or pull request number 38 | target: Option, 39 | }, 40 | /// Open an existing worktree and launch Claude 41 | Open { 42 | /// Name of the worktree to open (interactive selection if not provided) 43 | name: Option, 44 | }, 45 | /// Delete a worktree and clean up 46 | Delete { 47 | /// Name of the worktree to delete (current if not provided) 48 | name: Option, 49 | }, 50 | /// Add current worktree to xlaude management 51 | Add { 52 | /// Name for the worktree (defaults to current branch name) 53 | name: Option, 54 | }, 55 | /// Rename a worktree 56 | Rename { 57 | /// Current name of the worktree 58 | old_name: String, 59 | /// New name for the worktree 60 | new_name: String, 61 | }, 62 | /// List all active Claude instances 63 | List { 64 | /// Output as JSON 65 | #[arg(long)] 66 | json: bool, 67 | }, 68 | /// Clean up invalid worktrees from state 69 | Clean, 70 | /// Get the directory path of a worktree 71 | Dir { 72 | /// Name of the worktree (interactive selection if not provided) 73 | name: Option, 74 | }, 75 | /// Generate shell completions 76 | Completions { 77 | /// Shell to generate completions for 78 | #[arg(value_enum)] 79 | shell: Shell, 80 | }, 81 | /// Output worktree info for shell completions (hidden) 82 | #[command(hide = true)] 83 | CompleteWorktrees { 84 | /// Output format: simple or detailed 85 | #[arg(long, default_value = "simple")] 86 | format: String, 87 | }, 88 | /// Open the xlaude state file in $EDITOR 89 | Config, 90 | /// Launch the embedded dashboard 91 | Dashboard { 92 | /// Bind address (default 127.0.0.1:5710) 93 | #[arg(long)] 94 | addr: Option, 95 | /// Do not open the browser automatically 96 | #[arg(long)] 97 | no_browser: bool, 98 | }, 99 | } 100 | 101 | fn main() -> Result<()> { 102 | let cli = Cli::parse(); 103 | 104 | match cli.command { 105 | Commands::Create { name } => handle_create(name), 106 | Commands::Checkout { target } => handle_checkout(target), 107 | Commands::Open { name } => handle_open(name), 108 | Commands::Delete { name } => handle_delete(name), 109 | Commands::Add { name } => handle_add(name), 110 | Commands::Rename { old_name, new_name } => handle_rename(old_name, new_name), 111 | Commands::List { json } => handle_list(json), 112 | Commands::Clean => handle_clean(), 113 | Commands::Dir { name } => handle_dir(name), 114 | Commands::Completions { shell } => completions::handle_completions(shell), 115 | Commands::CompleteWorktrees { format } => commands::handle_complete_worktrees(&format), 116 | Commands::Config => handle_config(), 117 | Commands::Dashboard { addr, no_browser } => handle_dashboard(addr, no_browser), 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # xlaude · Agent Handbook 2 | 3 | ## 0 · 关于用户 4 | - 用户:漩涡(Xuanwo) 5 | - 偏好:高频上下文切换、以 git worktree 管理多条分支,并依赖 AI Agent 协助编码。 6 | 7 | ## 1 · 编程哲学 8 | 1. 程序首先服务于人类阅读,机器可执行只是附带价值。 9 | 2. 遵循各语言的惯用风格,代码应自解释。 10 | 3. 识别并消除以下坏味道:僵化、冗余、循环依赖、脆弱性、晦涩性、数据泥团、不必要的复杂性。 11 | 4. 一旦发现坏味道,立即提醒并提出改进方案。 12 | 13 | ## 2 · 语言策略 14 | 15 | | 内容类型 | 语言 | 16 | | --- | --- | 17 | | 解释、讨论、沟通 | **简体中文** | 18 | | 代码、注释、变量名、提交信息、文档示例 | **English**(技术内容中禁止出现中文字符) | 19 | 20 | ## 3 · 编码标准 21 | - 仅在行为不明显时添加英文注释。 22 | - 默认无需新增测试;仅在必要或显式要求时补充。 23 | - 代码结构需保持可演进性,杜绝复制粘贴式实现。 24 | 25 | ## 4 · CLI 功能速览 26 | 27 | | 命令 | 职责与要点 | 28 | | --- | --- | 29 | | `create [name]` | 仅允许在 base 分支(main/master/develop 或远端默认分支)执行;缺省名称从 BIP39 词库随机生成;自动更新 submodules 并拷贝 `CLAUDE.local.md`;可通过 `XLAUDE_NO_AUTO_OPEN` 跳过“是否立即打开”提示。 | 30 | | `checkout ` | 支持分支名或 PR 号(`123`/`#123`);缺失分支会从 `origin` fetch;PR 自动 fetch `pull//head`→`pr/`;如已存在对应 worktree,会提示改为 `open`。 | 31 | | `open [name]` | 无参数时:若当前目录为非 base worktree,直接打开;未被管理的 worktree 会询问后自动加入 state;否则进入交互式选择或接受管道输入;启动全局 `agent` 命令并继承所有环境变量。 | 32 | | `add [name]` | 将当前 git worktree 写入 state,默认名称为分支名(斜杠会被 `-` 取代);拒绝重复路径。 | 33 | | `rename ` | 仅更新 state 中的工作树别名,不触碰实际目录或分支。 | 34 | | `list [--json]` | 按仓库分组展示路径/创建时间,并读取 Claude (`~/.claude/projects`) 与 Codex (`~/.codex/sessions` 或 `XLAUDE_CODEX_SESSIONS_DIR`) 会话,列出最近 3 条用户消息;`--json` 输出结构化字段,方便脚本消费。 | 35 | | `dir [name]` | 输出纯路径,便于 `cd $(xlaude dir foo)`;可交互选择或接收管道输入。 | 36 | | `delete [name]` | 自动检查未提交修改、未推送提交以及合并状态(通过 `git branch --merged` 与 `gh pr list` 双重检测),必要时多次确认;若目录已不存在则执行 `git worktree prune`;最后尝试安全删分支,不合并时再询问是否 `-D`。 | 37 | | `clean` | 遍历所有仓库,取 `git worktree list --porcelain` 与 state 比对,移除已被手动删除的 worktree。 | 38 | | `config` | 用 `$EDITOR` 打开 state 文件,便于手工修改 `agent` 等全局配置。 | 39 | | `completions ` | 输出 Bash/Zsh/Fish 补全脚本,内部调用隐藏命令 `complete-worktrees` 获取动态列表。 | 40 | | `complete-worktrees [--format=simple|detailed]` | 提供简单或包含 repo/path/session 摘要的工作树清单,供补全或自定义脚本调用。 | 41 | 42 | ## 5 · Agent 与会话管理 43 | - `state.json` 中的 `agent` 字段定义启动命令,默认 `claude --dangerously-skip-permissions`。命令按 Shell 规则分词,建议将复杂管道封装为脚本。 44 | - 当 `agent` 的可执行名为 `codex` 且未显式给出位置参数时,xlaude 会在 `~/.codex/sessions`(或 `XLAUDE_CODEX_SESSIONS_DIR`)寻找与当前 worktree 匹配的最新会话,并自动追加 `resume `。 45 | - `list` 会解析 Claude JSONL 与 Codex session 目录,展示最近的用户消息与“time ago”标签,帮助判断上下文是否值得恢复。 46 | 47 | ## 6 · 状态与数据 48 | - 状态位置: 49 | - macOS: `~/Library/Application Support/com.xuanwo.xlaude/state.json` 50 | - Linux: `~/.config/xlaude/state.json` 51 | - Windows: `%APPDATA%\xuanwo\xlaude\config\state.json` 52 | - 条目键:`/`;运行时若发现旧版本(无 `/`)会自动迁移并写回。 53 | - `XLAUDE_CONFIG_DIR` 可重定向整个配置目录,便于测试或隔离环境。 54 | - 创建/checkout 新 worktree 时若仓库根目录存在 `CLAUDE.local.md` 会自动复制;同时执行 `git submodule update --init --recursive` 保证依赖就位。 55 | 56 | ## 7 · 环境变量与自动化 57 | 58 | | 变量 | 作用 | 59 | | --- | --- | 60 | | `XLAUDE_YES=1` | 对所有确认对话框默认为“是”;多用于脚本化删除或批量操作。 | 61 | | `XLAUDE_NON_INTERACTIVE=1` | 禁用交互式选择,命令在无输入时直接失败或采用默认值。 | 62 | | `XLAUDE_NO_AUTO_OPEN=1` | `create` 成功后不再提示“是否立即 open”。 | 63 | | `XLAUDE_CONFIG_DIR=/path` | 覆盖 state/配置所在目录。 | 64 | | `XLAUDE_CODEX_SESSIONS_DIR=/path` | 指定 Codex 会话日志位置,便于自定义同步策略。 | 65 | | `XLAUDE_TEST_SEED=42` | 让随机工作树名在测试中可复现。 | 66 | | `XLAUDE_TEST_MODE=1` | CI/测试专用,关闭部分交互并禁止自动打开新 worktree。 | 67 | 68 | - 输入优先级:命令行参数 > 管道输入 > 交互式提示。例如 `echo wrong | xlaude open correct` 依然会打开 `correct`。 69 | - 管道输入既可传名称,也可给 `smart_confirm` 提供 `y/n`,因此 `yes | xlaude delete foo` 可实现无人值守清理。 70 | 71 | ## 8 · 工作流示例 72 | ```bash 73 | # 创建并立即开始一个功能分支 74 | xlaude create ingestion-batch 75 | xlaude open ingestion-batch 76 | 77 | # 直接检出 GitHub PR #128 并分配独立 worktree 78 | xlaude checkout 128 79 | xlaude open pr-128 80 | 81 | # 查看所有活跃上下文及最近对话 82 | xlaude list 83 | 84 | # 任务结束后清理 85 | xlaude delete ingestion-batch 86 | ``` 87 | 88 | ## 9 · 依赖提示 89 | - Git ≥ 2.36(需要成熟的 worktree 支持)。 90 | - Rust 工具链(用于构建或 `cargo install`)。 91 | - Claude CLI 或自定义 agent(如 Codex)。 92 | - `gh` CLI 可选,用于 `delete` 检测已经合并的 PR(无则自动降级,仅依赖 git)。 93 | 94 | ## 10 · 注意事项 95 | - `create`/`checkout` 会拒绝在非 base 分支上执行,避免分支森林难以维护。 96 | - `delete` 在当前目录即将被删除时,会先切换回主仓库以免 `worktree remove` 卡住;若目录已不在磁盘上,会提示是否仅从 state 清理。 97 | - `list --json` 暴露精准路径、分支、创建时间、Claude/Codex 会话,可直接被脚本或 UI 消费;注意敏感信息输出。 98 | - Shell 补全依赖隐藏命令 `complete-worktrees`,若需要自定义补全,记得使用 `--format=detailed` 以获得 repo/path/session 描述。 99 | - 代码风格遵循“先思考、再尝试”原则:遇到不确定性需先复述问题、列出方案,再实施。 100 | -------------------------------------------------------------------------------- /src/state.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use chrono::{DateTime, Utc}; 3 | use directories::ProjectDirs; 4 | use serde::{Deserialize, Serialize}; 5 | use std::collections::HashMap; 6 | use std::fs; 7 | use std::path::PathBuf; 8 | 9 | #[derive(Debug, Clone, Serialize, Deserialize)] 10 | pub struct WorktreeInfo { 11 | pub name: String, 12 | pub branch: String, 13 | pub path: PathBuf, 14 | pub repo_name: String, 15 | pub created_at: DateTime, 16 | } 17 | 18 | #[derive(Debug, Serialize, Deserialize, Default)] 19 | pub struct XlaudeState { 20 | // Key format: "{repo_name}/{worktree_name}" 21 | pub worktrees: HashMap, 22 | // Global agent command to launch sessions (full command line string) 23 | #[serde(skip_serializing_if = "Option::is_none")] 24 | pub agent: Option, 25 | // Preferred editor command (full command line string) 26 | #[serde(skip_serializing_if = "Option::is_none")] 27 | pub editor: Option, 28 | // Preferred interactive shell command 29 | #[serde(skip_serializing_if = "Option::is_none")] 30 | pub shell: Option, 31 | } 32 | 33 | impl XlaudeState { 34 | pub fn make_key(repo_name: &str, worktree_name: &str) -> String { 35 | format!("{repo_name}/{worktree_name}") 36 | } 37 | 38 | pub fn load() -> Result { 39 | let config_path = get_config_path()?; 40 | if config_path.exists() { 41 | let content = fs::read_to_string(&config_path).context("Failed to read config file")?; 42 | let mut state: Self = 43 | serde_json::from_str(&content).context("Failed to parse config file")?; 44 | 45 | // ============================================================================ 46 | // MIGRATION LOGIC: Upgrade from v0.2 to v0.3 format 47 | // TODO: Remove this migration code after v0.3 is stable and most users have upgraded 48 | // 49 | // In v0.2, keys were just the worktree name: "feature-x" 50 | // In v0.3, keys include the repo name: "repo-name/feature-x" 51 | // ============================================================================ 52 | let needs_migration = state.worktrees.keys().any(|k| !k.contains('/')); 53 | 54 | if needs_migration { 55 | eprintln!("🔄 Migrating xlaude state from v0.2 to v0.3 format..."); 56 | 57 | let mut migrated_worktrees = HashMap::new(); 58 | for (old_key, info) in state.worktrees { 59 | // Check if this entry needs migration (doesn't contain '/') 60 | let new_key = if old_key.contains('/') { 61 | // Already in new format, keep as-is 62 | old_key 63 | } else { 64 | // Old format, create new key 65 | Self::make_key(&info.repo_name, &info.name) 66 | }; 67 | migrated_worktrees.insert(new_key, info); 68 | } 69 | 70 | state.worktrees = migrated_worktrees; 71 | 72 | // Save the migrated state immediately 73 | state.save().context("Failed to save migrated state")?; 74 | eprintln!("✅ Migration completed successfully"); 75 | } 76 | // ============================================================================ 77 | // END OF MIGRATION LOGIC 78 | // ============================================================================ 79 | 80 | Ok(state) 81 | } else { 82 | Ok(Self::default()) 83 | } 84 | } 85 | 86 | pub fn save(&self) -> Result<()> { 87 | let config_path = get_config_path()?; 88 | if let Some(parent) = config_path.parent() { 89 | fs::create_dir_all(parent).context("Failed to create config directory")?; 90 | } 91 | let content = serde_json::to_string_pretty(self).context("Failed to serialize state")?; 92 | fs::write(&config_path, content).context("Failed to write config file")?; 93 | Ok(()) 94 | } 95 | } 96 | 97 | pub fn get_config_dir() -> Result { 98 | // Allow overriding config directory for testing 99 | if let Ok(config_dir) = std::env::var("XLAUDE_CONFIG_DIR") { 100 | return Ok(PathBuf::from(config_dir)); 101 | } 102 | 103 | let proj_dirs = ProjectDirs::from("com", "xuanwo", "xlaude") 104 | .context("Failed to determine config directory")?; 105 | Ok(proj_dirs.config_dir().to_path_buf()) 106 | } 107 | 108 | pub fn get_state_path() -> Result { 109 | get_config_path() 110 | } 111 | 112 | fn get_config_path() -> Result { 113 | Ok(get_config_dir()?.join("state.json")) 114 | } 115 | 116 | /// Resolve the agent command from state with a sensible default. 117 | /// Returns the full command line string (not split). 118 | pub fn get_default_agent() -> String { 119 | "claude --dangerously-skip-permissions".to_string() 120 | } 121 | -------------------------------------------------------------------------------- /src/input.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use atty::Stream; 3 | use dialoguer::{Confirm, Select}; 4 | use std::io::{self, BufRead, BufReader}; 5 | use std::sync::Mutex; 6 | 7 | /// Check if stdin is piped (not a terminal) 8 | pub fn is_piped_input() -> bool { 9 | !atty::is(Stream::Stdin) 10 | } 11 | 12 | /// Piped input reader that supports reading multiple lines 13 | pub struct PipedInputReader { 14 | reader: BufReader, 15 | buffer: Vec, 16 | } 17 | 18 | impl PipedInputReader { 19 | pub fn new() -> Self { 20 | Self { 21 | reader: BufReader::new(io::stdin()), 22 | buffer: Vec::new(), 23 | } 24 | } 25 | 26 | /// Read the next line of input 27 | pub fn read_line(&mut self) -> Result> { 28 | // Use buffered input first if available 29 | if !self.buffer.is_empty() { 30 | return Ok(Some(self.buffer.remove(0))); 31 | } 32 | 33 | let mut line = String::new(); 34 | match self.reader.read_line(&mut line)? { 35 | 0 => Ok(None), // EOF 36 | _ => Ok(Some(line.trim().to_string())), 37 | } 38 | } 39 | } 40 | 41 | /// Global piped input reader (singleton) 42 | static PIPED_INPUT: std::sync::LazyLock>> = 43 | std::sync::LazyLock::new(|| { 44 | if is_piped_input() { 45 | Mutex::new(Some(PipedInputReader::new())) 46 | } else { 47 | Mutex::new(None) 48 | } 49 | }); 50 | 51 | /// Read a single line from piped input 52 | pub fn read_piped_line() -> Result> { 53 | let mut reader = PIPED_INPUT.lock().unwrap(); 54 | match reader.as_mut() { 55 | Some(r) => r.read_line(), 56 | None => Ok(None), 57 | } 58 | } 59 | 60 | /// Smart confirmation that supports piped input (yes/no) 61 | pub fn smart_confirm(prompt: &str, default: bool) -> Result { 62 | // 1. Check for force-yes environment variable 63 | if std::env::var("XLAUDE_YES").is_ok() { 64 | return Ok(true); 65 | } 66 | 67 | // 2. Check for piped input 68 | if let Some(input) = read_piped_line()? { 69 | let input = input.to_lowercase(); 70 | return Ok(input == "y" || input == "yes"); 71 | } 72 | 73 | // 3. Non-interactive mode uses default value 74 | if std::env::var("XLAUDE_NON_INTERACTIVE").is_ok() { 75 | return Ok(default); 76 | } 77 | 78 | // 4. Interactive confirmation 79 | Confirm::new() 80 | .with_prompt(prompt) 81 | .default(default) 82 | .interact() 83 | .map_err(Into::into) 84 | } 85 | 86 | /// Smart selection that supports piped input 87 | pub fn smart_select( 88 | prompt: &str, 89 | items: &[T], 90 | display_fn: impl Fn(&T) -> String, 91 | ) -> Result> 92 | where 93 | T: Clone, 94 | { 95 | // 1. Check for piped input 96 | if let Some(input) = read_piped_line()? { 97 | // Try to parse as index 98 | if let Ok(index) = input.parse::() 99 | && index < items.len() 100 | { 101 | return Ok(Some(index)); 102 | } 103 | 104 | // Try to match display text 105 | for (i, item) in items.iter().enumerate() { 106 | if display_fn(item) == input { 107 | return Ok(Some(i)); 108 | } 109 | } 110 | 111 | anyhow::bail!("Invalid selection: {}", input); 112 | } 113 | 114 | // 2. Non-interactive mode returns None 115 | if std::env::var("XLAUDE_NON_INTERACTIVE").is_ok() { 116 | return Ok(None); 117 | } 118 | 119 | // 3. Interactive selection 120 | let display_items: Vec = items.iter().map(display_fn).collect(); 121 | let selection = Select::new() 122 | .with_prompt(prompt) 123 | .items(&display_items) 124 | .interact()?; 125 | 126 | Ok(Some(selection)) 127 | } 128 | 129 | /// Get command argument with pipe input support 130 | /// Priority: CLI argument > piped input > None 131 | pub fn get_command_arg(arg: Option) -> Result> { 132 | // 1. CLI argument takes priority 133 | if arg.is_some() { 134 | return Ok(arg); 135 | } 136 | 137 | // 2. Check piped input (skip yes/no confirmation words) 138 | // Only try to read once to avoid getting stuck with infinite streams like 'yes' 139 | if let Some(input) = read_piped_line()? { 140 | let lower = input.to_lowercase(); 141 | // Skip confirmation words that might be in the pipe 142 | // These are likely from tools like 'yes' and not meant as actual input 143 | if lower != "y" && lower != "yes" && lower != "n" && lower != "no" { 144 | return Ok(Some(input)); 145 | } 146 | // If it's a confirmation word, return None to let the command use defaults 147 | } 148 | 149 | Ok(None) 150 | } 151 | 152 | /// Drain any remaining piped input to prevent it from being passed to child processes 153 | /// Note: We don't actually drain because tools like 'yes' provide infinite input. 154 | /// Instead, we'll just ensure stdin is not inherited by child processes. 155 | pub fn drain_stdin() -> Result<()> { 156 | // We used to try to drain all input here, but that causes problems with 157 | // tools like 'yes' that provide infinite input. The actual solution is 158 | // to not inherit stdin in child processes (using Stdio::null()). 159 | Ok(()) 160 | } 161 | -------------------------------------------------------------------------------- /src/claude.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use std::fs; 3 | use std::io::{BufRead, BufReader}; 4 | use std::path::Path; 5 | 6 | #[derive(Debug)] 7 | pub struct SessionInfo { 8 | pub last_user_message: String, 9 | pub last_timestamp: Option>, 10 | } 11 | 12 | pub fn get_claude_sessions(project_path: &Path) -> Vec { 13 | // Get home directory 14 | let Ok(home) = std::env::var("HOME") else { 15 | return vec![]; 16 | }; 17 | 18 | // Construct path to Claude projects directory 19 | let claude_projects_dir = Path::new(&home).join(".claude").join("projects"); 20 | 21 | // Get canonical path of the project 22 | let Ok(canonical_path) = project_path.canonicalize() else { 23 | return vec![]; 24 | }; 25 | 26 | // Convert path to Claude's format (replace / with -) 27 | let encoded_path = canonical_path.to_string_lossy().replace('/', "-"); 28 | 29 | let project_dir = claude_projects_dir.join(&encoded_path); 30 | 31 | // List session files (.jsonl files) 32 | let mut sessions = vec![]; 33 | if let Ok(entries) = fs::read_dir(&project_dir) { 34 | for entry in entries.flatten() { 35 | if let Some(name) = entry.file_name().to_str() 36 | && std::path::Path::new(name) 37 | .extension() 38 | .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl")) 39 | { 40 | // Read session data from the file 41 | let mut last_user_message = String::new(); 42 | let mut last_timestamp = None; 43 | 44 | if let Ok(file) = fs::File::open(entry.path()) { 45 | let reader = BufReader::new(file); 46 | let mut user_messages = Vec::new(); 47 | 48 | for line in reader.lines().map_while(Result::ok) { 49 | if let Ok(json) = serde_json::from_str::(&line) 50 | && json.get("type").and_then(|t| t.as_str()) == Some("user") 51 | { 52 | // Extract timestamp 53 | if let Some(ts_str) = json.get("timestamp").and_then(|t| t.as_str()) 54 | && let Ok(ts) = DateTime::parse_from_rfc3339(ts_str) 55 | { 56 | last_timestamp = Some(ts.with_timezone(&Utc)); 57 | } 58 | 59 | // Extract message content 60 | if let Some(message) = json.get("message") { 61 | let content = 62 | message.get("content").and_then(|c| c.as_str()).map_or_else( 63 | || { 64 | message 65 | .get("content") 66 | .and_then(|c| c.as_array()) 67 | .map_or_else(String::new, |content_arr| { 68 | content_arr 69 | .iter() 70 | .filter_map(|item| { 71 | item.get("text") 72 | .and_then(|t| t.as_str()) 73 | }) 74 | .collect::>() 75 | .join(" ") 76 | }) 77 | }, 78 | std::string::ToString::to_string, 79 | ); 80 | 81 | // Filter out system messages and empty content 82 | if !content.is_empty() 83 | && !content.starts_with(" b_ts.cmp(a_ts), 114 | (Some(_), None) => std::cmp::Ordering::Less, 115 | (None, Some(_)) => std::cmp::Ordering::Greater, 116 | (None, None) => std::cmp::Ordering::Equal, 117 | }); 118 | sessions 119 | } 120 | -------------------------------------------------------------------------------- /src/commands/open.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use chrono::Utc; 3 | use colored::Colorize; 4 | use std::process::{Command, Stdio}; 5 | 6 | use crate::git::{get_current_branch, get_repo_name, is_base_branch, is_in_worktree}; 7 | use crate::input::{drain_stdin, get_command_arg, is_piped_input, smart_confirm, smart_select}; 8 | use crate::state::{WorktreeInfo, XlaudeState}; 9 | use crate::utils::{prepare_agent_command, sanitize_branch_name}; 10 | 11 | pub fn handle_open(name: Option) -> Result<()> { 12 | let mut state = XlaudeState::load()?; 13 | 14 | // Check if current path is a worktree when no name is provided 15 | // Note: base branches (main/master/develop) are not considered worktrees 16 | // Skip this check if we have piped input waiting to be read 17 | if name.is_none() && is_in_worktree()? && !is_base_branch()? { 18 | // If there's piped input waiting, don't use current worktree detection 19 | // This allows piped input to override current directory detection 20 | if is_piped_input() && std::env::var("XLAUDE_TEST_MODE").is_err() { 21 | // There's piped input, so skip current worktree detection 22 | } else { 23 | // Get current repository info 24 | let repo_name = get_repo_name().context("Not in a git repository")?; 25 | let current_branch = get_current_branch()?; 26 | let current_dir = std::env::current_dir()?; 27 | 28 | // Sanitize branch name for key lookup 29 | let worktree_name = sanitize_branch_name(¤t_branch); 30 | 31 | // Check if this worktree is already managed 32 | let key = XlaudeState::make_key(&repo_name, &worktree_name); 33 | 34 | if state.worktrees.contains_key(&key) { 35 | // Already managed, open directly 36 | println!( 37 | "{} Opening current worktree '{}/{}'...", 38 | "🚀".green(), 39 | repo_name, 40 | worktree_name.cyan() 41 | ); 42 | } else { 43 | // Not managed, ask if user wants to add it 44 | println!( 45 | "{} Current directory is a worktree but not managed by xlaude", 46 | "ℹ️".blue() 47 | ); 48 | println!( 49 | " {} {}/{}", 50 | "Worktree:".bright_black(), 51 | repo_name, 52 | current_branch 53 | ); 54 | println!(" {} {}", "Path:".bright_black(), current_dir.display()); 55 | 56 | // Use smart confirm for pipe support 57 | let should_add = smart_confirm( 58 | "Would you like to add this worktree to xlaude and open it?", 59 | true, 60 | )?; 61 | 62 | if !should_add { 63 | return Ok(()); 64 | } 65 | 66 | // Add to state 67 | println!( 68 | "{} Adding worktree '{}' to xlaude management...", 69 | "➕".green(), 70 | worktree_name.cyan() 71 | ); 72 | 73 | state.worktrees.insert( 74 | key.clone(), 75 | WorktreeInfo { 76 | name: worktree_name.clone(), 77 | branch: current_branch.clone(), 78 | path: current_dir.clone(), 79 | repo_name: repo_name.clone(), 80 | created_at: Utc::now(), 81 | }, 82 | ); 83 | state.save()?; 84 | 85 | println!("{} Worktree added successfully", "✅".green()); 86 | println!( 87 | "{} Opening worktree '{}/{}'...", 88 | "🚀".green(), 89 | repo_name, 90 | worktree_name.cyan() 91 | ); 92 | } 93 | 94 | // Launch agent in current directory 95 | let (program, args) = prepare_agent_command(¤t_dir)?; 96 | let mut cmd = Command::new(&program); 97 | cmd.args(&args); 98 | 99 | cmd.envs(std::env::vars()); 100 | 101 | // If there's piped input, drain it and don't pass to Claude 102 | if is_piped_input() { 103 | drain_stdin()?; 104 | cmd.stdin(Stdio::null()); 105 | } 106 | 107 | let status = cmd.status().context("Failed to launch agent")?; 108 | 109 | if !status.success() { 110 | anyhow::bail!("Agent exited with error"); 111 | } 112 | 113 | return Ok(()); 114 | } 115 | } 116 | 117 | if state.worktrees.is_empty() { 118 | anyhow::bail!("No worktrees found. Create one first with 'xlaude create'"); 119 | } 120 | 121 | // Get the name from CLI args or pipe 122 | let target_name = get_command_arg(name)?; 123 | 124 | // Determine which worktree to open 125 | let (_key, worktree_info) = if let Some(n) = target_name { 126 | // Find worktree by name across all projects 127 | state 128 | .worktrees 129 | .iter() 130 | .find(|(_, w)| w.name == n) 131 | .map(|(k, w)| (k.clone(), w.clone())) 132 | .context(format!("Worktree '{n}' not found"))? 133 | } else { 134 | // Interactive selection - show repo/name format 135 | let worktree_list: Vec<(String, WorktreeInfo)> = state 136 | .worktrees 137 | .iter() 138 | .map(|(k, v)| (k.clone(), v.clone())) 139 | .collect(); 140 | 141 | let selection = smart_select("Select a worktree to open", &worktree_list, |(_, info)| { 142 | format!("{}/{}", info.repo_name, info.name) 143 | })?; 144 | 145 | match selection { 146 | Some(idx) => worktree_list[idx].clone(), 147 | None => anyhow::bail!( 148 | "Interactive selection not available in non-interactive mode. Please specify a worktree name." 149 | ), 150 | } 151 | }; 152 | 153 | let worktree_name = &worktree_info.name; 154 | 155 | println!( 156 | "{} Opening worktree '{}/{}'...", 157 | "🚀".green(), 158 | worktree_info.repo_name, 159 | worktree_name.cyan() 160 | ); 161 | 162 | // Change to worktree directory and launch Claude 163 | std::env::set_current_dir(&worktree_info.path).context("Failed to change directory")?; 164 | 165 | // Resolve global agent command 166 | let (program, args) = prepare_agent_command(&worktree_info.path)?; 167 | let mut cmd = Command::new(&program); 168 | cmd.args(&args); 169 | 170 | // Inherit all environment variables 171 | cmd.envs(std::env::vars()); 172 | 173 | // If there's piped input, drain it and don't pass to Claude 174 | if is_piped_input() { 175 | drain_stdin()?; 176 | cmd.stdin(Stdio::null()); 177 | } 178 | 179 | let status = cmd.status().context("Failed to launch agent")?; 180 | 181 | if !status.success() { 182 | anyhow::bail!("Agent exited with error"); 183 | } 184 | 185 | Ok(()) 186 | } 187 | -------------------------------------------------------------------------------- /src/completions.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap_complete::Shell; 3 | 4 | pub fn handle_completions(shell: Shell) -> Result<()> { 5 | match shell { 6 | Shell::Bash => print_bash_completions(), 7 | Shell::Zsh => print_zsh_completions(), 8 | Shell::Fish => print_fish_completions(), 9 | _ => { 10 | eprintln!("Unsupported shell: {:?}", shell); 11 | eprintln!("Supported shells: bash, zsh, fish"); 12 | } 13 | } 14 | Ok(()) 15 | } 16 | 17 | fn print_bash_completions() { 18 | println!( 19 | r#"#!/bin/bash 20 | 21 | _xlaude() {{ 22 | local cur prev words cword 23 | if type _init_completion &>/dev/null; then 24 | _init_completion || return 25 | else 26 | # Fallback for older bash-completion 27 | COMPREPLY=() 28 | cur="${{COMP_WORDS[COMP_CWORD]}}" 29 | prev="${{COMP_WORDS[COMP_CWORD-1]}}" 30 | words=("${{COMP_WORDS[@]}}") 31 | cword=$COMP_CWORD 32 | fi 33 | 34 | # Main commands 35 | local commands="create open delete add rename list clean dir completions" 36 | 37 | # Complete main commands 38 | if [[ $cword -eq 1 ]]; then 39 | COMPREPLY=($(compgen -W "$commands" -- "$cur")) 40 | return 41 | fi 42 | 43 | # Complete subcommand arguments 44 | case "${{words[1]}}" in 45 | open|dir|delete) 46 | if [[ $cword -eq 2 ]]; then 47 | # Get worktree names for completion 48 | local worktrees=$(xlaude complete-worktrees 2>/dev/null) 49 | COMPREPLY=($(compgen -W "$worktrees" -- "$cur")) 50 | fi 51 | ;; 52 | rename) 53 | if [[ $cword -eq 2 ]]; then 54 | # Complete first argument (old name) 55 | local worktrees=$(xlaude complete-worktrees 2>/dev/null) 56 | COMPREPLY=($(compgen -W "$worktrees" -- "$cur")) 57 | fi 58 | ;; 59 | completions) 60 | if [[ $cword -eq 2 ]]; then 61 | COMPREPLY=($(compgen -W "bash zsh fish" -- "$cur")) 62 | fi 63 | ;; 64 | esac 65 | }} 66 | 67 | complete -F _xlaude xlaude 68 | "# 69 | ); 70 | } 71 | 72 | fn print_zsh_completions() { 73 | println!( 74 | r#"#compdef xlaude 75 | 76 | _xlaude() {{ 77 | local -a commands 78 | commands=( 79 | 'create:Create a new git worktree' 80 | 'open:Open an existing worktree and launch Claude' 81 | 'delete:Delete a worktree and clean up' 82 | 'add:Add current worktree to xlaude management' 83 | 'rename:Rename a worktree' 84 | 'list:List all active Claude instances' 85 | 'clean:Clean up invalid worktrees from state' 86 | 'dir:Get the directory path of a worktree' 87 | 'completions:Generate shell completions' 88 | ) 89 | 90 | # Main command completion 91 | if (( CURRENT == 2 )); then 92 | _describe 'command' commands 93 | return 94 | fi 95 | 96 | # Subcommand argument completion 97 | case "${{words[2]}}" in 98 | open|dir|delete) 99 | if (( CURRENT == 3 )); then 100 | _xlaude_worktrees 101 | fi 102 | ;; 103 | rename) 104 | if (( CURRENT == 3 )); then 105 | _xlaude_worktrees 106 | elif (( CURRENT == 4 )); then 107 | _message "new name" 108 | fi 109 | ;; 110 | create|add) 111 | if (( CURRENT == 3 )); then 112 | _message "worktree name" 113 | fi 114 | ;; 115 | completions) 116 | if (( CURRENT == 3 )); then 117 | local -a shells 118 | shells=(bash zsh fish) 119 | _describe 'shell' shells 120 | fi 121 | ;; 122 | esac 123 | }} 124 | 125 | _xlaude_worktrees() {{ 126 | local -a worktrees 127 | local IFS=$'\n' 128 | 129 | # Get detailed worktree information (sorted by repo, then by name) 130 | local worktree_data 131 | worktree_data=($(xlaude complete-worktrees --format=detailed 2>/dev/null)) 132 | 133 | if [[ -n "$worktree_data" ]]; then 134 | for line in $worktree_data; do 135 | # Parse tab-separated values: namerepopathsessions 136 | local name=$(echo "$line" | cut -f1) 137 | local repo=$(echo "$line" | cut -f2) 138 | local sessions=$(echo "$line" | cut -f4) 139 | 140 | # Add worktree with clear repo marker and session info 141 | worktrees+=("$name:[$repo] $sessions") 142 | done 143 | 144 | # Use _describe for better presentation 145 | # -V flag preserves the order (no sorting) 146 | if (( ${{#worktrees[@]}} > 0 )); then 147 | _describe -V -t worktrees 'worktree' worktrees 148 | fi 149 | else 150 | # Fallback to simple completion 151 | local simple_worktrees 152 | simple_worktrees=($(xlaude complete-worktrees 2>/dev/null)) 153 | if [[ -n "$simple_worktrees" ]]; then 154 | compadd -a simple_worktrees 155 | fi 156 | fi 157 | }} 158 | 159 | _xlaude "$@" 160 | "# 161 | ); 162 | } 163 | 164 | fn print_fish_completions() { 165 | println!( 166 | r#"# Fish completion for xlaude 167 | 168 | # Disable file completions by default 169 | complete -c xlaude -f 170 | 171 | # Main commands 172 | complete -c xlaude -n "__fish_use_subcommand" -a create -d "Create a new git worktree" 173 | complete -c xlaude -n "__fish_use_subcommand" -a open -d "Open an existing worktree and launch Claude" 174 | complete -c xlaude -n "__fish_use_subcommand" -a delete -d "Delete a worktree and clean up" 175 | complete -c xlaude -n "__fish_use_subcommand" -a add -d "Add current worktree to xlaude management" 176 | complete -c xlaude -n "__fish_use_subcommand" -a rename -d "Rename a worktree" 177 | complete -c xlaude -n "__fish_use_subcommand" -a list -d "List all active Claude instances" 178 | complete -c xlaude -n "__fish_use_subcommand" -a clean -d "Clean up invalid worktrees from state" 179 | complete -c xlaude -n "__fish_use_subcommand" -a dir -d "Get the directory path of a worktree" 180 | complete -c xlaude -n "__fish_use_subcommand" -a completions -d "Generate shell completions" 181 | 182 | # Function to get worktree completions with repo markers 183 | function __xlaude_worktrees 184 | xlaude complete-worktrees --format=detailed 2>/dev/null | while read -l line 185 | # Split tab-separated values: namerepopathsessions 186 | set -l parts (string split \t $line) 187 | if test (count $parts) -ge 4 188 | set -l name $parts[1] 189 | set -l repo $parts[2] 190 | set -l sessions $parts[4] 191 | echo "$name\t[$repo] $sessions" 192 | end 193 | end 194 | end 195 | 196 | # Simple worktree names (fallback) 197 | function __xlaude_worktrees_simple 198 | xlaude complete-worktrees 2>/dev/null 199 | end 200 | 201 | # Worktree completions for commands 202 | complete -c xlaude -n "__fish_seen_subcommand_from open dir delete" -a "(__xlaude_worktrees)" 203 | complete -c xlaude -n "__fish_seen_subcommand_from rename" -n "not __fish_seen_argument_from (__xlaude_worktrees_simple)" -a "(__xlaude_worktrees)" 204 | 205 | # Shell completions for completions command 206 | complete -c xlaude -n "__fish_seen_subcommand_from completions" -a "bash zsh fish" 207 | "# 208 | ); 209 | } 210 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use rand::seq::IndexedRandom; 3 | use rand::{RngCore, SeedableRng}; 4 | use std::path::Path; 5 | 6 | pub fn generate_random_name() -> Result { 7 | // Allow setting seed for testing 8 | let mut rng = if let Ok(seed_str) = std::env::var("XLAUDE_TEST_SEED") { 9 | let seed: u64 = seed_str.parse().unwrap_or(42); 10 | Box::new(rand::rngs::StdRng::seed_from_u64(seed)) as Box 11 | } else { 12 | Box::new(rand::rng()) as Box 13 | }; 14 | 15 | // Generate 128 bits of entropy for a 12-word mnemonic 16 | let mut entropy = [0u8; 16]; 17 | rng.fill_bytes(&mut entropy); 18 | 19 | let mnemonic = bip39::Mnemonic::from_entropy(&entropy)?; 20 | let words: Vec<&str> = mnemonic.words().collect(); 21 | 22 | // Use the same RNG for choosing the word 23 | let mut chooser_rng = if let Ok(seed_str) = std::env::var("XLAUDE_TEST_SEED") { 24 | let seed: u64 = seed_str.parse().unwrap_or(42); 25 | rand::rngs::StdRng::seed_from_u64(seed) 26 | } else { 27 | let mut entropy_rng = rand::rng(); 28 | rand::rngs::StdRng::from_rng(&mut entropy_rng) 29 | }; 30 | 31 | words 32 | .choose(&mut chooser_rng) 33 | .map(|&word| word.to_string()) 34 | .context("Failed to generate random name") 35 | } 36 | 37 | /// Sanitize a branch name for use in directory names 38 | /// Replaces forward slashes with hyphens to avoid creating subdirectories 39 | pub fn sanitize_branch_name(branch: &str) -> String { 40 | branch.replace('/', "-") 41 | } 42 | 43 | pub fn execute_in_dir(path: P, f: F) -> Result 44 | where 45 | P: AsRef, 46 | F: FnOnce() -> Result, 47 | { 48 | let original_dir = std::env::current_dir().context("Failed to get current directory")?; 49 | std::env::set_current_dir(&path) 50 | .with_context(|| format!("Failed to change to directory: {}", path.as_ref().display()))?; 51 | 52 | let result = f(); 53 | 54 | std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?; 55 | 56 | result 57 | } 58 | 59 | /// Resolve agent command from state or default, and split into program + args. 60 | pub fn resolve_agent_command() -> Result<(String, Vec)> { 61 | let state = crate::state::XlaudeState::load()?; 62 | let cmdline = state 63 | .agent 64 | .clone() 65 | .unwrap_or_else(crate::state::get_default_agent); 66 | 67 | // Use shell-style splitting to handle quotes and spaces. 68 | let parts = shell_words::split(&cmdline) 69 | .map_err(|e| anyhow::anyhow!("Invalid agent command: {} ({e})", cmdline))?; 70 | 71 | if parts.is_empty() { 72 | anyhow::bail!("Agent command is empty"); 73 | } 74 | 75 | let program = parts[0].clone(); 76 | let args = parts[1..].to_vec(); 77 | Ok((program, args)) 78 | } 79 | 80 | const CODEX_OPTIONS_WITH_VALUES: &[&str] = &[ 81 | "-c", 82 | "--config", 83 | "--enable", 84 | "--disable", 85 | "-i", 86 | "--image", 87 | "-m", 88 | "--model", 89 | "-p", 90 | "--profile", 91 | "-s", 92 | "--sandbox", 93 | "-a", 94 | "--ask-for-approval", 95 | "--add-dir", 96 | "-C", 97 | "--cd", 98 | ]; 99 | 100 | fn codex_has_positional_arguments(args: &[String]) -> bool { 101 | let mut index = 0usize; 102 | 103 | while index < args.len() { 104 | let arg = &args[index]; 105 | 106 | if arg == "--" { 107 | return index + 1 < args.len(); 108 | } 109 | 110 | let (option_name, has_inline_value) = match arg.split_once('=') { 111 | Some((name, value)) => (name, !value.is_empty()), 112 | None => (arg.as_str(), false), 113 | }; 114 | 115 | if CODEX_OPTIONS_WITH_VALUES.contains(&option_name) { 116 | if !has_inline_value { 117 | index += 1; 118 | } 119 | index += 1; 120 | continue; 121 | } 122 | 123 | if arg.starts_with('-') { 124 | index += 1; 125 | continue; 126 | } 127 | 128 | return true; 129 | } 130 | 131 | false 132 | } 133 | 134 | pub fn prepare_agent_command(worktree_path: &Path) -> Result<(String, Vec)> { 135 | let (program, args) = resolve_agent_command()?; 136 | 137 | if !program.eq_ignore_ascii_case("codex") { 138 | return Ok((program, args)); 139 | } 140 | 141 | if codex_has_positional_arguments(&args) { 142 | return Ok((program, args)); 143 | } 144 | 145 | let Some(session) = crate::codex::find_latest_session(worktree_path)? else { 146 | return Ok((program, args)); 147 | }; 148 | 149 | let mut new_args = args; 150 | new_args.push("resume".to_string()); 151 | new_args.push(session.id); 152 | 153 | Ok((program, new_args)) 154 | } 155 | 156 | #[cfg(test)] 157 | mod tests { 158 | use super::*; 159 | use serde_json::json; 160 | use std::fs; 161 | use std::sync::{Mutex, OnceLock}; 162 | use tempfile::TempDir; 163 | 164 | static ENV_MUTEX: OnceLock> = OnceLock::new(); 165 | 166 | #[test] 167 | fn prepare_agent_command_resumes_latest_codex_session() { 168 | let _guard = ENV_MUTEX.get_or_init(|| Mutex::new(())).lock().unwrap(); 169 | 170 | let config_dir = TempDir::new().unwrap(); 171 | let sessions_dir = TempDir::new().unwrap(); 172 | let worktree_dir = TempDir::new().unwrap(); 173 | 174 | fs::create_dir_all(config_dir.path()).unwrap(); 175 | fs::create_dir_all(sessions_dir.path()).unwrap(); 176 | 177 | let state = json!({ 178 | "worktrees": {}, 179 | "agent": "codex" 180 | }); 181 | fs::write( 182 | config_dir.path().join("state.json"), 183 | serde_json::to_string_pretty(&state).unwrap(), 184 | ) 185 | .unwrap(); 186 | 187 | let worktree_path = worktree_dir.path().canonicalize().unwrap(); 188 | let worktree_str = worktree_path.to_string_lossy().to_string(); 189 | 190 | let session_dir = sessions_dir.path().join("2025").join("10").join("27"); 191 | fs::create_dir_all(&session_dir).unwrap(); 192 | 193 | let session_meta = json!({ 194 | "timestamp": "2025-10-27T05:29:08.620Z", 195 | "type": "session_meta", 196 | "payload": { 197 | "id": "session-123", 198 | "timestamp": "2025-10-27T05:29:08.601Z", 199 | "cwd": worktree_str, 200 | "originator": "codex_cli_rs", 201 | "cli_version": "0.50.0" 202 | } 203 | }); 204 | 205 | let user_message = json!({ 206 | "timestamp": "2025-10-27T05:30:00.000Z", 207 | "type": "response_item", 208 | "payload": { 209 | "type": "message", 210 | "role": "user", 211 | "content": [ 212 | { 213 | "type": "input_text", 214 | "text": "resume me" 215 | } 216 | ] 217 | } 218 | }); 219 | 220 | fs::write( 221 | session_dir.join("rollout-test.jsonl"), 222 | format!("{session_meta}\n{user_message}\n"), 223 | ) 224 | .unwrap(); 225 | 226 | let config_dir_str = config_dir.path().to_string_lossy().to_string(); 227 | let sessions_dir_str = sessions_dir.path().to_string_lossy().to_string(); 228 | 229 | temp_env::with_vars( 230 | [ 231 | ("XLAUDE_CONFIG_DIR", Some(config_dir_str.as_str())), 232 | ("XLAUDE_CODEX_SESSIONS_DIR", Some(sessions_dir_str.as_str())), 233 | ], 234 | || { 235 | let (program, args) = prepare_agent_command(&worktree_path).unwrap(); 236 | assert_eq!(program, "codex"); 237 | assert_eq!(args, vec!["resume".to_string(), "session-123".to_string()]); 238 | }, 239 | ); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/commands/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use chrono::{DateTime, Local, Utc}; 3 | use colored::Colorize; 4 | use serde::{Deserialize, Serialize}; 5 | use std::collections::BTreeMap; 6 | 7 | use crate::claude::get_claude_sessions; 8 | use crate::codex; 9 | use crate::state::XlaudeState; 10 | 11 | #[derive(Debug, Serialize, Deserialize)] 12 | struct JsonSessionInfo { 13 | last_user_message: String, 14 | last_timestamp: Option>, 15 | time_ago: String, 16 | } 17 | 18 | #[derive(Debug, Serialize, Deserialize)] 19 | struct JsonWorktreeInfo { 20 | name: String, 21 | branch: String, 22 | path: String, 23 | repo_name: String, 24 | created_at: DateTime, 25 | sessions: Vec, 26 | codex_sessions: Vec, 27 | } 28 | 29 | #[derive(Debug, Serialize, Deserialize)] 30 | struct JsonOutput { 31 | worktrees: Vec, 32 | } 33 | 34 | #[derive(Debug, Serialize, Deserialize)] 35 | struct JsonCodexSessionInfo { 36 | id: String, 37 | last_user_message: Option, 38 | last_timestamp: Option>, 39 | time_ago: String, 40 | } 41 | 42 | fn format_time_ago(timestamp: Option>) -> String { 43 | timestamp.map_or_else( 44 | || "unknown".to_string(), 45 | |ts| { 46 | let now = Utc::now(); 47 | let diff = now.signed_duration_since(ts); 48 | 49 | if diff.num_minutes() < 60 { 50 | format!("{}m ago", diff.num_minutes()) 51 | } else if diff.num_hours() < 24 { 52 | format!("{}h ago", diff.num_hours()) 53 | } else { 54 | format!("{}d ago", diff.num_days()) 55 | } 56 | }, 57 | ) 58 | } 59 | 60 | fn format_message_preview(message: &str, limit: usize) -> String { 61 | if message.len() <= limit { 62 | return message.to_string(); 63 | } 64 | 65 | let mut truncated = String::new(); 66 | let safe_limit = limit.saturating_sub(3); 67 | for ch in message.chars() { 68 | if truncated.len() + ch.len_utf8() > safe_limit { 69 | break; 70 | } 71 | truncated.push(ch); 72 | } 73 | truncated.push_str("..."); 74 | truncated 75 | } 76 | 77 | pub fn handle_list(json: bool) -> Result<()> { 78 | let state = XlaudeState::load()?; 79 | 80 | if state.worktrees.is_empty() { 81 | if json { 82 | let output = JsonOutput { worktrees: vec![] }; 83 | println!("{}", serde_json::to_string_pretty(&output)?); 84 | } else { 85 | println!("{} No active worktrees", "📭".yellow()); 86 | } 87 | return Ok(()); 88 | } 89 | 90 | if json { 91 | // JSON output 92 | let mut worktrees = Vec::new(); 93 | 94 | for info in state.worktrees.values() { 95 | let claude_sessions = get_claude_sessions(&info.path); 96 | let json_sessions: Vec = claude_sessions 97 | .into_iter() 98 | .map(|session| JsonSessionInfo { 99 | last_user_message: session.last_user_message, 100 | last_timestamp: session.last_timestamp, 101 | time_ago: format_time_ago(session.last_timestamp), 102 | }) 103 | .collect(); 104 | 105 | let (codex_sessions, _) = codex::recent_sessions(&info.path, usize::MAX)?; 106 | let json_codex_sessions: Vec = codex_sessions 107 | .into_iter() 108 | .map(|session| JsonCodexSessionInfo { 109 | id: session.id, 110 | last_user_message: session.last_user_message, 111 | last_timestamp: session.last_timestamp, 112 | time_ago: format_time_ago(session.last_timestamp), 113 | }) 114 | .collect(); 115 | 116 | worktrees.push(JsonWorktreeInfo { 117 | name: info.name.clone(), 118 | branch: info.branch.clone(), 119 | path: info.path.display().to_string(), 120 | repo_name: info.repo_name.clone(), 121 | created_at: info.created_at, 122 | sessions: json_sessions, 123 | codex_sessions: json_codex_sessions, 124 | }); 125 | } 126 | 127 | // Sort worktrees by repo name and then by name 128 | worktrees.sort_by(|a, b| { 129 | a.repo_name 130 | .cmp(&b.repo_name) 131 | .then_with(|| a.name.cmp(&b.name)) 132 | }); 133 | 134 | let output = JsonOutput { worktrees }; 135 | println!("{}", serde_json::to_string_pretty(&output)?); 136 | } else { 137 | // Original colored output 138 | println!("{} Active worktrees:", "📋".cyan()); 139 | println!(); 140 | 141 | // Group worktrees by repository 142 | let mut grouped: BTreeMap> = BTreeMap::new(); 143 | for info in state.worktrees.values() { 144 | grouped 145 | .entry(info.repo_name.clone()) 146 | .or_default() 147 | .push(info); 148 | } 149 | 150 | // Display grouped by repository 151 | for (repo_name, mut worktrees) in grouped { 152 | println!(" {} {}", "📦".blue(), repo_name.bold()); 153 | 154 | // Sort worktrees within each repo by name 155 | worktrees.sort_by_key(|w| &w.name); 156 | 157 | for info in worktrees { 158 | println!(" {} {}", "•".green(), info.name.cyan()); 159 | println!(" {} {}", "Path:".bright_black(), info.path.display()); 160 | println!( 161 | " {} {}", 162 | "Created:".bright_black(), 163 | info.created_at 164 | .with_timezone(&Local) 165 | .format("%Y-%m-%d %H:%M:%S") 166 | ); 167 | 168 | // Get Claude sessions for this worktree 169 | let claude_sessions = get_claude_sessions(&info.path); 170 | if !claude_sessions.is_empty() { 171 | println!( 172 | " {} {} session(s):", 173 | "Claude:".bright_black(), 174 | claude_sessions.len() 175 | ); 176 | for session in claude_sessions.iter().take(3) { 177 | let time_str = format_time_ago(session.last_timestamp); 178 | let message = format_message_preview(&session.last_user_message, 60); 179 | 180 | println!( 181 | " {} {} {}", 182 | "-".bright_black(), 183 | time_str.bright_black(), 184 | message.bright_black() 185 | ); 186 | } 187 | if claude_sessions.len() > 3 { 188 | println!( 189 | " {} ... and {} more", 190 | "-".bright_black(), 191 | claude_sessions.len() - 3 192 | ); 193 | } 194 | } 195 | 196 | let (codex_sessions, codex_total) = codex::recent_sessions(&info.path, 3)?; 197 | if codex_total > 0 { 198 | println!( 199 | " {} {} session(s):", 200 | "Codex:".bright_black(), 201 | codex_total 202 | ); 203 | for session in &codex_sessions { 204 | let time_str = format_time_ago(session.last_timestamp); 205 | let message = session 206 | .last_user_message 207 | .as_deref() 208 | .map(|msg| format_message_preview(msg, 60)) 209 | .unwrap_or_else(|| "(no user message)".to_string()); 210 | 211 | println!( 212 | " {} {} {}", 213 | "-".bright_black(), 214 | time_str.bright_black(), 215 | message.bright_black() 216 | ); 217 | } 218 | if codex_total > codex_sessions.len() { 219 | println!( 220 | " {} ... and {} more", 221 | "-".bright_black(), 222 | codex_total - codex_sessions.len() 223 | ); 224 | } 225 | } 226 | } 227 | println!(); 228 | } 229 | } 230 | 231 | Ok(()) 232 | } 233 | -------------------------------------------------------------------------------- /tests/pipe_input.rs: -------------------------------------------------------------------------------- 1 | use assert_cmd::Command; 2 | use predicates::prelude::*; 3 | use std::fs; 4 | use tempfile::TempDir; 5 | 6 | /// Helper to create a test git repository with worktree management 7 | fn setup_test_repo() -> (TempDir, String, String) { 8 | let temp_dir = TempDir::new().unwrap(); 9 | let repo_path = temp_dir.path().join("test-repo"); 10 | fs::create_dir(&repo_path).unwrap(); 11 | 12 | // Create config directory for xlaude state and default agent 13 | let config_dir = temp_dir.path().join(".config/xlaude"); 14 | fs::create_dir_all(&config_dir).unwrap(); 15 | let default_state = serde_json::json!({ 16 | "worktrees": {}, 17 | "agent": "true" 18 | }); 19 | fs::write( 20 | config_dir.join("state.json"), 21 | serde_json::to_string_pretty(&default_state).unwrap(), 22 | ) 23 | .unwrap(); 24 | 25 | // Initialize git repo 26 | Command::new("git") 27 | .current_dir(&repo_path) 28 | .args(["init"]) 29 | .assert() 30 | .success(); 31 | 32 | // Configure git user for the test 33 | Command::new("git") 34 | .current_dir(&repo_path) 35 | .args(["config", "user.email", "test@example.com"]) 36 | .assert() 37 | .success(); 38 | 39 | Command::new("git") 40 | .current_dir(&repo_path) 41 | .args(["config", "user.name", "Test User"]) 42 | .assert() 43 | .success(); 44 | 45 | // Disable GPG signing for test commits 46 | Command::new("git") 47 | .current_dir(&repo_path) 48 | .args(["config", "commit.gpgsign", "false"]) 49 | .assert() 50 | .success(); 51 | 52 | // Create initial commit 53 | let readme_path = repo_path.join("README.md"); 54 | fs::write(&readme_path, "# Test Repo").unwrap(); 55 | 56 | Command::new("git") 57 | .current_dir(&repo_path) 58 | .args(["add", "."]) 59 | .assert() 60 | .success(); 61 | 62 | Command::new("git") 63 | .current_dir(&repo_path) 64 | .args(["commit", "-m", "Initial commit"]) 65 | .assert() 66 | .success(); 67 | 68 | ( 69 | temp_dir, 70 | repo_path.to_str().unwrap().to_string(), 71 | config_dir.to_str().unwrap().to_string(), 72 | ) 73 | } 74 | 75 | #[test] 76 | fn test_create_with_piped_input() { 77 | let (_temp_dir, repo_path, config_dir) = setup_test_repo(); 78 | 79 | // Test creating worktree with piped name 80 | let mut cmd = Command::new(env!("CARGO_BIN_EXE_xlaude")); 81 | cmd.current_dir(&repo_path) 82 | .env("XLAUDE_CONFIG_DIR", &config_dir) 83 | .env("XLAUDE_NON_INTERACTIVE", "1") 84 | .env("XLAUDE_TEST_MODE", "1") 85 | .args(["create"]) 86 | .write_stdin("test-feature\n"); 87 | 88 | cmd.assert() 89 | .success() 90 | .stdout(predicate::str::contains("Creating worktree 'test-feature'")); 91 | 92 | // Verify the worktree was created 93 | let worktree_path = std::path::Path::new(&repo_path) 94 | .parent() 95 | .unwrap() 96 | .join("test-repo-test-feature"); 97 | assert!(worktree_path.exists()); 98 | } 99 | 100 | #[test] 101 | fn test_dir_with_piped_input() { 102 | let (_temp_dir, repo_path, config_dir) = setup_test_repo(); 103 | 104 | // First create a worktree 105 | Command::new(env!("CARGO_BIN_EXE_xlaude")) 106 | .current_dir(&repo_path) 107 | .env("XLAUDE_CONFIG_DIR", &config_dir) 108 | .env("XLAUDE_NON_INTERACTIVE", "1") 109 | .env("XLAUDE_TEST_MODE", "1") 110 | .args(["create", "test-dir"]) 111 | .assert() 112 | .success(); 113 | 114 | // Test getting directory with piped input 115 | let mut cmd = Command::new(env!("CARGO_BIN_EXE_xlaude")); 116 | cmd.current_dir(&repo_path) 117 | .env("XLAUDE_CONFIG_DIR", &config_dir) 118 | .env("XLAUDE_NON_INTERACTIVE", "1") 119 | .args(["dir"]) 120 | .write_stdin("test-dir\n"); 121 | 122 | cmd.assert() 123 | .success() 124 | .stdout(predicate::str::contains("test-repo-test-dir")); 125 | } 126 | 127 | #[test] 128 | fn test_delete_with_auto_confirm() { 129 | let (_temp_dir, repo_path, config_dir) = setup_test_repo(); 130 | 131 | // Create a worktree 132 | Command::new(env!("CARGO_BIN_EXE_xlaude")) 133 | .current_dir(&repo_path) 134 | .env("XLAUDE_CONFIG_DIR", &config_dir) 135 | .env("XLAUDE_NON_INTERACTIVE", "1") 136 | .env("XLAUDE_TEST_MODE", "1") 137 | .args(["create", "test-delete"]) 138 | .assert() 139 | .success(); 140 | 141 | // Test deleting with piped confirmation 142 | let mut cmd = Command::new(env!("CARGO_BIN_EXE_xlaude")); 143 | cmd.current_dir(&repo_path) 144 | .env("XLAUDE_CONFIG_DIR", &config_dir) 145 | .args(["delete", "test-delete"]) 146 | .write_stdin("y\n"); 147 | 148 | cmd.assert() 149 | .success() 150 | .stdout(predicate::str::contains("deleted successfully")); 151 | } 152 | 153 | #[test] 154 | fn test_delete_with_env_yes() { 155 | // This test primarily demonstrates that XLAUDE_YES environment variable 156 | // can be used to auto-confirm prompts. The actual deletion test would 157 | // require proper state management which is complex in test environments. 158 | 159 | // Test that XLAUDE_YES is recognized by running help with it set 160 | Command::new(env!("CARGO_BIN_EXE_xlaude")) 161 | .env("XLAUDE_YES", "1") 162 | .args(["--help"]) 163 | .assert() 164 | .success(); 165 | } 166 | 167 | #[test] 168 | fn test_multiple_confirmations_with_pipe() { 169 | let (_temp_dir, repo_path, config_dir) = setup_test_repo(); 170 | 171 | // Test that we can provide multiple answers via pipe 172 | // Create with "n" answer to not open 173 | let mut cmd = Command::new(env!("CARGO_BIN_EXE_xlaude")); 174 | cmd.current_dir(&repo_path) 175 | .env("XLAUDE_CONFIG_DIR", &config_dir) 176 | .args(["create", "test-multi"]) 177 | .write_stdin("n\n"); // Answer no to open prompt 178 | 179 | cmd.assert() 180 | .success() 181 | .stdout(predicate::str::contains("Creating worktree")) 182 | .stdout(predicate::str::contains("To open it later")); 183 | } 184 | 185 | #[test] 186 | fn test_create_with_piped_confirmation() { 187 | let (_temp_dir, repo_path, config_dir) = setup_test_repo(); 188 | 189 | // Test creating worktree and answering "no" to open prompt via pipe 190 | let mut cmd = Command::new(env!("CARGO_BIN_EXE_xlaude")); 191 | cmd.current_dir(&repo_path) 192 | .env("XLAUDE_CONFIG_DIR", &config_dir) 193 | .args(["create", "test-no-open"]) 194 | .write_stdin("n\n"); // Answer "no" to the open prompt 195 | 196 | cmd.assert() 197 | .success() 198 | .stdout(predicate::str::contains("Creating worktree 'test-no-open'")) 199 | .stdout(predicate::str::contains("To open it later")); 200 | } 201 | 202 | #[test] 203 | fn test_yes_doesnt_interfere_with_open() { 204 | let (_temp_dir, repo_path, config_dir) = setup_test_repo(); 205 | 206 | // Create a worktree 207 | Command::new(env!("CARGO_BIN_EXE_xlaude")) 208 | .current_dir(&repo_path) 209 | .env("XLAUDE_CONFIG_DIR", &config_dir) 210 | .env("XLAUDE_NON_INTERACTIVE", "1") 211 | .env("XLAUDE_TEST_MODE", "1") 212 | .args(["create", "test-yes"]) 213 | .assert() 214 | .success(); 215 | 216 | // Test that 'yes' doesn't interfere with opening 217 | // The extra 'y' lines should be drained and not passed to the mock Claude command 218 | let mut cmd = Command::new(env!("CARGO_BIN_EXE_xlaude")); 219 | cmd.current_dir(&repo_path) 220 | .env("XLAUDE_CONFIG_DIR", &config_dir) 221 | // agent is set to "true" in state; no need to override 222 | .args(["open", "test-yes"]) 223 | .write_stdin("y\ny\ny\n"); // Extra yes responses that should be drained 224 | 225 | // This should succeed - the echo command won't fail from extra stdin 226 | cmd.assert() 227 | .success() 228 | .stdout(predicate::str::contains("Opening worktree")); 229 | } 230 | 231 | #[test] 232 | fn test_pipe_input_priority() { 233 | let (_temp_dir, repo_path, config_dir) = setup_test_repo(); 234 | 235 | // Create a worktree 236 | Command::new(env!("CARGO_BIN_EXE_xlaude")) 237 | .current_dir(&repo_path) 238 | .env("XLAUDE_CONFIG_DIR", &config_dir) 239 | .env("XLAUDE_NON_INTERACTIVE", "1") 240 | .env("XLAUDE_TEST_MODE", "1") 241 | .args(["create", "priority-test"]) 242 | .assert() 243 | .success(); 244 | 245 | // Test that CLI argument takes priority over piped input 246 | let mut cmd = Command::new(env!("CARGO_BIN_EXE_xlaude")); 247 | cmd.current_dir(&repo_path) 248 | .env("XLAUDE_CONFIG_DIR", &config_dir) 249 | .args(["dir", "priority-test"]) 250 | .write_stdin("wrong-name\n"); 251 | 252 | cmd.assert() 253 | .success() 254 | .stdout(predicate::str::contains("test-repo-priority-test")); 255 | } 256 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xlaude 2 | 3 | > Manage Claude or Codex coding sessions by turning every git worktree into its own agent playground. 4 | 5 | xlaude keeps large projects organized by pairing each feature branch with a dedicated AI session. It automates worktree creation, keeps track of conversation history, and helps you pause, resume, and clean up work in seconds. 6 | 7 | ## Why xlaude? 8 | 9 | - **Worktree-native workflow** – every feature branch lives in `../-` with automatic branch creation, sanitized names, and submodule updates. 10 | - **Session awareness** – `list` reads Claude (`~/.claude/projects`) and Codex (`~/.codex/sessions`) logs to surface the last user prompt and activity timestamps per worktree. 11 | - **Agent agnostic** – configure a single `agent` command (default `claude --dangerously-skip-permissions`). When that command is `codex`, xlaude auto-appends `resume ` matching the worktree. 12 | - **Automation ready** – every subcommand accepts piped input, honors `XLAUDE_YES`/`XLAUDE_NON_INTERACTIVE`, and exposes a hidden completion helper for shell integration. 13 | 14 | ## Installation 15 | 16 | ### Prerequisites 17 | 18 | - Git with worktree support (git ≥ 2.36 recommended). 19 | - Rust toolchain (for `cargo install` or local builds). 20 | - Claude CLI or any other agent command you plan to run. 21 | - Optional but recommended: GitHub CLI (`gh`) so `delete` can detect merged PRs even after squash merges. 22 | 23 | ### From crates.io 24 | 25 | ```bash 26 | cargo install xlaude 27 | ``` 28 | 29 | ### From source 30 | 31 | ```bash 32 | git clone https://github.com/xuanwo/xlaude 33 | cd xlaude 34 | cargo build --release 35 | ``` 36 | 37 | Use `target/release/xlaude` or add it to your `PATH`. 38 | 39 | ### Upgrading 40 | 41 | Re-run `cargo install xlaude` to pull the latest published release, or `git pull && cargo build --release` if you track `main`. 42 | 43 | ## Shell completions 44 | 45 | Generate completion scripts for bash, zsh, or fish: 46 | 47 | ```bash 48 | xlaude completions bash > ~/.bash_completion.d/xlaude 49 | xlaude completions zsh > ~/.zfunc/_xlaude 50 | xlaude completions fish > ~/.config/fish/completions/xlaude.fish 51 | ``` 52 | 53 | The completions use the hidden `xlaude complete-worktrees --format=detailed` helper to surface worktree names, repositories, and recent session counts. 54 | 55 | ## Configuration & state 56 | 57 | ### State file 58 | 59 | State lives in a JSON file that xlaude migrates automatically: 60 | 61 | - macOS: `~/Library/Application Support/com.xuanwo.xlaude/state.json` 62 | - Linux: `~/.config/xlaude/state.json` 63 | - Windows: `%APPDATA%\xuanwo\xlaude\config\state.json` 64 | 65 | Each entry is keyed by `/` (introduced in v0.3). Use `XLAUDE_CONFIG_DIR` to override the directory for testing or portable setups. 66 | 67 | ### Agent command 68 | 69 | Set the global `agent` field to the exact command line xlaude should launch for every worktree. Example: 70 | 71 | ```json 72 | { 73 | "agent": "codex --dangerously-bypass-approvals-and-sandbox", 74 | "worktrees": { 75 | "repo/feature": { /* ... */ } 76 | } 77 | } 78 | ``` 79 | 80 | - Default value: `claude --dangerously-skip-permissions`. 81 | - The command is split with shell-style rules, so quotes are supported. Pipelines or redirects should live in a wrapper script. 82 | - When the program name is `codex` and no positional arguments were supplied, xlaude will locate the latest session under `~/.codex/sessions` (or `XLAUDE_CODEX_SESSIONS_DIR`) whose `cwd` matches the worktree and automatically append `resume `. 83 | 84 | ### Worktree creation defaults 85 | 86 | - `xlaude create` and `checkout` copy `CLAUDE.local.md` into the new worktree if it exists at the repo root. 87 | - Submodules are initialized with `git submodule update --init --recursive` in every new worktree. 88 | - Branch names are sanitized (`feature/foo` → `feature-foo`) before creating the directory. 89 | 90 | ## Command reference 91 | 92 | ### `xlaude create [name]` 93 | 94 | - Must be run from a base branch (`main`, `master`, `develop`, or the remote default). 95 | - Without a name, xlaude selects a random BIP39 word; set `XLAUDE_TEST_SEED` for deterministic names in CI. 96 | - Rejects duplicate worktree directories or existing state entries. 97 | - Offers to open the new worktree unless `XLAUDE_NO_AUTO_OPEN` or `XLAUDE_TEST_MODE` is set. 98 | 99 | ```bash 100 | xlaude create auth-gateway 101 | xlaude create # -> ../repo-harbor 102 | ``` 103 | 104 | ### `xlaude checkout ` 105 | 106 | - Accepts either a branch name or a GitHub pull request number (with or without `#`). 107 | - Ensures the branch exists locally by fetching `origin/` when missing. 108 | - For PR numbers, fetches `pull//head` into `pr/` before creating the worktree. 109 | - If the branch already has a managed worktree, xlaude offers to open it instead of duplicating the environment. 110 | 111 | ### `xlaude open [name]` 112 | 113 | - With a name, finds the corresponding worktree across all repositories and launches the configured agent. 114 | - Without a name and while standing inside a non-base worktree, it reuses the current directory. If the worktree is not tracked yet, xlaude offers to add it to `state.json`. 115 | - Otherwise, presents an interactive selector (`fzf`-like list) or honors piped input. 116 | - Every environment variable from the parent shell is forwarded to the agent process. When stdin is piped into `xlaude`, it is drained and not passed to the agent to avoid stuck sessions. 117 | 118 | ### `xlaude add [name]` 119 | 120 | Attach the current git worktree (where `.git` is a file) to xlaude state. Name defaults to the sanitized branch. The command refuses to add the same path twice, even under a different alias. 121 | 122 | ### `xlaude rename ` 123 | 124 | Renames the entry in `state.json` within the current repository, keeping the underlying directory and git branch unchanged. 125 | 126 | ### `xlaude list [--json]` 127 | 128 | - Default output groups worktrees by repository, showing path, creation timestamp, and recent sessions. 129 | - Claude sessions are read from `~/.claude/projects/`; up to three per worktree are previewed with "time ago" labels. 130 | - Codex sessions are read from the sessions archive, showing the last user utterance when available. 131 | - `--json` emits a machine-readable structure: 132 | 133 | ```json 134 | { 135 | "worktrees": [ 136 | { 137 | "name": "auth-gateway", 138 | "branch": "feature/auth-gateway", 139 | "path": "/repos/repo-auth-gateway", 140 | "repo_name": "repo", 141 | "created_at": "2025-10-30T02:41:18Z", 142 | "sessions": [ { "last_user_message": "Deploy staging", "time_ago": "5m ago" } ], 143 | "codex_sessions": [ ... ] 144 | } 145 | ] 146 | } 147 | ``` 148 | 149 | ### `xlaude dir [name]` 150 | 151 | Prints the absolute path of a worktree with no ANSI formatting, making it ideal for subshells: 152 | 153 | ```bash 154 | cd $(xlaude dir auth-gateway) 155 | ``` 156 | 157 | When no argument is provided, an interactive selector (or piped input) chooses the worktree. 158 | 159 | ### `xlaude delete [name]` 160 | 161 | - If run without arguments, targets the worktree that matches the current directory. 162 | - Refuses to proceed when there are uncommitted changes or unpushed commits unless you confirm. 163 | - Checks whether the branch is merged either via `git branch --merged` or GitHub PR history (`gh pr list --state merged --head `). Squash mergers are therefore detected. 164 | - Removes the git worktree (force-removing if needed), prunes it if the directory already disappeared, and deletes the local branch after confirmation. 165 | 166 | ### `xlaude clean` 167 | 168 | Cross-checks `state.json` against actual `git worktree list` output for every known repository. Any missing directories are removed from state with a concise report. 169 | 170 | ### `xlaude config` 171 | 172 | Opens the state file in `$EDITOR`, creating parent directories as needed. Use this to hand-edit the global `agent` or worktree metadata. 173 | 174 | ### `xlaude completions ` 175 | 176 | Prints shell completion scripts. Combine with `complete-worktrees` for dynamic worktree hints. 177 | 178 | ### `xlaude complete-worktrees [--format=simple|detailed]` (hidden) 179 | 180 | Emits sorted worktree names. The `detailed` format prints `namerepopathsession-summary` and is consumed by the provided zsh/fish completion functions. You can also call it in custom tooling. 181 | 182 | ## Automation & non-interactive usage 183 | 184 | Input priority is always **CLI argument > piped input > interactive prompt**. Example: `echo feature-x | xlaude open correct-name` opens `correct-name`. 185 | 186 | Environment switches: 187 | 188 | | Variable | Effect | 189 | | --- | --- | 190 | | `XLAUDE_YES=1` | Auto-confirm every prompt (used by `delete`, `create`, etc.). | 191 | | `XLAUDE_NON_INTERACTIVE=1` | Disable interactive prompts/selectors; commands fall back to defaults or fail fast. | 192 | | `XLAUDE_NO_AUTO_OPEN=1` | Skip the “open now?” question after `create`. | 193 | | `XLAUDE_CONFIG_DIR=/tmp/xlaude-config` | Redirect both reads and writes of `state.json`. | 194 | | `XLAUDE_CODEX_SESSIONS_DIR=/path/to/sessions` | Point Codex session discovery to a non-default location. | 195 | | `XLAUDE_TEST_SEED=42` | Deterministically pick random names (handy for tests). | 196 | | `XLAUDE_TEST_MODE=1` | Test harness flag; suppresses some interactivity (also skips auto-open). | 197 | 198 | Piped input works with selectors and confirmations. For example, `yes | xlaude delete feature-x` or `printf "1\n" | xlaude open` to pick the first entry. 199 | 200 | ## Typical workflow 201 | 202 | ```bash 203 | # 1. Create an isolated workspace from main 204 | xlaude create payments-strategy 205 | 206 | # 2. Start working with your agent 207 | xlaude open payments-strategy 208 | 209 | # 3. Inspect outstanding worktrees across repositories 210 | xlaude list --json | jq '.worktrees | length' 211 | 212 | # 4. Clean up after merge 213 | xlaude delete payments-strategy 214 | ``` 215 | 216 | ## License 217 | 218 | Apache License 2.0. See `LICENSE` for details. 219 | -------------------------------------------------------------------------------- /src/commands/checkout.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path::{Path, PathBuf}; 3 | 4 | use anyhow::{Context, Result, bail}; 5 | use chrono::Utc; 6 | use colored::Colorize; 7 | 8 | use crate::commands::open::handle_open; 9 | use crate::git::{execute_git, get_repo_name, update_submodules}; 10 | use crate::input::{get_command_arg, smart_confirm}; 11 | use crate::state::{WorktreeInfo, XlaudeState}; 12 | use crate::utils::sanitize_branch_name; 13 | 14 | pub fn handle_checkout(target: Option) -> Result<()> { 15 | let raw_target = get_command_arg(target)? 16 | .map(|s| s.trim().to_string()) 17 | .filter(|s| !s.is_empty()) 18 | .context("Please provide a branch name or pull request number")?; 19 | 20 | let checkout_target = CheckoutTarget::parse(&raw_target)?; 21 | let repo_root_str = execute_git(&["rev-parse", "--show-toplevel"])? 22 | .trim() 23 | .to_string(); 24 | let repo_root = PathBuf::from(&repo_root_str); 25 | let repo_name = get_repo_name().context("Not in a git repository")?; 26 | 27 | let branch_name = checkout_target.branch_name(); 28 | let worktree_name = sanitize_branch_name(&branch_name); 29 | 30 | if let Some(existing) = find_existing_worktree(&repo_name, &branch_name)? { 31 | println!( 32 | "{} Worktree for {} already exists at {}", 33 | "⚠️".yellow(), 34 | checkout_target.describe().cyan(), 35 | existing.path.display() 36 | ); 37 | println!( 38 | " {} To open it manually, run: {} {}", 39 | "💡".cyan(), 40 | "xlaude open".cyan(), 41 | existing.name.cyan() 42 | ); 43 | 44 | let should_open = smart_confirm( 45 | "Worktree already exists. Open it now with 'xlaude open'?", 46 | false, 47 | )?; 48 | 49 | if should_open { 50 | handle_open(Some(existing.name.clone()))?; 51 | return Ok(()); 52 | } 53 | 54 | bail!( 55 | "Worktree '{}' already exists for {}", 56 | existing.name, 57 | checkout_target.describe() 58 | ); 59 | } 60 | 61 | ensure_branch_ready(&checkout_target, &branch_name)?; 62 | 63 | println!( 64 | "{} Checking out {} into worktree '{}'...", 65 | "✨".green(), 66 | checkout_target.describe().cyan(), 67 | worktree_name.cyan() 68 | ); 69 | 70 | let created_path = create_worktree(&repo_root, &repo_name, &branch_name, &worktree_name)?; 71 | 72 | println!( 73 | "{} Worktree created at: {}", 74 | "✅".green(), 75 | created_path.display() 76 | ); 77 | println!( 78 | " {} To open it later, run: {} {}", 79 | "💡".cyan(), 80 | "xlaude open".cyan(), 81 | worktree_name.cyan() 82 | ); 83 | 84 | Ok(()) 85 | } 86 | 87 | fn find_existing_worktree(repo_name: &str, branch_name: &str) -> Result> { 88 | let state = XlaudeState::load()?; 89 | Ok(state 90 | .worktrees 91 | .values() 92 | .find(|w| w.repo_name == repo_name && w.branch == branch_name) 93 | .cloned() 94 | .map(ExistingWorktree)) 95 | } 96 | 97 | fn ensure_branch_ready(target: &CheckoutTarget, branch_name: &str) -> Result<()> { 98 | match target { 99 | CheckoutTarget::Branch(_) => ensure_branch_available(branch_name), 100 | CheckoutTarget::PullRequest(pr_number) => fetch_pull_request(*pr_number, branch_name), 101 | } 102 | } 103 | 104 | fn ensure_branch_available(branch_name: &str) -> Result<()> { 105 | if branch_exists(branch_name) { 106 | return Ok(()); 107 | } 108 | 109 | println!( 110 | "{} Branch '{}' not found locally. Attempting to fetch from origin...", 111 | "🌐".blue(), 112 | branch_name.cyan() 113 | ); 114 | 115 | ensure_origin_remote()?; 116 | let fetch_spec = format!("{branch_name}:{branch_name}"); 117 | execute_git(&["fetch", "origin", &fetch_spec]) 118 | .with_context(|| format!("Failed to fetch branch '{branch_name}' from origin"))?; 119 | 120 | if branch_exists(branch_name) { 121 | Ok(()) 122 | } else { 123 | bail!("Branch '{branch_name}' does not exist locally or on origin"); 124 | } 125 | } 126 | 127 | fn fetch_pull_request(pr_number: u64, branch_name: &str) -> Result<()> { 128 | ensure_origin_remote()?; 129 | println!( 130 | "{} Fetching pull request #{} from origin...", 131 | "🌐".blue(), 132 | pr_number 133 | ); 134 | 135 | let fetch_ref = format!("pull/{pr_number}/head:refs/heads/{branch_name}"); 136 | execute_git(&["fetch", "origin", &fetch_ref]) 137 | .with_context(|| format!("Failed to fetch pull request #{pr_number} from origin"))?; 138 | 139 | Ok(()) 140 | } 141 | 142 | fn ensure_origin_remote() -> Result<()> { 143 | execute_git(&["remote", "get-url", "origin"]) 144 | .context("Remote 'origin' is not configured. Please add a remote before using checkout.")?; 145 | Ok(()) 146 | } 147 | 148 | fn branch_exists(branch_name: &str) -> bool { 149 | execute_git(&["show-ref", "--verify", &format!("refs/heads/{branch_name}")]).is_ok() 150 | } 151 | 152 | fn create_worktree( 153 | repo_root: &Path, 154 | repo_name: &str, 155 | branch_name: &str, 156 | worktree_name: &str, 157 | ) -> Result { 158 | let repo_root_str = repo_root 159 | .to_str() 160 | .context("Repository path contains invalid UTF-8")?; 161 | 162 | let worktree_parent = repo_root 163 | .parent() 164 | .context("Repository root has no parent directory for worktrees")?; 165 | let worktree_path = worktree_parent.join(format!("{repo_name}-{worktree_name}")); 166 | 167 | if worktree_path.exists() { 168 | bail!( 169 | "Directory '{}' already exists. Please remove it or choose another branch.", 170 | worktree_path.display() 171 | ); 172 | } 173 | 174 | let existing_worktrees = list_worktrees_for_repo(repo_root)?; 175 | if existing_worktrees.iter().any(|w| w == &worktree_path) { 176 | bail!( 177 | "A git worktree already exists at '{}'. Remove it or pick a different branch.", 178 | worktree_path.display() 179 | ); 180 | } 181 | 182 | let mut state = XlaudeState::load()?; 183 | let key = XlaudeState::make_key(repo_name, worktree_name); 184 | if state.worktrees.contains_key(&key) { 185 | bail!( 186 | "A worktree named '{}' is already tracked for '{}'.", 187 | worktree_name, 188 | repo_name 189 | ); 190 | } 191 | 192 | let worktree_arg = worktree_path 193 | .to_str() 194 | .context("Worktree path contains invalid UTF-8")?; 195 | 196 | execute_git(&[ 197 | "-C", 198 | repo_root_str, 199 | "worktree", 200 | "add", 201 | worktree_arg, 202 | branch_name, 203 | ]) 204 | .context("Failed to create worktree")?; 205 | 206 | if let Err(e) = update_submodules(&worktree_path) { 207 | println!( 208 | "{} Warning: Failed to update submodules: {}", 209 | "⚠️".yellow(), 210 | e 211 | ); 212 | } else { 213 | let gitmodules = worktree_path.join(".gitmodules"); 214 | if gitmodules.exists() { 215 | println!("{} Updated submodules", "📦".green()); 216 | } 217 | } 218 | 219 | let claude_local = repo_root.join("CLAUDE.local.md"); 220 | if claude_local.exists() { 221 | let target = worktree_path.join("CLAUDE.local.md"); 222 | fs::copy(&claude_local, &target).context("Failed to copy CLAUDE.local.md")?; 223 | println!("{} Copied CLAUDE.local.md to worktree", "📄".green()); 224 | } 225 | 226 | state.worktrees.insert( 227 | key, 228 | WorktreeInfo { 229 | name: worktree_name.to_string(), 230 | branch: branch_name.to_string(), 231 | path: worktree_path.clone(), 232 | repo_name: repo_name.to_string(), 233 | created_at: Utc::now(), 234 | }, 235 | ); 236 | state.save()?; 237 | 238 | Ok(worktree_path) 239 | } 240 | 241 | fn list_worktrees_for_repo(repo_root: &Path) -> Result> { 242 | let repo_root_str = repo_root 243 | .to_str() 244 | .context("Repository path contains invalid UTF-8")?; 245 | let output = execute_git(&["-C", repo_root_str, "worktree", "list", "--porcelain"])?; 246 | 247 | let mut worktrees = Vec::new(); 248 | for line in output.lines() { 249 | if let Some(path) = line.strip_prefix("worktree ") { 250 | worktrees.push(PathBuf::from(path)); 251 | } 252 | } 253 | 254 | Ok(worktrees) 255 | } 256 | 257 | #[derive(Clone)] 258 | struct ExistingWorktree(WorktreeInfo); 259 | 260 | impl std::ops::Deref for ExistingWorktree { 261 | type Target = WorktreeInfo; 262 | 263 | fn deref(&self) -> &Self::Target { 264 | &self.0 265 | } 266 | } 267 | 268 | enum CheckoutTarget { 269 | Branch(String), 270 | PullRequest(u64), 271 | } 272 | 273 | impl CheckoutTarget { 274 | fn parse(input: &str) -> Result { 275 | let trimmed = input.trim(); 276 | if trimmed.is_empty() { 277 | bail!("Target cannot be empty"); 278 | } 279 | 280 | let digits_only = trimmed.trim_start_matches('#'); 281 | if !digits_only.is_empty() && digits_only.chars().all(|c| c.is_ascii_digit()) { 282 | let value = digits_only 283 | .parse::() 284 | .context("Invalid pull request number")?; 285 | return Ok(Self::PullRequest(value)); 286 | } 287 | 288 | Ok(Self::Branch(trimmed.to_string())) 289 | } 290 | 291 | fn branch_name(&self) -> String { 292 | match self { 293 | Self::Branch(name) => name.clone(), 294 | Self::PullRequest(number) => format!("pr/{number}"), 295 | } 296 | } 297 | 298 | fn describe(&self) -> String { 299 | match self { 300 | Self::Branch(name) => format!("branch '{name}'"), 301 | Self::PullRequest(number) => format!("pull request #{number}"), 302 | } 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /src/git.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use std::path::{Path, PathBuf}; 3 | use std::process::Command; 4 | 5 | pub fn execute_git(args: &[&str]) -> Result { 6 | let output = Command::new("git") 7 | .args(args) 8 | .output() 9 | .context("Failed to execute git command")?; 10 | 11 | if output.status.success() { 12 | Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) 13 | } else { 14 | let stderr = String::from_utf8_lossy(&output.stderr); 15 | anyhow::bail!("Git command failed: {}", stderr); 16 | } 17 | } 18 | 19 | pub fn get_repo_name() -> Result { 20 | // First, try to get the repository name from the remote URL 21 | // This gives us the true repository name regardless of local directory name 22 | if let Ok(remote_url) = execute_git(&["remote", "get-url", "origin"]) { 23 | // Extract repo name from URL 24 | // Supports: 25 | // - https://github.com/user/repo.git 26 | // - git@github.com:user/repo.git 27 | // - https://gitlab.com/user/repo 28 | // - /path/to/local/repo.git 29 | let repo_name = if let Some(name) = extract_repo_name_from_url(&remote_url) { 30 | name 31 | } else { 32 | // Fallback to directory name if URL parsing fails 33 | get_repo_name_from_directory()? 34 | }; 35 | return Ok(repo_name); 36 | } 37 | 38 | // If no remote, use the directory name of the main repository 39 | get_repo_name_from_directory() 40 | } 41 | 42 | pub fn extract_repo_name_from_url(url: &str) -> Option { 43 | let url = url.trim(); 44 | 45 | // Remove .git suffix if present 46 | let url = url.strip_suffix(".git").unwrap_or(url); 47 | 48 | // Handle SSH URLs (git@github.com:user/repo) 49 | if url.starts_with("git@") { 50 | return url 51 | .split(':') 52 | .nth(1) 53 | .and_then(|path| path.split('/').next_back()) 54 | .map(|s| s.to_string()); 55 | } 56 | 57 | // Handle HTTP(S) URLs and file paths 58 | url.split('/') 59 | .next_back() 60 | .filter(|s| !s.is_empty()) 61 | .map(|s| s.to_string()) 62 | } 63 | 64 | fn get_repo_name_from_directory() -> Result { 65 | // For worktrees, we need to get the main repository path 66 | // Try to get the common git directory first (which points to main repo for worktrees) 67 | let git_common_dir = execute_git(&["rev-parse", "--git-common-dir"])?; 68 | let git_dir = execute_git(&["rev-parse", "--git-dir"])?; 69 | 70 | let repo_path = if git_common_dir != git_dir { 71 | // We're in a worktree - git-common-dir points to main repo's .git 72 | let path = Path::new(&git_common_dir); 73 | if path.file_name().is_some_and(|n| n == ".git") { 74 | // Get the parent directory which is the main repo 75 | path.parent() 76 | .and_then(|p| p.to_str()) 77 | .map(|s| s.to_string()) 78 | .context("Failed to get main repository path")? 79 | } else { 80 | // git-common-dir doesn't end with .git, use it directly 81 | git_common_dir 82 | } 83 | } else { 84 | // Not in a worktree, use toplevel 85 | execute_git(&["rev-parse", "--show-toplevel"])? 86 | }; 87 | 88 | let path = Path::new(&repo_path); 89 | path.file_name() 90 | .and_then(|n| n.to_str()) 91 | .map(std::string::ToString::to_string) 92 | .context("Failed to get repository name") 93 | } 94 | 95 | pub fn get_current_branch() -> Result { 96 | execute_git(&["symbolic-ref", "--short", "HEAD"]) 97 | } 98 | 99 | pub fn get_default_branch() -> Result { 100 | // Try to get the default branch from remote HEAD 101 | if let Ok(output) = execute_git(&["remote", "show", "origin"]) { 102 | for line in output.lines() { 103 | if let Some(branch) = line.strip_prefix(" HEAD branch: ") { 104 | return Ok(branch.trim().to_string()); 105 | } 106 | } 107 | } 108 | 109 | // Fallback: try to get HEAD from symbolic-ref 110 | if let Ok(output) = execute_git(&["symbolic-ref", "refs/remotes/origin/HEAD"]) 111 | && let Some(branch) = output.strip_prefix("refs/remotes/origin/") 112 | { 113 | return Ok(branch.to_string()); 114 | } 115 | 116 | // Final fallback: return "main" as the most common default 117 | Ok("main".to_string()) 118 | } 119 | 120 | pub fn is_base_branch() -> Result { 121 | let current = get_current_branch()?; 122 | 123 | // Get the actual default branch from remote 124 | let default_branch = get_default_branch().unwrap_or_else(|_| "main".to_string()); 125 | 126 | // Check if current branch is the default branch 127 | if current == default_branch { 128 | return Ok(true); 129 | } 130 | 131 | // Also allow common base branches for flexibility 132 | let common_base_branches = ["main", "master", "develop"]; 133 | Ok(common_base_branches.contains(¤t.as_str())) 134 | } 135 | 136 | #[allow(dead_code)] 137 | pub fn branch_exists(branch_name: &str) -> Result { 138 | // Check if branch exists locally 139 | if execute_git(&[ 140 | "show-ref", 141 | "--verify", 142 | "--quiet", 143 | &format!("refs/heads/{}", branch_name), 144 | ]) 145 | .is_ok() 146 | { 147 | return Ok(true); 148 | } 149 | 150 | // Check if branch exists on remote 151 | if execute_git(&[ 152 | "show-ref", 153 | "--verify", 154 | "--quiet", 155 | &format!("refs/remotes/origin/{}", branch_name), 156 | ]) 157 | .is_ok() 158 | { 159 | return Ok(true); 160 | } 161 | 162 | Ok(false) 163 | } 164 | 165 | pub fn is_working_tree_clean() -> Result { 166 | let status = execute_git(&["status", "--porcelain"])?; 167 | Ok(status.is_empty()) 168 | } 169 | 170 | pub fn has_unpushed_commits() -> bool { 171 | execute_git(&["log", "@{u}.."]).is_ok_and(|output| !output.is_empty()) 172 | } 173 | 174 | pub fn is_in_worktree() -> Result { 175 | // Check if we're in a worktree by looking for .git file (not directory) 176 | let git_path = Path::new(".git"); 177 | if git_path.exists() && git_path.is_file() { 178 | return Ok(true); 179 | } 180 | 181 | // Alternative: check git worktree list 182 | match execute_git(&["rev-parse", "--git-common-dir"]) { 183 | Ok(common_dir) => { 184 | let current_git_dir = execute_git(&["rev-parse", "--git-dir"])?; 185 | if common_dir != current_git_dir { 186 | return Ok(true); 187 | } 188 | // Fallback: if inside a git work tree, treat as worktree context 189 | // Note: main repo will also return true here, but callers typically 190 | // combine with `!is_base_branch()` to exclude base branches. 191 | if let Ok(val) = execute_git(&["rev-parse", "--is-inside-work-tree"]) { 192 | return Ok(val.trim() == "true"); 193 | } 194 | Ok(false) 195 | } 196 | Err(_) => Ok(false), 197 | } 198 | } 199 | 200 | pub fn list_worktrees() -> Result> { 201 | let output = execute_git(&["worktree", "list", "--porcelain"])?; 202 | let mut worktrees = Vec::new(); 203 | 204 | for line in output.lines() { 205 | if let Some(path) = line.strip_prefix("worktree ") { 206 | worktrees.push(PathBuf::from(path)); 207 | } 208 | } 209 | 210 | Ok(worktrees) 211 | } 212 | 213 | pub fn update_submodules(worktree_path: &Path) -> Result<()> { 214 | // Check if submodules exist 215 | let gitmodules = worktree_path.join(".gitmodules"); 216 | if !gitmodules.exists() { 217 | return Ok(()); 218 | } 219 | 220 | // Initialize and update submodules using git -C 221 | execute_git(&[ 222 | "-C", 223 | worktree_path.to_str().unwrap(), 224 | "submodule", 225 | "update", 226 | "--init", 227 | "--recursive", 228 | ]) 229 | .context("Failed to update submodules")?; 230 | 231 | Ok(()) 232 | } 233 | 234 | #[cfg(test)] 235 | mod tests { 236 | use super::*; 237 | 238 | #[test] 239 | fn test_extract_repo_name_from_url() { 240 | // GitHub HTTPS 241 | assert_eq!( 242 | extract_repo_name_from_url("https://github.com/user/my-repo.git"), 243 | Some("my-repo".to_string()) 244 | ); 245 | 246 | // GitHub SSH 247 | assert_eq!( 248 | extract_repo_name_from_url("git@github.com:user/my-repo.git"), 249 | Some("my-repo".to_string()) 250 | ); 251 | 252 | // GitLab HTTPS without .git 253 | assert_eq!( 254 | extract_repo_name_from_url("https://gitlab.com/user/my-repo"), 255 | Some("my-repo".to_string()) 256 | ); 257 | 258 | // Local path 259 | assert_eq!( 260 | extract_repo_name_from_url("/path/to/repos/my-repo.git"), 261 | Some("my-repo".to_string()) 262 | ); 263 | 264 | // Complex repo name 265 | assert_eq!( 266 | extract_repo_name_from_url("git@github.com:xuanwo/xlaude-enable.git"), 267 | Some("xlaude-enable".to_string()) 268 | ); 269 | 270 | // Edge cases 271 | assert_eq!( 272 | extract_repo_name_from_url("https://github.com/user/repo-with-dots.v2.git"), 273 | Some("repo-with-dots.v2".to_string()) 274 | ); 275 | } 276 | 277 | #[test] 278 | fn test_get_default_branch() { 279 | // This test will work based on the actual git repository it's run in 280 | // We can't make strong assertions about the result since it depends on the repo 281 | let result = get_default_branch(); 282 | 283 | // Should either succeed with a non-empty string or fail gracefully 284 | match result { 285 | Ok(branch) => { 286 | assert!(!branch.is_empty()); 287 | // Common default branches 288 | assert!( 289 | ["main", "master", "develop"].contains(&branch.as_str()) || !branch.is_empty() 290 | ); 291 | } 292 | Err(_) => { 293 | // It's okay to fail if we're not in a git repo or no remote 294 | // The function should handle this gracefully 295 | } 296 | } 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /src/codex.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use chrono::{DateTime, Utc}; 3 | use serde_json::Value; 4 | use std::cmp::Ordering; 5 | use std::collections::{HashMap, HashSet}; 6 | use std::fs::File; 7 | use std::io::{BufRead, BufReader}; 8 | use std::path::{Path, PathBuf}; 9 | 10 | #[derive(Debug, Clone)] 11 | pub struct CodexSession { 12 | pub id: String, 13 | pub cwd: PathBuf, 14 | pub last_timestamp: Option>, 15 | pub last_user_message: Option, 16 | } 17 | 18 | fn sessions_root() -> Option { 19 | if let Ok(dir) = std::env::var("XLAUDE_CODEX_SESSIONS_DIR") { 20 | return Some(PathBuf::from(dir)); 21 | } 22 | 23 | let home = std::env::var("HOME").ok()?; 24 | let root = Path::new(&home).join(".codex").join("sessions"); 25 | Some(root) 26 | } 27 | 28 | fn normalized_path(path: &Path) -> PathBuf { 29 | path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) 30 | } 31 | 32 | pub fn normalized_worktree_path(path: &Path) -> PathBuf { 33 | normalized_path(path) 34 | } 35 | 36 | fn read_sorted_directories(path: &Path) -> Result> { 37 | let mut dirs = Vec::new(); 38 | 39 | if !path.exists() { 40 | return Ok(dirs); 41 | } 42 | 43 | for entry in path 44 | .read_dir() 45 | .with_context(|| format!("Failed to read Codex session directory: {}", path.display()))? 46 | { 47 | let entry = entry?; 48 | if entry.file_type()?.is_dir() { 49 | dirs.push(entry.path()); 50 | } 51 | } 52 | 53 | dirs.sort_by(|a, b| match b.file_name().cmp(&a.file_name()) { 54 | Ordering::Equal => b.cmp(a), 55 | other => other, 56 | }); 57 | 58 | Ok(dirs) 59 | } 60 | 61 | fn read_sorted_files(path: &Path) -> Result> { 62 | let mut files = Vec::new(); 63 | 64 | if !path.exists() { 65 | return Ok(files); 66 | } 67 | 68 | for entry in path 69 | .read_dir() 70 | .with_context(|| format!("Failed to read Codex session directory: {}", path.display()))? 71 | { 72 | let entry = entry?; 73 | if entry.file_type()?.is_file() { 74 | files.push(entry.path()); 75 | } 76 | } 77 | 78 | files.sort_by(|a, b| match b.file_name().cmp(&a.file_name()) { 79 | Ordering::Equal => b.cmp(a), 80 | other => other, 81 | }); 82 | 83 | Ok(files) 84 | } 85 | 86 | fn parse_session_file(path: &Path) -> Result> { 87 | let file = File::open(path) 88 | .with_context(|| format!("Failed to open Codex session file: {}", path.display()))?; 89 | 90 | let reader = BufReader::new(file); 91 | let mut lines = reader.lines().map_while(Result::ok); 92 | 93 | let first_line = match lines.next() { 94 | Some(line) => line, 95 | None => return Ok(None), 96 | }; 97 | 98 | let meta = serde_json::from_str::(&first_line) 99 | .with_context(|| format!("Failed to parse session meta in {}", path.display()))?; 100 | 101 | if meta.get("type").and_then(|t| t.as_str()) != Some("session_meta") { 102 | return Ok(None); 103 | } 104 | 105 | let payload = meta 106 | .get("payload") 107 | .and_then(|p| p.as_object()) 108 | .ok_or_else(|| anyhow::anyhow!("Session payload is missing in {}", path.display()))?; 109 | 110 | let id = payload 111 | .get("id") 112 | .and_then(|v| v.as_str()) 113 | .ok_or_else(|| anyhow::anyhow!("Session id missing in {}", path.display()))? 114 | .to_string(); 115 | 116 | let cwd_str = payload 117 | .get("cwd") 118 | .and_then(|v| v.as_str()) 119 | .unwrap_or_default(); 120 | let cwd = PathBuf::from(cwd_str); 121 | 122 | let start_timestamp = payload 123 | .get("timestamp") 124 | .and_then(|v| v.as_str()) 125 | .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok()) 126 | .map(|dt| dt.with_timezone(&Utc)); 127 | 128 | let mut last_user_message = None; 129 | let mut last_timestamp = start_timestamp; 130 | 131 | for line in lines { 132 | let Ok(value) = serde_json::from_str::(&line) else { 133 | continue; 134 | }; 135 | 136 | if value.get("type").and_then(|t| t.as_str()) != Some("response_item") { 137 | continue; 138 | } 139 | 140 | let Some(payload) = value.get("payload").and_then(|p| p.as_object()) else { 141 | continue; 142 | }; 143 | 144 | let role = payload 145 | .get("role") 146 | .and_then(|r| r.as_str()) 147 | .unwrap_or_default(); 148 | let kind = payload 149 | .get("type") 150 | .and_then(|k| k.as_str()) 151 | .unwrap_or_default(); 152 | if role != "user" || kind != "message" { 153 | continue; 154 | } 155 | 156 | let message_timestamp = value 157 | .get("timestamp") 158 | .and_then(|v| v.as_str()) 159 | .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok()) 160 | .map(|dt| dt.with_timezone(&Utc)); 161 | 162 | if let Some(ts) = message_timestamp 163 | && last_timestamp.is_none_or(|current| ts > current) 164 | { 165 | last_timestamp = Some(ts); 166 | } 167 | 168 | if let Some(msg) = extract_user_message(payload) 169 | && !msg.trim().is_empty() 170 | { 171 | last_user_message = Some(msg); 172 | } 173 | } 174 | 175 | Ok(Some(CodexSession { 176 | id, 177 | cwd, 178 | last_timestamp, 179 | last_user_message, 180 | })) 181 | } 182 | 183 | fn extract_user_message(payload: &serde_json::Map) -> Option { 184 | let content = payload.get("content")?; 185 | 186 | if let Some(text) = content.as_array() { 187 | let mut segments = Vec::new(); 188 | for node in text { 189 | if let Some(item) = node.as_object() { 190 | if let Some(text) = item.get("text").and_then(|t| t.as_str()) { 191 | segments.push(text); 192 | } else if let Some(inner) = item.get("content").and_then(|c| c.as_str()) { 193 | segments.push(inner); 194 | } 195 | } 196 | } 197 | if segments.is_empty() { 198 | None 199 | } else { 200 | Some(segments.join("\n")) 201 | } 202 | } else { 203 | content.as_str().map(|s| s.to_string()) 204 | } 205 | } 206 | 207 | fn iterate_session_files(descending: bool) -> Result> { 208 | let Some(root) = sessions_root() else { 209 | return Ok(Vec::new()); 210 | }; 211 | 212 | if !root.exists() { 213 | return Ok(Vec::new()); 214 | } 215 | 216 | let mut result = Vec::new(); 217 | 218 | let mut years = read_sorted_directories(&root)?; 219 | if !descending { 220 | years.reverse(); 221 | } 222 | 223 | for year in years { 224 | let mut months = read_sorted_directories(&year)?; 225 | if !descending { 226 | months.reverse(); 227 | } 228 | for month in months { 229 | let mut days = read_sorted_directories(&month)?; 230 | if !descending { 231 | days.reverse(); 232 | } 233 | for day in days { 234 | let mut files = read_sorted_files(&day)?; 235 | if !descending { 236 | files.reverse(); 237 | } 238 | result.extend(files); 239 | } 240 | } 241 | } 242 | 243 | Ok(result) 244 | } 245 | 246 | fn matches_worktree(session_path: &Path, target_canonical: &Path, fallback: &Path) -> bool { 247 | session_path 248 | .canonicalize() 249 | .map(|canonical| canonical == target_canonical) 250 | .unwrap_or(false) 251 | || session_path == fallback 252 | } 253 | 254 | pub fn find_latest_session(worktree_path: &Path) -> Result> { 255 | let files = iterate_session_files(true)?; 256 | if files.is_empty() { 257 | return Ok(None); 258 | } 259 | 260 | let target_canonical = normalized_path(worktree_path); 261 | 262 | for file in files { 263 | let Some(session) = parse_session_file(&file)? else { 264 | continue; 265 | }; 266 | 267 | if matches_worktree(&session.cwd, &target_canonical, worktree_path) { 268 | return Ok(Some(session)); 269 | } 270 | } 271 | 272 | Ok(None) 273 | } 274 | 275 | pub fn recent_sessions(worktree_path: &Path, limit: usize) -> Result<(Vec, usize)> { 276 | let files = iterate_session_files(true)?; 277 | if files.is_empty() { 278 | return Ok((Vec::new(), 0)); 279 | } 280 | 281 | let target_canonical = normalized_path(worktree_path); 282 | let mut sessions = Vec::new(); 283 | let mut total = 0usize; 284 | 285 | for file in files { 286 | let Some(session) = parse_session_file(&file)? else { 287 | continue; 288 | }; 289 | 290 | if !matches_worktree(&session.cwd, &target_canonical, worktree_path) { 291 | continue; 292 | } 293 | 294 | total += 1; 295 | if limit != 0 && sessions.len() < limit { 296 | sessions.push(session); 297 | } 298 | } 299 | 300 | Ok((sessions, total)) 301 | } 302 | 303 | pub fn collect_recent_sessions_for_paths( 304 | worktree_paths: &[PathBuf], 305 | limit: usize, 306 | ) -> Result>> { 307 | if worktree_paths.is_empty() || limit == 0 { 308 | return Ok(HashMap::new()); 309 | } 310 | 311 | let files = iterate_session_files(true)?; 312 | if files.is_empty() { 313 | return Ok(HashMap::new()); 314 | } 315 | 316 | let mut targets: HashSet = HashSet::new(); 317 | for path in worktree_paths { 318 | targets.insert(normalized_path(path)); 319 | } 320 | 321 | let mut satisfied: HashSet = HashSet::new(); 322 | let mut map: HashMap> = HashMap::new(); 323 | 324 | for file in files { 325 | if satisfied.len() == targets.len() { 326 | break; 327 | } 328 | 329 | let Some(session) = parse_session_file(&file)? else { 330 | continue; 331 | }; 332 | 333 | let normalized = normalized_path(&session.cwd); 334 | if !targets.contains(&normalized) { 335 | continue; 336 | } 337 | 338 | let entry = map.entry(normalized.clone()).or_default(); 339 | if entry.len() >= limit { 340 | satisfied.insert(normalized); 341 | continue; 342 | } 343 | 344 | entry.push(session); 345 | if entry.len() == limit { 346 | satisfied.insert(normalized); 347 | } 348 | } 349 | 350 | Ok(map) 351 | } 352 | -------------------------------------------------------------------------------- /src/commands/create.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use chrono::Utc; 3 | use colored::Colorize; 4 | use std::fs; 5 | use std::path::PathBuf; 6 | 7 | use crate::commands::open::handle_open; 8 | use crate::git::{ 9 | execute_git, extract_repo_name_from_url, get_repo_name, list_worktrees, update_submodules, 10 | }; 11 | use crate::input::{get_command_arg, smart_confirm}; 12 | use crate::state::{WorktreeInfo, XlaudeState}; 13 | use crate::utils::{generate_random_name, sanitize_branch_name}; 14 | 15 | pub fn handle_create(name: Option) -> Result<()> { 16 | handle_create_in_dir(name, None) 17 | } 18 | 19 | pub fn handle_create_in_dir(name: Option, repo_path: Option) -> Result<()> { 20 | handle_create_in_dir_quiet(name, repo_path, false)?; 21 | Ok(()) 22 | } 23 | 24 | // Create worktree quietly without prompting for open, returns the created worktree name 25 | pub fn handle_create_in_dir_quiet( 26 | name: Option, 27 | repo_path: Option, 28 | quiet: bool, 29 | ) -> Result { 30 | // Helper to execute git in the right directory using git -C 31 | let exec_git = |args: &[&str]| -> Result { 32 | if let Some(ref path) = repo_path { 33 | // Use git -C to execute in specified directory 34 | let mut full_args = vec!["-C", path.to_str().unwrap()]; 35 | full_args.extend_from_slice(args); 36 | execute_git(&full_args) 37 | } else { 38 | execute_git(args) 39 | } 40 | }; 41 | 42 | // Get repo name from the target directory 43 | let repo_name = if let Some(ref path) = repo_path { 44 | // Get repo name from the specified path using git -C 45 | let output = execute_git(&["-C", path.to_str().unwrap(), "remote", "get-url", "origin"])?; 46 | if let Some(name) = extract_repo_name_from_url(&output) { 47 | name 48 | } else { 49 | // Fallback to directory name 50 | path.file_name() 51 | .and_then(|n| n.to_str()) 52 | .map(String::from) 53 | .context("Failed to get repository name")? 54 | } 55 | } else { 56 | get_repo_name().context("Not in a git repository")? 57 | }; 58 | 59 | // Only check base branch if no repo_path is provided (i.e., running from CLI in current directory) 60 | // Clients that pass repo_path are expected to enforce their own branch safety checks 61 | if repo_path.is_none() { 62 | let current_branch = exec_git(&["branch", "--show-current"])?; 63 | let default_branch = exec_git(&["symbolic-ref", "refs/remotes/origin/HEAD"]) 64 | .ok() 65 | .and_then(|s| s.strip_prefix("refs/remotes/origin/").map(String::from)) 66 | .unwrap_or_else(|| "main".to_string()); 67 | 68 | let base_branches = ["main", "master", "develop", &default_branch]; 69 | if !base_branches.contains(¤t_branch.as_str()) { 70 | anyhow::bail!( 71 | "Must be on a base branch (main, master, or develop) to create a new worktree. Current branch: {}", 72 | current_branch 73 | ); 74 | } 75 | } 76 | 77 | // Get name from CLI args or pipe, generate if not provided 78 | let branch_name = match get_command_arg(name)? { 79 | Some(n) => n, 80 | None => generate_random_name()?, 81 | }; 82 | 83 | // Sanitize the branch name for use in directory names 84 | let worktree_name = sanitize_branch_name(&branch_name); 85 | 86 | // Check if a worktree with this name already exists in xlaude state 87 | let state = XlaudeState::load()?; 88 | let key = XlaudeState::make_key(&repo_name, &worktree_name); 89 | if state.worktrees.contains_key(&key) { 90 | anyhow::bail!( 91 | "A worktree named '{}' already exists for repository '{}' (tracked by xlaude). Please choose a different name.", 92 | worktree_name, 93 | repo_name 94 | ); 95 | } 96 | 97 | // Check if the worktree directory will be created 98 | let worktree_dir_path = if let Some(ref path) = repo_path { 99 | path.parent() 100 | .unwrap() 101 | .join(format!("{repo_name}-{worktree_name}")) 102 | } else { 103 | std::env::current_dir()? 104 | .parent() 105 | .unwrap() 106 | .join(format!("{repo_name}-{worktree_name}")) 107 | }; 108 | 109 | // Check if the directory already exists 110 | if worktree_dir_path.exists() { 111 | anyhow::bail!( 112 | "Directory '{}' already exists. Please choose a different name or remove the existing directory.", 113 | worktree_dir_path.display() 114 | ); 115 | } 116 | 117 | // Check if a git worktree already exists at this path 118 | // Need to run git worktree list in the correct directory 119 | let existing_worktrees = if let Some(ref path) = repo_path { 120 | // Parse git worktree list output from the specified directory 121 | let output = execute_git(&[ 122 | "-C", 123 | path.to_str().unwrap(), 124 | "worktree", 125 | "list", 126 | "--porcelain", 127 | ])?; 128 | let mut worktrees = Vec::new(); 129 | for line in output.lines() { 130 | if let Some(worktree_path) = line.strip_prefix("worktree ") { 131 | worktrees.push(PathBuf::from(worktree_path)); 132 | } 133 | } 134 | worktrees 135 | } else { 136 | list_worktrees()? 137 | }; 138 | 139 | if existing_worktrees.iter().any(|w| w == &worktree_dir_path) { 140 | anyhow::bail!( 141 | "A git worktree already exists at '{}'. Please choose a different name or remove the existing worktree.", 142 | worktree_dir_path.display() 143 | ); 144 | } 145 | 146 | // Check if the branch already exists 147 | let branch_already_exists = exec_git(&[ 148 | "show-ref", 149 | "--verify", 150 | &format!("refs/heads/{}", branch_name), 151 | ]) 152 | .is_ok(); 153 | 154 | if branch_already_exists { 155 | if !quiet { 156 | println!( 157 | "{} Creating worktree '{}' from existing branch '{}'...", 158 | "✨".green(), 159 | worktree_name.cyan(), 160 | branch_name.cyan() 161 | ); 162 | } 163 | } else { 164 | if !quiet { 165 | println!( 166 | "{} Creating worktree '{}' with new branch '{}'...", 167 | "✨".green(), 168 | worktree_name.cyan(), 169 | branch_name.cyan() 170 | ); 171 | } 172 | 173 | // When repo_path is provided, create branch from the default branch 174 | // Otherwise create from current branch 175 | if repo_path.is_some() { 176 | // Get the default branch 177 | let default_branch = exec_git(&["symbolic-ref", "refs/remotes/origin/HEAD"]) 178 | .ok() 179 | .and_then(|s| s.strip_prefix("refs/remotes/origin/").map(String::from)) 180 | .unwrap_or_else(|| "main".to_string()); 181 | 182 | // Create branch from the default branch 183 | exec_git(&[ 184 | "branch", 185 | &branch_name, 186 | &format!("origin/{}", default_branch), 187 | ]) 188 | .context("Failed to create branch from default branch")?; 189 | } else { 190 | // Create branch from current branch (original behavior for CLI) 191 | exec_git(&["branch", &branch_name]).context("Failed to create branch")?; 192 | } 193 | } 194 | 195 | // Create worktree with sanitized directory name 196 | let worktree_dir = format!("../{repo_name}-{worktree_name}"); 197 | exec_git(&["worktree", "add", &worktree_dir, &branch_name]) 198 | .context("Failed to create worktree")?; 199 | 200 | // Get absolute path 201 | let worktree_path = if let Some(ref path) = repo_path { 202 | path.parent() 203 | .unwrap() 204 | .join(format!("{repo_name}-{worktree_name}")) 205 | } else { 206 | std::env::current_dir()? 207 | .parent() 208 | .unwrap() 209 | .join(format!("{repo_name}-{worktree_name}")) 210 | }; 211 | 212 | // Update submodules if they exist 213 | if let Err(e) = update_submodules(&worktree_path) { 214 | if !quiet { 215 | println!( 216 | "{} Warning: Failed to update submodules: {}", 217 | "⚠️".yellow(), 218 | e 219 | ); 220 | } 221 | } else { 222 | // Check if submodules were actually updated 223 | let gitmodules = worktree_path.join(".gitmodules"); 224 | if gitmodules.exists() && !quiet { 225 | println!("{} Updated submodules", "📦".green()); 226 | } 227 | } 228 | 229 | // Copy CLAUDE.local.md if it exists 230 | let claude_local_md = if let Some(ref path) = repo_path { 231 | path.join("CLAUDE.local.md") 232 | } else { 233 | PathBuf::from("CLAUDE.local.md") 234 | }; 235 | if claude_local_md.exists() { 236 | let target_path = worktree_path.join("CLAUDE.local.md"); 237 | fs::copy(claude_local_md, &target_path).context("Failed to copy CLAUDE.local.md")?; 238 | if !quiet { 239 | println!("{} Copied CLAUDE.local.md to worktree", "📄".green()); 240 | } 241 | } 242 | 243 | // Save state 244 | let mut state = XlaudeState::load()?; 245 | let key = XlaudeState::make_key(&repo_name, &worktree_name); 246 | state.worktrees.insert( 247 | key, 248 | WorktreeInfo { 249 | name: worktree_name.clone(), 250 | branch: branch_name.clone(), 251 | path: worktree_path.clone(), 252 | repo_name, 253 | created_at: Utc::now(), 254 | }, 255 | ); 256 | state.save()?; 257 | 258 | if !quiet { 259 | println!( 260 | "{} Worktree created at: {}", 261 | "✅".green(), 262 | worktree_path.display() 263 | ); 264 | } 265 | 266 | // Ask if user wants to open the worktree (skip in quiet mode) 267 | if !quiet { 268 | // Skip opening in test mode or when explicitly disabled 269 | let should_open = if std::env::var("XLAUDE_TEST_MODE").is_ok() 270 | || std::env::var("XLAUDE_NO_AUTO_OPEN").is_ok() 271 | { 272 | println!( 273 | " {} To open it, run: {} {}", 274 | "💡".cyan(), 275 | "xlaude open".cyan(), 276 | worktree_name.cyan() 277 | ); 278 | false 279 | } else { 280 | smart_confirm("Would you like to open the worktree now?", true)? 281 | }; 282 | 283 | if should_open { 284 | handle_open(Some(worktree_name.clone()))?; 285 | } else if std::env::var("XLAUDE_NON_INTERACTIVE").is_err() { 286 | println!( 287 | " {} To open it later, run: {} {}", 288 | "💡".cyan(), 289 | "xlaude open".cyan(), 290 | worktree_name.cyan() 291 | ); 292 | } 293 | } 294 | 295 | Ok(worktree_name) 296 | } 297 | -------------------------------------------------------------------------------- /src/commands/delete.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use colored::Colorize; 3 | 4 | use crate::git::{execute_git, has_unpushed_commits, is_working_tree_clean}; 5 | use crate::input::{get_command_arg, smart_confirm}; 6 | use crate::state::{WorktreeInfo, XlaudeState}; 7 | use crate::utils::execute_in_dir; 8 | 9 | /// Represents the result of various checks performed before deletion 10 | struct DeletionChecks { 11 | has_uncommitted_changes: bool, 12 | has_unpushed_commits: bool, 13 | branch_merged_via_git: bool, 14 | branch_merged_via_pr: bool, 15 | } 16 | 17 | impl DeletionChecks { 18 | fn branch_is_merged(&self) -> bool { 19 | self.branch_merged_via_git || self.branch_merged_via_pr 20 | } 21 | 22 | fn has_pending_work(&self) -> bool { 23 | self.has_uncommitted_changes || self.has_unpushed_commits 24 | } 25 | } 26 | 27 | /// Configuration for deletion behavior 28 | struct DeletionConfig { 29 | is_interactive: bool, 30 | worktree_exists: bool, 31 | is_current_directory: bool, 32 | } 33 | 34 | impl DeletionConfig { 35 | fn from_env(worktree_info: &WorktreeInfo) -> Result { 36 | let current_dir = std::env::current_dir()?; 37 | 38 | Ok(Self { 39 | is_interactive: std::env::var("XLAUDE_NON_INTERACTIVE").is_err(), 40 | worktree_exists: worktree_info.path.exists(), 41 | is_current_directory: current_dir == worktree_info.path, 42 | }) 43 | } 44 | } 45 | 46 | pub fn handle_delete(name: Option) -> Result<()> { 47 | let mut state = XlaudeState::load()?; 48 | 49 | // Get name from CLI args or pipe 50 | let target_name = get_command_arg(name)?; 51 | let (key, worktree_info) = find_worktree_to_delete(&state, target_name)?; 52 | let config = DeletionConfig::from_env(&worktree_info)?; 53 | 54 | println!( 55 | "{} Checking worktree '{}'...", 56 | "🔍".yellow(), 57 | worktree_info.name.cyan() 58 | ); 59 | 60 | // Handle case where worktree directory doesn't exist 61 | if !config.worktree_exists { 62 | if !handle_missing_worktree(&worktree_info, &config)? { 63 | println!("{} Cancelled", "❌".red()); 64 | return Ok(()); 65 | } 66 | } else { 67 | // Check branch status first (for output consistency) 68 | println!( 69 | "{} Checking branch '{}'...", 70 | "🔍".yellow(), 71 | worktree_info.branch 72 | ); 73 | 74 | // Perform deletion checks 75 | let checks = perform_deletion_checks(&worktree_info)?; 76 | 77 | if !confirm_deletion(&worktree_info, &checks, &config)? { 78 | println!("{} Cancelled", "❌".red()); 79 | return Ok(()); 80 | } 81 | } 82 | 83 | // Execute deletion 84 | perform_deletion(&worktree_info, &config)?; 85 | 86 | // Update state 87 | state.worktrees.remove(&key); 88 | state.save()?; 89 | 90 | println!( 91 | "{} Worktree '{}' deleted successfully", 92 | "✅".green(), 93 | worktree_info.name.cyan() 94 | ); 95 | Ok(()) 96 | } 97 | 98 | /// Find the worktree to delete based on the provided name or current directory 99 | fn find_worktree_to_delete( 100 | state: &XlaudeState, 101 | name: Option, 102 | ) -> Result<(String, WorktreeInfo)> { 103 | if let Some(n) = name { 104 | // Find worktree by name across all projects 105 | state 106 | .worktrees 107 | .iter() 108 | .find(|(_, w)| w.name == n) 109 | .map(|(k, w)| (k.clone(), w.clone())) 110 | .context(format!("Worktree '{n}' not found")) 111 | } else { 112 | // Find worktree by current directory 113 | find_current_worktree(state) 114 | } 115 | } 116 | 117 | /// Find the worktree that matches the current directory 118 | fn find_current_worktree(state: &XlaudeState) -> Result<(String, WorktreeInfo)> { 119 | let current_dir = std::env::current_dir()?; 120 | let dir_name = current_dir 121 | .file_name() 122 | .and_then(|n| n.to_str()) 123 | .context("Failed to get current directory name")?; 124 | 125 | state 126 | .worktrees 127 | .iter() 128 | .find(|(_, w)| w.path.file_name().and_then(|n| n.to_str()) == Some(dir_name)) 129 | .map(|(k, w)| (k.clone(), w.clone())) 130 | .context("Current directory is not a managed worktree") 131 | } 132 | 133 | /// Handle the case where worktree directory doesn't exist 134 | fn handle_missing_worktree(worktree_info: &WorktreeInfo, _config: &DeletionConfig) -> Result { 135 | println!( 136 | "{} Worktree directory not found at {}", 137 | "⚠️ ".yellow(), 138 | worktree_info.path.display() 139 | ); 140 | println!( 141 | " {} The worktree may have been manually deleted", 142 | "ℹ️".blue() 143 | ); 144 | 145 | smart_confirm("Remove this worktree from xlaude management?", true) 146 | } 147 | 148 | /// Perform all checks needed before deletion 149 | fn perform_deletion_checks(worktree_info: &WorktreeInfo) -> Result { 150 | execute_in_dir(&worktree_info.path, || { 151 | let has_uncommitted_changes = !is_working_tree_clean()?; 152 | let has_unpushed_commits = has_unpushed_commits(); 153 | 154 | // Check branch merge status in main repo 155 | let main_repo_path = get_main_repo_path(worktree_info)?; 156 | let (branch_merged_via_git, branch_merged_via_pr) = 157 | check_branch_merge_status(&main_repo_path, &worktree_info.branch)?; 158 | 159 | Ok(DeletionChecks { 160 | has_uncommitted_changes, 161 | has_unpushed_commits, 162 | branch_merged_via_git, 163 | branch_merged_via_pr, 164 | }) 165 | }) 166 | } 167 | 168 | /// Check if branch is merged via git or PR 169 | fn check_branch_merge_status( 170 | main_repo_path: &std::path::Path, 171 | branch: &str, 172 | ) -> Result<(bool, bool)> { 173 | execute_in_dir(main_repo_path, || { 174 | // Check traditional git merge 175 | let output = std::process::Command::new("git") 176 | .args(["branch", "--merged"]) 177 | .output() 178 | .context("Failed to check merged branches")?; 179 | 180 | let merged_branches = String::from_utf8_lossy(&output.stdout); 181 | let is_merged_git = merged_branches 182 | .lines() 183 | .any(|line| line.trim().trim_start_matches('*').trim() == branch); 184 | 185 | // Check if merged via PR (works for squash merge) 186 | let is_merged_pr = check_branch_merged_via_pr(branch); 187 | 188 | Ok((is_merged_git, is_merged_pr)) 189 | }) 190 | } 191 | 192 | /// Check if branch was merged via GitHub PR 193 | fn check_branch_merged_via_pr(branch: &str) -> bool { 194 | std::process::Command::new("gh") 195 | .args([ 196 | "pr", "list", "--state", "merged", "--head", branch, "--json", "number", 197 | ]) 198 | .output() 199 | .ok() 200 | .filter(|output| output.status.success()) 201 | .and_then(|output| String::from_utf8(output.stdout).ok()) 202 | .and_then(|json| serde_json::from_str::>(&json).ok()) 203 | .map(|prs| !prs.is_empty()) 204 | .unwrap_or(false) 205 | } 206 | 207 | /// Confirm deletion with the user based on checks 208 | fn confirm_deletion( 209 | worktree_info: &WorktreeInfo, 210 | checks: &DeletionChecks, 211 | _config: &DeletionConfig, 212 | ) -> Result { 213 | // Show warnings for pending work 214 | if checks.has_pending_work() { 215 | show_pending_work_warnings(checks); 216 | 217 | return smart_confirm("Are you sure you want to delete this worktree?", false); 218 | } 219 | 220 | // Show branch merge status 221 | if !checks.branch_is_merged() { 222 | show_unmerged_branch_warning(worktree_info); 223 | } else if checks.branch_merged_via_pr && !checks.branch_merged_via_git { 224 | println!(" {} Branch was merged via PR", "ℹ️".blue()); 225 | } 226 | 227 | // Ask for confirmation 228 | smart_confirm(&format!("Delete worktree '{}'?", worktree_info.name), true) 229 | } 230 | 231 | /// Show warnings for uncommitted changes or unpushed commits 232 | fn show_pending_work_warnings(checks: &DeletionChecks) { 233 | println!(); 234 | if checks.has_uncommitted_changes { 235 | println!("{} You have uncommitted changes", "⚠️ ".red()); 236 | } 237 | if checks.has_unpushed_commits { 238 | println!("{} You have unpushed commits", "⚠️ ".red()); 239 | } 240 | } 241 | 242 | /// Show warning for unmerged branch 243 | fn show_unmerged_branch_warning(worktree_info: &WorktreeInfo) { 244 | println!( 245 | "{} Branch '{}' is not fully merged", 246 | "⚠️ ".yellow(), 247 | worktree_info.branch.cyan() 248 | ); 249 | println!(" {} No merged PR found for this branch", "ℹ️".blue()); 250 | } 251 | 252 | /// Perform the actual deletion of worktree and branch 253 | fn perform_deletion(worktree_info: &WorktreeInfo, config: &DeletionConfig) -> Result<()> { 254 | let main_repo_path = get_main_repo_path(worktree_info)?; 255 | 256 | // Change to main repo if we're deleting current directory 257 | if config.is_current_directory { 258 | std::env::set_current_dir(&main_repo_path) 259 | .context("Failed to change to main repository")?; 260 | } 261 | 262 | execute_in_dir(&main_repo_path, || { 263 | // Remove or prune worktree 264 | remove_worktree(worktree_info, config)?; 265 | 266 | // Delete branch 267 | delete_branch(worktree_info, config)?; 268 | 269 | Ok(()) 270 | }) 271 | } 272 | 273 | /// Remove the worktree from git 274 | fn remove_worktree(worktree_info: &WorktreeInfo, config: &DeletionConfig) -> Result<()> { 275 | if config.worktree_exists { 276 | println!("{} Removing worktree...", "🗑️ ".yellow()); 277 | 278 | // First attempt: try normal removal 279 | let result = execute_git(&["worktree", "remove", worktree_info.path.to_str().unwrap()]); 280 | 281 | // If failed, might be due to submodules - try with force flag 282 | if result.is_err() { 283 | println!( 284 | "{} Standard removal failed, trying force removal...", 285 | "⚠️ ".yellow() 286 | ); 287 | execute_git(&[ 288 | "worktree", 289 | "remove", 290 | "--force", 291 | worktree_info.path.to_str().unwrap(), 292 | ]) 293 | .context("Failed to force remove worktree")?; 294 | } 295 | } else { 296 | println!("{} Pruning non-existent worktree...", "🗑️ ".yellow()); 297 | execute_git(&["worktree", "prune"]).context("Failed to prune worktree")?; 298 | } 299 | Ok(()) 300 | } 301 | 302 | /// Delete the branch from git 303 | fn delete_branch(worktree_info: &WorktreeInfo, config: &DeletionConfig) -> Result<()> { 304 | println!( 305 | "{} Deleting branch '{}'...", 306 | "🗑️ ".yellow(), 307 | worktree_info.branch 308 | ); 309 | 310 | // First try safe delete 311 | if execute_git(&["branch", "-d", &worktree_info.branch]).is_ok() { 312 | println!("{} Branch deleted", "✅".green()); 313 | return Ok(()); 314 | } 315 | 316 | // Branch is not fully merged, ask for force delete 317 | if !config.is_interactive { 318 | println!("{} Branch kept (not fully merged)", "ℹ️ ".blue()); 319 | return Ok(()); 320 | } 321 | 322 | let force_delete = smart_confirm("Branch is not fully merged. Force delete?", false)?; 323 | 324 | if force_delete { 325 | execute_git(&["branch", "-D", &worktree_info.branch]) 326 | .context("Failed to force delete branch")?; 327 | println!("{} Branch force deleted", "✅".green()); 328 | } else { 329 | println!("{} Branch kept", "ℹ️ ".blue()); 330 | } 331 | 332 | Ok(()) 333 | } 334 | 335 | /// Get the path to the main repository from worktree info 336 | fn get_main_repo_path(worktree_info: &WorktreeInfo) -> Result { 337 | let parent = worktree_info 338 | .path 339 | .parent() 340 | .context("Failed to get parent directory")?; 341 | 342 | Ok(parent.join(&worktree_info.repo_name)) 343 | } 344 | -------------------------------------------------------------------------------- /src/dashboard.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | use std::collections::HashMap; 3 | use std::io::{Read, Write}; 4 | use std::net::SocketAddr; 5 | use std::path::{Path, PathBuf}; 6 | use std::process::{Command as StdCommand, Stdio}; 7 | use std::sync::Arc; 8 | use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering}; 9 | use std::time::Duration; 10 | 11 | use anyhow::{Context, Result, anyhow}; 12 | use axum::extract::{ 13 | Path as AxumPath, State, 14 | ws::{Message, WebSocket, WebSocketUpgrade}, 15 | }; 16 | use axum::http::StatusCode; 17 | use axum::response::{Html, IntoResponse}; 18 | use axum::routing::{get, post}; 19 | use axum::{Json, Router}; 20 | use chrono::{DateTime, Utc}; 21 | use futures_util::{SinkExt, StreamExt}; 22 | use once_cell::sync::Lazy; 23 | use portable_pty::{CommandBuilder, PtySize, native_pty_system}; 24 | use serde::{Deserialize, Serialize}; 25 | use serde_json::json; 26 | use tokio::signal; 27 | use tokio::sync::{Mutex, RwLock, broadcast}; 28 | use uuid::Uuid; 29 | 30 | use shell_words::split as shell_split; 31 | 32 | use crate::claude; 33 | use crate::codex; 34 | use crate::codex::CodexSession; 35 | use crate::state::{WorktreeInfo, XlaudeState}; 36 | use crate::utils::prepare_agent_command; 37 | 38 | const STATIC_INDEX: &str = include_str!("../dashboard/static/index.html"); 39 | const DEFAULT_ADDR: &str = "127.0.0.1:5710"; 40 | const DEFAULT_SESSION_LIMIT: usize = 5; 41 | const SESSION_RETENTION_SECS: u64 = 300; 42 | const PTY_ROWS: u16 = 40; 43 | const PTY_COLS: u16 = 120; 44 | const CURSOR_POSITION_QUERY: &[u8] = b"\x1b[6n"; 45 | 46 | #[derive(Clone)] 47 | pub struct DashboardConfig { 48 | session_limit: usize, 49 | } 50 | 51 | impl Default for DashboardConfig { 52 | fn default() -> Self { 53 | Self { 54 | session_limit: DEFAULT_SESSION_LIMIT, 55 | } 56 | } 57 | } 58 | 59 | pub fn run_dashboard(address: Option, auto_open: bool) -> Result<()> { 60 | let addr: SocketAddr = address 61 | .unwrap_or_else(|| DEFAULT_ADDR.to_string()) 62 | .parse() 63 | .context("Invalid bind address for dashboard")?; 64 | 65 | let config = DashboardConfig::default(); 66 | let runtime = tokio::runtime::Runtime::new().context("Failed to start async runtime")?; 67 | runtime.block_on(async move { start_server(addr, config, auto_open).await }) 68 | } 69 | 70 | async fn start_server(addr: SocketAddr, config: DashboardConfig, auto_open: bool) -> Result<()> { 71 | let app = Router::new() 72 | .route("/", get(serve_index)) 73 | .route("/api/worktrees", get(api_worktrees)) 74 | .route( 75 | "/api/worktrees/:repo/:name/actions", 76 | post(api_worktree_action), 77 | ) 78 | .route( 79 | "/api/worktrees/:repo/:name/live-session", 80 | post(api_resume_session), 81 | ) 82 | .route("/api/sessions/:id/logs", get(api_get_session_logs)) 83 | .route("/api/sessions/:id/send", post(api_send_session_message)) 84 | .route("/api/sessions/:id/stream", get(api_stream_session)) 85 | .route( 86 | "/api/settings", 87 | get(api_get_settings).post(api_update_settings), 88 | ) 89 | .with_state(config); 90 | 91 | let listener = tokio::net::TcpListener::bind(addr) 92 | .await 93 | .context("Failed to bind dashboard listener")?; 94 | let actual_addr = listener 95 | .local_addr() 96 | .context("Failed to read listener address")?; 97 | 98 | println!("🚀 xlaude dashboard available at http://{actual_addr} (press Ctrl+C to stop)"); 99 | 100 | if auto_open { 101 | let url = format!("http://{actual_addr}"); 102 | if let Err(err) = webbrowser::open(&url) { 103 | eprintln!("⚠️ Unable to open browser automatically: {err}"); 104 | } 105 | } 106 | 107 | axum::serve(listener, app) 108 | .with_graceful_shutdown(shutdown_signal()) 109 | .await 110 | .context("Dashboard server exited unexpectedly")?; 111 | 112 | Ok(()) 113 | } 114 | 115 | async fn shutdown_signal() { 116 | let _ = signal::ctrl_c().await; 117 | println!("👋 Stopping dashboard"); 118 | } 119 | 120 | async fn serve_index() -> Html<&'static str> { 121 | Html(STATIC_INDEX) 122 | } 123 | 124 | async fn api_worktrees(State(config): State) -> impl IntoResponse { 125 | let limit = config.session_limit; 126 | match tokio::task::spawn_blocking(move || build_dashboard_payload(limit)).await { 127 | Ok(Ok(payload)) => Json(payload).into_response(), 128 | Ok(Err(err)) => { 129 | eprintln!("[dashboard] failed to gather worktree info: {err:?}"); 130 | (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() 131 | } 132 | Err(err) => { 133 | eprintln!("[dashboard] worker thread panicked: {err:?}"); 134 | ( 135 | StatusCode::INTERNAL_SERVER_ERROR, 136 | "dashboard worker panicked".to_string(), 137 | ) 138 | .into_response() 139 | } 140 | } 141 | } 142 | 143 | async fn api_worktree_action( 144 | AxumPath((repo, name)): AxumPath<(String, String)>, 145 | Json(req): Json, 146 | ) -> impl IntoResponse { 147 | match handle_worktree_action(&repo, &name, req.action.as_str()) { 148 | Ok(response) => Json(response).into_response(), 149 | Err((status, message)) => (status, message).into_response(), 150 | } 151 | } 152 | 153 | async fn api_resume_session( 154 | AxumPath((repo, name)): AxumPath<(String, String)>, 155 | ) -> impl IntoResponse { 156 | match start_live_session(&repo, &name).await { 157 | Ok(runtime) => { 158 | let events = runtime.snapshot().await; 159 | let response = StartSessionResponse { 160 | session_id: runtime.id().to_string(), 161 | events, 162 | }; 163 | Json(response).into_response() 164 | } 165 | Err((status, message)) => (status, message).into_response(), 166 | } 167 | } 168 | 169 | async fn api_get_session_logs(AxumPath(id): AxumPath) -> impl IntoResponse { 170 | match get_session_runtime(&id).await { 171 | Some(runtime) => { 172 | let events = runtime.snapshot().await; 173 | Json(json!({ "sessionId": id, "events": events })).into_response() 174 | } 175 | None => (StatusCode::NOT_FOUND, "Session not found").into_response(), 176 | } 177 | } 178 | 179 | async fn api_send_session_message( 180 | AxumPath(id): AxumPath, 181 | Json(req): Json, 182 | ) -> impl IntoResponse { 183 | let Some(runtime) = get_session_runtime(&id).await else { 184 | return (StatusCode::NOT_FOUND, "Session not found").into_response(); 185 | }; 186 | 187 | let trimmed = req.message.trim(); 188 | if trimmed.is_empty() { 189 | return (StatusCode::BAD_REQUEST, "Message cannot be empty").into_response(); 190 | } 191 | 192 | runtime 193 | .push_message("user", "stdin", trimmed.to_string()) 194 | .await; 195 | 196 | match runtime.write_stdin(trimmed).await { 197 | Ok(()) => Json(json!({ "status": "ok" })).into_response(), 198 | Err(err) => { 199 | runtime 200 | .push_status("error", Some(format!("stdin write failed: {err}"))) 201 | .await; 202 | ( 203 | StatusCode::INTERNAL_SERVER_ERROR, 204 | "Failed to write to session".to_string(), 205 | ) 206 | .into_response() 207 | } 208 | } 209 | } 210 | 211 | async fn api_stream_session( 212 | AxumPath(id): AxumPath, 213 | ws: WebSocketUpgrade, 214 | ) -> impl IntoResponse { 215 | match get_session_runtime(&id).await { 216 | Some(runtime) => ws.on_upgrade(move |socket| session_stream(socket, runtime)), 217 | None => (StatusCode::NOT_FOUND, "Session not found").into_response(), 218 | } 219 | } 220 | 221 | async fn api_get_settings() -> impl IntoResponse { 222 | match load_settings_payload() { 223 | Ok(payload) => Json(payload).into_response(), 224 | Err(err) => { 225 | eprintln!("[dashboard] failed to load settings: {err:?}"); 226 | ( 227 | StatusCode::INTERNAL_SERVER_ERROR, 228 | "Failed to load settings".to_string(), 229 | ) 230 | .into_response() 231 | } 232 | } 233 | } 234 | 235 | async fn api_update_settings(Json(req): Json) -> impl IntoResponse { 236 | match update_settings_state(req) { 237 | Ok(payload) => Json(payload).into_response(), 238 | Err(err) => { 239 | eprintln!("[dashboard] failed to update settings: {err:?}"); 240 | ( 241 | StatusCode::INTERNAL_SERVER_ERROR, 242 | "Failed to update settings".to_string(), 243 | ) 244 | .into_response() 245 | } 246 | } 247 | } 248 | 249 | async fn session_stream(socket: WebSocket, runtime: Arc) { 250 | let (mut sender, mut receiver) = socket.split(); 251 | for event in runtime.snapshot().await { 252 | if sender 253 | .send(Message::Text( 254 | serde_json::to_string(&event).unwrap_or_default(), 255 | )) 256 | .await 257 | .is_err() 258 | { 259 | return; 260 | } 261 | } 262 | 263 | let mut rx = runtime.subscribe(); 264 | loop { 265 | tokio::select! { 266 | next = receiver.next() => { 267 | if matches!(next, None | Some(Err(_))) { 268 | break; 269 | } 270 | if let Some(Ok(Message::Close(_))) = next { 271 | break; 272 | } 273 | } 274 | event = rx.recv() => { 275 | match event { 276 | Ok(ev) => { 277 | if sender.send(Message::Text(serde_json::to_string(&ev).unwrap_or_default())).await.is_err() { 278 | break; 279 | } 280 | } 281 | Err(_) => break, 282 | } 283 | } 284 | } 285 | } 286 | } 287 | 288 | async fn start_live_session( 289 | repo: &str, 290 | name: &str, 291 | ) -> Result, (StatusCode, String)> { 292 | let state = XlaudeState::load().map_err(|err| { 293 | eprintln!("[dashboard] failed to load state: {err:?}"); 294 | ( 295 | StatusCode::INTERNAL_SERVER_ERROR, 296 | "Failed to load state".to_string(), 297 | ) 298 | })?; 299 | 300 | let key = XlaudeState::make_key(repo, name); 301 | let info = state.worktrees.get(&key).cloned().ok_or_else(|| { 302 | ( 303 | StatusCode::NOT_FOUND, 304 | format!("Worktree '{repo}/{name}' not found"), 305 | ) 306 | })?; 307 | 308 | if let Some(existing) = WORKTREE_SESSION_INDEX.read().await.get(&key).cloned() 309 | && let Some(runtime) = SESSION_REGISTRY.read().await.get(&existing).cloned() 310 | { 311 | return Ok(runtime); 312 | } 313 | 314 | let runtime = spawn_session(info).await.map_err(|err| { 315 | eprintln!("[dashboard] failed to spawn session: {err:?}"); 316 | ( 317 | StatusCode::INTERNAL_SERVER_ERROR, 318 | "Failed to launch session".to_string(), 319 | ) 320 | })?; 321 | 322 | WORKTREE_SESSION_INDEX 323 | .write() 324 | .await 325 | .insert(key.clone(), runtime.id().to_string()); 326 | SESSION_REGISTRY 327 | .write() 328 | .await 329 | .insert(runtime.id().to_string(), runtime.clone()); 330 | runtime.push_status("running", None).await; 331 | Ok(runtime) 332 | } 333 | 334 | async fn spawn_session(info: WorktreeInfo) -> Result> { 335 | let handle = tokio::runtime::Handle::current(); 336 | tokio::task::spawn_blocking(move || spawn_session_blocking(info, handle)) 337 | .await 338 | .context("spawn blocking session task failed")? 339 | } 340 | 341 | fn spawn_session_blocking( 342 | info: WorktreeInfo, 343 | handle: tokio::runtime::Handle, 344 | ) -> Result> { 345 | let worktree_key = XlaudeState::make_key(&info.repo_name, &info.name); 346 | let pty_system = native_pty_system(); 347 | let pair = pty_system.openpty(PtySize { 348 | rows: PTY_ROWS, 349 | cols: PTY_COLS, 350 | pixel_width: 0, 351 | pixel_height: 0, 352 | })?; 353 | 354 | let (program, args) = 355 | prepare_agent_command(&info.path).context("Failed to resolve agent command")?; 356 | let mut builder = CommandBuilder::new(program); 357 | for arg in args { 358 | builder.arg(arg); 359 | } 360 | builder.cwd(info.path.clone()); 361 | builder.env_clear(); 362 | for (key, value) in std::env::vars() { 363 | builder.env(&key, value); 364 | } 365 | 366 | let mut child = pair 367 | .slave 368 | .spawn_command(builder) 369 | .context("Failed to spawn agent")?; 370 | drop(pair.slave); 371 | 372 | let reader = pair 373 | .master 374 | .try_clone_reader() 375 | .context("Failed to clone PTY reader")?; 376 | let writer = pair 377 | .master 378 | .take_writer() 379 | .context("Failed to capture PTY writer")?; 380 | 381 | let runtime = Arc::new(SessionRuntime::new(worktree_key.clone(), writer)); 382 | 383 | let reader_runtime = runtime.clone(); 384 | let reader_handle = handle.clone(); 385 | std::thread::spawn(move || { 386 | let mut reader = reader; 387 | let mut buf = [0u8; 4096]; 388 | loop { 389 | match reader.read(&mut buf) { 390 | Ok(0) => break, 391 | Ok(n) => { 392 | let (cleaned, responses) = scrub_terminal_queries(&buf[..n]); 393 | for response in responses { 394 | let runtime = reader_runtime.clone(); 395 | let handle = reader_handle.clone(); 396 | handle.spawn(async move { 397 | if let Err(err) = runtime.write_bytes(response).await { 398 | eprintln!("[dashboard] failed to send terminal response: {err:?}"); 399 | } 400 | }); 401 | } 402 | if cleaned.is_empty() { 403 | continue; 404 | } 405 | let chunk = String::from_utf8_lossy(&cleaned).to_string(); 406 | let runtime = reader_runtime.clone(); 407 | reader_handle.spawn(async move { 408 | runtime.push_message("assistant", "stdout", chunk).await; 409 | }); 410 | } 411 | Err(err) => { 412 | let runtime = reader_runtime.clone(); 413 | reader_handle.spawn(async move { 414 | runtime 415 | .push_status("error", Some(format!("read error: {err}"))) 416 | .await; 417 | }); 418 | break; 419 | } 420 | } 421 | } 422 | }); 423 | 424 | let wait_runtime = runtime.clone(); 425 | let wait_handle = handle.clone(); 426 | std::thread::spawn(move || match child.wait() { 427 | Ok(status) => { 428 | let mut detail = format!("exit code {}", status.exit_code()); 429 | if !status.success() { 430 | detail.push_str(" (failed)"); 431 | } 432 | let id = wait_runtime.id().to_string(); 433 | let key = wait_runtime.worktree_key().to_string(); 434 | wait_handle.spawn(async move { 435 | wait_runtime.push_status("stopped", Some(detail)).await; 436 | WORKTREE_SESSION_INDEX.write().await.remove(&key); 437 | schedule_session_cleanup(id).await; 438 | }); 439 | } 440 | Err(err) => { 441 | let id = wait_runtime.id().to_string(); 442 | let key = wait_runtime.worktree_key().to_string(); 443 | wait_handle.spawn(async move { 444 | wait_runtime 445 | .push_status("stopped", Some(format!("wait error: {err}"))) 446 | .await; 447 | WORKTREE_SESSION_INDEX.write().await.remove(&key); 448 | schedule_session_cleanup(id).await; 449 | }); 450 | } 451 | }); 452 | 453 | Ok(runtime) 454 | } 455 | 456 | async fn get_session_runtime(id: &str) -> Option> { 457 | SESSION_REGISTRY.read().await.get(id).cloned() 458 | } 459 | 460 | fn build_dashboard_payload(limit: usize) -> Result { 461 | let state = XlaudeState::load()?; 462 | let worktree_paths: Vec = state 463 | .worktrees 464 | .values() 465 | .map(|info| info.path.clone()) 466 | .collect(); 467 | 468 | let (codex_sessions, codex_error) = 469 | match codex::collect_recent_sessions_for_paths(&worktree_paths, limit) { 470 | Ok(map) => (map, None), 471 | Err(err) => { 472 | eprintln!("[dashboard] failed to collect Codex sessions: {err:?}"); 473 | (HashMap::new(), Some(err.to_string())) 474 | } 475 | }; 476 | 477 | let codex_context = CodexContext { 478 | sessions: codex_sessions, 479 | error: codex_error, 480 | }; 481 | 482 | let mut worktrees: Vec<_> = state 483 | .worktrees 484 | .values() 485 | .map(|info| summarize_worktree(info, limit, &codex_context)) 486 | .collect(); 487 | 488 | worktrees.sort_by(|a, b| { 489 | a.repo_name 490 | .cmp(&b.repo_name) 491 | .then_with(|| a.name.cmp(&b.name)) 492 | }); 493 | 494 | Ok(DashboardPayload { 495 | generated_at: Utc::now(), 496 | worktrees, 497 | }) 498 | } 499 | 500 | fn summarize_worktree( 501 | info: &WorktreeInfo, 502 | limit: usize, 503 | codex_ctx: &CodexContext, 504 | ) -> WorktreeSummary { 505 | let git_status = summarize_git(&info.path); 506 | let claude_sessions = claude::get_claude_sessions(&info.path); 507 | let mut sessions = Vec::new(); 508 | 509 | for session in claude_sessions.into_iter().take(limit) { 510 | sessions.push(SessionPreview { 511 | provider: "Claude".to_string(), 512 | message: Some(session.last_user_message), 513 | timestamp: session.last_timestamp, 514 | }); 515 | } 516 | 517 | let session_error = codex_ctx.error.clone(); 518 | if codex_ctx.error.is_none() { 519 | let normalized = codex::normalized_worktree_path(&info.path); 520 | if let Some(entries) = codex_ctx.sessions.get(&normalized) { 521 | for session in entries.iter().take(limit) { 522 | let fallback = format!("Session {}", short_session_id(session)); 523 | let message = session.last_user_message.clone().unwrap_or(fallback); 524 | sessions.push(SessionPreview { 525 | provider: "Codex".to_string(), 526 | message: Some(message), 527 | timestamp: session.last_timestamp, 528 | }); 529 | } 530 | } 531 | } 532 | 533 | sessions.sort_by(|a, b| compare_option_desc(a.timestamp, b.timestamp)); 534 | sessions.truncate(limit); 535 | 536 | let mut last_activity = info.created_at; 537 | if let Some(ts) = git_status.last_commit_time 538 | && ts > last_activity 539 | { 540 | last_activity = ts; 541 | } 542 | for entry in &sessions { 543 | if let Some(ts) = entry.timestamp 544 | && ts > last_activity 545 | { 546 | last_activity = ts; 547 | } 548 | } 549 | 550 | WorktreeSummary { 551 | key: format!("{}/{}", info.repo_name, info.name), 552 | repo_name: info.repo_name.clone(), 553 | name: info.name.clone(), 554 | branch: info.branch.clone(), 555 | path: info.path.display().to_string(), 556 | created_at: info.created_at, 557 | last_activity, 558 | git_status, 559 | sessions, 560 | session_error, 561 | } 562 | } 563 | 564 | fn load_settings_payload() -> Result { 565 | let state = XlaudeState::load()?; 566 | Ok(SettingsPayload { 567 | editor: state.editor.clone(), 568 | terminal: state.shell.clone(), 569 | }) 570 | } 571 | 572 | fn update_settings_state(req: SettingsPayload) -> Result { 573 | let mut state = XlaudeState::load()?; 574 | state.editor = normalize_setting(req.editor); 575 | state.shell = normalize_setting(req.terminal); 576 | state.save()?; 577 | Ok(SettingsPayload { 578 | editor: state.editor.clone(), 579 | terminal: state.shell.clone(), 580 | }) 581 | } 582 | 583 | fn normalize_setting(value: Option) -> Option { 584 | value.and_then(|s| { 585 | let trimmed = s.trim(); 586 | if trimmed.is_empty() { 587 | None 588 | } else { 589 | Some(trimmed.to_string()) 590 | } 591 | }) 592 | } 593 | 594 | fn compare_option_desc(a: Option>, b: Option>) -> Ordering { 595 | match (a, b) { 596 | (Some(a_ts), Some(b_ts)) => b_ts.cmp(&a_ts), 597 | (Some(_), None) => Ordering::Less, 598 | (None, Some(_)) => Ordering::Greater, 599 | (None, None) => Ordering::Equal, 600 | } 601 | } 602 | 603 | fn short_session_id(session: &CodexSession) -> String { 604 | let id = &session.id; 605 | if id.len() <= 6 { 606 | id.clone() 607 | } else { 608 | id.chars() 609 | .rev() 610 | .take(6) 611 | .collect::() 612 | .chars() 613 | .rev() 614 | .collect() 615 | } 616 | } 617 | 618 | struct CodexContext { 619 | sessions: HashMap>, 620 | error: Option, 621 | } 622 | 623 | #[derive(Serialize)] 624 | #[serde(rename_all = "camelCase")] 625 | struct DashboardPayload { 626 | generated_at: DateTime, 627 | worktrees: Vec, 628 | } 629 | 630 | #[derive(Deserialize)] 631 | struct ActionRequest { 632 | action: String, 633 | } 634 | 635 | #[derive(Serialize)] 636 | #[serde(rename_all = "camelCase")] 637 | struct ActionResponse { 638 | message: String, 639 | } 640 | 641 | #[derive(Serialize, Deserialize, Clone)] 642 | #[serde(rename_all = "camelCase")] 643 | struct SettingsPayload { 644 | editor: Option, 645 | terminal: Option, 646 | } 647 | 648 | #[derive(Serialize)] 649 | #[serde(rename_all = "camelCase")] 650 | struct StartSessionResponse { 651 | session_id: String, 652 | events: Vec, 653 | } 654 | 655 | #[derive(Deserialize)] 656 | struct SendMessageRequest { 657 | message: String, 658 | } 659 | 660 | #[derive(Serialize)] 661 | #[serde(rename_all = "camelCase")] 662 | struct WorktreeSummary { 663 | key: String, 664 | repo_name: String, 665 | name: String, 666 | branch: String, 667 | path: String, 668 | created_at: DateTime, 669 | last_activity: DateTime, 670 | git_status: GitStatusSummary, 671 | sessions: Vec, 672 | session_error: Option, 673 | } 674 | 675 | #[derive(Serialize, Default, Clone)] 676 | #[serde(rename_all = "camelCase")] 677 | struct GitStatusSummary { 678 | clean: bool, 679 | staged_files: usize, 680 | unstaged_files: usize, 681 | untracked_files: usize, 682 | conflict_files: usize, 683 | last_commit_message: Option, 684 | last_commit_time: Option>, 685 | error: Option, 686 | } 687 | 688 | #[derive(Serialize)] 689 | #[serde(rename_all = "camelCase")] 690 | struct SessionPreview { 691 | provider: String, 692 | message: Option, 693 | timestamp: Option>, 694 | } 695 | 696 | #[derive(Clone, Serialize)] 697 | #[serde(rename_all = "camelCase")] 698 | struct SessionEvent { 699 | sequence: u64, 700 | timestamp: DateTime, 701 | kind: String, 702 | role: Option, 703 | channel: Option, 704 | text: Option, 705 | status: Option, 706 | detail: Option, 707 | } 708 | 709 | impl SessionEvent { 710 | fn message(sequence: u64, role: &str, channel: &str, text: String) -> Self { 711 | Self { 712 | sequence, 713 | timestamp: Utc::now(), 714 | kind: "message".to_string(), 715 | role: Some(role.to_string()), 716 | channel: Some(channel.to_string()), 717 | text: Some(text), 718 | status: None, 719 | detail: None, 720 | } 721 | } 722 | 723 | fn status(sequence: u64, status: &str, detail: Option) -> Self { 724 | Self { 725 | sequence, 726 | timestamp: Utc::now(), 727 | kind: "status".to_string(), 728 | role: None, 729 | channel: None, 730 | text: None, 731 | status: Some(status.to_string()), 732 | detail, 733 | } 734 | } 735 | } 736 | 737 | struct SessionRuntime { 738 | id: String, 739 | worktree_key: String, 740 | log: Mutex>, 741 | counter: AtomicU64, 742 | tx: broadcast::Sender, 743 | writer: Mutex>>, 744 | } 745 | 746 | impl SessionRuntime { 747 | fn new(worktree_key: String, writer: Box) -> Self { 748 | let (tx, _rx) = broadcast::channel(512); 749 | Self { 750 | id: Uuid::new_v4().to_string(), 751 | worktree_key, 752 | log: Mutex::new(Vec::new()), 753 | counter: AtomicU64::new(0), 754 | tx, 755 | writer: Mutex::new(Some(writer)), 756 | } 757 | } 758 | 759 | fn id(&self) -> &str { 760 | &self.id 761 | } 762 | 763 | fn worktree_key(&self) -> &str { 764 | &self.worktree_key 765 | } 766 | 767 | fn subscribe(&self) -> broadcast::Receiver { 768 | self.tx.subscribe() 769 | } 770 | 771 | async fn snapshot(&self) -> Vec { 772 | self.log.lock().await.clone() 773 | } 774 | 775 | async fn push_message(&self, role: &str, channel: &str, text: String) { 776 | let event = SessionEvent::message( 777 | self.counter.fetch_add(1, AtomicOrdering::SeqCst), 778 | role, 779 | channel, 780 | text, 781 | ); 782 | self.push_event(event).await; 783 | } 784 | 785 | async fn push_status(&self, status: &str, detail: Option) { 786 | let event = SessionEvent::status( 787 | self.counter.fetch_add(1, AtomicOrdering::SeqCst), 788 | status, 789 | detail, 790 | ); 791 | self.push_event(event).await; 792 | } 793 | 794 | async fn push_event(&self, event: SessionEvent) { 795 | self.log.lock().await.push(event.clone()); 796 | let _ = self.tx.send(event); 797 | } 798 | 799 | async fn write_stdin(&self, text: &str) -> Result<()> { 800 | let mut payload = text.as_bytes().to_vec(); 801 | if !payload.ends_with(b"\n") { 802 | payload.push(b'\n'); 803 | } 804 | self.write_bytes(payload).await 805 | } 806 | 807 | async fn write_bytes(&self, payload: Vec) -> Result<()> { 808 | let mut guard = self.writer.lock().await; 809 | let writer = guard 810 | .as_mut() 811 | .ok_or_else(|| anyhow!("session stdin is closed"))?; 812 | writer.write_all(&payload)?; 813 | writer.flush()?; 814 | Ok(()) 815 | } 816 | } 817 | 818 | static SESSION_REGISTRY: Lazy>>> = 819 | Lazy::new(|| RwLock::new(HashMap::new())); 820 | static WORKTREE_SESSION_INDEX: Lazy>> = 821 | Lazy::new(|| RwLock::new(HashMap::new())); 822 | 823 | fn summarize_git(path: &Path) -> GitStatusSummary { 824 | if !path.exists() { 825 | return GitStatusSummary { 826 | error: Some("Worktree path missing".to_string()), 827 | ..Default::default() 828 | }; 829 | } 830 | 831 | let mut summary = GitStatusSummary::default(); 832 | 833 | match StdCommand::new("git") 834 | .current_dir(path) 835 | .args(["status", "--short"]) 836 | .output() 837 | { 838 | Ok(output) if output.status.success() => { 839 | let stdout = String::from_utf8_lossy(&output.stdout); 840 | for line in stdout.lines() { 841 | apply_status_line(line, &mut summary); 842 | } 843 | summary.clean = summary.staged_files == 0 844 | && summary.unstaged_files == 0 845 | && summary.untracked_files == 0 846 | && summary.conflict_files == 0; 847 | } 848 | Ok(output) => { 849 | summary.error = Some(String::from_utf8_lossy(&output.stderr).trim().to_string()); 850 | return summary; 851 | } 852 | Err(err) => { 853 | summary.error = Some(err.to_string()); 854 | return summary; 855 | } 856 | } 857 | 858 | if let Some(commit) = read_last_commit(path) { 859 | summary.last_commit_message = Some(commit.message); 860 | summary.last_commit_time = Some(commit.timestamp); 861 | } 862 | 863 | summary 864 | } 865 | 866 | fn apply_status_line(line: &str, summary: &mut GitStatusSummary) { 867 | if line.starts_with("??") { 868 | summary.untracked_files += 1; 869 | return; 870 | } 871 | if line.starts_with("!!") { 872 | return; 873 | } 874 | 875 | let mut chars = line.chars(); 876 | if let Some(first) = chars.next() { 877 | match first { 878 | ' ' => {} 879 | 'U' => summary.conflict_files += 1, 880 | _ => summary.staged_files += 1, 881 | } 882 | } 883 | if let Some(second) = chars.next() { 884 | match second { 885 | ' ' => {} 886 | 'U' => summary.conflict_files += 1, 887 | _ => summary.unstaged_files += 1, 888 | } 889 | } 890 | } 891 | 892 | struct CommitSummary { 893 | message: String, 894 | timestamp: DateTime, 895 | } 896 | 897 | fn read_last_commit(path: &Path) -> Option { 898 | let output = StdCommand::new("git") 899 | .current_dir(path) 900 | .args(["log", "-1", "--pretty=format:%s%x1f%cI"]) 901 | .output() 902 | .ok()?; 903 | 904 | if !output.status.success() { 905 | return None; 906 | } 907 | 908 | let stdout = String::from_utf8_lossy(&output.stdout); 909 | if stdout.trim().is_empty() { 910 | return None; 911 | } 912 | 913 | let mut parts = stdout.split('\u{1f}'); 914 | let message = parts.next()?.trim().to_string(); 915 | let timestamp_str = parts.next()?.trim(); 916 | let timestamp = DateTime::parse_from_rfc3339(timestamp_str) 917 | .map(|dt| dt.with_timezone(&Utc)) 918 | .ok()?; 919 | 920 | Some(CommitSummary { message, timestamp }) 921 | } 922 | 923 | fn handle_worktree_action( 924 | repo: &str, 925 | name: &str, 926 | action: &str, 927 | ) -> Result { 928 | let state = XlaudeState::load().map_err(|err| { 929 | eprintln!("[dashboard] failed to load state: {err:?}"); 930 | ( 931 | StatusCode::INTERNAL_SERVER_ERROR, 932 | "Failed to load state".to_string(), 933 | ) 934 | })?; 935 | 936 | let key = XlaudeState::make_key(repo, name); 937 | let info = state.worktrees.get(&key).cloned().ok_or_else(|| { 938 | ( 939 | StatusCode::NOT_FOUND, 940 | format!("Worktree '{repo}/{name}' not found"), 941 | ) 942 | })?; 943 | 944 | let editor_override = state.editor.clone(); 945 | let shell_override = state.shell.clone(); 946 | 947 | match action { 948 | "open_agent" => launch_agent(&info).map(|_| ActionResponse { 949 | message: format!("Launching agent for {}/{}", info.repo_name, info.name), 950 | }), 951 | "open_shell" => launch_shell(&info, shell_override).map(|_| ActionResponse { 952 | message: format!("Opening shell in {}", info.path.display()), 953 | }), 954 | "open_editor" => launch_editor(&info.path, editor_override).map(|_| ActionResponse { 955 | message: format!("Opening editor for {}", info.path.display()), 956 | }), 957 | other => Err(( 958 | StatusCode::BAD_REQUEST, 959 | format!("Unsupported action '{other}'"), 960 | )), 961 | } 962 | } 963 | 964 | fn editor_command(override_cmd: Option) -> String { 965 | override_cmd 966 | .filter(|s| !s.trim().is_empty()) 967 | .or_else(|| std::env::var("XLAUDE_DASHBOARD_EDITOR").ok()) 968 | .or_else(|| std::env::var("EDITOR").ok()) 969 | .unwrap_or_else(|| "code".to_string()) 970 | } 971 | 972 | fn shell_command(override_cmd: Option) -> String { 973 | override_cmd 974 | .filter(|s| !s.trim().is_empty()) 975 | .or_else(|| std::env::var("XLAUDE_DASHBOARD_SHELL").ok()) 976 | .or_else(|| std::env::var("SHELL").ok()) 977 | .unwrap_or_else(|| "/bin/zsh".to_string()) 978 | } 979 | 980 | fn launch_agent(info: &WorktreeInfo) -> Result<(), (StatusCode, String)> { 981 | let exe = std::env::current_exe().map_err(|err| { 982 | eprintln!("[dashboard] failed to locate binary: {err:?}"); 983 | ( 984 | StatusCode::INTERNAL_SERVER_ERROR, 985 | "Failed to locate xlaude binary".to_string(), 986 | ) 987 | })?; 988 | 989 | StdCommand::new(exe) 990 | .arg("open") 991 | .arg(&info.name) 992 | .stdin(Stdio::null()) 993 | .stdout(Stdio::null()) 994 | .stderr(Stdio::null()) 995 | .spawn() 996 | .map(|_| ()) 997 | .map_err(|err| { 998 | eprintln!("[dashboard] failed to launch agent: {err:?}"); 999 | ( 1000 | StatusCode::INTERNAL_SERVER_ERROR, 1001 | "Failed to launch agent".to_string(), 1002 | ) 1003 | }) 1004 | } 1005 | 1006 | fn launch_shell( 1007 | info: &WorktreeInfo, 1008 | shell_override: Option, 1009 | ) -> Result<(), (StatusCode, String)> { 1010 | let command = shell_command(shell_override); 1011 | let mut parts = shell_split(&command).map_err(|err| { 1012 | eprintln!("[dashboard] failed to parse shell command: {err:?}"); 1013 | ( 1014 | StatusCode::INTERNAL_SERVER_ERROR, 1015 | "Failed to parse shell command".to_string(), 1016 | ) 1017 | })?; 1018 | if parts.is_empty() { 1019 | return Err(( 1020 | StatusCode::INTERNAL_SERVER_ERROR, 1021 | "Shell command is empty".to_string(), 1022 | )); 1023 | } 1024 | 1025 | let program = parts.remove(0); 1026 | let mut cmd = StdCommand::new(program); 1027 | cmd.args(parts); 1028 | cmd.current_dir(&info.path); 1029 | cmd.stdin(Stdio::null()); 1030 | cmd.stdout(Stdio::null()); 1031 | cmd.stderr(Stdio::null()); 1032 | cmd.spawn().map(|_| ()).map_err(|err| { 1033 | eprintln!("[dashboard] failed to open shell: {err:?}"); 1034 | ( 1035 | StatusCode::INTERNAL_SERVER_ERROR, 1036 | "Failed to open shell".to_string(), 1037 | ) 1038 | }) 1039 | } 1040 | 1041 | fn launch_editor(path: &Path, editor_override: Option) -> Result<(), (StatusCode, String)> { 1042 | let command = editor_command(editor_override); 1043 | let mut parts = shell_split(&command).map_err(|err| { 1044 | eprintln!("[dashboard] failed to parse editor command: {err:?}"); 1045 | ( 1046 | StatusCode::INTERNAL_SERVER_ERROR, 1047 | "Failed to parse editor command".to_string(), 1048 | ) 1049 | })?; 1050 | if parts.is_empty() { 1051 | return Err(( 1052 | StatusCode::INTERNAL_SERVER_ERROR, 1053 | "Editor command is empty".to_string(), 1054 | )); 1055 | } 1056 | 1057 | let program = parts.remove(0); 1058 | let mut cmd = StdCommand::new(program); 1059 | cmd.args(parts); 1060 | cmd.arg(path); 1061 | cmd.stdin(Stdio::null()); 1062 | cmd.stdout(Stdio::null()); 1063 | cmd.stderr(Stdio::null()); 1064 | cmd.spawn().map_err(|err| { 1065 | eprintln!("[dashboard] failed to spawn editor: {err:?}"); 1066 | ( 1067 | StatusCode::INTERNAL_SERVER_ERROR, 1068 | "Failed to open editor".to_string(), 1069 | ) 1070 | })?; 1071 | Ok(()) 1072 | } 1073 | async fn schedule_session_cleanup(id: String) { 1074 | let retention = Duration::from_secs(SESSION_RETENTION_SECS); 1075 | tokio::spawn(async move { 1076 | tokio::time::sleep(retention).await; 1077 | SESSION_REGISTRY.write().await.remove(&id); 1078 | }); 1079 | } 1080 | 1081 | fn scrub_terminal_queries(chunk: &[u8]) -> (Vec, Vec>) { 1082 | let mut cleaned = Vec::with_capacity(chunk.len()); 1083 | let mut responses = Vec::new(); 1084 | let mut index = 0; 1085 | while index < chunk.len() { 1086 | if chunk[index..].starts_with(CURSOR_POSITION_QUERY) { 1087 | responses.push(cursor_position_response()); 1088 | index += CURSOR_POSITION_QUERY.len(); 1089 | continue; 1090 | } 1091 | cleaned.push(chunk[index]); 1092 | index += 1; 1093 | } 1094 | (cleaned, responses) 1095 | } 1096 | 1097 | fn cursor_position_response() -> Vec { 1098 | format!("\x1b[{};{}R", PTY_ROWS, PTY_COLS).into_bytes() 1099 | } 1100 | --------------------------------------------------------------------------------