diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 09de41d4..dac940b0 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -677,6 +677,18 @@ impl CLIAgent { .map_or_else(|| self.binary().to_string(), str::to_string) } + /// The arguments that start an interactive session whose first message is + /// `prompt`. `None` for an agent whose positional prompt means a one-shot, + /// non-interactive run, or whose form is not known — the caller then + /// starts the agent bare rather than guess. + pub fn prompt_args(self, prompt: &str) -> Option> { + match self { + CLIAgent::Claude | CLIAgent::Codex => Some(vec![prompt.to_string()]), + CLIAgent::Gemini => Some(vec!["-i".to_string(), prompt.to_string()]), + _ => None, + } + } + /// `argv` as a command line worth launching this agent with again: its /// binary followed by the flags it ran with, minus everything that names /// one particular session (a resume, a fork, a session id) and minus diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 25faad4c..f2428569 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -427,6 +427,10 @@ pub struct Config { /// "New Agent Tab" opens. #[serde(default)] pub agent_frecency: HashMap, + /// Approved `.tty7/setup` scripts: repo (`worktree::setup::trust_key`) to + /// the sha256 of the content approved. A changed script asks again. + #[serde(default)] + pub worktree_setup_trust: HashMap, /// Past agent sessions taken out of the search's Sessions tab, as /// `:`. Only the listing forgets them; the /// agent's own history is not touched. @@ -814,6 +818,7 @@ impl Default for Config { agent_commands: HashMap::new(), agent_launch: HashMap::new(), agent_frecency: HashMap::new(), + worktree_setup_trust: HashMap::new(), hidden_agent_sessions: BTreeSet::new(), restore_agent_sessions: true, per_pane_history: false, diff --git a/crates/tty7-core/src/core/worktree.rs b/crates/tty7-core/src/core/worktree.rs deleted file mode 100644 index 55ec586c..00000000 --- a/crates/tty7-core/src/core/worktree.rs +++ /dev/null @@ -1,431 +0,0 @@ -use std::path::{Path, PathBuf}; - -use crate::core::codename::Names; -use crate::core::git::git_path; -use crate::host::Host; - -#[derive(Debug)] -pub struct NewWorktree { - pub path: PathBuf, - pub branch: String, -} - -#[derive(Debug, Clone)] -pub struct WorktreeRequest { - pub name: String, - pub branch: String, - pub base: String, -} - -#[derive(Debug, Clone)] -pub struct WorktreeDefaults { - pub name: String, - pub base: String, - pub dir: PathBuf, -} - -fn managed_root(host: &dyn Host, main_root: &Path) -> PathBuf { - host.join(&host.join(main_root, ".tty7"), "worktrees") -} - -fn git(host: &dyn Host, dir: &Path, args: &[&str]) -> Result { - match host.git(dir, args) { - Ok(out) if out.success() => Ok(out.stdout_trimmed()), - Ok(out) => Err(out.stderr_trimmed()), - Err(e) => Err(format!("failed to run git: {e}")), - } -} - -fn branch_exists(host: &dyn Host, repo_root: &Path, name: &str) -> bool { - git( - host, - repo_root, - &[ - "rev-parse", - "--verify", - "--quiet", - &format!("refs/heads/{name}"), - ], - ) - .is_ok() -} - -#[derive(Debug, Clone)] -pub struct ManagedWorktree { - pub path: PathBuf, - pub branch: String, - pub main_root: PathBuf, - pub dirty: bool, -} - -pub fn managed(host: &dyn Host, cwd: &Path) -> Option { - let cwd = host.canonicalize(cwd).ok()?; - let suffix = host.join(Path::new(".tty7"), "worktrees"); - if !cwd.ancestors().any(|a| a.ends_with(&suffix)) { - return None; - } - let path = git_path( - host, - &git(host, &cwd, &["rev-parse", "--show-toplevel"]).ok()?, - ); - let main_root = git( - host, - &path, - &["rev-parse", "--path-format=absolute", "--git-common-dir"], - ) - .ok() - .map(|d| git_path(host, &d))? - .parent()? - .to_path_buf(); - if !path.starts_with(managed_root(host, &main_root)) { - return None; - } - let branch = git(host, &path, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?; - let dirty = !git(host, &path, &["status", "--porcelain"]) - .ok()? - .is_empty(); - Some(ManagedWorktree { - path, - branch, - main_root, - dirty, - }) -} - -pub fn occupied(host: &dyn Host, path: &Path, cwds: &[PathBuf]) -> bool { - let Ok(path) = host.canonicalize(path) else { - return false; - }; - cwds.iter() - .any(|c| host.canonicalize(c).is_ok_and(|c| c.starts_with(&path))) -} - -pub fn remove(host: &dyn Host, wt: &ManagedWorktree, force: bool) -> Result<(), String> { - let path = wt.path.to_str().ok_or("worktree path is not valid UTF-8")?; - let mut args = vec!["worktree", "remove"]; - if force { - args.push("--force"); - } - args.push(path); - git(host, &wt.main_root, &args)?; - let _ = git(host, &wt.main_root, &["branch", "-d", &wt.branch]); - Ok(()) -} - -fn repo_dir(host: &dyn Host, cwd: &Path) -> Result<(PathBuf, PathBuf), String> { - let repo_root = git(host, cwd, &["rev-parse", "--show-toplevel"]) - .map_err(|_| "not inside a git repository".to_string())?; - let repo_root = git_path(host, &repo_root); - let main_root = git( - host, - cwd, - &["rev-parse", "--path-format=absolute", "--git-common-dir"], - ) - .ok() - .map(|d| git_path(host, &d)) - .and_then(|d| d.parent().map(Path::to_path_buf)) - .unwrap_or_else(|| repo_root.clone()); - let dir = managed_root(host, &main_root); - Ok((repo_root, dir)) -} - -pub fn defaults(host: &dyn Host, cwd: &Path) -> Result { - let (repo_root, dir) = repo_dir(host, cwd)?; - - let name = Names::new().unique(|name| { - branch_exists(host, &repo_root, name) || host.exists(&host.join(&dir, name)) - }); - - let base = git(host, &repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) - .unwrap_or_else(|_| "HEAD".to_string()); - Ok(WorktreeDefaults { name, base, dir }) -} - -pub fn create(host: &dyn Host, cwd: &Path, req: &WorktreeRequest) -> Result { - if req.name.is_empty() || req.name == "." || req.name == ".." || req.name.contains(['/', '\\']) - { - return Err(format!("invalid worktree name \"{}\"", req.name)); - } - let (repo_root, dir) = repo_dir(host, cwd)?; - host.create_dir(&dir, true) - .map_err(|e| format!("cannot create {}: {e}", dir.display()))?; - let ignore = host.join( - dir.parent().expect(".tty7/worktrees has a parent"), - ".gitignore", - ); - if !host.exists(&ignore) { - let _ = host.write_file(&ignore, b"*\n"); - } - - let path = host.join(&dir, &req.name); - if host.exists(&path) { - return Err(format!("{} already exists", path.display())); - } - git( - host, - &repo_root, - &[ - "worktree", - "add", - "-b", - &req.branch, - path.to_str().ok_or("worktree path is not valid UTF-8")?, - &req.base, - ], - )?; - Ok(NewWorktree { - path, - branch: req.branch.clone(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn scratch(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("tty7-wt-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - fn plain(p: &Path) -> PathBuf { - let s = p.to_string_lossy(); - PathBuf::from(s.strip_prefix(r"\\?\").unwrap_or(&s).to_string()) - } - - fn sh(dir: &Path, args: &[&str]) { - assert!( - std::process::Command::new(args[0]) - .args(&args[1..]) - .current_dir(dir) - .output() - .unwrap() - .status - .success(), - "command failed: {args:?}" - ); - } - - fn temp_repo(name: &str) -> PathBuf { - let dir = scratch(name); - sh(&dir, &["git", "init", "-q"]); - sh(&dir, &["git", "config", "user.email", "t@t"]); - sh(&dir, &["git", "config", "user.name", "t"]); - std::fs::write(dir.join("a.txt"), "a").unwrap(); - sh(&dir, &["git", "add", "."]); - sh(&dir, &["git", "commit", "-q", "-m", "init"]); - dir - } - - fn h() -> crate::host::SharedHost { - crate::host::local::LocalHost::new() - } - - fn req(name: &str) -> WorktreeRequest { - WorktreeRequest { - name: name.into(), - branch: name.into(), - base: "HEAD".into(), - } - } - - #[test] - fn defaults_proposes_fresh_name_current_branch_and_target_dir() { - let h = h(); - let repo = temp_repo("dflt"); - let d = defaults(&*h, &repo).unwrap(); - assert!(!branch_exists(&*h, &repo, &d.name)); - let head = git(&*h, &repo, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); - assert_eq!(d.base, head); - let canon = plain(&std::fs::canonicalize(&repo).unwrap()); - assert_eq!(plain(&d.dir), canon.join(".tty7").join("worktrees")); - let _ = std::fs::remove_dir_all(&repo); - } - - #[test] - fn create_makes_worktree_on_new_branch_inside_the_repo() { - let h = h(); - let repo = temp_repo("repo"); - let wt = create(&*h, &repo, &req("quiet-otter")).unwrap(); - assert!(wt.path.join("a.txt").exists()); - assert!(branch_exists(&*h, &repo, &wt.branch)); - let canon = plain(&std::fs::canonicalize(&repo).unwrap()); - assert_eq!(plain(&wt.path), canon.join(".tty7/worktrees/quiet-otter")); - let head = git(&*h, &wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); - assert_eq!(head, wt.branch); - assert_eq!( - std::fs::read_to_string(canon.join(".tty7/.gitignore")).unwrap(), - "*\n" - ); - assert_eq!(git(&*h, &repo, &["status", "--porcelain"]).unwrap(), ""); - assert!( - create(&*h, &repo, &req("quiet-otter")) - .unwrap_err() - .contains("already exists") - ); - let _ = std::fs::remove_dir_all(&repo); - } - - #[test] - fn create_honors_custom_branch_and_base() { - let h = h(); - let repo = temp_repo("base"); - sh(&repo, &["git", "branch", "stable"]); - std::fs::write(repo.join("b.txt"), "b").unwrap(); - sh(&repo, &["git", "add", "."]); - sh(&repo, &["git", "commit", "-q", "-m", "second"]); - let wt = create( - &*h, - &repo, - &WorktreeRequest { - name: "my-dir".into(), - branch: "feat/my-branch".into(), - base: "stable".into(), - }, - ) - .unwrap(); - assert_eq!(wt.path.file_name().unwrap().to_str().unwrap(), "my-dir"); - let head = git(&*h, &wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); - assert_eq!(head, "feat/my-branch"); - assert!(wt.path.join("a.txt").exists()); - assert!(!wt.path.join("b.txt").exists()); - let _ = std::fs::remove_dir_all(&repo); - } - - #[test] - fn create_rejects_escaping_names() { - let h = h(); - let repo = temp_repo("names"); - for bad in ["", ".", "..", "a/b", "a\\b"] { - let mut r = req("x"); - r.name = bad.into(); - assert!( - create(&*h, &repo, &r) - .unwrap_err() - .contains("invalid worktree name"), - "{bad:?} should be rejected" - ); - } - let _ = std::fs::remove_dir_all(&repo); - } - - #[test] - fn create_from_a_linked_worktree_lands_in_the_main_repo() { - let h = h(); - let repo = temp_repo("nest"); - let first = create(&*h, &repo, &req("first-wt")).unwrap(); - let second = create(&*h, &first.path, &req("second-wt")).unwrap(); - assert_eq!(second.path.parent().unwrap(), first.path.parent().unwrap()); - let _ = std::fs::remove_dir_all(&repo); - } - - #[test] - fn managed_resolves_managed_checkouts_and_remove_cleans_up() { - let h = h(); - let repo = temp_repo("mg"); - let wt = create(&*h, &repo, &req("mg-wt")).unwrap(); - assert!(managed(&*h, &repo).is_none()); - let own = scratch("mg-own"); - let _ = std::fs::remove_dir_all(&own); - sh( - &repo, - &[ - "git", - "worktree", - "add", - "-b", - "own-branch", - own.to_str().unwrap(), - ], - ); - assert!(managed(&*h, &own).is_none()); - let sub = wt.path.join("sub"); - std::fs::create_dir_all(&sub).unwrap(); - let m = managed(&*h, &sub).unwrap(); - assert_eq!(m.branch, wt.branch); - assert_eq!(m.path, wt.path); - assert!(!m.dirty); - std::fs::write(wt.path.join("b.txt"), "b").unwrap(); - let m = managed(&*h, &wt.path).unwrap(); - assert!(m.dirty); - assert!(remove(&*h, &m, false).is_err()); - remove(&*h, &m, true).unwrap(); - assert!(!wt.path.exists()); - assert!(!branch_exists(&*h, &repo, &wt.branch)); - let _ = std::fs::remove_dir_all(&repo); - let _ = std::fs::remove_dir_all(&own); - } - - #[test] - fn writes_survive_the_optional_locks_invariant() { - let h = h(); - let repo = temp_repo("locks"); - - let wt = create(&*h, &repo, &req("lock-wt")).unwrap(); - assert!(wt.path.join("a.txt").exists()); - - let list = git(&*h, &repo, &["worktree", "list", "--porcelain"]).unwrap(); - assert!( - list.lines() - .any(|l| l.starts_with("branch ") && l.ends_with(&wt.branch)), - "worktree list must show the new checkout: {list}" - ); - - std::fs::write(wt.path.join("c.txt"), "c").unwrap(); - git(&*h, &wt.path, &["add", "."]).unwrap(); - git( - &*h, - &wt.path, - &[ - "-c", - "user.email=t@t", - "-c", - "user.name=t", - "commit", - "-qm", - "c", - ], - ) - .unwrap(); - assert_eq!(git(&*h, &wt.path, &["status", "--porcelain"]).unwrap(), ""); - - let m = managed(&*h, &wt.path).unwrap(); - remove(&*h, &m, false).unwrap(); - assert!(!wt.path.exists()); - assert!(branch_exists(&*h, &repo, &wt.branch)); - let plain_wt = create(&*h, &repo, &req("lock-wt2")).unwrap(); - let m = managed(&*h, &plain_wt.path).unwrap(); - remove(&*h, &m, false).unwrap(); - assert!(!branch_exists(&*h, &repo, &plain_wt.branch)); - - let _ = std::fs::remove_dir_all(&repo); - } - - #[test] - fn occupied_detects_live_cwds_inside_the_worktree() { - let h = h(); - let repo = temp_repo("occ"); - let wt = create(&*h, &repo, &req("occ-wt")).unwrap(); - let inside = wt.path.join("deep"); - std::fs::create_dir_all(&inside).unwrap(); - assert!(occupied(&*h, &wt.path, &[repo.clone(), inside])); - assert!(!occupied(&*h, &wt.path, std::slice::from_ref(&repo))); - assert!(!occupied(&*h, &wt.path, &[wt.path.join("gone")])); - let _ = std::fs::remove_dir_all(&repo); - } - - #[test] - fn create_outside_a_repo_errors() { - let h = h(); - let plain = scratch("plain"); - let err = create(&*h, &plain, &req("x")).unwrap_err(); - assert_eq!(err, "not inside a git repository"); - assert_eq!( - defaults(&*h, &plain).unwrap_err(), - "not inside a git repository" - ); - let _ = std::fs::remove_dir_all(&plain); - } -} diff --git a/crates/tty7-core/src/core/worktree/include.rs b/crates/tty7-core/src/core/worktree/include.rs new file mode 100644 index 00000000..a7b26802 --- /dev/null +++ b/crates/tty7-core/src/core/worktree/include.rs @@ -0,0 +1,342 @@ +//! `.worktreeinclude`: the gitignored files a fresh checkout cannot run +//! without — `.env`, `*.local.*`, a machine's own settings — carried over +//! from the main checkout when a worktree is created. +//! +//! The rules: gitignore syntax, and a path is copied only if it matches *and* +//! git ignores it. A tracked +//! file is already in the checkout, and an untracked-but-unignored one would +//! show up as a spurious change there. +//! +//! Copies are copy-on-write where the filesystem can: `clonefile` on macOS +//! (whole directory trees in one call), `std::fs::copy` elsewhere, which is +//! `copy_file_range` — a reflink on btrfs and XFS — on Linux. A cloned +//! `node_modules` costs no disk until one side writes to it. Nothing is ever +//! symlinked: two worktrees sharing one dependency tree break each other the +//! moment their lockfiles disagree. +//! +//! A remote host has no copy of its own to ask for, so there the bytes go +//! through `Host::read_file` / `write_file` under a budget — enough for the +//! config files this is for, not for a dependency tree. + +use std::path::{Path, PathBuf}; + +use ignore::gitignore::{Gitignore, GitignoreBuilder}; + +use crate::host::Host; + +pub const FILE: &str = ".worktreeinclude"; + +/// What a remote copy may move in total, and per file. +const REMOTE_BUDGET: u64 = 64 << 20; +const REMOTE_FILE_MAX: u64 = 16 << 20; +/// How many entries the walk under an ignored directory may look at. +const WALK_MAX: usize = 10_000; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Carried { + /// Paths (relative, `/`-separated) now in the worktree. + pub copied: Vec, + /// Paths that matched but did not make it, and why. + pub skipped: Vec<(String, String)>, +} + +/// Copy what `main_root`'s `.worktreeinclude` asks for into `worktree`. +pub fn carry(host: &dyn Host, main_root: &Path, worktree: &Path) -> Carried { + let mut out = Carried::default(); + let Ok(bytes) = host.read_file(&host.join(main_root, FILE), 1 << 20) else { + return out; + }; + let text = String::from_utf8_lossy(&bytes); + let rules = Rules::parse(&text); + if rules.is_empty() { + return out; + } + let picked = picks(host, main_root, &rules); + let mut budget = REMOTE_BUDGET; + for rel in picked { + let src = join_rel(host, main_root, &rel); + let dst = join_rel(host, worktree, &rel); + if host.stat(&dst).is_ok() { + continue; + } + let result = if host.id().is_local() { + copy_local(&src, &dst) + } else { + copy_remote(host, &src, &dst, &mut budget) + }; + match result { + Ok(()) => out.copied.push(rel), + Err(e) => out.skipped.push((rel, e)), + } + } + out +} + +struct Rules { + matcher: Gitignore, + /// Directory prefixes (`a/b/`) that anchored patterns point into. An + /// ignored directory git reports whole is only walked when one of these + /// leads inside it — a bare `.env` rule must not send the walk through + /// every `node_modules`. + anchors: Vec, +} + +impl Rules { + fn parse(text: &str) -> Rules { + let mut builder = GitignoreBuilder::new(""); + let mut anchors = Vec::new(); + for line in text.lines() { + let line = line.trim_end(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let _ = builder.add_line(None, line); + if line.starts_with('!') { + continue; + } + let body = line.trim_start_matches('/'); + // Only a pattern with a slash before its last component is + // anchored; a leading `**` matches anywhere. + let Some(slash) = body.trim_end_matches('/').rfind('/') else { + continue; + }; + if body.starts_with("**") { + continue; + } + let dir = &body[..=slash]; + let literal = dir.find(['*', '?', '[']).map_or(dir, |glob| { + &dir[..dir[..glob].rfind('/').map_or(0, |s| s + 1)] + }); + if !literal.is_empty() { + anchors.push(literal.to_string()); + } + } + Rules { + matcher: builder.build().unwrap_or_else(|_| Gitignore::empty()), + anchors, + } + } + + fn is_empty(&self) -> bool { + self.matcher.is_empty() + } + + fn wants(&self, rel: &str, is_dir: bool) -> bool { + self.matcher + .matched_path_or_any_parents(rel, is_dir) + .is_ignore() + } + + /// Whether some anchored pattern reaches inside directory `dir/`. + fn leads_into(&self, dir: &str) -> bool { + self.anchors + .iter() + .any(|a| a.starts_with(dir) || dir.starts_with(a.as_str())) + } +} + +/// The ignored paths in `main_root` the rules select, outermost only. +fn picks(host: &dyn Host, main_root: &Path, rules: &Rules) -> Vec { + let Ok(out) = host.git( + main_root, + &[ + "ls-files", + "-z", + "--others", + "--ignored", + "--exclude-standard", + "--directory", + ], + ) else { + return Vec::new(); + }; + if !out.success() { + return Vec::new(); + } + let mut found = Vec::new(); + let mut walked = 0; + for entry in out.stdout.split(|b| *b == 0).filter(|e| !e.is_empty()) { + let entry = String::from_utf8_lossy(entry); + let (rel, is_dir) = match entry.strip_suffix('/') { + Some(dir) => (dir.to_string(), true), + None => (entry.to_string(), false), + }; + if rules.wants(&rel, is_dir) { + found.push(rel); + } else if is_dir && rules.leads_into(&format!("{rel}/")) { + walk(host, main_root, &rel, rules, &mut found, &mut walked); + } + } + // `--directory` also lists an untracked directory whose only contents are + // ignored, though the directory itself is not. Git has the last word. + let found = confirm_ignored(host, main_root, found); + outermost(found) +} + +fn walk( + host: &dyn Host, + root: &Path, + dir: &str, + rules: &Rules, + found: &mut Vec, + walked: &mut usize, +) { + let Ok(entries) = host.read_dir(&join_rel(host, root, dir), None) else { + return; + }; + for e in entries { + *walked += 1; + if *walked > WALK_MAX { + return; + } + let rel = format!("{dir}/{}", e.name); + let is_dir = e.is_dir && !e.is_symlink; + if rules.wants(&rel, is_dir) { + found.push(rel); + } else if is_dir && rules.leads_into(&format!("{rel}/")) { + walk(host, root, &rel, rules, found, walked); + } + } +} + +fn confirm_ignored(host: &dyn Host, root: &Path, paths: Vec) -> Vec { + if paths.is_empty() { + return paths; + } + // `-z` needs `--stdin`, which `Host::git` cannot feed; unquoted lines it is. + let mut args = vec!["-c", "core.quotePath=false", "check-ignore", "--"]; + args.extend(paths.iter().map(String::as_str)); + let Ok(out) = host.git(root, &args) else { + return Vec::new(); + }; + let ignored: Vec = String::from_utf8_lossy(&out.stdout) + .lines() + .map(str::to_string) + .collect(); + paths.into_iter().filter(|p| ignored.contains(p)).collect() +} + +/// Drop every path that sits inside another one on the list. +fn outermost(mut paths: Vec) -> Vec { + paths.sort(); + paths.dedup(); + let mut kept: Vec = Vec::new(); + for p in paths { + if !kept.iter().any(|k| p.starts_with(&format!("{k}/"))) { + kept.push(p); + } + } + kept +} + +fn join_rel(host: &dyn Host, root: &Path, rel: &str) -> PathBuf { + rel.split('/') + .fold(root.to_path_buf(), |p, c| host.join(&p, c)) +} + +fn copy_local(src: &Path, dst: &Path) -> Result<(), String> { + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + #[cfg(target_os = "macos")] + if clonefile(src, dst).is_ok() { + return Ok(()); + } + copy_tree(src, dst).map_err(|e| e.to_string()) +} + +/// One `clonefile(2)` — a whole directory tree included — or an error for the +/// caller to fall back from (another volume, a filesystem that is not APFS). +#[cfg(target_os = "macos")] +fn clonefile(src: &Path, dst: &Path) -> std::io::Result<()> { + use std::os::unix::ffi::OsStrExt; + let src = std::ffi::CString::new(src.as_os_str().as_bytes())?; + let dst = std::ffi::CString::new(dst.as_os_str().as_bytes())?; + // A symlink is cloned as the link, not what it points at. ``; + // the libc crate does not export it. + const CLONE_NOFOLLOW: u32 = 0x0001; + match unsafe { libc::clonefile(src.as_ptr(), dst.as_ptr(), CLONE_NOFOLLOW) } { + 0 => Ok(()), + _ => Err(std::io::Error::last_os_error()), + } +} + +fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> { + let meta = std::fs::symlink_metadata(src)?; + if meta.file_type().is_symlink() { + #[cfg(unix)] + return std::os::unix::fs::symlink(std::fs::read_link(src)?, dst); + #[cfg(not(unix))] + return Err(std::io::Error::other("symlinks are not copied here")); + } + if meta.is_dir() { + std::fs::create_dir(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + copy_tree(&entry.path(), &dst.join(entry.file_name()))?; + } + return Ok(()); + } + std::fs::copy(src, dst).map(|_| ()) +} + +fn copy_remote(host: &dyn Host, src: &Path, dst: &Path, budget: &mut u64) -> Result<(), String> { + let meta = host.stat(src).map_err(|e| e.to_string())?; + if meta.is_symlink { + return Err("symlinks are not copied to a remote worktree".into()); + } + if meta.is_dir { + host.create_dir(dst, true).map_err(|e| e.to_string())?; + for e in host.read_dir(src, None).map_err(|e| e.to_string())? { + copy_remote( + host, + &host.join(src, &e.name), + &host.join(dst, &e.name), + budget, + )?; + } + return Ok(()); + } + if meta.len > REMOTE_FILE_MAX.min(*budget) { + return Err("too large to copy to a remote worktree".into()); + } + let bytes = host + .read_file(src, REMOTE_FILE_MAX) + .map_err(|e| e.to_string())?; + *budget -= bytes.len() as u64; + if let Some(parent) = dst.parent() { + host.create_dir(parent, true).map_err(|e| e.to_string())?; + } + host.write_file(dst, &bytes).map_err(|e| e.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anchors_are_the_literal_directories_a_pattern_starts_in() { + let r = Rules::parse("# c\n.env\n/config/local/*.json\nsecrets/**\n**/x/y\n!keep\n"); + assert_eq!(r.anchors, vec!["config/local/", "secrets/"]); + assert!(r.leads_into("config/")); + assert!(r.leads_into("config/local/")); + assert!(!r.leads_into("node_modules/")); + assert!(r.wants(".env", false)); + assert!(r.wants("pkg/a/.env", false)); + assert!(r.wants("config/local/a.json", false)); + assert!(!r.wants("config/local/a.txt", false)); + } + + #[test] + fn outermost_drops_nested_paths() { + let got = outermost(vec![ + "a/b".into(), + "a".into(), + "ab".into(), + "c/d".into(), + "a".into(), + ]); + assert_eq!(got, vec!["a", "ab", "c/d"]); + } +} diff --git a/crates/tty7-core/src/core/worktree/mod.rs b/crates/tty7-core/src/core/worktree/mod.rs new file mode 100644 index 00000000..411d982d --- /dev/null +++ b/crates/tty7-core/src/core/worktree/mod.rs @@ -0,0 +1,788 @@ +//! Worktrees tty7 makes: one per task, under `/.tty7/worktrees/`. +//! +//! - **Create** starts from the remote's default branch, fetched first +//! ([`defaults`]): a new task starts from what is merged, not from whatever +//! the current checkout happens to be on. Then the `.worktreeinclude` files +//! come over ([`include`]), and the first pane runs `.tty7/setup` before the +//! agent ([`setup`]). +//! - **Remove** files a snapshot of the checkout, uncommitted work included, +//! under `refs/tty7/trash/` before deleting anything, so even "Discard +//! Changes & Remove" can be undone with `git checkout refs/tty7/trash/`. +//! - **Names** are never reused: that snapshot ref retires the name. An agent +//! keys its session history by directory, and a new worktree at an old path +//! would open onto another task's conversations. + +pub mod include; +pub mod setup; + +use std::path::{Path, PathBuf}; + +use crate::core::codename::Names; +use crate::core::git::git_path; +use crate::host::Host; + +/// Where removed worktrees leave their last state. +const TRASH_REFS: &str = "refs/tty7/trash"; + +#[derive(Debug)] +pub struct NewWorktree { + pub path: PathBuf, + pub branch: String, + pub main_root: PathBuf, + pub carried: include::Carried, +} + +/// What [`remove`] did beyond deleting the checkout. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Removed { + /// The ref holding the checkout's last state. + pub snapshot: Option, + /// `git branch -d` refused: the branch has commits nothing else has. + pub branch_kept: bool, +} + +#[derive(Debug, Clone)] +pub struct WorktreeRequest { + pub name: String, + pub branch: String, + pub base: String, +} + +#[derive(Debug, Clone)] +pub struct WorktreeDefaults { + pub name: String, + pub base: String, + pub dir: PathBuf, + /// The repo has a `.tty7/setup`. + pub has_setup: bool, + /// Without one, the install its lockfile suggests. + pub setup_hint: Option<&'static str>, +} + +fn managed_root(host: &dyn Host, main_root: &Path) -> PathBuf { + host.join(&host.join(main_root, ".tty7"), "worktrees") +} + +fn git(host: &dyn Host, dir: &Path, args: &[&str]) -> Result { + match host.git(dir, args) { + Ok(out) if out.success() => Ok(out.stdout_trimmed()), + Ok(out) => Err(out.stderr_trimmed()), + Err(e) => Err(format!("failed to run git: {e}")), + } +} + +fn branch_exists(host: &dyn Host, repo_root: &Path, name: &str) -> bool { + git( + host, + repo_root, + &[ + "rev-parse", + "--verify", + "--quiet", + &format!("refs/heads/{name}"), + ], + ) + .is_ok() +} + +#[derive(Debug, Clone)] +pub struct ManagedWorktree { + pub path: PathBuf, + pub branch: String, + pub main_root: PathBuf, + pub dirty: bool, +} + +pub fn managed(host: &dyn Host, cwd: &Path) -> Option { + let cwd = host.canonicalize(cwd).ok()?; + let suffix = host.join(Path::new(".tty7"), "worktrees"); + if !cwd.ancestors().any(|a| a.ends_with(&suffix)) { + return None; + } + let path = git_path( + host, + &git(host, &cwd, &["rev-parse", "--show-toplevel"]).ok()?, + ); + let main_root = git( + host, + &path, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + ) + .ok() + .map(|d| git_path(host, &d))? + .parent()? + .to_path_buf(); + if !path.starts_with(managed_root(host, &main_root)) { + return None; + } + let branch = git(host, &path, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?; + let dirty = !git(host, &path, &["status", "--porcelain"]) + .ok()? + .is_empty(); + Some(ManagedWorktree { + path, + branch, + main_root, + dirty, + }) +} + +pub fn occupied(host: &dyn Host, path: &Path, cwds: &[PathBuf]) -> bool { + let Ok(path) = host.canonicalize(path) else { + return false; + }; + cwds.iter() + .any(|c| host.canonicalize(c).is_ok_and(|c| c.starts_with(&path))) +} + +pub fn remove(host: &dyn Host, wt: &ManagedWorktree, force: bool) -> Result { + let path = wt.path.to_str().ok_or("worktree path is not valid UTF-8")?; + if wt.dirty && !force { + return Err(format!("{} has uncommitted changes", wt.path.display())); + } + // Uncommitted work is only thrown away once it is safe somewhere else. + let snapshot = match snapshot(host, wt) { + Ok(r) => Some(r), + Err(e) if wt.dirty => { + return Err(format!( + "could not save the uncommitted changes, so nothing was removed: {e}" + )); + } + Err(e) => { + log::warn!("no snapshot of {}: {e}", wt.path.display()); + None + } + }; + let mut args = vec!["worktree", "remove"]; + if force { + args.push("--force"); + } + args.push(path); + git(host, &wt.main_root, &args)?; + let branch_kept = + wt.branch != "HEAD" && git(host, &wt.main_root, &["branch", "-d", &wt.branch]).is_err(); + Ok(Removed { + snapshot, + branch_kept, + }) +} + +/// File the checkout's state under [`TRASH_REFS`]`/`: its commit, or a +/// stash-shaped commit on top of it when there are changes, untracked files +/// included. +fn snapshot(host: &dyn Host, wt: &ManagedWorktree) -> Result { + let name = wt + .path + .file_name() + .ok_or("worktree has no name")? + .to_string_lossy() + .into_owned(); + let head = || git(host, &wt.path, &["rev-parse", "HEAD"]); + let commit = if wt.dirty { + // `stash create` only sees the index. The checkout is about to go, so + // staging everything in it costs nothing. + git(host, &wt.path, &["add", "-A"])?; + let made = git( + host, + &wt.path, + &["stash", "create", "tty7: removed worktree"], + )?; + if made.is_empty() { head()? } else { made } + } else { + head()? + }; + let refname = format!("{TRASH_REFS}/{name}"); + git(host, &wt.main_root, &["update-ref", &refname, &commit])?; + Ok(refname) +} + +/// Names removed worktrees left behind. +fn retired(host: &dyn Host, repo_root: &Path) -> Vec { + git( + host, + repo_root, + &["for-each-ref", "--format=%(refname:strip=3)", TRASH_REFS], + ) + .map(|out| out.lines().map(str::to_string).collect()) + .unwrap_or_default() +} + +fn repo_dir(host: &dyn Host, cwd: &Path) -> Result<(PathBuf, PathBuf), String> { + let repo_root = git(host, cwd, &["rev-parse", "--show-toplevel"]) + .map_err(|_| "not inside a git repository".to_string())?; + let repo_root = git_path(host, &repo_root); + let main_root = git( + host, + cwd, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + ) + .ok() + .map(|d| git_path(host, &d)) + .and_then(|d| d.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| repo_root.clone()); + let dir = managed_root(host, &main_root); + Ok((repo_root, dir)) +} + +pub fn defaults(host: &dyn Host, cwd: &Path) -> Result { + let (repo_root, dir) = repo_dir(host, cwd)?; + let retired = retired(host, &repo_root); + let name = Names::new().unique(|name| { + retired.iter().any(|r| r == name) + || branch_exists(host, &repo_root, name) + || host.exists(&host.join(&dir, name)) + }); + let has_setup = setup::find(host, &repo_root).is_some(); + Ok(WorktreeDefaults { + name, + base: default_base(host, &repo_root), + dir, + has_setup, + setup_hint: (!has_setup) + .then(|| setup::hint(host, &repo_root)) + .flatten(), + }) +} + +/// The remote's default branch (`origin/main`), or the current branch when +/// there is no remote to start from. +fn default_base(host: &dyn Host, repo_root: &Path) -> String { + let verify = |r: &str| git(host, repo_root, &["rev-parse", "--verify", "--quiet", r]).is_ok(); + git( + host, + repo_root, + &[ + "symbolic-ref", + "--quiet", + "--short", + "refs/remotes/origin/HEAD", + ], + ) + .ok() + .or_else(|| { + ["main", "master"] + .into_iter() + .find(|b| verify(&format!("refs/remotes/origin/{b}"))) + .map(|b| format!("origin/{b}")) + }) + .unwrap_or_else(|| { + git(host, repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) + .unwrap_or_else(|_| "HEAD".to_string()) + }) +} + +/// Bring `base` up to date when it is a remote-tracking branch. Best effort: +/// offline, the local copy of the ref is still a fine place to start. +fn refresh(host: &dyn Host, repo_root: &Path, base: &str) { + let tracking = format!("refs/remotes/{base}"); + if git( + host, + repo_root, + &["rev-parse", "--verify", "--quiet", &tracking], + ) + .is_err() + { + return; + } + let Some((remote, branch)) = base.split_once('/') else { + return; + }; + let _ = host.git_with_deadline( + repo_root, + &["fetch", "--quiet", "--no-tags", remote, branch], + std::time::Duration::from_secs(15), + ); +} + +/// Keep `.tty7/worktrees/` out of `git status` without a file in the tree, +/// so the repo's `.tty7/setup` stays committable. Repos set up before this +/// carry a `.tty7/.gitignore` of `*`, which hid the setup script too. +fn exclude(host: &dyn Host, main_root: &Path) { + let legacy = host.join(&host.join(main_root, ".tty7"), ".gitignore"); + if host.read_file(&legacy, 64).is_ok_and(|b| b == b"*\n") { + let _ = host.remove(&legacy, false); + } + let Ok(common) = git( + host, + main_root, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + ) else { + return; + }; + let info = host.join(&git_path(host, &common), "info"); + let file = host.join(&info, "exclude"); + const LINE: &str = "/.tty7/worktrees/"; + let current = host.read_file(&file, 1 << 20).unwrap_or_default(); + let current = String::from_utf8_lossy(¤t); + if current.lines().any(|l| l.trim() == LINE) { + return; + } + let mut next = current.into_owned(); + if !next.is_empty() && !next.ends_with('\n') { + next.push('\n'); + } + next.push_str(LINE); + next.push('\n'); + let _ = host.create_dir(&info, true); + let _ = host.write_file(&file, next.as_bytes()); +} + +pub fn create(host: &dyn Host, cwd: &Path, req: &WorktreeRequest) -> Result { + if req.name.is_empty() || req.name == "." || req.name == ".." || req.name.contains(['/', '\\']) + { + return Err(format!("invalid worktree name \"{}\"", req.name)); + } + let (repo_root, dir) = repo_dir(host, cwd)?; + let main_root = dir + .parent() + .and_then(Path::parent) + .expect(".tty7/worktrees sits two levels under the main checkout") + .to_path_buf(); + host.create_dir(&dir, true) + .map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + exclude(host, &main_root); + + let path = host.join(&dir, &req.name); + if host.exists(&path) { + return Err(format!("{} already exists", path.display())); + } + refresh(host, &repo_root, &req.base); + git( + host, + &repo_root, + &[ + "worktree", + "add", + // Starting from `origin/main` must not make the branch track it: + // its first push belongs on a branch of its own name. + "--no-track", + "-b", + &req.branch, + path.to_str().ok_or("worktree path is not valid UTF-8")?, + &req.base, + ], + )?; + let carried = include::carry(host, &main_root, &path); + Ok(NewWorktree { + path, + branch: req.branch.clone(), + main_root, + carried, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("tty7-wt-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn plain(p: &Path) -> PathBuf { + let s = p.to_string_lossy(); + PathBuf::from(s.strip_prefix(r"\\?\").unwrap_or(&s).to_string()) + } + + fn sh(dir: &Path, args: &[&str]) { + assert!( + std::process::Command::new(args[0]) + .args(&args[1..]) + .current_dir(dir) + .output() + .unwrap() + .status + .success(), + "command failed: {args:?}" + ); + } + + fn temp_repo(name: &str) -> PathBuf { + let dir = scratch(name); + sh(&dir, &["git", "init", "-q"]); + sh(&dir, &["git", "config", "user.email", "t@t"]); + sh(&dir, &["git", "config", "user.name", "t"]); + std::fs::write(dir.join("a.txt"), "a").unwrap(); + sh(&dir, &["git", "add", "."]); + sh(&dir, &["git", "commit", "-q", "-m", "init"]); + dir + } + + fn h() -> crate::host::SharedHost { + crate::host::local::LocalHost::new() + } + + fn req(name: &str) -> WorktreeRequest { + WorktreeRequest { + name: name.into(), + branch: name.into(), + base: "HEAD".into(), + } + } + + #[test] + fn defaults_proposes_fresh_name_current_branch_and_target_dir() { + let h = h(); + let repo = temp_repo("dflt"); + let d = defaults(&*h, &repo).unwrap(); + assert!(!branch_exists(&*h, &repo, &d.name)); + let head = git(&*h, &repo, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); + assert_eq!(d.base, head); + let canon = plain(&std::fs::canonicalize(&repo).unwrap()); + assert_eq!(plain(&d.dir), canon.join(".tty7").join("worktrees")); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn create_makes_worktree_on_new_branch_inside_the_repo() { + let h = h(); + let repo = temp_repo("repo"); + let wt = create(&*h, &repo, &req("quiet-otter")).unwrap(); + assert!(wt.path.join("a.txt").exists()); + assert!(branch_exists(&*h, &repo, &wt.branch)); + let canon = plain(&std::fs::canonicalize(&repo).unwrap()); + assert_eq!(plain(&wt.path), canon.join(".tty7/worktrees/quiet-otter")); + let head = git(&*h, &wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); + assert_eq!(head, wt.branch); + assert!(!canon.join(".tty7/.gitignore").exists()); + let exclude = std::fs::read_to_string(canon.join(".git/info/exclude")).unwrap(); + assert_eq!( + exclude + .lines() + .filter(|l| *l == "/.tty7/worktrees/") + .count(), + 1 + ); + assert_eq!(git(&*h, &repo, &["status", "--porcelain"]).unwrap(), ""); + assert!( + create(&*h, &repo, &req("quiet-otter")) + .unwrap_err() + .contains("already exists") + ); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn create_honors_custom_branch_and_base() { + let h = h(); + let repo = temp_repo("base"); + sh(&repo, &["git", "branch", "stable"]); + std::fs::write(repo.join("b.txt"), "b").unwrap(); + sh(&repo, &["git", "add", "."]); + sh(&repo, &["git", "commit", "-q", "-m", "second"]); + let wt = create( + &*h, + &repo, + &WorktreeRequest { + name: "my-dir".into(), + branch: "feat/my-branch".into(), + base: "stable".into(), + }, + ) + .unwrap(); + assert_eq!(wt.path.file_name().unwrap().to_str().unwrap(), "my-dir"); + let head = git(&*h, &wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); + assert_eq!(head, "feat/my-branch"); + assert!(wt.path.join("a.txt").exists()); + assert!(!wt.path.join("b.txt").exists()); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn create_rejects_escaping_names() { + let h = h(); + let repo = temp_repo("names"); + for bad in ["", ".", "..", "a/b", "a\\b"] { + let mut r = req("x"); + r.name = bad.into(); + assert!( + create(&*h, &repo, &r) + .unwrap_err() + .contains("invalid worktree name"), + "{bad:?} should be rejected" + ); + } + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn create_from_a_linked_worktree_lands_in_the_main_repo() { + let h = h(); + let repo = temp_repo("nest"); + let first = create(&*h, &repo, &req("first-wt")).unwrap(); + let second = create(&*h, &first.path, &req("second-wt")).unwrap(); + assert_eq!(second.path.parent().unwrap(), first.path.parent().unwrap()); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn managed_resolves_managed_checkouts_and_remove_cleans_up() { + let h = h(); + let repo = temp_repo("mg"); + let wt = create(&*h, &repo, &req("mg-wt")).unwrap(); + assert!(managed(&*h, &repo).is_none()); + let own = scratch("mg-own"); + let _ = std::fs::remove_dir_all(&own); + sh( + &repo, + &[ + "git", + "worktree", + "add", + "-b", + "own-branch", + own.to_str().unwrap(), + ], + ); + assert!(managed(&*h, &own).is_none()); + let sub = wt.path.join("sub"); + std::fs::create_dir_all(&sub).unwrap(); + let m = managed(&*h, &sub).unwrap(); + assert_eq!(m.branch, wt.branch); + assert_eq!(m.path, wt.path); + assert!(!m.dirty); + std::fs::write(wt.path.join("b.txt"), "b").unwrap(); + let m = managed(&*h, &wt.path).unwrap(); + assert!(m.dirty); + assert!(remove(&*h, &m, false).is_err()); + remove(&*h, &m, true).unwrap(); + assert!(!wt.path.exists()); + assert!(!branch_exists(&*h, &repo, &wt.branch)); + let _ = std::fs::remove_dir_all(&repo); + let _ = std::fs::remove_dir_all(&own); + } + + #[test] + fn writes_survive_the_optional_locks_invariant() { + let h = h(); + let repo = temp_repo("locks"); + + let wt = create(&*h, &repo, &req("lock-wt")).unwrap(); + assert!(wt.path.join("a.txt").exists()); + + let list = git(&*h, &repo, &["worktree", "list", "--porcelain"]).unwrap(); + assert!( + list.lines() + .any(|l| l.starts_with("branch ") && l.ends_with(&wt.branch)), + "worktree list must show the new checkout: {list}" + ); + + std::fs::write(wt.path.join("c.txt"), "c").unwrap(); + git(&*h, &wt.path, &["add", "."]).unwrap(); + git( + &*h, + &wt.path, + &[ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-qm", + "c", + ], + ) + .unwrap(); + assert_eq!(git(&*h, &wt.path, &["status", "--porcelain"]).unwrap(), ""); + + let m = managed(&*h, &wt.path).unwrap(); + remove(&*h, &m, false).unwrap(); + assert!(!wt.path.exists()); + assert!(branch_exists(&*h, &repo, &wt.branch)); + let plain_wt = create(&*h, &repo, &req("lock-wt2")).unwrap(); + let m = managed(&*h, &plain_wt.path).unwrap(); + remove(&*h, &m, false).unwrap(); + assert!(!branch_exists(&*h, &repo, &plain_wt.branch)); + + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn occupied_detects_live_cwds_inside_the_worktree() { + let h = h(); + let repo = temp_repo("occ"); + let wt = create(&*h, &repo, &req("occ-wt")).unwrap(); + let inside = wt.path.join("deep"); + std::fs::create_dir_all(&inside).unwrap(); + assert!(occupied(&*h, &wt.path, &[repo.clone(), inside])); + assert!(!occupied(&*h, &wt.path, std::slice::from_ref(&repo))); + assert!(!occupied(&*h, &wt.path, &[wt.path.join("gone")])); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn create_outside_a_repo_errors() { + let h = h(); + let plain = scratch("plain"); + let err = create(&*h, &plain, &req("x")).unwrap_err(); + assert_eq!(err, "not inside a git repository"); + assert_eq!( + defaults(&*h, &plain).unwrap_err(), + "not inside a git repository" + ); + let _ = std::fs::remove_dir_all(&plain); + } + + #[test] + fn create_carries_worktreeinclude_matches_that_git_ignores() { + let h = h(); + let repo = temp_repo("incl"); + std::fs::write(repo.join(".gitignore"), ".env*\nnode_modules/\nlocal/\n").unwrap(); + std::fs::write( + repo.join(".worktreeinclude"), + ".env\n.env.local\nnode_modules/\n/local/cfg/*.json\nloose.txt\n", + ) + .unwrap(); + sh(&repo, &["git", "add", "."]); + sh(&repo, &["git", "commit", "-q", "-m", "include"]); + for (file, body) in [ + (".env", "A=1"), + (".env.local", "B=2"), + (".env.other", "C=3"), + ("node_modules/x/y.js", "y"), + ("local/cfg/a.json", "{}"), + ("local/cfg/b.txt", "b"), + ("loose.txt", "not ignored"), + ] { + let p = repo.join(file); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, body).unwrap(); + } + #[cfg(unix)] + std::os::unix::fs::symlink("x/y.js", repo.join("node_modules/link.js")).unwrap(); + + let wt = create(&*h, &repo, &req("incl-wt")).unwrap(); + let mut copied = wt.carried.copied.clone(); + copied.sort(); + assert_eq!( + copied, + [".env", ".env.local", "local/cfg/a.json", "node_modules"] + ); + assert!(wt.carried.skipped.is_empty(), "{:?}", wt.carried.skipped); + assert_eq!( + std::fs::read_to_string(wt.path.join(".env")).unwrap(), + "A=1" + ); + assert!(wt.path.join("node_modules/x/y.js").exists()); + #[cfg(unix)] + assert!( + std::fs::symlink_metadata(wt.path.join("node_modules/link.js")) + .unwrap() + .file_type() + .is_symlink() + ); + assert!(wt.path.join("local/cfg/a.json").exists()); + assert!(!wt.path.join("local/cfg/b.txt").exists()); + assert!(!wt.path.join(".env.other").exists()); + assert!(!wt.path.join("loose.txt").exists()); + assert_eq!(git(&*h, &wt.path, &["status", "--porcelain"]).unwrap(), ""); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn remove_snapshots_the_checkout_and_retires_its_name() { + let h = h(); + let repo = temp_repo("snap"); + let wt = create(&*h, &repo, &req("snap-wt")).unwrap(); + std::fs::write(wt.path.join("a.txt"), "changed").unwrap(); + std::fs::write(wt.path.join("new.txt"), "untracked").unwrap(); + let m = managed(&*h, &wt.path).unwrap(); + assert!(remove(&*h, &m, false).unwrap_err().contains("uncommitted")); + assert!(retired(&*h, &repo).is_empty()); + + let removed = remove(&*h, &m, true).unwrap(); + assert_eq!(removed.snapshot.as_deref(), Some("refs/tty7/trash/snap-wt")); + assert!(!removed.branch_kept); + assert!(!wt.path.exists()); + let show = |f: &str| { + git( + &*h, + &repo, + &["show", &format!("refs/tty7/trash/snap-wt:{f}")], + ) + }; + assert_eq!(show("a.txt").unwrap(), "changed"); + assert_eq!(show("new.txt").unwrap(), "untracked"); + assert_eq!(retired(&*h, &repo), ["snap-wt"]); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn remove_reports_a_branch_it_had_to_keep() { + let h = h(); + let repo = temp_repo("kept"); + let wt = create(&*h, &repo, &req("kept-wt")).unwrap(); + std::fs::write(wt.path.join("c.txt"), "c").unwrap(); + sh(&wt.path, &["git", "add", "."]); + sh(&wt.path, &["git", "commit", "-q", "-m", "c"]); + let removed = remove(&*h, &managed(&*h, &wt.path).unwrap(), false).unwrap(); + assert!(removed.branch_kept); + assert!(branch_exists(&*h, &repo, "kept-wt")); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn defaults_start_from_the_remote_default_branch_and_create_fetches_it() { + let h = h(); + let upstream = temp_repo("up"); + let clone = scratch("clone"); + sh( + clone.parent().unwrap(), + &[ + "git", + "clone", + "-q", + upstream.to_str().unwrap(), + clone.to_str().unwrap(), + ], + ); + let d = defaults(&*h, &clone).unwrap(); + let expected = git( + &*h, + &clone, + &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], + ) + .unwrap(); + assert_eq!(d.base, expected); + + // Upstream moves on after the clone; the worktree still starts there. + std::fs::write(upstream.join("later.txt"), "l").unwrap(); + sh(&upstream, &["git", "add", "."]); + sh(&upstream, &["git", "commit", "-q", "-m", "later"]); + let mut r = req("fresh-wt"); + r.base = d.base.clone(); + let wt = create(&*h, &clone, &r).unwrap(); + assert!(wt.path.join("later.txt").exists()); + assert!(git(&*h, &wt.path, &["rev-parse", "--abbrev-ref", "@{upstream}"]).is_err()); + let _ = std::fs::remove_dir_all(&upstream); + let _ = std::fs::remove_dir_all(&clone); + } + + #[test] + fn create_replaces_the_legacy_catch_all_gitignore() { + let h = h(); + let repo = temp_repo("legacy"); + std::fs::create_dir_all(repo.join(".tty7")).unwrap(); + std::fs::write(repo.join(".tty7/.gitignore"), "*\n").unwrap(); + std::fs::write(repo.join(".tty7/setup"), "#!/bin/sh\n").unwrap(); + create(&*h, &repo, &req("legacy-wt")).unwrap(); + create(&*h, &repo, &req("legacy-wt2")).unwrap(); + assert!(!repo.join(".tty7/.gitignore").exists()); + assert_eq!( + git(&*h, &repo, &["status", "--porcelain"]).unwrap(), + "?? .tty7/" + ); + let exclude = std::fs::read_to_string(repo.join(".git/info/exclude")).unwrap(); + assert_eq!( + exclude + .lines() + .filter(|l| *l == "/.tty7/worktrees/") + .count(), + 1 + ); + let _ = std::fs::remove_dir_all(&repo); + } +} diff --git a/crates/tty7-core/src/core/worktree/setup.rs b/crates/tty7-core/src/core/worktree/setup.rs new file mode 100644 index 00000000..c0460ef6 --- /dev/null +++ b/crates/tty7-core/src/core/worktree/setup.rs @@ -0,0 +1,148 @@ +//! `.tty7/setup`: the project's own "make this checkout runnable" step — +//! install dependencies, write a `.env.local`, seed a database. +//! +//! It is an executable in the repo, any language, run in the new worktree's +//! first pane before the agent starts, so its output is on screen and a +//! failure stops there instead of under an agent that cannot build. tty7 does +//! not run it anywhere else: the `Host` trait cannot run a command, and a +//! pane is where a person can see what a script did. +//! +//! It is code from the repo, so it runs only once someone has approved that +//! exact content ([`Setup::digest`]) for that repo; an edit asks again. +//! +//! The script is told where the main checkout and the worktree are, and +//! `TTY7_PORT` starts a block of ten ports the +//! worktree can use without colliding with its siblings — derived from its +//! path, so no allocator has to remember anything and a worktree recreated at +//! the same place gets the same ports back. + +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use crate::host::{Host, HostId, fnv1a64}; + +pub const SCRIPT: &[&str] = &[".tty7", "setup"]; + +const PORT_FIRST: u16 = 20_000; +const PORT_BLOCKS: u64 = 1_000; +pub const PORT_BLOCK: u16 = 10; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Setup { + pub script: PathBuf, + /// sha256 of the script, hex: what an approval is for. + pub digest: String, +} + +/// The worktree's setup script, if it has one. +pub fn find(host: &dyn Host, worktree: &Path) -> Option { + let script = SCRIPT + .iter() + .fold(worktree.to_path_buf(), |p, c| host.join(&p, c)); + let bytes = host.read_file(&script, 1 << 20).ok()?; + Some(Setup { + script, + digest: hex(&Sha256::digest(&bytes)), + }) +} + +/// What an approval is filed under: the repo, on the machine it lives on. +pub fn trust_key(host: HostId, main_root: &Path) -> String { + format!("{}:{}", host.0, main_root.display()) +} + +/// The first of the worktree's [`PORT_BLOCK`] ports. +pub fn port_base(worktree: &Path) -> u16 { + let h = fnv1a64(worktree.to_string_lossy().as_bytes()); + PORT_FIRST + (h % PORT_BLOCKS) as u16 * PORT_BLOCK +} + +/// The variables the script runs with. `None` unless both paths are +/// absolute: a script doing `rm -rf "$TTY7_WORKTREE_PATH/dist"` must never +/// see an empty or relative value. +pub fn env( + host: &dyn Host, + main_root: &Path, + worktree: &Path, +) -> Option> { + if !host.is_absolute(main_root) || !host.is_absolute(worktree) { + return None; + } + let name = worktree.file_name()?.to_string_lossy().into_owned(); + Some(vec![ + ("TTY7_ROOT_PATH", main_root.display().to_string()), + ("TTY7_WORKTREE_PATH", worktree.display().to_string()), + ("TTY7_WORKTREE_NAME", name), + ("TTY7_PORT", port_base(worktree).to_string()), + ]) +} + +/// For a repo without a setup script: the install its lockfile implies, as a +/// suggestion to show — never something run on the user's behalf. +pub fn hint(host: &dyn Host, worktree: &Path) -> Option<&'static str> { + const LOCKS: &[(&str, &str)] = &[ + ("pnpm-lock.yaml", "pnpm install"), + ("bun.lock", "bun install"), + ("bun.lockb", "bun install"), + ("yarn.lock", "yarn install"), + ("package-lock.json", "npm ci"), + ("uv.lock", "uv sync"), + ("poetry.lock", "poetry install"), + ("Gemfile.lock", "bundle install"), + ("composer.lock", "composer install"), + ("mix.lock", "mix deps.get"), + ]; + LOCKS + .iter() + .find(|(file, _)| host.exists(&host.join(worktree, file))) + .map(|(_, cmd)| *cmd) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ports_are_stable_blocks_inside_the_range() { + let a = port_base(Path::new("/r/.tty7/worktrees/quiet-otter")); + assert_eq!(a, port_base(Path::new("/r/.tty7/worktrees/quiet-otter"))); + assert_eq!((a - PORT_FIRST) % PORT_BLOCK, 0); + assert!(a >= PORT_FIRST && a + PORT_BLOCK <= PORT_FIRST + 10_000); + assert_ne!(a, port_base(Path::new("/r/.tty7/worktrees/amber-heron"))); + } + + #[test] + fn env_refuses_relative_paths() { + let h = crate::host::local::LocalHost::new(); + // Absolute on every platform: `/abs` is not, on Windows. + let root = std::env::temp_dir(); + let wt = root.join(".tty7").join("worktrees").join("w"); + assert!(env(&*h, Path::new("rel"), &wt).is_none()); + assert!(env(&*h, &root, Path::new("")).is_none()); + let vars = env(&*h, &root, &wt).unwrap(); + assert_eq!(vars[2], ("TTY7_WORKTREE_NAME", "w".to_string())); + } + + #[test] + fn find_digests_the_script_and_hint_reads_lockfiles() { + let dir = std::env::temp_dir().join(format!("tty7-setup-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join(".tty7")).unwrap(); + let h = crate::host::local::LocalHost::new(); + assert!(find(&*h, &dir).is_none()); + assert_eq!(hint(&*h, &dir), None); + std::fs::write(dir.join("pnpm-lock.yaml"), "").unwrap(); + assert_eq!(hint(&*h, &dir), Some("pnpm install")); + std::fs::write(dir.join(".tty7/setup"), "#!/bin/sh\n").unwrap(); + let a = find(&*h, &dir).unwrap(); + assert_eq!(a.digest.len(), 64); + std::fs::write(dir.join(".tty7/setup"), "#!/bin/sh\necho x\n").unwrap(); + assert_ne!(find(&*h, &dir).unwrap().digest, a.digest); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/ui/agent_launch.rs b/src/ui/agent_launch.rs index cb574cd6..e229f395 100644 --- a/src/ui/agent_launch.rs +++ b/src/ui/agent_launch.rs @@ -149,7 +149,7 @@ pub(crate) fn fork_line( /// Type `command` into the shell `slot` holds — now if it is up, or the /// moment it lands if it is still connecting. -fn run_when_ready(slot: &PaneSlot, command: String, cx: &mut App) { +pub(crate) fn run_when_ready(slot: &PaneSlot, command: String, cx: &mut App) { match slot { PaneSlot::Ready(view) => view.read(cx).run_command_line(&command), PaneSlot::Connecting(pending) => { diff --git a/src/ui/app.rs b/src/ui/app.rs index d634ad20..7ede071a 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -5243,8 +5243,16 @@ impl Tty7App { move |_this, found, cx| { let Some(wt) = found else { return }; let path = wt.path.display().to_string(); + let name = wt + .path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); let detail = if wt.dirty { - t_fmt(L10nKey::AppWorktreeRemoveDetailDirty, &[("path", &path)]) + t_fmt( + L10nKey::AppWorktreeRemoveDetailDirty, + &[("path", &path), ("name", &name)], + ) } else { t_fmt(L10nKey::AppWorktreeRemoveDetailClean, &[("path", &path)]) }; @@ -5283,8 +5291,15 @@ impl Tty7App { cx, move |h| crate::core::worktree::remove(h, &wt, force), move |_this, result, window, cx| match result { - Ok(()) => window.push_notification( - t_fmt(L10nKey::AppWorktreeRemoved, &[("branch", &branch)]), + Ok(removed) => window.push_notification( + t_fmt( + if removed.branch_kept { + L10nKey::AppWorktreeRemovedBranchKept + } else { + L10nKey::AppWorktreeRemoved + }, + &[("branch", &branch)], + ), cx, ), Err(e) => window.push_notification( @@ -5633,9 +5648,12 @@ impl Tty7App { ); } + /// Open `wt` in a new tab named after its branch, typing `first` — its + /// setup and agent — into the shell there. pub(crate) fn open_worktree_tab( &mut self, wt: crate::core::worktree::NewWorktree, + first: Option, window: &mut Window, cx: &mut Context, ) { @@ -5662,6 +5680,9 @@ impl Tty7App { return; } }; + if let Some(first) = first { + crate::ui::agent_launch::run_when_ready(&view, first, cx); + } self.remember_active_pane(window, cx); self.maximized = None; let insert_at = self.new_tab_insert_at(cx); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index b9b91c4a..72259179 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1861,7 +1861,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { "config.json could not be read. tty7 kept the settings it is running on and left the file exactly as it is. Fix its permissions or contents and tty7 reloads; saving a setting first overwrites it." } L10nKey::AppWorktreeRemoveDetailDirty => { - "The closed tab's worktree at {path} has uncommitted changes." + "The closed tab's worktree at {path} has uncommitted changes. They are saved to refs/tty7/trash/{name} before anything is deleted." } L10nKey::AppWorktreeRemoveDetailClean => "The closed tab's worktree at {path} is clean.", L10nKey::AppWorktreeRemoveTitle => "Remove worktree \"{branch}\"?", @@ -1884,6 +1884,17 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::PaneDragHandleTooltip => "Drag to move this pane", L10nKey::AppWorktreeRemoved => "Removed worktree \"{branch}\"", L10nKey::AppWorktreeRemoveFailed => "Worktree removal failed: {error}", + L10nKey::WorktreePromptAgent => "Start", + 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::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::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::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 44b64e7d..6784b6c7 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1904,7 +1904,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "config.json を読み込めませんでした。実行中の設定を保持し、ファイルもそのままです。権限か内容を直せば自動で再読み込みされます。それまでに設定を保存すると上書きされます" } L10nKey::AppWorktreeRemoveDetailDirty => { - "閉じたタブの {path} にあるワークツリーには未コミットの変更があります" + "閉じたタブの {path} にあるワークツリーには未コミットの変更があります。削除前に refs/tty7/trash/{name} へ保存します" } L10nKey::AppWorktreeRemoveDetailClean => { "閉じたタブの {path} にあるワークツリーはクリーンです" @@ -1929,6 +1929,17 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::PaneDragHandleTooltip => "ドラッグしてこのペインを移動", L10nKey::AppWorktreeRemoved => "ワークツリー「{branch}」を削除しました", L10nKey::AppWorktreeRemoveFailed => "ワークツリーの削除に失敗しました: {error}", + L10nKey::WorktreePromptAgent => "起動", + L10nKey::WorktreePromptShell => "シェル", + L10nKey::WorktreePromptTask => "タスク", + L10nKey::WorktreePromptSetup => "最初に .tty7/setup を実行", + L10nKey::WorktreePromptSetupHint => ".tty7/setup がありません。追加すると新しいワークツリーで `{command}` を実行します", + L10nKey::AppWorktreeSetupTitle => "このリポジトリのセットアップスクリプトを実行しますか?", + L10nKey::AppWorktreeSetupDetail => "{path} は新しいタブで最初に実行されます。このリポジトリを信頼できる場合のみ許可してください。スクリプトが変わるたびに再確認します", + L10nKey::AppWorktreeSetupRun => "実行", + L10nKey::AppWorktreeSetupSkip => "スキップ", + L10nKey::AppWorktreeNotCarried => ".worktreeinclude から複製されませんでした: {paths}", + L10nKey::AppWorktreeRemovedBranchKept => "ワークツリーを削除しました。ブランチ「{branch}」には未マージのコミットがあるため残しました", L10nKey::AppForkStillConnecting => "フォークできませんでした: ペインはまだ接続中です", L10nKey::AppPaneNoCodingAgent => "このペインはコーディングエージェントを実行していません", L10nKey::AppForkNoCommand => "tty7 には {name} 用のフォークコマンドがありません", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 8ebfec1d..3b5f1460 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1590,6 +1590,17 @@ l10n_keys! { PaneDragHandleTooltip, AppWorktreeRemoved, AppWorktreeRemoveFailed, + WorktreePromptAgent, + WorktreePromptShell, + WorktreePromptTask, + WorktreePromptSetup, + WorktreePromptSetupHint, + AppWorktreeSetupTitle, + AppWorktreeSetupDetail, + AppWorktreeSetupRun, + AppWorktreeSetupSkip, + AppWorktreeNotCarried, + AppWorktreeRemovedBranchKept, AppForkStillConnecting, AppPaneNoCodingAgent, AppForkNoCommand, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index e797ff9a..6443857e 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1730,7 +1730,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { "config.json 读取失败。tty7 保留了当前在用的设置,文件也原样保留。修好权限或内容后会自动重载;在此之前保存设置会覆盖它。" } L10nKey::AppWorktreeRemoveDetailDirty => { - "位于 {path} 的已关闭标签页的 worktree 有未提交的变更。" + "位于 {path} 的已关闭标签页的 worktree 有未提交的变更。删除前会先存到 refs/tty7/trash/{name}。" } L10nKey::AppWorktreeRemoveDetailClean => "位于 {path} 的已关闭标签页的 worktree 是干净的。", L10nKey::AppWorktreeRemoveTitle => "删除 worktree“{branch}”?", @@ -1751,6 +1751,17 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::PaneDragHandleTooltip => "拖动可把这个窗格挪到别处", L10nKey::AppWorktreeRemoved => "已删除 worktree“{branch}”", L10nKey::AppWorktreeRemoveFailed => "删除 worktree 失败:{error}", + L10nKey::WorktreePromptAgent => "启动", + L10nKey::WorktreePromptShell => "Shell", + L10nKey::WorktreePromptTask => "任务", + L10nKey::WorktreePromptSetup => "先运行 .tty7/setup", + L10nKey::WorktreePromptSetupHint => "没有 .tty7/setup。加一个,新 worktree 就会先运行 `{command}`", + L10nKey::AppWorktreeSetupTitle => "运行这个仓库的 setup 脚本?", + L10nKey::AppWorktreeSetupDetail => "{path} 会在新标签页里最先运行。只在信任这个仓库时批准;脚本内容一变,tty7 会再问一次。", + L10nKey::AppWorktreeSetupRun => "运行", + L10nKey::AppWorktreeSetupSkip => "跳过", + L10nKey::AppWorktreeNotCarried => "未按 .worktreeinclude 复制:{paths}", + 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 b504eb73..e137e88d 100644 --- a/src/ui/worktree_prompt.rs +++ b/src/ui/worktree_prompt.rs @@ -1,13 +1,24 @@ -use gpui::{AnyElement, Context, Entity, Subscription, Window, div, prelude::*, px, rems}; +use std::path::Path; + +use gpui::{ + AnyElement, Context, Entity, PromptLevel, Subscription, Window, div, prelude::*, px, rems, +}; use gpui_component::input::{Input, InputEvent, InputState}; use gpui_component::{ActiveTheme as _, WindowExt as _}; -use crate::core::worktree::{WorktreeDefaults, WorktreeRequest}; +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}; +use crate::ui::host_ops::HostId; use crate::ui::i18n::{L10nKey, t, t_fmt}; use crate::ui::right_panel::META_MONO; +/// How many agents the Start switch offers next to Shell. +const AGENTS_OFFERED: usize = 3; + pub(crate) struct WorktreePrompt { host: crate::ui::host_ops::SharedHost, cwd: std::path::PathBuf, @@ -15,10 +26,63 @@ pub(crate) struct WorktreePrompt { name: Entity, branch: Entity, base: Entity, + task: Entity, + agents: Vec, + /// Which of `agents` the first pane starts; `None` is a plain shell. + agent: Option, + has_setup: bool, + setup_hint: Option<&'static str>, busy: bool, _subs: Vec, } +/// What the new worktree's first pane was asked to start. +struct Start { + agent: Option, + task: String, +} + +/// A created worktree, and the setup script its checkout carries. +struct Created { + wt: NewWorktree, + 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, @@ -36,8 +100,9 @@ impl Tty7App { let name = prefill(defaults.name.clone(), window, cx); let branch = prefill(defaults.name, window, cx); let base = prefill(defaults.base, window, cx); + let task = cx.new(|cx| InputState::new(window, cx)); name.update(cx, |state, cx| state.focus(window, cx)); - let subs = [&name, &branch, &base] + let subs = [&name, &branch, &base, &task] .into_iter() .map(|input| { cx.subscribe_in( @@ -51,6 +116,13 @@ impl Tty7App { ) }) .collect(); + let mut agents = self.offered_agents(cx); + agents.truncate(AGENTS_OFFERED); + // A worktree is usually for a task, so it opens on the agent last + // used; Shell is one click away. + let agent = + crate::ui::agent_launch::most_recent(&agents, &cx.global::().agent_frecency) + .and_then(|a| agents.iter().position(|x| *x == a)); self.worktree_prompt = Some(WorktreePrompt { host, cwd, @@ -58,6 +130,11 @@ impl Tty7App { name, branch, base, + task, + agents, + agent, + has_setup: defaults.has_setup, + setup_hint: defaults.setup_hint, busy: false, _subs: subs, }); @@ -99,20 +176,34 @@ impl Tty7App { base }, }; + let start = Start { + agent: p.agent.map(|i| p.agents[i]), + task: p.task.read(cx).value().to_string(), + }; let p = self.worktree_prompt.as_mut().expect("checked above"); p.busy = true; let cwd = p.cwd.clone(); let host = p.host.clone(); + let host_id = host.id(); cx.notify(); crate::ui::host_ops::HostOps::run_in( host, window, cx, - move |h| crate::core::worktree::create(h, &cwd, &req), + move |h| { + let wt = crate::core::worktree::create(h, &cwd, &req)?; + // The script is a POSIX executable run through `env`; a + // Windows host has neither. + let setup = (h.separator() == '/') + .then(|| setup::find(h, &wt.path)) + .flatten() + .zip(setup::env(h, &wt.main_root, &wt.path)); + Ok::<_, String>(Created { wt, setup }) + }, move |this, result, window, cx| match result { - Ok(wt) => { + Ok(created) => { this.worktree_prompt = None; - this.open_worktree_tab(wt, window, cx); + this.start_worktree(host_id, created, start, window, cx); } Err(e) => { if let Some(p) = this.worktree_prompt.as_mut() { @@ -131,6 +222,71 @@ impl Tty7App { ); } + /// Open the worktree's tab, running its setup first — once the script's + /// content has been approved for this repo. + fn start_worktree( + &mut self, + host: HostId, + created: Created, + start: Start, + window: &mut Window, + cx: &mut Context, + ) { + let Created { wt, setup } = created; + if !wt.carried.skipped.is_empty() { + let paths = wt + .carried + .skipped + .iter() + .map(|(p, why)| format!("{p} ({why})")) + .collect::>() + .join(", "); + window.push_notification( + t_fmt(L10nKey::AppWorktreeNotCarried, &[("paths", &paths)]), + cx, + ); + } + let agent = start + .agent + .map(|a| agent_line(a, &start.task, cx.global::())); + let Some((script, env)) = setup else { + self.open_worktree_tab(wt, first_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); + self.open_worktree_tab(wt, line, window, cx); + return; + } + let answer = window.prompt( + PromptLevel::Warning, + t(L10nKey::AppWorktreeSetupTitle), + Some(&t_fmt( + L10nKey::AppWorktreeSetupDetail, + &[("path", &script.script.display().to_string())], + )), + &crate::ui::confirm_answers( + t(L10nKey::AppWorktreeSetupRun), + t(L10nKey::AppWorktreeSetupSkip), + ), + cx, + ); + cx.spawn_in(window, async move |this, cx| { + let run = matches!(answer.await, Ok(0)); + let _ = this.update_in(cx, |this, window, cx| { + if run { + this.update_config(cx, |cfg| { + cfg.worktree_setup_trust.insert(key, script.digest.clone()); + }); + } + let setup = run.then_some((script.script.as_path(), env.as_slice())); + this.open_worktree_tab(wt, first_line(setup, agent), window, cx); + }); + }) + .detach(); + } + pub(crate) fn render_worktree_prompt_overlay( &self, cx: &mut Context, @@ -159,6 +315,45 @@ impl Tty7App { }) .display() .to_string(); + let mono = cx.theme().mono_font_family.clone(); + let meta = move |text: String| { + div() + .truncate() + .text_size(rems(META_MONO)) + .font_family(mono.clone()) + .text_color(muted) + .child(text) + }; + let setup_note = match (p.has_setup, p.setup_hint) { + (true, _) => Some(t(L10nKey::WorktreePromptSetup).to_string()), + (false, Some(command)) => Some(t_fmt( + L10nKey::WorktreePromptSetupHint, + &[("command", command)], + )), + (false, None) => None, + }; + // Only an agent that can open on a first message gets the Task box; + // for the rest it would be a field that does nothing. + let takes_task = p + .agent + .is_some_and(|i| p.agents[i].prompt_args("").is_some()); + let start_labels: Vec<&str> = std::iter::once(t(L10nKey::WorktreePromptShell)) + .chain(p.agents.iter().map(|a| a.display_name())) + .collect(); + let start = (!p.agents.is_empty()).then(|| { + dialog::label(t(L10nKey::WorktreePromptAgent), cx).child(self.segmented( + "worktree-start", + &start_labels, + p.agent.map_or(0, |i| i + 1), + cx, + |this, ix, _window, cx| { + if let Some(p) = this.worktree_prompt.as_mut() { + p.agent = ix.checked_sub(1); + cx.notify(); + } + }, + )) + }); let rungs = dialog::popover_rungs(cx); let card = dialog::card(440., cx) @@ -169,25 +364,25 @@ impl Tty7App { // closer to it than the next field is. .child( dialog::labelled(t(L10nKey::WorktreePromptName), Input::new(&p.name), cx) - .child( - div() - .truncate() - .text_size(rems(META_MONO)) - .font_family(cx.theme().mono_font_family.clone()) - .text_color(muted) - .child(preview), - ), + .child(meta(preview)), ) .child(dialog::labelled( t(L10nKey::WorktreePromptBranch), Input::new(&p.branch), cx, )) - .child(dialog::labelled( - t(L10nKey::WorktreePromptBase), - Input::new(&p.base), - cx, - )), + .child( + dialog::labelled(t(L10nKey::WorktreePromptBase), Input::new(&p.base), cx) + .children(setup_note.map(meta)), + ) + .children(start) + .when(takes_task, |body| { + body.child(dialog::labelled( + t(L10nKey::WorktreePromptTask), + Input::new(&p.task), + cx, + )) + }), ) .child( dialog::footer(cx) @@ -245,3 +440,36 @@ 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"); + } +}