mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 00:15:06 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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(¤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<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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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} 用のフォークコマンドがありません",
|
||||
|
||||
@@ -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
@@ -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
@@ -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("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::<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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user