diff --git a/crates/tty7-cli/src/cli.rs b/crates/tty7-cli/src/cli.rs index 368d3be2..f304f69d 100644 --- a/crates/tty7-cli/src/cli.rs +++ b/crates/tty7-cli/src/cli.rs @@ -147,6 +147,12 @@ pub enum Command { #[command(subcommand, about = "Panes: the terminals themselves (%N addresses)")] Pane(PaneCmd), + #[command( + subcommand, + about = "Git worktrees tty7 makes: one per task, set up and handed to an agent" + )] + Worktree(WorktreeCmd), + #[command( subcommand, about = "Machines: this one plus every remote the server has a link to" @@ -575,6 +581,94 @@ pub enum TabCmd { }, } +#[derive(Debug, Subcommand)] +pub enum WorktreeCmd { + #[command( + about = "Create a worktree under /.tty7/worktrees, open a tab there, and run \ + .tty7/setup then the agent in it; print the path", + long_about = "Create a worktree under /.tty7/worktrees, open a tab there, and \ + run .tty7/setup then the agent in it; print the path.\n\n\ + Gitignored files listed in .worktreeinclude are copied over first \ + (copy-on-write where the filesystem can). The setup script gets \ + TTY7_ROOT_PATH, TTY7_WORKTREE_PATH, TTY7_WORKTREE_NAME and TTY7_PORT, \ + the first of ten ports this worktree has to itself." + )] + New { + #[arg( + value_name = "NAME", + help = "Directory name (default: a fresh two-word name)" + )] + name: Option, + #[arg(long, value_name = "BRANCH", help = "Branch to create (default: NAME)")] + branch: Option, + #[arg( + long, + value_name = "REF", + help = "Start point (default: the remote's default branch, fetched first)" + )] + from: Option, + #[arg( + long, + value_name = "AGENT", + help = "Agent to start after setup: claude, codex, …" + )] + agent: Option, + #[arg( + long, + value_name = "TEXT", + requires = "agent", + help = "The agent's first message (claude, codex and gemini take one)" + )] + task: Option, + #[arg(long, help = "Do not run .tty7/setup")] + no_setup: bool, + #[arg( + long, + value_name = "WORKSPACE", + help = "Workspace for the tab (default: this one)" + )] + ws: Option, + #[arg( + long, + value_name = "DIR", + help = "Repository to branch (default: the current directory)" + )] + repo: Option, + }, + + #[command(about = "Checkouts of the repository here; tty7's are marked")] + Ls { + #[arg( + long, + value_name = "DIR", + help = "Repository (default: the current directory)" + )] + repo: Option, + }, + + #[command( + about = "Remove a tty7 worktree and its branch if merged; its last state stays under \ + refs/tty7/trash/" + )] + Rm { + #[arg(value_name = "NAME|PATH")] + target: String, + #[arg( + long, + help = "Remove even with uncommitted changes (they are still snapshotted)" + )] + force: bool, + #[arg(long, help = "Close the panes still working in it first")] + close_panes: bool, + #[arg( + long, + value_name = "DIR", + help = "Repository (default: the current directory)" + )] + repo: Option, + }, +} + #[derive(Debug, Subcommand)] pub enum PaneCmd { #[command(about = "Panes of a workspace, or of the whole machine")] diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index 68da3d2a..aa16db45 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -12,7 +12,7 @@ use crate::address::{self, Context, WorkspaceAddress}; use crate::backend::{Backend, RunSpec}; use crate::cli::{ CaptureArgs, Cli, Command, ExecArgs, MachineCmd, PaneCmd, RunArgs, SendArgs, ServerCmd, - SplitArgs, TabCmd, WaitArgs, WaitState, WsCmd, + SplitArgs, TabCmd, WaitArgs, WaitState, WorktreeCmd, WsCmd, }; use crate::exec::ExecEnd; use crate::output; @@ -93,6 +93,46 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result { pane_close(&targets, orphans, ctx, backend) } + Some(Command::Worktree(cmd)) => { + if let Some(machine) = machine.as_deref() { + bail!( + "`tty7 worktree` works on this machine's repositories only — -m {machine} \ + would have run git here, not there" + ); + } + match cmd { + WorktreeCmd::New { + name, + branch, + from, + agent, + task, + no_setup, + ws, + repo, + } => worktree_new( + WorktreeNew { + name, + branch, + from, + agent, + task, + no_setup, + ws, + repo, + }, + ctx, + backend, + ), + WorktreeCmd::Ls { repo } => worktree_ls(repo, backend), + WorktreeCmd::Rm { + target, + force, + close_panes, + repo, + } => worktree_rm(&target, force, close_panes, repo, backend), + } + } Some(Command::Events) => events(json_mode, backend), Some(Command::Agents) => agents(backend), Some(Command::Wait(args)) => wait(args, ctx, backend), @@ -1114,6 +1154,242 @@ fn hang_up_removed_panes(request: &str, reply: ReplyOk, backend: &mut dyn Backen Ok(()) } +struct WorktreeNew { + name: Option, + branch: Option, + from: Option, + agent: Option, + task: Option, + no_setup: bool, + ws: Option, + repo: Option, +} + +fn repo_or_here(repo: Option) -> Result { + match repo { + Some(repo) => Ok(repo), + None => std::env::current_dir().context("cannot read the current directory"), + } +} + +/// The same worktree the GUI's New Worktree dialog makes, with the same first +/// line typed into its tab. Setup runs unless `--no-setup`: whoever can run +/// this can already run the script by hand, so the GUI's approval step would +/// guard nothing here. +fn worktree_new(args: WorktreeNew, ctx: &Context, backend: &mut dyn Backend) -> Result { + use tty7_core::core::cli_agent::CLIAgent; + use tty7_core::core::worktree::{self, WorktreeRequest, setup}; + + let host = tty7_core::host::local::LocalHost::new(); + let repo = repo_or_here(args.repo)?; + let agent = args + .agent + .as_deref() + .map(|slug| { + CLIAgent::from_slug(slug).ok_or_else(|| anyhow::anyhow!("no agent called \"{slug}\"")) + }) + .transpose()?; + // The workspace is settled before anything is created, so a bad --ws + // leaves no worktree behind. + let machine = fetch_machine(backend)?; + let ws = resolve_ws(args.ws.as_deref(), ctx, &machine)?; + let defaults = worktree::defaults(&*host, &repo).map_err(anyhow::Error::msg)?; + let name = args.name.unwrap_or(defaults.name); + let req = WorktreeRequest { + branch: args.branch.unwrap_or_else(|| name.clone()), + name, + base: args.from.unwrap_or(defaults.base), + }; + let wt = worktree::create(&*host, &repo, &req).map_err(anyhow::Error::msg)?; + + let setup = (!args.no_setup && cfg!(unix)) + .then(|| setup::find(&*host, &wt.path)) + .flatten() + .zip(setup::env(&*host, &wt.main_root, &wt.path)); + let overrides = tty7_core::core::config::Config::load().agent_launch; + let agent_line = + agent.map(|a| setup::agent_line(a, args.task.as_deref().unwrap_or(""), &overrides)); + let line = setup::launch_line( + setup + .as_ref() + .map(|(s, env)| (s.script.as_path(), env.as_slice())), + agent_line, + ); + + let cwd = wt.path.to_string_lossy().into_owned(); + let pane = backend.spawn_shell(ws, Some(cwd.clone()))?; + let tab = match backend.control(ControlRequest::TabCreate { + workspace: ws, + at: None, + pane: PaneSeed { + pane, + cwd: Some(cwd.clone()), + ssh_spec: None, + agent: None, + shell: None, + }, + tab: None, + })? { + ReplyOk::TabTree(tab) => *tab, + other => bail!("the server answered TabCreate with {other:?}"), + }; + backend.control(ControlRequest::TabRename { + workspace: ws, + tab: tab.id, + name: Some(wt.branch.clone()), + })?; + if let Some(line) = &line { + backend.send_input(pane, format!("{line}\r").into_bytes())?; + } + + let mut notes = vec![format!("branch {} from {}", wt.branch, req.base)]; + if !wt.carried.copied.is_empty() { + notes.push(format!("copied {}", wt.carried.copied.join(", "))); + } + for (path, why) in &wt.carried.skipped { + notes.push(format!("not copied {path}: {why}")); + } + notes.push(format!("pane %{pane}")); + if setup.is_some() { + notes.push("running .tty7/setup".into()); + } + report( + format!("{cwd}\n {}", notes.join(" · ")), + json!({ + "path": cwd, + "branch": wt.branch, + "base": req.base, + "tab": tab.id.to_string(), + "pane": pane, + "setup": setup.is_some(), + "port": setup::port_base(&wt.path), + "copied": wt.carried.copied, + "skipped": wt.carried.skipped.iter().map(|(p, why)| json!({ "path": p, "reason": why })).collect::>(), + "command": line, + }), + ) +} + +fn worktree_ls(repo: Option, backend: &mut dyn Backend) -> Result { + let host = tty7_core::host::local::LocalHost::new(); + let repo = repo_or_here(repo)?; + let listed = tty7_core::core::worktree::list(&*host, &repo).map_err(anyhow::Error::msg)?; + // Each live pane belongs to the deepest checkout its cwd is in: the main + // checkout contains `.tty7/worktrees/*`, and a pane in one of those is + // not also working in main. + let mut owner: Vec> = vec![Vec::new(); listed.len()]; + for pane in backend.list_panes().unwrap_or_default() { + let Some(cwd) = pane.cwd.as_deref().filter(|_| pane.alive) else { + continue; + }; + let deepest = listed + .iter() + .enumerate() + .filter(|(_, wt)| cwd.starts_with(&wt.path)) + .max_by_key(|(_, wt)| wt.path.components().count()); + if let Some((i, _)) = deepest { + owner[i].push(pane.pane_id); + } + } + let mut human = Vec::new(); + let mut rows = Vec::new(); + for (wt, inside) in listed.iter().zip(owner) { + let kind = match (wt.main, wt.managed) { + (true, _) => "main", + (false, true) => "tty7", + (false, false) => "", + }; + let panes_text = inside + .iter() + .map(|p| format!("%{p}")) + .collect::>() + .join(" "); + human.push(format!( + "{:<5} {:<28} {:<12} {}", + kind, + wt.branch.as_deref().unwrap_or("(detached)"), + panes_text, + wt.path.display() + )); + rows.push(json!({ + "path": wt.path, + "branch": wt.branch, + "main": wt.main, + "managed": wt.managed, + "panes": inside, + })); + } + report(human.join("\n"), json!({ "worktrees": rows })) +} + +fn worktree_rm( + target: &str, + force: bool, + close_panes: bool, + repo: Option, + backend: &mut dyn Backend, +) -> Result { + use tty7_core::core::worktree; + + let host = tty7_core::host::local::LocalHost::new(); + let repo = repo_or_here(repo)?; + let wt = worktree::find(&*host, &repo, target).map_err(anyhow::Error::msg)?; + let occupying: Vec = backend + .list_panes()? + .into_iter() + .filter(|p| p.alive && p.cwd.as_deref().is_some_and(|c| c.starts_with(&wt.path))) + .map(|p| p.pane_id) + .collect(); + if !occupying.is_empty() { + let list = occupying + .iter() + .map(|p| format!("%{p}")) + .collect::>() + .join(" "); + if !close_panes { + bail!( + "{list} still working in {} — close them, or pass --close-panes", + wt.path.display() + ); + } + // A dirty checkout is refused before anything is closed, so a + // missing --force does not cost the panes. + if wt.dirty && !force { + bail!( + "{} has uncommitted changes — pass --force to remove it anyway (they are snapshotted first)", + wt.path.display() + ); + } + for pane in &occupying { + backend.kill_pane(*pane)?; + } + } + let removed = worktree::remove(&*host, &wt, force).map_err(|e| { + anyhow::anyhow!(if wt.dirty && !force { + format!("{e} — pass --force to remove it anyway (they are snapshotted first)") + } else { + e + }) + })?; + let mut notes = vec![format!("removed {}", wt.path.display())]; + if let Some(snapshot) = &removed.snapshot { + notes.push(format!("last state in {snapshot}")); + } + if removed.branch_kept { + notes.push(format!("kept branch {} (unmerged commits)", wt.branch)); + } + report( + notes.join(" · "), + json!({ + "path": wt.path, + "branch": wt.branch, + "snapshot": removed.snapshot, + "branch_kept": removed.branch_kept, + "closed_panes": occupying, + }), + ) +} + fn tab_rename(tab: &str, name: String, backend: &mut dyn Backend) -> Result { let addr = address::parse_tab(tab)?; let machine = fetch_machine(backend)?; diff --git a/crates/tty7-cli/tests/cli_e2e.rs b/crates/tty7-cli/tests/cli_e2e.rs index 89f5e70e..b81805f4 100644 --- a/crates/tty7-cli/tests/cli_e2e.rs +++ b/crates/tty7-cli/tests/cli_e2e.rs @@ -103,6 +103,10 @@ fn main() { "send_paste_asks_the_pane_for_its_bracketed_paste_mode", send_paste_asks_the_pane_for_its_bracketed_paste_mode, ), + ( + "worktree_new_sets_up_the_checkout_and_rm_takes_it_down", + worktree_new_sets_up_the_checkout_and_rm_takes_it_down, + ), ]; let mut failed = 0; @@ -1275,3 +1279,99 @@ fn send_paste_asks_the_pane_for_its_bracketed_paste_mode(daemon: &Daemon) { let seen = await_capture(daemon, &address, "tty7_e2e_framed"); assert!(seen.contains("[200~"), "{seen:?}"); } + +/// `worktree new` end to end against a real server: the checkout, its +/// `.worktreeinclude` copy, the tab, and `.tty7/setup` actually running in +/// that tab with its variables; then `ls` sees the pane in it, and `rm` will +/// not take the checkout from under the pane unless told to. +fn worktree_new_sets_up_the_checkout_and_rm_takes_it_down(daemon: &Daemon) { + // The setup script is a POSIX executable; Windows has no `env` to run it. + if cfg!(windows) { + return; + } + let repo_dir = tempfile::tempdir().expect("temp repo"); + let repo = repo_dir.path(); + let git = |args: &[&str]| { + let out = Command::new("git") + .args(["-c", "user.email=t@t", "-c", "user.name=t"]) + .args(args) + .current_dir(repo) + .output() + .expect("run git"); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + }; + git(&["init", "-q"]); + std::fs::write(repo.join(".gitignore"), ".env\nsetup-ran\n").unwrap(); + std::fs::write(repo.join(".worktreeinclude"), ".env\n").unwrap(); + std::fs::create_dir(repo.join(".tty7")).unwrap(); + let script = repo.join(".tty7/setup"); + std::fs::write( + &script, + "#!/bin/sh\necho \"$TTY7_PORT $TTY7_WORKTREE_NAME\" > setup-ran\n", + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + git(&["add", "."]); + git(&["commit", "-q", "-m", "init"]); + std::fs::write(repo.join(".env"), "SECRET=1\n").unwrap(); + + let ws = daemon.run_json(&["new", &workdir()]); + let ws = ws["id"].as_str().expect("new prints the workspace id"); + let repo_arg = repo.to_str().unwrap(); + let made = daemon.run_json(&["worktree", "new", "e2e-wt", "--ws", ws, "--repo", repo_arg]); + let path = std::path::PathBuf::from(made["path"].as_str().expect("new prints the path")); + let pane = made["pane"].as_u64().expect("worktree new prints the pane"); + let port = made["port"].as_u64().expect("worktree new prints the port"); + assert_eq!(made["setup"], true, "{made}"); + assert_eq!( + std::fs::read_to_string(path.join(".env")).unwrap(), + "SECRET=1\n" + ); + + let ran = path.join("setup-ran"); + let deadline = Instant::now() + SETTLE_WITHIN; + let written = loop { + let text = std::fs::read_to_string(&ran).unwrap_or_default(); + if text.ends_with('\n') { + break text; + } + assert!( + Instant::now() < deadline, + "setup never ran; the pane shows:\n{}", + daemon.run_ok(&["capture", &format!("%{pane}"), "--scrollback"]) + ); + std::thread::sleep(Duration::from_millis(200)); + }; + assert_eq!(written, format!("{port} e2e-wt\n")); + + let listed = daemon.run_json(&["worktree", "ls", "--repo", repo_arg]); + let rows = listed["worktrees"].as_array().expect("ls lists worktrees"); + assert_eq!(rows.len(), 2, "{listed}"); + assert_eq!(rows[1]["managed"], true, "{listed}"); + assert_eq!(rows[1]["panes"][0].as_u64(), Some(pane), "{listed}"); + + let refused = daemon.run(&["worktree", "rm", "e2e-wt", "--repo", repo_arg]); + assert!( + !refused.status.success(), + "rm took the checkout from under its pane" + ); + assert!(path.exists()); + let removed = daemon.run_json(&[ + "worktree", + "rm", + "e2e-wt", + "--repo", + repo_arg, + "--close-panes", + ]); + assert_eq!(removed["snapshot"], "refs/tty7/trash/e2e-wt", "{removed}"); + assert!(!path.exists()); +} diff --git a/crates/tty7-core/src/core/mod.rs b/crates/tty7-core/src/core/mod.rs index 94cbe384..1d33ce75 100644 --- a/crates/tty7-core/src/core/mod.rs +++ b/crates/tty7-core/src/core/mod.rs @@ -19,6 +19,7 @@ pub mod paste; pub mod path_spelling; pub mod proc; pub mod session; +pub mod shell_quote; pub mod shells; #[allow(dead_code)] pub mod ssh_profile; diff --git a/src/core/shell_quote.rs b/crates/tty7-core/src/core/shell_quote.rs similarity index 100% rename from src/core/shell_quote.rs rename to crates/tty7-core/src/core/shell_quote.rs diff --git a/crates/tty7-core/src/core/worktree/mod.rs b/crates/tty7-core/src/core/worktree/mod.rs index 411d982d..dddf21bc 100644 --- a/crates/tty7-core/src/core/worktree/mod.rs +++ b/crates/tty7-core/src/core/worktree/mod.rs @@ -196,6 +196,55 @@ fn snapshot(host: &dyn Host, wt: &ManagedWorktree) -> Result { Ok(refname) } +/// One checkout of a repo, as `git worktree list` knows it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Listed { + pub path: PathBuf, + /// `None` on a detached HEAD. + pub branch: Option, + /// The repo's main checkout. + pub main: bool, + /// Made by tty7, under `.tty7/worktrees/`. + pub managed: bool, +} + +/// Every checkout of the repo `cwd` is in, the main one first. +pub fn list(host: &dyn Host, cwd: &Path) -> Result, String> { + let (_, dir) = repo_dir(host, cwd)?; + let out = git(host, cwd, &["worktree", "list", "--porcelain"])?; + let mut found: Vec = Vec::new(); + for line in out.lines() { + if let Some(path) = line.strip_prefix("worktree ") { + let path = git_path(host, path); + found.push(Listed { + managed: path.starts_with(&dir), + main: found.is_empty(), + path, + branch: None, + }); + } else if let (Some(branch), Some(last)) = (line.strip_prefix("branch "), found.last_mut()) + { + last.branch = Some(branch.trim_start_matches("refs/heads/").to_string()); + } + } + Ok(found) +} + +/// The tty7 worktree `target` names: a path, or a name under the repo's +/// `.tty7/worktrees/`. +pub fn find(host: &dyn Host, cwd: &Path, target: &str) -> Result { + let as_path = Path::new(target); + let path = if host.is_absolute(as_path) || target.contains(['/', '\\']) { + as_path.to_path_buf() + } else { + host.join(&repo_dir(host, cwd)?.1, target) + }; + if !host.exists(&path) { + return Err(format!("no worktree at {}", path.display())); + } + managed(host, &path).ok_or_else(|| format!("{} is not a worktree tty7 made", path.display())) +} + /// Names removed worktrees left behind. fn retired(host: &dyn Host, repo_root: &Path) -> Vec { git( @@ -761,6 +810,37 @@ mod tests { let _ = std::fs::remove_dir_all(&clone); } + #[test] + fn list_and_find_see_the_managed_checkouts() { + let h = h(); + let repo = temp_repo("list"); + let wt = create(&*h, &repo, &req("list-wt")).unwrap(); + let listed = list(&*h, &wt.path).unwrap(); + assert_eq!(listed.len(), 2); + assert!(listed[0].main && !listed[0].managed); + assert!(!listed[1].main && listed[1].managed); + assert_eq!(listed[1].branch.as_deref(), Some("list-wt")); + assert_eq!(plain(&listed[1].path), plain(&wt.path)); + + assert_eq!(find(&*h, &repo, "list-wt").unwrap().branch, "list-wt"); + assert_eq!( + find(&*h, &repo, wt.path.to_str().unwrap()).unwrap().branch, + "list-wt" + ); + assert!( + find(&*h, &repo, "nope") + .unwrap_err() + .contains("no worktree") + ); + let tmp = repo.to_str().unwrap(); + assert!( + find(&*h, &repo, tmp) + .unwrap_err() + .contains("not a worktree tty7 made") + ); + let _ = std::fs::remove_dir_all(&repo); + } + #[test] fn create_replaces_the_legacy_catch_all_gitignore() { let h = h(); diff --git a/crates/tty7-core/src/core/worktree/setup.rs b/crates/tty7-core/src/core/worktree/setup.rs index c0460ef6..763c5e2e 100644 --- a/crates/tty7-core/src/core/worktree/setup.rs +++ b/crates/tty7-core/src/core/worktree/setup.rs @@ -16,10 +16,13 @@ //! path, so no allocator has to remember anything and a worktree recreated at //! the same place gets the same ports back. +use std::collections::HashMap; use std::path::{Path, PathBuf}; use sha2::{Digest, Sha256}; +use crate::core::cli_agent::CLIAgent; +use crate::core::shell_quote::quote_for_shell; use crate::host::{Host, HostId, fnv1a64}; pub const SCRIPT: &[&str] = &[".tty7", "setup"]; @@ -99,6 +102,43 @@ pub fn hint(host: &dyn Host, worktree: &Path) -> Option<&'static str> { .map(|(_, cmd)| *cmd) } +/// The line a new worktree's first pane types: the setup script under its +/// variables, then the agent — chained with `&&`, so a failed setup stops +/// there, on screen, instead of under an agent that cannot build. +pub fn launch_line( + setup: Option<(&Path, &[(&'static str, String)])>, + agent: Option, +) -> Option { + let q = |s: &str| quote_for_shell(s, None); + let setup = setup.map(|(script, env)| { + let mut parts = vec!["env".to_string()]; + parts.extend(env.iter().map(|(k, v)| format!("{k}={}", q(v)))); + parts.push(q(&script.to_string_lossy())); + parts.join(" ") + }); + match (setup, agent) { + (Some(setup), Some(agent)) => Some(format!("{setup} && {agent}")), + (setup, agent) => setup.or(agent), + } +} + +/// `agent`'s launch line (`overrides` is `Config::agent_launch`), opening on +/// `task` when the agent takes a first message. +pub fn agent_line(agent: CLIAgent, task: &str, overrides: &HashMap) -> String { + let mut line = agent.launch_command(overrides); + let task = task.trim(); + if let Some(args) = (!task.is_empty()) + .then(|| agent.prompt_args(task)) + .flatten() + { + for arg in args { + line.push(' '); + line.push_str("e_for_shell(&arg, None)); + } + } + line +} + fn hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } @@ -116,6 +156,44 @@ mod tests { assert_ne!(a, port_base(Path::new("/r/.tty7/worktrees/amber-heron"))); } + #[cfg(unix)] + #[test] + fn launch_line_chains_setup_before_the_agent_and_quotes_values() { + let env = vec![ + ("TTY7_ROOT_PATH", "/r/my repo".to_string()), + ("TTY7_PORT", "20010".to_string()), + ]; + let script = Path::new("/r/my repo/.tty7/worktrees/w/.tty7/setup"); + assert_eq!( + launch_line(Some((script, &env)), Some("claude 'fix it'".into())).unwrap(), + "env TTY7_ROOT_PATH='/r/my repo' TTY7_PORT=20010 \ + '/r/my repo/.tty7/worktrees/w/.tty7/setup' && claude 'fix it'" + ); + assert_eq!(launch_line(None, Some("codex".into())).unwrap(), "codex"); + assert_eq!(launch_line(None, None), None); + } + + #[cfg(unix)] + #[test] + fn agent_line_passes_the_task_only_to_agents_that_take_one() { + let none = HashMap::new(); + assert_eq!( + agent_line(CLIAgent::Claude, " it's done ", &none), + r"claude 'it'\''s done'" + ); + assert_eq!(agent_line(CLIAgent::Gemini, "go", &none), "gemini -i go"); + assert_eq!(agent_line(CLIAgent::Claude, " ", &none), "claude"); + assert_eq!(agent_line(CLIAgent::Aider, "go", &none), "aider"); + let flags = HashMap::from([( + "claude".to_string(), + "claude --dangerously-skip-permissions".to_string(), + )]); + assert_eq!( + agent_line(CLIAgent::Claude, "go", &flags), + "claude --dangerously-skip-permissions go" + ); + } + #[test] fn env_refuses_relative_paths() { let h = crate::host::local::LocalHost::new(); diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index 9846cbb2..5b2d984b 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -353,6 +353,28 @@ one, else the agent running there, else the last segment of the cwd, else the foreground process. `name` stays literal, so a script can tell a real name from a stand-in. +## `worktree` — one checkout per task + +The same worktrees the GUI's **New Worktree Tab…** makes (see +[Worktrees](/git/worktrees)), for scripts and for agents handing work to other +agents. Runs git on this machine only; `-m` is refused. + +| Command | Effect | JSON | +|---|---|---| +| `worktree new [NAME] [--branch B] [--from REF] [--agent A] [--task TEXT] [--no-setup] [--ws WORKSPACE] [--repo DIR]` | Create the worktree, open a tab in it, run `.tty7/setup` then the agent there | `{"path","branch","base","tab","pane","setup","port","copied","skipped","command"}` | +| `worktree ls [--repo DIR]` | Every checkout of the repo; tty7's are marked, with the panes working in each | `{"worktrees":[{"path","branch","main","managed","panes"}]}` | +| `worktree rm NAME\|PATH [--force] [--close-panes] [--repo DIR]` | Remove a tty7 worktree, snapshotting it first | `{"path","branch","snapshot","branch_kept","closed_panes"}` | + +```bash +tty7 worktree new --agent claude --task "fix the flaky login test" +``` + +`new` prints the path first, so `cd "$(tty7 worktree new | head -1)"` works. +Setup runs unless `--no-setup`: the GUI asks before running a repo's script for +the first time, but anyone who can run this command can already run the script +by hand. `rm` refuses while a pane is still working inside the checkout; pass +`--close-panes` to close them first. + ## `pane` — panes | Command | Effect | JSON | diff --git a/docs/git/worktrees.mdx b/docs/git/worktrees.mdx index 458fcda5..b95f94e7 100644 --- a/docs/git/worktrees.mdx +++ b/docs/git/worktrees.mdx @@ -1,21 +1,24 @@ --- title: "Worktrees" -description: "An isolated checkout on a fresh branch, in one dialog and one tab." +description: "One checkout per task: fresh branch, set up, and handed to an agent — in one dialog." --- Running two agents on the same repository at once means they fight over the -working tree. A git worktree is the fix, and tty7 makes it a single dialog. +working tree. A git worktree is the fix, and tty7 makes it a single dialog: +create the checkout, make it runnable, and start the agent in it. ## Creating one **New Worktree Tab…** — in Search Everywhere, the tab's right-click menu, and -the application menu — asks three things: +the application menu — asks: | Field | Default | |---|---| -| **Worktree Name** | A fresh name that does not collide with an existing branch or directory | +| **Worktree Name** | A fresh name that no branch, directory or removed worktree has used | | **New Branch** | The same name, editable | -| **Start From** | The branch you are currently on | +| **Start From** | The remote's default branch (`origin/main`), fetched first; the current branch when there is no remote | +| **Start** | Shell, or one of the agents you use most — the last one used is picked | +| **Task** | The agent's first message (Claude Code, Codex and Gemini take one) | Each field opens on a suggestion you can accept or type straight over. @@ -23,9 +26,64 @@ Each field opens on a suggestion you can accept or type straight over. Creating a worktree -Confirm and tty7 creates the worktree, opens a tab in it, and starts a shell -there. The [sidebar](/window/sidebar) files it under the same repository group as -its parent, on its own branch. +Confirm and tty7 creates the worktree, copies in what `.worktreeinclude` asks +for, opens a tab there, and runs `.tty7/setup` followed by the agent. The +[sidebar](/window/sidebar) files it under the same repository group as its +parent, on its own branch. + +The new branch does not track `origin/main`: its first `git push -u` goes to a +branch of its own name. + +## Files a checkout needs: `.worktreeinclude` + +A fresh checkout has none of your gitignored files — no `.env`, no local +settings. List the ones it needs in `.worktreeinclude` at the repository root, +in gitignore syntax: + +``` +.env +.env.local +config/local/*.json +``` + +A path is copied only if it matches **and** git ignores it. + +Copies are copy-on-write where the filesystem supports it (APFS on macOS, +btrfs and XFS on Linux): a copied directory costs no disk until one side +changes it. Nothing is symlinked, because two checkouts sharing one dependency +tree break each other the moment their lockfiles disagree. On a remote machine +the files go over the connection, up to 64 MB in total — enough for +configuration, not for `node_modules`. + +## Making it runnable: `.tty7/setup` + +Commit an executable at `.tty7/setup` — any language, with a shebang — and +every new worktree runs it in its first tab, before the agent. Install +dependencies there, rather than copying them: + +```sh +#!/bin/sh +set -e +pnpm install +echo "PORT=$TTY7_PORT" > .env.local +``` + +| Variable | Value | +|---|---| +| `TTY7_ROOT_PATH` | The main checkout | +| `TTY7_WORKTREE_PATH` | The new worktree | +| `TTY7_WORKTREE_NAME` | Its directory name | +| `TTY7_PORT` | The first of ten ports this worktree has to itself | + +`TTY7_PORT` comes from the worktree's path, so a dev server started there does +not collide with its siblings, and the same worktree always gets the same +ports. The setup and the agent are chained with `&&`: if setup fails, the +agent does not start and the error stays on screen. + +The script is code from the repository, so tty7 asks before running it the +first time, and again whenever its content changes. Without a setup script, +the dialog suggests one based on your lockfile (`pnpm install`, `uv sync`, …). +Setup runs on macOS and Linux; Windows worktrees skip it. ## Where they go @@ -35,8 +93,8 @@ Worktrees land inside the repository, under: /.tty7/worktrees/ ``` -`/.tty7/.gitignore` is created with `*` in it the first time, so the -directory never shows up as an untracked mess in your own repository. +The directory is listed in `.git/info/exclude`, so it never shows up in +`git status`, and `.tty7/setup` next to it stays committable. ## Removing one @@ -46,8 +104,26 @@ Closing a worktree tab offers to remove the worktree with it: - **Dirty tree** — the dialog says so, and removing requires the explicit *Discard Changes & Remove*. -Nothing is removed silently, and *Keep* leaves the worktree on disk for `git -worktree list` to find later. +Before anything is deleted, the checkout's state — uncommitted and untracked +files included — is saved under `refs/tty7/trash/`. Even a discarded +change can come back: + +```bash +git checkout refs/tty7/trash/ -- . +``` + +The branch is deleted only if it is merged; otherwise tty7 says it kept it. +*Keep* leaves the worktree on disk, and `tty7 worktree ls` finds it later. + +## From the command line + +```bash +tty7 worktree new --agent claude --task "fix the flaky login test" +tty7 worktree ls +tty7 worktree rm quiet-otter +``` + +See the [CLI reference](/cli/reference#worktree--one-checkout-per-task). Pair this with [agent sessions](/agents/sessions): a worktree per agent means diff --git a/src/core/mod.rs b/src/core/mod.rs index 424f9105..b41bbd65 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -11,7 +11,6 @@ pub mod explorer_context_menu; pub mod keychain; pub mod rate_meter; pub mod session; -pub mod shell_quote; pub mod ssh_config; pub mod update; pub mod window_state; diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 72259179..f88c2e9a 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1888,13 +1888,19 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::WorktreePromptShell => "Shell", L10nKey::WorktreePromptTask => "Task", L10nKey::WorktreePromptSetup => "Runs .tty7/setup first", - L10nKey::WorktreePromptSetupHint => "No .tty7/setup. Add one to run `{command}` in new worktrees", + L10nKey::WorktreePromptSetupHint => { + "No .tty7/setup. Add one to run `{command}` in new worktrees" + } L10nKey::AppWorktreeSetupTitle => "Run this repo's setup script?", - L10nKey::AppWorktreeSetupDetail => "{path} runs in the new tab before anything else. Approve it only if you trust this repo; tty7 asks again whenever the script changes.", + L10nKey::AppWorktreeSetupDetail => { + "{path} runs in the new tab before anything else. Approve it only if you trust this repo; tty7 asks again whenever the script changes." + } L10nKey::AppWorktreeSetupRun => "Run Setup", L10nKey::AppWorktreeSetupSkip => "Skip", L10nKey::AppWorktreeNotCarried => "Not copied from .worktreeinclude: {paths}", - L10nKey::AppWorktreeRemovedBranchKept => "Removed worktree; kept branch \"{branch}\", which has unmerged commits", + L10nKey::AppWorktreeRemovedBranchKept => { + "Removed worktree; kept branch \"{branch}\", which has unmerged commits" + } L10nKey::AppForkStillConnecting => "Could not fork: the pane is still connecting", L10nKey::AppPaneNoCodingAgent => "This pane isn't running a coding agent", L10nKey::AppForkNoCommand => "tty7 has no fork command for {name}", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 6784b6c7..650fc3a5 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1933,13 +1933,19 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::WorktreePromptShell => "シェル", L10nKey::WorktreePromptTask => "タスク", L10nKey::WorktreePromptSetup => "最初に .tty7/setup を実行", - L10nKey::WorktreePromptSetupHint => ".tty7/setup がありません。追加すると新しいワークツリーで `{command}` を実行します", + L10nKey::WorktreePromptSetupHint => { + ".tty7/setup がありません。追加すると新しいワークツリーで `{command}` を実行します" + } L10nKey::AppWorktreeSetupTitle => "このリポジトリのセットアップスクリプトを実行しますか?", - L10nKey::AppWorktreeSetupDetail => "{path} は新しいタブで最初に実行されます。このリポジトリを信頼できる場合のみ許可してください。スクリプトが変わるたびに再確認します", + L10nKey::AppWorktreeSetupDetail => { + "{path} は新しいタブで最初に実行されます。このリポジトリを信頼できる場合のみ許可してください。スクリプトが変わるたびに再確認します" + } L10nKey::AppWorktreeSetupRun => "実行", L10nKey::AppWorktreeSetupSkip => "スキップ", L10nKey::AppWorktreeNotCarried => ".worktreeinclude から複製されませんでした: {paths}", - L10nKey::AppWorktreeRemovedBranchKept => "ワークツリーを削除しました。ブランチ「{branch}」には未マージのコミットがあるため残しました", + L10nKey::AppWorktreeRemovedBranchKept => { + "ワークツリーを削除しました。ブランチ「{branch}」には未マージのコミットがあるため残しました" + } L10nKey::AppForkStillConnecting => "フォークできませんでした: ペインはまだ接続中です", L10nKey::AppPaneNoCodingAgent => "このペインはコーディングエージェントを実行していません", L10nKey::AppForkNoCommand => "tty7 には {name} 用のフォークコマンドがありません", diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 6443857e..22d3aeab 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1752,16 +1752,22 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppWorktreeRemoved => "已删除 worktree“{branch}”", L10nKey::AppWorktreeRemoveFailed => "删除 worktree 失败:{error}", L10nKey::WorktreePromptAgent => "启动", - L10nKey::WorktreePromptShell => "Shell", + L10nKey::WorktreePromptShell => "终端", L10nKey::WorktreePromptTask => "任务", L10nKey::WorktreePromptSetup => "先运行 .tty7/setup", - L10nKey::WorktreePromptSetupHint => "没有 .tty7/setup。加一个,新 worktree 就会先运行 `{command}`", + L10nKey::WorktreePromptSetupHint => { + "没有 .tty7/setup。加一个,新 worktree 就会先运行 `{command}`" + } L10nKey::AppWorktreeSetupTitle => "运行这个仓库的 setup 脚本?", - L10nKey::AppWorktreeSetupDetail => "{path} 会在新标签页里最先运行。只在信任这个仓库时批准;脚本内容一变,tty7 会再问一次。", + L10nKey::AppWorktreeSetupDetail => { + "{path} 会在新标签页里最先运行。只在信任这个仓库时批准;脚本内容一变,tty7 会再问一次。" + } L10nKey::AppWorktreeSetupRun => "运行", L10nKey::AppWorktreeSetupSkip => "跳过", L10nKey::AppWorktreeNotCarried => "未按 .worktreeinclude 复制:{paths}", - L10nKey::AppWorktreeRemovedBranchKept => "已删除 worktree;分支“{branch}”有未合并的提交,已保留", + L10nKey::AppWorktreeRemovedBranchKept => { + "已删除 worktree;分支“{branch}”有未合并的提交,已保留" + } L10nKey::AppForkStillConnecting => "无法 fork:窗格仍在连接中", L10nKey::AppPaneNoCodingAgent => "此窗格未运行编码 agent", L10nKey::AppForkNoCommand => "tty7 没有用于 {name} 的 fork 命令", diff --git a/src/ui/worktree_prompt.rs b/src/ui/worktree_prompt.rs index e137e88d..a8dff991 100644 --- a/src/ui/worktree_prompt.rs +++ b/src/ui/worktree_prompt.rs @@ -1,5 +1,3 @@ -use std::path::Path; - use gpui::{ AnyElement, Context, Entity, PromptLevel, Subscription, Window, div, prelude::*, px, rems, }; @@ -8,7 +6,6 @@ use gpui_component::{ActiveTheme as _, WindowExt as _}; use crate::core::cli_agent::CLIAgent; use crate::core::config::Config; -use crate::core::shell_quote::quote_for_shell; use crate::core::worktree::{NewWorktree, WorktreeDefaults, WorktreeRequest, setup}; use crate::ui::app::Tty7App; use crate::ui::dialog::{self, Tone}; @@ -48,41 +45,6 @@ struct Created { setup: Option<(setup::Setup, Vec<(&'static str, String)>)>, } -/// The line the first pane types: the setup script with its variables, then -/// the agent — chained with `&&`, so a failed setup stops there, on screen. -fn first_line( - setup: Option<(&Path, &[(&'static str, String)])>, - agent: Option, -) -> Option { - let q = |s: &str| quote_for_shell(s, None); - let setup = setup.map(|(script, env)| { - let mut parts = vec!["env".to_string()]; - parts.extend(env.iter().map(|(k, v)| format!("{k}={}", q(v)))); - parts.push(q(&script.to_string_lossy())); - parts.join(" ") - }); - match (setup, agent) { - (Some(setup), Some(agent)) => Some(format!("{setup} && {agent}")), - (setup, agent) => setup.or(agent), - } -} - -/// `agent`'s launch line, opening on `task` when it takes a first message. -fn agent_line(agent: CLIAgent, task: &str, cfg: &Config) -> String { - let mut line = agent.launch_command(&cfg.agent_launch); - let task = task.trim(); - if let Some(args) = (!task.is_empty()) - .then(|| agent.prompt_args(task)) - .flatten() - { - for arg in args { - line.push(' '); - line.push_str("e_for_shell(&arg, None)); - } - } - line -} - impl Tty7App { pub(crate) fn open_worktree_prompt( &mut self, @@ -248,14 +210,14 @@ impl Tty7App { } let agent = start .agent - .map(|a| agent_line(a, &start.task, cx.global::())); + .map(|a| setup::agent_line(a, &start.task, &cx.global::().agent_launch)); let Some((script, env)) = setup else { - self.open_worktree_tab(wt, first_line(None, agent), window, cx); + self.open_worktree_tab(wt, setup::launch_line(None, agent), window, cx); return; }; let key = setup::trust_key(host, &wt.main_root); if cx.global::().worktree_setup_trust.get(&key) == Some(&script.digest) { - let line = first_line(Some((&script.script, &env)), agent); + let line = setup::launch_line(Some((&script.script, &env)), agent); self.open_worktree_tab(wt, line, window, cx); return; } @@ -281,7 +243,7 @@ impl Tty7App { }); } let setup = run.then_some((script.script.as_path(), env.as_slice())); - this.open_worktree_tab(wt, first_line(setup, agent), window, cx); + this.open_worktree_tab(wt, setup::launch_line(setup, agent), window, cx); }); }) .detach(); @@ -440,36 +402,3 @@ impl Tty7App { ) } } - -#[cfg(all(test, unix))] -mod tests { - use super::*; - - #[test] - fn first_line_chains_setup_before_the_agent_and_quotes_values() { - let env = vec![ - ("TTY7_ROOT_PATH", "/r/my repo".to_string()), - ("TTY7_PORT", "20010".to_string()), - ]; - let script = Path::new("/r/my repo/.tty7/worktrees/w/.tty7/setup"); - assert_eq!( - first_line(Some((script, &env)), Some("claude 'fix it'".into())).unwrap(), - "env TTY7_ROOT_PATH='/r/my repo' TTY7_PORT=20010 \ - '/r/my repo/.tty7/worktrees/w/.tty7/setup' && claude 'fix it'" - ); - assert_eq!(first_line(None, Some("codex".into())).unwrap(), "codex"); - assert_eq!(first_line(None, None), None); - } - - #[test] - fn agent_line_passes_the_task_only_to_agents_that_take_one() { - let cfg = Config::default(); - assert_eq!( - agent_line(CLIAgent::Claude, " it's done ", &cfg), - r"claude 'it'\''s done'" - ); - assert_eq!(agent_line(CLIAgent::Gemini, "go", &cfg), "gemini -i go"); - assert_eq!(agent_line(CLIAgent::Claude, " ", &cfg), "claude"); - assert_eq!(agent_line(CLIAgent::Aider, "go", &cfg), "aider"); - } -}