feat(worktree): setup script, .worktreeinclude, fresh base, recoverable removal

- Start new worktrees from the remote default branch, fetched first, with
  --no-track; fall back to the current branch without a remote.
- Carry gitignored files listed in .worktreeinclude over from the main
  checkout, copy-on-write (clonefile on macOS, copy_file_range elsewhere);
  remote hosts copy through the Host trait under a size budget.
- Run .tty7/setup in the first pane before the agent, with TTY7_ROOT_PATH,
  TTY7_WORKTREE_PATH, TTY7_WORKTREE_NAME and a per-worktree TTY7_PORT block.
  The script only runs once its exact content is approved for the repo.
- The New Worktree dialog picks what the first pane starts (Shell or a
  recent agent) and takes a task for agents that open on a first message.
- Removal snapshots the checkout, uncommitted work included, under
  refs/tty7/trash/<name> first; the ref also retires the name. A branch
  git refuses to delete is reported instead of silently kept.
- Hide .tty7/worktrees via info/exclude rather than a catch-all
  .tty7/.gitignore, so .tty7/setup can be committed.
This commit is contained in:
l0ng-ai
2026-09-29 10:07:14 +08:00
parent 4f8237f361
commit cd745ce154
13 changed files with 1614 additions and 457 deletions
+12
View File
@@ -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<Vec<String>> {
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
+5
View File
@@ -427,6 +427,10 @@ pub struct Config {
/// "New Agent Tab" opens.
#[serde(default)]
pub agent_frecency: HashMap<String, ProfileUsage>,
/// 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<String, String>,
/// Past agent sessions taken out of the search's Sessions tab, as
/// `<agent slug>:<session id>`. 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,
-431
View File
@@ -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<String, String> {
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<ManagedWorktree> {
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<WorktreeDefaults, String> {
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<NewWorktree, String> {
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);
}
}
@@ -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<String>,
/// 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<String>,
}
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<String> {
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<String>,
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<String>) -> Vec<String> {
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> = 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<String>) -> Vec<String> {
paths.sort();
paths.dedup();
let mut kept: Vec<String> = 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. `<sys/clonefile.h>`;
// 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"]);
}
}
+788
View File
@@ -0,0 +1,788 @@
//! Worktrees tty7 makes: one per task, under `<repo>/.tty7/worktrees/<name>`.
//!
//! - **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/<name>` before deleting anything, so even "Discard
//! Changes & Remove" can be undone with `git checkout refs/tty7/trash/<name>`.
//! - **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<String>,
/// `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<String, String> {
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<ManagedWorktree> {
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<Removed, String> {
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`]`/<name>`: 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<String, String> {
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<String> {
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<WorktreeDefaults, String> {
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(&current);
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<NewWorktree, String> {
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);
}
}
+148
View File
@@ -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<Setup> {
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<Vec<(&'static str, String)>> {
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);
}
}
+1 -1
View File
@@ -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) => {
+24 -3
View File
@@ -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<String>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -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);
+12 -1
View File
@@ -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}",
+12 -1
View File
@@ -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} 用のフォークコマンドがありません",
+11
View File
@@ -1590,6 +1590,17 @@ l10n_keys! {
PaneDragHandleTooltip,
AppWorktreeRemoved,
AppWorktreeRemoveFailed,
WorktreePromptAgent,
WorktreePromptShell,
WorktreePromptTask,
WorktreePromptSetup,
WorktreePromptSetupHint,
AppWorktreeSetupTitle,
AppWorktreeSetupDetail,
AppWorktreeSetupRun,
AppWorktreeSetupSkip,
AppWorktreeNotCarried,
AppWorktreeRemovedBranchKept,
AppForkStillConnecting,
AppPaneNoCodingAgent,
AppForkNoCommand,
+12 -1
View File
@@ -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 命令",
+247 -19
View File
@@ -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<InputState>,
branch: Entity<InputState>,
base: Entity<InputState>,
task: Entity<InputState>,
agents: Vec<CLIAgent>,
/// Which of `agents` the first pane starts; `None` is a plain shell.
agent: Option<usize>,
has_setup: bool,
setup_hint: Option<&'static str>,
busy: bool,
_subs: Vec<Subscription>,
}
/// What the new worktree's first pane was asked to start.
struct Start {
agent: Option<CLIAgent>,
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<String>,
) -> Option<String> {
let q = |s: &str| quote_for_shell(s, None);
let setup = setup.map(|(script, env)| {
let mut parts = vec!["env".to_string()];
parts.extend(env.iter().map(|(k, v)| format!("{k}={}", q(v))));
parts.push(q(&script.to_string_lossy()));
parts.join(" ")
});
match (setup, agent) {
(Some(setup), Some(agent)) => Some(format!("{setup} && {agent}")),
(setup, agent) => setup.or(agent),
}
}
/// `agent`'s launch line, opening on `task` when it takes a first message.
fn agent_line(agent: CLIAgent, task: &str, cfg: &Config) -> String {
let mut line = agent.launch_command(&cfg.agent_launch);
let task = task.trim();
if let Some(args) = (!task.is_empty())
.then(|| agent.prompt_args(task))
.flatten()
{
for arg in args {
line.push(' ');
line.push_str(&quote_for_shell(&arg, None));
}
}
line
}
impl Tty7App {
pub(crate) fn open_worktree_prompt(
&mut self,
@@ -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::<Config>().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<Self>,
) {
let Created { wt, setup } = created;
if !wt.carried.skipped.is_empty() {
let paths = wt
.carried
.skipped
.iter()
.map(|(p, why)| format!("{p} ({why})"))
.collect::<Vec<_>>()
.join(", ");
window.push_notification(
t_fmt(L10nKey::AppWorktreeNotCarried, &[("paths", &paths)]),
cx,
);
}
let agent = start
.agent
.map(|a| agent_line(a, &start.task, cx.global::<Config>()));
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::<Config>().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<Self>,
@@ -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");
}
}