feat(cli): tty7 worktree new/ls/rm

- worktree new: the GUI dialog's worktree from the command line — create,
  carry .worktreeinclude, open a tab named after the branch, and type
  .tty7/setup && <agent> [task] into it. Prints the path first.
- worktree ls: every checkout of the repo, tty7's marked, with the live
  panes working in each.
- worktree rm: refuses while panes are inside unless --close-panes, and a
  dirty checkout unless --force; reports the snapshot ref and a kept branch.
- Move shell_quote and the first-line builders into tty7-core so the GUI
  and the CLI type the same line.
- Docs: CLI reference and the Worktrees page cover .worktreeinclude,
  .tty7/setup, TTY7_PORT and recoverable removal.
This commit is contained in:
l0ng-ai
2026-09-29 10:07:14 +08:00
parent cd745ce154
commit 685f514ac8
14 changed files with 772 additions and 99 deletions
+94
View File
@@ -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 <repo>/.tty7/worktrees, open a tab there, and run \
.tty7/setup then the agent in it; print the path",
long_about = "Create a worktree under <repo>/.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<String>,
#[arg(long, value_name = "BRANCH", help = "Branch to create (default: NAME)")]
branch: Option<String>,
#[arg(
long,
value_name = "REF",
help = "Start point (default: the remote's default branch, fetched first)"
)]
from: Option<String>,
#[arg(
long,
value_name = "AGENT",
help = "Agent to start after setup: claude, codex, …"
)]
agent: Option<String>,
#[arg(
long,
value_name = "TEXT",
requires = "agent",
help = "The agent's first message (claude, codex and gemini take one)"
)]
task: Option<String>,
#[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<String>,
#[arg(
long,
value_name = "DIR",
help = "Repository to branch (default: the current directory)"
)]
repo: Option<std::path::PathBuf>,
},
#[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<std::path::PathBuf>,
},
#[command(
about = "Remove a tty7 worktree and its branch if merged; its last state stays under \
refs/tty7/trash/<name>"
)]
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<std::path::PathBuf>,
},
}
#[derive(Debug, Subcommand)]
pub enum PaneCmd {
#[command(about = "Panes of a workspace, or of the whole machine")]
+277 -1
View File
@@ -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<Out
Some(Command::Pane(PaneCmd::Close { targets, orphans })) => {
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<String>,
branch: Option<String>,
from: Option<String>,
agent: Option<String>,
task: Option<String>,
no_setup: bool,
ws: Option<String>,
repo: Option<std::path::PathBuf>,
}
fn repo_or_here(repo: Option<std::path::PathBuf>) -> Result<std::path::PathBuf> {
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<Outcome> {
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::<Vec<_>>(),
"command": line,
}),
)
}
fn worktree_ls(repo: Option<std::path::PathBuf>, backend: &mut dyn Backend) -> Result<Outcome> {
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<u64>> = 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::<Vec<_>>()
.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<std::path::PathBuf>,
backend: &mut dyn Backend,
) -> Result<Outcome> {
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<u64> = 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::<Vec<_>>()
.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<Outcome> {
let addr = address::parse_tab(tab)?;
let machine = fetch_machine(backend)?;
+100
View File
@@ -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());
}
+1
View File
@@ -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;
+80
View File
@@ -196,6 +196,55 @@ fn snapshot(host: &dyn Host, wt: &ManagedWorktree) -> Result<String, String> {
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<String>,
/// 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<Vec<Listed>, String> {
let (_, dir) = repo_dir(host, cwd)?;
let out = git(host, cwd, &["worktree", "list", "--porcelain"])?;
let mut found: Vec<Listed> = 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<ManagedWorktree, String> {
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<String> {
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();
@@ -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<String>,
) -> Option<String> {
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, String>) -> 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(&quote_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();
+22
View File
@@ -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 |
+88 -12
View File
@@ -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.
<img src="/images/new-worktree.webp" alt="Creating a worktree" />
</Frame>
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:
<repo>/.tty7/worktrees/<name>
```
`<repo>/.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/<name>`. Even a discarded
change can come back:
```bash
git checkout refs/tty7/trash/<name> -- .
```
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).
<Tip>
Pair this with [agent sessions](/agents/sessions): a worktree per agent means
-1
View File
@@ -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;
+9 -3
View File
@@ -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}",
+9 -3
View File
@@ -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} 用のフォークコマンドがありません",
+10 -4
View File
@@ -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 命令",
+4 -75
View File
@@ -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<String>,
) -> Option<String> {
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(&quote_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::<Config>()));
.map(|a| setup::agent_line(a, &start.task, &cx.global::<Config>().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::<Config>().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");
}
}