Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86443d195e | ||
|
|
abaa498b9f | ||
|
|
62581d9f85 | ||
|
|
267f8a6712 | ||
|
|
a575b8bef1 | ||
|
|
e9e0136a89 | ||
|
|
4921f62796 | ||
|
|
898c3cc763 | ||
|
|
44b9745e8c | ||
|
|
cc8af94254 | ||
|
|
50a9d35f62 | ||
|
|
a9ef3ff97b | ||
|
|
0457a61a61 | ||
|
|
132b606830 | ||
|
|
35c75f9238 | ||
|
|
bbbc24a4aa | ||
|
|
e9368bb242 | ||
|
|
918609574e | ||
|
|
bcb365f278 | ||
|
|
586c243056 | ||
|
|
1647e64aed | ||
|
|
5aedaf838a | ||
|
|
d922cdaa32 | ||
|
|
b4e57d1a4f | ||
|
|
74967e45b4 | ||
|
|
7a2cc30bf9 | ||
|
|
8ed44e785d | ||
|
|
63da845614 | ||
|
|
d336bd802d | ||
|
|
67d17f48d0 | ||
|
|
ec45312400 | ||
|
|
525dc9a04a | ||
|
|
264332266c | ||
|
|
37338def71 | ||
|
|
51f91b3f5d | ||
|
|
5c3f9cab9f | ||
|
|
117f371c41 |
@@ -62,6 +62,10 @@ nix/ # Home Manager module (`services.maestro`)
|
||||
|
||||
See `docs/INSTALL.md` for end-user install instructions.
|
||||
|
||||
`docs/WORKTREES.md` is the contract for session worktrees — placement,
|
||||
collisions, what blocks a kill, what `--force` retains, and what reclaim
|
||||
touches. Read it before changing anything that creates or removes one.
|
||||
|
||||
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
||||
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
||||
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
||||
|
||||
@@ -79,16 +79,15 @@ no bounce. Tokens `--dur-fast`/`--dur`/`--dur-slow`.
|
||||
`connecting` state pill pulses.
|
||||
- Interaction: row hover/focus, button press (1px), input focus ring, chevron
|
||||
rotation, theme-toggle sun/moon swap.
|
||||
- Modal: bottom-sheet slide-up on mobile, scale-pop on ≥600px.
|
||||
- Every animation has a `prefers-reduced-motion: reduce` path (pulses off,
|
||||
transitions near-instant).
|
||||
|
||||
## Layout & structure
|
||||
|
||||
Mobile-first, structural responsiveness (not fluid type). Single `≤520px`
|
||||
breakpoint stacks the header (full-width search, wrapped actions). `≥600px`
|
||||
switches the spawn modal from bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as
|
||||
terminal atmosphere (very low contrast, not decoration).
|
||||
breakpoint stacks the header (full-width search, wrapped actions). A faint
|
||||
slate grid texture sits on `--bg` as terminal atmosphere (very low contrast,
|
||||
not decoration).
|
||||
|
||||
## Components
|
||||
|
||||
@@ -101,8 +100,10 @@ Three surfaces, all DOM built in TS, styled by class:
|
||||
no-sessions, no-search-match.
|
||||
- **Attach** — header (back, `session <id>`, live/closed/error state pill) over
|
||||
a framed dark xterm with a green cursor.
|
||||
- **Spawn modal** — bottom-sheet/dialog form; mono inputs with green focus
|
||||
ring; grouped worktree fieldset.
|
||||
- **Draft pane** — the spawn form mounted inline in an empty split pane,
|
||||
focusable like any session pane; mono inputs with green focus ring; grouped
|
||||
worktree fieldset. Spawn opens one (reusing an empty focused pane, else
|
||||
splitting), and "Spawn similar" seeds one from a session's dir + command.
|
||||
|
||||
Interactive elements ship default / hover / focus-visible / active / disabled
|
||||
states; the accent green is used only for action, selection, focus, and live
|
||||
@@ -111,5 +112,5 @@ status — never decoration.
|
||||
## Dev review aids (DEV-only, stripped from production)
|
||||
|
||||
`web/src/mock.ts` + query params seed a representative catalog without a running
|
||||
daemon: `?mock` (seed sessions), `?mock&spawn` (open the spawn modal),
|
||||
daemon: `?mock` (seed sessions), `?mock&spawn` (open a draft pane),
|
||||
`?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.
|
||||
|
||||
@@ -41,6 +41,36 @@ pub enum Command {
|
||||
Attach(AttachArgs),
|
||||
/// Kill a session by ID.
|
||||
Kill(KillArgs),
|
||||
/// List retained worktrees, or reclaim one by ID (#86).
|
||||
Worktrees(WorktreesArgs),
|
||||
}
|
||||
|
||||
/// `maestro worktrees <list|reclaim>`
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct WorktreesArgs {
|
||||
#[command(subcommand)]
|
||||
pub command: WorktreesCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum WorktreesCommand {
|
||||
/// List retained worktrees and what is in the way of reclaiming each.
|
||||
List(ListWorktreesArgs),
|
||||
/// Reclaim one retained worktree: removes the directory, never the branch.
|
||||
Reclaim(ReclaimArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct ListWorktreesArgs {
|
||||
/// Output as JSON instead of a table.
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct ReclaimArgs {
|
||||
/// Retained worktree ID to reclaim. Required — there is no reclaim-all.
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
@@ -241,4 +271,56 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("KEY=VALUE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_worktrees_list() {
|
||||
let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap();
|
||||
match cli.command {
|
||||
Command::Worktrees(w) => match w.command {
|
||||
WorktreesCommand::List(a) => assert!(a.json),
|
||||
_ => panic!("wrong worktrees subcommand"),
|
||||
},
|
||||
_ => panic!("wrong subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_worktrees_reclaim() {
|
||||
let id = "01234567-89ab-cdef-0123-456789abcdef";
|
||||
let cli = Cli::try_parse_from(["maestro", "worktrees", "reclaim", id]).unwrap();
|
||||
match cli.command {
|
||||
Command::Worktrees(w) => match w.command {
|
||||
WorktreesCommand::Reclaim(a) => assert_eq!(a.id.to_string(), id),
|
||||
_ => panic!("wrong worktrees subcommand"),
|
||||
},
|
||||
_ => panic!("wrong subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Contract rule: reclaim refuses to run without an explicit target.
|
||||
/// There is no reclaim-everything convenience.
|
||||
#[test]
|
||||
fn reclaim_requires_an_explicit_target() {
|
||||
let err = Cli::try_parse_from(["maestro", "worktrees", "reclaim"]).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err.kind(),
|
||||
clap::error::ErrorKind::InvalidSubcommand
|
||||
| clap::error::ErrorKind::MissingRequiredArgument
|
||||
),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktrees_without_subcommand_shows_help() {
|
||||
let err = Cli::try_parse_from(["maestro", "worktrees"]).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err.kind(),
|
||||
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
|
||||
),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ use std::path::{Path, PathBuf};
|
||||
use maestro_protocol::DaemonClient;
|
||||
|
||||
use crate::attach::{parse_detach_key, run_attach_url};
|
||||
use crate::cli::Command;
|
||||
use crate::cli::{Command, WorktreesCommand};
|
||||
use crate::sessions::{run_kill, run_list, run_spawn};
|
||||
use crate::worktrees::{run_list as run_worktree_list, run_reclaim as run_worktree_reclaim};
|
||||
use maestro_config::{default_config_path, MaestroConfigFile};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -64,6 +65,26 @@ pub async fn dispatch(
|
||||
run_kill(&daemon, &args).await?;
|
||||
println!("killed {}", args.session_id);
|
||||
}
|
||||
Command::Worktrees(args) => match args.command {
|
||||
WorktreesCommand::List(list_args) => {
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
run_worktree_list(&daemon, &list_args, &mut stdout).await?;
|
||||
}
|
||||
WorktreesCommand::Reclaim(reclaim_args) => {
|
||||
// The daemon removes the directory and the store entry; the
|
||||
// branch is never deleted — worth saying out loud (#86).
|
||||
let entry = run_worktree_reclaim(&daemon, &reclaim_args).await?;
|
||||
match entry {
|
||||
Some(wt) => println!(
|
||||
"reclaimed {} ({})\nbranch '{}' kept",
|
||||
wt.id,
|
||||
wt.path.display(),
|
||||
wt.branch
|
||||
),
|
||||
None => println!("reclaimed {}\nbranch kept", reclaim_args.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -6,5 +6,6 @@ pub mod attach;
|
||||
pub mod cli;
|
||||
pub mod dispatch;
|
||||
pub mod sessions;
|
||||
pub mod worktrees;
|
||||
|
||||
pub use dispatch::{dispatch, CliError};
|
||||
|
||||
@@ -122,7 +122,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
|
||||
/// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web
|
||||
/// UI's abbreviation so CLI and browser show the same directory shorthand.
|
||||
fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
|
||||
pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
|
||||
let p = path.to_string_lossy();
|
||||
if !home.is_empty() {
|
||||
if p == home {
|
||||
@@ -153,6 +153,7 @@ mod tests {
|
||||
command: command.into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(),
|
||||
foreground: None,
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! `maestro worktrees list / reclaim` — the retained-store surface (#86).
|
||||
//!
|
||||
//! `reclaim` removes one directory; it never deletes the branch, which is
|
||||
//! said out loud in the output because "reclaim" otherwise sounds like it
|
||||
//! takes the commits too.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
|
||||
|
||||
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
|
||||
use crate::sessions::abbreviate_dir;
|
||||
|
||||
/// `maestro worktrees list`
|
||||
pub async fn run_list(
|
||||
client: &DaemonClient,
|
||||
args: &ListWorktreesArgs,
|
||||
out: &mut impl Write,
|
||||
) -> Result<(), ClientError> {
|
||||
let worktrees = client.list_worktrees().await?;
|
||||
let info = client.daemon_info().await.unwrap_or_default();
|
||||
|
||||
if args.json {
|
||||
let body = serde_json::json!({ "worktrees": worktrees });
|
||||
writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
print_worktree_table(out, &worktrees, &info);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `maestro worktrees reclaim <id>`
|
||||
///
|
||||
/// Returns the entry as listed just before the reclaim, when it was found in
|
||||
/// the listing, so the confirmation can name what was removed.
|
||||
pub async fn run_reclaim(
|
||||
client: &DaemonClient,
|
||||
args: &ReclaimArgs,
|
||||
) -> Result<Option<RetainedWorktree>, ClientError> {
|
||||
let listed = client
|
||||
.list_worktrees()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|w| w.id == args.id);
|
||||
client.reclaim_worktree(args.id).await?;
|
||||
Ok(listed)
|
||||
}
|
||||
|
||||
fn print_worktree_table(out: &mut impl Write, worktrees: &[RetainedWorktree], info: &DaemonInfo) {
|
||||
if worktrees.is_empty() {
|
||||
writeln!(out, "no retained worktrees").ok();
|
||||
return;
|
||||
}
|
||||
|
||||
let mut sorted: Vec<&RetainedWorktree> = worktrees.iter().collect();
|
||||
sorted.sort_by_key(|w| w.retained_at);
|
||||
|
||||
if !info.hostname.is_empty() {
|
||||
let warn = if maestro_protocol::is_compatible(&info.version) {
|
||||
""
|
||||
} else {
|
||||
" (protocol mismatch)"
|
||||
};
|
||||
let version = if info.version.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" maestro {}{warn}", info.version)
|
||||
};
|
||||
writeln!(out, "\n{}{version}", info.hostname).ok();
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<24} {:<28} {:<24} IN THE WAY",
|
||||
"ID", "BRANCH", "PATH", "REASON"
|
||||
)
|
||||
.ok();
|
||||
for w in sorted {
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<24} {:<28} {:<24} {}",
|
||||
w.id,
|
||||
w.branch,
|
||||
abbreviate_dir(&w.path, &info.home_dir),
|
||||
reason_label(w.reason),
|
||||
blocker_label(&w.in_the_way),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
// The branch survives every reclaim — see docs/WORKTREES.md.
|
||||
writeln!(
|
||||
out,
|
||||
"\nreclaim removes the directory only; branches are never deleted"
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn reason_label(reason: maestro_protocol::RetainedReason) -> &'static str {
|
||||
match reason {
|
||||
maestro_protocol::RetainedReason::ForceKilled => "force-killed",
|
||||
maestro_protocol::RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work",
|
||||
}
|
||||
}
|
||||
|
||||
/// The same breakdown that blocks an unforced kill, as words: what is still
|
||||
/// in the way, or `clear` when the work has since been committed and pushed.
|
||||
fn blocker_label(in_the_way: &InTheWay) -> String {
|
||||
if in_the_way.is_clear() {
|
||||
return "clear".into();
|
||||
}
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if in_the_way.uncommitted_files > 0 {
|
||||
parts.push(format!("{} uncommitted", in_the_way.uncommitted_files));
|
||||
}
|
||||
if in_the_way.untracked_files > 0 {
|
||||
parts.push(format!("{} untracked", in_the_way.untracked_files));
|
||||
}
|
||||
if in_the_way.unpushed_commits > 0 {
|
||||
parts.push(format!("{} unpushed", in_the_way.unpushed_commits));
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use maestro_protocol::{DaemonInfo, InTheWay, RetainedReason, RetainedWorktree};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree {
|
||||
RetainedWorktree {
|
||||
id: Uuid::from_u128(id),
|
||||
path: PathBuf::from(path),
|
||||
base_repo: PathBuf::from("/home/naps/maestro"),
|
||||
branch: branch.into(),
|
||||
reason: RetainedReason::ForceKilled,
|
||||
retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
|
||||
in_the_way: InTheWay::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn info(home: &str) -> DaemonInfo {
|
||||
DaemonInfo {
|
||||
hostname: "naps-laptop".into(),
|
||||
home_dir: home.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_table() {
|
||||
let mut w = worktree("feat/login", "/home/naps/maestro/worktrees/feat/login", 1);
|
||||
w.in_the_way = InTheWay {
|
||||
uncommitted_files: 2,
|
||||
untracked_files: 1,
|
||||
unpushed_commits: 3,
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
super::print_worktree_table(&mut buf, &[w], &info("/home/naps"));
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("naps-laptop"), "{s}");
|
||||
assert!(s.contains("feat/login"), "{s}");
|
||||
// Home dir collapses to ~ in the PATH column, like sessions' DIR.
|
||||
assert!(s.contains("~/maestro/worktrees/feat/login"), "{s}");
|
||||
assert!(s.contains("force-killed"), "{s}");
|
||||
assert!(s.contains("2 uncommitted, 1 untracked, 3 unpushed"), "{s}");
|
||||
assert!(s.contains("IN THE WAY"), "{s}");
|
||||
}
|
||||
|
||||
/// Contract rule, visible to the operator: reclaiming a tree must not
|
||||
/// throw away commits on its branch.
|
||||
#[test]
|
||||
fn says_the_branch_survives() {
|
||||
let mut buf = Vec::new();
|
||||
super::print_worktree_table(
|
||||
&mut buf,
|
||||
&[worktree("feat/x", "/repo/worktrees/feat/x", 1)],
|
||||
&info(""),
|
||||
);
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("never deleted"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_tree_says_clear() {
|
||||
let mut buf = Vec::new();
|
||||
super::print_worktree_table(
|
||||
&mut buf,
|
||||
&[worktree("main-ish", "/repo/worktrees/main-ish", 1)],
|
||||
&info(""),
|
||||
);
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("clear"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_store() {
|
||||
let mut buf = Vec::new();
|
||||
super::print_worktree_table(&mut buf, &[], &info(""));
|
||||
assert_eq!(String::from_utf8(buf).unwrap(), "no retained worktrees\n");
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
config.home_dir.clone(),
|
||||
config.spawn_presets.clone(),
|
||||
projects,
|
||||
std::sync::Arc::new(crate::retained_store::RetainedStore::new(
|
||||
config.retained_worktree_dir(),
|
||||
)),
|
||||
));
|
||||
|
||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||
|
||||
@@ -103,6 +103,11 @@ impl DaemonConfig {
|
||||
pub fn spawn_history_path(&self) -> PathBuf {
|
||||
self.state_dir.join("spawn-history.jsonl")
|
||||
}
|
||||
|
||||
/// Retained-worktree store directory (#78): `<state_dir>/retained-worktrees/`.
|
||||
pub fn retained_worktree_dir(&self) -> PathBuf {
|
||||
self.state_dir.join("retained-worktrees")
|
||||
}
|
||||
}
|
||||
|
||||
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
|
||||
|
||||
@@ -111,6 +111,7 @@ fn io_error(dir: &Path, e: &std::io::Error) -> ErrorInfo {
|
||||
ErrorInfo {
|
||||
code: code.into(),
|
||||
message: format!("{message}: {}", dir.display()),
|
||||
unsaved_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,13 @@ pub mod panes;
|
||||
pub mod proc;
|
||||
pub mod projects;
|
||||
pub mod pty;
|
||||
pub mod retained_store;
|
||||
pub mod session;
|
||||
pub mod sidecar;
|
||||
pub mod spawn_history;
|
||||
pub mod tmux;
|
||||
pub mod worktree;
|
||||
pub mod worktree_probe;
|
||||
|
||||
pub use config::{default_config_path, default_state_dir, load_config, DaemonConfig, EnvOverrides};
|
||||
pub use session::{SessionError, SessionManager};
|
||||
|
||||
@@ -38,6 +38,9 @@ pub struct LocalApiState {
|
||||
pub presets: Arc<Vec<SpawnPreset>>,
|
||||
/// Project discovery (#54) backing `GET /api/projects`.
|
||||
pub projects: Arc<crate::projects::ProjectScanner>,
|
||||
/// Retained worktrees (#78) backing `GET /api/worktrees` and their
|
||||
/// reclaim endpoint (#80).
|
||||
pub retained: Arc<crate::retained_store::RetainedStore>,
|
||||
}
|
||||
|
||||
pub fn router(state: LocalApiState) -> Router {
|
||||
@@ -50,6 +53,8 @@ pub fn router(state: LocalApiState) -> Router {
|
||||
.route(routes::GITROOT, get(git_root))
|
||||
.route(routes::BRANCHES, get(crate::branches::handler))
|
||||
.route(routes::NAMES, get(names))
|
||||
.route(routes::WORKTREES, get(list_worktrees))
|
||||
.route(routes::patterns::WORKTREE, delete(reclaim_worktree))
|
||||
.route(routes::INFO, get(daemon_info))
|
||||
.route(routes::EVENTS, any(events_ws))
|
||||
.route(routes::patterns::ATTACH, any(attach_ws))
|
||||
@@ -94,6 +99,8 @@ fn session_error_status(code: &str) -> StatusCode {
|
||||
match code {
|
||||
"not_found" => StatusCode::NOT_FOUND,
|
||||
"invalid_env" => StatusCode::BAD_REQUEST,
|
||||
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||
"kill_guarded" => StatusCode::CONFLICT,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
}
|
||||
}
|
||||
@@ -168,17 +175,124 @@ async fn create_session(
|
||||
/// `kill` already reports a missing session as `SessionError::NotFound`, which
|
||||
/// maps to 404. Checking first would only add a window in which the liveness
|
||||
/// watcher reaps the session between the two awaits, turning a 404 into a 502.
|
||||
///
|
||||
/// The full error info is passed through rather than rebuilt from
|
||||
/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work
|
||||
/// breakdown clients render as "3 uncommitted files, 2 unpushed commits".
|
||||
async fn kill_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
||||
match s.manager.kill(id).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => {
|
||||
let info = e.to_error_info();
|
||||
error_response(
|
||||
session_error_status(&info.code),
|
||||
info.code.as_str(),
|
||||
info.message.as_str(),
|
||||
let status = session_error_status(&info.code);
|
||||
(status, Json(ApiError { error: info })).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/worktrees` (#80): every retained worktree, with the in-the-way
|
||||
/// breakdown recomputed at read time — never stored, so a tree whose work has
|
||||
/// since been committed or pushed says so. Entries whose directory somebody
|
||||
/// removed by hand are pruned on the way through.
|
||||
async fn list_worktrees(State(s): State<LocalApiState>) -> Response {
|
||||
let retained = s.retained.clone();
|
||||
let listed = tokio::task::spawn_blocking(move || {
|
||||
retained.prune_missing()?;
|
||||
retained
|
||||
.load_all()?
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
// A tree git cannot read (permissions, corrupt checkout)
|
||||
// still gets listed; it just reports nothing in the way.
|
||||
let in_the_way = crate::worktree_probe::in_the_way(&entry.path).unwrap_or_default();
|
||||
Ok(maestro_protocol::RetainedWorktree {
|
||||
id: entry.id,
|
||||
path: entry.path.clone(),
|
||||
base_repo: entry.base_repo.clone(),
|
||||
branch: entry.branch.clone(),
|
||||
reason: entry.reason,
|
||||
retained_at: entry.retained_at,
|
||||
in_the_way,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, crate::retained_store::RetainedStoreError>>()
|
||||
})
|
||||
.await;
|
||||
match listed {
|
||||
Ok(Ok(worktrees)) => Json(worktrees).into_response(),
|
||||
Ok(Err(e)) => error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"store_failed",
|
||||
&e.to_string(),
|
||||
),
|
||||
Err(e) => error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"join_failed",
|
||||
&e.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `DELETE /api/worktrees/:id` (#80): reclaim one retained worktree. Removes
|
||||
/// the directory and drops the store entry, without consulting the guard —
|
||||
/// reclaim is the deliberate destructive act, chosen while looking at what is
|
||||
/// in the way. The branch itself is never deleted; commits on it survive.
|
||||
async fn reclaim_worktree(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
||||
let found = {
|
||||
let retained = s.retained.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<
|
||||
Option<crate::retained_store::RetainedWorktreeRecord>,
|
||||
crate::retained_store::RetainedStoreError,
|
||||
> {
|
||||
Ok(retained.load_all()?.into_iter().find(|e| e.id == id))
|
||||
})
|
||||
.await
|
||||
};
|
||||
let entry = match found {
|
||||
Ok(Ok(Some(entry))) => entry,
|
||||
Ok(Ok(None)) => {
|
||||
return error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"not_found",
|
||||
"no such retained worktree",
|
||||
)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
return error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"store_failed",
|
||||
&e.to_string(),
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
return error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"join_failed",
|
||||
&e.to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let outcome = {
|
||||
let retained = s.retained.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<(), String> {
|
||||
crate::worktree_probe::reclaim(&entry.base_repo, &entry.path)
|
||||
.map_err(|e| e.to_string())?;
|
||||
retained.remove(id).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
};
|
||||
match outcome {
|
||||
Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(),
|
||||
// The directory could not be removed: leave the entry so the
|
||||
// operator still sees the tree and can retry.
|
||||
Ok(Err(e)) => error_response(StatusCode::BAD_GATEWAY, "reclaim_failed", &e.to_string()),
|
||||
Err(e) => error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"join_failed",
|
||||
&e.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +311,7 @@ pub fn state_for(
|
||||
home_dir: impl Into<String>,
|
||||
presets: Vec<SpawnPreset>,
|
||||
projects: Arc<crate::projects::ProjectScanner>,
|
||||
retained: Arc<crate::retained_store::RetainedStore>,
|
||||
) -> LocalApiState {
|
||||
LocalApiState {
|
||||
manager,
|
||||
@@ -204,6 +319,7 @@ pub fn state_for(
|
||||
home_dir: Arc::new(home_dir.into()),
|
||||
presets: Arc::new(presets),
|
||||
projects,
|
||||
retained,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -262,10 +262,16 @@ impl ProjectScanner {
|
||||
|
||||
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
|
||||
pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> {
|
||||
self.repos_inner(false).await
|
||||
}
|
||||
|
||||
async fn repos_inner(&self, force: bool) -> std::sync::Arc<Vec<DiscoveredRepo>> {
|
||||
let mut cache = self.cache.lock().await;
|
||||
if let Some((at, repos)) = cache.as_ref() {
|
||||
if at.elapsed() < self.ttl {
|
||||
return repos.clone();
|
||||
if !force {
|
||||
if let Some((at, repos)) = cache.as_ref() {
|
||||
if at.elapsed() < self.ttl {
|
||||
return repos.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
let roots = self.roots.clone();
|
||||
@@ -281,9 +287,11 @@ impl ProjectScanner {
|
||||
repos
|
||||
}
|
||||
|
||||
/// Rescan now, ignoring the TTL. Called by the background timer.
|
||||
/// Rescan now, ignoring the TTL. Called by the background timer — going
|
||||
/// through `repos()` would hit the cache and make most ticks no-ops,
|
||||
/// leaving requests to pay for the rescan instead (#54 review).
|
||||
async fn refresh(&self) {
|
||||
self.repos().await;
|
||||
self.repos_inner(true).await;
|
||||
}
|
||||
|
||||
/// Spawn the slow background refresh task. Drop the returned handle to
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Store of retained worktrees (#78): worktrees the daemon created whose
|
||||
//! session is gone.
|
||||
//!
|
||||
//! One JSON file per entry under `<state_dir>/retained-worktrees/`, written
|
||||
//! the way `sidecar.rs` writes session records — atomic tmp+rename replace,
|
||||
//! missing or malformed files tolerated on read. The store survives a daemon
|
||||
//! restart; it is the recovery path for everything else in the worktree
|
||||
//! lifecycle.
|
||||
//!
|
||||
//! It is not an ownership record: it must never be consulted to decide whether
|
||||
//! a path may be adopted. Its only job is to remember what the daemon is
|
||||
//! holding so it can be listed and reclaimed.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
/// Why a worktree outlived its session — [`maestro_protocol::RetainedReason`],
|
||||
/// so the store and the wire payload cannot drift apart.
|
||||
pub use maestro_protocol::RetainedReason;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RetainedStoreError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("json: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RetainedWorktreeRecord {
|
||||
pub id: Uuid,
|
||||
pub path: PathBuf,
|
||||
pub base_repo: PathBuf,
|
||||
pub branch: String,
|
||||
pub reason: RetainedReason,
|
||||
pub retained_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetainedStore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl RetainedStore {
|
||||
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||
Self { dir: dir.into() }
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
fn path_for(&self, id: Uuid) -> PathBuf {
|
||||
self.dir.join(format!("{id}.json"))
|
||||
}
|
||||
|
||||
/// Record a retained worktree and return the entry that was written.
|
||||
pub fn retain(
|
||||
&self,
|
||||
path: &Path,
|
||||
base_repo: &Path,
|
||||
branch: &str,
|
||||
reason: RetainedReason,
|
||||
) -> Result<RetainedWorktreeRecord, RetainedStoreError> {
|
||||
let entry = RetainedWorktreeRecord {
|
||||
id: Uuid::new_v4(),
|
||||
path: path.to_path_buf(),
|
||||
base_repo: base_repo.to_path_buf(),
|
||||
branch: branch.to_string(),
|
||||
reason,
|
||||
retained_at: Utc::now(),
|
||||
};
|
||||
self.overwrite(&entry)?;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Atomically write `entry`. Creates the directory if it does not exist.
|
||||
pub fn overwrite(&self, entry: &RetainedWorktreeRecord) -> Result<(), RetainedStoreError> {
|
||||
std::fs::create_dir_all(&self.dir)?;
|
||||
let final_path = self.path_for(entry.id);
|
||||
let tmp = self.dir.join(format!("{}.json.tmp", entry.id));
|
||||
let json = serde_json::to_vec_pretty(entry)?;
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, &final_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop one entry. No-op when the id is unknown — reclaiming an entry the
|
||||
/// store already lost (e.g. pruned) must not fail.
|
||||
pub fn remove(&self, id: Uuid) -> Result<(), RetainedStoreError> {
|
||||
match std::fs::remove_file(self.path_for(id)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read every entry. Files that are missing or malformed are skipped
|
||||
/// silently: a corrupt record must not hide the others, and an empty or
|
||||
/// absent directory simply means nothing is retained.
|
||||
pub fn load_all(&self) -> Result<Vec<RetainedWorktreeRecord>, RetainedStoreError> {
|
||||
let mut out = Vec::new();
|
||||
let entries = match std::fs::read_dir(&self.dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(worktree) = serde_json::from_slice::<RetainedWorktreeRecord>(&bytes) {
|
||||
out.push(worktree);
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|w| w.retained_at);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Drop entries whose worktree directory no longer exists — somebody
|
||||
/// removed it by hand. Returns the ids that were pruned.
|
||||
pub fn prune_missing(&self) -> Result<Vec<Uuid>, RetainedStoreError> {
|
||||
let mut pruned = Vec::new();
|
||||
for entry in self.load_all()? {
|
||||
if !entry.path.exists() {
|
||||
self.remove(entry.id)?;
|
||||
pruned.push(entry.id);
|
||||
}
|
||||
}
|
||||
Ok(pruned)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn tmp_dir() -> tempfile::TempDir {
|
||||
tempfile::tempdir().expect("tempdir")
|
||||
}
|
||||
|
||||
fn sample_path(root: &std::path::Path, name: &str) -> PathBuf {
|
||||
let p = root.join(name);
|
||||
std::fs::create_dir_all(&p).expect("mkdir");
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retain_then_load_round_trips_every_field() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path());
|
||||
let wt = sample_path(dir.path(), "wt");
|
||||
let repo = sample_path(dir.path(), "repo");
|
||||
|
||||
let entry = store
|
||||
.retain(&wt, &repo, "feature/login", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
|
||||
let all = store.load_all().expect("load_all");
|
||||
assert_eq!(all, vec![entry.clone()]);
|
||||
assert_eq!(entry.path, wt);
|
||||
assert_eq!(entry.base_repo, repo);
|
||||
assert_eq!(entry.branch, "feature/login");
|
||||
assert_eq!(entry.reason, RetainedReason::ForceKilled);
|
||||
let age = Utc::now() - entry.retained_at;
|
||||
assert!(age >= Duration::zero() && age < Duration::minutes(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reason_serializes_kebab_case() {
|
||||
let json =
|
||||
serde_json::to_string(&RetainedReason::ExitedWithUnsavedWork).expect("serialize");
|
||||
assert_eq!(json, r#""exited-with-unsaved-work""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_survive_a_restart() {
|
||||
let dir = tmp_dir();
|
||||
let wt = sample_path(dir.path(), "wt");
|
||||
|
||||
let first = RetainedStore::new(dir.path());
|
||||
let entry = first
|
||||
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
|
||||
// A fresh store over the same state_dir — what a daemon restart sees.
|
||||
let second = RetainedStore::new(dir.path());
|
||||
assert_eq!(second.load_all().expect("load_all"), vec![entry]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_store_reads_as_empty() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path().join("does-not-exist"));
|
||||
assert_eq!(store.load_all().expect("load_all"), Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_entries_are_skipped_silently() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path());
|
||||
let wt = sample_path(dir.path(), "wt");
|
||||
store
|
||||
.retain(&wt, dir.path(), "kept", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
std::fs::write(dir.path().join("not-a-uuid.json"), b"{oops").expect("write junk");
|
||||
|
||||
let all = store.load_all().expect("load_all");
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].branch, "kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_unique() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path());
|
||||
let a = store
|
||||
.retain(dir.path(), dir.path(), "a", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
let b = store
|
||||
.retain(dir.path(), dir.path(), "b", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
assert_ne!(a.id, b.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_drops_the_entry() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path());
|
||||
let wt = sample_path(dir.path(), "wt");
|
||||
let entry = store
|
||||
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
|
||||
store.remove(entry.id).expect("remove");
|
||||
assert_eq!(store.load_all().expect("load_all"), Vec::new());
|
||||
|
||||
// Removing an unknown id is a no-op, not an error.
|
||||
store.remove(entry.id).expect("remove again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_all_is_sorted_by_time() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path());
|
||||
let mut first = store
|
||||
.retain(dir.path(), dir.path(), "first", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
first.retained_at -= Duration::hours(1);
|
||||
store.overwrite(&first).expect("rewrite");
|
||||
|
||||
let second = store
|
||||
.retain(
|
||||
dir.path(),
|
||||
dir.path(),
|
||||
"second",
|
||||
RetainedReason::ForceKilled,
|
||||
)
|
||||
.expect("retain");
|
||||
|
||||
let branches: Vec<_> = store
|
||||
.load_all()
|
||||
.expect("load_all")
|
||||
.into_iter()
|
||||
.map(|e| e.branch)
|
||||
.collect();
|
||||
assert_eq!(branches, vec!["first", "second"]);
|
||||
let _ = second;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_missing_drops_gone_directories_only() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path());
|
||||
let alive = sample_path(dir.path(), "alive");
|
||||
let gone = sample_path(dir.path(), "gone");
|
||||
let keep = store
|
||||
.retain(&alive, dir.path(), "keep", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
let drop = store
|
||||
.retain(
|
||||
&gone,
|
||||
dir.path(),
|
||||
"drop",
|
||||
RetainedReason::ExitedWithUnsavedWork,
|
||||
)
|
||||
.expect("retain");
|
||||
std::fs::remove_dir_all(&gone).expect("rm");
|
||||
|
||||
let pruned = store.prune_missing().expect("prune_missing");
|
||||
assert_eq!(pruned, vec![drop.id]);
|
||||
|
||||
let branches: Vec<_> = store
|
||||
.load_all()
|
||||
.expect("load_all")
|
||||
.into_iter()
|
||||
.map(|e| e.branch)
|
||||
.collect();
|
||||
assert_eq!(branches, vec!["keep"]);
|
||||
let _ = keep;
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,8 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
|
||||
pub struct SessionRecord {
|
||||
pub session: Session,
|
||||
pub fifo_path: PathBuf,
|
||||
/// Worktree we created at spawn time; removed on kill unless the
|
||||
/// dirty-work guard refuses (#56).
|
||||
/// Worktree we created at spawn time; removed on kill, but only after the
|
||||
/// kill guard (#79) found nothing worth keeping in it.
|
||||
pub worktree_path: Option<PathBuf>,
|
||||
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
||||
/// worktree remove` is invoked from the base repo.
|
||||
@@ -61,6 +61,11 @@ pub enum SessionError {
|
||||
NotFound(Uuid),
|
||||
#[error("invalid environment variable name: {0:?}")]
|
||||
InvalidEnv(String),
|
||||
/// The kill was refused (#79): the session's worktree holds unsaved work.
|
||||
/// Nothing happened — the session keeps running and the tree is untouched,
|
||||
/// so it stays the operator's handle on their own unfinished work.
|
||||
#[error("kill refused: the session's worktree holds unsaved work ({0})")]
|
||||
KillGuarded(maestro_protocol::InTheWay),
|
||||
}
|
||||
|
||||
impl SessionError {
|
||||
@@ -72,10 +77,16 @@ impl SessionError {
|
||||
SessionError::Io(_) => "io_failed",
|
||||
SessionError::NotFound(_) => "not_found",
|
||||
SessionError::InvalidEnv(_) => "invalid_env",
|
||||
SessionError::KillGuarded(_) => "kill_guarded",
|
||||
};
|
||||
let unsaved_work = match self {
|
||||
SessionError::KillGuarded(b) => Some(*b),
|
||||
_ => None,
|
||||
};
|
||||
ErrorInfo {
|
||||
code: code.into(),
|
||||
message: self.to_string(),
|
||||
unsaved_work,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,6 +237,9 @@ impl SessionManager {
|
||||
let WorktreeSpec {
|
||||
base_repo, branch, ..
|
||||
} = spec;
|
||||
// A path collision is sidestepped inside `run_add` (#77), and
|
||||
// a branch already checked out elsewhere is git's refusal,
|
||||
// not ours.
|
||||
let path = if spec.create_branch {
|
||||
self.inner.worktree_mgr.add_new(base_repo, branch)
|
||||
} else {
|
||||
@@ -290,6 +304,12 @@ impl SessionManager {
|
||||
command: req.command,
|
||||
env: req.env,
|
||||
metadata: req.metadata,
|
||||
worktree: worktree_path.as_ref().zip(worktree_base_repo.as_ref()).map(
|
||||
|(path, base_repo)| maestro_protocol::SessionWorktree {
|
||||
path: path.clone(),
|
||||
base_repo: base_repo.clone(),
|
||||
},
|
||||
),
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
// A freshly-spawned session is producing its initial output; the
|
||||
@@ -361,20 +381,48 @@ impl SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill a session and remove its on-disk artifacts. The worktree is
|
||||
/// always pruned -- unless it holds uncommitted changes or commits not on
|
||||
/// any other ref, in which case the guard leaves it on disk (#56).
|
||||
/// Kill a session and remove its on-disk artifacts.
|
||||
///
|
||||
/// Refused (#79) when the session's worktree holds unsaved work —
|
||||
/// uncommitted tracked changes, untracked non-ignored files, or commits
|
||||
/// reachable from no remote-tracking ref. Nothing is killed and nothing
|
||||
/// is removed; the error names what blocked it and how much of it there
|
||||
/// is. The session stays as the operator's handle on their own unfinished
|
||||
/// work, so ending a session that still matters becomes a deliberate
|
||||
/// `--force`, which retains rather than destroys (WORKTREES.md).
|
||||
///
|
||||
/// This is also how an already-dead session is dismissed. tmux is only
|
||||
/// asked to kill something it still has — calling `kill-session` on a
|
||||
/// session tmux already lost fails, and that failure is not interesting.
|
||||
pub async fn kill(&self, id: Uuid) -> Result<(), SessionError> {
|
||||
let (worktree, alive) = {
|
||||
let sessions = self.inner.sessions.lock().await;
|
||||
match sessions.get(&id) {
|
||||
None => return Err(SessionError::NotFound(id)),
|
||||
Some(record) => (
|
||||
record
|
||||
.worktree_base_repo
|
||||
.clone()
|
||||
.zip(record.worktree_path.clone()),
|
||||
record.session.status == SessionStatus::Running,
|
||||
),
|
||||
}
|
||||
};
|
||||
// The guard runs before anything is torn down: refusing must leave the
|
||||
// session running, exactly as the operator left it.
|
||||
if let Some((_, path)) = worktree {
|
||||
let probe = tmux_blocking(move || crate::worktree_probe::in_the_way(&path))
|
||||
.await
|
||||
.map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e)))?;
|
||||
if !probe.is_clear() {
|
||||
return Err(SessionError::KillGuarded(probe));
|
||||
}
|
||||
}
|
||||
let removed = {
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
sessions.remove(&id)
|
||||
};
|
||||
let record = removed.ok_or(SessionError::NotFound(id))?;
|
||||
let alive = record.session.status == SessionStatus::Running;
|
||||
cleanup_record(&self.inner, id, record, alive).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -430,6 +478,7 @@ impl SessionManager {
|
||||
command: String::new(),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
// Activity is unknown on adoption; the watcher fills it in.
|
||||
@@ -457,8 +506,11 @@ impl SessionManager {
|
||||
SessionRecord {
|
||||
session: session.clone(),
|
||||
fifo_path,
|
||||
worktree_path: None,
|
||||
worktree_base_repo: None,
|
||||
// Restored from the sidecar: without it the recovered
|
||||
// session cleans up nothing on kill, and the leftover
|
||||
// directory blocks that branch forever (#54 review).
|
||||
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
|
||||
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
|
||||
bridge,
|
||||
},
|
||||
);
|
||||
@@ -707,10 +759,9 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
|
||||
if let Err(e) = inner.sidecar.delete(id) {
|
||||
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
||||
}
|
||||
// Cleanup is unconditional now (#56): `cleanup_on_kill` is no longer a
|
||||
// user-facing choice. The manager's guard is what protects unsaved work --
|
||||
// when it refuses, leave the tree on disk and say where it is so the
|
||||
// operator can find it.
|
||||
// The kill already passed the unsaved-work guard (#79), so this removal
|
||||
// is unconditional; the manager's own nested-worktree refusal is all that
|
||||
// can still leave a tree on disk, and it says where.
|
||||
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
||||
if let Err(e) = inner.worktree_mgr.remove(base, path) {
|
||||
tracing::warn!(
|
||||
|
||||
@@ -10,15 +10,24 @@ pub enum WorktreeError {
|
||||
Spawn(#[from] std::io::Error),
|
||||
#[error("git exited with code {code}: {stderr}")]
|
||||
NonZero { code: i32, stderr: String },
|
||||
/// A worktree already occupies the target path. The caller decides what
|
||||
/// to do; the daemon never relocates to a different directory (#56).
|
||||
#[error("worktree path already exists: {}", .path.display())]
|
||||
PathExists { path: PathBuf },
|
||||
/// The worktree holds unsaved work; removal was refused (#56).
|
||||
/// A registered worktree lives under the tree about to be removed; git
|
||||
/// hides its contents from `git status`, so removal would delete its
|
||||
/// uncommitted work unseen (#54 review).
|
||||
#[error("left {} on disk: {}", .path.display(), .reason)]
|
||||
Guarded { path: PathBuf, reason: String },
|
||||
}
|
||||
|
||||
impl From<crate::worktree_probe::ProbeError> for WorktreeError {
|
||||
fn from(e: crate::worktree_probe::ProbeError) -> Self {
|
||||
match e {
|
||||
crate::worktree_probe::ProbeError::Spawn(io) => WorktreeError::Spawn(io),
|
||||
crate::worktree_probe::ProbeError::NonZero { code, stderr } => {
|
||||
WorktreeError::NonZero { code, stderr }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `git worktree add` rooted at `base_repo`. By default worktrees land
|
||||
/// inside the repository itself at `<repo>/worktrees/<branch>` (#56) so they
|
||||
/// are findable from the repo; an operator-set root overrides that.
|
||||
@@ -39,9 +48,9 @@ impl WorktreeManager {
|
||||
}
|
||||
|
||||
/// Where a worktree for `branch` will be created: the override root, or
|
||||
/// `<main checkout>/worktrees/<sanitized branch>` by default. The path is
|
||||
/// predictable (no UUID suffix), so an existing directory at the target
|
||||
/// is an error rather than a silently different location.
|
||||
/// `<main checkout>/worktrees/<sanitized branch>` by default. If the path
|
||||
/// is occupied, [`Self::add_new`] / [`Self::add_existing`] sidestep to
|
||||
/// `<path>-2`, `<path>-3`, ... instead.
|
||||
///
|
||||
/// `base_repo` is whatever the client picked, which the project list makes
|
||||
/// easy to point at a *linked worktree* — placing the new tree inside it
|
||||
@@ -51,23 +60,36 @@ impl WorktreeManager {
|
||||
/// uncommitted work (#54 review). Always place against the main checkout.
|
||||
///
|
||||
/// The override root gets the repo's basename too: without it two repos
|
||||
/// spawning the same branch name target one directory and the second
|
||||
/// fails with `PathExists`.
|
||||
/// spawning the same branch name target one directory.
|
||||
fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf {
|
||||
let slug = sanitize_branch_for_path(branch);
|
||||
// An unusable branch name falls back to a digest rather than escaping
|
||||
// the root; `run_add` surfaces git's own error for it anyway.
|
||||
let slug = branch_relpath(branch).unwrap_or_else(|| PathBuf::from(short_hash(branch)));
|
||||
let main = main_checkout(base_repo);
|
||||
match &self.root_override {
|
||||
Some(root) => {
|
||||
let repo = main
|
||||
// Basename alone collides for two checkouts of the same name
|
||||
// (`~/a/maestro` and `~/b/maestro`), so disambiguate with a
|
||||
// digest of the full path (#54 review).
|
||||
let base = main
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "repo".into());
|
||||
let repo = format!("{base}-{}", short_hash(&main.to_string_lossy()));
|
||||
root.join(repo).join(slug)
|
||||
}
|
||||
None => main.join("worktrees").join(slug),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::target_path`] with `-n` appended to the final component, the
|
||||
/// collision suffix (#77).
|
||||
fn target_path_suffixed(&self, base_repo: &Path, branch: &str, n: u32) -> PathBuf {
|
||||
let mut name = self.target_path(base_repo, branch).into_os_string();
|
||||
name.push(format!("-{n}"));
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
/// `git -C <base_repo> worktree add -b <branch> <path>` — creates the
|
||||
/// branch. Returns the path of the new worktree on success.
|
||||
pub fn add_new(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
|
||||
@@ -86,12 +108,18 @@ impl WorktreeManager {
|
||||
branch: &str,
|
||||
create_branch: bool,
|
||||
) -> Result<PathBuf, WorktreeError> {
|
||||
let path = self.target_path(base_repo, branch);
|
||||
// Clear registrations whose directory is gone before testing the
|
||||
// path, so a previously-leaked entry doesn't block this branch.
|
||||
// Clear registrations whose directory is gone first, so a
|
||||
// previously-leaked entry doesn't push this branch to a suffix.
|
||||
prune(base_repo)?;
|
||||
if path.symlink_metadata().is_ok() {
|
||||
return Err(WorktreeError::PathExists { path });
|
||||
// Anything occupying the target — a tree we left behind, one the
|
||||
// operator made, an unrelated directory — is sidestepped with a
|
||||
// numeric suffix on the directory; the branch stays whatever was
|
||||
// requested (#77). The daemon never adopts what it finds.
|
||||
let mut path = self.target_path(base_repo, branch);
|
||||
let mut n = 1;
|
||||
while path.symlink_metadata().is_ok() {
|
||||
n += 1;
|
||||
path = self.target_path_suffixed(base_repo, branch, n);
|
||||
}
|
||||
// The parent (`<repo>/worktrees` or the override root) may not exist
|
||||
// yet; the target itself must not.
|
||||
@@ -111,12 +139,13 @@ impl WorktreeManager {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// `git -C <base_repo> worktree remove <path>` — but only when the
|
||||
/// worktree holds nothing unique (#56). A worktree with uncommitted
|
||||
/// changes, or with commits reachable from no other ref, is refused and
|
||||
/// left on disk so the operator can rescue it; cleanup is unconditional,
|
||||
/// which makes this guard the only thing standing between a stray kill
|
||||
/// and destroyed work.
|
||||
/// `git -C <base_repo> worktree remove <path>`.
|
||||
///
|
||||
/// Unconditional (#79): the guard moved off the worktree and onto the
|
||||
/// kill, so by the time removal runs the caller has already chosen to
|
||||
/// destroy the tree — the kill only gets here after
|
||||
/// [`crate::worktree_probe::in_the_way`] came back clear. Still refused for a tree holding a
|
||||
/// nested registered worktree, which `git status` cannot see.
|
||||
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
|
||||
// Gone from disk already (operator `rm -rf`, or a create that failed
|
||||
// half-way). Probing it with git would fail and be misread as unsaved
|
||||
@@ -132,12 +161,6 @@ impl WorktreeManager {
|
||||
reason: format!("holds a nested worktree at {}", inner.display()),
|
||||
});
|
||||
}
|
||||
if let Some(reason) = unsafe_to_remove(path)? {
|
||||
return Err(WorktreeError::Guarded {
|
||||
path: path.to_path_buf(),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(base_repo)
|
||||
@@ -180,44 +203,60 @@ fn nested_worktree(path: &Path) -> Option<PathBuf> {
|
||||
.args(["worktree", "list", "--porcelain"])
|
||||
.output()
|
||||
.ok()?;
|
||||
// `worktree list` prints fully-resolved paths while ours comes from
|
||||
// `--git-common-dir` and may still hold symlinks. Comparing the two forms
|
||||
// directly makes the guard silently pass on any symlinked repo path — and
|
||||
// this guard is what stands between a kill and deleting an inner
|
||||
// worktree's uncommitted work (#54 review).
|
||||
let needle = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
text.lines()
|
||||
.filter_map(|l| l.strip_prefix("worktree "))
|
||||
.map(PathBuf::from)
|
||||
.find(|p| p != path && p.starts_with(path))
|
||||
.find(|p| {
|
||||
let real = p.canonicalize().unwrap_or_else(|_| p.clone());
|
||||
real != needle && real.starts_with(&needle)
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitize_branch_for_path(branch: &str) -> String {
|
||||
let mut out = String::with_capacity(branch.len());
|
||||
for c in branch.chars() {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
out.push(c);
|
||||
} else if c == '/' || c == '.' {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push_str("wt");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `Some(reason)` when deleting `path` would destroy work.
|
||||
/// Short digest used to disambiguate override roots.
|
||||
///
|
||||
/// Only an unclean working tree qualifies. `git worktree remove` deletes the
|
||||
/// checkout but keeps `refs/heads/<branch>`, so committed work — pushed or
|
||||
/// not — stays reachable and is never at risk. Guarding on "commits on no
|
||||
/// other ref" instead would refuse every ordinary unpushed feature branch,
|
||||
/// and because the path is deterministic that branch could then never be
|
||||
/// spawned again (#56 review).
|
||||
fn unsafe_to_remove(path: &Path) -> Result<Option<String>, WorktreeError> {
|
||||
// `--ignored=no` keeps build output out of it; untracked sources count,
|
||||
// since removal would delete them.
|
||||
let status = run_git(path, ["status", "--porcelain", "--ignored=no"])?;
|
||||
if !status.stdout.is_empty() {
|
||||
return Ok(Some("has uncommitted changes".into()));
|
||||
/// Hand-rolled FNV-1a rather than `DefaultHasher`: this value becomes a
|
||||
/// directory name, and `DefaultHasher`'s algorithm is explicitly not
|
||||
/// guaranteed stable across Rust releases — a toolchain bump would remap every
|
||||
/// repo to a new directory and orphan the worktrees under the old one
|
||||
/// (#67 review).
|
||||
fn short_hash(s: &str) -> String {
|
||||
let mut h: u32 = 0x811c_9dc5;
|
||||
for b in s.as_bytes() {
|
||||
h ^= u32::from(*b);
|
||||
h = h.wrapping_mul(0x0100_0193);
|
||||
}
|
||||
Ok(None)
|
||||
format!("{h:08x}")
|
||||
}
|
||||
|
||||
/// The relative directory a branch's worktree lives in.
|
||||
///
|
||||
/// A git branch name is already a valid relative path — git stores it as
|
||||
/// `.git/refs/heads/feat/example` — so it is used verbatim and `feat/example`
|
||||
/// nests under `worktrees/feat/`. That keeps the path readable *and* keeps the
|
||||
/// mapping injective: flattening `/` and `.` to `-` made `feat/x`, `feat.x`
|
||||
/// and `feat-x` share one directory (#54 review).
|
||||
///
|
||||
/// Git already forbids holding both `feat` and `feat/x`, so the directory
|
||||
/// collision that nesting could cause cannot arise from valid refs. Only
|
||||
/// traversal and absolute components are rejected.
|
||||
fn branch_relpath(branch: &str) -> Option<PathBuf> {
|
||||
use std::path::Component;
|
||||
let candidate = Path::new(branch);
|
||||
if branch.is_empty()
|
||||
|| !candidate
|
||||
.components()
|
||||
.all(|c| matches!(c, Component::Normal(_)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(candidate.to_path_buf())
|
||||
}
|
||||
|
||||
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
|
||||
@@ -231,22 +270,6 @@ fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
||||
check(out)
|
||||
}
|
||||
|
||||
fn run_git(
|
||||
dir: &Path,
|
||||
args: impl IntoIterator<Item = &'static str>,
|
||||
) -> Result<Output, WorktreeError> {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("-C").arg(dir).args(args);
|
||||
let out = cmd.output()?;
|
||||
if !out.status.success() {
|
||||
return Err(WorktreeError::NonZero {
|
||||
code: out.status.code().unwrap_or(-1),
|
||||
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn check(out: Output) -> Result<(), WorktreeError> {
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
//! The one git probe for what a worktree holds that would be lost, plus
|
||||
//! guard-free reclaim (#79, #80).
|
||||
//!
|
||||
//! [`in_the_way`] is the single predicate behind all three paths: the kill
|
||||
//! refusal (#79), the natural-exit classifier (#82), and the retained list
|
||||
//! (#80). It lives here rather than in `worktree.rs` so that none of them can
|
||||
//! answer the question differently — #79 and #80 were built in parallel and
|
||||
//! each grew its own copy, which is exactly the drift this collapses.
|
||||
//!
|
||||
//! Reclaim never consults it: reclaim is the deliberate destructive act, and
|
||||
//! the operator chose it while looking at what was in the way.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use maestro_protocol::InTheWay;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProbeError {
|
||||
#[error("failed to spawn git: {0}")]
|
||||
Spawn(#[from] std::io::Error),
|
||||
#[error("git exited with code {code}: {stderr}")]
|
||||
NonZero { code: i32, stderr: String },
|
||||
}
|
||||
|
||||
/// What a kill refusal would report about the worktree at `path`, right now.
|
||||
///
|
||||
/// Ignored files never count (`--ignored=no`) — every Rust worktree has a
|
||||
/// `target/`, and counting those would put every tree permanently in the way.
|
||||
/// A directory that no longer exists reports nothing in the way: there is
|
||||
/// nothing left to lose, and the store's own pruning drops the entry.
|
||||
pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
|
||||
let mut breakdown = InTheWay::default();
|
||||
// `symlink_metadata` rather than `exists`: a broken symlink is gone for
|
||||
// our purposes, and following it would probe the wrong tree.
|
||||
if std::fs::symlink_metadata(path).is_err() {
|
||||
return Ok(breakdown);
|
||||
}
|
||||
let status = run_git(
|
||||
path,
|
||||
[
|
||||
"status",
|
||||
"--porcelain",
|
||||
"--ignored=no",
|
||||
"--untracked-files=normal",
|
||||
],
|
||||
)?;
|
||||
for line in String::from_utf8_lossy(&status.stdout).lines() {
|
||||
if line.starts_with("??") {
|
||||
breakdown.untracked_files += 1;
|
||||
} else if !line.is_empty() {
|
||||
breakdown.uncommitted_files += 1;
|
||||
}
|
||||
}
|
||||
// Resolved inside the worktree so a detached HEAD still has a starting
|
||||
// point; asking the base repo for `refs/heads/<branch>` fails there.
|
||||
let head = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?;
|
||||
let branch = String::from_utf8_lossy(&head.stdout).trim().to_string();
|
||||
let tip: &str = if branch == "HEAD" { "HEAD" } else { &branch };
|
||||
// Commits reachable from the tip but from no remote-tracking ref. A branch
|
||||
// pushed anywhere counts as pushed; there is no "which remote" question to
|
||||
// get wrong.
|
||||
let unpushed = run_git(path, ["rev-list", "--count", tip, "--not", "--remotes"])?;
|
||||
breakdown.unpushed_commits = String::from_utf8_lossy(&unpushed.stdout)
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap_or(0);
|
||||
Ok(breakdown)
|
||||
}
|
||||
|
||||
/// Remove a retained worktree's directory without consulting any guard, per
|
||||
/// the reclaim contract: the operator chose this while looking at what was in
|
||||
/// the way. `git worktree remove` keeps `refs/heads/<branch>`, so commits on
|
||||
/// the branch survive even when they were never pushed.
|
||||
///
|
||||
/// A directory that is already gone only leaves a stale registration behind;
|
||||
/// `worktree prune` clears it so the branch can be spawned again.
|
||||
pub fn reclaim(base_repo: &Path, path: &Path) -> Result<(), ProbeError> {
|
||||
if !path.exists() {
|
||||
return run_git(base_repo, ["worktree", "prune"]).map(|_| ());
|
||||
}
|
||||
run_git(
|
||||
base_repo,
|
||||
[
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
path.to_str().expect("worktree path is valid utf-8"),
|
||||
],
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn run_git<I, S>(dir: &Path, args: I) -> Result<std::process::Output, ProbeError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<std::ffi::OsStr>,
|
||||
{
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.arg("-C").arg(dir).args(args);
|
||||
let out = cmd.output()?;
|
||||
if !out.status.success() {
|
||||
return Err(ProbeError::NonZero {
|
||||
code: out.status.code().unwrap_or(-1),
|
||||
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
struct Repo {
|
||||
root: tempfile::TempDir,
|
||||
repo: PathBuf,
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("git spawn");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
/// A repo with a remote, one commit on `main`, and a linked worktree for
|
||||
/// branch `feature` at `<repo>/worktrees/feature`.
|
||||
fn repo_with_worktree() -> Repo {
|
||||
let root = tempfile::tempdir().expect("tempdir");
|
||||
let repo = root.path().join("repo");
|
||||
let remote = root.path().join("remote.git");
|
||||
std::fs::create_dir_all(&repo).unwrap();
|
||||
std::fs::create_dir_all(&remote).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
git(&repo, &["config", "user.email", "t@t"]);
|
||||
git(&repo, &["config", "user.name", "T"]);
|
||||
std::fs::write(repo.join("README"), b"hello\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
git(&remote, &["init", "-q", "--bare"]);
|
||||
git(&repo, &["remote", "add", "origin", &path_str(&remote)]);
|
||||
git(&repo, &["push", "-q", "-u", "origin", "main"]);
|
||||
git(
|
||||
&repo,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
"-b",
|
||||
"feature",
|
||||
"worktrees/feature",
|
||||
],
|
||||
);
|
||||
Repo { root, repo }
|
||||
}
|
||||
|
||||
fn path_str(path: &Path) -> String {
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
fn wt(&self) -> PathBuf {
|
||||
self.repo.join("worktrees").join("feature")
|
||||
}
|
||||
|
||||
fn commit_in_wt(&self, msg: &str) {
|
||||
std::fs::write(self.wt().join("README"), msg.as_bytes()).unwrap();
|
||||
git(&self.wt(), &["add", "-A"]);
|
||||
git(&self.wt(), &["commit", "-qm", msg]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_pushed_worktree_has_nothing_in_the_way() {
|
||||
let r = repo_with_worktree();
|
||||
git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]);
|
||||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(breakdown, InTheWay::default());
|
||||
assert!(breakdown.is_clear());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncommitted_untracked_and_unpushed_are_counted_separately() {
|
||||
let r = repo_with_worktree();
|
||||
std::fs::write(r.wt().join("README"), b"edited\n").unwrap();
|
||||
std::fs::write(r.wt().join("scratch.txt"), b"untracked\n").unwrap();
|
||||
|
||||
let dirty = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(dirty.uncommitted_files, 1);
|
||||
assert_eq!(dirty.untracked_files, 1);
|
||||
assert_eq!(dirty.unpushed_commits, 0);
|
||||
assert!(!dirty.is_clear());
|
||||
|
||||
// Committing clears exactly one dimension and creates another.
|
||||
git(&r.wt(), &["add", "README"]);
|
||||
git(&r.wt(), &["commit", "-qm", "wip"]);
|
||||
|
||||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(breakdown.uncommitted_files, 0);
|
||||
assert_eq!(breakdown.untracked_files, 1);
|
||||
assert_eq!(breakdown.unpushed_commits, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_edits_count_as_uncommitted() {
|
||||
let r = repo_with_worktree();
|
||||
std::fs::write(r.wt().join("README"), b"edited\n").unwrap();
|
||||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(breakdown.uncommitted_files, 1);
|
||||
assert_eq!(breakdown.untracked_files, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pushing_clears_unpushed_but_keeps_other_entries() {
|
||||
let r = repo_with_worktree();
|
||||
std::fs::write(r.wt().join("new.txt"), b"x\n").unwrap();
|
||||
r.commit_in_wt("wip");
|
||||
|
||||
let before = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(before.unpushed_commits, 1);
|
||||
|
||||
git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]);
|
||||
// Same stored entry, different tree: the breakdown must move with it.
|
||||
let after = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(after.unpushed_commits, 0);
|
||||
assert_eq!(after.untracked_files, 0);
|
||||
|
||||
std::fs::write(r.wt().join("loose.txt"), b"x\n").unwrap();
|
||||
let dirty = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(dirty.untracked_files, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_files_never_count() {
|
||||
let r = repo_with_worktree();
|
||||
std::fs::write(r.wt().join(".gitignore"), b"target/\n").unwrap();
|
||||
std::fs::create_dir_all(r.wt().join("target")).unwrap();
|
||||
std::fs::write(r.wt().join("target/artifact"), b"x").unwrap();
|
||||
|
||||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(
|
||||
breakdown,
|
||||
InTheWay {
|
||||
untracked_files: 1,
|
||||
..InTheWay::default()
|
||||
},
|
||||
"the .gitignore itself is untracked; target/ is not"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_directory_reports_nothing_in_the_way() {
|
||||
let r = repo_with_worktree();
|
||||
let gone = r.root.path().join("nowhere");
|
||||
let breakdown = in_the_way(&gone).expect("probe");
|
||||
assert!(breakdown.is_clear());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reclaim_removes_the_directory_and_prunes_a_gone_one() {
|
||||
let r = repo_with_worktree();
|
||||
r.commit_in_wt("precious");
|
||||
reclaim(&r.repo, &r.wt()).expect("reclaim");
|
||||
assert!(!r.wt().exists());
|
||||
|
||||
// A second reclaim over the now-missing path just prunes.
|
||||
reclaim(&r.repo, &r.wt()).expect("reclaim again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reclaim_keeps_the_branch_and_its_commits() {
|
||||
let r = repo_with_worktree();
|
||||
r.commit_in_wt("precious");
|
||||
let sha = {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(r.wt())
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
|
||||
reclaim(&r.repo, &r.wt()).expect("reclaim");
|
||||
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&r.repo)
|
||||
.args(["rev-parse", "--verify", "refs/heads/feature"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"branch must survive reclaim: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), sha);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reclaim_is_guard_free_on_a_dirty_tree() {
|
||||
let r = repo_with_worktree();
|
||||
std::fs::write(r.wt().join("README"), b"unsaved\n").unwrap();
|
||||
std::fs::write(r.wt().join("notes.txt"), b"unsaved\n").unwrap();
|
||||
reclaim(&r.repo, &r.wt()).expect("reclaim ignores the guard");
|
||||
assert!(!r.wt().exists());
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ async fn spawn_daemon_ws() -> Harness {
|
||||
"/home/test",
|
||||
Vec::new(),
|
||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
std::sync::Arc::new(fx.retained_store()),
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
|
||||
@@ -27,6 +27,7 @@ async fn spawn() -> Harness {
|
||||
"/home/test",
|
||||
presets,
|
||||
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
std::sync::Arc::new(fx.retained_store()),
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
|
||||
@@ -93,6 +93,38 @@ fn run(cmd: &mut Command) {
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty bare repository at `dir`, usable as a push target (#79).
|
||||
pub fn init_bare_repo(dir: &Path) {
|
||||
run(Command::new("git")
|
||||
.arg("init")
|
||||
.arg("-q")
|
||||
.arg("--bare")
|
||||
.arg(dir.to_str().expect("non-utf8 bare repo path")));
|
||||
}
|
||||
|
||||
/// Push every branch of `repo` to `origin`, creating remote-tracking refs.
|
||||
///
|
||||
/// The unsaved-work probe (#79) counts commits reachable from no
|
||||
/// remote-tracking ref as unpushed, so tests that want a killable worktree
|
||||
/// must give its branch one. Safe to call again: re-adding an existing
|
||||
/// remote is ignored.
|
||||
pub fn push_all_to_origin(repo: &Path, origin: &Path) {
|
||||
let _ = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args([
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
origin.to_str().expect("non-utf8 origin path"),
|
||||
])
|
||||
.status();
|
||||
run(Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["push", "-q", "--all", "origin"]));
|
||||
}
|
||||
|
||||
/// Async polling sibling of [`wait_for`] for tokio tests.
|
||||
pub async fn wait_for_async<F, Fut>(timeout: Duration, mut cond: F)
|
||||
where
|
||||
@@ -163,6 +195,15 @@ impl DaemonFixture {
|
||||
self.state_dir.path().join("sessions")
|
||||
}
|
||||
|
||||
/// Retained-worktree store rooted at the fixture's own state dir (#78).
|
||||
pub fn retained_store(&self) -> maestro_daemon::retained_store::RetainedStore {
|
||||
maestro_daemon::retained_store::RetainedStore::new(self.retained_dir())
|
||||
}
|
||||
|
||||
pub fn retained_dir(&self) -> PathBuf {
|
||||
self.state_dir.path().join("retained-worktrees")
|
||||
}
|
||||
|
||||
pub fn fifo_dir(&self) -> PathBuf {
|
||||
self.state_dir.path().join("fifos")
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::time::Duration;
|
||||
use common::DaemonFixture;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_daemon::local_api;
|
||||
use maestro_protocol::{CreateSessionRequest, Session};
|
||||
use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
@@ -48,6 +48,7 @@ async fn spawn_with(
|
||||
"/home/test",
|
||||
presets,
|
||||
projects,
|
||||
std::sync::Arc::new(fx.retained_store()),
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
@@ -338,6 +339,53 @@ async fn delete_owned_session_kills_it() {
|
||||
assert!(!h.manager.has(session.id).await);
|
||||
}
|
||||
|
||||
/// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409
|
||||
/// with the structured breakdown, and the session keeps running.
|
||||
#[tokio::test]
|
||||
async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the_breakdown() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let repo = tempfile::TempDir::new().unwrap();
|
||||
common::init_repo(repo.path());
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/guarded".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
repo.path()
|
||||
.join("worktrees")
|
||||
.join("feat")
|
||||
.join("guarded")
|
||||
.join("README"),
|
||||
b"unsaved edits\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(url(h.addr, &format!("/api/sessions/{}", session.id)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::CONFLICT);
|
||||
let body: ApiError = resp.json().await.unwrap();
|
||||
assert_eq!(body.error.code, "kill_guarded");
|
||||
let breakdown = body.error.unsaved_work.expect("breakdown on the wire");
|
||||
assert_eq!(breakdown.uncommitted_files, 1);
|
||||
|
||||
assert!(h.manager.has(session.id).await, "the session keeps running");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attach_ws_streams_pty_bytes() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
@@ -492,3 +540,234 @@ async fn next_event(ws: &mut WsClient) -> maestro_protocol::CatalogEvent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/worktrees + DELETE /api/worktrees/:id (#80)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `git -C dir args`, asserting success — the worktree endpoints are tested
|
||||
/// against real git, like everything else in this crate.
|
||||
fn git(dir: &std::path::Path, args: &[&str]) {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("git spawn");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
struct RetainedFixture {
|
||||
repo: tempfile::TempDir,
|
||||
wt: std::path::PathBuf,
|
||||
}
|
||||
|
||||
/// A real repo with a linked worktree for branch `feat/login` whose tree holds
|
||||
/// an uncommitted tracked edit and an unpushed commit.
|
||||
fn retained_fixture() -> RetainedFixture {
|
||||
let repo = tempfile::tempdir().unwrap();
|
||||
common::init_repo(repo.path());
|
||||
// A pushed main, so "unpushed" means commits made on the feature branch,
|
||||
// not the shared history.
|
||||
let remote = repo.path().join("remote.git");
|
||||
std::fs::create_dir_all(&remote).unwrap();
|
||||
let remote_s = remote.to_string_lossy().into_owned();
|
||||
git(&remote, &["init", "-q", "--bare"]);
|
||||
git(repo.path(), &["remote", "add", "origin", &remote_s]);
|
||||
git(repo.path(), &["push", "-q", "-u", "origin", "main"]);
|
||||
git(
|
||||
repo.path(),
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
"-b",
|
||||
"feat/login",
|
||||
"worktrees/feat/login",
|
||||
],
|
||||
);
|
||||
let wt = repo.path().join("worktrees").join("feat").join("login");
|
||||
RetainedFixture { repo, wt }
|
||||
}
|
||||
|
||||
impl RetainedFixture {
|
||||
fn commit_in_wt(&self, msg: &str) {
|
||||
std::fs::write(self.wt.join("README"), msg.as_bytes()).unwrap();
|
||||
git(&self.wt, &["add", "-A"]);
|
||||
git(&self.wt, &["commit", "-qm", msg]);
|
||||
}
|
||||
|
||||
fn branch_sha(&self, branch: &str) -> String {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(self.repo.path())
|
||||
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "branch {branch} must exist");
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worktrees_lists_retained_entries_with_recomputed_breakdown() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fx = retained_fixture();
|
||||
// Uncommitted tracked edit + an untracked file; the branch has no commits
|
||||
// beyond main yet.
|
||||
std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap();
|
||||
std::fs::write(fx.wt.join("scratch.txt"), b"loose\n").unwrap();
|
||||
|
||||
let store = h._fx.retained_store();
|
||||
let entry = store
|
||||
.retain(
|
||||
&fx.wt,
|
||||
fx.repo.path(),
|
||||
"feat/login",
|
||||
maestro_daemon::retained_store::RetainedReason::ForceKilled,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
let w = &listed[0];
|
||||
assert_eq!(w.id, entry.id);
|
||||
assert_eq!(w.path, fx.wt);
|
||||
assert_eq!(w.base_repo, fx.repo.path());
|
||||
assert_eq!(w.branch, "feat/login");
|
||||
assert_eq!(w.reason, maestro_protocol::RetainedReason::ForceKilled);
|
||||
assert_eq!(w.in_the_way.uncommitted_files, 1);
|
||||
assert_eq!(w.in_the_way.untracked_files, 1);
|
||||
assert_eq!(w.in_the_way.unpushed_commits, 0);
|
||||
|
||||
// The operator saves the work themselves. The stored entry is untouched —
|
||||
// the next read must report the tree as it is *now*, not as it was when
|
||||
// it was retained.
|
||||
fx.commit_in_wt("saved");
|
||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed.len(), 1, "same entry, still retained");
|
||||
assert_eq!(listed[0].id, entry.id);
|
||||
assert_eq!(listed[0].in_the_way.uncommitted_files, 0);
|
||||
assert_eq!(listed[0].in_the_way.unpushed_commits, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reclaim_removes_directory_and_entry_but_never_the_branch() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fx = retained_fixture();
|
||||
fx.commit_in_wt("precious");
|
||||
let sha = fx.branch_sha("feat/login");
|
||||
std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap(); // guard would refuse
|
||||
|
||||
let store = h._fx.retained_store();
|
||||
let entry = store
|
||||
.retain(
|
||||
&fx.wt,
|
||||
fx.repo.path(),
|
||||
"feat/login",
|
||||
maestro_daemon::retained_store::RetainedReason::ExitedWithUnsavedWork,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(url(h.addr, &format!("/api/worktrees/{}", entry.id)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
|
||||
assert!(!fx.wt.exists(), "reclaim removes the directory");
|
||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(listed.is_empty(), "reclaim drops the store entry");
|
||||
assert_eq!(
|
||||
fx.branch_sha("feat/login"),
|
||||
sha,
|
||||
"the branch and its commits survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reclaim_of_an_already_gone_directory_still_drops_the_entry() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fx = retained_fixture();
|
||||
let store = h._fx.retained_store();
|
||||
let entry = store
|
||||
.retain(
|
||||
&fx.wt,
|
||||
fx.repo.path(),
|
||||
"feat/login",
|
||||
maestro_daemon::retained_store::RetainedReason::ForceKilled,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::remove_dir_all(&fx.wt).unwrap(); // operator rm -rf
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(url(h.addr, &format!("/api/worktrees/{}", entry.id)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
assert!(store.load_all().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reclaim_unknown_id_returns_404() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(url(h.addr, &format!("/api/worktrees/{}", Uuid::new_v4())))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worktrees_prunes_entries_whose_directory_vanished_by_hand() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fx = retained_fixture();
|
||||
let store = h._fx.retained_store();
|
||||
store
|
||||
.retain(
|
||||
&fx.wt,
|
||||
fx.repo.path(),
|
||||
"feat/login",
|
||||
maestro_daemon::retained_store::RetainedReason::ForceKilled,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::remove_dir_all(&fx.wt).unwrap();
|
||||
|
||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(listed.is_empty(), "a hand-removed directory is not listed");
|
||||
assert!(
|
||||
store.load_all().unwrap().is_empty(),
|
||||
"and its entry is gone"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{init_repo, wait_for_async, DaemonFixture};
|
||||
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
|
||||
use maestro_daemon::SessionError;
|
||||
use maestro_protocol::CreateSessionRequest;
|
||||
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
|
||||
use tempfile::TempDir;
|
||||
@@ -363,14 +365,15 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
||||
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
|
||||
}
|
||||
|
||||
/// Cleanup is unconditional (#56): even a spec that says "don't clean up"
|
||||
/// loses its worktree on kill — the guard, not the flag, protects work.
|
||||
/// Cleanup on kill is unconditional once the guard passes (#79): even a spec
|
||||
/// that says "don't clean up" loses its worktree — the branch was pushed, so
|
||||
/// nothing in the tree is unique and removal goes ahead.
|
||||
#[tokio::test]
|
||||
async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat-keep");
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("keep");
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
@@ -390,18 +393,25 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||
.expect("create");
|
||||
assert!(wt_path.exists());
|
||||
|
||||
// Push the branch so its commits are reachable from a remote-tracking ref.
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
fx.manager.kill(s.id).await.expect("kill");
|
||||
assert!(!wt_path.exists(), "worktree must be removed on kill");
|
||||
}
|
||||
|
||||
/// The guard (#56): a worktree holding uncommitted changes survives the kill,
|
||||
/// left on disk where the operator can find it.
|
||||
/// The kill guard (#79): a worktree holding uncommitted changes refuses the
|
||||
/// kill outright. Nothing happens — the session keeps running as the
|
||||
/// operator's handle on their unfinished work, and the tree is untouched.
|
||||
/// The refusal names what blocked it and how much of it there is.
|
||||
#[tokio::test]
|
||||
async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
|
||||
async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat-guarded");
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("guarded");
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
@@ -420,13 +430,138 @@ async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
|
||||
.await
|
||||
.expect("create");
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap();
|
||||
|
||||
fx.manager.kill(s.id).await.expect("kill");
|
||||
let err = fx.manager.kill(s.id).await.expect_err("kill must refuse");
|
||||
let SessionError::KillGuarded(breakdown) = err else {
|
||||
panic!("expected KillGuarded, got {err:?}");
|
||||
};
|
||||
assert_eq!(breakdown.uncommitted_files, 1);
|
||||
assert_eq!(breakdown.untracked_files, 1);
|
||||
assert!(
|
||||
wt_path.exists(),
|
||||
"dirty worktree must be left on disk, not destroyed"
|
||||
breakdown.unpushed_commits >= 1,
|
||||
"a fresh branch has no remote-tracking ref"
|
||||
);
|
||||
let message = breakdown.to_string();
|
||||
assert!(message.contains("1 uncommitted file"), "{message}");
|
||||
assert!(message.contains("1 untracked file"), "{message}");
|
||||
|
||||
// Nothing happened: the session survives and so does the tree.
|
||||
assert!(fx.manager.has(s.id).await, "the session must keep running");
|
||||
assert!(wt_path.exists(), "the worktree must be untouched");
|
||||
|
||||
// A second kill after the work is saved goes through.
|
||||
run_git_in(&wt_path, &["add", "."]);
|
||||
run_git_in(&wt_path, &["commit", "-q", "-m", "saved"]);
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
fx.manager.kill(s.id).await.expect("kill after saving");
|
||||
assert!(!fx.manager.has(s.id).await);
|
||||
}
|
||||
|
||||
/// Unpushed commits alone block the kill (#79): a feature branch almost
|
||||
/// always has them, which is why `--force` (which retains) is the ordinary
|
||||
/// way to end such a session.
|
||||
#[tokio::test]
|
||||
async fn killing_refuses_until_the_branch_is_pushed() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("pushed");
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/pushed".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
let err = fx.manager.kill(s.id).await.expect_err("unpushed blocks");
|
||||
let SessionError::KillGuarded(breakdown) = err else {
|
||||
panic!("expected KillGuarded, got {err:?}");
|
||||
};
|
||||
assert_eq!(breakdown.uncommitted_files, 0);
|
||||
assert_eq!(breakdown.untracked_files, 0);
|
||||
assert!(breakdown.unpushed_commits >= 1);
|
||||
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
fx.manager.kill(s.id).await.expect("pushed branch kills");
|
||||
assert!(!wt_path.exists());
|
||||
assert!(!fx.manager.has(s.id).await);
|
||||
}
|
||||
|
||||
/// Ignored files NEVER block (#79): build output like `target/` exists in
|
||||
/// every Rust checkout, and an ignored-only worktree stays killable.
|
||||
#[tokio::test]
|
||||
async fn killing_an_ignored_only_worktree_succeeds() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
// .gitignore must be committed before the worktree branches off, or it
|
||||
// would itself read as an untracked file.
|
||||
std::fs::write(repo.path().join(".gitignore"), b"/target\n").unwrap();
|
||||
run_git_in(repo.path(), &["add", ".gitignore"]);
|
||||
run_git_in(repo.path(), &["commit", "-q", "-m", "ignore target"]);
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("rusty");
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/rusty".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
std::fs::create_dir_all(wt_path.join("target")).unwrap();
|
||||
std::fs::write(wt_path.join("target").join("app.o"), b"binary\n").unwrap();
|
||||
|
||||
// Branch pushed; the only dirt is ignored output.
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
fx.manager
|
||||
.kill(s.id)
|
||||
.await
|
||||
.expect("ignored files never block");
|
||||
assert!(!wt_path.exists());
|
||||
assert!(!fx.manager.has(s.id).await);
|
||||
}
|
||||
|
||||
fn run_git_in(dir: &std::path::Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("git spawn");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(fx.manager.snapshot().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -19,6 +19,7 @@ fn sample(id: Uuid) -> Session {
|
||||
command: "bash".into(),
|
||||
env: HashMap::from([("FOO".into(), "bar".into())]),
|
||||
metadata: HashMap::from([("name".into(), Value::String("maestro-impl".into()))]),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z")
|
||||
.unwrap()
|
||||
|
||||
@@ -6,8 +6,9 @@ mod common;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use common::init_repo;
|
||||
use common::{init_bare_repo, init_repo, push_all_to_origin};
|
||||
use maestro_daemon::worktree::WorktreeManager;
|
||||
use maestro_daemon::worktree_probe::in_the_way;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn worktree_branches(repo: &PathBuf) -> Vec<String> {
|
||||
@@ -53,7 +54,7 @@ fn add_places_worktree_inside_the_base_repo_by_default() {
|
||||
let mgr = WorktreeManager::new(None);
|
||||
|
||||
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
|
||||
assert_eq!(path, repo.join("worktrees").join("feat-example"));
|
||||
assert_eq!(path, repo.join("worktrees").join("feat").join("example"));
|
||||
assert!(path.exists());
|
||||
assert!(path.join("README").exists());
|
||||
|
||||
@@ -74,31 +75,47 @@ fn worktree_root_override_redirects_placement() {
|
||||
// repos spawning the same branch name don't target one directory.
|
||||
let repo_name = repo.file_name().unwrap();
|
||||
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
|
||||
assert_eq!(path, wt_root.path().join(repo_name).join("feat-example"));
|
||||
// The override root disambiguates same-named checkouts with a digest of
|
||||
// the full repo path, and the branch keeps its own shape.
|
||||
// <root>/<repo basename>-<digest>/feat/example
|
||||
let prefix = format!("{}-", repo_name.to_string_lossy());
|
||||
let repo_dir_name = path
|
||||
.strip_prefix(wt_root.path())
|
||||
.unwrap()
|
||||
.components()
|
||||
.next()
|
||||
.unwrap();
|
||||
assert!(
|
||||
repo_dir_name
|
||||
.as_os_str()
|
||||
.to_string_lossy()
|
||||
.starts_with(&prefix),
|
||||
"{path:?}"
|
||||
);
|
||||
assert!(path.ends_with("feat/example"), "{path:?}");
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
/// Anything at all occupying the target path — ours, the operator's, or an
|
||||
/// unrelated directory — is sidestepped, not adopted and not reported (#77).
|
||||
#[test]
|
||||
fn existing_directory_at_target_path_is_an_error_carrying_the_path() {
|
||||
fn an_occupied_target_path_gets_a_numeric_suffix() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
// A predictable path means collisions are possible; they must be
|
||||
// reported, never silently relocated.
|
||||
std::fs::create_dir_all(repo.join("worktrees").join("feat-collide")).unwrap();
|
||||
// A non-git directory sits exactly where the worktree would go.
|
||||
let squatter = repo.join("worktrees").join("feat").join("collide");
|
||||
std::fs::create_dir_all(&squatter).unwrap();
|
||||
std::fs::write(squatter.join("operator-notes.txt"), b"mine\n").unwrap();
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let err = mgr.add_new(&repo, "feat/collide").unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
let path = mgr.add_new(&repo, "feat/collide").expect("sidesteps");
|
||||
assert_eq!(path, repo.join("worktrees").join("feat").join("collide-2"));
|
||||
assert!(path.exists());
|
||||
assert!(
|
||||
msg.contains(
|
||||
repo.join("worktrees")
|
||||
.join("feat-collide")
|
||||
.to_str()
|
||||
.unwrap()
|
||||
),
|
||||
"{msg}"
|
||||
squatter.join("operator-notes.txt").exists(),
|
||||
"the occupant is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,6 +135,123 @@ fn add_twice_generates_distinct_paths() {
|
||||
assert!(p2.exists());
|
||||
}
|
||||
|
||||
/// The unsaved-work probe (#79) counts uncommitted tracked changes.
|
||||
#[test]
|
||||
fn the_probe_counts_uncommitted_tracked_changes() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let path = mgr.add_new(&repo, "feat/dirty").expect("add");
|
||||
std::fs::write(path.join("README"), b"unsaved edits\n").unwrap();
|
||||
|
||||
let w = in_the_way(&path).expect("probe");
|
||||
assert_eq!(w.uncommitted_files, 1);
|
||||
assert_eq!(w.untracked_files, 0);
|
||||
}
|
||||
|
||||
/// Untracked sources count; a staged new file is also unsaved tracked work.
|
||||
#[test]
|
||||
fn the_probe_counts_untracked_files() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let path = mgr.add_new(&repo, "feat/untracked").expect("add");
|
||||
std::fs::write(path.join("notes.txt"), b"wip\n").unwrap();
|
||||
std::fs::write(path.join("more.txt"), b"also wip\n").unwrap();
|
||||
|
||||
let w = in_the_way(&path).expect("probe");
|
||||
assert_eq!(w.untracked_files, 2);
|
||||
assert_eq!(w.uncommitted_files, 0);
|
||||
|
||||
run_git(&path, &["add", "notes.txt"]);
|
||||
let w = in_the_way(&path).expect("probe");
|
||||
assert_eq!(w.uncommitted_files, 1, "staged counts as uncommitted");
|
||||
assert_eq!(w.untracked_files, 1);
|
||||
}
|
||||
|
||||
/// Ignored files NEVER block: every Rust checkout has a `target/`, and
|
||||
/// counting ignored output would leave no session killable (#79).
|
||||
#[test]
|
||||
fn the_probe_ignores_ignored_files() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
// .gitignore must be committed, or it would itself read as untracked.
|
||||
std::fs::write(repo.join(".gitignore"), b"/target\n").unwrap();
|
||||
run_git(&repo, &["add", ".gitignore"]);
|
||||
run_git(&repo, &["commit", "-q", "-m", "ignore target"]);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let path = mgr.add_new(&repo, "feat/builds").expect("add");
|
||||
std::fs::create_dir_all(path.join("target")).unwrap();
|
||||
std::fs::write(path.join("target").join("app.o"), b"binary\n").unwrap();
|
||||
|
||||
let w = in_the_way(&path).expect("probe");
|
||||
assert_eq!(w.uncommitted_files, 0);
|
||||
assert_eq!(w.untracked_files, 0);
|
||||
|
||||
// With the branch pushed too, an ignored-only tree reads fully clean.
|
||||
let origin_dir = TempDir::new().unwrap();
|
||||
init_bare_repo(origin_dir.path());
|
||||
push_all_to_origin(&repo, origin_dir.path());
|
||||
let w = in_the_way(&path).expect("probe after push");
|
||||
assert!(w.is_clear(), "ignored-only worktree must read clean: {w:?}");
|
||||
}
|
||||
|
||||
/// Commits reachable from no remote-tracking ref block; pushing the branch
|
||||
/// clears them. With no remote at all every commit is unreachable from one,
|
||||
/// which is exactly why feature branches almost always refuse (#79).
|
||||
#[test]
|
||||
fn the_probe_counts_unpushed_commits_until_the_branch_is_pushed() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let path = mgr.add_new(&repo, "feat/branch").expect("add");
|
||||
|
||||
let w = in_the_way(&path).expect("probe");
|
||||
assert!(
|
||||
w.unpushed_commits >= 1,
|
||||
"a fresh branch has no remote-tracking ref: {w:?}"
|
||||
);
|
||||
|
||||
let origin_dir = TempDir::new().unwrap();
|
||||
init_bare_repo(origin_dir.path());
|
||||
push_all_to_origin(&repo, origin_dir.path());
|
||||
|
||||
let w = in_the_way(&path).expect("probe after push");
|
||||
assert_eq!(w.unpushed_commits, 0, "{w:?}");
|
||||
assert!(w.is_clear(), "pushed and clean must be empty: {w:?}");
|
||||
}
|
||||
|
||||
/// The full breakdown names each blocker with its count.
|
||||
#[test]
|
||||
fn the_probe_reports_every_blocker_at_once() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let path = mgr.add_new(&repo, "feat/mixed").expect("add");
|
||||
std::fs::write(path.join("README"), b"edited\n").unwrap();
|
||||
std::fs::write(path.join("scratch.txt"), b"untracked\n").unwrap();
|
||||
|
||||
let w = in_the_way(&path).expect("probe");
|
||||
assert_eq!(w.uncommitted_files, 1);
|
||||
assert_eq!(w.untracked_files, 1);
|
||||
assert!(w.unpushed_commits >= 1);
|
||||
let summary = w.to_string();
|
||||
assert!(summary.contains("1 uncommitted file"), "{summary}");
|
||||
assert!(summary.contains("1 untracked file"), "{summary}");
|
||||
assert!(summary.contains("unpushed commit"), "{summary}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_prunes_worktree() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
@@ -195,8 +329,14 @@ fn add_existing_checks_out_branch_without_creating_it() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Removal is guarded upstream now (#79): the kill refuses unsaved work
|
||||
/// before anything is torn down, so by the time `remove` runs the tree is
|
||||
/// clean. Git still refuses a dirty tree on its own — this asserts that last
|
||||
/// line of defense — and committed-but-unpushed work does not block removal,
|
||||
/// because `worktree remove` keeps `refs/heads/<branch>` and the commits stay
|
||||
/// reachable.
|
||||
#[test]
|
||||
fn remove_refuses_worktree_with_uncommitted_changes() {
|
||||
fn remove_still_relies_on_git_refusing_a_dirty_tree() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
@@ -209,10 +349,6 @@ fn remove_refuses_worktree_with_uncommitted_changes() {
|
||||
.expect_err("dirty worktree must be refused");
|
||||
assert!(path.exists(), "guarded worktree must stay on disk");
|
||||
|
||||
// Committing it makes removal safe: `worktree remove` keeps
|
||||
// `refs/heads/feat/dirty`, so the work is still reachable. Refusing here
|
||||
// instead would make every unpushed branch a one-shot, since the path is
|
||||
// deterministic and the leftover tree blocks the next spawn (#56 review).
|
||||
run_git(&path, &["add", "."]);
|
||||
run_git(&path, &["commit", "-q", "-m", "saved"]);
|
||||
|
||||
@@ -312,10 +448,9 @@ fn placement_resolves_a_linked_worktree_to_the_main_checkout() {
|
||||
!inner.starts_with(&outer),
|
||||
"must not nest: {inner:?} inside {outer:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
inner.parent().and_then(|p| p.parent()),
|
||||
Some(repo.canonicalize().unwrap().as_path()),
|
||||
"placed against the main checkout"
|
||||
assert!(
|
||||
inner.starts_with(repo.canonicalize().unwrap().join("worktrees")),
|
||||
"placed against the main checkout: {inner:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -363,3 +498,75 @@ fn override_root_keeps_repos_apart() {
|
||||
let b = mgr.add_new(b_dir.path(), "feat/shared").expect("repo b");
|
||||
assert_ne!(a, b, "same branch name in two repos must not collide");
|
||||
}
|
||||
|
||||
/// Flattening `/` and `.` to `-` made these three share one directory, so the
|
||||
/// losers could never get a worktree (#54 review).
|
||||
#[test]
|
||||
fn similar_branch_names_get_distinct_directories() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let a = mgr.add_new(&repo, "feat/x").expect("feat/x");
|
||||
let b = mgr.add_new(&repo, "feat.x").expect("feat.x");
|
||||
let c = mgr.add_new(&repo, "feat-x").expect("feat-x");
|
||||
assert_ne!(a, b);
|
||||
assert_ne!(b, c);
|
||||
assert_ne!(a, c);
|
||||
assert!(a.ends_with("feat/x"), "{a:?}");
|
||||
}
|
||||
|
||||
/// A guarded leftover stays on disk untouched, and is never adopted (#77).
|
||||
/// Re-spawning its branch is refused by git itself — the branch is still
|
||||
/// checked out inside the leftover — which the caller surfaces.
|
||||
#[test]
|
||||
fn a_guarded_leftover_is_never_adopted() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let first = mgr.add_new(&repo, "feat/resume").expect("add");
|
||||
std::fs::write(first.join("notes.txt"), b"unsaved\n").unwrap();
|
||||
|
||||
// Removal is refused — git itself refuses a dirty tree — leaving it on
|
||||
// disk; the daemon-side guard lives on the kill (#79).
|
||||
mgr.remove(&repo, &first)
|
||||
.expect_err("dirty tree is guarded");
|
||||
|
||||
let err = mgr
|
||||
.add_existing(&repo, "feat/resume")
|
||||
.expect_err("a branch already checked out cannot be spawned twice");
|
||||
assert!(format!("{err}").contains("git exited"), "{err}");
|
||||
assert!(
|
||||
first.join("notes.txt").exists(),
|
||||
"the leftover's unsaved work is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `worktree_root` override gets the same collision suffix (#77).
|
||||
#[test]
|
||||
fn the_override_root_suffixes_collisions_too() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let wt_root = TempDir::new().unwrap();
|
||||
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||
|
||||
// First add settles `<root>/<repo basename>-<digest>/` so the squatter
|
||||
// can be created at the exact target.
|
||||
mgr.add_new(&repo, "feat/first").expect("add first");
|
||||
let repo_dir_in_root = std::fs::read_dir(wt_root.path())
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.path();
|
||||
let squatter = repo_dir_in_root.join("feat").join("collide");
|
||||
std::fs::create_dir_all(&squatter).unwrap();
|
||||
|
||||
let path = mgr.add_new(&repo, "feat/collide").expect("sidesteps");
|
||||
assert_eq!(path, squatter.with_file_name("collide-2"), "{path:?}");
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_protocol::{CatalogEvent, CreateSessionRequest, DaemonClient, Session};
|
||||
use maestro_protocol::{
|
||||
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, Project, Session,
|
||||
};
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||
@@ -62,6 +64,14 @@ impl TestClient {
|
||||
Ok(self.daemon.kill_session(id).await?)
|
||||
}
|
||||
|
||||
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
|
||||
Ok(self.daemon.list_projects().await?)
|
||||
}
|
||||
|
||||
pub async fn list_branches(&self, path: &str) -> Result<Vec<BranchInfo>, ClientError> {
|
||||
Ok(self.daemon.list_branches(path).await?)
|
||||
}
|
||||
|
||||
/// Connect to the daemon's per-session WS and return the stream. The
|
||||
/// caller drives the conversation.
|
||||
pub async fn attach(
|
||||
|
||||
@@ -29,11 +29,32 @@ impl Daemon {
|
||||
Self::spawn_at(SocketAddr::from((Ipv4Addr::LOCALHOST, free_port()))).await
|
||||
}
|
||||
|
||||
/// Spawn with a config file pre-seeded at the daemon's expected path
|
||||
/// (`$XDG_CONFIG_HOME/maestro/config.toml`).
|
||||
pub async fn spawn_with_config(toml: &str) -> Self {
|
||||
Self::spawn_config_at(
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, free_port())),
|
||||
Some(toml),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Spawn on a caller-supplied bind address.
|
||||
pub async fn spawn_at(bind: SocketAddr) -> Self {
|
||||
Self::spawn_config_at(bind, None).await
|
||||
}
|
||||
|
||||
/// Spawn on a caller-supplied bind address, with an optional config file.
|
||||
async fn spawn_config_at(bind: SocketAddr, config: impl Into<Option<&str>>) -> Self {
|
||||
let home = TempDir::new().expect("home tmpdir");
|
||||
let tmux = TempDir::new().expect("tmux tmpdir");
|
||||
|
||||
if let Some(toml) = config.into() {
|
||||
let dir = home.path().join(".config").join("maestro");
|
||||
std::fs::create_dir_all(&dir).expect("config dir");
|
||||
std::fs::write(dir.join("config.toml"), toml).expect("write config.toml");
|
||||
}
|
||||
|
||||
let child = launch(bind, home.path(), tmux.path());
|
||||
let me = Self {
|
||||
bind_addr: bind,
|
||||
|
||||
@@ -14,5 +14,5 @@ pub mod daemon;
|
||||
pub mod util;
|
||||
|
||||
pub use build::workspace_target_bin;
|
||||
pub use client::TestClient;
|
||||
pub use client::{ClientError, TestClient};
|
||||
pub use daemon::Daemon;
|
||||
|
||||
+366
-15
@@ -35,6 +35,22 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn request carrying a worktree spec against `base`.
|
||||
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
|
||||
CreateSessionRequest {
|
||||
cwd: base.to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: base.to_path_buf(),
|
||||
branch: branch.into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn lists_spawned_sessions() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
@@ -140,10 +156,11 @@ async fn kill_removes_clean_worktree() {
|
||||
assert!(
|
||||
worktrees
|
||||
.iter()
|
||||
.any(|w| w.contains("worktrees/feature-e2e")),
|
||||
.any(|w| w.contains("worktrees/feature/e2e")),
|
||||
"expected a worktree for feature/e2e under <repo>/worktrees, got: {worktrees:?}"
|
||||
);
|
||||
|
||||
push_to_throwaway_origin(base.path());
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
|
||||
// Give the daemon a moment to remove the worktree, then verify.
|
||||
@@ -161,16 +178,21 @@ async fn kill_removes_clean_worktree() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The guard (#56): killing a session whose worktree holds uncommitted
|
||||
/// changes must leave the tree on disk — cleanup is unconditional, so this
|
||||
/// guard is what keeps a stray kill from destroying work.
|
||||
/// The kill guard (#79): killing a session whose worktree holds uncommitted
|
||||
/// changes is refused outright. Nothing happens — the session keeps running
|
||||
/// as the operator's handle on their unfinished work, the tree stays on disk,
|
||||
/// and the refusal names what blocked it and how much.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn kill_spares_worktree_with_uncommitted_changes() {
|
||||
async fn kill_refuses_worktree_with_uncommitted_changes() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
let wt_path = base.path().join("worktrees").join("feature-unsaved");
|
||||
let wt_path = base
|
||||
.path()
|
||||
.join("worktrees")
|
||||
.join("feature")
|
||||
.join("unsaved");
|
||||
|
||||
let req = CreateSessionRequest {
|
||||
cwd: base.path().to_path_buf(),
|
||||
@@ -189,21 +211,42 @@ async fn kill_spares_worktree_with_uncommitted_changes() {
|
||||
assert!(wt_path.exists());
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
|
||||
// The session is gone but the dirty worktree stays findable on disk.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
let sessions = client.list_sessions().await.unwrap_or_default();
|
||||
if sessions.is_empty() {
|
||||
break;
|
||||
let err = client
|
||||
.kill_session(s.id)
|
||||
.await
|
||||
.expect_err("kill must refuse");
|
||||
match err {
|
||||
maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => {
|
||||
assert_eq!(info.code, "kill_guarded", "{info:?}");
|
||||
let breakdown = info.unsaved_work.expect("breakdown on the wire");
|
||||
assert_eq!(breakdown.uncommitted_files, 1);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
other => panic!("expected a kill_guarded refusal, got {other:?}"),
|
||||
}
|
||||
|
||||
// The session survives and the dirty worktree stays findable on disk.
|
||||
let sessions = client.list_sessions().await.unwrap_or_default();
|
||||
assert!(
|
||||
sessions.iter().any(|x| x.id == s.id),
|
||||
"a refused kill must keep the session alive"
|
||||
);
|
||||
assert!(
|
||||
wt_path.exists(),
|
||||
"guarded worktree must be left on disk after kill"
|
||||
);
|
||||
// Left findable, not silently half-deleted: git still registers it and
|
||||
// the uncommitted edit survives byte for byte (#66).
|
||||
assert!(
|
||||
git_worktree_list(base.path())
|
||||
.iter()
|
||||
.any(|w| w.contains("feature/unsaved")),
|
||||
"guarded worktree must stay registered in git worktree list"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(wt_path.join("README")).unwrap(),
|
||||
b"unsaved edits\n",
|
||||
"uncommitted changes must survive the guarded kill"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -237,9 +280,290 @@ async fn spawn_on_existing_branch_checks_it_out() {
|
||||
"expected a worktree checked out at feature/existing, got: {worktrees:?}"
|
||||
);
|
||||
|
||||
push_to_throwaway_origin(base.path());
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// #66: a fresh branch spawns a worktree at `<repo>/worktrees/<branch>` and
|
||||
/// the session actually runs inside it — `current_cwd` comes from tmux's live
|
||||
/// pane state, not from the spawn request.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
let wt_path = base.path().join("worktrees").join("feature").join("fresh");
|
||||
|
||||
let s = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/fresh", true))
|
||||
.await
|
||||
.expect("spawn new-branch worktree");
|
||||
|
||||
assert_eq!(
|
||||
s.cwd, wt_path,
|
||||
"session must report the worktree as its cwd"
|
||||
);
|
||||
assert!(wt_path.is_dir(), "worktree directory must exist on disk");
|
||||
|
||||
// The activity watcher fills `current_cwd` from tmux's pane_current_path,
|
||||
// so this is the daemon confirming where the process really runs.
|
||||
wait_for_async(
|
||||
"session's live cwd is the worktree",
|
||||
Duration::from_secs(20),
|
||||
|| {
|
||||
let client = client.clone();
|
||||
let id = s.id;
|
||||
let want = wt_path.clone();
|
||||
async move {
|
||||
client
|
||||
.list_sessions()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|ss| ss.iter().find(|s| s.id == id).cloned())
|
||||
.map(|s| s.current_cwd == Some(want.clone()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
push_to_throwaway_origin(base.path());
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// #66: a second spawn onto a branch that already has a worktree is rejected
|
||||
/// with a structured worktree error; the first worktree stays intact.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn second_spawn_on_same_branch_is_rejected() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
|
||||
let first = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
||||
.await
|
||||
.expect("first spawn");
|
||||
let dup = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
||||
.await;
|
||||
match dup {
|
||||
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
|
||||
assert_eq!(
|
||||
info.code, "worktree_failed",
|
||||
"expected worktree error: {info:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected rejection for duplicate branch, got {other:?}"),
|
||||
}
|
||||
|
||||
// Exactly one registration, one directory, still usable.
|
||||
// Count branch records only: the worktree path now contains the branch
|
||||
// name too (`worktrees/feature/dup`), so a substring match over every line
|
||||
// would count the same tree twice.
|
||||
let holders = git_worktree_list(base.path())
|
||||
.iter()
|
||||
.filter(|w| w.as_str() == "branch refs/heads/feature/dup")
|
||||
.count();
|
||||
assert_eq!(holders, 1, "duplicate spawn must not register another tree");
|
||||
assert!(
|
||||
base.path()
|
||||
.join("worktrees")
|
||||
.join("feature")
|
||||
.join("dup")
|
||||
.is_dir(),
|
||||
"original worktree must be untouched"
|
||||
);
|
||||
|
||||
push_to_throwaway_origin(base.path());
|
||||
client.kill_session(first.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement
|
||||
/// resolves through `--git-common-dir` to the main checkout, so the new tree
|
||||
/// lands at `<main>/worktrees/<branch>` instead of nesting inside another
|
||||
/// worktree.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn spawn_from_linked_worktree_places_in_main_checkout() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
let linked = base.path().join("linked");
|
||||
run_git(
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
"-b",
|
||||
"other",
|
||||
&linked.to_string_lossy(),
|
||||
],
|
||||
base.path(),
|
||||
);
|
||||
|
||||
let s = client
|
||||
.spawn_session(&worktree_req(&linked, "feature/nested", true))
|
||||
.await
|
||||
.expect("spawn from linked worktree");
|
||||
|
||||
let want = base.path().join("worktrees").join("feature").join("nested");
|
||||
assert_eq!(
|
||||
s.cwd, want,
|
||||
"worktree must be placed under the main checkout"
|
||||
);
|
||||
assert!(want.is_dir());
|
||||
assert!(
|
||||
!s.cwd.starts_with(&linked),
|
||||
"worktrees must never nest inside another worktree"
|
||||
);
|
||||
|
||||
push_to_throwaway_origin(base.path());
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/projects + /api/branches (#54/#55/#66)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// #66: two repos under a configured `[spawn].project_roots` root are both
|
||||
/// discovered by `GET /api/projects`; git activity gives every repo a
|
||||
/// `last_activity`, and spawning inside one merges its command into the
|
||||
/// project entry via spawn history.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn projects_endpoint_discovers_roots_and_merges_activity() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let alpha = make_repo(root.path(), "alpha");
|
||||
make_repo(root.path(), "beta");
|
||||
|
||||
let daemon = Daemon::spawn_with_config(&format!(
|
||||
"[spawn]\nproject_roots = [\"{}\"]\nproject_scan_depth = 2\n",
|
||||
root.path().display()
|
||||
))
|
||||
.await;
|
||||
let client = TestClient::new(&daemon.http_base());
|
||||
|
||||
let projects = client.list_projects().await.expect("list projects");
|
||||
let names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
|
||||
assert!(
|
||||
names.contains(&"alpha") && names.contains(&"beta"),
|
||||
"both repos must be discovered, got: {names:?}"
|
||||
);
|
||||
|
||||
let find = |name: &str| {
|
||||
projects
|
||||
.iter()
|
||||
.find(|p| p.name == name)
|
||||
.cloned()
|
||||
.expect("project present")
|
||||
};
|
||||
// Both have commits, so git activity alone populates last_activity…
|
||||
assert!(
|
||||
find("alpha").last_activity.is_some(),
|
||||
"git activity must surface"
|
||||
);
|
||||
assert!(
|
||||
find("beta").last_activity.is_some(),
|
||||
"git activity must surface"
|
||||
);
|
||||
// …and neither has been spawned into yet.
|
||||
assert_eq!(find("alpha").last_command, None);
|
||||
assert_eq!(find("beta").last_command, None);
|
||||
|
||||
// Spawning inside alpha writes durable history; /api/projects folds it in.
|
||||
let s = client
|
||||
.spawn_session(&create_req(&alpha, "sleep 30"))
|
||||
.await
|
||||
.expect("spawn in alpha");
|
||||
|
||||
wait_for_async(
|
||||
"alpha's project entry reflects spawn history",
|
||||
Duration::from_secs(5),
|
||||
|| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
client
|
||||
.list_projects()
|
||||
.await
|
||||
.ok()
|
||||
.map(|ps| {
|
||||
ps.iter()
|
||||
.find(|p| p.name == "alpha")
|
||||
.map(|p| p.last_command.as_deref() == Some("sleep 30"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
// beta was never spawned into: no command recorded there.
|
||||
let beta_after = client
|
||||
.list_projects()
|
||||
.await
|
||||
.expect("list after spawn")
|
||||
.into_iter()
|
||||
.find(|p| p.name == "beta")
|
||||
.unwrap();
|
||||
assert_eq!(beta_after.last_command, None);
|
||||
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// #66: `GET /api/branches` lists every local branch, flags the main
|
||||
/// checkout's HEAD with `is_head`, and reports which linked worktree holds an
|
||||
/// occupied branch.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn branches_endpoint_lists_branches_and_flags_occupied() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
run_git(&["branch", "feature/taken"], base.path());
|
||||
run_git(&["branch", "feature/free"], base.path());
|
||||
let holder = base.path().join("holder-wt");
|
||||
run_git(
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
&holder.to_string_lossy(),
|
||||
"feature/taken",
|
||||
],
|
||||
base.path(),
|
||||
);
|
||||
|
||||
let branches = client
|
||||
.list_branches(&base.path().to_string_lossy())
|
||||
.await
|
||||
.expect("list branches");
|
||||
let by_name = |name: &str| {
|
||||
branches
|
||||
.iter()
|
||||
.find(|b| b.name == name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("branch {name} missing from {branches:?}"))
|
||||
};
|
||||
|
||||
let main = by_name("main");
|
||||
assert!(main.is_head, "main is the main checkout's HEAD");
|
||||
assert_eq!(main.worktree, None);
|
||||
|
||||
let taken = by_name("feature/taken");
|
||||
assert!(!taken.is_head);
|
||||
assert_eq!(
|
||||
taken.worktree.as_deref(),
|
||||
Some(holder.canonicalize().unwrap().as_path()),
|
||||
"occupied branch must report its holding worktree"
|
||||
);
|
||||
|
||||
let free = by_name("feature/free");
|
||||
assert!(!free.is_head);
|
||||
assert_eq!(free.worktree, None, "unoccupied branch must be spawnable");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn daemon_restart_recovers_session() {
|
||||
let (mut daemon, client) = bring_up().await;
|
||||
@@ -428,6 +752,15 @@ fn init_repo(dir: &std::path::Path) {
|
||||
run_git(&["commit", "-q", "-m", "initial"], dir);
|
||||
}
|
||||
|
||||
/// Real git repo at `<root>/<name>`, canonicalized (the scanner canonicalizes
|
||||
/// discovered paths too, so comparisons must agree on the real path).
|
||||
fn make_repo(root: &std::path::Path, name: &str) -> std::path::PathBuf {
|
||||
let dir = root.join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
init_repo(&dir);
|
||||
dir.canonicalize().unwrap()
|
||||
}
|
||||
|
||||
fn run_git(args: &[&str], cwd: &std::path::Path) {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
@@ -455,6 +788,24 @@ fn git_worktree_list(dir: &std::path::Path) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Push every branch of `repo` to a throwaway bare remote, so its commits are
|
||||
/// reachable from remote-tracking refs. Without one the kill guard (#79)
|
||||
/// reads every commit as unpushed, and any worktree session refuses to die.
|
||||
fn push_to_throwaway_origin(repo: &std::path::Path) {
|
||||
let origin = tempfile::TempDir::new().unwrap();
|
||||
run_git(
|
||||
&["init", "-q", "--bare", origin.path().to_str().unwrap()],
|
||||
repo,
|
||||
);
|
||||
let _ = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["remote", "add", "origin", origin.path().to_str().unwrap()])
|
||||
.output()
|
||||
.expect("git remote add");
|
||||
run_git(&["push", "-q", "--all", "origin"], repo);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn daemon_events_ws_streams_snapshot_then_session_added() {
|
||||
// NAP-248: subscribe to the daemon's WS /api/events, expect an initial
|
||||
|
||||
@@ -15,8 +15,8 @@ use uuid::Uuid;
|
||||
|
||||
use crate::routes::{self, UrlError};
|
||||
use crate::{
|
||||
ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, Project,
|
||||
RecentDirs, Session,
|
||||
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||
Project, RecentDirs, RetainedWorktree, Session,
|
||||
};
|
||||
|
||||
/// Time budget for establishing a TCP connection to the daemon.
|
||||
@@ -143,6 +143,28 @@ impl DaemonClient {
|
||||
self.get(routes::PROJECTS, &[]).await
|
||||
}
|
||||
|
||||
pub async fn list_branches(&self, path: &str) -> Result<Vec<BranchInfo>, ClientError> {
|
||||
self.get(routes::BRANCHES, &[(routes::PATH_QUERY, path)])
|
||||
.await
|
||||
}
|
||||
|
||||
/// `GET /api/worktrees` (#80): every retained worktree with its in-the-way
|
||||
/// breakdown as of this read.
|
||||
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
|
||||
self.get(routes::WORKTREES, &[]).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/worktrees/:id` (#86): reclaim one retained worktree. The
|
||||
/// daemon removes the directory and drops the store entry; the branch is
|
||||
/// never deleted.
|
||||
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
|
||||
let resp = self
|
||||
.send(Method::DELETE, &routes::worktree(id), &[], None::<&()>)
|
||||
.await?;
|
||||
check(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- plumbing -----------------------------------------------------------
|
||||
|
||||
async fn get<T: DeserializeOwned>(
|
||||
|
||||
@@ -53,6 +53,14 @@ pub struct Session {
|
||||
pub env: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, Value>,
|
||||
/// The worktree this session owns, when it was spawned with one.
|
||||
///
|
||||
/// Persisted rather than kept in memory only: after a daemon restart the
|
||||
/// recovered session must still know what to clean up, and with
|
||||
/// deterministic worktree paths a forgotten association leaves a directory
|
||||
/// that blocks that branch from ever being spawned again (#54 review).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worktree: Option<SessionWorktree>,
|
||||
pub status: SessionStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Current foreground command in the session's pane (e.g. `claude`,
|
||||
@@ -127,6 +135,95 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The worktree a running session owns, carried on [`Session`] so it survives
|
||||
/// a daemon restart.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SessionWorktree {
|
||||
pub path: PathBuf,
|
||||
pub base_repo: PathBuf,
|
||||
}
|
||||
|
||||
/// Why a retained worktree outlived its session (#80). Same values the
|
||||
/// daemon's retained store persists; new variants are additive but clients
|
||||
/// should render unknown reasons generically.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum RetainedReason {
|
||||
/// The session was killed with `--force` while its worktree held unsaved
|
||||
/// work.
|
||||
ForceKilled,
|
||||
/// The session ended by itself and its worktree held unsaved work.
|
||||
ExitedWithUnsavedWork,
|
||||
}
|
||||
|
||||
/// What a kill refusal would report about a worktree right now (#80) — the
|
||||
/// same breakdown that blocks an unforced kill, recomputed on every read of
|
||||
/// [`RetainedWorktree`] so it reflects the tree now rather than when it was
|
||||
/// retained.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InTheWay {
|
||||
/// Tracked files with uncommitted modifications.
|
||||
#[serde(default)]
|
||||
pub uncommitted_files: usize,
|
||||
/// Untracked files that are not ignored (ignored files never block).
|
||||
#[serde(default)]
|
||||
pub untracked_files: usize,
|
||||
/// Commits on the branch reachable from no remote-tracking ref.
|
||||
#[serde(default)]
|
||||
pub unpushed_commits: usize,
|
||||
}
|
||||
|
||||
impl InTheWay {
|
||||
/// Nothing blocks any more — the work has since been committed and pushed.
|
||||
pub fn is_clear(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of `GET /api/worktrees` (#80): a retained worktree with its
|
||||
/// in-the-way state as of this read. Reclaiming it via
|
||||
/// `DELETE /api/worktrees/:id` removes the directory but **never** the branch.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RetainedWorktree {
|
||||
pub id: Uuid,
|
||||
pub path: PathBuf,
|
||||
pub base_repo: PathBuf,
|
||||
pub branch: String,
|
||||
pub reason: RetainedReason,
|
||||
pub retained_at: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
pub in_the_way: InTheWay,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InTheWay {
|
||||
/// "3 uncommitted files, 2 unpushed commits" — zero parts are omitted;
|
||||
/// all-zero renders "no unsaved work". This is the phrasing every client
|
||||
/// uses for both the kill refusal and the retained list, so the two
|
||||
/// cannot describe the same tree differently.
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let parts = [
|
||||
(self.uncommitted_files, "uncommitted file"),
|
||||
(self.untracked_files, "untracked file"),
|
||||
(self.unpushed_commits, "unpushed commit"),
|
||||
];
|
||||
let said: Vec<String> = parts
|
||||
.into_iter()
|
||||
.filter(|(n, _)| *n > 0)
|
||||
.map(|(n, unit)| {
|
||||
if n == 1 {
|
||||
format!("1 {unit}")
|
||||
} else {
|
||||
format!("{n} {unit}s")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if said.is_empty() {
|
||||
return write!(f, "no unsaved work");
|
||||
}
|
||||
write!(f, "{}", said.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP `POST /api/sessions` body.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreateSessionRequest {
|
||||
@@ -232,6 +329,10 @@ pub struct BranchInfo {
|
||||
pub struct ErrorInfo {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
/// Set only by the kill guard (#79): the unsaved work that blocked
|
||||
/// `DELETE /api/sessions/:id`, so a client can render counts directly.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub unsaved_work: Option<InTheWay>,
|
||||
}
|
||||
|
||||
/// The envelope every `/api/*` failure is wrapped in: `{"error": {…}}`.
|
||||
@@ -252,6 +353,7 @@ impl ApiError {
|
||||
error: ErrorInfo {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
unsaved_work: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -410,6 +512,7 @@ mod tests {
|
||||
command: "claude".into(),
|
||||
env: HashMap::from([("FOO".into(), "bar".into())]),
|
||||
metadata: HashMap::from([("name".into(), Value::String("maestro-impl".into()))]),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z")
|
||||
.unwrap()
|
||||
@@ -522,6 +625,60 @@ mod tests {
|
||||
assert_eq!(spec, round_trip(&spec));
|
||||
}
|
||||
|
||||
fn sample_retained_worktree() -> RetainedWorktree {
|
||||
RetainedWorktree {
|
||||
id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef),
|
||||
path: PathBuf::from("/work/maestro/worktrees/feat/login"),
|
||||
base_repo: PathBuf::from("/work/maestro"),
|
||||
branch: "feat/login".into(),
|
||||
reason: RetainedReason::ForceKilled,
|
||||
retained_at: DateTime::parse_from_rfc3339("2026-08-24T10:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
in_the_way: InTheWay {
|
||||
uncommitted_files: 2,
|
||||
untracked_files: 1,
|
||||
unpushed_commits: 3,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_worktree_round_trips() {
|
||||
let wt = sample_retained_worktree();
|
||||
assert_eq!(wt, round_trip(&wt));
|
||||
}
|
||||
|
||||
/// A payload from a daemon predating `in_the_way` still decodes; the
|
||||
/// breakdown defaults to "nothing in the way".
|
||||
#[test]
|
||||
fn retained_worktree_defaults_in_the_way_on_legacy_payload() {
|
||||
let legacy = serde_json::json!({
|
||||
"id": Uuid::from_u128(1),
|
||||
"path": "/w/t",
|
||||
"base_repo": "/w",
|
||||
"branch": "feat/login",
|
||||
"reason": "force-killed",
|
||||
"retained_at": "2026-08-24T10:00:00Z",
|
||||
});
|
||||
let wt: RetainedWorktree = serde_json::from_value(legacy).unwrap();
|
||||
assert_eq!(wt.reason, RetainedReason::ForceKilled);
|
||||
assert_eq!(wt.in_the_way, InTheWay::default());
|
||||
assert!(wt.in_the_way.is_clear());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_reason_serializes_kebab_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(RetainedReason::ForceKilled).unwrap(),
|
||||
serde_json::json!("force-killed")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(RetainedReason::ExitedWithUnsavedWork).unwrap(),
|
||||
serde_json::json!("exited-with-unsaved-work")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_event_round_trips_and_tags() {
|
||||
let session = sample_session();
|
||||
@@ -561,6 +718,7 @@ mod tests {
|
||||
ClientControl::Error(ErrorInfo {
|
||||
code: "bad_frame".into(),
|
||||
message: "decode failed".into(),
|
||||
unsaved_work: None,
|
||||
}),
|
||||
resize,
|
||||
] {
|
||||
|
||||
@@ -26,6 +26,9 @@ pub const BRANCHES: &str = "/api/branches";
|
||||
/// `GET` a batch of generated session handles (#53). Takes a `count` query
|
||||
/// param (default 1, capped at [`maestro_names`]' batch cap).
|
||||
pub const NAMES: &str = "/api/names";
|
||||
/// `GET` retained worktrees (#80): what the store holds, with the in-the-way
|
||||
/// breakdown recomputed on read.
|
||||
pub const WORKTREES: &str = "/api/worktrees";
|
||||
/// `WS` catalog snapshot + session deltas.
|
||||
pub const EVENTS: &str = "/api/events";
|
||||
|
||||
@@ -40,6 +43,11 @@ pub fn session(id: Uuid) -> String {
|
||||
format!("{SESSIONS}/{id}")
|
||||
}
|
||||
|
||||
/// `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80).
|
||||
pub fn worktree(id: Uuid) -> String {
|
||||
format!("{WORKTREES}/{id}")
|
||||
}
|
||||
|
||||
/// `WS /api/attach/:id` — raw PTY bytes.
|
||||
pub fn attach(id: Uuid) -> String {
|
||||
format!("{API_PREFIX}/attach/{id}")
|
||||
@@ -50,6 +58,7 @@ pub fn attach(id: Uuid) -> String {
|
||||
pub mod patterns {
|
||||
pub const SESSION: &str = "/api/sessions/:id";
|
||||
pub const ATTACH: &str = "/api/attach/:id";
|
||||
pub const WORKTREE: &str = "/api/worktrees/:id";
|
||||
}
|
||||
|
||||
/// A daemon base URL that could not be turned into a WebSocket base.
|
||||
@@ -160,6 +169,10 @@ mod tests {
|
||||
attach(id()),
|
||||
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||
);
|
||||
assert_eq!(
|
||||
worktree(id()),
|
||||
"/api/worktrees/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||
);
|
||||
assert_eq!(
|
||||
attach_url("https://laptop.lan:7070", id()).unwrap(),
|
||||
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||
@@ -178,6 +191,10 @@ mod tests {
|
||||
patterns::ATTACH.trim_end_matches(":id"),
|
||||
attach(id()).trim_end_matches(&id().to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
patterns::WORKTREE.trim_end_matches(":id"),
|
||||
worktree(id()).trim_end_matches(&id().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -191,6 +208,7 @@ mod tests {
|
||||
GITROOT,
|
||||
BRANCHES,
|
||||
NAMES,
|
||||
WORKTREES,
|
||||
EVENTS,
|
||||
] {
|
||||
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
||||
|
||||
@@ -36,6 +36,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
||||
command: command.into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
foreground: None,
|
||||
|
||||
@@ -39,6 +39,9 @@ pub enum Action {
|
||||
AttachHighlighted,
|
||||
ToggleAttachHighlighted,
|
||||
NewDraftPane,
|
||||
/// Fast-forward spawn (#67): spawn immediately with every default
|
||||
/// resolved; unresolvable defaults open a pre-filled draft instead.
|
||||
FastForwardSpawn,
|
||||
KillHighlighted,
|
||||
|
||||
// Tabs
|
||||
@@ -79,6 +82,7 @@ impl Action {
|
||||
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
||||
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
||||
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||
"NewTab" => return Ok(Action::NewTab),
|
||||
"CloseTab" => return Ok(Action::CloseTab),
|
||||
@@ -197,6 +201,16 @@ mod tests {
|
||||
assert_eq!(err, ActionParseError::Malformed("GotoTab(3".into()));
|
||||
}
|
||||
|
||||
/// The fast-forward pair sits next to the draft-pane key (#67).
|
||||
#[test]
|
||||
fn fast_forward_spawn_round_trips() {
|
||||
assert_eq!(
|
||||
Action::parse("FastForwardSpawn").unwrap(),
|
||||
Action::FastForwardSpawn
|
||||
);
|
||||
assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_round_trips() {
|
||||
for a in [
|
||||
|
||||
+546
-51
@@ -12,12 +12,13 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Stdout;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use crossterm::event::{Event as CtEvent, EventStream};
|
||||
use futures_util::StreamExt;
|
||||
use maestro_protocol::{routes, CreateSessionRequest, DaemonClient, NameBatch};
|
||||
use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
use uuid::Uuid;
|
||||
@@ -54,6 +55,14 @@ pub struct App {
|
||||
help: HelpOverlay,
|
||||
chord: ChordState,
|
||||
daemon: DaemonClient,
|
||||
/// Results of draft data loads, delivered back to the event loop.
|
||||
///
|
||||
/// These are network calls — `/api/projects` on a cold cache walks all of
|
||||
/// `$HOME` — and awaiting them inside `perform` froze the whole TUI: no
|
||||
/// keys, no frames, attached panes stalled, for up to the request timeout
|
||||
/// (#67 review). They run as detached tasks and post here instead.
|
||||
draft_tx: tokio::sync::mpsc::UnboundedSender<DraftUpdate>,
|
||||
draft_rx: tokio::sync::mpsc::UnboundedReceiver<DraftUpdate>,
|
||||
/// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet:
|
||||
/// `GET /api/names` (#64).
|
||||
http: reqwest::Client,
|
||||
@@ -86,12 +95,25 @@ pub enum Effect {
|
||||
},
|
||||
/// Fetch presets / projects / names for a freshly opened draft (#64).
|
||||
LoadDraft(usize),
|
||||
/// Fast-forward spawn (#67): resolve every default from the daemon and
|
||||
/// spawn with no form; unresolvable defaults open a pre-filled draft.
|
||||
FastForward,
|
||||
/// A reroll drained the draft's local name batch (#64).
|
||||
RefillNames(usize),
|
||||
/// The draft's target changed: probe `/api/gitroot` so the worktree
|
||||
/// section knows its base repo (#65). `seq` guards stale answers.
|
||||
ProbeGitRoot {
|
||||
tab: usize,
|
||||
seq: u32,
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Fetch the draft target's branches from `/api/branches` (#65).
|
||||
LoadBranches(usize),
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(cfg: TuiConfig, catalog: CatalogStore, daemon: DaemonClient) -> Self {
|
||||
let (draft_tx, draft_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let chord_timeout = cfg.chord_timeout;
|
||||
let help = HelpOverlay {
|
||||
lines: render_help_lines(&cfg),
|
||||
@@ -110,6 +132,8 @@ impl App {
|
||||
chord: ChordState::new(chord_timeout),
|
||||
daemon,
|
||||
http,
|
||||
draft_tx,
|
||||
draft_rx,
|
||||
region: RegionMap::default(),
|
||||
last_sent_size: HashMap::new(),
|
||||
clicks: ClickTracker::default(),
|
||||
@@ -131,6 +155,9 @@ impl App {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Some(update) = self.draft_rx.recv() => {
|
||||
self.apply_draft_update(update).await;
|
||||
}
|
||||
_ = self.catalog.changed() => {
|
||||
// Render on next loop iteration.
|
||||
}
|
||||
@@ -191,8 +218,13 @@ impl App {
|
||||
Effect::Attach(id) => self.attach(id),
|
||||
Effect::Spawn(req) => self.submit_spawn(req).await,
|
||||
Effect::Kill(id) => self.submit_kill(id).await,
|
||||
Effect::LoadDraft(tab_idx) => self.load_draft_data(tab_idx).await,
|
||||
Effect::RefillNames(tab_idx) => self.refill_names(tab_idx).await,
|
||||
Effect::LoadDraft(tab_idx) => self.spawn_load_draft(tab_idx),
|
||||
Effect::FastForward => self.spawn_fast_forward(),
|
||||
Effect::RefillNames(tab_idx) => self.spawn_refill_names(tab_idx),
|
||||
Effect::ProbeGitRoot { tab, seq, path } => {
|
||||
self.spawn_probe_git_root(tab, seq, path)
|
||||
}
|
||||
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
|
||||
Effect::Forward { id, bytes } => {
|
||||
if let Some(handle) = self.emulators.get(id) {
|
||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||
@@ -273,6 +305,7 @@ impl App {
|
||||
self.state.open_draft_pane();
|
||||
effects.push(Effect::LoadDraft(self.state.active_tab_idx));
|
||||
}
|
||||
Action::FastForwardSpawn => effects.push(Effect::FastForward),
|
||||
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
||||
if let Some(sid) = self.pick_sidebar_target() {
|
||||
effects.push(Effect::Attach(sid));
|
||||
@@ -349,66 +382,149 @@ impl App {
|
||||
if draft.take_name_refill() {
|
||||
effects.push(Effect::RefillNames(tab_idx));
|
||||
}
|
||||
effects
|
||||
}
|
||||
|
||||
/// One `/api/names` batch. [`DaemonClient`] has no typed call for this
|
||||
/// route yet, so it goes through plain reqwest against the same base URL
|
||||
/// and query contract (`routes::NAMES` + `routes::COUNT_QUERY`).
|
||||
async fn fetch_names(&self, count: usize) -> Vec<String> {
|
||||
let url = format!(
|
||||
"{}{}",
|
||||
maestro_protocol::routes::normalize_base(self.daemon.base()),
|
||||
routes::NAMES
|
||||
);
|
||||
match self
|
||||
.http
|
||||
.get(url)
|
||||
.query(&[(routes::COUNT_QUERY, count)])
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp
|
||||
.json::<NameBatch>()
|
||||
.await
|
||||
.map(|batch| batch.names)
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
if let Some((seq, path)) = draft.take_gitroot_probe() {
|
||||
effects.push(Effect::ProbeGitRoot {
|
||||
tab: tab_idx,
|
||||
seq,
|
||||
path,
|
||||
});
|
||||
}
|
||||
if draft.take_branch_load().is_some() {
|
||||
effects.push(Effect::LoadBranches(tab_idx));
|
||||
}
|
||||
effects
|
||||
}
|
||||
|
||||
/// Populate a freshly opened draft: presets from `/api/info` (falling back
|
||||
/// to the built-ins already in place), projects from `/api/projects`, and
|
||||
/// a first name batch. Applies only if that tab still shows the draft —
|
||||
/// a slow response must never clobber what replaced it.
|
||||
async fn load_draft_data(&mut self, tab_idx: usize) {
|
||||
let (info, projects) = tokio::join!(self.daemon.daemon_info(), self.daemon.list_projects());
|
||||
let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await;
|
||||
let Some(PaneRef::Draft(draft)) = self
|
||||
/// Kick off the draft's initial data load without blocking the loop.
|
||||
fn spawn_load_draft(&self, tab: usize) {
|
||||
let Some(draft) = self.draft_id(tab) else {
|
||||
return;
|
||||
};
|
||||
let (daemon, http, base, tx) = self.loader_handles();
|
||||
tokio::spawn(async move {
|
||||
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
|
||||
let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await;
|
||||
let _ = tx.send(DraftUpdate::Data {
|
||||
draft,
|
||||
info: info.ok(),
|
||||
projects: projects.ok(),
|
||||
names,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_refill_names(&self, tab: usize) {
|
||||
let Some(draft) = self.draft_id(tab) else {
|
||||
return;
|
||||
};
|
||||
let (_, http, base, tx) = self.loader_handles();
|
||||
tokio::spawn(async move {
|
||||
let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await;
|
||||
let _ = tx.send(DraftUpdate::Names { draft, names });
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_probe_git_root(&self, tab: usize, seq: u32, path: PathBuf) {
|
||||
let Some(draft) = self.draft_id(tab) else {
|
||||
return;
|
||||
};
|
||||
let (daemon, _, _, tx) = self.loader_handles();
|
||||
tokio::spawn(async move {
|
||||
let root = daemon
|
||||
.git_root(&path.to_string_lossy())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|g| g.root);
|
||||
let _ = tx.send(DraftUpdate::GitRoot { draft, seq, root });
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_load_branches(&self, tab: usize) {
|
||||
let Some(draft) = self.draft_id(tab) else {
|
||||
return;
|
||||
};
|
||||
let Some(cwd) = self
|
||||
.state
|
||||
.tabs
|
||||
.get_mut(tab_idx)
|
||||
.and_then(|t| t.pane.as_mut())
|
||||
.get(tab)
|
||||
.and_then(|t| t.pane.as_ref())
|
||||
.and_then(|p| match p {
|
||||
PaneRef::Draft(d) => d.cwd(),
|
||||
_ => None,
|
||||
})
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Ok(info) = info {
|
||||
draft.home_dir.clone_from(&info.home_dir);
|
||||
if !info.presets.is_empty() {
|
||||
draft.set_presets(info.presets);
|
||||
}
|
||||
}
|
||||
if let Ok(projects) = projects {
|
||||
draft.set_projects(projects, Utc::now());
|
||||
}
|
||||
if !names.is_empty() {
|
||||
draft.seed_names(names);
|
||||
let (_, http, base, tx) = self.loader_handles();
|
||||
tokio::spawn(async move {
|
||||
let branches = fetch_branches(&http, &base, &cwd).await;
|
||||
let _ = tx.send(DraftUpdate::Branches {
|
||||
draft,
|
||||
cwd,
|
||||
branches,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The draft pane living in `tab`, by identity.
|
||||
fn draft_id(&self, tab: usize) -> Option<u64> {
|
||||
match self.state.tabs.get(tab).and_then(|t| t.pane.as_ref()) {
|
||||
Some(PaneRef::Draft(d)) => Some(d.id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a fresh name batch after a reroll drained the local queue.
|
||||
async fn refill_names(&mut self, tab_idx: usize) {
|
||||
let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await;
|
||||
fn loader_handles(
|
||||
&self,
|
||||
) -> (
|
||||
DaemonClient,
|
||||
reqwest::Client,
|
||||
String,
|
||||
tokio::sync::mpsc::UnboundedSender<DraftUpdate>,
|
||||
) {
|
||||
(
|
||||
self.daemon.clone(),
|
||||
self.http.clone(),
|
||||
self.daemon.base().to_string(),
|
||||
self.draft_tx.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Fold a finished background load into the draft that asked for it.
|
||||
async fn apply_draft_update(&mut self, update: DraftUpdate) {
|
||||
if let DraftUpdate::FastForward {
|
||||
info,
|
||||
projects,
|
||||
names,
|
||||
} = update
|
||||
{
|
||||
self.fast_forward_spawn(info, projects, names).await;
|
||||
return;
|
||||
}
|
||||
let want = match &update {
|
||||
DraftUpdate::Data { draft, .. }
|
||||
| DraftUpdate::Names { draft, .. }
|
||||
| DraftUpdate::GitRoot { draft, .. }
|
||||
| DraftUpdate::Branches { draft, .. } => *draft,
|
||||
// Handled above; it targets no particular draft.
|
||||
DraftUpdate::FastForward { .. } => unreachable!(),
|
||||
};
|
||||
// Find it by identity: closing a tab shifts every index to its right,
|
||||
// so an in-flight load addressed by index would be dropped or land on
|
||||
// whatever pane inherited the slot (#67 review).
|
||||
let Some(tab_idx) = self
|
||||
.state
|
||||
.tabs
|
||||
.iter()
|
||||
.position(|t| matches!(&t.pane, Some(PaneRef::Draft(d)) if d.id == want))
|
||||
else {
|
||||
// The draft went away while the request was in flight.
|
||||
return;
|
||||
};
|
||||
let Some(PaneRef::Draft(draft)) = self
|
||||
.state
|
||||
.tabs
|
||||
@@ -417,9 +533,112 @@ impl App {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !names.is_empty() {
|
||||
draft.extend_names(names);
|
||||
let mut follow_up_branches = false;
|
||||
match update {
|
||||
DraftUpdate::Data {
|
||||
info,
|
||||
projects,
|
||||
names,
|
||||
..
|
||||
} => {
|
||||
if let Some(info) = info {
|
||||
draft.home_dir.clone_from(&info.home_dir);
|
||||
if !info.presets.is_empty() {
|
||||
draft.set_presets(info.presets);
|
||||
}
|
||||
}
|
||||
if let Some(projects) = projects {
|
||||
draft.set_projects(projects, Utc::now());
|
||||
}
|
||||
if !names.is_empty() {
|
||||
draft.seed_names(names);
|
||||
}
|
||||
}
|
||||
DraftUpdate::Names { names, .. } => {
|
||||
if !names.is_empty() {
|
||||
draft.extend_names(names);
|
||||
}
|
||||
}
|
||||
DraftUpdate::GitRoot { seq, root, .. } => {
|
||||
draft.resolve_gitroot(seq, root);
|
||||
follow_up_branches = draft.take_branch_load().is_some();
|
||||
}
|
||||
DraftUpdate::Branches { cwd, branches, .. } => {
|
||||
draft.set_branches(&cwd, branches);
|
||||
}
|
||||
DraftUpdate::FastForward { .. } => unreachable!(),
|
||||
}
|
||||
if follow_up_branches {
|
||||
self.spawn_load_branches(tab_idx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fast-forward spawn (#67): one keystroke, every default resolved, no
|
||||
/// form. The most recent project, its last command (or the first
|
||||
/// non-custom preset), a freshly generated name, worktree on with a new
|
||||
/// branch named after that name. Anything unresolvable — no projects
|
||||
/// known yet, no command, no name — downgrades to a draft pane seeded
|
||||
/// with whatever did resolve.
|
||||
/// Load what the fast path needs without blocking the loop — the same
|
||||
/// reason the draft loaders were moved off it (#67 review): a cold
|
||||
/// `/api/projects` walks all of `$HOME`, and pressing the key must not
|
||||
/// freeze keys, frames and attached panes until it returns.
|
||||
fn spawn_fast_forward(&self) {
|
||||
let (daemon, http, base, tx) = self.loader_handles();
|
||||
tokio::spawn(async move {
|
||||
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
|
||||
let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await;
|
||||
let _ = tx.send(DraftUpdate::FastForward {
|
||||
info: info.ok(),
|
||||
projects: projects.ok(),
|
||||
names,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async fn fast_forward_spawn(
|
||||
&mut self,
|
||||
info: Option<maestro_protocol::DaemonInfo>,
|
||||
projects: Option<Vec<maestro_protocol::Project>>,
|
||||
names: Vec<String>,
|
||||
) {
|
||||
// Keep the home dir: without it the pre-filled draft renders
|
||||
// unabbreviated paths and "Other directory…" seeds `/` instead of
|
||||
// home, so the two draft entry points disagreed (#67 review).
|
||||
let home_dir = info
|
||||
.as_ref()
|
||||
.map(|i| i.home_dir.clone())
|
||||
.unwrap_or_default();
|
||||
let presets = match info {
|
||||
Some(info) if !info.presets.is_empty() => info.presets,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let discovered = projects.unwrap_or_default();
|
||||
let resolved =
|
||||
crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now());
|
||||
if let Some(req) = resolved.build_request() {
|
||||
// Never eat a pane already showing something: the spawn takes a
|
||||
// fresh tab, same as opening a draft over a session would.
|
||||
if self.state.active_tab().pane.is_some() {
|
||||
self.state.new_tab();
|
||||
}
|
||||
self.submit_spawn(req).await;
|
||||
return;
|
||||
}
|
||||
// Unresolved defaults: a draft pre-filled with what did resolve.
|
||||
self.state.open_draft_pane();
|
||||
let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else {
|
||||
return;
|
||||
};
|
||||
draft.home_dir = home_dir;
|
||||
if !presets.is_empty() {
|
||||
draft.set_presets(presets);
|
||||
}
|
||||
draft.set_projects(discovered, Utc::now());
|
||||
resolved.apply_to_draft(draft);
|
||||
// A missing generated name still arrives here; seeding is a no-op
|
||||
// once apply_to_draft has set a touched name.
|
||||
draft.seed_names(names);
|
||||
}
|
||||
|
||||
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
|
||||
@@ -609,10 +828,87 @@ fn render_help_lines(cfg: &TuiConfig) -> Vec<String> {
|
||||
lines
|
||||
}
|
||||
|
||||
/// A completed background load, applied on the event loop.
|
||||
#[derive(Debug)]
|
||||
enum DraftUpdate {
|
||||
Data {
|
||||
draft: u64,
|
||||
info: Option<maestro_protocol::DaemonInfo>,
|
||||
projects: Option<Vec<maestro_protocol::Project>>,
|
||||
names: Vec<String>,
|
||||
},
|
||||
Names {
|
||||
draft: u64,
|
||||
names: Vec<String>,
|
||||
},
|
||||
GitRoot {
|
||||
draft: u64,
|
||||
seq: u32,
|
||||
root: Option<PathBuf>,
|
||||
},
|
||||
Branches {
|
||||
draft: u64,
|
||||
cwd: PathBuf,
|
||||
branches: Vec<BranchInfo>,
|
||||
},
|
||||
/// A fast-forward's data arrived; resolution and spawn happen on the loop.
|
||||
FastForward {
|
||||
info: Option<maestro_protocol::DaemonInfo>,
|
||||
projects: Option<Vec<maestro_protocol::Project>>,
|
||||
names: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`.
|
||||
async fn fetch_names(http: &reqwest::Client, base: &str, count: usize) -> Vec<String> {
|
||||
let url = format!(
|
||||
"{}{}",
|
||||
maestro_protocol::routes::normalize_base(base),
|
||||
routes::NAMES
|
||||
);
|
||||
match http
|
||||
.get(url)
|
||||
.query(&[(routes::COUNT_QUERY, count)])
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp
|
||||
.json::<NameBatch>()
|
||||
.await
|
||||
.map(|batch| batch.names)
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/branches?path=`. On failure the picker stays empty and "new
|
||||
/// branch" mode still works.
|
||||
async fn fetch_branches(
|
||||
http: &reqwest::Client,
|
||||
base: &str,
|
||||
path: &std::path::Path,
|
||||
) -> Vec<BranchInfo> {
|
||||
let url = format!(
|
||||
"{}{}",
|
||||
maestro_protocol::routes::normalize_base(base),
|
||||
routes::BRANCHES
|
||||
);
|
||||
match http
|
||||
.get(url)
|
||||
.query(&[(routes::PATH_QUERY, path.to_string_lossy().as_ref())])
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json::<Vec<BranchInfo>>().await.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::action::Action;
|
||||
use crate::draft::DraftField;
|
||||
use crate::keymap::Mode;
|
||||
use maestro_config::TuiSection;
|
||||
|
||||
@@ -694,6 +990,7 @@ mod tests {
|
||||
cwd: std::path::PathBuf::from("/"),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
foreground: None,
|
||||
@@ -755,6 +1052,147 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The fast-forward keystroke itself is side-effect-free until its
|
||||
/// daemon fetches come back.
|
||||
#[test]
|
||||
fn fast_forward_spawn_dispatches_the_fast_forward_effect() {
|
||||
let mut app = make_app();
|
||||
assert_eq!(
|
||||
app.dispatch(Action::FastForwardSpawn),
|
||||
vec![Effect::FastForward]
|
||||
);
|
||||
}
|
||||
|
||||
/// Every default resolves: the POST goes straight out — most recent
|
||||
/// project, its last command, generated name, worktree branch named
|
||||
/// after it (#67).
|
||||
#[tokio::test]
|
||||
async fn fast_forward_spawns_immediately_when_every_default_resolves() {
|
||||
use axum::routing::{get, post};
|
||||
use axum::Json;
|
||||
use serde_json::json;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
async fn info() -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"hostname": "box", "version": "0.1.0", "home_dir": "/home/u",
|
||||
"presets": [{"id": "claude", "label": "Claude", "command": "claude"}]
|
||||
}))
|
||||
}
|
||||
async fn projects() -> Json<serde_json::Value> {
|
||||
Json(json!([
|
||||
{"path": "/repos/old", "name": "old",
|
||||
"last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "zsh"},
|
||||
{"path": "/repos/fresh", "name": "fresh", "base_repo": "/repos/fresh",
|
||||
"last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "aider"}
|
||||
]))
|
||||
}
|
||||
async fn names() -> Json<serde_json::Value> {
|
||||
Json(json!({"names": ["crimson-otter"]}))
|
||||
}
|
||||
let posted: Arc<Mutex<Vec<serde_json::Value>>> = Arc::default();
|
||||
let seen = posted.clone();
|
||||
async fn spawn_session(
|
||||
axum::extract::State(seen): axum::extract::State<Arc<Mutex<Vec<serde_json::Value>>>>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Json<serde_json::Value> {
|
||||
seen.lock().unwrap().push(body);
|
||||
Json(json!({
|
||||
"id": uuid::Uuid::from_u128(77).to_string(),
|
||||
"cwd": "/repos/fresh", "command": "aider",
|
||||
"status": "Running", "created_at": chrono::Utc::now().to_rfc3339()
|
||||
}))
|
||||
}
|
||||
|
||||
let app_router = axum::Router::new()
|
||||
.route(maestro_protocol::routes::INFO, get(info))
|
||||
.route(maestro_protocol::routes::PROJECTS, get(projects))
|
||||
.route(maestro_protocol::routes::NAMES, get(names))
|
||||
.route(
|
||||
maestro_protocol::routes::SESSIONS,
|
||||
post(spawn_session).with_state(seen),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
|
||||
|
||||
let mut app = make_app_at(&base);
|
||||
let effects = app.dispatch(Action::FastForwardSpawn);
|
||||
// The fast path loads off the event loop now; the run loop drains the
|
||||
// channel, so do that by hand.
|
||||
app.perform(effects).await;
|
||||
let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv())
|
||||
.await
|
||||
.expect("load completed")
|
||||
.expect("channel open");
|
||||
app.apply_draft_update(update).await;
|
||||
|
||||
let posts = posted.lock().unwrap();
|
||||
assert_eq!(posts.len(), 1, "one immediate POST, no form shown");
|
||||
let req = &posts[0];
|
||||
assert_eq!(req["cwd"], json!("/repos/fresh"));
|
||||
assert_eq!(req["command"], json!("aider"));
|
||||
assert_eq!(req["metadata"]["name"], json!("crimson-otter"));
|
||||
assert_eq!(req["worktree"]["base_repo"], json!("/repos/fresh"));
|
||||
assert_eq!(req["worktree"]["branch"], json!("crimson-otter"));
|
||||
assert_eq!(req["worktree"]["create_branch"], json!(true));
|
||||
}
|
||||
|
||||
/// No projects known yet: nothing can resolve, so the keystroke opens a
|
||||
/// draft pane (focused, Insert mode) instead of guessing (#67).
|
||||
#[tokio::test]
|
||||
async fn unresolvable_defaults_open_a_prefilled_draft_instead() {
|
||||
use axum::routing::get;
|
||||
use axum::Json;
|
||||
use serde_json::json;
|
||||
|
||||
async fn info() -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"hostname": "box", "version": "0.1.0", "home_dir": "/home/u",
|
||||
"presets": [{"id": "claude", "label": "Claude", "command": "claude"}]
|
||||
}))
|
||||
}
|
||||
async fn projects() -> Json<serde_json::Value> {
|
||||
Json(json!([]))
|
||||
}
|
||||
async fn names() -> Json<serde_json::Value> {
|
||||
Json(json!({"names": ["amber-falcon"]}))
|
||||
}
|
||||
|
||||
let app_router = axum::Router::new()
|
||||
.route(maestro_protocol::routes::INFO, get(info))
|
||||
.route(maestro_protocol::routes::PROJECTS, get(projects))
|
||||
.route(maestro_protocol::routes::NAMES, get(names));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
|
||||
|
||||
let mut app = make_app_at(&base);
|
||||
let effects = app.dispatch(Action::FastForwardSpawn);
|
||||
// The fast path loads off the event loop now; the run loop drains the
|
||||
// channel, so do that by hand.
|
||||
app.perform(effects).await;
|
||||
let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv())
|
||||
.await
|
||||
.expect("load completed")
|
||||
.expect("channel open");
|
||||
app.apply_draft_update(update).await;
|
||||
|
||||
assert!(
|
||||
matches!(app.state.active_tab().pane, Some(PaneRef::Draft(_))),
|
||||
"the fallback is a draft pane"
|
||||
);
|
||||
assert_eq!(app.state.focus, Focus::Pane);
|
||||
assert_eq!(app.state.mode, Mode::Insert);
|
||||
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
|
||||
panic!("expected a draft pane");
|
||||
};
|
||||
// Whatever did resolve (nothing project-side) is seeded; the
|
||||
// generated name still lands so the form is ready to go.
|
||||
assert_eq!(draft.name.value(), "amber-falcon");
|
||||
assert_eq!(draft.command(), "claude");
|
||||
}
|
||||
|
||||
/// A valid form flows through: pick a project (set directly — the picker
|
||||
/// itself is covered in `draft.rs`), select the custom tile, type a
|
||||
/// command, then submit from the Spawn row.
|
||||
@@ -767,6 +1205,8 @@ mod tests {
|
||||
};
|
||||
|
||||
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
||||
draft.base_repo = Some(std::path::PathBuf::from("/srv/work"));
|
||||
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
||||
draft.selected_preset = draft.presets.len() - 1; // custom
|
||||
app.handle_draft_input(&InputKey::Tab); // preset → custom command
|
||||
for c in "claude".chars() {
|
||||
@@ -777,6 +1217,9 @@ mod tests {
|
||||
for c in "crimson-otter".chars() {
|
||||
app.handle_draft_input(&InputKey::char(c));
|
||||
}
|
||||
app.handle_draft_input(&InputKey::Tab); // → worktree toggle
|
||||
app.handle_draft_input(&InputKey::Tab); // → branch mode
|
||||
app.handle_draft_input(&InputKey::Tab); // → new-branch name
|
||||
app.handle_draft_input(&InputKey::Tab); // → submit
|
||||
|
||||
match app.handle_draft_input(&InputKey::Enter).as_slice() {
|
||||
@@ -793,6 +1236,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Editing the raw path with the worktree toggle on schedules a
|
||||
/// `/api/gitroot` probe so the section learns its base repo (#65).
|
||||
#[test]
|
||||
fn typing_a_raw_path_schedules_a_gitroot_probe() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
||||
panic!("expected a draft pane");
|
||||
};
|
||||
draft.cwd_raw_visible = true;
|
||||
draft.focused_field = DraftField::CwdRaw;
|
||||
|
||||
let effects = app.handle_draft_input(&InputKey::char('/'));
|
||||
assert!(
|
||||
matches!(
|
||||
effects.as_slice(),
|
||||
[Effect::ProbeGitRoot { tab: 0, seq: 2, .. }]
|
||||
),
|
||||
"{effects:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Switching the worktree branch source to Existing schedules one
|
||||
/// `/api/branches` load for the current target (#65).
|
||||
#[test]
|
||||
fn switching_to_existing_branch_mode_schedules_a_branch_load() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
||||
panic!("expected a draft pane");
|
||||
};
|
||||
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/repo"));
|
||||
draft.base_repo = Some(std::path::PathBuf::from("/srv/repo"));
|
||||
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/repo"));
|
||||
draft.focused_field = DraftField::BranchMode;
|
||||
|
||||
let effects = app.handle_draft_input(&InputKey::Right);
|
||||
assert!(
|
||||
matches!(effects.as_slice(), [Effect::LoadBranches(0)]),
|
||||
"{effects:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Submitting with no directory names the missing field inside the form
|
||||
/// and posts nothing (#64).
|
||||
#[test]
|
||||
@@ -800,7 +1286,7 @@ mod tests {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
// Walk to Submit without picking anything.
|
||||
for _ in 0..3 {
|
||||
for _ in 0..4 {
|
||||
app.handle_draft_input(&InputKey::Tab);
|
||||
}
|
||||
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
|
||||
@@ -909,7 +1395,15 @@ mod tests {
|
||||
let mut app = make_app_at(&base);
|
||||
let effects = app.dispatch(Action::NewDraftPane);
|
||||
assert!(matches!(effects.as_slice(), [Effect::LoadDraft(0)]));
|
||||
// `perform` only spawns the load — it must not block the event loop on
|
||||
// the network (#67 review). The result arrives on the draft channel,
|
||||
// which the run loop drains; do that by hand here.
|
||||
app.perform(effects).await;
|
||||
let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv())
|
||||
.await
|
||||
.expect("load completed")
|
||||
.expect("channel open");
|
||||
app.apply_draft_update(update).await;
|
||||
|
||||
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
|
||||
panic!("expected a draft pane");
|
||||
@@ -1002,6 +1496,7 @@ mod tests {
|
||||
cwd: std::path::PathBuf::from("/"),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
foreground: None,
|
||||
|
||||
@@ -188,6 +188,7 @@ mod tests {
|
||||
cwd: std::path::PathBuf::from("/tmp"),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
foreground: None,
|
||||
|
||||
@@ -46,6 +46,9 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
// Catalog actions
|
||||
("enter", "AttachHighlighted"),
|
||||
("s", "NewDraftPane"),
|
||||
// Fast-forward spawn (#67): the draft-pane key's neighbour does the same
|
||||
// thing with every default resolved and no form.
|
||||
("S", "FastForwardSpawn"),
|
||||
("x", "KillHighlighted"),
|
||||
// Tabs
|
||||
("ctrl-t", "NewTab"),
|
||||
@@ -159,6 +162,10 @@ mod tests {
|
||||
cfg.keymap.resolve(Mode::Normal, "s"),
|
||||
Some(Action::NewDraftPane),
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, "S"),
|
||||
Some(Action::FastForwardSpawn),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+865
-12
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,283 @@
|
||||
//! Fast-forward spawn (#67): one keystroke, every default resolved, no form.
|
||||
//!
|
||||
//! The defaults mirror what a draft pane would settle on after the operator
|
||||
//! picked the most recent project and kept their hands off everything else:
|
||||
//! its last command (or the first non-custom preset), a freshly generated
|
||||
//! name, and a worktree with a branch named after that name. Any default
|
||||
//! that cannot be resolved — no projects yet, no command, no generated
|
||||
//! name — downgrades the keystroke to a draft pane pre-filled with whatever
|
||||
//! did resolve ([`FastForwardDefaults::apply_to_draft`]).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use maestro_protocol::{CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
||||
|
||||
use crate::draft::DraftPane;
|
||||
|
||||
/// What one fast-forward keystroke resolved. Every field optional: `None`
|
||||
/// anywhere means "open a draft instead of spawning".
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FastForwardDefaults {
|
||||
pub project: Option<Project>,
|
||||
pub command: Option<String>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolve the fast-forward defaults from the daemon's answers. `name` is
|
||||
/// `None` when `/api/names` yielded nothing; `presets` may be empty (the
|
||||
/// caller falls back to the built-ins it already has).
|
||||
pub fn resolve(
|
||||
projects: &[Project],
|
||||
presets: &[SpawnPreset],
|
||||
name: Option<String>,
|
||||
now: DateTime<Utc>,
|
||||
) -> FastForwardDefaults {
|
||||
let project = crate::projects::sort_projects(projects.to_vec(), now)
|
||||
.into_iter()
|
||||
.next();
|
||||
let command = project
|
||||
.as_ref()
|
||||
.and_then(|p| p.last_command.clone())
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
.or_else(|| {
|
||||
presets
|
||||
.iter()
|
||||
.find(|p| !p.command.is_empty())
|
||||
.map(|p| p.command.clone())
|
||||
});
|
||||
FastForwardDefaults {
|
||||
project,
|
||||
command,
|
||||
name,
|
||||
}
|
||||
}
|
||||
|
||||
impl FastForwardDefaults {
|
||||
/// The spawn request when *every* default resolved: worktree on, new
|
||||
/// branch named after the generated session name, base repo from the
|
||||
/// scan (`None` means the project is itself a main checkout).
|
||||
pub fn build_request(&self) -> Option<CreateSessionRequest> {
|
||||
let project = self.project.as_ref()?;
|
||||
let command = self.command.as_deref()?.trim();
|
||||
let name = self.name.as_deref()?;
|
||||
if command.is_empty() || name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut metadata = std::collections::HashMap::new();
|
||||
metadata.insert(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String(name.to_string()),
|
||||
);
|
||||
Some(CreateSessionRequest {
|
||||
cwd: project.path.clone(),
|
||||
command: command.to_string(),
|
||||
env: Default::default(),
|
||||
metadata,
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: project
|
||||
.base_repo
|
||||
.clone()
|
||||
.unwrap_or_else(|| project.path.clone()),
|
||||
branch: name.to_string(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Seed a fresh draft pane with whatever did resolve — the fallback when
|
||||
/// [`Self::build_request`] is `None`. The operator fills in the rest.
|
||||
pub fn apply_to_draft(&self, draft: &mut DraftPane) {
|
||||
if let Some(project) = &self.project {
|
||||
draft.picked_cwd = Some(PathBuf::from(&project.path));
|
||||
// The scan already knows this directory's repo (#61): no probe
|
||||
// round trip needed, exactly like picking the row by hand.
|
||||
draft.base_repo = Some(
|
||||
project
|
||||
.base_repo
|
||||
.clone()
|
||||
.unwrap_or_else(|| project.path.clone()),
|
||||
);
|
||||
draft.probed_cwd = Some(PathBuf::from(&project.path));
|
||||
}
|
||||
if let Some(command) = &self.command {
|
||||
if let Some(idx) = draft.presets.iter().position(|p| &p.command == command) {
|
||||
draft.selected_preset = idx;
|
||||
} else if !command.trim().is_empty() {
|
||||
// A last_command no preset advertises lands in custom free text.
|
||||
draft.selected_preset = draft.presets.len().saturating_sub(1);
|
||||
draft.custom_command = crate::text_input::TextInput::with_value(command);
|
||||
}
|
||||
}
|
||||
if let Some(name) = &self.name {
|
||||
draft.set_name(name.clone());
|
||||
// Seeded, not generated: a later refill must not clobber it.
|
||||
draft.name_touched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeDelta;
|
||||
|
||||
fn project(path: &str, days_ago: i64, cmd: Option<&str>) -> Project {
|
||||
Project {
|
||||
path: PathBuf::from(path),
|
||||
name: path.rsplit('/').next().unwrap_or(path).into(),
|
||||
is_worktree: false,
|
||||
base_repo: None,
|
||||
last_activity: Some(Utc::now() - TimeDelta::days(days_ago)),
|
||||
last_command: cmd.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
fn repo_project(path: &str, base: &str, days_ago: i64, cmd: Option<&str>) -> Project {
|
||||
Project {
|
||||
base_repo: Some(PathBuf::from(base)),
|
||||
..project(path, days_ago, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
fn preset(id: &str, command: &str) -> SpawnPreset {
|
||||
SpawnPreset {
|
||||
id: id.into(),
|
||||
label: id.into(),
|
||||
command: command.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_every_default_from_the_most_recent_project() {
|
||||
let projects = vec![
|
||||
project("/repos/old", 30, Some("zsh")),
|
||||
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
|
||||
];
|
||||
let presets = vec![preset("aider", "aider"), preset("custom", "")];
|
||||
let r = resolve(&projects, &presets, Some("amber-falcon".into()), Utc::now());
|
||||
assert_eq!(
|
||||
r.project.as_ref().unwrap().path,
|
||||
PathBuf::from("/repos/fresh")
|
||||
);
|
||||
assert_eq!(r.command.as_deref(), Some("claude"));
|
||||
assert_eq!(r.name.as_deref(), Some("amber-falcon"));
|
||||
|
||||
let req = r.build_request().expect("all defaults resolved");
|
||||
assert_eq!(req.cwd, PathBuf::from("/repos/fresh"));
|
||||
assert_eq!(req.command, "claude");
|
||||
assert_eq!(
|
||||
req.metadata.get("name").and_then(|v| v.as_str()),
|
||||
Some("amber-falcon")
|
||||
);
|
||||
let wt = req.worktree.expect("worktree on");
|
||||
assert_eq!(wt.base_repo, PathBuf::from("/repos/fresh"));
|
||||
assert_eq!(wt.branch, "amber-falcon");
|
||||
assert!(wt.create_branch, "fresh branch named after the session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_of_the_most_recent_project_targets_its_base_repo() {
|
||||
let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)];
|
||||
let r = resolve(
|
||||
&projects,
|
||||
&[preset("zsh", "zsh")],
|
||||
Some("s".into()),
|
||||
Utc::now(),
|
||||
);
|
||||
let wt = r.build_request().unwrap().worktree.unwrap();
|
||||
assert_eq!(wt.base_repo, PathBuf::from("/repos/main"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_last_command_falls_back_to_the_first_non_custom_preset() {
|
||||
let projects = vec![project("/repos/p", 0, None)];
|
||||
let presets = vec![preset("custom", ""), preset("claude", "claude")];
|
||||
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
|
||||
assert_eq!(r.command.as_deref(), Some("claude"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_last_command_is_no_command_at_all() {
|
||||
let projects = vec![project("/repos/p", 0, Some(""))];
|
||||
let presets = vec![preset("custom", "")];
|
||||
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
|
||||
assert_eq!(r.command, None);
|
||||
assert!(r.build_request().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_resolves_when_no_projects_are_known_yet() {
|
||||
let r = resolve(
|
||||
&[],
|
||||
&[preset("claude", "claude")],
|
||||
Some("n".into()),
|
||||
Utc::now(),
|
||||
);
|
||||
assert_eq!(r.project, None);
|
||||
// No project → no directory → no request, even with command + name.
|
||||
assert!(r.build_request().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_generated_name_downgrades_to_a_draft() {
|
||||
let projects = vec![project("/repos/p", 0, Some("claude"))];
|
||||
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
|
||||
assert!(r.build_request().is_none(), "no name → no immediate spawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() {
|
||||
let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))];
|
||||
let r = resolve(&projects, &[preset("aider", "aider")], None, Utc::now());
|
||||
|
||||
let mut draft = DraftPane::default();
|
||||
r.apply_to_draft(&mut draft);
|
||||
assert_eq!(draft.cwd(), Some(PathBuf::from("/repos/p")));
|
||||
assert_eq!(draft.base_repo, Some(PathBuf::from("/repos/p")));
|
||||
assert_eq!(draft.command(), "aider");
|
||||
assert!(
|
||||
draft.name.value().is_empty(),
|
||||
"nothing to seed the name with"
|
||||
);
|
||||
assert!(draft.worktree_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resolved_name_survives_late_refills_and_tracks_the_branch() {
|
||||
let projects = vec![project("/repos/p", 0, Some("claude"))];
|
||||
let named = resolve(
|
||||
&projects,
|
||||
&[preset("claude", "claude")],
|
||||
Some("amber-falcon".into()),
|
||||
Utc::now(),
|
||||
);
|
||||
let mut draft = DraftPane::default();
|
||||
named.apply_to_draft(&mut draft);
|
||||
// A later batch must not overwrite the name the spawn was seeded with.
|
||||
draft.seed_names(vec!["late-name".into()]);
|
||||
assert_eq!(draft.name.value(), "amber-falcon");
|
||||
assert_eq!(draft.branch.value(), "amber-falcon");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unresolved_name_still_gets_generated_by_a_later_batch() {
|
||||
let projects = vec![project("/repos/p", 0, Some("claude"))];
|
||||
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
|
||||
let mut draft = DraftPane::default();
|
||||
r.apply_to_draft(&mut draft);
|
||||
draft.seed_names(vec!["late-name".into()]);
|
||||
assert_eq!(draft.name.value(), "late-name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_last_command_no_preset_advertises_lands_in_custom_text() {
|
||||
let projects = vec![project("/repos/p", 0, Some("nvim -d"))];
|
||||
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
|
||||
let mut draft = DraftPane::default();
|
||||
r.apply_to_draft(&mut draft);
|
||||
assert_eq!(draft.command(), "nvim -d");
|
||||
assert!(draft.is_custom());
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod cli;
|
||||
pub mod config;
|
||||
pub mod draft;
|
||||
pub mod emulators;
|
||||
pub mod fast_forward;
|
||||
pub mod input;
|
||||
pub mod keymap;
|
||||
pub mod overlays;
|
||||
|
||||
@@ -92,6 +92,7 @@ mod tests {
|
||||
cwd: std::path::PathBuf::from("/tmp"),
|
||||
env: Default::default(),
|
||||
metadata,
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
foreground: None,
|
||||
@@ -179,6 +180,7 @@ mod tests {
|
||||
cwd: std::path::PathBuf::from("/tmp"),
|
||||
env: Default::default(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
foreground: None,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Worktrees
|
||||
|
||||
Maestro can create a git worktree for a session and is responsible for it from
|
||||
that moment until the worktree is removed. This document is the contract for
|
||||
that lifetime: where a worktree goes, what ends a session, what survives, and
|
||||
what the operator has to do by hand.
|
||||
|
||||
It exists because the rules here are not guessable from the code. Every rule
|
||||
below was decided once, re-litigated in review, and decided the same way again.
|
||||
The reasoning is written down alongside each rule so the next reader does not
|
||||
have to reconstruct it.
|
||||
|
||||
## Nothing is ever adopted
|
||||
|
||||
The daemon only ever removes a worktree it created in this session's lifetime or
|
||||
recorded in its retained store. It never takes ownership of a directory it finds
|
||||
on disk, whatever that directory looks like.
|
||||
|
||||
There is no reliable way to ask "is this worktree mine?". A git worktree carries
|
||||
no marker distinguishing one a daemon made from one a human made ten minutes
|
||||
earlier, and any heuristic that answers the question will eventually answer it
|
||||
wrong — in the direction of deleting somebody's work.
|
||||
|
||||
So the daemon does not ask. If the target path is occupied by anything at all, it
|
||||
picks a different path.
|
||||
|
||||
## Placement
|
||||
|
||||
A worktree for branch `<branch>` of repository `<repo>` goes to
|
||||
`<repo>/worktrees/<branch>`, where `<repo>` is the main checkout — not whichever
|
||||
worktree the request happened to come from.
|
||||
|
||||
The branch name is used verbatim as a relative path, so `feature/login` becomes
|
||||
`worktrees/feature/login`. A branch name that cannot be a relative path falls
|
||||
back to a short hash of the branch.
|
||||
|
||||
If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds
|
||||
a free one. The suffix names the **directory only**. The branch is whatever was
|
||||
requested; a second worktree of the same branch is not what happens here, because
|
||||
git refuses to check the same branch out twice and the branch picker already
|
||||
marks branches that are checked out elsewhere.
|
||||
|
||||
### The `worktree_root` override
|
||||
|
||||
`[daemon].worktree_root` moves every worktree out of the repository and under one
|
||||
root. The per-repository directory is the repository's basename plus a short
|
||||
digest of its full path — the digest is what keeps two repositories with the same
|
||||
basename apart. Inside it the layout and the collision suffix are the same.
|
||||
|
||||
## What ends a session
|
||||
|
||||
`DELETE /api/sessions/:id` is **refused** when the session's worktree holds
|
||||
unsaved work, meaning any of:
|
||||
|
||||
- uncommitted changes to tracked files
|
||||
- untracked files that are not ignored
|
||||
- commits on the branch reachable from no remote-tracking ref
|
||||
|
||||
Ignored files never block. Every Rust worktree has a `target/`; if ignored files
|
||||
counted, no session would ever be killable.
|
||||
|
||||
The refusal is a structured error naming what blocked it and how much of it there
|
||||
is, so a client can say "3 uncommitted files, 2 unpushed commits". The session
|
||||
keeps running and the worktree is untouched.
|
||||
|
||||
The point of refusing rather than cleaning up is that the session stays as the
|
||||
operator's handle on their own unfinished work. A session that is killed while
|
||||
its worktree still matters leaves a directory nobody is looking at.
|
||||
|
||||
A consequence, accepted deliberately: a feature branch almost always has unpushed
|
||||
commits, so `--force` is the ordinary way to end a session, not an exception.
|
||||
That is only safe because force retains.
|
||||
|
||||
### `--force`
|
||||
|
||||
`--force` overrides the refusal and nothing else. It ends the session and
|
||||
**retains** the worktree, which enters the retained store with reason
|
||||
`force-killed`. Force never removes a tree that would have blocked.
|
||||
|
||||
Ending a session and destroying its work are two separate destructive acts. Each
|
||||
is chosen on its own, and the second one — reclaim — is chosen while looking at
|
||||
exactly what would be lost.
|
||||
|
||||
### Exiting on its own
|
||||
|
||||
A session that ends by itself — the command finished, the operator typed `exit` —
|
||||
never reaches the kill path. When the watcher notices the tmux session is gone it
|
||||
applies the same rule: a pristine worktree is removed, anything else is retained
|
||||
with reason `exited-with-unsaved-work`.
|
||||
|
||||
Nobody is waiting for an answer here, so there is nothing to refuse; the choice is
|
||||
only between removing and retaining. It uses the same predicate as the kill, from
|
||||
the same place, so the two paths cannot drift apart.
|
||||
|
||||
## The retained store
|
||||
|
||||
A `state_dir`-backed record of worktrees the daemon created that no longer have a
|
||||
session. For each one: the path, the base repository, the branch, why it was
|
||||
retained, and when.
|
||||
|
||||
It survives a daemon restart. It is the recovery path for everything above — a
|
||||
store that is lost on restart is worse than no store, because the worktrees are
|
||||
still on disk with nothing pointing at them.
|
||||
|
||||
Entries leave when the worktree is reclaimed, and when the daemon notices the
|
||||
directory is gone because somebody removed it by hand.
|
||||
|
||||
**It is not an ownership record.** It must never be consulted to decide whether a
|
||||
path may be adopted — see the first rule. Its only job is to remember what the
|
||||
daemon is holding so that it can be listed and reclaimed.
|
||||
|
||||
## Reclaim
|
||||
|
||||
`GET /api/worktrees` lists the store: path, base repository, branch, reason, when,
|
||||
and what is currently in the way. The in-the-way breakdown is recomputed on read,
|
||||
not stored, so a tree whose work has since been pushed says so.
|
||||
|
||||
`DELETE /api/worktrees/<id>` reclaims one. It removes the directory and drops the
|
||||
store entry. It does **not** consult the guard — reclaim is the deliberate
|
||||
destructive act, and the operator chose it while looking at what was in the way.
|
||||
|
||||
It never deletes the branch. `git worktree remove` leaves the ref, and reclaiming
|
||||
a tree must not throw away commits on it. This is worth saying out loud in every
|
||||
client, because "reclaim" otherwise sounds like it takes the commits too.
|
||||
|
||||
Nothing is reclaimed automatically. There is no age-based expiry and no bulk
|
||||
reclaim.
|
||||
+5
-4
@@ -20,9 +20,10 @@ src/
|
||||
main.ts # entry point, hash-route dispatch
|
||||
api.ts # daemon HTTP API client
|
||||
events.ts # `/api/events` WebSocket subscriber
|
||||
catalog.ts # session tree view (groups by working directory)
|
||||
attach.ts # xterm.js + attach WS view
|
||||
spawn.ts # spawn-session modal
|
||||
catalog.ts # session tree view (groups by working directory)
|
||||
attach.ts # xterm.js + attach WS view
|
||||
multiview.ts # tabs + split panes; draft panes host the spawn form
|
||||
spawnForm.ts # shared spawn form (preset tiles, project picker, worktree)
|
||||
theme.ts # dark/light theme toggle + persistence
|
||||
types.ts # mirror of `maestro-protocol` JSON shapes
|
||||
styles.css # design-system tokens + component styles
|
||||
@@ -40,7 +41,7 @@ follows the OS by default and can be pinned via the header toggle (persisted in
|
||||
|
||||
`mock.ts` is a DEV-only fixture (stripped from production builds): visit the dev
|
||||
server with `?mock` to seed a representative catalog without a running daemon,
|
||||
`?mock&spawn` to open the spawn modal, and `?theme=light|dark` to pin a theme.
|
||||
`?mock&spawn` to open a draft pane, and `?theme=light|dark` to pin a theme.
|
||||
|
||||
Routing is hash-based:
|
||||
- `#/` → catalog
|
||||
|
||||
+13
-12
@@ -6,9 +6,15 @@ import type { Session } from "./types.js";
|
||||
import { abbreviateFolderName, sessionName } from "./types.js";
|
||||
import type { TreeNode } from "./tree.js";
|
||||
import { buildTree } from "./tree.js";
|
||||
import { openSpawnModal } from "./spawn.js";
|
||||
import { createThemeToggle } from "./theme.js";
|
||||
|
||||
/** Ask the shell to open a new draft pane (#62). Handled in main.ts, where the
|
||||
* multi-view controller lives; dispatched from every Spawn affordance. */
|
||||
export const OPEN_SPAWN_EVENT = "catalog-open-spawn";
|
||||
export function requestOpenSpawn(): void {
|
||||
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
||||
}
|
||||
|
||||
const collapsed = new Set<string>();
|
||||
|
||||
function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
@@ -190,7 +196,9 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
|
||||
spawnBtn.type = "button";
|
||||
spawnBtn.className = "btn btn-primary spawn-btn";
|
||||
spawnBtn.textContent = "+ Spawn";
|
||||
spawnBtn.addEventListener("click", () => openSpawnModal());
|
||||
// The keyboard pair sits right here in the tooltip (#67).
|
||||
spawnBtn.title = "Draft pane: n — fast-forward spawn: N";
|
||||
spawnBtn.addEventListener("click", () => requestOpenSpawn());
|
||||
actions.appendChild(spawnBtn);
|
||||
return header;
|
||||
}
|
||||
@@ -342,7 +350,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
treeHost.replaceChildren();
|
||||
if (sessions.length === 0) {
|
||||
treeHost.appendChild(
|
||||
renderEmpty(filter.trim().length > 0, () => openSpawnModal()),
|
||||
renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -364,14 +372,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
const rerenderHandler = () => refresh();
|
||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||
|
||||
// Spawn similar: reopen the spawn modal pre-seeded from a session's dir and
|
||||
// command (matched back to a preset by the form) — but with a freshly
|
||||
// generated name rather than the original's, since it's a new session.
|
||||
const spawnSimilarHandler = (ev: Event) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
openSpawnModal({ cwd: s.cwd, command: s.command });
|
||||
};
|
||||
document.addEventListener("catalog-spawn-similar", spawnSimilarHandler);
|
||||
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
||||
// multi-view controller can seed a draft pane with the session's dir/command.
|
||||
|
||||
const unsubscribe = store.subscribe(refresh);
|
||||
|
||||
@@ -391,7 +393,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
},
|
||||
dispose() {
|
||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||
document.removeEventListener("catalog-spawn-similar", spawnSimilarHandler);
|
||||
unsubscribe();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFastForwardRequest,
|
||||
fastForwardSeed,
|
||||
resolveFastForward,
|
||||
} from "./fastForward.js";
|
||||
import type { Project, SpawnPreset } from "./types.js";
|
||||
|
||||
const presets: SpawnPreset[] = [
|
||||
{ id: "custom", label: "Custom…", command: "" },
|
||||
{ id: "claude", label: "Claude", command: "claude" },
|
||||
];
|
||||
|
||||
function project(overrides: Partial<Project>): Project {
|
||||
return {
|
||||
path: "/repos/p",
|
||||
name: "p",
|
||||
last_activity: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveFastForward (#67)", () => {
|
||||
it("resolves every default from the most recent project", () => {
|
||||
const projects = [
|
||||
project({ path: "/repos/old", name: "old", last_activity: "2020-01-01T00:00:00Z" }),
|
||||
project({
|
||||
path: "/repos/fresh",
|
||||
name: "fresh",
|
||||
base_repo: "/repos/fresh",
|
||||
last_command: "aider",
|
||||
}),
|
||||
];
|
||||
const res = resolveFastForward(projects, presets, "amber-falcon");
|
||||
expect(res.project?.path).toBe("/repos/fresh");
|
||||
expect(res.command).toBe("aider");
|
||||
expect(res.name).toBe("amber-falcon");
|
||||
|
||||
const req = buildFastForwardRequest(res)!;
|
||||
expect(req.cwd).toBe("/repos/fresh");
|
||||
expect(req.command).toBe("aider");
|
||||
expect(req.metadata).toEqual({ name: "amber-falcon" });
|
||||
expect(req.worktree).toEqual({
|
||||
base_repo: "/repos/fresh",
|
||||
branch: "amber-falcon",
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the first non-custom preset when there is no last command", () => {
|
||||
const res = resolveFastForward([project({ last_command: undefined })], presets, "n");
|
||||
expect(res.command).toBe("claude");
|
||||
});
|
||||
|
||||
it("treats an empty last command as no command", () => {
|
||||
const res = resolveFastForward([project({ last_command: "" })], [presets[0]], "n");
|
||||
expect(res.command).toBeUndefined();
|
||||
expect(buildFastForwardRequest(res)).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves nothing when no projects are known yet", () => {
|
||||
const res = resolveFastForward([], presets, "n");
|
||||
expect(res.project).toBeUndefined();
|
||||
expect(buildFastForwardRequest(res)).toBeNull();
|
||||
});
|
||||
|
||||
it("downgrades to a draft when the generated name is missing", () => {
|
||||
const res = resolveFastForward([project({ last_command: "claude" })], presets);
|
||||
expect(res.name).toBeUndefined();
|
||||
expect(buildFastForwardRequest(res)).toBeNull();
|
||||
});
|
||||
|
||||
it("seeds the fallback draft with whatever did resolve", () => {
|
||||
const res = resolveFastForward(
|
||||
[project({ path: "/repos/p", base_repo: "/repos/p", last_command: "aider" })],
|
||||
presets,
|
||||
);
|
||||
expect(fastForwardSeed(res)).toEqual({
|
||||
cwd: "/repos/p",
|
||||
command: "aider",
|
||||
name: "",
|
||||
worktreeEnabled: true,
|
||||
worktreeBranch: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the worktree default alone when not even a project resolved", () => {
|
||||
// Not `false`: the form defaults the toggle on and a false would stick,
|
||||
// so the fallback draft would disagree with a normally-opened draft and
|
||||
// with the TUI's fast path.
|
||||
const seed = fastForwardSeed(resolveFastForward([], presets));
|
||||
expect(seed.worktreeEnabled).toBeUndefined();
|
||||
expect(seed.cwd).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// Fast-forward spawn (#67): one keystroke, every default resolved, no form.
|
||||
// Pure resolution only — the caller (main.ts) owns the fetches and the POST,
|
||||
// and falls back to a seeded draft pane when anything is missing.
|
||||
|
||||
import type { CreateSessionRequest, Project, SpawnPreset } from "./types.js";
|
||||
import { sortProjects } from "./projectList.js";
|
||||
|
||||
/** What one fast-forward keystroke resolved. Anything absent means "open a
|
||||
* draft pre-filled with the rest instead of spawning". */
|
||||
export interface FastForwardResolution {
|
||||
project?: Project;
|
||||
command?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** The defaults: most recent project (fresh-first, same ordering the picker
|
||||
* shows), its last command falling back to the first non-custom preset, and
|
||||
* a freshly generated name when the daemon supplied one. */
|
||||
export function resolveFastForward(
|
||||
projects: Project[],
|
||||
presets: SpawnPreset[],
|
||||
name?: string,
|
||||
): FastForwardResolution {
|
||||
const project = sortProjects(projects, new Date())[0];
|
||||
const lastCommand = project?.last_command?.trim();
|
||||
const command =
|
||||
lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
|
||||
return { project, command, name: name || undefined };
|
||||
}
|
||||
|
||||
/** The immediate spawn request — worktree on, new branch named after the
|
||||
* generated session name — or `null` when any default is missing. */
|
||||
export function buildFastForwardRequest(
|
||||
res: FastForwardResolution,
|
||||
): CreateSessionRequest | null {
|
||||
const { project, command, name } = res;
|
||||
if (!project || !command?.trim() || !name) return null;
|
||||
return {
|
||||
cwd: project.path,
|
||||
command,
|
||||
metadata: { name },
|
||||
worktree: {
|
||||
base_repo: project.base_repo ?? project.path,
|
||||
branch: name,
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Seed for the fallback draft pane: whatever did resolve goes into the
|
||||
* form; the rest stays with its own defaults. */
|
||||
export function fastForwardSeed(res: FastForwardResolution) {
|
||||
return {
|
||||
cwd: res.project?.path ?? "",
|
||||
command: res.command ?? "",
|
||||
name: res.name ?? "",
|
||||
// Left undefined rather than false when nothing resolved: the form
|
||||
// defaults the toggle on, and `false` would stick, so the fallback draft
|
||||
// would disagree with both a normally-opened draft and the TUI's fast path
|
||||
// (#67 review).
|
||||
worktreeEnabled: res.project ? true : undefined,
|
||||
worktreeBranch:
|
||||
res.project && res.name ? res.name : undefined,
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
// One HTML escaper. Previously written four times, in catalog.ts, spawn.ts,
|
||||
// types.ts and attach.ts.
|
||||
// One HTML escaper. Previously written four times, in catalog.ts, types.ts
|
||||
// and attach.ts.
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
return s
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@
|
||||
background: color-mix(in oklab, var(--surface) 40%, var(--bg));
|
||||
}
|
||||
|
||||
.kit-slot .modal-body {
|
||||
.kit-slot .spawn-form {
|
||||
padding: 0;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
+3
-3
@@ -135,7 +135,7 @@ export function renderKit(): HTMLElement {
|
||||
{
|
||||
const { el, body } = section(
|
||||
"Buttons — current",
|
||||
"The shipping button styles — now the bracket terminal style, as used across the header, modal, and empty states.",
|
||||
"The shipping button styles — now the bracket terminal style, as used across the header, panes, and empty states.",
|
||||
);
|
||||
body.append(
|
||||
specimen("btn-primary", btn("btn btn-primary", "+ Spawn")),
|
||||
@@ -170,12 +170,12 @@ export function renderKit(): HTMLElement {
|
||||
search.type = "search";
|
||||
search.className = "search";
|
||||
search.placeholder = "Search sessions…";
|
||||
const text = h("div", "modal-body");
|
||||
const text = h("div", "spawn-form");
|
||||
const ti = document.createElement("input");
|
||||
ti.type = "text";
|
||||
ti.placeholder = "branch-name";
|
||||
text.appendChild(ti);
|
||||
const sel = h("div", "modal-body");
|
||||
const sel = h("div", "spawn-form");
|
||||
const s = document.createElement("select");
|
||||
s.innerHTML = `<option>thinkpad</option><option>tower</option><option>pi</option>`;
|
||||
sel.appendChild(s);
|
||||
|
||||
+94
-3
@@ -3,13 +3,25 @@ import { ConnectionPool } from "./connections.js";
|
||||
import { installHudKeybind } from "./debug.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import { shouldRetryClose } from "./reconnect.js";
|
||||
import {
|
||||
createSession,
|
||||
fetchNames,
|
||||
getDaemonInfo,
|
||||
projects as fetchProjects,
|
||||
} from "./api.js";
|
||||
import {
|
||||
buildFastForwardRequest,
|
||||
fastForwardSeed,
|
||||
resolveFastForward,
|
||||
} from "./fastForward.js";
|
||||
import { FALLBACK_PRESETS } from "./spawnForm.js";
|
||||
import { mountHud, type Hud } from "./hud.js";
|
||||
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
||||
import { createMultiView, type MultiView } from "./multiview.js";
|
||||
import { loadLayout, saveLayout } from "./persistence.js";
|
||||
import { openSpawnModal } from "./spawn.js";
|
||||
import { OPEN_SPAWN_EVENT } from "./catalog.js";
|
||||
import { initTheme } from "./theme.js";
|
||||
import { sessionName } from "./types.js";
|
||||
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
|
||||
import { installViewportSizing } from "./viewport.js";
|
||||
import "./styles.css";
|
||||
|
||||
@@ -212,7 +224,8 @@ function renderWelcome(): HTMLElement {
|
||||
spawn.type = "button";
|
||||
spawn.className = "btn btn-primary";
|
||||
spawn.textContent = "+ Spawn session";
|
||||
spawn.addEventListener("click", () => openSpawnModal());
|
||||
spawn.title = "Draft pane: n — fast-forward spawn: N";
|
||||
spawn.addEventListener("click", () => requestOpenSpawn());
|
||||
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
||||
return el;
|
||||
}
|
||||
@@ -251,6 +264,84 @@ function showMultiView(): void {
|
||||
updateShellRoute();
|
||||
}
|
||||
|
||||
// Spawn affordances (#62): the header button, the sidebar's empty state, and
|
||||
// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one with the
|
||||
// cloned session's dir + command.
|
||||
function requestOpenSpawn(): void {
|
||||
showMultiView();
|
||||
mv.openDraft();
|
||||
}
|
||||
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
||||
document.addEventListener("catalog-spawn-similar", (ev) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
showMultiView();
|
||||
mv.openDraft({ cwd: s.cwd, command: s.command });
|
||||
});
|
||||
|
||||
// Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns
|
||||
// immediately with every default resolved — most recent project, its last
|
||||
// command (or the first non-custom preset), a fresh name, worktree on with
|
||||
// a new branch named after that name. Anything unresolvable (no projects
|
||||
// known yet, no command, no generated name) opens a draft pre-filled with
|
||||
// what did resolve instead.
|
||||
async function requestFastForward(): Promise<void> {
|
||||
showMultiView();
|
||||
const [discovered, name] = await Promise.all([
|
||||
fetchProjects().catch(() => [] as Project[]),
|
||||
fetchNames(1)
|
||||
.then((batch) => batch.names[0])
|
||||
.catch(() => undefined),
|
||||
]);
|
||||
let presets: SpawnPreset[] = FALLBACK_PRESETS;
|
||||
try {
|
||||
const info = await getDaemonInfo();
|
||||
if (info.presets?.length) presets = info.presets;
|
||||
} catch {
|
||||
/* keep fallbacks */
|
||||
}
|
||||
const resolved = resolveFastForward(discovered, presets, name);
|
||||
const req = buildFastForwardRequest(resolved);
|
||||
if (req) {
|
||||
try {
|
||||
const session = await createSession(req);
|
||||
// Mount it, the way the TUI's fast path attaches. Without this the
|
||||
// keystroke creates a session and leaves the UI untouched, so it reads
|
||||
// as a dead key (#67 review).
|
||||
mv.openSessionBeside(session.id);
|
||||
updateShellRoute();
|
||||
return;
|
||||
} catch {
|
||||
// A failed POST degrades to the same pre-filled draft as an
|
||||
// unresolvable default — never a silent dead keystroke.
|
||||
}
|
||||
}
|
||||
mv.openDraft(fastForwardSeed(resolved));
|
||||
}
|
||||
|
||||
// Keyboard pair (#67): `n` opens a draft pane, `N` fast-forwards a spawn.
|
||||
// Ignored while typing anywhere (form fields, the terminal's hidden
|
||||
// textarea, search) so an attached session's keys are never hijacked.
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === "SELECT" ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (e.key === "n") {
|
||||
e.preventDefault();
|
||||
requestOpenSpawn();
|
||||
} else if (e.key === "N") {
|
||||
e.preventDefault();
|
||||
void requestFastForward();
|
||||
}
|
||||
});
|
||||
|
||||
type Route =
|
||||
| { kind: "catalog" }
|
||||
| { kind: "attach"; sessionId: string }
|
||||
|
||||
+2
-2
@@ -135,10 +135,10 @@ export function seedMock(store: CatalogStore): void {
|
||||
sessions,
|
||||
});
|
||||
|
||||
// `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn
|
||||
// `?mock&spawn` auto-opens a draft pane for visual review, with the spawn
|
||||
// backend endpoints stubbed so presets/projects/gitroot all render.
|
||||
if (location.search.includes("spawn")) {
|
||||
stubSpawnApi();
|
||||
void import("./spawn.js").then((m) => m.openSpawnModal());
|
||||
document.dispatchEvent(new CustomEvent("catalog-open-spawn"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { emptyLayout, leaves, type Layout } from "./layout.js";
|
||||
import { splitLeaf } from "./layout.js";
|
||||
import { createMultiView, type MultiView } from "./multiview.js";
|
||||
import type { ConnectionPool } from "./connections.js";
|
||||
import type { SidebarHandle } from "./catalog.js";
|
||||
|
||||
function stubFetch(): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const body = url.includes("/api/info")
|
||||
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
|
||||
: url.includes("/api/projects")
|
||||
? []
|
||||
: url.includes("/api/names")
|
||||
? { names: ["allegro-cello"] }
|
||||
: undefined;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function fakePool(): ConnectionPool {
|
||||
return {
|
||||
setVisible() {},
|
||||
setFocus() {},
|
||||
} as unknown as ConnectionPool;
|
||||
}
|
||||
|
||||
function fakeSidebar(): SidebarHandle {
|
||||
return {
|
||||
el: document.createElement("aside"),
|
||||
setActive() {},
|
||||
setHighlights() {},
|
||||
dispose() {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeMv(layout: Layout): MultiView {
|
||||
return createMultiView({
|
||||
pool: fakePool(),
|
||||
layout,
|
||||
sidebar: fakeSidebar(),
|
||||
infoFor: () => ({ name: "sess", command: "bash" }),
|
||||
onDisconnect: () => {},
|
||||
renderWelcome: () => document.createElement("div"),
|
||||
});
|
||||
}
|
||||
|
||||
/** Drain the microtask queue so the draft form's async seed application lands. */
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("multiview openDraft (#62)", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("turns an empty tab into a draft pane", async () => {
|
||||
const layout = emptyLayout();
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
|
||||
mv.openDraft();
|
||||
await flush();
|
||||
|
||||
const ls = leaves(layout.tabs[0].root);
|
||||
expect(ls).toHaveLength(1);
|
||||
expect(ls[0].sessionId).toBeNull();
|
||||
expect(ls[0].draft).not.toBeNull();
|
||||
expect(mv.el.querySelector(".mv-draft")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("splits the focused session pane into a fresh draft", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||
tab.focusedLeafId = first;
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
mv.openDraft();
|
||||
await flush();
|
||||
|
||||
const ls = leaves(tab.root);
|
||||
expect(ls).toHaveLength(2);
|
||||
expect(ls[0].sessionId).toBe("s1");
|
||||
const draftLeaf = ls.find((l) => l.sessionId === null)!;
|
||||
expect(draftLeaf.draft).not.toBeNull();
|
||||
expect(tab.focusedLeafId).toBe(draftLeaf.id);
|
||||
expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reuses the focused pane when it is already empty", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||
const empty = splitLeaf(tab, first, null, "row", true);
|
||||
tab.focusedLeafId = empty;
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
mv.openDraft();
|
||||
await flush();
|
||||
|
||||
const ls = leaves(tab.root);
|
||||
expect(ls).toHaveLength(2);
|
||||
const target = ls.find((l) => l.id === empty)!;
|
||||
expect(target.draft).not.toBeNull();
|
||||
expect(tab.focusedLeafId).toBe(empty);
|
||||
});
|
||||
|
||||
it("keeps the draft form instance when an unrelated session closes (#68)", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const s1 = splitLeaf(tab, "", "s1", "row", true);
|
||||
const draftLeaf = splitLeaf(tab, s1, null, "row", true);
|
||||
tab.focusedLeafId = draftLeaf;
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
await flush();
|
||||
|
||||
const nameInput = mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement;
|
||||
nameInput.value = "wip";
|
||||
nameInput.dispatchEvent(new Event("input"));
|
||||
await flush();
|
||||
expect(leaves(tab.root).find((l) => l.id === draftLeaf)?.draft?.name).toBe("wip");
|
||||
|
||||
const formBefore = mv.el.querySelector(".mv-draft");
|
||||
mv.closeSession("s1");
|
||||
await flush();
|
||||
|
||||
expect(mv.el.querySelector(".mv-draft")).toBe(formBefore);
|
||||
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
|
||||
});
|
||||
|
||||
it("seeds a clone's cwd and command into the draft", async () => {
|
||||
const layout = emptyLayout();
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
|
||||
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
|
||||
await flush();
|
||||
|
||||
const leaf = leaves(layout.tabs[0].root)[0];
|
||||
expect(leaf.draft?.cwd).toBe("/home/user/proj");
|
||||
expect(leaf.draft?.command).toBe("claude");
|
||||
});
|
||||
});
|
||||
+118
-29
@@ -13,6 +13,7 @@ import {
|
||||
closeTab,
|
||||
findLeaf,
|
||||
type Dir,
|
||||
type Draft,
|
||||
type Layout,
|
||||
leaves,
|
||||
locateSession,
|
||||
@@ -48,10 +49,15 @@ export interface MultiView {
|
||||
/** Bring a session into the layout: focus it if already open, else fill the
|
||||
* focused pane (or open the first pane in an empty tab). */
|
||||
openSession: (sessionId: string) => void;
|
||||
openSessionBeside: (sessionId: string) => void;
|
||||
/** Drop the pane showing `sessionId` (the session was disconnected/killed). */
|
||||
closeSession: (sessionId: string) => void;
|
||||
/** Every session currently shown in some pane, across all tabs. */
|
||||
openSessions: () => Set<string>;
|
||||
/** Open a draft pane (issue #62): reuse the focused pane if it is already
|
||||
* empty, otherwise split the focused pane. `seed` pre-fills the form
|
||||
* ("Spawn similar" clones a session's cwd + command). */
|
||||
openDraft: (seed?: Partial<Draft>) => void;
|
||||
/** Start dragging a sidebar session into the canvas (issue #32). The
|
||||
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
|
||||
* drag shows drop zones to place it. */
|
||||
@@ -65,9 +71,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
|
||||
const persist = () => opts.onLayoutChange?.();
|
||||
|
||||
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so each
|
||||
// render tears down the previous generation before rebuilding the canvas.
|
||||
const draftForms = new Set<SpawnFormHandle>();
|
||||
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so forms
|
||||
// whose leaves are gone are destroyed on each render. Forms for draft leaves
|
||||
// that survive the render are reused in place (#68): rebuilding one mid-edit
|
||||
// would drop focus, caret and selection, and re-fetch info/projects/names.
|
||||
const draftForms = new Map<string, SpawnFormHandle>();
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "mv";
|
||||
@@ -145,30 +153,35 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
// Draft pane (#57): an empty pane is a spawn form. Values hang off the
|
||||
// leaf (never persisted) so each draft keeps its own half-filled state;
|
||||
// submitting fills this leaf with the new session in place.
|
||||
const form = createSpawnForm({
|
||||
seed: leaf.draft ?? undefined,
|
||||
class: "mv-draft",
|
||||
onChange: (values) => {
|
||||
leaf.draft = values;
|
||||
},
|
||||
onSuccess: (session) => {
|
||||
// The pane can be closed while the POST is in flight. Mounting the
|
||||
// session onto a leaf no longer in the tree would strand it in no
|
||||
// pane at all, so only claim the leaf while it is still there —
|
||||
// searching every tab, since the operator may have switched tabs
|
||||
// while the request was in flight.
|
||||
const host = layout.tabs.find((t) => leaves(t.root).includes(leaf));
|
||||
if (host) {
|
||||
leaf.sessionId = session.id;
|
||||
leaf.draft = null;
|
||||
host.focusedLeafId = leaf.id;
|
||||
}
|
||||
render();
|
||||
opts.onOpenSessionsChange?.();
|
||||
},
|
||||
});
|
||||
draftForms.add(form);
|
||||
host.appendChild(form.el);
|
||||
const form = draftForms.get(leaf.id);
|
||||
if (form) {
|
||||
host.appendChild(form.el);
|
||||
} else {
|
||||
const created = createSpawnForm({
|
||||
seed: leaf.draft ?? undefined,
|
||||
class: "mv-draft",
|
||||
onChange: (values) => {
|
||||
leaf.draft = values;
|
||||
},
|
||||
onSuccess: (session) => {
|
||||
// The pane can be closed while the POST is in flight. Mounting the
|
||||
// session onto a leaf no longer in the tree would strand it in no
|
||||
// pane at all, so only claim the leaf while it is still there —
|
||||
// searching every tab, since the operator may have switched tabs
|
||||
// while the request was in flight.
|
||||
const host = layout.tabs.find((t) => leaves(t.root).includes(leaf));
|
||||
if (host) {
|
||||
leaf.sessionId = session.id;
|
||||
leaf.draft = null;
|
||||
host.focusedLeafId = leaf.id;
|
||||
}
|
||||
render();
|
||||
opts.onOpenSessionsChange?.();
|
||||
},
|
||||
});
|
||||
draftForms.set(leaf.id, created);
|
||||
host.appendChild(created.el);
|
||||
}
|
||||
}
|
||||
|
||||
const tools = document.createElement("div");
|
||||
@@ -366,11 +379,82 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
render();
|
||||
}
|
||||
|
||||
/// Mount a session without evicting one.
|
||||
///
|
||||
/// `openSession` deliberately replaces the focused pane (VS Code style) for
|
||||
/// a sidebar click, but the fast path must never do that: pressing the key
|
||||
/// while attached would drop the running session out of the layout
|
||||
/// entirely. Follows `openDraft`'s rule instead — reuse an empty pane, else
|
||||
/// split beside the focused one (#67 review).
|
||||
function openSessionBeside(sessionId: string): void {
|
||||
const loc = locateSession(layout, sessionId);
|
||||
if (loc) {
|
||||
layout.activeTabId = loc.tab.id;
|
||||
loc.tab.focusedLeafId = loc.leaf.id;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const tab = activeTab(layout);
|
||||
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
||||
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
|
||||
render();
|
||||
opts.onOpenSessionsChange?.();
|
||||
return;
|
||||
}
|
||||
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
||||
if (!target.sessionId) {
|
||||
target.sessionId = sessionId;
|
||||
target.draft = null;
|
||||
} else {
|
||||
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
|
||||
}
|
||||
render();
|
||||
opts.onOpenSessionsChange?.();
|
||||
}
|
||||
|
||||
function openDraft(seed?: Partial<Draft>): void {
|
||||
const tab = activeTab(layout);
|
||||
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
|
||||
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
||||
// Empty tab (or stale focus): the draft becomes the first pane.
|
||||
tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);
|
||||
findLeaf(tab.root!, tab.focusedLeafId)!.draft = draft();
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
||||
if (!target.sessionId && !target.draft) {
|
||||
// An already-empty pane becomes the draft in place.
|
||||
target.draft = draft();
|
||||
} else {
|
||||
// Split a fresh draft beside the focused pane.
|
||||
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
|
||||
findLeaf(tab.root, tab.focusedLeafId)!.draft = draft();
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
// ---- top-level render ----
|
||||
|
||||
/** Destroy forms whose leaf is no longer a draft (leaf removed or filled by
|
||||
* a session). Surviving draft leaves keep their form instance (#68). */
|
||||
function pruneForms(tab: { root: Node | null }): void {
|
||||
const alive = new Set(
|
||||
tab.root
|
||||
? leaves(tab.root)
|
||||
.filter((l) => !l.sessionId)
|
||||
.map((l) => l.id)
|
||||
: [],
|
||||
);
|
||||
for (const [id, f] of draftForms) {
|
||||
if (!alive.has(id)) {
|
||||
f.destroy();
|
||||
draftForms.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
for (const f of draftForms) f.destroy();
|
||||
draftForms.clear();
|
||||
renderTabs();
|
||||
canvas.replaceChildren();
|
||||
const tab = activeTab(layout);
|
||||
@@ -379,6 +463,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
|
||||
|
||||
if (!tab.root) {
|
||||
pruneForms(tab);
|
||||
pool.setVisible([]);
|
||||
pool.setFocus(null);
|
||||
canvas.appendChild(renderWelcome());
|
||||
@@ -394,12 +479,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
|
||||
if (isMobile) {
|
||||
renderMobile(tab);
|
||||
pruneForms(tab);
|
||||
persist();
|
||||
return;
|
||||
}
|
||||
|
||||
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
|
||||
canvas.appendChild(buildTree(tab.root, hosts));
|
||||
pruneForms(tab);
|
||||
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
|
||||
syncFocus();
|
||||
persist();
|
||||
@@ -640,6 +727,8 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
return {
|
||||
el,
|
||||
openSession,
|
||||
openSessionBeside,
|
||||
openDraft,
|
||||
closeSession,
|
||||
openSessions: () => openSessions(layout),
|
||||
beginSessionDrag,
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
// The spawn modal (issue #27): bottom-sheet/dialog chrome around the shared
|
||||
// spawn form (spawnForm.ts). The modal coexists with the inline draft panes
|
||||
// (#57) until a later issue deletes it.
|
||||
|
||||
import { createSpawnForm, type SpawnFormSeed } from "./spawnForm.js";
|
||||
|
||||
/** Seed values for the spawn form — used by "clone" to reopen the dialog with
|
||||
* the same cwd, command, and name as an existing session. */
|
||||
export type SpawnPrefill = SpawnFormSeed;
|
||||
|
||||
export function openSpawnModal(prefill?: SpawnPrefill): void {
|
||||
const existing = document.getElementById("modal-host");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const host = document.createElement("div");
|
||||
host.id = "modal-host";
|
||||
host.className = "modal-backdrop";
|
||||
host.innerHTML = `
|
||||
<div class="modal spawn-modal" role="dialog" aria-modal="true" aria-labelledby="spawn-title">
|
||||
<header class="modal-header">
|
||||
<h2 id="spawn-title">Spawn session</h2>
|
||||
<button type="button" class="btn btn-icon close" aria-label="Close">✕</button>
|
||||
</header>
|
||||
<div class="form-slot"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(host);
|
||||
|
||||
const formHandle = createSpawnForm({
|
||||
seed: prefill,
|
||||
class: "modal-body",
|
||||
onSuccess(session) {
|
||||
close();
|
||||
location.hash = `#/attach/${encodeURIComponent(session.id)}`;
|
||||
},
|
||||
});
|
||||
(host.querySelector(".form-slot") as HTMLElement).replaceWith(formHandle.el);
|
||||
|
||||
const close = () => {
|
||||
formHandle.destroy();
|
||||
host.remove();
|
||||
};
|
||||
// The modal keeps its Cancel affordance; the shared form ships only Submit.
|
||||
const actions = formHandle.el.querySelector(".form-actions") as HTMLElement;
|
||||
const cancel = document.createElement("button");
|
||||
cancel.type = "button";
|
||||
cancel.className = "btn btn-ghost cancel";
|
||||
cancel.textContent = "Cancel";
|
||||
cancel.addEventListener("click", close);
|
||||
actions.prepend(cancel);
|
||||
|
||||
host.querySelector(".close")?.addEventListener("click", close);
|
||||
host.addEventListener("click", (e) => {
|
||||
if (e.target === host) close();
|
||||
});
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(e) => {
|
||||
if (e.key === "Escape") close();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
// ---- clone seed ------------------------------------------------------
|
||||
if (prefill) {
|
||||
(host.querySelector("#spawn-title") as HTMLElement).textContent = "Clone session";
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const base: SpawnFormValues = {
|
||||
worktreeEnabled: false,
|
||||
worktreeBranch: "",
|
||||
worktreeBranchMode: "new",
|
||||
worktreeBranchEdited: false,
|
||||
};
|
||||
|
||||
describe("buildSpawnRequest", () => {
|
||||
|
||||
+39
-14
@@ -16,15 +16,19 @@ import { abbreviateCwd, abbreviateFolderName } from "./types.js";
|
||||
* from the repository's existing branches (#61). */
|
||||
export type WorktreeBranchMode = "new" | "existing";
|
||||
|
||||
/** Seed values for the spawn form — used by "clone" to reopen with the same
|
||||
* cwd, command, and name as an existing session, and by draft panes to restore
|
||||
* their own half-filled state after a re-render. */
|
||||
/** Seed values for the spawn form — used by "clone" to open a draft with the
|
||||
* same cwd, command, and name as an existing session, and by draft panes to
|
||||
* restore their own half-filled state after a re-render. */
|
||||
export interface SpawnFormSeed {
|
||||
command?: string;
|
||||
cwd?: string;
|
||||
name?: string;
|
||||
worktreeEnabled?: boolean;
|
||||
worktreeBranch?: string;
|
||||
/** Whether the operator typed the branch themselves, rather than it having
|
||||
* been auto-synced from the session name. Carried separately because the
|
||||
* value alone cannot distinguish the two. */
|
||||
worktreeBranchEdited?: boolean;
|
||||
worktreeBranchMode?: WorktreeBranchMode;
|
||||
cwdRawVisible?: boolean;
|
||||
}
|
||||
@@ -37,6 +41,7 @@ export interface SpawnFormValues {
|
||||
name: string;
|
||||
worktreeEnabled: boolean;
|
||||
worktreeBranch: string;
|
||||
worktreeBranchEdited: boolean;
|
||||
/** "new" creates the branch; "existing" checks out a picked one (#61). */
|
||||
worktreeBranchMode: WorktreeBranchMode;
|
||||
/** Whether the cwd lives in the raw text input (vs a picked recent row). */
|
||||
@@ -83,8 +88,9 @@ export function buildSpawnRequest(
|
||||
}
|
||||
|
||||
/** Built-ins used when the daemon's `/api/info` presets can't be fetched
|
||||
* (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. */
|
||||
const FALLBACK_PRESETS: SpawnPreset[] = [
|
||||
* (older daemon, transient error). Mirrors `maestro_config::builtin_presets`.
|
||||
* Exported for the fast-forward spawn's preset fallback (#67). */
|
||||
export const FALLBACK_PRESETS: SpawnPreset[] = [
|
||||
{ id: "claude", label: "Claude", command: "claude" },
|
||||
{ id: "opencode", label: "opencode", command: "opencode" },
|
||||
{ id: "zsh", label: "Shell", command: "zsh" },
|
||||
@@ -99,7 +105,7 @@ export interface SpawnFormHandle {
|
||||
|
||||
export interface SpawnFormOptions {
|
||||
seed?: SpawnFormSeed;
|
||||
/** Extra class(es) for the <form> root (e.g. `modal-body` in the modal). */
|
||||
/** Extra class(es) for the <form> root (e.g. `mv-draft` in a draft pane). */
|
||||
class?: string;
|
||||
/** Fired whenever any value changes (input, tile pick, checkbox). */
|
||||
onChange?: (values: SpawnFormValues) => void;
|
||||
@@ -108,7 +114,7 @@ export interface SpawnFormOptions {
|
||||
}
|
||||
|
||||
/** The spawn form itself — preset tiles, project picker, name, worktree.
|
||||
* Shared by the modal (`spawn.ts`) and inline draft panes (issue #57). */
|
||||
* Mounted inline by draft panes (issue #57). */
|
||||
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
const prefill = opts.seed ?? {};
|
||||
|
||||
@@ -174,7 +180,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
</div>
|
||||
`;
|
||||
// Field queries are scoped to this form instance — several forms can be
|
||||
// mounted at once (modal + draft panes), so ids would collide.
|
||||
// mounted at once (one per draft pane), so ids would collide.
|
||||
|
||||
const errEl = form.querySelector(".form-error") as HTMLElement;
|
||||
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
|
||||
@@ -229,6 +235,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
name: nameInput.value,
|
||||
worktreeEnabled: wtEnabled.checked,
|
||||
worktreeBranch: branchValue(),
|
||||
worktreeBranchEdited: branchEdited,
|
||||
worktreeBranchMode: branchMode,
|
||||
cwdRawVisible: !cwdRawWrap.hidden,
|
||||
});
|
||||
@@ -238,7 +245,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// The worktree branch defaults to the session name — including a clone's
|
||||
// prefilled name — and tracks it on reroll/edit until the user types their
|
||||
// own branch. Only in "new" mode: an existing-branch pick is not a name.
|
||||
let branchEdited = Boolean(prefill.worktreeBranch);
|
||||
// True only once the operator types their own branch. It cannot be inferred
|
||||
// from the seed: in "new" mode `emit()` publishes the auto-synced session
|
||||
// name, so seeding from it froze the branch after any form rebuild
|
||||
// (#67 review). `worktreeBranchEdited` is carried explicitly instead.
|
||||
let branchEdited = Boolean(prefill.worktreeBranchEdited);
|
||||
const syncBranchToName = () => {
|
||||
if (!branchEdited && branchMode === "new") branchInput.value = nameInput.value;
|
||||
};
|
||||
@@ -539,6 +550,16 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
/** Decide how the worktree section describes the current target. Called
|
||||
* once the initial load has settled `cwd`. */
|
||||
function resolveBaseRepo(): void {
|
||||
// Nothing to resolve when the operator turned the worktree off — the
|
||||
// section would otherwise rebuild expanded under an unchecked box, and
|
||||
// fire a `/api/gitroot` probe for a worktree nobody asked for
|
||||
// (#67 review).
|
||||
if (!wtEnabled.checked) {
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = true;
|
||||
inferredBaseRepo = null;
|
||||
return;
|
||||
}
|
||||
if (!cwd) {
|
||||
inferredBaseRepo = null;
|
||||
wtBody.hidden = true;
|
||||
@@ -633,11 +654,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
return;
|
||||
}
|
||||
for (const b of branches) {
|
||||
// A branch another worktree holds stays visible but unpickable — git
|
||||
// would refuse to check it out twice; the path says who holds it.
|
||||
const o = opt(b.worktree ? `${b.name} — held by ${b.worktree}` : b.name, b.name);
|
||||
o.disabled = Boolean(b.worktree);
|
||||
o.title = b.worktree ?? "";
|
||||
// A branch that is already checked out anywhere stays visible but
|
||||
// unpickable — git refuses a second checkout. That includes the main
|
||||
// checkout's own HEAD, which is commonly `main` and would otherwise be
|
||||
// the default pick, making every existing-branch spawn fail (#54 review).
|
||||
const held = b.worktree ?? (b.is_head ? "the main checkout" : null);
|
||||
const o = opt(held ? `${b.name} — held by ${held}` : b.name, b.name);
|
||||
o.disabled = held !== null;
|
||||
o.title = held ?? "";
|
||||
branchSelect.appendChild(o);
|
||||
}
|
||||
}
|
||||
@@ -684,6 +708,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
name: nameInput.value,
|
||||
worktreeEnabled: wtEnabled.checked,
|
||||
worktreeBranch: branchValue(),
|
||||
worktreeBranchEdited: branchEdited,
|
||||
worktreeBranchMode: branchMode,
|
||||
},
|
||||
inferredBaseRepo,
|
||||
|
||||
+11
-117
@@ -1205,72 +1205,9 @@ textarea {
|
||||
border-color: var(--accent-strong);
|
||||
}
|
||||
|
||||
/* ---------- modal ---------- */
|
||||
/* ---------- forms ---------- */
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: oklch(0.12 0.01 256 / 0.6);
|
||||
backdrop-filter: blur(3px);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
z-index: var(--z-modal);
|
||||
animation: fade var(--dur) var(--ease-out);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.modal-backdrop {
|
||||
align-items: center;
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border-radius: var(--r-3) var(--r-3) 0 0;
|
||||
border: 1px solid var(--border);
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
max-height: 92vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-2);
|
||||
animation: sheet-up var(--dur-slow) var(--ease-out);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.modal {
|
||||
border-radius: var(--r-3);
|
||||
animation: pop var(--dur) var(--ease-out);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-15);
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.modal-header h2::before {
|
||||
content: "+";
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
.spawn-form {
|
||||
padding: var(--sp-3);
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
@@ -1280,7 +1217,6 @@ textarea {
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
.modal-body label,
|
||||
.spawn-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1290,7 +1226,6 @@ textarea {
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.modal-body label.row-inline,
|
||||
.spawn-form label.row-inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -1300,10 +1235,9 @@ textarea {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-body input[type="text"],
|
||||
.modal-body input[type="search"],
|
||||
.modal-body select,
|
||||
.spawn-form input[type="text"] {
|
||||
.spawn-form input[type="text"],
|
||||
.spawn-form input[type="search"],
|
||||
.spawn-form select {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
@@ -1318,23 +1252,20 @@ textarea {
|
||||
background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.modal-body input[type="text"]:hover,
|
||||
.modal-body select:hover,
|
||||
.spawn-form input[type="text"]:hover {
|
||||
.spawn-form input[type="text"]:hover,
|
||||
.spawn-form select:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.modal-body input[type="text"]:focus,
|
||||
.modal-body input[type="search"]:focus,
|
||||
.modal-body select:focus,
|
||||
.spawn-form input[type="text"]:focus {
|
||||
.spawn-form input[type="text"]:focus,
|
||||
.spawn-form input[type="search"]:focus,
|
||||
.spawn-form select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 3px var(--accent-tint);
|
||||
}
|
||||
|
||||
.modal-body input[type="checkbox"],
|
||||
.spawn-form input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -1359,13 +1290,6 @@ textarea {
|
||||
font-size: var(--fs-13);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--danger);
|
||||
margin: 0;
|
||||
@@ -1381,7 +1305,6 @@ textarea {
|
||||
|
||||
/* ---------- spawn form: tiles + recents picker (#27) ---------- */
|
||||
|
||||
.spawn-modal .field,
|
||||
.spawn-form .field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1611,7 +1534,7 @@ textarea {
|
||||
|
||||
.dir-ac {
|
||||
position: fixed;
|
||||
z-index: var(--z-toast); /* above the modal it completes within */
|
||||
z-index: var(--z-toast); /* above the draft pane it completes within */
|
||||
margin: 0;
|
||||
padding: var(--sp-1);
|
||||
list-style: none;
|
||||
@@ -1759,26 +1682,6 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sheet-up {
|
||||
from {
|
||||
transform: translateY(16px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop {
|
||||
from {
|
||||
transform: scale(0.98);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- responsive ---------- */
|
||||
|
||||
@media (max-width: 520px) {
|
||||
@@ -2119,15 +2022,6 @@ textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
/* The modal form got its stacking and padding from `.modal-body`; in a pane
|
||||
the form is the host, so it carries them itself. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
|
||||
/* pane tools cluster (disconnect / split / close), top-right corner. The attach
|
||||
|
||||
Reference in New Issue
Block a user