mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 00:15:06 +00:00
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:
@@ -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")]
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 命令",
|
||||
|
||||
@@ -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("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::<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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user