Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
596e7e2f49 | ||
|
|
16609eff9e | ||
|
|
2a2da7f779 | ||
|
|
5eab434647 | ||
|
|
8e9ed580ea | ||
|
|
2d5e2f1718 | ||
|
|
b80d604408 | ||
|
|
56d1afe9bd | ||
|
|
73e9ecfc85 | ||
|
|
095f810176 | ||
|
|
3e809db1bc | ||
|
|
e8bd8a4952 | ||
|
|
a85773cef0 | ||
|
|
9c6e06ec4f | ||
|
|
3029de667e | ||
|
|
a8d693c0a3 | ||
|
|
6c1bcf042c | ||
|
|
fb87a00906 | ||
|
|
d4dcfe7f00 | ||
|
|
6161cabe77 | ||
|
|
524a848fc6 | ||
|
|
187bcb01d6 | ||
|
|
003e3839bc | ||
|
|
d2079cac3a | ||
|
|
2083911baa | ||
|
|
b5450651d1 | ||
|
|
8aa007c73b | ||
|
|
f4d12fa02d | ||
|
|
31702e03e3 | ||
|
|
dde8dd8cfc | ||
|
|
2fc19a886e | ||
|
|
0b95d11aa6 | ||
|
|
a85084d0d9 | ||
|
|
5282ba3411 | ||
|
|
6f698df9f4 | ||
|
|
8e8540460f | ||
|
|
2d796a3556 | ||
|
|
1984bf9472 | ||
|
|
f67803acf5 | ||
|
|
0c135aa065 | ||
|
|
56c8e35d6f | ||
|
|
b96f18c7cb | ||
|
|
0166607064 | ||
|
|
bb2f439302 | ||
|
|
58b4257c92 | ||
|
|
86443d195e | ||
|
|
abaa498b9f | ||
|
|
62581d9f85 | ||
|
|
267f8a6712 | ||
|
|
a575b8bef1 | ||
|
|
e9e0136a89 | ||
|
|
898c3cc763 |
+113
-28
@@ -41,6 +41,36 @@ pub enum Command {
|
|||||||
Attach(AttachArgs),
|
Attach(AttachArgs),
|
||||||
/// Kill a session by ID.
|
/// Kill a session by ID.
|
||||||
Kill(KillArgs),
|
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)]
|
#[derive(Debug, clap::Args)]
|
||||||
@@ -72,11 +102,6 @@ pub struct SpawnArgs {
|
|||||||
/// there.
|
/// there.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub existing_branch: bool,
|
pub existing_branch: bool,
|
||||||
/// Remove the worktree when the session is killed. Kept for wire
|
|
||||||
/// compatibility (#56): the daemon now always cleans up on kill, guarded
|
|
||||||
/// against unsaved work.
|
|
||||||
#[arg(long, default_value_t = true)]
|
|
||||||
pub cleanup_on_kill: bool,
|
|
||||||
/// After spawning, attach immediately.
|
/// After spawning, attach immediately.
|
||||||
#[arg(long, short = 'a')]
|
#[arg(long, short = 'a')]
|
||||||
pub attach: bool,
|
pub attach: bool,
|
||||||
@@ -95,6 +120,11 @@ pub struct AttachArgs {
|
|||||||
pub struct KillArgs {
|
pub struct KillArgs {
|
||||||
/// Session ID to kill.
|
/// Session ID to kill.
|
||||||
pub session_id: Uuid,
|
pub session_id: Uuid,
|
||||||
|
/// End the session even when its worktree holds unsaved work (#81). The
|
||||||
|
/// worktree is retained — never removed — and enters the retained store;
|
||||||
|
/// the output says where the work went.
|
||||||
|
#[arg(long)]
|
||||||
|
pub force: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_kv(s: &str) -> Result<(String, String), String> {
|
fn parse_kv(s: &str) -> Result<(String, String), String> {
|
||||||
@@ -133,7 +163,6 @@ mod tests {
|
|||||||
"/repos/foo",
|
"/repos/foo",
|
||||||
"--branch",
|
"--branch",
|
||||||
"feat/x",
|
"feat/x",
|
||||||
"--cleanup-on-kill",
|
|
||||||
"-e",
|
"-e",
|
||||||
"FOO=bar",
|
"FOO=bar",
|
||||||
"-e",
|
"-e",
|
||||||
@@ -144,7 +173,6 @@ mod tests {
|
|||||||
Command::Spawn(a) => {
|
Command::Spawn(a) => {
|
||||||
assert_eq!(a.command, "bash");
|
assert_eq!(a.command, "bash");
|
||||||
assert_eq!(a.branch.as_deref(), Some("feat/x"));
|
assert_eq!(a.branch.as_deref(), Some("feat/x"));
|
||||||
assert!(a.cleanup_on_kill);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
a.env,
|
a.env,
|
||||||
vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into()),]
|
vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into()),]
|
||||||
@@ -154,27 +182,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cleanup_on_kill_defaults_true_for_the_wire() {
|
|
||||||
let cli = Cli::try_parse_from([
|
|
||||||
"maestro",
|
|
||||||
"spawn",
|
|
||||||
"--cwd",
|
|
||||||
"/tmp",
|
|
||||||
"--command",
|
|
||||||
"bash",
|
|
||||||
"--worktree-from",
|
|
||||||
"/repos/foo",
|
|
||||||
"--branch",
|
|
||||||
"feat/x",
|
|
||||||
])
|
|
||||||
.unwrap();
|
|
||||||
match cli.command {
|
|
||||||
Command::Spawn(a) => assert!(a.cleanup_on_kill),
|
|
||||||
_ => panic!("wrong subcommand"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Review fix: without a flag the CLI always sent `create_branch: true`,
|
/// Review fix: without a flag the CLI always sent `create_branch: true`,
|
||||||
/// so the daemon's existing-branch path was unreachable and spawning onto
|
/// so the daemon's existing-branch path was unreachable and spawning onto
|
||||||
/// a branch that already existed just failed.
|
/// a branch that already existed just failed.
|
||||||
@@ -226,6 +233,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_kill_with_force() {
|
||||||
|
let id = "01234567-89ab-cdef-0123-456789abcdef";
|
||||||
|
let cli = Cli::try_parse_from(["maestro", "kill", "--force", id]).unwrap();
|
||||||
|
match cli.command {
|
||||||
|
Command::Kill(a) => {
|
||||||
|
assert!(a.force);
|
||||||
|
assert_eq!(a.session_id.to_string(), id);
|
||||||
|
}
|
||||||
|
_ => panic!("wrong subcommand"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kill_force_defaults_false() {
|
||||||
|
let id = "01234567-89ab-cdef-0123-456789abcdef";
|
||||||
|
let cli = Cli::try_parse_from(["maestro", "kill", id]).unwrap();
|
||||||
|
match cli.command {
|
||||||
|
Command::Kill(a) => {
|
||||||
|
assert!(!a.force);
|
||||||
|
assert_eq!(a.session_id.to_string(), id);
|
||||||
|
}
|
||||||
|
_ => panic!("wrong subcommand"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_env_without_equals() {
|
fn rejects_env_without_equals() {
|
||||||
let err = Cli::try_parse_from([
|
let err = Cli::try_parse_from([
|
||||||
@@ -241,4 +274,56 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(err.to_string().contains("KEY=VALUE"));
|
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 maestro_protocol::DaemonClient;
|
||||||
|
|
||||||
use crate::attach::{parse_detach_key, run_attach_url};
|
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::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};
|
use maestro_config::{default_config_path, MaestroConfigFile};
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -61,9 +62,41 @@ pub async fn dispatch(
|
|||||||
run_attach_url(&daemon.attach_url(args.session_id)?, detach).await?;
|
run_attach_url(&daemon.attach_url(args.session_id)?, detach).await?;
|
||||||
}
|
}
|
||||||
Command::Kill(args) => {
|
Command::Kill(args) => {
|
||||||
run_kill(&daemon, &args).await?;
|
// #83: warn BEFORE the attempt, so the refusal is not a surprise.
|
||||||
|
// Best-effort: an unreachable list must not block the kill.
|
||||||
|
if !args.force {
|
||||||
|
warn_if_blocked(&daemon, args.session_id).await;
|
||||||
|
}
|
||||||
|
let outcome = run_kill(&daemon, &args).await?;
|
||||||
println!("killed {}", args.session_id);
|
println!("killed {}", args.session_id);
|
||||||
|
if let Some(w) = outcome.retained {
|
||||||
|
println!(
|
||||||
|
"worktree retained at {} (branch {}, force-killed)",
|
||||||
|
w.path.display(),
|
||||||
|
w.branch
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -71,6 +104,25 @@ pub async fn dispatch(
|
|||||||
/// Default detach byte, matching `maestro attach --detach-key`'s `^q`.
|
/// Default detach byte, matching `maestro attach --detach-key`'s `^q`.
|
||||||
const CTRL_Q: u8 = 0x11;
|
const CTRL_Q: u8 = 0x11;
|
||||||
|
|
||||||
|
/// #83: say "this will be refused" before the operator commits to it, in the
|
||||||
|
/// same words [`maestro_protocol::InTheWay`] uses everywhere else.
|
||||||
|
async fn warn_if_blocked(daemon: &DaemonClient, id: uuid::Uuid) {
|
||||||
|
let Ok(sessions) = daemon.list_sessions().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(b) = sessions
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == id)
|
||||||
|
.and_then(|s| s.blocked_work)
|
||||||
|
{
|
||||||
|
if !b.is_clear() {
|
||||||
|
eprintln!(
|
||||||
|
"warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to retain it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// HTTP client pointed at the daemon's API. That surface needs no auth —
|
/// HTTP client pointed at the daemon's API. That surface needs no auth —
|
||||||
/// reaching `bind_addr` is the authorization.
|
/// reaching `bind_addr` is the authorization.
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ pub mod attach;
|
|||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod dispatch;
|
pub mod dispatch;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
|
pub mod worktrees;
|
||||||
|
|
||||||
pub use dispatch::{dispatch, CliError};
|
pub use dispatch::{dispatch, CliError};
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
|||||||
(Some(repo), Some(branch)) => Some(WorktreeSpec {
|
(Some(repo), Some(branch)) => Some(WorktreeSpec {
|
||||||
base_repo: repo.clone(),
|
base_repo: repo.clone(),
|
||||||
branch: branch.clone(),
|
branch: branch.clone(),
|
||||||
cleanup_on_kill: args.cleanup_on_kill,
|
|
||||||
create_branch: !args.existing_branch,
|
create_branch: !args.existing_branch,
|
||||||
}),
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -59,9 +58,14 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
|||||||
client.create_session(&body).await
|
client.create_session(&body).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `maestro kill`
|
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
||||||
pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), ClientError> {
|
/// of removing it; the outcome names what was kept so dispatch can say where
|
||||||
client.kill_session(args.session_id).await
|
/// the work went.
|
||||||
|
pub async fn run_kill(
|
||||||
|
client: &DaemonClient,
|
||||||
|
args: &KillArgs,
|
||||||
|
) -> Result<maestro_protocol::KillOutcome, ClientError> {
|
||||||
|
client.kill_session(args.session_id, args.force).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::write_literal)]
|
#[allow(clippy::write_literal)]
|
||||||
@@ -89,8 +93,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
}
|
}
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" {:<36} {:<8} {:<9} {:<28} {}",
|
" {:<36} {:<8} {:<9} {:<28} {:<40} {}",
|
||||||
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND"
|
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
for s in sorted {
|
for s in sorted {
|
||||||
@@ -111,18 +115,31 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg),
|
Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg),
|
||||||
_ => s.command.clone(),
|
_ => s.command.clone(),
|
||||||
};
|
};
|
||||||
|
// #83: what an unforced kill would be refused for, in InTheWay's own
|
||||||
|
// words; blank while nothing blocks (or hasn't been probed yet).
|
||||||
|
let blocked = blocked_label(s);
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" {:<36} {:<8} {:<9} {:<28} {command}",
|
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
|
||||||
s.id, status, activity, dir
|
s.id, status, activity, dir, command
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The blocked-work indicator (#83), phrased by [`InTheWay`]'s `Display`.
|
||||||
|
/// Empty unless something actually blocks — "no unsaved work" would just be
|
||||||
|
/// noise on every row.
|
||||||
|
pub(crate) fn blocked_label(s: &Session) -> String {
|
||||||
|
s.blocked_work
|
||||||
|
.filter(|b| !b.is_clear())
|
||||||
|
.map(|b| b.to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web
|
/// 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.
|
/// 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();
|
let p = path.to_string_lossy();
|
||||||
if !home.is_empty() {
|
if !home.is_empty() {
|
||||||
if p == home {
|
if p == home {
|
||||||
@@ -160,6 +177,7 @@ mod tests {
|
|||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,4 +238,37 @@ mod tests {
|
|||||||
super::print_session_table(&mut buf, &[], &info(""));
|
super::print_session_table(&mut buf, &[], &info(""));
|
||||||
assert_eq!(String::from_utf8(buf).unwrap(), "no sessions\n");
|
assert_eq!(String::from_utf8(buf).unwrap(), "no sessions\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #83: a blocked session shows what an unforced kill would be refused
|
||||||
|
/// for, in `InTheWay`'s own words; a clear or unprobed one shows nothing.
|
||||||
|
#[test]
|
||||||
|
fn shows_the_blocked_work_summary_only_when_something_blocks() {
|
||||||
|
use maestro_protocol::InTheWay;
|
||||||
|
|
||||||
|
let mut blocked = session("/tmp", "claude", 1);
|
||||||
|
blocked.blocked_work = Some(InTheWay {
|
||||||
|
uncommitted_files: 3,
|
||||||
|
unpushed_commits: 2,
|
||||||
|
..InTheWay::default()
|
||||||
|
});
|
||||||
|
let mut clear = session("/tmp", "bash", 2);
|
||||||
|
clear.blocked_work = Some(InTheWay::default());
|
||||||
|
let unprobed = session("/tmp", "zsh", 3);
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
super::print_session_table(&mut buf, &[blocked, clear, unprobed], &info(""));
|
||||||
|
let out = String::from_utf8(buf).unwrap();
|
||||||
|
assert!(
|
||||||
|
out.contains("3 uncommitted files, 2 unpushed commits"),
|
||||||
|
"{out}"
|
||||||
|
);
|
||||||
|
// Clear and unprobed rows stay blank in the BLOCKED column.
|
||||||
|
let lines: Vec<&str> = out.lines().collect();
|
||||||
|
let header = lines.iter().position(|l| l.contains("BLOCKED")).unwrap();
|
||||||
|
for line in &lines[header + 1..] {
|
||||||
|
if !line.contains("uncommitted files") {
|
||||||
|
assert!(!line.contains("unsaved work"), "{line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,7 +76,7 @@ async fn killing_an_unknown_session_reports_the_daemon_reason() {
|
|||||||
|
|
||||||
let err = mock
|
let err = mock
|
||||||
.client()
|
.client()
|
||||||
.kill_session(Uuid::new_v4())
|
.kill_session(Uuid::new_v4(), false)
|
||||||
.await
|
.await
|
||||||
.expect_err("should fail");
|
.expect_err("should fail");
|
||||||
|
|
||||||
@@ -102,6 +102,9 @@ async fn spawn_round_trips_through_the_daemon() {
|
|||||||
assert_eq!(created.command, "bash");
|
assert_eq!(created.command, "bash");
|
||||||
assert_eq!(mock.session_count(), 1);
|
assert_eq!(mock.session_count(), 1);
|
||||||
|
|
||||||
mock.client().kill_session(created.id).await.expect("kill");
|
mock.client()
|
||||||
|
.kill_session(created.id, false)
|
||||||
|
.await
|
||||||
|
.expect("kill");
|
||||||
assert_eq!(mock.session_count(), 0);
|
assert_eq!(mock.session_count(), 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
Some(p) => Tmux::new().with_tmpdir(p),
|
Some(p) => Tmux::new().with_tmpdir(p),
|
||||||
None => Tmux::new(),
|
None => Tmux::new(),
|
||||||
};
|
};
|
||||||
|
let retained = std::sync::Arc::new(crate::retained_store::RetainedStore::new(
|
||||||
|
config.retained_worktree_dir(),
|
||||||
|
));
|
||||||
let manager = SessionManager::new_with_replay(
|
let manager = SessionManager::new_with_replay(
|
||||||
std::sync::Arc::new(tmux),
|
std::sync::Arc::new(tmux),
|
||||||
sidecar,
|
sidecar,
|
||||||
@@ -66,7 +69,10 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
.with_spawn_history(crate::spawn_history::SpawnHistory::new(
|
.with_spawn_history(crate::spawn_history::SpawnHistory::new(
|
||||||
config.spawn_history_path(),
|
config.spawn_history_path(),
|
||||||
config.spawn_history_days,
|
config.spawn_history_days,
|
||||||
));
|
))
|
||||||
|
// One store instance shared with the API handlers: forced kills (#81)
|
||||||
|
// record here, `GET /api/worktrees` (#80) reads back from here.
|
||||||
|
.with_retained_store((*retained).clone());
|
||||||
|
|
||||||
let recovered = manager.recover().await?;
|
let recovered = manager.recover().await?;
|
||||||
tracing::info!(recovered, "adopted existing tmux sessions");
|
tracing::info!(recovered, "adopted existing tmux sessions");
|
||||||
@@ -85,9 +91,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
config.home_dir.clone(),
|
config.home_dir.clone(),
|
||||||
config.spawn_presets.clone(),
|
config.spawn_presets.clone(),
|
||||||
projects,
|
projects,
|
||||||
std::sync::Arc::new(crate::retained_store::RetainedStore::new(
|
retained,
|
||||||
config.retained_worktree_dir(),
|
|
||||||
)),
|
|
||||||
));
|
));
|
||||||
|
|
||||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ fn io_error(dir: &Path, e: &std::io::Error) -> ErrorInfo {
|
|||||||
ErrorInfo {
|
ErrorInfo {
|
||||||
code: code.into(),
|
code: code.into(),
|
||||||
message: format!("{message}: {}", dir.display()),
|
message: format!("{message}: {}", dir.display()),
|
||||||
|
unsaved_work: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ fn session_error_status(code: &str) -> StatusCode {
|
|||||||
match code {
|
match code {
|
||||||
"not_found" => StatusCode::NOT_FOUND,
|
"not_found" => StatusCode::NOT_FOUND,
|
||||||
"invalid_env" => StatusCode::BAD_REQUEST,
|
"invalid_env" => StatusCode::BAD_REQUEST,
|
||||||
|
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||||
|
"kill_guarded" => StatusCode::CONFLICT,
|
||||||
|
// The retention record failed before anything was torn down (#81).
|
||||||
|
"retain_failed" => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
_ => StatusCode::BAD_GATEWAY,
|
_ => StatusCode::BAD_GATEWAY,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,16 +177,37 @@ async fn create_session(
|
|||||||
/// `kill` already reports a missing session as `SessionError::NotFound`, which
|
/// `kill` already reports a missing session as `SessionError::NotFound`, which
|
||||||
/// maps to 404. Checking first would only add a window in which the liveness
|
/// 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.
|
/// watcher reaps the session between the two awaits, turning a 404 into a 502.
|
||||||
async fn kill_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
///
|
||||||
match s.manager.kill(id).await {
|
/// The full error info is passed through rather than rebuilt from
|
||||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work
|
||||||
|
/// breakdown clients render as "3 uncommitted files, 2 unpushed commits".
|
||||||
|
///
|
||||||
|
/// With `force=true` (#81) the refusal is overridden and the worktree
|
||||||
|
/// retained; the answer is `200` with a [`KillOutcome`] naming what was kept.
|
||||||
|
/// Without it the historical `204` stands.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct KillQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn kill_session(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Query(q): Query<KillQuery>,
|
||||||
|
) -> Response {
|
||||||
|
match s.manager.kill(id, q.force).await {
|
||||||
|
Ok(outcome) => {
|
||||||
|
if q.force {
|
||||||
|
(StatusCode::OK, Json(outcome)).into_response()
|
||||||
|
} else {
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let info = e.to_error_info();
|
let info = e.to_error_info();
|
||||||
error_response(
|
let status = session_error_status(&info.code);
|
||||||
session_error_status(&info.code),
|
(status, Json(ApiError { error: info })).into_response()
|
||||||
info.code.as_str(),
|
|
||||||
info.message.as_str(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,9 +226,7 @@ async fn list_worktrees(State(s): State<LocalApiState>) -> Response {
|
|||||||
.map(|entry| {
|
.map(|entry| {
|
||||||
// A tree git cannot read (permissions, corrupt checkout)
|
// A tree git cannot read (permissions, corrupt checkout)
|
||||||
// still gets listed; it just reports nothing in the way.
|
// still gets listed; it just reports nothing in the way.
|
||||||
let in_the_way =
|
let in_the_way = crate::worktree_probe::in_the_way(&entry.path).unwrap_or_default();
|
||||||
crate::worktree_probe::in_the_way(&entry.path, &entry.base_repo, &entry.branch)
|
|
||||||
.unwrap_or_default();
|
|
||||||
Ok(maestro_protocol::RetainedWorktree {
|
Ok(maestro_protocol::RetainedWorktree {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
path: entry.path.clone(),
|
path: entry.path.clone(),
|
||||||
|
|||||||
@@ -261,17 +261,13 @@ impl ProjectScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
|
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
|
||||||
|
/// Also the background timer's entry point: a tick inside the TTL is a
|
||||||
|
/// no-op (#75).
|
||||||
pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> {
|
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;
|
let mut cache = self.cache.lock().await;
|
||||||
if !force {
|
if let Some((at, repos)) = cache.as_ref() {
|
||||||
if let Some((at, repos)) = cache.as_ref() {
|
if at.elapsed() < self.ttl {
|
||||||
if at.elapsed() < self.ttl {
|
return repos.clone();
|
||||||
return repos.clone();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let roots = self.roots.clone();
|
let roots = self.roots.clone();
|
||||||
@@ -287,11 +283,11 @@ impl ProjectScanner {
|
|||||||
repos
|
repos
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rescan now, ignoring the TTL. Called by the background timer — going
|
/// One background-refresh tick: rescan only if the cached snapshot has
|
||||||
/// through `repos()` would hit the cache and make most ticks no-ops,
|
/// passed its TTL (#75) — the timer used to bypass it and re-walk the
|
||||||
/// leaving requests to pay for the rescan instead (#54 review).
|
/// roots every [`REFRESH_INTERVAL`].
|
||||||
async fn refresh(&self) {
|
async fn refresh(&self) {
|
||||||
self.repos_inner(true).await;
|
self.repos().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn the slow background refresh task. Drop the returned handle to
|
/// Spawn the slow background refresh task. Drop the returned handle to
|
||||||
@@ -544,6 +540,32 @@ mod tests {
|
|||||||
assert_eq!(fresh[0].name, "late");
|
assert_eq!(fresh[0].name, "late");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn background_refresh_honours_the_ttl() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let mut scanner = ProjectScanner::new(
|
||||||
|
vec![tmp.path().to_path_buf()],
|
||||||
|
3,
|
||||||
|
PathBuf::from("/nonexistent"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let empty = scanner.repos().await;
|
||||||
|
assert!(empty.is_empty());
|
||||||
|
|
||||||
|
make_repo(tmp.path(), "late");
|
||||||
|
// A background tick inside the TTL must not re-walk the filesystem:
|
||||||
|
// the new repo stays invisible until the cache expires.
|
||||||
|
scanner.refresh().await;
|
||||||
|
assert!(scanner.repos().await.is_empty());
|
||||||
|
|
||||||
|
// Once the TTL lapses, the next tick picks it up.
|
||||||
|
scanner.ttl = Duration::ZERO;
|
||||||
|
scanner.refresh().await;
|
||||||
|
let fresh = scanner.repos().await;
|
||||||
|
assert_eq!(fresh.len(), 1);
|
||||||
|
assert_eq!(fresh[0].name, "late");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn disabled_scanner_always_serves_empty() {
|
fn disabled_scanner_always_serves_empty() {
|
||||||
let scanner = ProjectScanner::disabled();
|
let scanner = ProjectScanner::disabled();
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, Session, SessionStatus,
|
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, KillOutcome, RetainedReason,
|
||||||
WorktreeSpec,
|
RetainedWorktree, Session, SessionStatus, WorktreeSpec,
|
||||||
};
|
};
|
||||||
use tokio::sync::{broadcast, Mutex};
|
use tokio::sync::{broadcast, Mutex};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -33,17 +33,29 @@ pub const EVENTS_CAPACITY: usize = maestro_config::EVENTS_CAPACITY;
|
|||||||
/// How often the liveness watcher polls tmux for sessions that disappeared.
|
/// How often the liveness watcher polls tmux for sessions that disappeared.
|
||||||
pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
|
pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// How often the liveness watcher re-probes worktrees for the blocked-work
|
||||||
|
/// indicator (#83). Each probe costs three git subprocesses per session, so
|
||||||
|
/// the pass rides the same tick as everything else but runs on a slower
|
||||||
|
/// cadence: an indicator a few seconds stale still warns before the kill
|
||||||
|
/// attempt, which is all #83 needs.
|
||||||
|
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
/// Per-session daemon-local bookkeeping that complements the public `Session`.
|
/// Per-session daemon-local bookkeeping that complements the public `Session`.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct SessionRecord {
|
pub struct SessionRecord {
|
||||||
pub session: Session,
|
pub session: Session,
|
||||||
pub fifo_path: PathBuf,
|
pub fifo_path: PathBuf,
|
||||||
/// Worktree we created at spawn time; removed on kill unless the
|
/// Worktree we created at spawn time; removed on kill, but only after the
|
||||||
/// dirty-work guard refuses (#56).
|
/// kill guard (#79) found nothing worth keeping in it.
|
||||||
pub worktree_path: Option<PathBuf>,
|
pub worktree_path: Option<PathBuf>,
|
||||||
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
||||||
/// worktree remove` is invoked from the base repo.
|
/// worktree remove` is invoked from the base repo.
|
||||||
pub worktree_base_repo: Option<PathBuf>,
|
pub worktree_base_repo: Option<PathBuf>,
|
||||||
|
/// Branch the worktree was created for, recorded at spawn time so a
|
||||||
|
/// forced kill (#81) can name it in the retained store without asking
|
||||||
|
/// git. Sessions adopted from a sidecar have no branch on record and
|
||||||
|
/// fall back to a probe.
|
||||||
|
pub worktree_branch: Option<String>,
|
||||||
pub bridge: PtyBridge,
|
pub bridge: PtyBridge,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +73,16 @@ pub enum SessionError {
|
|||||||
NotFound(Uuid),
|
NotFound(Uuid),
|
||||||
#[error("invalid environment variable name: {0:?}")]
|
#[error("invalid environment variable name: {0:?}")]
|
||||||
InvalidEnv(String),
|
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),
|
||||||
|
/// A forced kill (#81) records the retained worktree *before* tearing
|
||||||
|
/// anything down; if that write fails nothing was destroyed, and the
|
||||||
|
/// error says so rather than silently dropping unrecorded work.
|
||||||
|
#[error("could not record the retained worktree: {0}")]
|
||||||
|
RetainFailed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionError {
|
impl SessionError {
|
||||||
@@ -72,10 +94,17 @@ impl SessionError {
|
|||||||
SessionError::Io(_) => "io_failed",
|
SessionError::Io(_) => "io_failed",
|
||||||
SessionError::NotFound(_) => "not_found",
|
SessionError::NotFound(_) => "not_found",
|
||||||
SessionError::InvalidEnv(_) => "invalid_env",
|
SessionError::InvalidEnv(_) => "invalid_env",
|
||||||
|
SessionError::KillGuarded(_) => "kill_guarded",
|
||||||
|
SessionError::RetainFailed(_) => "retain_failed",
|
||||||
|
};
|
||||||
|
let unsaved_work = match self {
|
||||||
|
SessionError::KillGuarded(b) => Some(*b),
|
||||||
|
_ => None,
|
||||||
};
|
};
|
||||||
ErrorInfo {
|
ErrorInfo {
|
||||||
code: code.into(),
|
code: code.into(),
|
||||||
message: self.to_string(),
|
message: self.to_string(),
|
||||||
|
unsaved_work,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,6 +128,9 @@ struct Inner {
|
|||||||
/// `None` when not wired (tests, or before `with_spawn_history`); a present
|
/// `None` when not wired (tests, or before `with_spawn_history`); a present
|
||||||
/// but disabled (`0`-day) history no-ops internally.
|
/// but disabled (`0`-day) history no-ops internally.
|
||||||
spawn_history: Option<SpawnHistory>,
|
spawn_history: Option<SpawnHistory>,
|
||||||
|
/// Retained-worktree store (#78). `None` when not wired (tests); a forced
|
||||||
|
/// kill (#81) without one fails rather than destroying unrecorded work.
|
||||||
|
retained: Option<crate::retained_store::RetainedStore>,
|
||||||
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
||||||
events: broadcast::Sender<CatalogEvent>,
|
events: broadcast::Sender<CatalogEvent>,
|
||||||
}
|
}
|
||||||
@@ -137,6 +169,7 @@ impl SessionManager {
|
|||||||
fifo_dir,
|
fifo_dir,
|
||||||
replay_bytes,
|
replay_bytes,
|
||||||
spawn_history: None,
|
spawn_history: None,
|
||||||
|
retained: None,
|
||||||
sessions: Mutex::new(HashMap::new()),
|
sessions: Mutex::new(HashMap::new()),
|
||||||
events,
|
events,
|
||||||
}),
|
}),
|
||||||
@@ -159,6 +192,16 @@ impl SessionManager {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach the retained-worktree store (#78). A forced kill (#81) needs it
|
||||||
|
/// to record what it retained; without one, forcing a guarded kill fails
|
||||||
|
/// instead of destroying unrecorded work.
|
||||||
|
pub fn with_retained_store(mut self, store: crate::retained_store::RetainedStore) -> Self {
|
||||||
|
let inner = Arc::get_mut(&mut self.inner)
|
||||||
|
.expect("with_retained_store must be called before the SessionManager is cloned");
|
||||||
|
inner.retained = Some(store);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Recent working dirs for the spawn form's picker (#24), newest-first and
|
/// Recent working dirs for the spawn form's picker (#24), newest-first and
|
||||||
/// deduped by dir. Empty when no history is wired or it's disabled.
|
/// deduped by dir. Empty when no history is wired or it's disabled.
|
||||||
pub fn recent_dirs(&self) -> Vec<RecentDir> {
|
pub fn recent_dirs(&self) -> Vec<RecentDir> {
|
||||||
@@ -308,6 +351,9 @@ impl SessionManager {
|
|||||||
last_activity_at: Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
|
// The watcher fills this in on its first blocked-work pass; a
|
||||||
|
// fresh tree has nothing in the way anyway.
|
||||||
|
blocked_work: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = self.inner.sidecar.write(&session) {
|
if let Err(e) = self.inner.sidecar.write(&session) {
|
||||||
@@ -329,6 +375,7 @@ impl SessionManager {
|
|||||||
fifo_path,
|
fifo_path,
|
||||||
worktree_path,
|
worktree_path,
|
||||||
worktree_base_repo,
|
worktree_base_repo,
|
||||||
|
worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()),
|
||||||
bridge,
|
bridge,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -370,22 +417,78 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kill a session and remove its on-disk artifacts. The worktree is
|
/// Kill a session and remove its on-disk artifacts.
|
||||||
/// always pruned -- unless it holds uncommitted changes or commits not on
|
///
|
||||||
/// any other ref, in which case the guard leaves it on disk (#56).
|
/// 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).
|
||||||
|
///
|
||||||
|
/// With `force` (#81) the refusal — and nothing else — is overridden:
|
||||||
|
/// the session ends and the guarded worktree is retained into the store
|
||||||
|
/// with reason `force-killed`, never removed. The outcome names what was
|
||||||
|
/// kept so a client can say where the work went. A tree whose guard is
|
||||||
|
/// clear never needed the override; force removes it as usual.
|
||||||
///
|
///
|
||||||
/// This is also how an already-dead session is dismissed. tmux is only
|
/// 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
|
/// asked to kill something it still has — calling `kill-session` on a
|
||||||
/// session tmux already lost fails, and that failure is not interesting.
|
/// session tmux already lost fails, and that failure is not interesting.
|
||||||
pub async fn kill(&self, id: Uuid) -> Result<(), SessionError> {
|
pub async fn kill(&self, id: Uuid, force: bool) -> Result<KillOutcome, SessionError> {
|
||||||
|
let (worktree, alive, known_branch) = {
|
||||||
|
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,
|
||||||
|
record.worktree_branch.clone(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let probe = match &worktree {
|
||||||
|
Some(wt) => Some(probe_worktree(wt).await?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let guarded = probe.as_ref().is_some_and(|p| !p.is_clear());
|
||||||
|
if guarded && !force {
|
||||||
|
return Err(SessionError::KillGuarded(probe.expect("guarded ⇒ probed")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut retained = None;
|
||||||
|
if guarded {
|
||||||
|
let entry = record_retention(
|
||||||
|
&self.inner,
|
||||||
|
worktree.as_ref().expect("guarded ⇒ worktree"),
|
||||||
|
known_branch,
|
||||||
|
RetainedReason::ForceKilled,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
retained = Some((entry, probe.expect("guarded ⇒ probed")));
|
||||||
|
}
|
||||||
|
|
||||||
let removed = {
|
let removed = {
|
||||||
let mut sessions = self.inner.sessions.lock().await;
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
sessions.remove(&id)
|
sessions.remove(&id)
|
||||||
};
|
};
|
||||||
let record = removed.ok_or(SessionError::NotFound(id))?;
|
let record = removed.ok_or(SessionError::NotFound(id))?;
|
||||||
let alive = record.session.status == SessionStatus::Running;
|
cleanup_record(&self.inner, id, record, alive, retained.is_some()).await;
|
||||||
cleanup_record(&self.inner, id, record, alive).await;
|
Ok(KillOutcome {
|
||||||
Ok(())
|
retained: retained.map(|(entry, in_the_way)| RetainedWorktree {
|
||||||
|
id: entry.id,
|
||||||
|
path: entry.path,
|
||||||
|
base_repo: entry.base_repo,
|
||||||
|
branch: entry.branch,
|
||||||
|
reason: entry.reason,
|
||||||
|
retained_at: entry.retained_at,
|
||||||
|
in_the_way,
|
||||||
|
}),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a background task that polls tmux every `interval` and reaps any
|
/// Spawn a background task that polls tmux every `interval` and reaps any
|
||||||
@@ -396,6 +499,17 @@ impl SessionManager {
|
|||||||
/// `SessionManager` clone is dropped — call this once from the daemon
|
/// `SessionManager` clone is dropped — call this once from the daemon
|
||||||
/// startup path after [`Self::recover`] has populated the map.
|
/// startup path after [`Self::recover`] has populated the map.
|
||||||
pub fn start_liveness_watcher(&self, interval: Duration) {
|
pub fn start_liveness_watcher(&self, interval: Duration) {
|
||||||
|
self.start_liveness_watcher_with_blocked_work(interval, DEFAULT_BLOCKED_WORK_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::start_liveness_watcher`] with the blocked-work cadence (#83)
|
||||||
|
/// made explicit. Tests pass a small `blocked_work_interval` to watch the
|
||||||
|
/// probe actually happen.
|
||||||
|
pub fn start_liveness_watcher_with_blocked_work(
|
||||||
|
&self,
|
||||||
|
interval: Duration,
|
||||||
|
blocked_work_interval: Duration,
|
||||||
|
) {
|
||||||
let weak = Arc::downgrade(&self.inner);
|
let weak = Arc::downgrade(&self.inner);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut ticker = tokio::time::interval(interval);
|
let mut ticker = tokio::time::interval(interval);
|
||||||
@@ -403,6 +517,9 @@ impl SessionManager {
|
|||||||
// first tick fires immediately — skip it so we don't reap before
|
// first tick fires immediately — skip it so we don't reap before
|
||||||
// the caller has a chance to create anything.
|
// the caller has a chance to create anything.
|
||||||
ticker.tick().await;
|
ticker.tick().await;
|
||||||
|
// Start "due" so the first live tick probes: a recovered daemon
|
||||||
|
// should not wait a full interval to show what blocks.
|
||||||
|
let mut since_probe = blocked_work_interval;
|
||||||
loop {
|
loop {
|
||||||
ticker.tick().await;
|
ticker.tick().await;
|
||||||
let Some(inner) = weak.upgrade() else {
|
let Some(inner) = weak.upgrade() else {
|
||||||
@@ -412,6 +529,11 @@ impl SessionManager {
|
|||||||
// Same tick, no new timer (NAP-340): refresh activity for the
|
// Same tick, no new timer (NAP-340): refresh activity for the
|
||||||
// survivors and emit SessionUpdated on any transition.
|
// survivors and emit SessionUpdated on any transition.
|
||||||
run_activity_pass(&inner).await;
|
run_activity_pass(&inner).await;
|
||||||
|
since_probe += interval;
|
||||||
|
if since_probe >= blocked_work_interval {
|
||||||
|
since_probe = Duration::ZERO;
|
||||||
|
run_blocked_work_pass(&inner).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -448,6 +570,7 @@ impl SessionManager {
|
|||||||
last_activity_at: Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
|
blocked_work: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
||||||
@@ -472,6 +595,9 @@ impl SessionManager {
|
|||||||
// directory blocks that branch forever (#54 review).
|
// directory blocks that branch forever (#54 review).
|
||||||
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
|
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
|
||||||
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
|
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
|
||||||
|
// The sidecar carries no branch; a forced kill of a
|
||||||
|
// recovered session probes git for it at retain time.
|
||||||
|
worktree_branch: None,
|
||||||
bridge,
|
bridge,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -548,6 +674,49 @@ where
|
|||||||
.expect("tmux blocking task panicked")
|
.expect("tmux blocking task panicked")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What would be lost if this session's worktree were removed right now.
|
||||||
|
///
|
||||||
|
/// The single [`crate::worktree_probe::in_the_way`] call site behind both
|
||||||
|
/// endings of a session — the kill guard (#79) and the natural-exit
|
||||||
|
/// classifier (#82) — so neither can grow its own answer. Runs on the
|
||||||
|
/// blocking pool; callers invoke it before anything is torn down, because
|
||||||
|
/// refusing (#79), recording a retention (#81) and classifying an exit (#82)
|
||||||
|
/// must all see the tree as it stands.
|
||||||
|
async fn probe_worktree(
|
||||||
|
(_, path): &(PathBuf, PathBuf),
|
||||||
|
) -> Result<maestro_protocol::InTheWay, SessionError> {
|
||||||
|
let path = path.clone();
|
||||||
|
tmux_blocking(move || crate::worktree_probe::in_the_way(&path))
|
||||||
|
.await
|
||||||
|
.map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a guarded worktree into the retained store with `reason`, BEFORE
|
||||||
|
/// the caller tears anything down: a failed write then leaves every artifact
|
||||||
|
/// untouched instead of destroying work nothing records anymore. Shared by
|
||||||
|
/// the forced kill (`ForceKilled`, #81) and the natural exit
|
||||||
|
/// (`ExitedWithUnsavedWork`, #82) so the two paths cannot drift apart.
|
||||||
|
async fn record_retention(
|
||||||
|
inner: &Arc<Inner>,
|
||||||
|
(base_repo, path): &(PathBuf, PathBuf),
|
||||||
|
known_branch: Option<String>,
|
||||||
|
reason: RetainedReason,
|
||||||
|
) -> Result<crate::retained_store::RetainedWorktreeRecord, SessionError> {
|
||||||
|
let store = inner
|
||||||
|
.retained
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| SessionError::RetainFailed("no retained store is wired".into()))?;
|
||||||
|
// Sessions adopted from a sidecar carry no branch; ask the tree itself.
|
||||||
|
let branch = known_branch
|
||||||
|
.or_else(|| crate::worktree_probe::current_branch(path))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let base_repo = base_repo.clone();
|
||||||
|
let path = path.clone();
|
||||||
|
tmux_blocking(move || store.retain(&path, &base_repo, &branch, reason))
|
||||||
|
.await
|
||||||
|
.map_err(|e| SessionError::RetainFailed(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
/// One pass of the liveness watcher: diff the in-memory session set against
|
/// One pass of the liveness watcher: diff the in-memory session set against
|
||||||
/// the tmux-reported one, and reap anything we still track but tmux has lost.
|
/// the tmux-reported one, and reap anything we still track but tmux has lost.
|
||||||
async fn run_liveness_pass(inner: &Arc<Inner>) {
|
async fn run_liveness_pass(inner: &Arc<Inner>) {
|
||||||
@@ -670,18 +839,79 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refresh each running session's blocked-work indicator (#83): what its
|
||||||
|
/// worktree holds right now that would block an unforced kill. Rides the
|
||||||
|
/// liveness tick on its own (slower) cadence — see
|
||||||
|
/// [`DEFAULT_BLOCKED_WORK_INTERVAL`] — because every probe is three git
|
||||||
|
/// subprocesses per session.
|
||||||
|
///
|
||||||
|
/// Sessions without a worktree are filtered out BEFORE any probe: they can
|
||||||
|
/// never block a kill, so their indicator must cost nothing at all. A failed
|
||||||
|
/// probe keeps the previous value rather than clearing it — a transient git
|
||||||
|
/// error must not read as "nothing blocks". Like the activity pass, this only
|
||||||
|
/// emits `SessionUpdated` when the value actually changed.
|
||||||
|
async fn run_blocked_work_pass(inner: &Arc<Inner>) {
|
||||||
|
let targets: Vec<(Uuid, (PathBuf, PathBuf))> = {
|
||||||
|
let sessions = inner.sessions.lock().await;
|
||||||
|
sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, r)| r.session.status == SessionStatus::Running)
|
||||||
|
.filter_map(|(id, r)| {
|
||||||
|
let wt = r.worktree_base_repo.clone().zip(r.worktree_path.clone())?;
|
||||||
|
Some((*id, wt))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
if targets.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (id, wt) in targets {
|
||||||
|
// The kill guard's own probe (#79), so the indicator can never
|
||||||
|
// disagree with what a kill attempt would find.
|
||||||
|
let breakdown = match probe_worktree(&wt).await {
|
||||||
|
Ok(breakdown) => breakdown,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(%id, error = ?e, "blocked-work: probe failed; keeping previous value");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let updated = {
|
||||||
|
let mut sessions = inner.sessions.lock().await;
|
||||||
|
let Some(rec) = sessions.get_mut(&id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if rec.session.blocked_work == Some(breakdown) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
rec.session.blocked_work = Some(breakdown);
|
||||||
|
Some(rec.session.clone())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(session) = updated {
|
||||||
|
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`]
|
/// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`]
|
||||||
/// and announce it, keeping everything else intact.
|
/// and settle its worktree per the natural-exit rule (#82).
|
||||||
///
|
///
|
||||||
/// The record, its [`PtyBridge`] and its sidecar all survive, so a client can
|
/// The record, its [`PtyBridge`] and its sidecar all survive, so a client can
|
||||||
/// still attach and read the replay buffer — the last thing a crashed agent
|
/// still attach and read the replay buffer — the last thing a crashed agent
|
||||||
/// printed. Artifacts are only released when the operator dismisses the session
|
/// printed. The worktree, though, is settled right away: a pristine tree is
|
||||||
/// via [`SessionManager::kill`].
|
/// removed, anything else is retained into the store with reason
|
||||||
|
/// `exited-with-unsaved-work` (#82) — nobody is waiting for an answer here,
|
||||||
|
/// so there is nothing to refuse. Once settled, the session drops its
|
||||||
|
/// worktree reference so a later dismissal neither re-probes nor retains
|
||||||
|
/// twice. When settling is impossible (probe or store failure) nothing is
|
||||||
|
/// destroyed: the tree stays attached to the dead record for a later forced
|
||||||
|
/// dismissal (#81).
|
||||||
///
|
///
|
||||||
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
|
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
|
||||||
/// adopts only what tmux still holds.
|
/// adopts only what tmux still holds.
|
||||||
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||||
let updated = {
|
let flipped = {
|
||||||
let mut sessions = inner.sessions.lock().await;
|
let mut sessions = inner.sessions.lock().await;
|
||||||
match sessions.get_mut(&id) {
|
match sessions.get_mut(&id) {
|
||||||
Some(record) if record.session.status == SessionStatus::Running => {
|
Some(record) if record.session.status == SessionStatus::Running => {
|
||||||
@@ -689,19 +919,114 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
|||||||
// Release anyone attached; the scrollback survives for a later
|
// Release anyone attached; the scrollback survives for a later
|
||||||
// attach.
|
// attach.
|
||||||
record.bridge.seal();
|
record.bridge.seal();
|
||||||
Some(record.session.clone())
|
let session = record.session.clone();
|
||||||
|
let worktree = record
|
||||||
|
.worktree_base_repo
|
||||||
|
.clone()
|
||||||
|
.zip(record.worktree_path.clone());
|
||||||
|
let branch = record.worktree_branch.clone();
|
||||||
|
Some((session, worktree, branch))
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let Some(session) = updated else {
|
let Some((mut session, worktree, branch)) = flipped else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
tracing::info!(%id, "session vanished from tmux — marking dead");
|
tracing::info!(%id, "session vanished from tmux — marking dead");
|
||||||
|
|
||||||
|
if let Some(wt) = &worktree {
|
||||||
|
if settle_worktree_on_exit(inner, id, wt, branch).await {
|
||||||
|
// The tree is settled (removed or retained); a stale "in the
|
||||||
|
// way" would describe something the session no longer holds.
|
||||||
|
session.blocked_work = None;
|
||||||
|
let mut sessions = inner.sessions.lock().await;
|
||||||
|
if let Some(record) = sessions.get_mut(&id) {
|
||||||
|
record.session.worktree = None;
|
||||||
|
record.session.blocked_work = None;
|
||||||
|
record.worktree_path = None;
|
||||||
|
record.worktree_base_repo = None;
|
||||||
|
record.worktree_branch = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _ = inner.sidecar.write(&session);
|
let _ = inner.sidecar.write(&session);
|
||||||
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply the exit rule (#82) to a vanished session's worktree: probe what
|
||||||
|
/// would be lost through the kill's own predicate, then remove a pristine
|
||||||
|
/// tree or retain anything else under `exited-with-unsaved-work` — recorded
|
||||||
|
/// before anything is torn down, exactly as the forced kill does (#81). A
|
||||||
|
/// removal that fails falls back to retaining, since whatever blocked it is
|
||||||
|
/// by definition something worth keeping.
|
||||||
|
///
|
||||||
|
/// Returns whether the worktree was fully accounted for. When it wasn't,
|
||||||
|
/// nothing was destroyed and the caller keeps the reference on the record.
|
||||||
|
async fn settle_worktree_on_exit(
|
||||||
|
inner: &Arc<Inner>,
|
||||||
|
id: Uuid,
|
||||||
|
(base_repo, path): &(PathBuf, PathBuf),
|
||||||
|
known_branch: Option<String>,
|
||||||
|
) -> bool {
|
||||||
|
let probe = match probe_worktree(&(base_repo.clone(), path.clone())).await {
|
||||||
|
Ok(probe) => probe,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
%id,
|
||||||
|
path = %path.display(),
|
||||||
|
error = ?e,
|
||||||
|
"natural exit: could not probe the worktree; leaving it untouched"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if probe.is_clear() {
|
||||||
|
match inner.worktree_mgr.remove(base_repo, path) {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!(%id, path = %path.display(), "natural exit: pristine worktree removed");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
%id,
|
||||||
|
path = %path.display(),
|
||||||
|
error = ?e,
|
||||||
|
"natural exit: removal failed; falling back to retention"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match record_retention(
|
||||||
|
inner,
|
||||||
|
&(base_repo.clone(), path.clone()),
|
||||||
|
known_branch,
|
||||||
|
RetainedReason::ExitedWithUnsavedWork,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(entry) => {
|
||||||
|
tracing::info!(
|
||||||
|
%id,
|
||||||
|
path = %path.display(),
|
||||||
|
entry = %entry.id,
|
||||||
|
"natural exit: worktree retained with unsaved work"
|
||||||
|
);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
%id,
|
||||||
|
path = %path.display(),
|
||||||
|
error = ?e,
|
||||||
|
"natural exit: could not record the retained worktree; leaving it untouched"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Tear down a session's artifacts after it has already been removed from the
|
/// Tear down a session's artifacts after it has already been removed from the
|
||||||
/// live map. Shared by [`SessionManager::kill`] and [`reap_dead_session`],
|
/// live map. Shared by [`SessionManager::kill`] and [`reap_dead_session`],
|
||||||
/// which previously hand-rolled the same five-step choreography and only
|
/// which previously hand-rolled the same five-step choreography and only
|
||||||
@@ -711,7 +1036,17 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
|||||||
/// gone from the authoritative in-memory map, so a stray FIFO or sidecar can't
|
/// gone from the authoritative in-memory map, so a stray FIFO or sidecar can't
|
||||||
/// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the
|
/// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the
|
||||||
/// liveness reaper (tmux already lost the session).
|
/// liveness reaper (tmux already lost the session).
|
||||||
async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kill_tmux: bool) {
|
///
|
||||||
|
/// `retain_worktree` is `true` for a forced kill (#81) that retained the
|
||||||
|
/// worktree instead of removing it: the store entry is already written, so
|
||||||
|
/// only the removal is skipped.
|
||||||
|
async fn cleanup_record(
|
||||||
|
inner: &Arc<Inner>,
|
||||||
|
id: Uuid,
|
||||||
|
record: SessionRecord,
|
||||||
|
kill_tmux: bool,
|
||||||
|
retain_worktree: bool,
|
||||||
|
) {
|
||||||
if kill_tmux {
|
if kill_tmux {
|
||||||
let tmux = inner.tmux.clone();
|
let tmux = inner.tmux.clone();
|
||||||
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
|
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
|
||||||
@@ -720,12 +1055,18 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
|
|||||||
if let Err(e) = inner.sidecar.delete(id) {
|
if let Err(e) = inner.sidecar.delete(id) {
|
||||||
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
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.
|
|
||||||
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
||||||
if let Err(e) = inner.worktree_mgr.remove(base, path) {
|
if retain_worktree {
|
||||||
|
tracing::info!(
|
||||||
|
%id,
|
||||||
|
path = %path.display(),
|
||||||
|
"forced kill: worktree retained in the store"
|
||||||
|
);
|
||||||
|
} else if let Err(e) = inner.worktree_mgr.remove(base, path) {
|
||||||
|
// 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.
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
%id,
|
%id,
|
||||||
path = %path.display(),
|
path = %path.display(),
|
||||||
|
|||||||
@@ -10,11 +10,24 @@ pub enum WorktreeError {
|
|||||||
Spawn(#[from] std::io::Error),
|
Spawn(#[from] std::io::Error),
|
||||||
#[error("git exited with code {code}: {stderr}")]
|
#[error("git exited with code {code}: {stderr}")]
|
||||||
NonZero { code: i32, stderr: String },
|
NonZero { code: i32, stderr: String },
|
||||||
/// 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)]
|
#[error("left {} on disk: {}", .path.display(), .reason)]
|
||||||
Guarded { path: PathBuf, reason: String },
|
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
|
/// Runs `git worktree add` rooted at `base_repo`. By default worktrees land
|
||||||
/// inside the repository itself at `<repo>/worktrees/<branch>` (#56) so they
|
/// inside the repository itself at `<repo>/worktrees/<branch>` (#56) so they
|
||||||
/// are findable from the repo; an operator-set root overrides that.
|
/// are findable from the repo; an operator-set root overrides that.
|
||||||
@@ -126,12 +139,13 @@ impl WorktreeManager {
|
|||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `git -C <base_repo> worktree remove <path>` — but only when the
|
/// `git -C <base_repo> worktree remove <path>`.
|
||||||
/// worktree holds nothing unique (#56). A worktree with uncommitted
|
///
|
||||||
/// changes, or with commits reachable from no other ref, is refused and
|
/// Unconditional (#79): the guard moved off the worktree and onto the
|
||||||
/// left on disk so the operator can rescue it; cleanup is unconditional,
|
/// kill, so by the time removal runs the caller has already chosen to
|
||||||
/// which makes this guard the only thing standing between a stray kill
|
/// destroy the tree — the kill only gets here after
|
||||||
/// and destroyed work.
|
/// [`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> {
|
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
|
||||||
// Gone from disk already (operator `rm -rf`, or a create that failed
|
// 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
|
// half-way). Probing it with git would fail and be misread as unsaved
|
||||||
@@ -147,12 +161,6 @@ impl WorktreeManager {
|
|||||||
reason: format!("holds a nested worktree at {}", inner.display()),
|
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")
|
let out = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(base_repo)
|
.arg(base_repo)
|
||||||
@@ -251,24 +259,6 @@ fn branch_relpath(branch: &str) -> Option<PathBuf> {
|
|||||||
Some(candidate.to_path_buf())
|
Some(candidate.to_path_buf())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `Some(reason)` when deleting `path` would destroy work.
|
|
||||||
///
|
|
||||||
/// 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()));
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
|
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
|
||||||
/// no longer exists.
|
/// no longer exists.
|
||||||
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
||||||
@@ -280,22 +270,6 @@ fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
|||||||
check(out)
|
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> {
|
fn check(out: Output) -> Result<(), WorktreeError> {
|
||||||
if out.status.success() {
|
if out.status.success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
//! Read-side git probes for retained worktrees (#80): what is currently in
|
//! The one git probe for what a worktree holds that would be lost, plus
|
||||||
//! the way, and guard-free reclaim.
|
//! guard-free reclaim (#79, #80).
|
||||||
//!
|
//!
|
||||||
//! The kill-refusal breakdown lives in `worktree.rs`, which this module must
|
//! [`in_the_way`] is the single predicate behind all three paths: the kill
|
||||||
//! not touch (#77 owns it) and must not consult for reclaim — reclaim is the
|
//! refusal (#79), the natural-exit classifier (#82), and the retained list
|
||||||
//! deliberate destructive act and never asks the guard. Issue #79 will factor
|
//! (#80). It lives here rather than in `worktree.rs` so that none of them can
|
||||||
//! the shared git plumbing out of `worktree.rs`; until then these probes are
|
//! answer the question differently — #79 and #80 were built in parallel and
|
||||||
//! duplicated here on purpose.
|
//! 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 std::path::Path;
|
||||||
|
|
||||||
@@ -25,9 +28,11 @@ pub enum ProbeError {
|
|||||||
/// `target/`, and counting those would put every tree permanently in the way.
|
/// `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
|
/// 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.
|
/// nothing left to lose, and the store's own pruning drops the entry.
|
||||||
pub fn in_the_way(path: &Path, base_repo: &Path, branch: &str) -> Result<InTheWay, ProbeError> {
|
pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
|
||||||
let mut breakdown = InTheWay::default();
|
let mut breakdown = InTheWay::default();
|
||||||
if !path.exists() {
|
// `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);
|
return Ok(breakdown);
|
||||||
}
|
}
|
||||||
let status = run_git(
|
let status = run_git(
|
||||||
@@ -46,19 +51,15 @@ pub fn in_the_way(path: &Path, base_repo: &Path, branch: &str) -> Result<InTheWa
|
|||||||
breakdown.uncommitted_files += 1;
|
breakdown.uncommitted_files += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Commits reachable from the branch but from no remote-tracking ref. A
|
// Resolved inside the worktree so a detached HEAD still has a starting
|
||||||
// branch pushed anywhere counts as pushed; there is no "which remote"
|
// point; asking the base repo for `refs/heads/<branch>` fails there.
|
||||||
// question to get wrong.
|
let head = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?;
|
||||||
let unpushed = run_git(
|
let branch = String::from_utf8_lossy(&head.stdout).trim().to_string();
|
||||||
base_repo,
|
let tip: &str = if branch == "HEAD" { "HEAD" } else { &branch };
|
||||||
[
|
// Commits reachable from the tip but from no remote-tracking ref. A branch
|
||||||
"rev-list",
|
// pushed anywhere counts as pushed; there is no "which remote" question to
|
||||||
"--count",
|
// get wrong.
|
||||||
&format!("refs/heads/{branch}"),
|
let unpushed = run_git(path, ["rev-list", "--count", tip, "--not", "--remotes"])?;
|
||||||
"--not",
|
|
||||||
"--glob=refs/remotes",
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
breakdown.unpushed_commits = String::from_utf8_lossy(&unpushed.stdout)
|
breakdown.unpushed_commits = String::from_utf8_lossy(&unpushed.stdout)
|
||||||
.trim()
|
.trim()
|
||||||
.parse()
|
.parse()
|
||||||
@@ -89,6 +90,14 @@ pub fn reclaim(base_repo: &Path, path: &Path) -> Result<(), ProbeError> {
|
|||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Branch checked out at `path`. Sessions adopted from a sidecar carry no
|
||||||
|
/// branch on record, so a forced kill (#81) asks the tree itself when it
|
||||||
|
/// needs one for the retained store. `None` when git cannot answer.
|
||||||
|
pub fn current_branch(path: &Path) -> Option<String> {
|
||||||
|
let out = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
|
||||||
|
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn run_git<I, S>(dir: &Path, args: I) -> Result<std::process::Output, ProbeError>
|
fn run_git<I, S>(dir: &Path, args: I) -> Result<std::process::Output, ProbeError>
|
||||||
where
|
where
|
||||||
I: IntoIterator<Item = S>,
|
I: IntoIterator<Item = S>,
|
||||||
@@ -182,7 +191,7 @@ mod tests {
|
|||||||
fn a_clean_pushed_worktree_has_nothing_in_the_way() {
|
fn a_clean_pushed_worktree_has_nothing_in_the_way() {
|
||||||
let r = repo_with_worktree();
|
let r = repo_with_worktree();
|
||||||
git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]);
|
git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]);
|
||||||
let breakdown = in_the_way(&r.wt(), &r.repo, "feature").expect("probe");
|
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||||
assert_eq!(breakdown, InTheWay::default());
|
assert_eq!(breakdown, InTheWay::default());
|
||||||
assert!(breakdown.is_clear());
|
assert!(breakdown.is_clear());
|
||||||
}
|
}
|
||||||
@@ -193,7 +202,7 @@ mod tests {
|
|||||||
std::fs::write(r.wt().join("README"), b"edited\n").unwrap();
|
std::fs::write(r.wt().join("README"), b"edited\n").unwrap();
|
||||||
std::fs::write(r.wt().join("scratch.txt"), b"untracked\n").unwrap();
|
std::fs::write(r.wt().join("scratch.txt"), b"untracked\n").unwrap();
|
||||||
|
|
||||||
let dirty = in_the_way(&r.wt(), &r.repo, "feature").expect("probe");
|
let dirty = in_the_way(&r.wt()).expect("probe");
|
||||||
assert_eq!(dirty.uncommitted_files, 1);
|
assert_eq!(dirty.uncommitted_files, 1);
|
||||||
assert_eq!(dirty.untracked_files, 1);
|
assert_eq!(dirty.untracked_files, 1);
|
||||||
assert_eq!(dirty.unpushed_commits, 0);
|
assert_eq!(dirty.unpushed_commits, 0);
|
||||||
@@ -203,7 +212,7 @@ mod tests {
|
|||||||
git(&r.wt(), &["add", "README"]);
|
git(&r.wt(), &["add", "README"]);
|
||||||
git(&r.wt(), &["commit", "-qm", "wip"]);
|
git(&r.wt(), &["commit", "-qm", "wip"]);
|
||||||
|
|
||||||
let breakdown = in_the_way(&r.wt(), &r.repo, "feature").expect("probe");
|
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||||
assert_eq!(breakdown.uncommitted_files, 0);
|
assert_eq!(breakdown.uncommitted_files, 0);
|
||||||
assert_eq!(breakdown.untracked_files, 1);
|
assert_eq!(breakdown.untracked_files, 1);
|
||||||
assert_eq!(breakdown.unpushed_commits, 1);
|
assert_eq!(breakdown.unpushed_commits, 1);
|
||||||
@@ -213,7 +222,7 @@ mod tests {
|
|||||||
fn tracked_edits_count_as_uncommitted() {
|
fn tracked_edits_count_as_uncommitted() {
|
||||||
let r = repo_with_worktree();
|
let r = repo_with_worktree();
|
||||||
std::fs::write(r.wt().join("README"), b"edited\n").unwrap();
|
std::fs::write(r.wt().join("README"), b"edited\n").unwrap();
|
||||||
let breakdown = in_the_way(&r.wt(), &r.repo, "feature").expect("probe");
|
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||||
assert_eq!(breakdown.uncommitted_files, 1);
|
assert_eq!(breakdown.uncommitted_files, 1);
|
||||||
assert_eq!(breakdown.untracked_files, 0);
|
assert_eq!(breakdown.untracked_files, 0);
|
||||||
}
|
}
|
||||||
@@ -224,17 +233,17 @@ mod tests {
|
|||||||
std::fs::write(r.wt().join("new.txt"), b"x\n").unwrap();
|
std::fs::write(r.wt().join("new.txt"), b"x\n").unwrap();
|
||||||
r.commit_in_wt("wip");
|
r.commit_in_wt("wip");
|
||||||
|
|
||||||
let before = in_the_way(&r.wt(), &r.repo, "feature").expect("probe");
|
let before = in_the_way(&r.wt()).expect("probe");
|
||||||
assert_eq!(before.unpushed_commits, 1);
|
assert_eq!(before.unpushed_commits, 1);
|
||||||
|
|
||||||
git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]);
|
git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]);
|
||||||
// Same stored entry, different tree: the breakdown must move with it.
|
// Same stored entry, different tree: the breakdown must move with it.
|
||||||
let after = in_the_way(&r.wt(), &r.repo, "feature").expect("probe");
|
let after = in_the_way(&r.wt()).expect("probe");
|
||||||
assert_eq!(after.unpushed_commits, 0);
|
assert_eq!(after.unpushed_commits, 0);
|
||||||
assert_eq!(after.untracked_files, 0);
|
assert_eq!(after.untracked_files, 0);
|
||||||
|
|
||||||
std::fs::write(r.wt().join("loose.txt"), b"x\n").unwrap();
|
std::fs::write(r.wt().join("loose.txt"), b"x\n").unwrap();
|
||||||
let dirty = in_the_way(&r.wt(), &r.repo, "feature").expect("probe");
|
let dirty = in_the_way(&r.wt()).expect("probe");
|
||||||
assert_eq!(dirty.untracked_files, 1);
|
assert_eq!(dirty.untracked_files, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +254,7 @@ mod tests {
|
|||||||
std::fs::create_dir_all(r.wt().join("target")).unwrap();
|
std::fs::create_dir_all(r.wt().join("target")).unwrap();
|
||||||
std::fs::write(r.wt().join("target/artifact"), b"x").unwrap();
|
std::fs::write(r.wt().join("target/artifact"), b"x").unwrap();
|
||||||
|
|
||||||
let breakdown = in_the_way(&r.wt(), &r.repo, "feature").expect("probe");
|
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
breakdown,
|
breakdown,
|
||||||
InTheWay {
|
InTheWay {
|
||||||
@@ -260,7 +269,7 @@ mod tests {
|
|||||||
fn a_missing_directory_reports_nothing_in_the_way() {
|
fn a_missing_directory_reports_nothing_in_the_way() {
|
||||||
let r = repo_with_worktree();
|
let r = repo_with_worktree();
|
||||||
let gone = r.root.path().join("nowhere");
|
let gone = r.root.path().join("nowhere");
|
||||||
let breakdown = in_the_way(&gone, &r.repo, "feature").expect("probe");
|
let breakdown = in_the_way(&gone).expect("probe");
|
||||||
assert!(breakdown.is_clear());
|
assert!(breakdown.is_clear());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ async fn pty_round_trip_through_real_tmux() {
|
|||||||
"marker not seen in {} bytes",
|
"marker not seen in {} bytes",
|
||||||
got.len()
|
got.len()
|
||||||
);
|
);
|
||||||
let _ = h.manager.kill(id).await;
|
let _ = h.manager.kill(id, false).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -170,7 +170,7 @@ async fn multi_attach_broadcasts_to_every_client() {
|
|||||||
"client B missed marker"
|
"client B missed marker"
|
||||||
);
|
);
|
||||||
|
|
||||||
let _ = h.manager.kill(id).await;
|
let _ = h.manager.kill(id, false).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -219,7 +219,7 @@ async fn sends_close_frame_when_session_is_killed() {
|
|||||||
// give the broadcast subscriber a moment to register before killing.
|
// give the broadcast subscriber a moment to register before killing.
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
h.manager.kill(id).await.unwrap();
|
h.manager.kill(id, false).await.unwrap();
|
||||||
|
|
||||||
let mut saw_close = false;
|
let mut saw_close = false;
|
||||||
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||||||
@@ -267,7 +267,7 @@ async fn ping_text_frame_gets_pong() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(saw_pong, "no Pong frame received");
|
assert!(saw_pong, "no Pong frame received");
|
||||||
let _ = h.manager.kill(id).await;
|
let _ = h.manager.kill(id, false).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
//! The performance contract of #83: the blocked-work pass must cost NO git
|
||||||
|
//! probe at all for a session without a worktree, while a session with one is
|
||||||
|
//! still probed.
|
||||||
|
//!
|
||||||
|
//! `git` itself is shimmed through PATH with a wrapper that logs every
|
||||||
|
//! invocation before exec'ing the real binary, so the assertion counts actual
|
||||||
|
//! probes rather than inferring them from state. This lives in its own test
|
||||||
|
//! binary because it mutates the process environment.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
|
||||||
|
use maestro_protocol::{CreateSessionRequest, InTheWay, WorktreeSpec};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
|
||||||
|
CreateSessionRequest {
|
||||||
|
cwd: cwd.to_path_buf(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: Some(WorktreeSpec {
|
||||||
|
base_repo: repo.to_path_buf(),
|
||||||
|
branch: branch.into(),
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_session_without_a_worktree_costs_no_probe() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let log = TempDir::new().unwrap();
|
||||||
|
let log_path = log.path().join("git-calls.log");
|
||||||
|
|
||||||
|
// Session A owns a real (pristine, pushed) worktree; session B has none.
|
||||||
|
let repo_a = TempDir::new().unwrap();
|
||||||
|
init_repo(repo_a.path());
|
||||||
|
init_bare_repo(&repo_a.path().join("origin.git"));
|
||||||
|
push_all_to_origin(repo_a.path(), &repo_a.path().join("origin.git"));
|
||||||
|
fx.manager
|
||||||
|
.create(request(repo_a.path(), repo_a.path(), "feat/probed"))
|
||||||
|
.await
|
||||||
|
.expect("create A");
|
||||||
|
|
||||||
|
let cwd_b = TempDir::new().unwrap();
|
||||||
|
let mut req_b = request(cwd_b.path(), cwd_b.path(), "feat/unused");
|
||||||
|
req_b.worktree = None;
|
||||||
|
fx.manager.create(req_b).await.expect("create B");
|
||||||
|
|
||||||
|
// Shim `git` AFTER setup so only watcher-driven probes get logged.
|
||||||
|
let real_git = String::from_utf8(
|
||||||
|
Command::new("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg("command -v git")
|
||||||
|
.output()
|
||||||
|
.expect("locate git")
|
||||||
|
.stdout,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
let shim_dir = TempDir::new().unwrap();
|
||||||
|
let script = format!(
|
||||||
|
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> {}\nexec {} \"$@\"\n",
|
||||||
|
log_path.display(),
|
||||||
|
real_git,
|
||||||
|
);
|
||||||
|
std::fs::write(shim_dir.path().join("git"), script).unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(
|
||||||
|
shim_dir.path().join("git"),
|
||||||
|
std::fs::Permissions::from_mode(0o755),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let old_path = std::env::var("PATH").unwrap_or_default();
|
||||||
|
std::env::set_var(
|
||||||
|
"PATH",
|
||||||
|
format!("{}:{}", shim_dir.path().display(), old_path),
|
||||||
|
);
|
||||||
|
|
||||||
|
fx.manager.start_liveness_watcher_with_blocked_work(
|
||||||
|
Duration::from_millis(50),
|
||||||
|
Duration::from_millis(100),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The pass demonstrably runs and probed A's tree...
|
||||||
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
|
fx.manager
|
||||||
|
.snapshot()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.worktree.is_some() && s.blocked_work == Some(InTheWay::default()))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
std::env::set_var("PATH", &old_path);
|
||||||
|
|
||||||
|
let calls = std::fs::read_to_string(&log_path).unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
!calls.is_empty(),
|
||||||
|
"the pass never probed anything — the shim saw no git call at all"
|
||||||
|
);
|
||||||
|
// ...while B — no worktree, so nothing to probe — was never touched.
|
||||||
|
assert!(
|
||||||
|
!calls.contains(cwd_b.path().to_str().unwrap()),
|
||||||
|
"a worktree-less session was probed:\n{calls}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.
|
/// Async polling sibling of [`wait_for`] for tokio tests.
|
||||||
pub async fn wait_for_async<F, Fut>(timeout: Duration, mut cond: F)
|
pub async fn wait_for_async<F, Fut>(timeout: Duration, mut cond: F)
|
||||||
where
|
where
|
||||||
@@ -144,6 +176,11 @@ impl DaemonFixture {
|
|||||||
manager =
|
manager =
|
||||||
manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days));
|
manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days));
|
||||||
}
|
}
|
||||||
|
// Same wiring as production: the manager records retained worktrees
|
||||||
|
// into the same dir the API reads back from (#78/#81).
|
||||||
|
let retained =
|
||||||
|
maestro_daemon::retained_store::RetainedStore::new(Self::retained_path(&state_dir));
|
||||||
|
manager = manager.with_retained_store(retained.clone());
|
||||||
Self {
|
Self {
|
||||||
tmux,
|
tmux,
|
||||||
manager,
|
manager,
|
||||||
@@ -155,6 +192,10 @@ impl DaemonFixture {
|
|||||||
state_dir.path().join("spawn-history.jsonl")
|
state_dir.path().join("spawn-history.jsonl")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn retained_path(state_dir: &TempDir) -> PathBuf {
|
||||||
|
state_dir.path().join("retained-worktrees")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn spawn_history_path(&self) -> PathBuf {
|
pub fn spawn_history_path(&self) -> PathBuf {
|
||||||
Self::history_path(&self.state_dir)
|
Self::history_path(&self.state_dir)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use std::time::Duration;
|
|||||||
use common::DaemonFixture;
|
use common::DaemonFixture;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_daemon::local_api;
|
use maestro_daemon::local_api;
|
||||||
use maestro_protocol::{CreateSessionRequest, Session};
|
use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||||
@@ -25,6 +25,13 @@ struct Harness {
|
|||||||
_fx: DaemonFixture,
|
_fx: DaemonFixture,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Harness {
|
||||||
|
/// Store over the same dir the manager records into.
|
||||||
|
fn fx_retained(&self) -> maestro_daemon::retained_store::RetainedStore {
|
||||||
|
self._fx.retained_store()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn spawn(hostname: &str) -> Harness {
|
async fn spawn(hostname: &str) -> Harness {
|
||||||
spawn_with(
|
spawn_with(
|
||||||
hostname,
|
hostname,
|
||||||
@@ -339,6 +346,107 @@ async fn delete_owned_session_kills_it() {
|
|||||||
assert!(!h.manager.has(session.id).await);
|
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(),
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `?force=true` on the wire (#81): the refusal is overridden, the session
|
||||||
|
/// ends, and the answer is 200 with the retained worktree named — path,
|
||||||
|
/// branch, reason — while the directory survives on disk.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_with_force_ends_the_session_and_answers_what_was_retained() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let repo = tempfile::TempDir::new().unwrap();
|
||||||
|
common::init_repo(repo.path());
|
||||||
|
let wt_path = repo
|
||||||
|
.path()
|
||||||
|
.join("worktrees")
|
||||||
|
.join("feat")
|
||||||
|
.join("guarded")
|
||||||
|
.to_path_buf();
|
||||||
|
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(),
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||||
|
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.delete(url(h.addr, &format!("/api/sessions/{}", session.id)))
|
||||||
|
.query(&[("force", "true")])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), reqwest::StatusCode::OK);
|
||||||
|
let body: maestro_protocol::KillOutcome = resp.json().await.unwrap();
|
||||||
|
let retained = body.retained.expect("force names what was kept");
|
||||||
|
assert_eq!(retained.path, wt_path);
|
||||||
|
assert_eq!(retained.branch, "feat/guarded");
|
||||||
|
assert_eq!(
|
||||||
|
retained.reason,
|
||||||
|
maestro_protocol::RetainedReason::ForceKilled
|
||||||
|
);
|
||||||
|
|
||||||
|
// The tree would have blocked; force must not have removed it.
|
||||||
|
assert!(wt_path.exists(), "a forced kill never removes the worktree");
|
||||||
|
// And the store entry exists, so reclaim can find it (#80).
|
||||||
|
let all = h.fx_retained().load_all().unwrap();
|
||||||
|
assert_eq!(all.len(), 1);
|
||||||
|
assert_eq!(all[0].id, retained.id);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn attach_ws_streams_pty_bytes() {
|
async fn attach_ws_streams_pty_bytes() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
@@ -429,7 +537,7 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
|
|||||||
other => panic!("expected SessionAdded, got {other:?}"),
|
other => panic!("expected SessionAdded, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
h.manager.kill(created.id).await.expect("kill");
|
h.manager.kill(created.id, false).await.expect("kill");
|
||||||
|
|
||||||
// Activity ticks may interleave; the removal is what we are waiting for.
|
// Activity ticks may interleave; the removal is what we are waiting for.
|
||||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ async fn killing_a_dead_session_dismisses_it_without_touching_tmux() {
|
|||||||
fx.manager.start_liveness_watcher(Duration::from_millis(25));
|
fx.manager.start_liveness_watcher(Duration::from_millis(25));
|
||||||
wait_for_status(&fx.manager, s.id, SessionStatus::Dead).await;
|
wait_for_status(&fx.manager, s.id, SessionStatus::Dead).await;
|
||||||
|
|
||||||
fx.manager.kill(s.id).await.expect("dismiss");
|
fx.manager.kill(s.id, false).await.expect("dismiss");
|
||||||
|
|
||||||
assert!(fx.manager.snapshot().await.is_empty());
|
assert!(fx.manager.snapshot().await.is_empty());
|
||||||
assert!(
|
assert!(
|
||||||
@@ -371,7 +371,7 @@ async fn killing_an_unknown_session_is_reported_as_not_found() {
|
|||||||
let fx = Fx::new();
|
let fx = Fx::new();
|
||||||
let err = fx
|
let err = fx
|
||||||
.manager
|
.manager
|
||||||
.kill(uuid::Uuid::new_v4())
|
.kill(uuid::Uuid::new_v4(), false)
|
||||||
.await
|
.await
|
||||||
.expect_err("should fail");
|
.expect_err("should fail");
|
||||||
assert_eq!(err.to_error_info().code, "not_found");
|
assert_eq!(err.to_error_info().code, "not_found");
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ mod common;
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
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, TmuxFixture,
|
||||||
|
};
|
||||||
|
use maestro_daemon::SessionError;
|
||||||
use maestro_protocol::CreateSessionRequest;
|
use maestro_protocol::CreateSessionRequest;
|
||||||
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
|
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
@@ -42,7 +46,7 @@ async fn create_then_kill_round_trip() {
|
|||||||
// FIFO should also exist
|
// FIFO should also exist
|
||||||
assert!(fx.fifo_dir().join(format!("{}.fifo", session.id)).exists());
|
assert!(fx.fifo_dir().join(format!("{}.fifo", session.id)).exists());
|
||||||
|
|
||||||
fx.manager.kill(session.id).await.expect("kill");
|
fx.manager.kill(session.id, false).await.expect("kill");
|
||||||
assert!(!fx.manager.has(session.id).await);
|
assert!(!fx.manager.has(session.id).await);
|
||||||
assert!(!fx
|
assert!(!fx
|
||||||
.sidecar_dir()
|
.sidecar_dir()
|
||||||
@@ -77,7 +81,7 @@ async fn create_appends_to_spawn_history() {
|
|||||||
assert_eq!(recents[0].cwd, cwd.path());
|
assert_eq!(recents[0].cwd, cwd.path());
|
||||||
assert_eq!(recents[0].last_command, "sleep 30");
|
assert_eq!(recents[0].last_command, "sleep 30");
|
||||||
|
|
||||||
fx.manager.kill(session.id).await.expect("kill");
|
fx.manager.kill(session.id, false).await.expect("kill");
|
||||||
// History is durable — it survives the kill that deletes the sidecar.
|
// History is durable — it survives the kill that deletes the sidecar.
|
||||||
assert_eq!(fx.manager.recent_dirs().len(), 1);
|
assert_eq!(fx.manager.recent_dirs().len(), 1);
|
||||||
}
|
}
|
||||||
@@ -183,7 +187,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
|
|||||||
"first-line marker never reached the replay buffer — pipe-pane raced past the child's initial output",
|
"first-line marker never reached the replay buffer — pipe-pane raced past the child's initial output",
|
||||||
);
|
);
|
||||||
|
|
||||||
fx.manager.kill(s.id).await.unwrap();
|
fx.manager.kill(s.id, false).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression for NAP-228: after `recover`, PTY output must reach new
|
/// Regression for NAP-228: after `recover`, PTY output must reach new
|
||||||
@@ -249,7 +253,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
|||||||
String::from_utf8_lossy(&buf),
|
String::from_utf8_lossy(&buf),
|
||||||
);
|
);
|
||||||
|
|
||||||
revived.kill(s.id).await.unwrap();
|
revived.kill(s.id, false).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -298,7 +302,7 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
|||||||
assert!(buf_a.windows(want.len()).any(|w| w == want), "A missed");
|
assert!(buf_a.windows(want.len()).any(|w| w == want), "A missed");
|
||||||
assert!(buf_b.windows(want.len()).any(|w| w == want), "B missed");
|
assert!(buf_b.windows(want.len()).any(|w| w == want), "B missed");
|
||||||
|
|
||||||
fx.manager.kill(s.id).await.unwrap();
|
fx.manager.kill(s.id, false).await.unwrap();
|
||||||
let _ = (cwd, PathBuf::from("/"));
|
let _ = (cwd, PathBuf::from("/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,14 +361,76 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Dismissing it is what finally releases the artifacts.
|
// Dismissing it is what finally releases the artifacts.
|
||||||
fx.manager.kill(s.id).await.expect("dismiss");
|
fx.manager.kill(s.id, false).await.expect("dismiss");
|
||||||
assert!(!fx.manager.has(s.id).await);
|
assert!(!fx.manager.has(s.id).await);
|
||||||
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
||||||
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
|
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cleanup is unconditional (#56): even a spec that says "don't clean up"
|
/// #83: the watcher refreshes `Session.blocked_work` on its own cadence —
|
||||||
/// loses its worktree on kill — the guard, not the flag, protects work.
|
/// first "nothing blocks" for a pristine tree, then the breakdown once the
|
||||||
|
/// tree picks up unsaved work, so clients can warn before the kill attempt.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn watcher_refreshes_the_blocked_work_indicator() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let repo = TempDir::new().unwrap();
|
||||||
|
init_repo(repo.path());
|
||||||
|
let wt_path = repo.path().join("worktrees").join("feat").join("indicator");
|
||||||
|
|
||||||
|
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/indicator".into(),
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
|
||||||
|
// Pushed and clean: nothing in the way, and the indicator must say so.
|
||||||
|
let origin = TempDir::new().unwrap();
|
||||||
|
init_bare_repo(origin.path());
|
||||||
|
push_all_to_origin(repo.path(), origin.path());
|
||||||
|
|
||||||
|
fx.manager.start_liveness_watcher_with_blocked_work(
|
||||||
|
Duration::from_millis(50),
|
||||||
|
Duration::from_millis(100),
|
||||||
|
);
|
||||||
|
|
||||||
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
|
fx.manager.snapshot().await[0].blocked_work == Some(maestro_protocol::InTheWay::default())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Unsaved work appears; the next pass must surface it.
|
||||||
|
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||||
|
std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap();
|
||||||
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
|
matches!(
|
||||||
|
&fx.manager.snapshot().await[0].blocked_work,
|
||||||
|
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// A steady state must not re-emit every tick (#83 change-detection).
|
||||||
|
let mut events = fx.manager.events();
|
||||||
|
while events.try_recv().is_ok() {}
|
||||||
|
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||||
|
assert!(
|
||||||
|
!matches!(events.try_recv(), Ok(CatalogEvent::SessionUpdated { .. })),
|
||||||
|
"an unchanged worktree must not re-emit SessionUpdated"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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]
|
#[tokio::test]
|
||||||
async fn kill_removes_worktree_even_when_spec_says_keep() {
|
async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
@@ -382,7 +448,6 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
|||||||
worktree: Some(WorktreeSpec {
|
worktree: Some(WorktreeSpec {
|
||||||
base_repo: repo.path().to_path_buf(),
|
base_repo: repo.path().to_path_buf(),
|
||||||
branch: "feat/keep".into(),
|
branch: "feat/keep".into(),
|
||||||
cleanup_on_kill: false,
|
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -390,14 +455,21 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
|||||||
.expect("create");
|
.expect("create");
|
||||||
assert!(wt_path.exists());
|
assert!(wt_path.exists());
|
||||||
|
|
||||||
fx.manager.kill(s.id).await.expect("kill");
|
// 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, false).await.expect("kill");
|
||||||
assert!(!wt_path.exists(), "worktree must be removed on kill");
|
assert!(!wt_path.exists(), "worktree must be removed on kill");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The guard (#56): a worktree holding uncommitted changes survives the kill,
|
/// The kill guard (#79): a worktree holding uncommitted changes refuses the
|
||||||
/// left on disk where the operator can find it.
|
/// 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]
|
#[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 fx = DaemonFixture::new();
|
||||||
let repo = TempDir::new().unwrap();
|
let repo = TempDir::new().unwrap();
|
||||||
init_repo(repo.path());
|
init_repo(repo.path());
|
||||||
@@ -413,7 +485,120 @@ async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
|
|||||||
worktree: Some(WorktreeSpec {
|
worktree: Some(WorktreeSpec {
|
||||||
base_repo: repo.path().to_path_buf(),
|
base_repo: repo.path().to_path_buf(),
|
||||||
branch: "feat/guarded".into(),
|
branch: "feat/guarded".into(),
|
||||||
cleanup_on_kill: true,
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.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();
|
||||||
|
|
||||||
|
let err = fx
|
||||||
|
.manager
|
||||||
|
.kill(s.id, false)
|
||||||
|
.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!(
|
||||||
|
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, false)
|
||||||
|
.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(),
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
|
||||||
|
let err = fx
|
||||||
|
.manager
|
||||||
|
.kill(s.id, false)
|
||||||
|
.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, false)
|
||||||
|
.await
|
||||||
|
.expect("pushed branch kills");
|
||||||
|
assert!(!wt_path.exists());
|
||||||
|
assert!(!fx.manager.has(s.id).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--force` overrides the refusal and nothing else (#81): the session ends,
|
||||||
|
/// but the guarded worktree is RETAINED — never removed — and enters the
|
||||||
|
/// retained store with reason `force-killed`. The outcome names what was
|
||||||
|
/// kept, so a client can say where the work went.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn force_killing_a_guarded_worktree_retains_it_in_the_store() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let repo = TempDir::new().unwrap();
|
||||||
|
init_repo(repo.path());
|
||||||
|
let wt_path = repo.path().join("worktrees").join("feat").join("forced");
|
||||||
|
|
||||||
|
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/forced".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -421,12 +606,320 @@ async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
|
|||||||
.expect("create");
|
.expect("create");
|
||||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||||
|
|
||||||
fx.manager.kill(s.id).await.expect("kill");
|
// Without force the same kill is refused (#79).
|
||||||
|
assert!(fx.manager.kill(s.id, false).await.is_err());
|
||||||
|
|
||||||
|
let outcome = fx
|
||||||
|
.manager
|
||||||
|
.kill(s.id, true)
|
||||||
|
.await
|
||||||
|
.expect("force overrides the refusal");
|
||||||
|
|
||||||
|
// The tree would have blocked, so force must NOT have removed it...
|
||||||
assert!(
|
assert!(
|
||||||
wt_path.exists(),
|
wt_path.exists(),
|
||||||
"dirty worktree must be left on disk, not destroyed"
|
"a forced kill never removes a tree that would have blocked"
|
||||||
|
);
|
||||||
|
// ...but the session itself still ends.
|
||||||
|
assert!(!fx.manager.has(s.id).await);
|
||||||
|
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
||||||
|
|
||||||
|
let retained = outcome.retained.expect("the outcome names what was kept");
|
||||||
|
assert_eq!(retained.path, wt_path);
|
||||||
|
assert_eq!(retained.branch, "feat/forced");
|
||||||
|
assert_eq!(
|
||||||
|
retained.reason,
|
||||||
|
maestro_protocol::RetainedReason::ForceKilled
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
retained.in_the_way.uncommitted_files >= 1,
|
||||||
|
"the outcome carries what blocked the unforced kill"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The store holds the same entry, so reclaim can find it later (#80).
|
||||||
|
let all = fx.retained_store().load_all().expect("load_all");
|
||||||
|
assert_eq!(all.len(), 1);
|
||||||
|
assert_eq!(all[0].id, retained.id);
|
||||||
|
assert_eq!(all[0].path, wt_path);
|
||||||
|
assert_eq!(all[0].branch, "feat/forced");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force overrides the *refusal*, not ordinary cleanup (#81): a tree whose
|
||||||
|
/// guard is clear never needed the override, so a forced kill removes it as
|
||||||
|
/// usual and retains nothing.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let repo = TempDir::new().unwrap();
|
||||||
|
init_repo(repo.path());
|
||||||
|
let wt_path = repo.path().join("worktrees").join("feat").join("clear");
|
||||||
|
|
||||||
|
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/clear".into(),
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
|
||||||
|
let origin = TempDir::new().unwrap();
|
||||||
|
init_bare_repo(origin.path());
|
||||||
|
push_all_to_origin(repo.path(), origin.path());
|
||||||
|
|
||||||
|
let outcome = fx.manager.kill(s.id, true).await.expect("kill");
|
||||||
|
assert!(!wt_path.exists(), "a clear tree needs no override");
|
||||||
|
assert_eq!(outcome.retained, None);
|
||||||
|
assert!(fx.retained_store().load_all().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(),
|
||||||
|
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, false)
|
||||||
|
.await
|
||||||
|
.expect("ignored files never block");
|
||||||
|
assert!(!wt_path.exists());
|
||||||
|
assert!(!fx.manager.has(s.id).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Natural exit (#82): a session that ends on its own never reaches the kill
|
||||||
|
/// path, so the watcher applies the kill's rule itself. A worktree holding
|
||||||
|
/// unsaved work is RETAINED into the store with reason
|
||||||
|
/// `exited-with-unsaved-work`; the session stays in the catalog marked dead
|
||||||
|
/// until dismissed — and dismissing it afterwards neither refuses nor
|
||||||
|
/// retains a second time.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let repo = TempDir::new().unwrap();
|
||||||
|
init_repo(repo.path());
|
||||||
|
let wt_path = repo.path().join("worktrees").join("feat").join("exited");
|
||||||
|
|
||||||
|
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/exited".into(),
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||||
|
|
||||||
|
// The command finishes / the operator types exit: tmux loses the session.
|
||||||
|
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
|
||||||
|
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
||||||
|
wait_for_async(Duration::from_secs(3), || async {
|
||||||
|
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// The tree would have blocked a kill, so the exit kept it...
|
||||||
|
assert!(
|
||||||
|
wt_path.exists(),
|
||||||
|
"a dirty worktree must be retained on natural exit"
|
||||||
|
);
|
||||||
|
// ...in the store, under the exit reason.
|
||||||
|
let all = fx.retained_store().load_all().expect("load_all");
|
||||||
|
assert_eq!(all.len(), 1);
|
||||||
|
assert_eq!(all[0].path, wt_path);
|
||||||
|
assert_eq!(all[0].branch, "feat/exited");
|
||||||
|
assert_eq!(
|
||||||
|
all[0].reason,
|
||||||
|
maestro_protocol::RetainedReason::ExitedWithUnsavedWork
|
||||||
|
);
|
||||||
|
|
||||||
|
// The session itself stays until dismissed; its worktree reference is
|
||||||
|
// settled, so dismissal is neither refused nor retained twice.
|
||||||
|
// (`Dead` is observable a moment before the settle step finishes.)
|
||||||
|
wait_for_async(Duration::from_secs(3), || async {
|
||||||
|
let s = &fx.manager.snapshot().await[0];
|
||||||
|
s.worktree.is_none() && s.blocked_work.is_none()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let snap = fx.manager.snapshot().await;
|
||||||
|
assert_eq!(snap.len(), 1);
|
||||||
|
assert_eq!(snap[0].worktree, None);
|
||||||
|
|
||||||
|
fx.manager.kill(s.id, false).await.expect("dismiss");
|
||||||
|
assert!(!fx.manager.has(s.id).await);
|
||||||
|
assert!(
|
||||||
|
wt_path.exists(),
|
||||||
|
"dismissal must not remove what was retained"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fx.retained_store().load_all().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"dismissal must not record a second entry for the same tree"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Natural exit (#82), pristine side of the same rule: nothing in the tree,
|
||||||
|
/// nothing to keep — the worktree is removed and no store entry appears.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let repo = TempDir::new().unwrap();
|
||||||
|
init_repo(repo.path());
|
||||||
|
let wt_path = repo.path().join("worktrees").join("feat").join("clean");
|
||||||
|
|
||||||
|
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/clean".into(),
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
|
||||||
|
// Push everything so the branch has no unpushed commits: fully pristine.
|
||||||
|
let origin = TempDir::new().unwrap();
|
||||||
|
init_bare_repo(origin.path());
|
||||||
|
push_all_to_origin(repo.path(), origin.path());
|
||||||
|
|
||||||
|
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
|
||||||
|
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
||||||
|
wait_for_async(Duration::from_secs(3), || async {
|
||||||
|
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!wt_path.exists(),
|
||||||
|
"a pristine worktree leaves nothing behind"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fx.retained_store().load_all().unwrap().is_empty(),
|
||||||
|
"nothing was retained"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fx.manager.has(s.id).await,
|
||||||
|
"the session stays until dismissed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When nothing can be recorded — here: no retained store wired — the exit
|
||||||
|
/// destroys nothing. The tree stays on disk AND stays attached to the dead
|
||||||
|
/// record, where dismissing it later still goes through #81's forced-kill
|
||||||
|
/// retention. Losing the store must not lose the work.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn natural_exit_without_a_retained_store_destroys_nothing() {
|
||||||
|
let tmux = TmuxFixture::new();
|
||||||
|
let state_dir = TempDir::new().unwrap();
|
||||||
|
let manager = maestro_daemon::SessionManager::new(
|
||||||
|
Arc::new(tmux.tmux.clone()),
|
||||||
|
maestro_daemon::sidecar::SidecarStore::new(state_dir.path().join("sessions")),
|
||||||
|
maestro_daemon::worktree::WorktreeManager::new(None),
|
||||||
|
state_dir.path().join("fifos"),
|
||||||
|
);
|
||||||
|
// Deliberately NO with_retained_store.
|
||||||
|
|
||||||
|
let repo = TempDir::new().unwrap();
|
||||||
|
init_repo(repo.path());
|
||||||
|
let wt_path = repo.path().join("worktrees").join("feat").join("nostore");
|
||||||
|
|
||||||
|
let s = 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/nostore".into(),
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||||
|
|
||||||
|
tmux.tmux.kill_session(s.id).expect("kill tmux directly");
|
||||||
|
manager.start_liveness_watcher(Duration::from_millis(50));
|
||||||
|
wait_for_async(Duration::from_secs(3), || async {
|
||||||
|
manager.status(s.id).await == Some(SessionStatus::Dead)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
wt_path.exists(),
|
||||||
|
"nothing may be destroyed when it cannot be recorded"
|
||||||
|
);
|
||||||
|
let snap = manager.snapshot().await;
|
||||||
|
assert!(
|
||||||
|
snap[0].worktree.is_some(),
|
||||||
|
"the tree must stay attached to the record so a later forced kill can retain it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
#[tokio::test]
|
||||||
@@ -452,5 +945,5 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
|
|||||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||||
assert!(fx.manager.has(s.id).await, "live session got reaped");
|
assert!(fx.manager.has(s.id).await, "live session got reaped");
|
||||||
|
|
||||||
fx.manager.kill(s.id).await.unwrap();
|
fx.manager.kill(s.id, false).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ fn sample(id: Uuid) -> Session {
|
|||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
||||||
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ mod common;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
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::WorktreeManager;
|
||||||
|
use maestro_daemon::worktree_probe::in_the_way;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn worktree_branches(repo: &PathBuf) -> Vec<String> {
|
fn worktree_branches(repo: &PathBuf) -> Vec<String> {
|
||||||
@@ -134,6 +135,123 @@ fn add_twice_generates_distinct_paths() {
|
|||||||
assert!(p2.exists());
|
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]
|
#[test]
|
||||||
fn remove_prunes_worktree() {
|
fn remove_prunes_worktree() {
|
||||||
let repo_dir = TempDir::new().unwrap();
|
let repo_dir = TempDir::new().unwrap();
|
||||||
@@ -211,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]
|
#[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_dir = TempDir::new().unwrap();
|
||||||
let repo = repo_dir.path().to_path_buf();
|
let repo = repo_dir.path().to_path_buf();
|
||||||
init_repo(&repo);
|
init_repo(&repo);
|
||||||
@@ -225,10 +349,6 @@ fn remove_refuses_worktree_with_uncommitted_changes() {
|
|||||||
.expect_err("dirty worktree must be refused");
|
.expect_err("dirty worktree must be refused");
|
||||||
assert!(path.exists(), "guarded worktree must stay on disk");
|
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, &["add", "."]);
|
||||||
run_git(&path, &["commit", "-q", "-m", "saved"]);
|
run_git(&path, &["commit", "-q", "-m", "saved"]);
|
||||||
|
|
||||||
@@ -410,7 +530,8 @@ fn a_guarded_leftover_is_never_adopted() {
|
|||||||
let first = mgr.add_new(&repo, "feat/resume").expect("add");
|
let first = mgr.add_new(&repo, "feat/resume").expect("add");
|
||||||
std::fs::write(first.join("notes.txt"), b"unsaved\n").unwrap();
|
std::fs::write(first.join("notes.txt"), b"unsaved\n").unwrap();
|
||||||
|
|
||||||
// The guard refuses, leaving it on disk.
|
// 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)
|
mgr.remove(&repo, &first)
|
||||||
.expect_err("dirty tree is guarded");
|
.expect_err("dirty tree is guarded");
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, Project, Session,
|
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, KillOutcome, Project,
|
||||||
|
RetainedWorktree, Session,
|
||||||
};
|
};
|
||||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||||
@@ -61,7 +62,21 @@ impl TestClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn kill_session(&self, id: Uuid) -> Result<(), ClientError> {
|
pub async fn kill_session(&self, id: Uuid) -> Result<(), ClientError> {
|
||||||
Ok(self.daemon.kill_session(id).await?)
|
Ok(self.daemon.kill_session(id, false).await.map(|_| ())?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill with `force=true`: overrides a kill refusal and retains the
|
||||||
|
/// guarded worktree (#81). Answers with what was kept.
|
||||||
|
pub async fn kill_session_forced(&self, id: Uuid) -> Result<KillOutcome, ClientError> {
|
||||||
|
Ok(self.daemon.kill_session(id, true).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
|
||||||
|
Ok(self.daemon.list_worktrees().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
|
||||||
|
Ok(self.daemon.reclaim_worktree(id).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
|
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
|
||||||
|
|||||||
+558
-37
@@ -35,6 +35,17 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A worktree spec built through its wire shape. The daemon and clients ship
|
||||||
|
/// together (#87 dropped `cleanup_on_kill` from the struct), so the JSON
|
||||||
|
/// object carries only the fields the lifecycle actually honours.
|
||||||
|
fn worktree_spec(base_repo: &std::path::Path, branch: &str, create_branch: bool) -> WorktreeSpec {
|
||||||
|
WorktreeSpec {
|
||||||
|
base_repo: base_repo.to_path_buf(),
|
||||||
|
branch: branch.into(),
|
||||||
|
create_branch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn request carrying a worktree spec against `base`.
|
/// Spawn request carrying a worktree spec against `base`.
|
||||||
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
|
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
|
||||||
CreateSessionRequest {
|
CreateSessionRequest {
|
||||||
@@ -42,12 +53,7 @@ fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> Cr
|
|||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(WorktreeSpec {
|
worktree: Some(worktree_spec(base, branch, create_branch)),
|
||||||
base_repo: base.to_path_buf(),
|
|
||||||
branch: branch.into(),
|
|
||||||
cleanup_on_kill: true,
|
|
||||||
create_branch,
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,12 +147,7 @@ async fn kill_removes_clean_worktree() {
|
|||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(WorktreeSpec {
|
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
|
||||||
base_repo: base.path().to_path_buf(),
|
|
||||||
branch: "feature/e2e".into(),
|
|
||||||
cleanup_on_kill: true,
|
|
||||||
create_branch: true,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||||
@@ -160,6 +161,7 @@ async fn kill_removes_clean_worktree() {
|
|||||||
"expected a worktree for feature/e2e under <repo>/worktrees, got: {worktrees:?}"
|
"expected a worktree for feature/e2e under <repo>/worktrees, got: {worktrees:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let _origin = throwaway_origin(base.path());
|
||||||
client.kill_session(s.id).await.expect("kill");
|
client.kill_session(s.id).await.expect("kill");
|
||||||
|
|
||||||
// Give the daemon a moment to remove the worktree, then verify.
|
// Give the daemon a moment to remove the worktree, then verify.
|
||||||
@@ -177,11 +179,12 @@ async fn kill_removes_clean_worktree() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The guard (#56): killing a session whose worktree holds uncommitted
|
/// The kill guard (#79): killing a session whose worktree holds uncommitted
|
||||||
/// changes must leave the tree on disk — cleanup is unconditional, so this
|
/// changes is refused outright. Nothing happens — the session keeps running
|
||||||
/// guard is what keeps a stray kill from destroying work.
|
/// 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")]
|
#[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 (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
let base = TempDir::new().unwrap();
|
let base = TempDir::new().unwrap();
|
||||||
@@ -197,29 +200,32 @@ async fn kill_spares_worktree_with_uncommitted_changes() {
|
|||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(WorktreeSpec {
|
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
|
||||||
base_repo: base.path().to_path_buf(),
|
|
||||||
branch: "feature/unsaved".into(),
|
|
||||||
cleanup_on_kill: true,
|
|
||||||
create_branch: true,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||||
assert!(wt_path.exists());
|
assert!(wt_path.exists());
|
||||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||||
|
|
||||||
client.kill_session(s.id).await.expect("kill");
|
let err = client
|
||||||
|
.kill_session(s.id)
|
||||||
// The session is gone but the dirty worktree stays findable on disk.
|
.await
|
||||||
let deadline = Instant::now() + Duration::from_secs(5);
|
.expect_err("kill must refuse");
|
||||||
while Instant::now() < deadline {
|
match err {
|
||||||
let sessions = client.list_sessions().await.unwrap_or_default();
|
maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => {
|
||||||
if sessions.is_empty() {
|
assert_eq!(info.code, "kill_guarded", "{info:?}");
|
||||||
break;
|
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!(
|
assert!(
|
||||||
wt_path.exists(),
|
wt_path.exists(),
|
||||||
"guarded worktree must be left on disk after kill"
|
"guarded worktree must be left on disk after kill"
|
||||||
@@ -252,12 +258,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
|
|||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(WorktreeSpec {
|
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
|
||||||
base_repo: base.path().to_path_buf(),
|
|
||||||
branch: "feature/existing".into(),
|
|
||||||
cleanup_on_kill: true,
|
|
||||||
create_branch: false,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client
|
let s = client
|
||||||
@@ -270,6 +271,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
|
|||||||
"expected a worktree checked out at feature/existing, got: {worktrees:?}"
|
"expected a worktree checked out at feature/existing, got: {worktrees:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let _origin = throwaway_origin(base.path());
|
||||||
client.kill_session(s.id).await.expect("kill");
|
client.kill_session(s.id).await.expect("kill");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +319,7 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
let _origin = throwaway_origin(base.path());
|
||||||
client.kill_session(s.id).await.expect("kill");
|
client.kill_session(s.id).await.expect("kill");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,6 +367,7 @@ async fn second_spawn_on_same_branch_is_rejected() {
|
|||||||
"original worktree must be untouched"
|
"original worktree must be untouched"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let _origin = throwaway_origin(base.path());
|
||||||
client.kill_session(first.id).await.expect("kill");
|
client.kill_session(first.id).await.expect("kill");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,9 +410,481 @@ async fn spawn_from_linked_worktree_places_in_main_checkout() {
|
|||||||
"worktrees must never nest inside another worktree"
|
"worktrees must never nest inside another worktree"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let _origin = throwaway_origin(base.path());
|
||||||
client.kill_session(s.id).await.expect("kill");
|
client.kill_session(s.id).await.expect("kill");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Worktree lifecycle: collisions, retention, reclaim (#88)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// #77/#88: the target path `<repo>/worktrees/<branch>` is occupied by a real
|
||||||
|
/// registered worktree the operator made by hand. The daemon sidesteps to the
|
||||||
|
/// suffixed directory and never touches the occupant — and both trees keep
|
||||||
|
/// working: two registered worktrees, two sessions, each running in its own.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn occupied_path_gets_suffixed_directory_and_both_worktrees_work() {
|
||||||
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
|
let base = TempDir::new().unwrap();
|
||||||
|
init_repo(base.path());
|
||||||
|
|
||||||
|
// An operator-made worktree sits exactly where `feature/collide` would go.
|
||||||
|
// Its branch is a different one, so the requested branch stays creatable.
|
||||||
|
let squatter = base
|
||||||
|
.path()
|
||||||
|
.join("worktrees")
|
||||||
|
.join("feature")
|
||||||
|
.join("collide");
|
||||||
|
run_git(
|
||||||
|
&[
|
||||||
|
"worktree",
|
||||||
|
"add",
|
||||||
|
"-q",
|
||||||
|
"-b",
|
||||||
|
"operator/manual",
|
||||||
|
&squatter.to_string_lossy(),
|
||||||
|
],
|
||||||
|
base.path(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let s = client
|
||||||
|
.spawn_session(&worktree_req(base.path(), "feature/collide", true))
|
||||||
|
.await
|
||||||
|
.expect("spawn onto occupied path");
|
||||||
|
|
||||||
|
let suffixed = base
|
||||||
|
.path()
|
||||||
|
.join("worktrees")
|
||||||
|
.join("feature")
|
||||||
|
.join("collide-2");
|
||||||
|
assert_eq!(
|
||||||
|
s.cwd, suffixed,
|
||||||
|
"the daemon must take the suffixed directory, not the occupied one"
|
||||||
|
);
|
||||||
|
assert!(suffixed.is_dir(), "suffixed worktree must exist on disk");
|
||||||
|
|
||||||
|
// Both trees are registered, distinct, and the squatter is byte-intact.
|
||||||
|
let registrations = git_worktree_list(base.path());
|
||||||
|
assert!(
|
||||||
|
registrations
|
||||||
|
.iter()
|
||||||
|
.any(|l| l == &format!("worktree {}", squatter.canonicalize().unwrap().display())),
|
||||||
|
"operator's worktree must stay registered: {registrations:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
registrations.iter().any(|l| l.contains("collide-2")),
|
||||||
|
"suffixed worktree must be registered too: {registrations:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both sessions run inside their own tree at the same time.
|
||||||
|
let sessions = client.list_sessions().await.expect("list");
|
||||||
|
assert!(
|
||||||
|
sessions.iter().any(|x| x.id == s.id && x.cwd == suffixed),
|
||||||
|
"session must keep running in its suffixed tree"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _origin = throwaway_origin(base.path());
|
||||||
|
client.kill_session(s.id).await.expect("kill");
|
||||||
|
|
||||||
|
// Only the daemon-made trees go; the operator's manual one survives.
|
||||||
|
let after = git_worktree_list(base.path());
|
||||||
|
assert!(
|
||||||
|
after
|
||||||
|
.iter()
|
||||||
|
.any(|l| l == &format!("worktree {}", squatter.canonicalize().unwrap().display())),
|
||||||
|
"operator's worktree must outlive the sessions: {after:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #77/#88: the target path holds a plain directory the daemon never made and
|
||||||
|
/// git has never heard of. The daemon neither adopts it nor errors — it builds
|
||||||
|
/// beside it and leaves every byte of it alone.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn foreign_directory_is_sidestepped_never_adopted() {
|
||||||
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
|
let base = TempDir::new().unwrap();
|
||||||
|
init_repo(base.path());
|
||||||
|
|
||||||
|
let squatter = base
|
||||||
|
.path()
|
||||||
|
.join("worktrees")
|
||||||
|
.join("feature")
|
||||||
|
.join("foreign");
|
||||||
|
std::fs::create_dir_all(&squatter).unwrap();
|
||||||
|
std::fs::write(squatter.join("operator-notes.txt"), b"mine\n").unwrap();
|
||||||
|
|
||||||
|
let s = client
|
||||||
|
.spawn_session(&worktree_req(base.path(), "feature/foreign", true))
|
||||||
|
.await
|
||||||
|
.expect("spawn must sidestep, not adopt or fail");
|
||||||
|
|
||||||
|
let suffixed = base
|
||||||
|
.path()
|
||||||
|
.join("worktrees")
|
||||||
|
.join("feature")
|
||||||
|
.join("foreign-2");
|
||||||
|
assert_eq!(s.cwd, suffixed, "session must run in the suffixed tree");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(squatter.join("operator-notes.txt")).unwrap(),
|
||||||
|
b"mine\n",
|
||||||
|
"the foreign directory must be untouched"
|
||||||
|
);
|
||||||
|
let registrations = git_worktree_list(base.path());
|
||||||
|
let registered_paths: Vec<&str> = registrations
|
||||||
|
.iter()
|
||||||
|
.filter_map(|l| l.strip_prefix("worktree "))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
!registered_paths
|
||||||
|
.iter()
|
||||||
|
.any(|p| *p == squatter.canonicalize().unwrap().display().to_string()),
|
||||||
|
"a plain directory must never be adopted as a worktree: {registered_paths:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _origin = throwaway_origin(base.path());
|
||||||
|
client.kill_session(s.id).await.expect("kill");
|
||||||
|
assert!(squatter.join("operator-notes.txt").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #79/#88: killing with uncommitted changes is refused; committing and
|
||||||
|
/// pushing that work clears the guard and the very same kill then succeeds
|
||||||
|
/// and removes the tree. The session is the handle on the work: while it is
|
||||||
|
/// unsaved the session stays, once it is saved the tree goes.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn guarded_kill_then_commit_and_push_makes_the_same_kill_succeed() {
|
||||||
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
|
let base = TempDir::new().unwrap();
|
||||||
|
init_repo(base.path());
|
||||||
|
let _origin = throwaway_origin(base.path());
|
||||||
|
|
||||||
|
let wt = base.path().join("worktrees").join("feature").join("guard");
|
||||||
|
let s = client
|
||||||
|
.spawn_session(&worktree_req(base.path(), "feature/guard", true))
|
||||||
|
.await
|
||||||
|
.expect("spawn");
|
||||||
|
std::fs::write(wt.join("README"), b"work in progress\n").unwrap();
|
||||||
|
|
||||||
|
// Refused: the edit is uncommitted.
|
||||||
|
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);
|
||||||
|
assert_eq!(breakdown.unpushed_commits, 0);
|
||||||
|
}
|
||||||
|
other => panic!("expected a kill_guarded refusal, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.list_sessions()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|x| x.id == s.id),
|
||||||
|
"a refused kill keeps the session running"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(wt.join("README")).unwrap(),
|
||||||
|
b"work in progress\n",
|
||||||
|
"the worktree is untouched by the refusal"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The operator does exactly what the refusal asked for: save and push.
|
||||||
|
commit_all(&wt, "wip");
|
||||||
|
push_all(base.path());
|
||||||
|
|
||||||
|
client.kill_session(s.id).await.expect("kill after saving");
|
||||||
|
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if !wt.exists() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!wt.exists(),
|
||||||
|
"a cleared worktree must be removed after kill"
|
||||||
|
);
|
||||||
|
let registrations = git_worktree_list(base.path());
|
||||||
|
assert!(
|
||||||
|
!registrations.iter().any(|l| l.contains("feature/guard")),
|
||||||
|
"removal must deregister the tree: {registrations:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #81/#88: commits that no remote has refuse the kill; `--force` ends the
|
||||||
|
/// session anyway but RETAINS the worktree — force never destroys blocked
|
||||||
|
/// work, it parks it in the retained store with reason `force-killed`.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn unpushed_commits_block_kill_and_force_retains_the_tree() {
|
||||||
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
|
let base = TempDir::new().unwrap();
|
||||||
|
init_repo(base.path());
|
||||||
|
let _origin = throwaway_origin(base.path());
|
||||||
|
|
||||||
|
let wt = base.path().join("worktrees").join("feature").join("local");
|
||||||
|
let s = client
|
||||||
|
.spawn_session(&worktree_req(base.path(), "feature/local", true))
|
||||||
|
.await
|
||||||
|
.expect("spawn");
|
||||||
|
std::fs::write(wt.join("README"), b"local only\n").unwrap();
|
||||||
|
commit_all(&wt, "not pushed yet");
|
||||||
|
|
||||||
|
// Refused: the commit is on no remote-tracking ref.
|
||||||
|
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.unpushed_commits, 1);
|
||||||
|
assert_eq!(breakdown.uncommitted_files, 0);
|
||||||
|
}
|
||||||
|
other => panic!("expected a kill_guarded refusal, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force overrides the refusal and nothing else: session gone, tree kept.
|
||||||
|
let outcome = client.kill_session_forced(s.id).await.expect("forced kill");
|
||||||
|
let retained = outcome
|
||||||
|
.retained
|
||||||
|
.expect("force must retain the guarded tree");
|
||||||
|
assert_eq!(
|
||||||
|
retained.reason,
|
||||||
|
maestro_protocol::RetainedReason::ForceKilled
|
||||||
|
);
|
||||||
|
assert_eq!(retained.path, wt);
|
||||||
|
assert_eq!(retained.branch, "feature/local");
|
||||||
|
assert_eq!(retained.in_the_way.unpushed_commits, 1);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!client
|
||||||
|
.list_sessions()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|x| x.id == s.id),
|
||||||
|
"the forced kill ends the session"
|
||||||
|
);
|
||||||
|
assert!(wt.is_dir(), "a force-retained worktree must stay on disk");
|
||||||
|
assert!(
|
||||||
|
git_worktree_list(base.path())
|
||||||
|
.iter()
|
||||||
|
.any(|l| l.contains("feature/local")),
|
||||||
|
"the retained tree stays registered in git"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #82/#88: a session that exits on its own never reaches the kill path, but
|
||||||
|
/// its worktree follows the same rule. Dirty → retained with reason
|
||||||
|
/// `exited-with-unsaved-work`; pristine → removed, leaving nothing behind.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
||||||
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
|
// Two repos so the two cases cannot see each other's state.
|
||||||
|
let dirty_repo = TempDir::new().unwrap();
|
||||||
|
init_repo(dirty_repo.path());
|
||||||
|
let _origin = throwaway_origin(dirty_repo.path());
|
||||||
|
let pristine_repo = TempDir::new().unwrap();
|
||||||
|
init_repo(pristine_repo.path());
|
||||||
|
let _origin_pristine = throwaway_origin(pristine_repo.path());
|
||||||
|
|
||||||
|
let dirty_wt = dirty_repo
|
||||||
|
.path()
|
||||||
|
.join("worktrees")
|
||||||
|
.join("feature")
|
||||||
|
.join("messy");
|
||||||
|
let pristine_wt = pristine_repo
|
||||||
|
.path()
|
||||||
|
.join("worktrees")
|
||||||
|
.join("feature")
|
||||||
|
.join("tidy");
|
||||||
|
|
||||||
|
let _dirty = client
|
||||||
|
.spawn_session(&CreateSessionRequest {
|
||||||
|
cwd: dirty_repo.path().to_path_buf(),
|
||||||
|
command: "sh -c 'echo unsaved > scratch.txt'".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("spawn dirty");
|
||||||
|
let _pristine = client
|
||||||
|
.spawn_session(&create_req_cmd(
|
||||||
|
pristine_repo.path(),
|
||||||
|
"true",
|
||||||
|
"feature/tidy",
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("spawn pristine");
|
||||||
|
|
||||||
|
assert!(dirty_wt.is_dir() && pristine_wt.is_dir());
|
||||||
|
|
||||||
|
// The watcher reaps dead tmux sessions every couple of ticks and settles
|
||||||
|
// each worktree accordingly. Poll for both outcomes.
|
||||||
|
wait_for_async(
|
||||||
|
"dirty exit retains its worktree",
|
||||||
|
Duration::from_secs(20),
|
||||||
|
|| {
|
||||||
|
let client = client.clone();
|
||||||
|
let path = dirty_wt.clone();
|
||||||
|
async move {
|
||||||
|
client
|
||||||
|
.list_worktrees()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.map(|wts| {
|
||||||
|
wts.iter().any(|w| {
|
||||||
|
w.path == path
|
||||||
|
&& w.reason
|
||||||
|
== maestro_protocol::RetainedReason::ExitedWithUnsavedWork
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
wait_for_async(
|
||||||
|
"pristine exit removes its worktree entirely",
|
||||||
|
Duration::from_secs(20),
|
||||||
|
|| {
|
||||||
|
let client = client.clone();
|
||||||
|
let repo = pristine_repo.path().to_path_buf();
|
||||||
|
let path = pristine_wt.clone();
|
||||||
|
async move {
|
||||||
|
if path.exists() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let listed = client.list_worktrees().await.ok();
|
||||||
|
let listed_clear = listed
|
||||||
|
.map(|wts| !wts.iter().any(|w| w.path == path))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let registered = git_worktree_list(&repo);
|
||||||
|
!registered.iter().any(|l| l.contains("feature/tidy")) && listed_clear
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// The dirty tree is still fully there, file included.
|
||||||
|
assert!(dirty_wt.is_dir());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(dirty_wt.join("scratch.txt")).unwrap(),
|
||||||
|
b"unsaved\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest {
|
||||||
|
CreateSessionRequest {
|
||||||
|
cwd: cwd.to_path_buf(),
|
||||||
|
command: command.into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: Some(worktree_spec(cwd, branch, true)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #80/#88: `GET /api/worktrees` lists what the daemon is holding, with the
|
||||||
|
/// reason and a live in-the-way breakdown; reclaim removes the directory and
|
||||||
|
/// the store entry but NEVER the branch — its commits outlive the reclaim.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn worktrees_endpoint_lists_reasons_and_reclaim_keeps_the_branch() {
|
||||||
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
|
let base = TempDir::new().unwrap();
|
||||||
|
init_repo(base.path());
|
||||||
|
let _origin = throwaway_origin(base.path());
|
||||||
|
|
||||||
|
let wt = base.path().join("worktrees").join("feature").join("parked");
|
||||||
|
let s = client
|
||||||
|
.spawn_session(&worktree_req(base.path(), "feature/parked", true))
|
||||||
|
.await
|
||||||
|
.expect("spawn");
|
||||||
|
std::fs::write(wt.join("README"), b"worth keeping\n").unwrap();
|
||||||
|
commit_all(&wt, "worth keeping");
|
||||||
|
let sha = branch_sha(&wt, "feature/parked");
|
||||||
|
|
||||||
|
let outcome = client.kill_session_forced(s.id).await.expect("force kill");
|
||||||
|
assert!(outcome.retained.is_some());
|
||||||
|
|
||||||
|
let listed = client.list_worktrees().await.expect("list worktrees");
|
||||||
|
assert_eq!(listed.len(), 1, "exactly one retained entry: {listed:?}");
|
||||||
|
let entry = &listed[0];
|
||||||
|
assert_eq!(entry.path, wt);
|
||||||
|
assert_eq!(entry.branch, "feature/parked");
|
||||||
|
assert_eq!(entry.reason, maestro_protocol::RetainedReason::ForceKilled);
|
||||||
|
assert_eq!(entry.in_the_way.unpushed_commits, 1);
|
||||||
|
|
||||||
|
client.reclaim_worktree(entry.id).await.expect("reclaim");
|
||||||
|
assert!(!wt.exists(), "reclaim removes the directory");
|
||||||
|
assert!(
|
||||||
|
client.list_worktrees().await.unwrap().is_empty(),
|
||||||
|
"reclaim drops the store entry"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The whole point of reclaim: the branch and its commits survive.
|
||||||
|
assert_eq!(
|
||||||
|
branch_sha(base.path(), "feature/parked"),
|
||||||
|
sha,
|
||||||
|
"the branch must keep pointing at the same commit after reclaim"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The retained store is state-dir backed (#80): a daemon restart must not
|
||||||
|
/// orphan the trees it is holding — they are still on disk with entries
|
||||||
|
/// pointing at them, and still reclaimable afterwards.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn retained_store_survives_a_daemon_restart() {
|
||||||
|
let (mut daemon, client) = bring_up().await;
|
||||||
|
|
||||||
|
let base = TempDir::new().unwrap();
|
||||||
|
init_repo(base.path());
|
||||||
|
let _origin = throwaway_origin(base.path());
|
||||||
|
|
||||||
|
let wt = base
|
||||||
|
.path()
|
||||||
|
.join("worktrees")
|
||||||
|
.join("feature")
|
||||||
|
.join("durable");
|
||||||
|
let s = client
|
||||||
|
.spawn_session(&worktree_req(base.path(), "feature/durable", true))
|
||||||
|
.await
|
||||||
|
.expect("spawn");
|
||||||
|
std::fs::write(wt.join("README"), b"survives restarts\n").unwrap();
|
||||||
|
commit_all(&wt, "survives restarts");
|
||||||
|
client.kill_session_forced(s.id).await.expect("force kill");
|
||||||
|
|
||||||
|
daemon.restart().await;
|
||||||
|
|
||||||
|
let listed = client.list_worktrees().await.expect("list after restart");
|
||||||
|
assert_eq!(listed.len(), 1, "store must survive restart: {listed:?}");
|
||||||
|
assert_eq!(listed[0].path, wt);
|
||||||
|
assert_eq!(
|
||||||
|
listed[0].reason,
|
||||||
|
maestro_protocol::RetainedReason::ForceKilled
|
||||||
|
);
|
||||||
|
assert!(wt.is_dir(), "the tree itself must survive the restart too");
|
||||||
|
|
||||||
|
client
|
||||||
|
.reclaim_worktree(listed[0].id)
|
||||||
|
.await
|
||||||
|
.expect("reclaim");
|
||||||
|
assert!(!wt.exists());
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /api/projects + /api/branches (#54/#55/#66)
|
// GET /api/projects + /api/branches (#54/#55/#66)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -774,6 +1250,51 @@ fn git_worktree_list(dir: &std::path::Path) -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bare remote registered as `origin` on `repo`, seeded with every current
|
||||||
|
/// branch, so existing 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. The caller must keep the `TempDir` alive
|
||||||
|
/// while the remote is still needed; later pushes go through [`push_all`].
|
||||||
|
fn throwaway_origin(repo: &std::path::Path) -> TempDir {
|
||||||
|
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");
|
||||||
|
push_all(repo);
|
||||||
|
origin
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_all(repo: &std::path::Path) {
|
||||||
|
run_git(&["push", "-q", "--all", "origin"], repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_all(dir: &std::path::Path, message: &str) {
|
||||||
|
run_git(&["add", "-A"], dir);
|
||||||
|
run_git(&["commit", "-q", "-m", message], dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn branch_sha(repo: &std::path::Path, branch: &str) -> String {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repo)
|
||||||
|
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
|
||||||
|
.output()
|
||||||
|
.expect("git rev-parse");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"branch {branch} missing: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn daemon_events_ws_streams_snapshot_then_session_added() {
|
async fn daemon_events_ws_streams_snapshot_then_session_added() {
|
||||||
// NAP-248: subscribe to the daemon's WS /api/events, expect an initial
|
// NAP-248: subscribe to the daemon's WS /api/events, expect an initial
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use uuid::Uuid;
|
|||||||
use crate::routes::{self, UrlError};
|
use crate::routes::{self, UrlError};
|
||||||
use crate::{
|
use crate::{
|
||||||
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||||
Project, RecentDirs, Session,
|
KillOutcome, Project, RecentDirs, RetainedWorktree, Session,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Time budget for establishing a TCP connection to the daemon.
|
/// Time budget for establishing a TCP connection to the daemon.
|
||||||
@@ -114,12 +114,24 @@ impl DaemonClient {
|
|||||||
self.post(routes::SESSIONS, req).await
|
self.post(routes::SESSIONS, req).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn kill_session(&self, id: Uuid) -> Result<(), ClientError> {
|
/// Kill a session. With `force`, the daemon overrides the kill refusal
|
||||||
|
/// (#79) and retains the worktree instead of removing it (#81); the
|
||||||
|
/// outcome then names what was kept and where.
|
||||||
|
pub async fn kill_session(&self, id: Uuid, force: bool) -> Result<KillOutcome, ClientError> {
|
||||||
|
let query: &[(&str, &str)] = if force {
|
||||||
|
&[(routes::FORCE_QUERY, "true")]
|
||||||
|
} else {
|
||||||
|
&[]
|
||||||
|
};
|
||||||
let resp = self
|
let resp = self
|
||||||
.send(Method::DELETE, &routes::session(id), &[], None::<&()>)
|
.send(Method::DELETE, &routes::session(id), query, None::<&()>)
|
||||||
.await?;
|
.await?;
|
||||||
check(resp).await?;
|
let resp = check(resp).await?;
|
||||||
Ok(())
|
// An unforced kill keeps its historical `204 No Content`.
|
||||||
|
if resp.status() == reqwest::StatusCode::NO_CONTENT {
|
||||||
|
return Ok(KillOutcome { retained: None });
|
||||||
|
}
|
||||||
|
json(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
|
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
|
||||||
@@ -148,6 +160,23 @@ impl DaemonClient {
|
|||||||
.await
|
.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 -----------------------------------------------------------
|
// -- plumbing -----------------------------------------------------------
|
||||||
|
|
||||||
async fn get<T: DeserializeOwned>(
|
async fn get<T: DeserializeOwned>(
|
||||||
|
|||||||
@@ -87,6 +87,13 @@ pub struct Session {
|
|||||||
/// from daemons that predate the field.
|
/// from daemons that predate the field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub current_cwd: Option<PathBuf>,
|
pub current_cwd: Option<PathBuf>,
|
||||||
|
/// Whether the session's worktree currently holds anything that would
|
||||||
|
/// block an unforced kill (#83), summarised by [`InTheWay`]'s `Display`.
|
||||||
|
/// Refreshed by the daemon's watcher; `None` for sessions without a
|
||||||
|
/// worktree and until the first probe. Defaults on the wire so payloads
|
||||||
|
/// from daemons that predate the field stay forward-compatible.
|
||||||
|
#[serde(default)]
|
||||||
|
pub blocked_work: Option<InTheWay>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default for [`Session::last_activity_at`] when an older payload omits it.
|
/// Default for [`Session::last_activity_at`] when an older payload omits it.
|
||||||
@@ -119,11 +126,6 @@ pub enum ActivityState {
|
|||||||
pub struct WorktreeSpec {
|
pub struct WorktreeSpec {
|
||||||
pub base_repo: PathBuf,
|
pub base_repo: PathBuf,
|
||||||
pub branch: String,
|
pub branch: String,
|
||||||
/// Kept on the wire for CLI compatibility (#56) but no longer a
|
|
||||||
/// user-facing choice: the daemon always cleans up on kill, guarded
|
|
||||||
/// against unsaved work. Defaults to `true`.
|
|
||||||
#[serde(default = "default_true")]
|
|
||||||
pub cleanup_on_kill: bool,
|
|
||||||
/// When false, `branch` must already exist and is checked out as-is
|
/// When false, `branch` must already exist and is checked out as-is
|
||||||
/// (#56); when true (the default, and what every payload meant before
|
/// (#56); when true (the default, and what every payload meant before
|
||||||
/// the field existed) the daemon creates it with `worktree add -b`.
|
/// the field existed) the daemon creates it with `worktree add -b`.
|
||||||
@@ -195,6 +197,48 @@ pub struct RetainedWorktree {
|
|||||||
pub in_the_way: InTheWay,
|
pub in_the_way: InTheWay,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Body of a successful forced `DELETE /api/sessions/:id?force=true` (#81).
|
||||||
|
/// A forced kill ends the session and retains its worktree instead of
|
||||||
|
/// removing it; this names what was kept, where, and why, so a client can say
|
||||||
|
/// where the work went rather than leaving the operator to wonder.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct KillOutcome {
|
||||||
|
/// Set when the worktree was retained with reason `force-killed`; `None`
|
||||||
|
/// when there was nothing to retain — no worktree at all, or one whose
|
||||||
|
/// guard was clear, which a forced kill removes as usual.
|
||||||
|
#[serde(default)]
|
||||||
|
pub retained: Option<RetainedWorktree>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
/// HTTP `POST /api/sessions` body.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct CreateSessionRequest {
|
pub struct CreateSessionRequest {
|
||||||
@@ -300,6 +344,10 @@ pub struct BranchInfo {
|
|||||||
pub struct ErrorInfo {
|
pub struct ErrorInfo {
|
||||||
pub code: String,
|
pub code: String,
|
||||||
pub message: 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": {…}}`.
|
/// The envelope every `/api/*` failure is wrapped in: `{"error": {…}}`.
|
||||||
@@ -320,6 +368,7 @@ impl ApiError {
|
|||||||
error: ErrorInfo {
|
error: ErrorInfo {
|
||||||
code: code.into(),
|
code: code.into(),
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
|
unsaved_work: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -489,6 +538,7 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.with_timezone(&Utc),
|
.with_timezone(&Utc),
|
||||||
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
||||||
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,6 +558,34 @@ mod tests {
|
|||||||
assert_eq!(s.activity, ActivityState::Idle);
|
assert_eq!(s.activity, ActivityState::Idle);
|
||||||
assert_eq!(s.last_activity_at, epoch());
|
assert_eq!(s.last_activity_at, epoch());
|
||||||
assert!(s.current_cwd.is_none());
|
assert!(s.current_cwd.is_none());
|
||||||
|
assert!(s.blocked_work.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocked_work_defaults_to_none_on_legacy_payload() {
|
||||||
|
// A pre-#83 daemon omits the indicator; it must deserialize to `None`
|
||||||
|
// rather than failing, and a present value round-trips.
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"id": Uuid::from_u128(1),
|
||||||
|
"cwd": "/tmp",
|
||||||
|
"command": "bash",
|
||||||
|
"status": "Running",
|
||||||
|
"created_at": "2026-05-24T12:00:00Z"
|
||||||
|
});
|
||||||
|
let s: Session = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(s.blocked_work, None);
|
||||||
|
|
||||||
|
let mut with_blocker = sample_session();
|
||||||
|
with_blocker.blocked_work = Some(InTheWay {
|
||||||
|
uncommitted_files: 3,
|
||||||
|
..InTheWay::default()
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
with_blocker.clone().blocked_work,
|
||||||
|
serde_json::from_value::<Session>(serde_json::to_value(&with_blocker).unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.blocked_work
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -547,7 +625,6 @@ mod tests {
|
|||||||
worktree: Some(WorktreeSpec {
|
worktree: Some(WorktreeSpec {
|
||||||
base_repo: PathBuf::from("/home/naps62/projects/maestro"),
|
base_repo: PathBuf::from("/home/naps62/projects/maestro"),
|
||||||
branch: "feat/foo".into(),
|
branch: "feat/foo".into(),
|
||||||
cleanup_on_kill: true,
|
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -577,7 +654,6 @@ mod tests {
|
|||||||
});
|
});
|
||||||
let spec: WorktreeSpec = serde_json::from_value(json).unwrap();
|
let spec: WorktreeSpec = serde_json::from_value(json).unwrap();
|
||||||
assert!(spec.create_branch);
|
assert!(spec.create_branch);
|
||||||
assert!(spec.cleanup_on_kill);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -585,7 +661,6 @@ mod tests {
|
|||||||
let spec = WorktreeSpec {
|
let spec = WorktreeSpec {
|
||||||
base_repo: PathBuf::from("/work"),
|
base_repo: PathBuf::from("/work"),
|
||||||
branch: "feat/foo".into(),
|
branch: "feat/foo".into(),
|
||||||
cleanup_on_kill: true,
|
|
||||||
create_branch: false,
|
create_branch: false,
|
||||||
};
|
};
|
||||||
assert_eq!(spec, round_trip(&spec));
|
assert_eq!(spec, round_trip(&spec));
|
||||||
@@ -645,6 +720,22 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kill_outcome_round_trips_a_retained_worktree() {
|
||||||
|
let outcome = KillOutcome {
|
||||||
|
retained: Some(sample_retained_worktree()),
|
||||||
|
};
|
||||||
|
assert_eq!(outcome, round_trip(&outcome));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A kill with nothing to retain — no worktree, or a clean one removed as
|
||||||
|
/// usual — decodes from an absent field.
|
||||||
|
#[test]
|
||||||
|
fn kill_outcome_defaults_to_nothing_retained() {
|
||||||
|
let outcome: KillOutcome = serde_json::from_value(serde_json::json!({})).unwrap();
|
||||||
|
assert_eq!(outcome.retained, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn catalog_event_round_trips_and_tags() {
|
fn catalog_event_round_trips_and_tags() {
|
||||||
let session = sample_session();
|
let session = sample_session();
|
||||||
@@ -684,6 +775,7 @@ mod tests {
|
|||||||
ClientControl::Error(ErrorInfo {
|
ClientControl::Error(ErrorInfo {
|
||||||
code: "bad_frame".into(),
|
code: "bad_frame".into(),
|
||||||
message: "decode failed".into(),
|
message: "decode failed".into(),
|
||||||
|
unsaved_work: None,
|
||||||
}),
|
}),
|
||||||
resize,
|
resize,
|
||||||
] {
|
] {
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ pub const PATH_QUERY: &str = "path";
|
|||||||
/// Name of the query parameter [`NAMES`] takes.
|
/// Name of the query parameter [`NAMES`] takes.
|
||||||
pub const COUNT_QUERY: &str = "count";
|
pub const COUNT_QUERY: &str = "count";
|
||||||
|
|
||||||
|
/// Name of the boolean query parameter `DELETE /api/sessions/:id` takes (#81).
|
||||||
|
/// `force=true` overrides the kill refusal and retains the worktree.
|
||||||
|
pub const FORCE_QUERY: &str = "force";
|
||||||
|
|
||||||
/// `DELETE /api/sessions/:id`.
|
/// `DELETE /api/sessions/:id`.
|
||||||
pub fn session(id: Uuid) -> String {
|
pub fn session(id: Uuid) -> String {
|
||||||
format!("{SESSIONS}/{id}")
|
format!("{SESSIONS}/{id}")
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
|||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ pub enum Action {
|
|||||||
/// resolved; unresolvable defaults open a pre-filled draft instead.
|
/// resolved; unresolvable defaults open a pre-filled draft instead.
|
||||||
FastForwardSpawn,
|
FastForwardSpawn,
|
||||||
KillHighlighted,
|
KillHighlighted,
|
||||||
|
/// Open the retained-worktrees view (#85).
|
||||||
|
ShowWorktrees,
|
||||||
|
|
||||||
// Tabs
|
// Tabs
|
||||||
NewTab,
|
NewTab,
|
||||||
@@ -84,6 +86,7 @@ impl Action {
|
|||||||
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
||||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||||
|
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
|
||||||
"NewTab" => return Ok(Action::NewTab),
|
"NewTab" => return Ok(Action::NewTab),
|
||||||
"CloseTab" => return Ok(Action::CloseTab),
|
"CloseTab" => return Ok(Action::CloseTab),
|
||||||
"NextTab" => return Ok(Action::NextTab),
|
"NextTab" => return Ok(Action::NextTab),
|
||||||
|
|||||||
+320
-17
@@ -18,7 +18,9 @@ use std::time::Duration;
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use crossterm::event::{Event as CtEvent, EventStream};
|
use crossterm::event::{Event as CtEvent, EventStream};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
|
use maestro_protocol::{
|
||||||
|
routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch, RetainedWorktree,
|
||||||
|
};
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -37,6 +39,7 @@ use crate::session_task::{SessionInput, SessionTask};
|
|||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
|
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
|
||||||
use crate::tabs::TabBar;
|
use crate::tabs::TabBar;
|
||||||
|
use crate::worktrees::WorktreesView;
|
||||||
use crate::TuiError;
|
use crate::TuiError;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -55,7 +58,8 @@ pub struct App {
|
|||||||
help: HelpOverlay,
|
help: HelpOverlay,
|
||||||
chord: ChordState,
|
chord: ChordState,
|
||||||
daemon: DaemonClient,
|
daemon: DaemonClient,
|
||||||
/// Results of draft data loads, delivered back to the event loop.
|
/// Results of draft data loads (and the worktrees view fetch, #85),
|
||||||
|
/// delivered back to the event loop.
|
||||||
///
|
///
|
||||||
/// These are network calls — `/api/projects` on a cold cache walks all of
|
/// These are network calls — `/api/projects` on a cold cache walks all of
|
||||||
/// `$HOME` — and awaiting them inside `perform` froze the whole TUI: no
|
/// `$HOME` — and awaiting them inside `perform` froze the whole TUI: no
|
||||||
@@ -66,6 +70,9 @@ pub struct App {
|
|||||||
/// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet:
|
/// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet:
|
||||||
/// `GET /api/names` (#64).
|
/// `GET /api/names` (#64).
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
|
/// Entries of the retained-worktrees view (#85). Daemon data, kept here
|
||||||
|
/// rather than in `AppState` so the overlay kind stays payload-free.
|
||||||
|
worktrees: WorktreesView,
|
||||||
region: RegionMap,
|
region: RegionMap,
|
||||||
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
||||||
clicks: ClickTracker,
|
clicks: ClickTracker,
|
||||||
@@ -109,6 +116,11 @@ pub enum Effect {
|
|||||||
},
|
},
|
||||||
/// Fetch the draft target's branches from `/api/branches` (#65).
|
/// Fetch the draft target's branches from `/api/branches` (#65).
|
||||||
LoadBranches(usize),
|
LoadBranches(usize),
|
||||||
|
/// Fetch `/api/worktrees` for the retained-worktrees view (#85).
|
||||||
|
LoadWorktrees,
|
||||||
|
/// Reclaim one retained worktree (#85) — removes its directory; the
|
||||||
|
/// branch is never deleted.
|
||||||
|
ReclaimWorktree(Uuid),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -132,6 +144,7 @@ impl App {
|
|||||||
chord: ChordState::new(chord_timeout),
|
chord: ChordState::new(chord_timeout),
|
||||||
daemon,
|
daemon,
|
||||||
http,
|
http,
|
||||||
|
worktrees: WorktreesView::default(),
|
||||||
draft_tx,
|
draft_tx,
|
||||||
draft_rx,
|
draft_rx,
|
||||||
region: RegionMap::default(),
|
region: RegionMap::default(),
|
||||||
@@ -162,7 +175,8 @@ impl App {
|
|||||||
// Render on next loop iteration.
|
// Render on next loop iteration.
|
||||||
}
|
}
|
||||||
_ = tokio::time::sleep(Duration::from_millis(33)) => {
|
_ = tokio::time::sleep(Duration::from_millis(33)) => {
|
||||||
// 30 Hz heartbeat.
|
let effects = self.poll_due_probes_at(std::time::Instant::now());
|
||||||
|
self.perform(effects).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,6 +239,8 @@ impl App {
|
|||||||
self.spawn_probe_git_root(tab, seq, path)
|
self.spawn_probe_git_root(tab, seq, path)
|
||||||
}
|
}
|
||||||
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
|
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
|
||||||
|
Effect::LoadWorktrees => self.spawn_load_worktrees(),
|
||||||
|
Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await,
|
||||||
Effect::Forward { id, bytes } => {
|
Effect::Forward { id, bytes } => {
|
||||||
if let Some(handle) = self.emulators.get(id) {
|
if let Some(handle) = self.emulators.get(id) {
|
||||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||||
@@ -247,6 +263,44 @@ impl App {
|
|||||||
}
|
}
|
||||||
_ => (OverlayDecision::Consumed, Vec::new()),
|
_ => (OverlayDecision::Consumed, Vec::new()),
|
||||||
},
|
},
|
||||||
|
Some(OverlayKind::Worktrees) => match key {
|
||||||
|
InputKey::Char { c: 'j', .. } | InputKey::Down => {
|
||||||
|
self.worktrees.move_highlight(1);
|
||||||
|
(OverlayDecision::Consumed, Vec::new())
|
||||||
|
}
|
||||||
|
InputKey::Char { c: 'k', .. } | InputKey::Up => {
|
||||||
|
self.worktrees.move_highlight(-1);
|
||||||
|
(OverlayDecision::Consumed, Vec::new())
|
||||||
|
}
|
||||||
|
InputKey::Char { c: 'r', .. } => {
|
||||||
|
if let Some(wt) = self.worktrees.selected() {
|
||||||
|
self.state.show_overlay(OverlayKind::ReclaimConfirm {
|
||||||
|
id: wt.id,
|
||||||
|
branch: wt.branch.clone(),
|
||||||
|
// Shared phrasing: InTheWay's Display impl is what
|
||||||
|
// the kill refusal and the web client use too.
|
||||||
|
blockers: wt.in_the_way.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(OverlayDecision::Consumed, Vec::new())
|
||||||
|
}
|
||||||
|
InputKey::Char { c: 'q', .. } | InputKey::Esc => {
|
||||||
|
self.state.dismiss_overlay();
|
||||||
|
(OverlayDecision::Consumed, Vec::new())
|
||||||
|
}
|
||||||
|
_ => (OverlayDecision::Consumed, Vec::new()),
|
||||||
|
},
|
||||||
|
Some(OverlayKind::ReclaimConfirm { id, .. }) => match key {
|
||||||
|
InputKey::Char { c: 'y', .. } => {
|
||||||
|
self.state.dismiss_overlay();
|
||||||
|
(OverlayDecision::Consumed, vec![Effect::ReclaimWorktree(id)])
|
||||||
|
}
|
||||||
|
InputKey::Char { c: 'n', .. } | InputKey::Esc => {
|
||||||
|
self.state.show_overlay(OverlayKind::Worktrees);
|
||||||
|
(OverlayDecision::Consumed, Vec::new())
|
||||||
|
}
|
||||||
|
_ => (OverlayDecision::Consumed, Vec::new()),
|
||||||
|
},
|
||||||
Some(OverlayKind::Help) => {
|
Some(OverlayKind::Help) => {
|
||||||
self.state.dismiss_overlay();
|
self.state.dismiss_overlay();
|
||||||
(OverlayDecision::Consumed, Vec::new())
|
(OverlayDecision::Consumed, Vec::new())
|
||||||
@@ -317,6 +371,10 @@ impl App {
|
|||||||
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Action::ShowWorktrees => {
|
||||||
|
self.state.show_overlay(OverlayKind::Worktrees);
|
||||||
|
effects.push(Effect::LoadWorktrees);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
effects
|
effects
|
||||||
}
|
}
|
||||||
@@ -382,7 +440,7 @@ impl App {
|
|||||||
if draft.take_name_refill() {
|
if draft.take_name_refill() {
|
||||||
effects.push(Effect::RefillNames(tab_idx));
|
effects.push(Effect::RefillNames(tab_idx));
|
||||||
}
|
}
|
||||||
if let Some((seq, path)) = draft.take_gitroot_probe() {
|
if let Some((seq, path)) = draft.take_gitroot_probe(std::time::Instant::now()) {
|
||||||
effects.push(Effect::ProbeGitRoot {
|
effects.push(Effect::ProbeGitRoot {
|
||||||
tab: tab_idx,
|
tab: tab_idx,
|
||||||
seq,
|
seq,
|
||||||
@@ -395,6 +453,26 @@ impl App {
|
|||||||
effects
|
effects
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fire any draft gitroot probes whose debounce window has elapsed (#74).
|
||||||
|
/// The 30 Hz heartbeat calls this so a burst of typing issues its one
|
||||||
|
/// probe even after the last keystroke.
|
||||||
|
fn poll_due_probes_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
|
||||||
|
let mut effects = Vec::new();
|
||||||
|
for tab_idx in 0..self.state.tabs.len() {
|
||||||
|
let Some(PaneRef::Draft(draft)) = self.state.tabs[tab_idx].pane.as_mut() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some((seq, path)) = draft.take_gitroot_probe(now) {
|
||||||
|
effects.push(Effect::ProbeGitRoot {
|
||||||
|
tab: tab_idx,
|
||||||
|
seq,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
effects
|
||||||
|
}
|
||||||
|
|
||||||
/// Populate a freshly opened draft: presets from `/api/info` (falling back
|
/// Populate a freshly opened draft: presets from `/api/info` (falling back
|
||||||
/// to the built-ins already in place), projects from `/api/projects`, and
|
/// 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 first name batch. Applies only if that tab still shows the draft —
|
||||||
@@ -505,13 +583,22 @@ impl App {
|
|||||||
self.fast_forward_spawn(info, projects, names).await;
|
self.fast_forward_spawn(info, projects, names).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if let DraftUpdate::WorktreesLoaded {
|
||||||
|
worktrees,
|
||||||
|
home_dir,
|
||||||
|
} = update
|
||||||
|
{
|
||||||
|
self.worktrees.apply(worktrees);
|
||||||
|
self.worktrees.home_dir = home_dir;
|
||||||
|
return;
|
||||||
|
}
|
||||||
let want = match &update {
|
let want = match &update {
|
||||||
DraftUpdate::Data { draft, .. }
|
DraftUpdate::Data { draft, .. }
|
||||||
| DraftUpdate::Names { draft, .. }
|
| DraftUpdate::Names { draft, .. }
|
||||||
| DraftUpdate::GitRoot { draft, .. }
|
| DraftUpdate::GitRoot { draft, .. }
|
||||||
| DraftUpdate::Branches { draft, .. } => *draft,
|
| DraftUpdate::Branches { draft, .. } => *draft,
|
||||||
// Handled above; it targets no particular draft.
|
// Handled above; it targets no particular draft.
|
||||||
DraftUpdate::FastForward { .. } => unreachable!(),
|
DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(),
|
||||||
};
|
};
|
||||||
// Find it by identity: closing a tab shifts every index to its right,
|
// 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
|
// so an in-flight load addressed by index would be dropped or land on
|
||||||
@@ -566,7 +653,7 @@ impl App {
|
|||||||
DraftUpdate::Branches { cwd, branches, .. } => {
|
DraftUpdate::Branches { cwd, branches, .. } => {
|
||||||
draft.set_branches(&cwd, branches);
|
draft.set_branches(&cwd, branches);
|
||||||
}
|
}
|
||||||
DraftUpdate::FastForward { .. } => unreachable!(),
|
DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(),
|
||||||
}
|
}
|
||||||
if follow_up_branches {
|
if follow_up_branches {
|
||||||
self.spawn_load_branches(tab_idx);
|
self.spawn_load_branches(tab_idx);
|
||||||
@@ -667,12 +754,50 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn submit_kill(&mut self, sid: Uuid) {
|
async fn submit_kill(&mut self, sid: Uuid) {
|
||||||
match self.daemon.kill_session(sid).await {
|
match self.daemon.kill_session(sid, false).await {
|
||||||
Ok(()) => self.release_session(sid),
|
Ok(outcome) => {
|
||||||
|
if let Some(w) = outcome.retained {
|
||||||
|
self.state
|
||||||
|
.toast(format!("worktree retained: {}", w.path.display()));
|
||||||
|
}
|
||||||
|
self.release_session(sid)
|
||||||
|
}
|
||||||
Err(e) => self.state.toast(format!("kill failed: {e}")),
|
Err(e) => self.state.toast(format!("kill failed: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch the retained-worktree store without blocking the loop (#85) —
|
||||||
|
/// same off-loop pattern as the draft loaders.
|
||||||
|
fn spawn_load_worktrees(&self) {
|
||||||
|
let (daemon, _, _, tx) = self.loader_handles();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (worktrees, info) = tokio::join!(daemon.list_worktrees(), daemon.daemon_info());
|
||||||
|
let _ = tx.send(DraftUpdate::WorktreesLoaded {
|
||||||
|
worktrees: worktrees.unwrap_or_default(),
|
||||||
|
home_dir: info.map(|i| i.home_dir).unwrap_or_default(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reclaim one retained worktree (#85): the directory goes, the branch
|
||||||
|
/// never does. The open view drops the entry so the list tells the truth
|
||||||
|
/// without waiting for a re-fetch.
|
||||||
|
async fn submit_reclaim(&mut self, id: Uuid) {
|
||||||
|
match self.daemon.reclaim_worktree(id).await {
|
||||||
|
Ok(()) => {
|
||||||
|
self.worktrees.remove(id);
|
||||||
|
self.state.toast("worktree reclaimed; branch kept");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let message = e
|
||||||
|
.daemon_message()
|
||||||
|
.map(String::from)
|
||||||
|
.unwrap_or_else(|| e.to_string());
|
||||||
|
self.state.toast(format!("reclaim failed: {message}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn redraw(
|
fn redraw(
|
||||||
&mut self,
|
&mut self,
|
||||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||||
@@ -713,6 +838,7 @@ impl App {
|
|||||||
state: &self.state,
|
state: &self.state,
|
||||||
draft,
|
draft,
|
||||||
help: &self.help,
|
help: &self.help,
|
||||||
|
worktrees: Some(&self.worktrees),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut new_region = RegionMap::default();
|
let mut new_region = RegionMap::default();
|
||||||
@@ -857,6 +983,11 @@ enum DraftUpdate {
|
|||||||
projects: Option<Vec<maestro_protocol::Project>>,
|
projects: Option<Vec<maestro_protocol::Project>>,
|
||||||
names: Vec<String>,
|
names: Vec<String>,
|
||||||
},
|
},
|
||||||
|
/// The retained-worktree store arrived for the worktrees view (#85).
|
||||||
|
WorktreesLoaded {
|
||||||
|
worktrees: Vec<RetainedWorktree>,
|
||||||
|
home_dir: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`.
|
/// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`.
|
||||||
@@ -997,6 +1128,7 @@ mod tests {
|
|||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
|
blocked_work: None,
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
app.dispatch(Action::SidebarNext);
|
app.dispatch(Action::SidebarNext);
|
||||||
@@ -1236,10 +1368,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Editing the raw path with the worktree toggle on schedules a
|
/// Editing the raw path schedules a `/api/gitroot` probe only once the
|
||||||
/// `/api/gitroot` probe so the section learns its base repo (#65).
|
/// keystroke burst settles (#74): nothing fires inside the debounce
|
||||||
|
/// window; the heartbeat picks the due probe up afterwards.
|
||||||
#[test]
|
#[test]
|
||||||
fn typing_a_raw_path_schedules_a_gitroot_probe() {
|
fn typing_a_raw_path_schedules_a_debounced_gitroot_probe() {
|
||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
app.dispatch(Action::NewDraftPane);
|
app.dispatch(Action::NewDraftPane);
|
||||||
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
||||||
@@ -1248,13 +1381,24 @@ mod tests {
|
|||||||
draft.cwd_raw_visible = true;
|
draft.cwd_raw_visible = true;
|
||||||
draft.focused_field = DraftField::CwdRaw;
|
draft.focused_field = DraftField::CwdRaw;
|
||||||
|
|
||||||
let effects = app.handle_draft_input(&InputKey::char('/'));
|
// A burst of keystrokes fires nothing.
|
||||||
|
for c in "/opt/tooling".chars() {
|
||||||
|
assert!(app.handle_draft_input(&InputKey::char(c)).is_empty());
|
||||||
|
assert!(app.poll_due_probes_at(std::time::Instant::now()).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the quiet period passes, exactly one probe is due.
|
||||||
|
let due = std::time::Instant::now()
|
||||||
|
+ std::time::Duration::from_millis(crate::draft::GITROOT_PROBE_DEBOUNCE_MS) * 10;
|
||||||
|
match app.poll_due_probes_at(due).as_slice() {
|
||||||
|
[Effect::ProbeGitRoot { tab: 0, path, .. }] => {
|
||||||
|
assert_eq!(path, &std::path::PathBuf::from("/opt/tooling"));
|
||||||
|
}
|
||||||
|
other => panic!("expected one ProbeGitRoot effect, got {other:?}"),
|
||||||
|
}
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
app.poll_due_probes_at(due).is_empty(),
|
||||||
effects.as_slice(),
|
"the probe is consumed once"
|
||||||
[Effect::ProbeGitRoot { tab: 0, seq: 2, .. }]
|
|
||||||
),
|
|
||||||
"{effects:?}"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1504,9 +1648,168 @@ mod tests {
|
|||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
|
blocked_work: None,
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
app.dispatch(Action::KillHighlighted);
|
app.dispatch(Action::KillHighlighted);
|
||||||
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid));
|
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- retained worktrees (#85) -------------------------------------------
|
||||||
|
|
||||||
|
fn sample_worktree(id: u128) -> maestro_protocol::RetainedWorktree {
|
||||||
|
use chrono::TimeZone;
|
||||||
|
maestro_protocol::RetainedWorktree {
|
||||||
|
id: Uuid::from_u128(id),
|
||||||
|
path: "/home/naps/tea/maestro/worktrees/feat/login".into(),
|
||||||
|
base_repo: "/home/naps/tea/maestro".into(),
|
||||||
|
branch: "feat/login".into(),
|
||||||
|
reason: maestro_protocol::RetainedReason::ForceKilled,
|
||||||
|
retained_at: chrono::Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
|
||||||
|
in_the_way: maestro_protocol::InTheWay {
|
||||||
|
uncommitted_files: 2,
|
||||||
|
untracked_files: 1,
|
||||||
|
unpushed_commits: 3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_with_one_retained_worktree() -> App {
|
||||||
|
let mut app = make_app();
|
||||||
|
app.worktrees.home_dir = "/home/naps".into();
|
||||||
|
app.worktrees.apply(vec![sample_worktree(42)]);
|
||||||
|
app.state.show_overlay(OverlayKind::Worktrees);
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_worktrees_opens_the_view_and_fetches() {
|
||||||
|
let mut app = make_app();
|
||||||
|
assert_eq!(
|
||||||
|
app.dispatch(Action::ShowWorktrees),
|
||||||
|
vec![Effect::LoadWorktrees]
|
||||||
|
);
|
||||||
|
assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn r_over_the_highlighted_worktree_asks_for_a_confirmation() {
|
||||||
|
let mut app = app_with_one_retained_worktree();
|
||||||
|
|
||||||
|
let (decision, effects) = app.handle_overlay_input(&InputKey::char('r'));
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
match app.state.overlay() {
|
||||||
|
Some(OverlayKind::ReclaimConfirm { id, branch, .. }) => {
|
||||||
|
assert_eq!(*id, Uuid::from_u128(42));
|
||||||
|
assert_eq!(branch, "feat/login");
|
||||||
|
}
|
||||||
|
other => panic!("expected a reclaim confirm, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The confirmation names what is in the way using the shared phrasing,
|
||||||
|
/// so the confirm step cannot drift from the list or the CLI.
|
||||||
|
#[test]
|
||||||
|
fn the_reclaim_confirmation_carries_the_shared_in_the_way_phrasing() {
|
||||||
|
let mut app = app_with_one_retained_worktree();
|
||||||
|
app.handle_overlay_input(&InputKey::char('r'));
|
||||||
|
match app.state.overlay() {
|
||||||
|
Some(OverlayKind::ReclaimConfirm { blockers, .. }) => {
|
||||||
|
assert_eq!(
|
||||||
|
blockers,
|
||||||
|
"2 uncommitted files, 1 untracked file, 3 unpushed commits"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected a reclaim confirm, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirming_a_reclaim_asks_for_the_reclaim() {
|
||||||
|
let mut app = app_with_one_retained_worktree();
|
||||||
|
app.handle_overlay_input(&InputKey::char('r'));
|
||||||
|
|
||||||
|
let (decision, effects) = app.handle_overlay_input(&InputKey::char('y'));
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
|
assert_eq!(effects, vec![Effect::ReclaimWorktree(Uuid::from_u128(42))]);
|
||||||
|
assert!(app.state.overlay().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn declining_a_reclaim_returns_to_the_list() {
|
||||||
|
let mut app = app_with_one_retained_worktree();
|
||||||
|
app.handle_overlay_input(&InputKey::char('r'));
|
||||||
|
|
||||||
|
let (decision, effects) = app.handle_overlay_input(&InputKey::char('n'));
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn j_and_k_move_the_highlight_inside_the_view() {
|
||||||
|
let mut app = make_app();
|
||||||
|
app.worktrees
|
||||||
|
.apply(vec![sample_worktree(1), sample_worktree(2)]);
|
||||||
|
// Distinct ids so movement is observable.
|
||||||
|
app.worktrees.items[1].branch = "other".into();
|
||||||
|
app.state.show_overlay(OverlayKind::Worktrees);
|
||||||
|
|
||||||
|
app.handle_overlay_input(&InputKey::char('j'));
|
||||||
|
assert_eq!(app.worktrees.selected().unwrap().branch, "other");
|
||||||
|
app.handle_overlay_input(&InputKey::char('k'));
|
||||||
|
assert_eq!(app.worktrees.selected().unwrap().branch, "feat/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn loading_worktrees_fills_the_view_from_the_daemon() {
|
||||||
|
use axum::routing::{delete, 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/naps"}))
|
||||||
|
}
|
||||||
|
async fn worktrees() -> Json<serde_json::Value> {
|
||||||
|
Json(json!([{
|
||||||
|
"id": Uuid::from_u128(42).to_string(),
|
||||||
|
"path": "/home/naps/tea/maestro/worktrees/feat/login",
|
||||||
|
"base_repo": "/home/naps/tea/maestro",
|
||||||
|
"branch": "feat/login",
|
||||||
|
"reason": "force-killed",
|
||||||
|
"retained_at": "2026-08-24T10:00:00Z",
|
||||||
|
"in_the_way": {"uncommitted_files": 2, "untracked_files": 1, "unpushed_commits": 3},
|
||||||
|
}]))
|
||||||
|
}
|
||||||
|
async fn reclaim() -> Json<serde_json::Value> {
|
||||||
|
Json(json!(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_router = axum::Router::new()
|
||||||
|
.route(maestro_protocol::routes::INFO, get(info))
|
||||||
|
.route(maestro_protocol::routes::WORKTREES, get(worktrees))
|
||||||
|
.route("/api/worktrees/:id", delete(reclaim));
|
||||||
|
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::ShowWorktrees);
|
||||||
|
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_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees));
|
||||||
|
assert_eq!(app.worktrees.items.len(), 1);
|
||||||
|
assert_eq!(app.worktrees.items[0].branch, "feat/login");
|
||||||
|
assert_eq!(app.worktrees.home_dir, "/home/naps");
|
||||||
|
|
||||||
|
// Reclaiming drops the entry from the open view.
|
||||||
|
app.submit_reclaim(Uuid::from_u128(42)).await;
|
||||||
|
assert!(app.worktrees.items.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ mod tests {
|
|||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
// thing with every default resolved and no form.
|
// thing with every default resolved and no form.
|
||||||
("S", "FastForwardSpawn"),
|
("S", "FastForwardSpawn"),
|
||||||
("x", "KillHighlighted"),
|
("x", "KillHighlighted"),
|
||||||
|
("w", "ShowWorktrees"),
|
||||||
// Tabs
|
// Tabs
|
||||||
("ctrl-t", "NewTab"),
|
("ctrl-t", "NewTab"),
|
||||||
("g t", "NextTab"),
|
("g t", "NextTab"),
|
||||||
@@ -166,6 +167,10 @@ mod tests {
|
|||||||
cfg.keymap.resolve(Mode::Normal, "S"),
|
cfg.keymap.resolve(Mode::Normal, "S"),
|
||||||
Some(Action::FastForwardSpawn),
|
Some(Action::FastForwardSpawn),
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.keymap.resolve(Mode::Normal, "w"),
|
||||||
|
Some(Action::ShowWorktrees),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -233,6 +238,26 @@ mod tests {
|
|||||||
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression for #70: the previous canonical form of a real Shift+S
|
||||||
|
/// keypress was `"shift-S"`, which the default `"S"` binding never
|
||||||
|
/// matched. Drive the actual crossterm event, not the binding string,
|
||||||
|
/// so the dead-key bug can't come back unnoticed.
|
||||||
|
#[test]
|
||||||
|
fn real_shift_s_keypress_resolves_to_fast_forward_spawn() {
|
||||||
|
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
|
use crate::input::{canonicalise, Region};
|
||||||
|
|
||||||
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||||
|
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
|
||||||
|
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
cfg.keymap.resolve(Mode::Normal, &key.canonical()),
|
||||||
|
Some(Action::FastForwardSpawn),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn custom_chord_timeout_is_applied() {
|
fn custom_chord_timeout_is_applied() {
|
||||||
let mut section = TuiSection::default();
|
let mut section = TuiSection::default();
|
||||||
|
|||||||
+140
-15
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use maestro_config::{builtin_presets, custom_preset};
|
use maestro_config::{builtin_presets, custom_preset};
|
||||||
@@ -35,6 +36,12 @@ use crate::projects;
|
|||||||
/// dry.
|
/// dry.
|
||||||
pub const NAME_BATCH_SIZE: usize = 20;
|
pub const NAME_BATCH_SIZE: usize = 20;
|
||||||
|
|
||||||
|
/// Quiet period after raw-path typing before a `/api/gitroot` probe fires
|
||||||
|
/// (#74). Every keystroke resets it, so a burst issues one request once the
|
||||||
|
/// operator pauses. Must stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in
|
||||||
|
/// web/src/spawnForm.ts so both clients debounce alike.
|
||||||
|
pub const GITROOT_PROBE_DEBOUNCE_MS: u64 = 250;
|
||||||
|
|
||||||
/// Width of the fixed label gutter every form line starts with (`cmd: `,
|
/// Width of the fixed label gutter every form line starts with (`cmd: `,
|
||||||
/// `dir: `, …).
|
/// `dir: `, …).
|
||||||
const GUTTER: usize = 5;
|
const GUTTER: usize = 5;
|
||||||
@@ -149,6 +156,9 @@ pub struct DraftPane {
|
|||||||
/// Bumped on every target change; answers carrying an older seq are stale.
|
/// Bumped on every target change; answers carrying an older seq are stale.
|
||||||
probe_seq: u32,
|
probe_seq: u32,
|
||||||
needs_probe: bool,
|
needs_probe: bool,
|
||||||
|
/// When the pending probe may fire (debounce, #74). Typing into the raw
|
||||||
|
/// path resets it; a discrete action like the toggle makes it due at once.
|
||||||
|
probe_due_at: Option<Instant>,
|
||||||
/// Set when a probe found no repository (drives the rendered hint).
|
/// Set when a probe found no repository (drives the rendered hint).
|
||||||
repo_missing: bool,
|
repo_missing: bool,
|
||||||
/// New-branch name. Tracks [`Self::name`] until edited, like the web form.
|
/// New-branch name. Tracks [`Self::name`] until edited, like the web form.
|
||||||
@@ -198,6 +208,7 @@ impl Default for DraftPane {
|
|||||||
probed_cwd: None,
|
probed_cwd: None,
|
||||||
probe_seq: 0,
|
probe_seq: 0,
|
||||||
needs_probe: false,
|
needs_probe: false,
|
||||||
|
probe_due_at: None,
|
||||||
repo_missing: false,
|
repo_missing: false,
|
||||||
branch: crate::text_input::TextInput::default(),
|
branch: crate::text_input::TextInput::default(),
|
||||||
branch_edited: false,
|
branch_edited: false,
|
||||||
@@ -320,18 +331,23 @@ impl DraftPane {
|
|||||||
self.repo_missing = false;
|
self.repo_missing = false;
|
||||||
self.probe_seq += 1;
|
self.probe_seq += 1;
|
||||||
self.needs_probe = false;
|
self.needs_probe = false;
|
||||||
|
self.probe_due_at = None;
|
||||||
self.probed_cwd = self.cwd();
|
self.probed_cwd = self.cwd();
|
||||||
self.branches_for = None;
|
self.branches_for = None;
|
||||||
self.schedule_branch_load();
|
self.schedule_branch_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A target with an unknown repo (raw path edit or the Other… hatch).
|
/// A target with an unknown repo (raw path edit or the Other… hatch).
|
||||||
/// Cancels any in-flight probe and requests a fresh one.
|
/// Cancels any in-flight probe and schedules a fresh one after the
|
||||||
|
/// debounce window (#74).
|
||||||
fn note_unknown_repo(&mut self) {
|
fn note_unknown_repo(&mut self) {
|
||||||
self.base_repo = None;
|
self.base_repo = None;
|
||||||
self.repo_missing = false;
|
self.repo_missing = false;
|
||||||
self.probe_seq += 1;
|
self.probe_seq += 1;
|
||||||
self.needs_probe = self.worktree_enabled && self.cwd().is_some();
|
self.needs_probe = self.worktree_enabled && self.cwd().is_some();
|
||||||
|
self.probe_due_at = self
|
||||||
|
.needs_probe
|
||||||
|
.then(|| Instant::now() + Duration::from_millis(GITROOT_PROBE_DEBOUNCE_MS));
|
||||||
self.branches_for = None;
|
self.branches_for = None;
|
||||||
self.schedule_branch_load();
|
self.schedule_branch_load();
|
||||||
}
|
}
|
||||||
@@ -342,14 +358,19 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consume the gitroot-probe request: the caller fetches
|
/// Consume the gitroot-probe request once its debounce window has passed
|
||||||
|
/// (`now` at or after [`Self::probe_due_at`]): the caller fetches
|
||||||
/// `GET /api/gitroot?path=…` and reports back via [`Self::resolve_gitroot`]
|
/// `GET /api/gitroot?path=…` and reports back via [`Self::resolve_gitroot`]
|
||||||
/// with the same seq.
|
/// with the same seq. Returns `None` while typing is still in flight.
|
||||||
pub fn take_gitroot_probe(&mut self) -> Option<(u32, PathBuf)> {
|
pub fn take_gitroot_probe(&mut self, now: Instant) -> Option<(u32, PathBuf)> {
|
||||||
if !self.needs_probe {
|
if !self.needs_probe {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
if now < self.probe_due_at? {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
self.needs_probe = false;
|
self.needs_probe = false;
|
||||||
|
self.probe_due_at = None;
|
||||||
let cwd = self.cwd()?;
|
let cwd = self.cwd()?;
|
||||||
self.probe_seq += 1;
|
self.probe_seq += 1;
|
||||||
Some((self.probe_seq, cwd))
|
Some((self.probe_seq, cwd))
|
||||||
@@ -363,6 +384,7 @@ impl DraftPane {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.needs_probe = false;
|
self.needs_probe = false;
|
||||||
|
self.probe_due_at = None;
|
||||||
self.probed_cwd = self.cwd();
|
self.probed_cwd = self.cwd();
|
||||||
match root {
|
match root {
|
||||||
Some(repo) => {
|
Some(repo) => {
|
||||||
@@ -434,7 +456,9 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
if self.base_repo.is_none() {
|
if self.base_repo.is_none() {
|
||||||
self.probe_seq += 1;
|
self.probe_seq += 1;
|
||||||
|
// A discrete toggle, not a keystroke burst: due immediately.
|
||||||
self.needs_probe = cwd.is_some();
|
self.needs_probe = cwd.is_some();
|
||||||
|
self.probe_due_at = self.needs_probe.then(Instant::now);
|
||||||
}
|
}
|
||||||
self.schedule_branch_load();
|
self.schedule_branch_load();
|
||||||
}
|
}
|
||||||
@@ -549,7 +573,6 @@ impl DraftPane {
|
|||||||
WorktreeSpec {
|
WorktreeSpec {
|
||||||
base_repo,
|
base_repo,
|
||||||
branch: branch.to_string(),
|
branch: branch.to_string(),
|
||||||
cleanup_on_kill: true,
|
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -562,7 +585,6 @@ impl DraftPane {
|
|||||||
WorktreeSpec {
|
WorktreeSpec {
|
||||||
base_repo,
|
base_repo,
|
||||||
branch: name,
|
branch: name,
|
||||||
cleanup_on_kill: true,
|
|
||||||
create_branch: false,
|
create_branch: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -656,6 +678,11 @@ impl DraftPane {
|
|||||||
let project = visible[sel].project.clone();
|
let project = visible[sel].project.clone();
|
||||||
self.picked_cwd = Some(project.path.clone());
|
self.picked_cwd = Some(project.path.clone());
|
||||||
self.cwd_raw_visible = false;
|
self.cwd_raw_visible = false;
|
||||||
|
// A directory that *is* a linked worktree defaults to spawning in
|
||||||
|
// place (#91): creating a second worktree of its branch fails.
|
||||||
|
// Turning it back on by hand still tries (git's own message),
|
||||||
|
// which is deliberate (#91 non-goal).
|
||||||
|
self.worktree_enabled = !project.is_worktree;
|
||||||
// The scan already knows this directory's repo (#61): no probe
|
// The scan already knows this directory's repo (#61): no probe
|
||||||
// round trip needed, exactly like the web form.
|
// round trip needed, exactly like the web form.
|
||||||
let repo = project
|
let repo = project
|
||||||
@@ -1186,7 +1213,7 @@ fn draw_text_field(
|
|||||||
/// `~` abbreviation plus leading-segment collapsing, matching the web UI's
|
/// `~` abbreviation plus leading-segment collapsing, matching the web UI's
|
||||||
/// `abbreviateCwd` + `abbreviateFolderName`: `/home/u/tea/Maestro` renders as
|
/// `abbreviateCwd` + `abbreviateFolderName`: `/home/u/tea/Maestro` renders as
|
||||||
/// `/h/u/t/Maestro` given home `/home/u`.
|
/// `/h/u/t/Maestro` given home `/home/u`.
|
||||||
fn abbreviate_path(path: &Path, home: &str) -> String {
|
pub(crate) fn abbreviate_path(path: &Path, home: &str) -> String {
|
||||||
let raw = path.to_string_lossy();
|
let raw = path.to_string_lossy();
|
||||||
let abbreviated = if !home.is_empty() {
|
let abbreviated = if !home.is_empty() {
|
||||||
if raw == home {
|
if raw == home {
|
||||||
@@ -1274,6 +1301,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A moment comfortably past the probe debounce window (#74).
|
||||||
|
fn probe_due() -> Instant {
|
||||||
|
Instant::now() + Duration::from_millis(GITROOT_PROBE_DEBOUNCE_MS) * 10
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn defaults_fall_back_to_builtin_presets_with_custom_last() {
|
fn defaults_fall_back_to_builtin_presets_with_custom_last() {
|
||||||
let draft = DraftPane::default();
|
let draft = DraftPane::default();
|
||||||
@@ -1461,7 +1493,7 @@ mod tests {
|
|||||||
typed(&mut draft, "/opt/tooling");
|
typed(&mut draft, "/opt/tooling");
|
||||||
assert_eq!(draft.cwd(), Some(PathBuf::from("/home/u/opt/tooling")));
|
assert_eq!(draft.cwd(), Some(PathBuf::from("/home/u/opt/tooling")));
|
||||||
// The raw target probed clean, so the worktree spec carries its repo.
|
// The raw target probed clean, so the worktree spec carries its repo.
|
||||||
let (seq, _) = draft.take_gitroot_probe().unwrap();
|
let (seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
||||||
draft.resolve_gitroot(seq, Some(PathBuf::from("/home/u/opt/tooling")));
|
draft.resolve_gitroot(seq, Some(PathBuf::from("/home/u/opt/tooling")));
|
||||||
draft.seed_names(vec!["amber-falcon".into()]);
|
draft.seed_names(vec!["amber-falcon".into()]);
|
||||||
let req = draft.build_request().unwrap();
|
let req = draft.build_request().unwrap();
|
||||||
@@ -1682,14 +1714,47 @@ mod tests {
|
|||||||
Some(Path::new("/repos/maestro")),
|
Some(Path::new("/repos/maestro")),
|
||||||
"a picked project resolves its base repo without a probe"
|
"a picked project resolves its base repo without a probe"
|
||||||
);
|
);
|
||||||
assert!(draft.take_gitroot_probe().is_none());
|
assert!(draft.take_gitroot_probe(probe_due()).is_none());
|
||||||
draft.seed_names(vec!["crimson-otter".into()]);
|
draft.seed_names(vec!["crimson-otter".into()]);
|
||||||
|
|
||||||
let wt = draft.build_request().unwrap().worktree.unwrap();
|
let wt = draft.build_request().unwrap().worktree.unwrap();
|
||||||
assert_eq!(wt.base_repo, PathBuf::from("/repos/maestro"));
|
assert_eq!(wt.base_repo, PathBuf::from("/repos/maestro"));
|
||||||
assert_eq!(wt.branch, "crimson-otter", "branch defaults to the name");
|
assert_eq!(wt.branch, "crimson-otter", "branch defaults to the name");
|
||||||
assert!(wt.create_branch);
|
assert!(wt.create_branch);
|
||||||
assert!(wt.cleanup_on_kill);
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picking_a_worktree_project_defaults_the_toggle_off() {
|
||||||
|
let mut draft = DraftPane::default();
|
||||||
|
let mut wt = repo_project("wt-91", "/repos/wt91", "/repos/main", 1, None);
|
||||||
|
wt.is_worktree = true;
|
||||||
|
draft.set_projects(
|
||||||
|
vec![
|
||||||
|
repo_project("main", "/repos/main", "/repos/main", 2, None),
|
||||||
|
wt,
|
||||||
|
],
|
||||||
|
Utc::now(),
|
||||||
|
);
|
||||||
|
draft.focused_field = DraftField::DirPicker;
|
||||||
|
typed(&mut draft, "wt-91");
|
||||||
|
draft.on_input(&InputKey::Enter);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!draft.worktree_enabled,
|
||||||
|
"the directory is already a worktree; spawning in place"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
draft.build_request().unwrap().worktree.is_none(),
|
||||||
|
"no worktree record — the session just runs there (#91)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deliberately re-enabling still works, and the scan-known base repo
|
||||||
|
// survives so the toggle-on path needs no new probe.
|
||||||
|
draft.focused_field = DraftField::WorktreeToggle;
|
||||||
|
draft.on_input(&InputKey::Enter);
|
||||||
|
assert!(draft.worktree_enabled);
|
||||||
|
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/repos/main")));
|
||||||
|
assert!(draft.take_gitroot_probe(probe_due()).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1701,13 +1766,18 @@ mod tests {
|
|||||||
draft.on_input(&InputKey::Enter); // no projects → Other directory…
|
draft.on_input(&InputKey::Enter); // no projects → Other directory…
|
||||||
typed(&mut draft, "/opt/tool");
|
typed(&mut draft, "/opt/tool");
|
||||||
|
|
||||||
let (seq, path) = draft.take_gitroot_probe().expect("probe requested");
|
let (seq, path) = draft
|
||||||
|
.take_gitroot_probe(probe_due())
|
||||||
|
.expect("probe requested");
|
||||||
assert_eq!(path, PathBuf::from("/opt/tool"));
|
assert_eq!(path, PathBuf::from("/opt/tool"));
|
||||||
assert!(draft.take_gitroot_probe().is_none(), "one probe at a time");
|
assert!(
|
||||||
|
draft.take_gitroot_probe(probe_due()).is_none(),
|
||||||
|
"one probe at a time"
|
||||||
|
);
|
||||||
|
|
||||||
// Further typing supersedes the in-flight probe…
|
// Further typing supersedes the in-flight probe…
|
||||||
typed(&mut draft, "-x");
|
typed(&mut draft, "-x");
|
||||||
let (seq2, _) = draft.take_gitroot_probe().unwrap();
|
let (seq2, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
||||||
assert_ne!(seq2, seq);
|
assert_ne!(seq2, seq);
|
||||||
// …so a late answer to the old one must be ignored.
|
// …so a late answer to the old one must be ignored.
|
||||||
draft.resolve_gitroot(seq, Some(PathBuf::from("/somewhere/else")));
|
draft.resolve_gitroot(seq, Some(PathBuf::from("/somewhere/else")));
|
||||||
@@ -1728,7 +1798,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
draft.on_input(&InputKey::Enter);
|
draft.on_input(&InputKey::Enter);
|
||||||
typed(&mut draft, "/not/a/repo");
|
typed(&mut draft, "/not/a/repo");
|
||||||
let (seq, _) = draft.take_gitroot_probe().unwrap();
|
let (seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
||||||
|
|
||||||
draft.resolve_gitroot(seq, None);
|
draft.resolve_gitroot(seq, None);
|
||||||
assert!(!draft.worktree_enabled);
|
assert!(!draft.worktree_enabled);
|
||||||
@@ -1872,6 +1942,61 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_keystroke_burst_schedules_one_debounced_gitroot_probe() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
focused_field: DraftField::DirPicker,
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.on_input(&InputKey::Enter); // no projects → Other directory…
|
||||||
|
|
||||||
|
// Burst: every keystroke reschedules; nothing may fire inside the
|
||||||
|
// debounce window (#74).
|
||||||
|
for c in "/opt/tooling".chars() {
|
||||||
|
draft.on_input(&InputKey::char(c));
|
||||||
|
assert!(
|
||||||
|
draft.take_gitroot_probe(Instant::now()).is_none(),
|
||||||
|
"no probe while typing is still in flight"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the quiet period passes, exactly one probe fires.
|
||||||
|
let (seq, path) = draft
|
||||||
|
.take_gitroot_probe(probe_due())
|
||||||
|
.expect("probe due after the quiet period");
|
||||||
|
assert_eq!(path, PathBuf::from("/opt/tooling"));
|
||||||
|
assert!(
|
||||||
|
draft.take_gitroot_probe(probe_due()).is_none(),
|
||||||
|
"one probe total"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The answer still lands through the unchanged seq guard.
|
||||||
|
draft.resolve_gitroot(seq, Some(PathBuf::from("/opt/tooling")));
|
||||||
|
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/opt/tooling")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stale_probe_answer_cannot_overwrite_a_newer_target_after_the_debounce() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
focused_field: DraftField::DirPicker,
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.on_input(&InputKey::Enter); // Other directory…
|
||||||
|
|
||||||
|
typed(&mut draft, "/repo/old");
|
||||||
|
let (old_seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
||||||
|
|
||||||
|
// Retyping supersedes the in-flight probe and gets a newer seq…
|
||||||
|
typed(&mut draft, "/repo/new");
|
||||||
|
let (new_seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
||||||
|
assert_ne!(new_seq, old_seq);
|
||||||
|
|
||||||
|
// …so the late answer to the old target must be ignored.
|
||||||
|
draft.resolve_gitroot(new_seq, Some(PathBuf::from("/repo/new")));
|
||||||
|
draft.resolve_gitroot(old_seq, Some(PathBuf::from("/repo/old")));
|
||||||
|
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/repo/new")));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toggling_worktree_off_spawns_in_the_checkout_itself() {
|
fn toggling_worktree_off_spawns_in_the_checkout_itself() {
|
||||||
let mut draft = DraftPane::default();
|
let mut draft = DraftPane::default();
|
||||||
@@ -1897,7 +2022,7 @@ mod tests {
|
|||||||
draft.on_input(&InputKey::Enter);
|
draft.on_input(&InputKey::Enter);
|
||||||
assert!(draft.worktree_enabled);
|
assert!(draft.worktree_enabled);
|
||||||
assert!(
|
assert!(
|
||||||
draft.take_gitroot_probe().is_none(),
|
draft.take_gitroot_probe(probe_due()).is_none(),
|
||||||
"re-enabling reuses the known repo for an unchanged target"
|
"re-enabling reuses the known repo for an unchanged target"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ impl FastForwardDefaults {
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| project.path.clone()),
|
.unwrap_or_else(|| project.path.clone()),
|
||||||
branch: name.to_string(),
|
branch: name.to_string(),
|
||||||
cleanup_on_kill: true,
|
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -117,7 +117,9 @@ impl InputKey {
|
|||||||
if *alt {
|
if *alt {
|
||||||
out.push_str("alt-");
|
out.push_str("alt-");
|
||||||
}
|
}
|
||||||
if *shift {
|
// Uppercase already implies shift; crossterm sets the flag
|
||||||
|
// on every such char, so skip the redundant prefix (#70).
|
||||||
|
if *shift && !c.is_uppercase() {
|
||||||
out.push_str("shift-");
|
out.push_str("shift-");
|
||||||
}
|
}
|
||||||
out.push(*c);
|
out.push(*c);
|
||||||
@@ -580,12 +582,28 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_round_trips_simple_chars() {
|
fn canonical_round_trips_simple_chars() {
|
||||||
for s in ["a", "ctrl-a", "ctrl-w", "alt-x", "shift-A"] {
|
for s in ["a", "ctrl-a", "ctrl-w", "alt-x"] {
|
||||||
let k = InputKey::parse(s).unwrap();
|
let k = InputKey::parse(s).unwrap();
|
||||||
assert_eq!(k.canonical(), s, "round-trip {s:?}");
|
assert_eq!(k.canonical(), s, "round-trip {s:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// crossterm 0.28 attaches `KeyModifiers::SHIFT` to any uppercase char,
|
||||||
|
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
|
||||||
|
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
|
||||||
|
/// the uppercase letter already says "shifted"; a redundant prefix would
|
||||||
|
/// make the default `S` binding unreachable from the keyboard (#70).
|
||||||
|
#[test]
|
||||||
|
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
|
||||||
|
let k = InputKey::Char {
|
||||||
|
c: 'S',
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
shift: true,
|
||||||
|
};
|
||||||
|
assert_eq!(k.canonical(), "S");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_round_trips_specials() {
|
fn canonical_round_trips_specials() {
|
||||||
for s in [
|
for s in [
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub mod state;
|
|||||||
pub mod tabs;
|
pub mod tabs;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod text_input;
|
pub mod text_input;
|
||||||
|
pub mod worktrees;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|||||||
@@ -14,23 +14,28 @@ use ratatui::Frame;
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct KillConfirm {
|
pub struct KillConfirm {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
|
/// #83: what the daemon's watcher last saw in the worktree, phrased by
|
||||||
|
/// [`maestro_protocol::InTheWay`]'s Display — so "this will be refused"
|
||||||
|
/// is visible before the attempt. `None` while nothing blocks.
|
||||||
|
pub blockers: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KillConfirm {
|
impl KillConfirm {
|
||||||
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
||||||
let area = centered(parent, 50, 5);
|
let area = centered(parent, 60, if self.blockers.is_some() { 6 } else { 5 });
|
||||||
f.render_widget(Clear, area);
|
f.render_widget(Clear, area);
|
||||||
let block = Block::default().title(" kill ").borders(Borders::ALL);
|
let block = Block::default().title(" kill ").borders(Borders::ALL);
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
f.render_widget(block, area);
|
f.render_widget(block, area);
|
||||||
f.render_widget(
|
let mut lines = vec![Line::from(format!("Kill session {}?", self.label))];
|
||||||
Paragraph::new(vec![
|
if let Some(blockers) = &self.blockers {
|
||||||
Line::from(format!("Kill session {}?", self.label)),
|
// Same phrasing the reclaim confirmation uses for its own guard.
|
||||||
Line::from(""),
|
lines.push(Line::from(format!("Currently in the way: {blockers}")));
|
||||||
Line::from("[y] yes [n] no"),
|
lines.push(Line::from("The kill will be refused unless forced."));
|
||||||
]),
|
}
|
||||||
inner,
|
lines.push(Line::from(""));
|
||||||
);
|
lines.push(Line::from("[y] yes [n] no"));
|
||||||
|
f.render_widget(Paragraph::new(lines), inner);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +87,7 @@ impl Toast {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn centered(parent: Rect, w: u16, h: u16) -> Rect {
|
pub(crate) fn centered(parent: Rect, w: u16, h: u16) -> Rect {
|
||||||
let w = w.min(parent.width);
|
let w = w.min(parent.width);
|
||||||
let h = h.min(parent.height);
|
let h = h.min(parent.height);
|
||||||
Rect {
|
Rect {
|
||||||
@@ -121,6 +126,7 @@ mod tests {
|
|||||||
fn kill_confirm_renders_prompt() {
|
fn kill_confirm_renders_prompt() {
|
||||||
let modal = KillConfirm {
|
let modal = KillConfirm {
|
||||||
label: "session-7".into(),
|
label: "session-7".into(),
|
||||||
|
blockers: None,
|
||||||
};
|
};
|
||||||
let rendered = render_to_string(60, 10, |f| modal.render(f, f.area()));
|
let rendered = render_to_string(60, 10, |f| modal.render(f, f.area()));
|
||||||
assert!(rendered.contains("Kill"));
|
assert!(rendered.contains("Kill"));
|
||||||
@@ -129,6 +135,22 @@ mod tests {
|
|||||||
assert!(rendered.contains("no"));
|
assert!(rendered.contains("no"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #83: when the worktree blocks, the confirm names it before the
|
||||||
|
/// operator commits — in `InTheWay`'s own words.
|
||||||
|
#[test]
|
||||||
|
fn kill_confirm_names_what_blocks() {
|
||||||
|
let modal = KillConfirm {
|
||||||
|
label: "session-7".into(),
|
||||||
|
blockers: Some("2 uncommitted files".into()),
|
||||||
|
};
|
||||||
|
let rendered = render_to_string(70, 10, |f| modal.render(f, f.area()));
|
||||||
|
assert!(
|
||||||
|
rendered.contains("Currently in the way: 2 uncommitted files"),
|
||||||
|
"{rendered}"
|
||||||
|
);
|
||||||
|
assert!(rendered.contains("refused"), "{rendered}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_overlay_lists_lines() {
|
fn help_overlay_lists_lines() {
|
||||||
let help = HelpOverlay {
|
let help = HelpOverlay {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use crate::pane::Pane;
|
|||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind};
|
use crate::state::{AppState, Focus, OverlayKind};
|
||||||
use crate::tabs::TabBar;
|
use crate::tabs::TabBar;
|
||||||
|
use crate::worktrees::{ReclaimConfirm, WorktreesView};
|
||||||
|
|
||||||
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
||||||
/// coordinate to one of these via [`RegionMap::region_at`].
|
/// coordinate to one of these via [`RegionMap::region_at`].
|
||||||
@@ -67,6 +68,8 @@ pub struct RenderInputs<'a> {
|
|||||||
pub draft: Option<&'a crate::draft::DraftPane>,
|
pub draft: Option<&'a crate::draft::DraftPane>,
|
||||||
pub state: &'a AppState,
|
pub state: &'a AppState,
|
||||||
pub help: &'a HelpOverlay,
|
pub help: &'a HelpOverlay,
|
||||||
|
/// Retained-worktree entries for the worktrees view (#85).
|
||||||
|
pub worktrees: Option<&'a WorktreesView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw one frame. Returns the `RegionMap` the input router will consult for
|
/// Draw one frame. Returns the `RegionMap` the input router will consult for
|
||||||
@@ -143,8 +146,19 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
|
|
||||||
match inputs.state.overlay() {
|
match inputs.state.overlay() {
|
||||||
Some(OverlayKind::KillConfirm(id)) => {
|
Some(OverlayKind::KillConfirm(id)) => {
|
||||||
|
// #83: surface what the watcher last saw in the worktree, in
|
||||||
|
// InTheWay's own words, before the operator commits to the kill.
|
||||||
|
let blockers = inputs
|
||||||
|
.sidebar
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|s| &s.id == id)
|
||||||
|
.and_then(|s| s.blocked_work)
|
||||||
|
.filter(|b| !b.is_clear())
|
||||||
|
.map(|b| b.to_string());
|
||||||
let kc = KillConfirm {
|
let kc = KillConfirm {
|
||||||
label: id.to_string(),
|
label: id.to_string(),
|
||||||
|
blockers,
|
||||||
};
|
};
|
||||||
kc.render(f, area);
|
kc.render(f, area);
|
||||||
map.modal = Some(area);
|
map.modal = Some(area);
|
||||||
@@ -153,6 +167,22 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
inputs.help.render(f, area);
|
inputs.help.render(f, area);
|
||||||
map.modal = Some(area);
|
map.modal = Some(area);
|
||||||
}
|
}
|
||||||
|
Some(OverlayKind::Worktrees) => {
|
||||||
|
if let Some(view) = inputs.worktrees {
|
||||||
|
view.render(f, area);
|
||||||
|
map.modal = Some(area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(OverlayKind::ReclaimConfirm {
|
||||||
|
branch, blockers, ..
|
||||||
|
}) => {
|
||||||
|
let rc = ReclaimConfirm {
|
||||||
|
branch: branch.clone(),
|
||||||
|
blockers: blockers.clone(),
|
||||||
|
};
|
||||||
|
rc.render(f, area);
|
||||||
|
map.modal = Some(area);
|
||||||
|
}
|
||||||
Some(OverlayKind::Toast(text)) => {
|
Some(OverlayKind::Toast(text)) => {
|
||||||
let t = Toast { text: text.clone() };
|
let t = Toast { text: text.clone() };
|
||||||
t.render(f, area);
|
t.render(f, area);
|
||||||
@@ -298,6 +328,7 @@ mod tests {
|
|||||||
draft: Some(draft),
|
draft: Some(draft),
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
|
worktrees: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ impl Sidebar {
|
|||||||
(SessionStatus::Running, false) => "○",
|
(SessionStatus::Running, false) => "○",
|
||||||
};
|
};
|
||||||
let label = name_for(s);
|
let label = name_for(s);
|
||||||
|
// #83: a session whose worktree blocks a kill gets a warning
|
||||||
|
// glyph; the kill confirm names the specifics.
|
||||||
|
let blocked = s
|
||||||
|
.blocked_work
|
||||||
|
.filter(|b| !b.is_clear())
|
||||||
|
.map(|_| " ⚠")
|
||||||
|
.unwrap_or_default();
|
||||||
let style = if self.highlight == Some(s.id) {
|
let style = if self.highlight == Some(s.id) {
|
||||||
Style::default().add_modifier(Modifier::REVERSED)
|
Style::default().add_modifier(Modifier::REVERSED)
|
||||||
} else {
|
} else {
|
||||||
@@ -59,7 +66,7 @@ impl Sidebar {
|
|||||||
};
|
};
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(format!("{glyph} {label}"), style),
|
Span::styled(format!("{glyph} {label}{blocked}"), style),
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
f.render_widget(Paragraph::new(lines), inner);
|
f.render_widget(Paragraph::new(lines), inner);
|
||||||
@@ -99,6 +106,7 @@ mod tests {
|
|||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +180,40 @@ mod tests {
|
|||||||
assert!(line.contains('✗'), "expected dead glyph in: {line:?}");
|
assert!(line.contains('✗'), "expected dead glyph in: {line:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #83: a session whose worktree blocks a kill carries the warning glyph;
|
||||||
|
/// a clear or unprobed one does not.
|
||||||
|
#[test]
|
||||||
|
fn blocked_session_gets_a_warning_glyph() {
|
||||||
|
use maestro_protocol::InTheWay;
|
||||||
|
|
||||||
|
let mut blocked = session_named(1, "claude");
|
||||||
|
blocked.blocked_work = Some(InTheWay {
|
||||||
|
unpushed_commits: 4,
|
||||||
|
..InTheWay::default()
|
||||||
|
});
|
||||||
|
let mut clear = session_named(2, "bash");
|
||||||
|
clear.blocked_work = Some(InTheWay::default());
|
||||||
|
let unprobed = session_named(3, "vim");
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![blocked, clear, unprobed],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let rendered = render_to_string(&sb, 40, 8);
|
||||||
|
let line = rendered
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.contains("claude"))
|
||||||
|
.expect("blocked row");
|
||||||
|
assert!(line.contains('⚠'), "expected warning glyph in: {line:?}");
|
||||||
|
for name in ["bash", "vim"] {
|
||||||
|
let line = rendered
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.contains(name))
|
||||||
|
.expect("clear row");
|
||||||
|
assert!(!line.contains('⚠'), "{name} must not warn: {line:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn falls_back_to_command_when_name_metadata_is_absent() {
|
fn falls_back_to_command_when_name_metadata_is_absent() {
|
||||||
let s = Session {
|
let s = Session {
|
||||||
@@ -188,6 +230,7 @@ mod tests {
|
|||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
|
blocked_work: None,
|
||||||
};
|
};
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ pub enum OverlayKind {
|
|||||||
KillConfirm(Uuid),
|
KillConfirm(Uuid),
|
||||||
Help,
|
Help,
|
||||||
Toast(String),
|
Toast(String),
|
||||||
|
/// The retained-worktrees view (#85); its entries live on `App` because
|
||||||
|
/// they are daemon data, not UI state.
|
||||||
|
Worktrees,
|
||||||
|
/// Reclaim confirmation for the worktree with this id (#85), guarding
|
||||||
|
/// the destructive key like [`OverlayKind::KillConfirm`] does.
|
||||||
|
ReclaimConfirm {
|
||||||
|
id: Uuid,
|
||||||
|
branch: String,
|
||||||
|
/// [`maestro_protocol::InTheWay`] as of when the confirm was opened.
|
||||||
|
blockers: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
//! Retained-worktrees view (#85): an overlay listing every retained worktree
|
||||||
|
//! — branch, abbreviated path, reason, what is in the way — with j/k to move,
|
||||||
|
//! `r` to reclaim behind a confirmation, esc to close.
|
||||||
|
//!
|
||||||
|
//! The in-the-way summary comes straight from [`InTheWay`]'s `Display` impl,
|
||||||
|
//! the same source the kill refusal and the web client use, so no two clients
|
||||||
|
//! can describe the same tree differently.
|
||||||
|
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::text::Line;
|
||||||
|
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
|
||||||
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
use maestro_protocol::{RetainedReason, RetainedWorktree};
|
||||||
|
|
||||||
|
use crate::draft::abbreviate_path;
|
||||||
|
|
||||||
|
/// The open worktrees overlay: its entries and which one is highlighted.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct WorktreesView {
|
||||||
|
pub items: Vec<RetainedWorktree>,
|
||||||
|
pub highlight: usize,
|
||||||
|
/// Daemon-reported home dir, for `~` abbreviation of paths.
|
||||||
|
pub home_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorktreesView {
|
||||||
|
/// Replace the listing and keep the highlight pointing at something real.
|
||||||
|
pub fn apply(&mut self, items: Vec<RetainedWorktree>) {
|
||||||
|
self.items = items;
|
||||||
|
if !self.items.is_empty() {
|
||||||
|
self.highlight = self.highlight.min(self.items.len() - 1);
|
||||||
|
} else {
|
||||||
|
self.highlight = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&mut self, id: uuid::Uuid) {
|
||||||
|
let idx = self.items.iter().position(|w| w.id == id);
|
||||||
|
self.items.retain(|w| w.id != id);
|
||||||
|
if let Some(i) = idx {
|
||||||
|
self.highlight = self.highlight.min(self.items.len().saturating_sub(1));
|
||||||
|
let _ = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the highlight by `delta` (positive = down). Wraps. No-op on empty.
|
||||||
|
pub fn move_highlight(&mut self, delta: i32) {
|
||||||
|
if self.items.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let len = self.items.len() as i32;
|
||||||
|
let next = (self.highlight as i32 + delta).rem_euclid(len);
|
||||||
|
self.highlight = next as usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected(&self) -> Option<&RetainedWorktree> {
|
||||||
|
self.items.get(self.highlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
||||||
|
let h = (self.items.len() as u16 + 6).clamp(4, parent.height);
|
||||||
|
let area = super::overlays::centered(parent, parent.width.saturating_sub(4), h);
|
||||||
|
f.render_widget(Clear, area);
|
||||||
|
let block = Block::default()
|
||||||
|
.title(" retained worktrees ")
|
||||||
|
.borders(Borders::ALL);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
if self.items.is_empty() {
|
||||||
|
// A drained store is the normal state — say so plainly, not like
|
||||||
|
// something went wrong.
|
||||||
|
lines.push(Line::from("no retained worktrees"));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(format!(
|
||||||
|
" {:<26} {:<34} {:<24} IN THE WAY",
|
||||||
|
"BRANCH", "PATH", "REASON"
|
||||||
|
)));
|
||||||
|
for (i, w) in self.items.iter().enumerate() {
|
||||||
|
let marker = if i == self.highlight { ">" } else { " " };
|
||||||
|
lines.push(Line::from(format!(
|
||||||
|
"{marker} {:<25} {:<34} {:<24} {}",
|
||||||
|
w.branch,
|
||||||
|
abbreviate_path(&w.path, &self.home_dir),
|
||||||
|
reason_label(w.reason),
|
||||||
|
w.in_the_way,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::from("j/k: select r: reclaim esc: close"));
|
||||||
|
f.render_widget(Paragraph::new(lines), inner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The confirmation guarding reclaim, mirroring [`super::overlays::KillConfirm`].
|
||||||
|
/// It names the branch and says out loud what reclaim does not take with it —
|
||||||
|
/// docs/WORKTREES.md: "reclaim" otherwise sounds like it takes the commits too.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ReclaimConfirm {
|
||||||
|
pub branch: String,
|
||||||
|
/// [`maestro_protocol::InTheWay`] rendered via its `Display` impl.
|
||||||
|
pub blockers: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReclaimConfirm {
|
||||||
|
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
||||||
|
let area = super::overlays::centered(parent, 70, 7);
|
||||||
|
f.render_widget(Clear, area);
|
||||||
|
let block = Block::default().title(" reclaim ").borders(Borders::ALL);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
f.render_widget(
|
||||||
|
ratatui::widgets::Paragraph::new(vec![
|
||||||
|
Line::from(format!("Reclaim worktree {}?", self.branch)),
|
||||||
|
Line::from(format!("Currently in the way: {}", self.blockers)),
|
||||||
|
Line::from("The directory is removed; the branch is never deleted."),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from("[y] yes [n] no"),
|
||||||
|
]),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reason_label(reason: RetainedReason) -> &'static str {
|
||||||
|
match reason {
|
||||||
|
RetainedReason::ForceKilled => "force-killed",
|
||||||
|
RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
|
use maestro_protocol::{InTheWay, RetainedReason};
|
||||||
|
use ratatui::backend::TestBackend;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
|
||||||
|
fn render_to_string<F>(w: u16, h: u16, draw: F) -> String
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Frame),
|
||||||
|
{
|
||||||
|
let backend = TestBackend::new(w, h);
|
||||||
|
let mut terminal = Terminal::new(backend).unwrap();
|
||||||
|
terminal.draw(draw).unwrap();
|
||||||
|
let buf = terminal.backend().buffer().clone();
|
||||||
|
let mut out = String::new();
|
||||||
|
for y in 0..buf.area.height {
|
||||||
|
for x in 0..buf.area.width {
|
||||||
|
out.push_str(buf[(x, y)].symbol());
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree {
|
||||||
|
RetainedWorktree {
|
||||||
|
id: uuid::Uuid::from_u128(id),
|
||||||
|
path: path.into(),
|
||||||
|
base_repo: "/home/naps/tea/maestro".into(),
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn view_lists_branch_path_reason_and_what_is_in_the_way() {
|
||||||
|
let mut view = WorktreesView {
|
||||||
|
home_dir: "/home/naps".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut wt = worktree(
|
||||||
|
"feat/login",
|
||||||
|
"/home/naps/tea/maestro/worktrees/feat/login",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
wt.in_the_way = InTheWay {
|
||||||
|
uncommitted_files: 2,
|
||||||
|
untracked_files: 1,
|
||||||
|
unpushed_commits: 3,
|
||||||
|
};
|
||||||
|
view.apply(vec![wt]);
|
||||||
|
|
||||||
|
let rendered = render_to_string(160, 20, |f| view.render(f, f.area()));
|
||||||
|
assert!(rendered.contains("feat/login"), "{rendered}");
|
||||||
|
// Path abbreviated against the daemon's home dir, same helper as the
|
||||||
|
// draft pane's project list.
|
||||||
|
assert!(rendered.contains("~/t/m/w/f/login"), "{rendered}");
|
||||||
|
assert!(rendered.contains("force-killed"), "{rendered}");
|
||||||
|
// Phrasing comes from InTheWay's Display impl — shared with the kill
|
||||||
|
// refusal and the web client.
|
||||||
|
assert!(
|
||||||
|
rendered.contains("2 uncommitted files, 1 untracked file, 3 unpushed commits"),
|
||||||
|
"{rendered}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_store_looks_like_a_normal_state_not_an_error() {
|
||||||
|
let view = WorktreesView::default();
|
||||||
|
let rendered = render_to_string(80, 12, |f| view.render(f, f.area()));
|
||||||
|
assert!(rendered.contains("no retained worktrees"), "{rendered}");
|
||||||
|
assert!(!rendered.contains("error"), "{rendered}");
|
||||||
|
assert!(!rendered.contains("failed"), "{rendered}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_moves_and_wraps() {
|
||||||
|
let mut view = WorktreesView::default();
|
||||||
|
view.apply(vec![
|
||||||
|
worktree("a", "/r/a", 1),
|
||||||
|
worktree("b", "/r/b", 2),
|
||||||
|
worktree("c", "/r/c", 3),
|
||||||
|
]);
|
||||||
|
assert_eq!(view.selected().unwrap().branch, "a");
|
||||||
|
view.move_highlight(1);
|
||||||
|
assert_eq!(view.selected().unwrap().branch, "b");
|
||||||
|
view.move_highlight(-1);
|
||||||
|
assert_eq!(view.selected().unwrap().branch, "a");
|
||||||
|
view.move_highlight(-1);
|
||||||
|
// Wraps backward to the last entry.
|
||||||
|
assert_eq!(view.selected().unwrap().branch, "c");
|
||||||
|
view.move_highlight(1);
|
||||||
|
assert_eq!(view.selected().unwrap().branch, "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_clamps_a_stale_highlight() {
|
||||||
|
let mut view = WorktreesView::default();
|
||||||
|
view.apply(vec![worktree("a", "/r/a", 1), worktree("b", "/r/b", 2)]);
|
||||||
|
view.move_highlight(1);
|
||||||
|
view.apply(vec![worktree("c", "/r/c", 3)]);
|
||||||
|
assert_eq!(view.selected().unwrap().branch, "c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reclaim_confirm_names_the_branch_and_says_it_survives() {
|
||||||
|
let confirm = ReclaimConfirm {
|
||||||
|
branch: "feat/login".into(),
|
||||||
|
blockers: "2 uncommitted files".into(),
|
||||||
|
};
|
||||||
|
let rendered = render_to_string(80, 14, |f| confirm.render(f, f.area()));
|
||||||
|
assert!(
|
||||||
|
rendered.contains("Reclaim worktree feat/login?"),
|
||||||
|
"{rendered}"
|
||||||
|
);
|
||||||
|
assert!(rendered.contains("2 uncommitted files"), "{rendered}");
|
||||||
|
assert!(rendered.contains("never deleted"), "{rendered}");
|
||||||
|
assert!(rendered.contains("yes"), "{rendered}");
|
||||||
|
assert!(rendered.contains("no"), "{rendered}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-4
@@ -75,11 +75,16 @@ That is only safe because force retains.
|
|||||||
|
|
||||||
`--force` overrides the refusal and nothing else. It ends the session and
|
`--force` overrides the refusal and nothing else. It ends the session and
|
||||||
**retains** the worktree, which enters the retained store with reason
|
**retains** the worktree, which enters the retained store with reason
|
||||||
`force-killed`. Force never removes a tree that would have blocked.
|
`force-killed`. Force never removes a tree that would have blocked. A tree
|
||||||
|
whose guard is clear never needed the override, so a forced kill removes it as
|
||||||
|
usual — force changes what happens to blocked work, not ordinary cleanup.
|
||||||
|
|
||||||
Ending a session and destroying its work are two separate destructive acts. Each
|
Ending a session and destroying its work are two separate destructive acts.
|
||||||
is chosen on its own, and the second one — reclaim — is chosen while looking at
|
Each is chosen on its own, and the second one — reclaim — is chosen while
|
||||||
exactly what would be lost.
|
looking at exactly what would be lost.
|
||||||
|
|
||||||
|
A forced kill answers with what was retained and why, so a client can say where
|
||||||
|
the work went rather than leaving the operator to wonder.
|
||||||
|
|
||||||
### Exiting on its own
|
### Exiting on its own
|
||||||
|
|
||||||
@@ -92,6 +97,17 @@ Nobody is waiting for an answer here, so there is nothing to refuse; the choice
|
|||||||
only between removing and retaining. It uses the same predicate as the kill, from
|
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 same place, so the two paths cannot drift apart.
|
||||||
|
|
||||||
|
The session itself stays in the catalog marked dead until it is dismissed — the
|
||||||
|
replay buffer of a crashed agent outlives it — but its worktree is settled at
|
||||||
|
once. Once settled, the session drops its worktree reference, so dismissing it
|
||||||
|
afterwards neither refuses (there is no guard left to trip) nor records a second
|
||||||
|
store entry.
|
||||||
|
|
||||||
|
If settling is impossible — the probe fails, or the store cannot take the entry —
|
||||||
|
nothing is destroyed. The tree stays on disk *and* stays attached to the dead
|
||||||
|
record, where dismissing it later still goes through `--force`'s
|
||||||
|
retain-before-teardown path. Losing the store must never mean losing the work.
|
||||||
|
|
||||||
## The retained store
|
## The retained store
|
||||||
|
|
||||||
A `state_dir`-backed record of worktrees the daemon created that no longer have a
|
A `state_dir`-backed record of worktrees the daemon created that no longer have a
|
||||||
|
|||||||
@@ -125,7 +125,7 @@
|
|||||||
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
||||||
inherit (finalAttrs) pname version src;
|
inherit (finalAttrs) pname version src;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 3;
|
||||||
hash = "sha256-Lhi41AtCK69SM4+7zih19+Njf7o2hyXrAV0W6VO9YxU=";
|
hash = "sha256-D9JdgI92vzAY8en2zGEEPd4hwOLVi3sr1joT4b17Be4=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
NameBatch,
|
NameBatch,
|
||||||
Projects,
|
Projects,
|
||||||
RecentDirs,
|
RecentDirs,
|
||||||
|
RetainedWorktree,
|
||||||
Session,
|
Session,
|
||||||
Uuid,
|
Uuid,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
@@ -97,4 +98,22 @@ export async function fetchBranches(path: string): Promise<Branches> {
|
|||||||
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The daemon's retained worktrees (#80): trees kept after a session ended
|
||||||
|
* with unsaved work, with what is currently in the way. Drives the
|
||||||
|
* retained-worktrees view (#84). */
|
||||||
|
export async function listWorktrees(): Promise<RetainedWorktree[]> {
|
||||||
|
return jsonOrError(await fetch(routes.WORKTREES, { credentials: "same-origin" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reclaim a retained worktree: removes its directory and drops the store
|
||||||
|
* entry. Never deletes the branch (docs/WORKTREES.md). */
|
||||||
|
export async function reclaimWorktree(id: Uuid): Promise<void> {
|
||||||
|
await jsonOrError(
|
||||||
|
await fetch(routes.worktree(id), {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "same-origin",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export { eventsWsUrl } from "./routes.js";
|
export { eventsWsUrl } from "./routes.js";
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { renderSidebar } from "./catalog.js";
|
||||||
|
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||||
|
import type { InTheWay, Session } from "./types.js";
|
||||||
|
|
||||||
|
// happy-dom runs without a backing store; renderSidebar reads the persisted
|
||||||
|
// sidebar width on mount.
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
value: { getItem: () => null, setItem: () => {} },
|
||||||
|
});
|
||||||
|
// No daemon here; renderSidebar treats a failed info fetch as "absolute
|
||||||
|
// paths are a fine fallback".
|
||||||
|
window.fetch = () => Promise.reject(new Error("offline"));
|
||||||
|
});
|
||||||
|
|
||||||
|
function session(overrides: Partial<Session>): Session {
|
||||||
|
return {
|
||||||
|
id: "11111111-2222-3333-4444-555555555555",
|
||||||
|
cwd: "/home/naps/tea/maestro",
|
||||||
|
command: "claude",
|
||||||
|
env: {},
|
||||||
|
metadata: {},
|
||||||
|
status: "Running",
|
||||||
|
created_at: "2026-08-24T10:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeWith(sessions: Session[]): CatalogStore {
|
||||||
|
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||||
|
return {
|
||||||
|
snapshot: () => state,
|
||||||
|
// Like the real store, deliver an immediate snapshot so the sidebar
|
||||||
|
// renders its rows during construction.
|
||||||
|
subscribe: (fn: CatalogListener) => {
|
||||||
|
fn(state);
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
} as unknown as CatalogStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowFor(el: HTMLElement, id: string): HTMLElement | null {
|
||||||
|
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("blocked-work indicator (#83)", () => {
|
||||||
|
it("marks the row of a blocked session with the shared phrasing", () => {
|
||||||
|
const blocked = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000001",
|
||||||
|
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([blocked])).el;
|
||||||
|
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
|
||||||
|
expect(badge?.textContent).toContain("⚠");
|
||||||
|
expect(badge?.textContent).toContain("3 uncommitted files, 2 unpushed commits");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mark clear or unprobed sessions", () => {
|
||||||
|
const clear = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000002",
|
||||||
|
blocked_work: {} as InTheWay,
|
||||||
|
});
|
||||||
|
const unprobed = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000003",
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([clear, unprobed])).el;
|
||||||
|
expect(rowFor(el, clear.id)?.querySelector(".leaf-blocked")).toBeNull();
|
||||||
|
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restates the warning at the kill confirm", async () => {
|
||||||
|
const confirmations: string[] = [];
|
||||||
|
window.confirm = (msg?: string) => {
|
||||||
|
confirmations.push(msg ?? "");
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const blocked = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000004",
|
||||||
|
command: "bash",
|
||||||
|
blocked_work: { untracked_files: 1 },
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([blocked])).el;
|
||||||
|
const kill = rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill");
|
||||||
|
kill?.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(confirmations).toHaveLength(1);
|
||||||
|
expect(confirmations[0]).toContain("1 untracked file");
|
||||||
|
expect(confirmations[0]).toContain("refused unless forced");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirms plainly when nothing blocks", async () => {
|
||||||
|
const confirmations: string[] = [];
|
||||||
|
window.confirm = (msg?: string) => {
|
||||||
|
confirmations.push(msg ?? "");
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
|
||||||
|
const el = renderSidebar(storeWith([plain])).el;
|
||||||
|
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(confirmations).toEqual(['Kill session "claude"?']);
|
||||||
|
});
|
||||||
|
});
|
||||||
+20
-2
@@ -3,7 +3,7 @@ import { getDaemonInfo, killSession } from "./api.js";
|
|||||||
import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
|
import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
|
||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
import type { Session } from "./types.js";
|
import type { Session } from "./types.js";
|
||||||
import { abbreviateFolderName, sessionName } from "./types.js";
|
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
|
||||||
import type { TreeNode } from "./tree.js";
|
import type { TreeNode } from "./tree.js";
|
||||||
import { buildTree } from "./tree.js";
|
import { buildTree } from "./tree.js";
|
||||||
import { createThemeToggle } from "./theme.js";
|
import { createThemeToggle } from "./theme.js";
|
||||||
@@ -64,10 +64,14 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
// The directory is the enclosing folder, so only surface the command — and
|
// The directory is the enclosing folder, so only surface the command — and
|
||||||
// only when it isn't already what the name shows.
|
// only when it isn't already what the name shows.
|
||||||
const showCmd = s.command !== name;
|
const showCmd = s.command !== name;
|
||||||
|
// #83: warn on the row itself, in `formatInTheWay`'s shared phrasing, so
|
||||||
|
// "this will be refused" is visible before the kill is attempted.
|
||||||
|
const blockers = sessionBlockers(s);
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<span class="leaf-name">${escapeHtml(name)}</span>
|
<span class="leaf-name">${escapeHtml(name)}</span>
|
||||||
${showCmd ? `<span class="leaf-meta"><span class="leaf-cmd">${escapeHtml(s.command)}</span></span>` : ""}
|
${showCmd ? `<span class="leaf-meta"><span class="leaf-cmd">${escapeHtml(s.command)}</span></span>` : ""}
|
||||||
${running ? "" : `<span class="leaf-badge">exited</span>`}
|
${running ? "" : `<span class="leaf-badge">exited</span>`}
|
||||||
|
${blockers ? `<span class="leaf-badge leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>` : ""}
|
||||||
<span class="leaf-actions">
|
<span class="leaf-actions">
|
||||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
||||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
||||||
@@ -83,7 +87,12 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
killBtn.addEventListener("click", async (ev) => {
|
killBtn.addEventListener("click", async (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
if (!confirm(`Kill session "${sessionName(s)}"?`)) return;
|
// #83: the confirm restates the warning at the moment of decision.
|
||||||
|
const blockers = sessionBlockers(s);
|
||||||
|
const question = blockers
|
||||||
|
? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.`
|
||||||
|
: `Kill session "${name}"?`;
|
||||||
|
if (!confirm(question)) return;
|
||||||
try {
|
try {
|
||||||
await killSession(s.id);
|
await killSession(s.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -180,6 +189,15 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
|
|||||||
renderStatus();
|
renderStatus();
|
||||||
|
|
||||||
const actions = header.querySelector(".app-actions") as HTMLElement;
|
const actions = header.querySelector(".app-actions") as HTMLElement;
|
||||||
|
// Retained worktrees (#84): a section reachable from the header, not a
|
||||||
|
// sidebar fixture — PRODUCT.md's scan is the product, so this must not
|
||||||
|
// compete with the session catalog for attention.
|
||||||
|
const worktreesLink = document.createElement("a");
|
||||||
|
worktreesLink.className = "btn btn-ghost";
|
||||||
|
worktreesLink.href = "#/worktrees";
|
||||||
|
worktreesLink.textContent = "worktrees";
|
||||||
|
worktreesLink.title = "Retained worktrees";
|
||||||
|
actions.appendChild(worktreesLink);
|
||||||
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
|
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
|
||||||
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
|
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ describe("resolveFastForward (#67)", () => {
|
|||||||
expect(req.worktree).toEqual({
|
expect(req.worktree).toEqual({
|
||||||
base_repo: "/repos/fresh",
|
base_repo: "/repos/fresh",
|
||||||
branch: "amber-falcon",
|
branch: "amber-falcon",
|
||||||
cleanup_on_kill: true,
|
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export function buildFastForwardRequest(
|
|||||||
worktree: {
|
worktree: {
|
||||||
base_repo: project.base_repo ?? project.path,
|
base_repo: project.base_repo ?? project.path,
|
||||||
branch: name,
|
branch: name,
|
||||||
cleanup_on_kill: true,
|
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
createSession,
|
createSession,
|
||||||
fetchNames,
|
fetchNames,
|
||||||
getDaemonInfo,
|
getDaemonInfo,
|
||||||
|
listWorktrees,
|
||||||
projects as fetchProjects,
|
projects as fetchProjects,
|
||||||
|
reclaimWorktree,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import {
|
import {
|
||||||
buildFastForwardRequest,
|
buildFastForwardRequest,
|
||||||
@@ -22,6 +24,7 @@ import { loadLayout, saveLayout } from "./persistence.js";
|
|||||||
import { OPEN_SPAWN_EVENT } from "./catalog.js";
|
import { OPEN_SPAWN_EVENT } from "./catalog.js";
|
||||||
import { initTheme } from "./theme.js";
|
import { initTheme } from "./theme.js";
|
||||||
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
|
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
|
||||||
|
import { createWorktreesView, type WorktreesViewHandle } from "./worktreesView.js";
|
||||||
import { installViewportSizing } from "./viewport.js";
|
import { installViewportSizing } from "./viewport.js";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
@@ -345,16 +348,47 @@ document.addEventListener("keydown", (e) => {
|
|||||||
type Route =
|
type Route =
|
||||||
| { kind: "catalog" }
|
| { kind: "catalog" }
|
||||||
| { kind: "attach"; sessionId: string }
|
| { kind: "attach"; sessionId: string }
|
||||||
|
| { kind: "worktrees" }
|
||||||
| { kind: "not-found"; raw: string };
|
| { kind: "not-found"; raw: string };
|
||||||
|
|
||||||
function parseRoute(): Route {
|
function parseRoute(): Route {
|
||||||
const hash = location.hash.replace(/^#/, "");
|
const hash = location.hash.replace(/^#/, "");
|
||||||
if (hash === "" || hash === "/") return { kind: "catalog" };
|
if (hash === "" || hash === "/") return { kind: "catalog" };
|
||||||
|
if (hash === "/worktrees") return { kind: "worktrees" };
|
||||||
const m = hash.match(/^\/attach\/([^/?#]+)$/);
|
const m = hash.match(/^\/attach\/([^/?#]+)$/);
|
||||||
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
|
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
|
||||||
return { kind: "not-found", raw: hash };
|
return { kind: "not-found", raw: hash };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retained worktrees (#84): built lazily on first visit, then kept so a
|
||||||
|
// revisit re-enters instantly. The daemon's home dir abbreviates paths once
|
||||||
|
// `/api/info` answers.
|
||||||
|
let worktreesView: WorktreesViewHandle | undefined;
|
||||||
|
let homeDir: string | undefined;
|
||||||
|
void getDaemonInfo()
|
||||||
|
.then((info) => {
|
||||||
|
homeDir = info.home_dir;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* absolute paths are a fine fallback */
|
||||||
|
});
|
||||||
|
|
||||||
|
function showWorktrees(): void {
|
||||||
|
pool.setVisible([]);
|
||||||
|
pool.setFocus(null);
|
||||||
|
contentShowsMv = false;
|
||||||
|
if (!worktreesView) {
|
||||||
|
worktreesView = createWorktreesView({
|
||||||
|
load: listWorktrees,
|
||||||
|
reclaim: reclaimWorktree,
|
||||||
|
homeDir: () => homeDir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
content.replaceChildren(worktreesView.el);
|
||||||
|
shellEl.dataset.route = "worktrees";
|
||||||
|
void worktreesView.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
function dispatch(): void {
|
function dispatch(): void {
|
||||||
const route = parseRoute();
|
const route = parseRoute();
|
||||||
switch (route.kind) {
|
switch (route.kind) {
|
||||||
@@ -376,6 +410,9 @@ function dispatch(): void {
|
|||||||
// tears panes down.
|
// tears panes down.
|
||||||
showMultiView();
|
showMultiView();
|
||||||
return;
|
return;
|
||||||
|
case "worktrees":
|
||||||
|
showWorktrees();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,48 @@ describe("multiview openDraft (#62)", () => {
|
|||||||
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
|
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reuses an already-synced empty pane instead of splitting (#72)", 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();
|
||||||
|
// Let the mounted form's loadProjects() settle and auto-sync its picked
|
||||||
|
// defaults into leaf.draft before openDraft runs (#72's dead-branch window).
|
||||||
|
await flush();
|
||||||
|
expect(leaves(tab.root).find((l) => l.id === empty)?.draft).not.toBeNull();
|
||||||
|
|
||||||
|
mv.openDraft();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(leaves(tab.root)).toHaveLength(2);
|
||||||
|
expect(tab.focusedLeafId).toBe(empty);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a seed after reusing an already-synced empty pane (#72)", 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();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const leaf = leaves(tab.root).find((l) => l.id === empty)!;
|
||||||
|
expect(leaf.draft?.cwd).toBe("/home/user/proj");
|
||||||
|
expect(leaf.draft?.command).toBe("claude");
|
||||||
|
const cwdRaw = mv.el.querySelector(".mv-draft .cwd-raw") as HTMLInputElement;
|
||||||
|
expect(cwdRaw.value).toBe("/home/user/proj");
|
||||||
|
});
|
||||||
|
|
||||||
it("seeds a clone's cwd and command into the draft", async () => {
|
it("seeds a clone's cwd and command into the draft", async () => {
|
||||||
const layout = emptyLayout();
|
const layout = emptyLayout();
|
||||||
const mv = makeMv(layout);
|
const mv = makeMv(layout);
|
||||||
@@ -160,3 +202,83 @@ describe("multiview openDraft (#62)", () => {
|
|||||||
expect(leaf.draft?.command).toBe("claude");
|
expect(leaf.draft?.command).toBe("claude");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("multiview pane-drag swap (#73)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stubFetch();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Drag `sourcePane`'s title onto `targetPane`'s center, as the real pane
|
||||||
|
* drag handler does — modelled after the pointer sequence in runDrag()/
|
||||||
|
* updateDock() in multiview.ts, since neither is exposed on MultiView. */
|
||||||
|
async function dragPaneOntoCenter(sourcePane: HTMLElement, targetPane: HTMLElement): Promise<void> {
|
||||||
|
// The real drag-start gate requires the pointerdown target to sit inside
|
||||||
|
// an `.attach-header` (attach.ts's session title bar). Draft panes mount
|
||||||
|
// no such element, so stand one in for the gate check itself.
|
||||||
|
const marker = document.createElement("div");
|
||||||
|
marker.className = "attach-header";
|
||||||
|
sourcePane.appendChild(marker);
|
||||||
|
|
||||||
|
vi.spyOn(targetPane, "getBoundingClientRect").mockReturnValue({
|
||||||
|
left: 100,
|
||||||
|
top: 0,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
right: 200,
|
||||||
|
bottom: 100,
|
||||||
|
x: 100,
|
||||||
|
y: 0,
|
||||||
|
toJSON() {},
|
||||||
|
} as DOMRect);
|
||||||
|
(document as unknown as { elementsFromPoint: (x: number, y: number) => Element[] }).elementsFromPoint = () => [
|
||||||
|
targetPane,
|
||||||
|
];
|
||||||
|
|
||||||
|
marker.dispatchEvent(
|
||||||
|
new PointerEvent("pointerdown", { clientX: 0, clientY: 0, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new PointerEvent("pointermove", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }));
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("moves each draft's form along with its data when two draft panes are swapped", async () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
const leaf1 = splitLeaf(tab, "", null, "row", true);
|
||||||
|
const leaf2 = splitLeaf(tab, leaf1, null, "row", true);
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const paneOf = (leafId: string) => mv.el.querySelector(`[data-leaf-id="${leafId}"]`) as HTMLElement;
|
||||||
|
const nameInputOf = (leafId: string) => paneOf(leafId).querySelector("input[name=name]") as HTMLInputElement;
|
||||||
|
|
||||||
|
nameInputOf(leaf1).value = "west";
|
||||||
|
nameInputOf(leaf1).dispatchEvent(new Event("input"));
|
||||||
|
nameInputOf(leaf2).value = "east";
|
||||||
|
nameInputOf(leaf2).dispatchEvent(new Event("input"));
|
||||||
|
await flush();
|
||||||
|
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("west");
|
||||||
|
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("east");
|
||||||
|
|
||||||
|
await dragPaneOntoCenter(paneOf(leaf1), paneOf(leaf2));
|
||||||
|
|
||||||
|
// The leaf data swaps (already correct pre-fix)...
|
||||||
|
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("east");
|
||||||
|
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("west");
|
||||||
|
// ...and the mounted forms must follow, not stay behind showing the old
|
||||||
|
// pane's values (#73).
|
||||||
|
expect(nameInputOf(leaf1).value).toBe("east");
|
||||||
|
expect(nameInputOf(leaf2).value).toBe("west");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+18
-2
@@ -423,9 +423,15 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
||||||
if (!target.sessionId && !target.draft) {
|
if (!target.sessionId) {
|
||||||
// An already-empty pane becomes the draft in place.
|
// Empty = no session, not `!draft` (#72): the mounted form auto-syncs
|
||||||
|
// picked defaults into `draft` once ready, so that's never reliably null.
|
||||||
target.draft = draft();
|
target.draft = draft();
|
||||||
|
const stale = draftForms.get(target.id);
|
||||||
|
if (stale) {
|
||||||
|
stale.destroy();
|
||||||
|
draftForms.delete(target.id);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Split a fresh draft beside the focused pane.
|
// Split a fresh draft beside the focused pane.
|
||||||
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
|
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
|
||||||
@@ -695,6 +701,16 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
const dragged = findLeaf(tab.root, self)!;
|
const dragged = findLeaf(tab.root, self)!;
|
||||||
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId];
|
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId];
|
||||||
[dragged.draft, target.draft] = [target.draft, dragged.draft];
|
[dragged.draft, target.draft] = [target.draft, dragged.draft];
|
||||||
|
// The leaf data moved but each pane's mounted form is still keyed to
|
||||||
|
// its old leaf (#73) — tear both down like openDraft's reuse case
|
||||||
|
// (#72) so render() remounts fresh forms seeded from the swapped data.
|
||||||
|
for (const id of [dragged.id, target.id]) {
|
||||||
|
const stale = draftForms.get(id);
|
||||||
|
if (stale) {
|
||||||
|
stale.destroy();
|
||||||
|
draftForms.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
tab.focusedLeafId = target.id;
|
tab.focusedLeafId = target.id;
|
||||||
} else if (sessionId) {
|
} else if (sessionId) {
|
||||||
const loc = locateSession(layout, sessionId);
|
const loc = locateSession(layout, sessionId);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const PROJECTS = "/api/projects";
|
|||||||
export const GITROOT = "/api/gitroot";
|
export const GITROOT = "/api/gitroot";
|
||||||
export const NAMES = "/api/names";
|
export const NAMES = "/api/names";
|
||||||
export const BRANCHES = "/api/branches";
|
export const BRANCHES = "/api/branches";
|
||||||
|
export const WORKTREES = "/api/worktrees";
|
||||||
export const EVENTS = "/api/events";
|
export const EVENTS = "/api/events";
|
||||||
|
|
||||||
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
|
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
|
||||||
@@ -29,6 +30,11 @@ export function attach(id: Uuid): string {
|
|||||||
return `${API_PREFIX}/attach/${encodeURIComponent(id)}`;
|
return `${API_PREFIX}/attach/${encodeURIComponent(id)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80). */
|
||||||
|
export function worktree(id: Uuid): string {
|
||||||
|
return `${WORKTREES}/${encodeURIComponent(id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
||||||
export function withPathQuery(route: string, value: string): string {
|
export function withPathQuery(route: string, value: string): string {
|
||||||
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ describe("buildSpawnRequest", () => {
|
|||||||
worktree: {
|
worktree: {
|
||||||
base_repo: "/home/user/project",
|
base_repo: "/home/user/project",
|
||||||
branch: "feat/foo",
|
branch: "feat/foo",
|
||||||
cleanup_on_kill: true,
|
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -63,7 +62,6 @@ describe("buildSpawnRequest", () => {
|
|||||||
worktree: {
|
worktree: {
|
||||||
base_repo: "/home/user/project",
|
base_repo: "/home/user/project",
|
||||||
branch: "spawn-v2",
|
branch: "spawn-v2",
|
||||||
cleanup_on_kill: true,
|
|
||||||
create_branch: false,
|
create_branch: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+34
-8
@@ -75,9 +75,6 @@ export function buildSpawnRequest(
|
|||||||
req.worktree = {
|
req.worktree = {
|
||||||
base_repo: baseRepo,
|
base_repo: baseRepo,
|
||||||
branch,
|
branch,
|
||||||
// The daemon always cleans up, guarded against unsaved work (#56);
|
|
||||||
// the wire field stays for older daemons.
|
|
||||||
cleanup_on_kill: true,
|
|
||||||
// Existing-mode picks must check out as-is; the daemon creates
|
// Existing-mode picks must check out as-is; the daemon creates
|
||||||
// anything else with `worktree add -b` (#56/#61).
|
// anything else with `worktree add -b` (#56/#61).
|
||||||
create_branch: mode !== "existing",
|
create_branch: mode !== "existing",
|
||||||
@@ -97,6 +94,11 @@ export const FALLBACK_PRESETS: SpawnPreset[] = [
|
|||||||
{ id: "custom", label: "Custom…", command: "" },
|
{ id: "custom", label: "Custom…", command: "" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Quiet period after raw-path typing before `/api/gitroot` fires (#74).
|
||||||
|
* Must stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in
|
||||||
|
* crates/maestro-tui/src/draft.rs so both clients debounce alike. */
|
||||||
|
const GITROOT_PROBE_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
export interface SpawnFormHandle {
|
export interface SpawnFormHandle {
|
||||||
el: HTMLFormElement;
|
el: HTMLFormElement;
|
||||||
/** Remove global listeners (dir autocomplete) — call when unmounting. */
|
/** Remove global listeners (dir autocomplete) — call when unmounting. */
|
||||||
@@ -416,7 +418,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
cwd = cwdRaw.value.trim();
|
cwd = cwdRaw.value.trim();
|
||||||
branchCache = null;
|
branchCache = null;
|
||||||
emit();
|
emit();
|
||||||
if (wtEnabled.checked) probeGitRoot();
|
if (wtEnabled.checked) scheduleGitRootProbe();
|
||||||
});
|
});
|
||||||
|
|
||||||
function markActiveProject(activeCwd: string | null): void {
|
function markActiveProject(activeCwd: string | null): void {
|
||||||
@@ -433,9 +435,19 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
cwdRawWrap.hidden = true;
|
cwdRawWrap.hidden = true;
|
||||||
markActiveProject(project.path);
|
markActiveProject(project.path);
|
||||||
// The scan already knows this directory is inside a repo (#61): the
|
// The scan already knows this directory is inside a repo (#61): the
|
||||||
// toggle defaults back on and no /api/gitroot round trip is needed.
|
// toggle defaults back on and no /api/gitroot round trip is needed —
|
||||||
wtEnabled.checked = true;
|
// unless the directory *is* a linked worktree itself (#91): spawning
|
||||||
applyProjectRepo(project.base_repo ?? project.path);
|
// in place is the only thing that works there, so default the toggle
|
||||||
|
// off. Turning it back on by hand still tries (and fails with git's
|
||||||
|
// own message), which is deliberate (#91 non-goal).
|
||||||
|
wtEnabled.checked = !project.is_worktree;
|
||||||
|
if (project.is_worktree) {
|
||||||
|
inferredBaseRepo = null;
|
||||||
|
wtBody.hidden = true;
|
||||||
|
wtHint.hidden = true;
|
||||||
|
} else {
|
||||||
|
applyProjectRepo(project.base_repo ?? project.path);
|
||||||
|
}
|
||||||
emit();
|
emit();
|
||||||
// Picking a project pre-selects the command it was last used with.
|
// Picking a project pre-selects the command it was last used with.
|
||||||
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
|
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
|
||||||
@@ -572,7 +584,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
applyProjectRepo(project.base_repo ?? project.path);
|
applyProjectRepo(project.base_repo ?? project.path);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void probeGitRoot();
|
scheduleGitRootProbe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every keystroke resets this timer, so a burst of typing issues one
|
||||||
|
// request once the operator pauses. `probeToken` inside probeGitRoot still
|
||||||
|
// owns ordering: a scheduled probe that fires after a newer one started
|
||||||
|
// loses the token race and is dropped.
|
||||||
|
let probeTimer: number | undefined;
|
||||||
|
function scheduleGitRootProbe(): void {
|
||||||
|
window.clearTimeout(probeTimer);
|
||||||
|
probeTimer = window.setTimeout(() => {
|
||||||
|
probeTimer = undefined;
|
||||||
|
void probeGitRoot();
|
||||||
|
}, GITROOT_PROBE_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probeGitRoot(): Promise<void> {
|
async function probeGitRoot(): Promise<void> {
|
||||||
@@ -733,6 +758,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
el: form,
|
el: form,
|
||||||
destroy() {
|
destroy() {
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
|
window.clearTimeout(probeTimer);
|
||||||
autocomplete.destroy();
|
autocomplete.destroy();
|
||||||
form.remove();
|
form.remove();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||||
|
|
||||||
|
/** Debounce window under test — must equal GITROOT_PROBE_DEBOUNCE_MS in
|
||||||
|
* spawnForm.ts (#74). */
|
||||||
|
const DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
|
const PROJECTS = [
|
||||||
|
{ path: "/home/user/maestro", name: "maestro", last_activity: "2026-08-20T10:00:00Z" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Stub the fetches the form makes. Gitroot responses for a given path can be
|
||||||
|
* held until `releaseGitRoot(path)` is called, so tests can land answers out
|
||||||
|
* of order. */
|
||||||
|
function stubFetch(opts?: { holdGitRootFor?: string[] }): {
|
||||||
|
urls: string[];
|
||||||
|
gitRootCalls: string[];
|
||||||
|
releaseGitRoot: (path: string) => void;
|
||||||
|
} {
|
||||||
|
const urls: string[] = [];
|
||||||
|
const gitRootCalls: string[] = [];
|
||||||
|
const releases = new Map<string, () => void>();
|
||||||
|
const held = new Map<string, Promise<void>>();
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
urls.push(url);
|
||||||
|
if (url.includes("/api/dirs")) {
|
||||||
|
return new Response(JSON.stringify({ entries: [] }), { status: 200 });
|
||||||
|
}
|
||||||
|
if (url.includes("/api/gitroot")) {
|
||||||
|
const path = new URL(url, "http://localhost").searchParams.get("path") ?? "";
|
||||||
|
gitRootCalls.push(path);
|
||||||
|
if (opts?.holdGitRootFor?.includes(path)) {
|
||||||
|
let release!: () => void;
|
||||||
|
held.set(
|
||||||
|
path,
|
||||||
|
new Promise<void>((r) => {
|
||||||
|
release = r;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
releases.set(path, release);
|
||||||
|
}
|
||||||
|
await held.get(path);
|
||||||
|
return new Response(JSON.stringify({ root: path }), { status: 200 });
|
||||||
|
}
|
||||||
|
if (url.includes("/api/names")) {
|
||||||
|
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
|
||||||
|
}
|
||||||
|
if (url.includes("/api/projects")) {
|
||||||
|
return new Response(JSON.stringify(PROJECTS), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
urls,
|
||||||
|
gitRootCalls,
|
||||||
|
releaseGitRoot: (path: string) => releases.get(path)?.(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("spawnForm gitroot probe debounce (#74)", () => {
|
||||||
|
let handle: SpawnFormHandle | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
handle?.destroy();
|
||||||
|
handle = null;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mount(opts?: Parameters<typeof stubFetch>[0]) {
|
||||||
|
const stub = stubFetch(opts);
|
||||||
|
handle = createSpawnForm({ onSuccess() {} });
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||||
|
return { form: handle.el, stub };
|
||||||
|
}
|
||||||
|
|
||||||
|
const drain = async () => {
|
||||||
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
function typeRaw(form: HTMLFormElement, value: string): void {
|
||||||
|
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||||
|
raw.value = value;
|
||||||
|
raw.dispatchEvent(new Event("input"));
|
||||||
|
}
|
||||||
|
|
||||||
|
it("a keystroke burst fires exactly one probe after the quiet period", async () => {
|
||||||
|
const { form, stub } = await mount();
|
||||||
|
|
||||||
|
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
|
||||||
|
// Burst: every keystroke reschedules; nothing may hit /api/gitroot yet.
|
||||||
|
for (let i = 1; i <= "/opt/somewhere".length; i++) {
|
||||||
|
typeRaw(form, "/opt/somewhere".slice(0, i));
|
||||||
|
await drain();
|
||||||
|
expect(stub.gitRootCalls).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(stub.gitRootCalls).toEqual(["/opt/somewhere"]);
|
||||||
|
expect(stub.urls.filter((u) => u.includes("/api/gitroot")).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a stale probe answer cannot overwrite a newer input", async () => {
|
||||||
|
const { form, stub } = await mount({ holdGitRootFor: ["/repo/old"] });
|
||||||
|
|
||||||
|
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
|
||||||
|
|
||||||
|
// Probe for the old target, held in flight.
|
||||||
|
typeRaw(form, "/repo/old");
|
||||||
|
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
|
||||||
|
|
||||||
|
// Newer target supersedes it and answers immediately.
|
||||||
|
typeRaw(form, "/repo/new");
|
||||||
|
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(stub.gitRootCalls).toEqual(["/repo/old", "/repo/new"]);
|
||||||
|
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
|
||||||
|
expect(
|
||||||
|
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// The late answer to the old target lands last but must lose.
|
||||||
|
stub.releaseGitRoot("/repo/old");
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
|
||||||
|
expect(
|
||||||
|
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ const BRANCHES = [
|
|||||||
* its branch list is still in flight. */
|
* its branch list is still in flight. */
|
||||||
function stubFetch(opts?: {
|
function stubFetch(opts?: {
|
||||||
gateBranches?: (release: () => void) => void;
|
gateBranches?: (release: () => void) => void;
|
||||||
|
projects?: unknown[];
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
const gated = new Promise<void>((resolve) => {
|
const gated = new Promise<void>((resolve) => {
|
||||||
@@ -37,7 +38,7 @@ function stubFetch(opts?: {
|
|||||||
return new Response(JSON.stringify(BRANCHES), { status: 200 });
|
return new Response(JSON.stringify(BRANCHES), { status: 200 });
|
||||||
}
|
}
|
||||||
if (url.includes("/api/projects")) {
|
if (url.includes("/api/projects")) {
|
||||||
return new Response(JSON.stringify(PROJECTS), { status: 200 });
|
return new Response(JSON.stringify(opts?.projects ?? PROJECTS), { status: 200 });
|
||||||
}
|
}
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
|
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
|
||||||
@@ -100,14 +101,44 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults the worktree toggle off for a row that is itself a worktree (#91)", async () => {
|
||||||
|
urls = stubFetch({
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
path: "/home/user/wt-91",
|
||||||
|
name: "wt-91",
|
||||||
|
is_worktree: true,
|
||||||
|
base_repo: "/home/user/maestro",
|
||||||
|
last_activity: "2026-08-20T10:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
handle = createSpawnForm({ onSuccess() {} });
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||||
|
|
||||||
|
handle.el
|
||||||
|
.querySelectorAll<HTMLButtonElement>(".project-row")
|
||||||
|
.forEach((row) => row.dataset.cwd === "/home/user/wt-91" && row.click());
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(handle.el.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
|
||||||
|
expect(handle.el.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("still probes the gitroot for a free-text directory", async () => {
|
it("still probes the gitroot for a free-text directory", async () => {
|
||||||
const form = await mount();
|
const form = await mount();
|
||||||
|
|
||||||
// Escape hatch: switch to a free-text dir; only now does /api/gitroot fire.
|
// Escape hatch: switch to a free-text dir; only now does /api/gitroot
|
||||||
|
// fire — debounced, so the quiet period must elapse first (#74).
|
||||||
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
|
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
|
||||||
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||||
raw.value = "/opt/somewhere";
|
raw.value = "/opt/somewhere";
|
||||||
raw.dispatchEvent(new Event("input"));
|
raw.dispatchEvent(new Event("input"));
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
await drain();
|
await drain();
|
||||||
|
|
||||||
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true);
|
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true);
|
||||||
|
|||||||
@@ -831,6 +831,13 @@ textarea {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* #83: the session row's blocked-work warning — an unforced kill will be
|
||||||
|
refused, and the operator should see that before pressing the button. */
|
||||||
|
.leaf-badge.leaf-blocked {
|
||||||
|
color: var(--warn, #b45309);
|
||||||
|
border-color: var(--warn, #b45309);
|
||||||
|
}
|
||||||
|
|
||||||
.leaf-actions {
|
.leaf-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: var(--sp-1);
|
right: var(--sp-1);
|
||||||
@@ -1208,6 +1215,9 @@ textarea {
|
|||||||
/* ---------- forms ---------- */
|
/* ---------- forms ---------- */
|
||||||
|
|
||||||
.spawn-form {
|
.spawn-form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 60ch;
|
||||||
|
margin: 0 auto;
|
||||||
padding: var(--sp-3);
|
padding: var(--sp-3);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2225,3 +2235,148 @@ textarea {
|
|||||||
background: oklch(0 0 0 / 0.5);
|
background: oklch(0 0 0 / 0.5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- retained worktrees (#84) ---------- */
|
||||||
|
|
||||||
|
.worktrees {
|
||||||
|
padding: var(--sp-4);
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-head {
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-title {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-15);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
margin: 0 0 var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-sub {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-2) var(--sp-2);
|
||||||
|
border-bottom: 1px solid var(--surface-2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-branch {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-path {
|
||||||
|
color: var(--muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-reason {
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status never rides on color alone: the label itself says what blocks. */
|
||||||
|
.wt-way[data-clear] {
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-way:not([data-clear]) {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-row .wt-reclaim-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-row .wt-reclaim-btn::before {
|
||||||
|
content: "[";
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-row .wt-reclaim-btn::after {
|
||||||
|
content: "]";
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-row .wt-reclaim-btn:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
background: var(--danger-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: var(--danger-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The destructive confirmation, centered over the section. */
|
||||||
|
.wt-confirm {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: var(--z-backdrop);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: oklch(0 0 0 / 0.5);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-confirm[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-confirm-panel {
|
||||||
|
pointer-events: auto;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--surface-2);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
max-width: 480px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-confirm-q {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-confirm-way {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-confirm-note {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
}
|
||||||
|
|||||||
+66
-1
@@ -31,6 +31,11 @@ export interface Session {
|
|||||||
* observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir).
|
* observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir).
|
||||||
* Drives the directory-based session tree. Absent until first observed. */
|
* Drives the directory-based session tree. Absent until first observed. */
|
||||||
current_cwd?: string | null;
|
current_cwd?: string | null;
|
||||||
|
/** What currently blocks an unforced kill of this session (#83), as the
|
||||||
|
* daemon's watcher last probed it. Absent until probed and for sessions
|
||||||
|
* without a worktree; all counts default to 0 on the wire for older
|
||||||
|
* daemons. */
|
||||||
|
blocked_work?: InTheWay | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One directory's immediate subdirectories (NAP-329). */
|
/** One directory's immediate subdirectories (NAP-329). */
|
||||||
@@ -113,7 +118,6 @@ export type Projects = Project[];
|
|||||||
export interface WorktreeSpec {
|
export interface WorktreeSpec {
|
||||||
base_repo: string;
|
base_repo: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
cleanup_on_kill?: boolean;
|
|
||||||
/** When false, `branch` already exists and is checked out as-is (#61).
|
/** When false, `branch` already exists and is checked out as-is (#61).
|
||||||
* Defaults to true (create a new branch) on older daemons. */
|
* Defaults to true (create a new branch) on older daemons. */
|
||||||
create_branch?: boolean;
|
create_branch?: boolean;
|
||||||
@@ -133,6 +137,67 @@ export interface BranchInfo {
|
|||||||
/** `GET /api/branches?path=` — the local branches of one repository (#55). */
|
/** `GET /api/branches?path=` — the local branches of one repository (#55). */
|
||||||
export type Branches = BranchInfo[];
|
export type Branches = BranchInfo[];
|
||||||
|
|
||||||
|
/** Why a retained worktree outlived its session (#80). New values are
|
||||||
|
* additive; unknown reasons render generically. */
|
||||||
|
export type RetainedReason = "force-killed" | "exited-with-unsaved-work";
|
||||||
|
|
||||||
|
/** What currently blocks an unforced kill of a worktree's session (#80),
|
||||||
|
* recomputed by the daemon on every read. All counts default to 0 on the
|
||||||
|
* wire for older daemons. */
|
||||||
|
export interface InTheWay {
|
||||||
|
uncommitted_files?: number;
|
||||||
|
untracked_files?: number;
|
||||||
|
unpushed_commits?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry of `GET /api/worktrees` (#80): a retained worktree with its
|
||||||
|
* in-the-way state as of this read. Reclaiming it removes the directory but
|
||||||
|
* never the branch. Mirrors `maestro_protocol::RetainedWorktree`. */
|
||||||
|
export interface RetainedWorktree {
|
||||||
|
id: Uuid;
|
||||||
|
path: string;
|
||||||
|
base_repo: string;
|
||||||
|
branch: string;
|
||||||
|
reason: RetainedReason;
|
||||||
|
retained_at: IsoTimestamp;
|
||||||
|
in_the_way?: InTheWay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by
|
||||||
|
* the kill refusal, the TUI and this client, so no two clients can describe
|
||||||
|
* the same tree differently. Zero parts are omitted; all-zero renders
|
||||||
|
* "no unsaved work". */
|
||||||
|
export function formatInTheWay(w: InTheWay): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const push = (n: number | undefined, unit: string): void => {
|
||||||
|
if (!n || n <= 0) return;
|
||||||
|
parts.push(n === 1 ? `1 ${unit}` : `${n} ${unit}s`);
|
||||||
|
};
|
||||||
|
push(w.uncommitted_files, "uncommitted file");
|
||||||
|
push(w.untracked_files, "untracked file");
|
||||||
|
push(w.unpushed_commits, "unpushed commit");
|
||||||
|
return parts.length > 0 ? parts.join(", ") : "no unsaved work";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same reason labels the CLI table and the TUI overlay use; unknown future
|
||||||
|
* reasons render their raw wire value. */
|
||||||
|
export function reasonLabel(reason: RetainedReason): string {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #83: this session's blocked-work breakdown when something actually blocks
|
||||||
|
* an unforced kill; `null` while clear or not yet probed. Render it with
|
||||||
|
* {@link formatInTheWay} so every client phrases it identically. */
|
||||||
|
export function sessionBlockers(s: Session): InTheWay | null {
|
||||||
|
const w = s.blocked_work;
|
||||||
|
if (!w) return null;
|
||||||
|
const blocked =
|
||||||
|
(w.uncommitted_files ?? 0) > 0 ||
|
||||||
|
(w.untracked_files ?? 0) > 0 ||
|
||||||
|
(w.unpushed_commits ?? 0) > 0;
|
||||||
|
return blocked ? w : null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateSessionRequest {
|
export interface CreateSessionRequest {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
command: string;
|
command: string;
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createWorktreesView } from "./worktreesView.js";
|
||||||
|
import type { RetainedWorktree } from "./types.js";
|
||||||
|
|
||||||
|
function worktree(overrides: Partial<RetainedWorktree>): RetainedWorktree {
|
||||||
|
return {
|
||||||
|
id: "11111111-2222-3333-4444-555555555555",
|
||||||
|
path: "/home/naps/tea/maestro/worktrees/feat/login",
|
||||||
|
base_repo: "/home/naps/tea/maestro",
|
||||||
|
branch: "feat/login",
|
||||||
|
reason: "force-killed",
|
||||||
|
retained_at: "2026-08-24T10:00:00Z",
|
||||||
|
in_the_way: {
|
||||||
|
uncommitted_files: 2,
|
||||||
|
untracked_files: 1,
|
||||||
|
unpushed_commits: 3,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mount a view over a scripted store and return (el, reclaimed-ids). */
|
||||||
|
function mount(items: RetainedWorktree[], homeDir = "/home/naps") {
|
||||||
|
const reclaimed: string[] = [];
|
||||||
|
const view = createWorktreesView({
|
||||||
|
load: async () => items,
|
||||||
|
reclaim: async (id) => {
|
||||||
|
reclaimed.push(id);
|
||||||
|
items.splice(
|
||||||
|
items.findIndex((w) => w.id === id),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
homeDir: () => homeDir,
|
||||||
|
});
|
||||||
|
document.body.appendChild(view.el);
|
||||||
|
return { view, reclaimed };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("retained-worktrees view (#84)", () => {
|
||||||
|
it("lists branch, abbreviated path, reason and what is in the way", async () => {
|
||||||
|
const { view } = mount([
|
||||||
|
worktree({
|
||||||
|
path: "/home/naps/tea/maestro/worktrees/feat/login",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await view.refresh();
|
||||||
|
const text = view.el.textContent ?? "";
|
||||||
|
expect(text).toContain("feat/login");
|
||||||
|
// Sidebar-style abbreviation of the path against the daemon's home dir,
|
||||||
|
// full path kept in the tooltip.
|
||||||
|
expect(text).toContain("t/m/w/f/login");
|
||||||
|
const pathEl = view.el.querySelector<HTMLElement>(".wt-path")!;
|
||||||
|
expect(pathEl.title).toBe("/home/naps/tea/maestro/worktrees/feat/login");
|
||||||
|
// Same reason labels as `maestro worktrees list` and the TUI overlay.
|
||||||
|
expect(text).toContain("force-killed");
|
||||||
|
// Same phrasing as InTheWay's Display impl — shared with the kill refusal.
|
||||||
|
expect(text).toContain("2 uncommitted files, 1 untracked file, 3 unpushed commits");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says a tree whose work has since been pushed is clear", async () => {
|
||||||
|
const { view } = mount([worktree({ in_the_way: {} })]);
|
||||||
|
await view.refresh();
|
||||||
|
expect(view.el.textContent).toContain("no unsaved work");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an empty store as a normal state, not an error", async () => {
|
||||||
|
const { view } = mount([]);
|
||||||
|
await view.refresh();
|
||||||
|
const text = view.el.textContent ?? "";
|
||||||
|
expect(text).toContain("no retained worktrees");
|
||||||
|
expect(text.toLowerCase()).not.toContain("error");
|
||||||
|
expect(text.toLowerCase()).not.toContain("failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards reclaim with a confirmation that names what is lost and that the branch survives", async () => {
|
||||||
|
const wt = worktree({ branch: "feat/login" });
|
||||||
|
const { view, reclaimed } = mount([wt]);
|
||||||
|
await view.refresh();
|
||||||
|
|
||||||
|
view.el.querySelector<HTMLButtonElement>(".wt-reclaim-btn")!.click();
|
||||||
|
const confirmText = view.el.querySelector<HTMLElement>(".wt-confirm")!.textContent ?? "";
|
||||||
|
expect(confirmText).toContain("Reclaim worktree feat/login?");
|
||||||
|
// Names what will be lost before it happens…
|
||||||
|
expect(confirmText).toContain(
|
||||||
|
"2 uncommitted files, 1 untracked file, 3 unpushed commits",
|
||||||
|
);
|
||||||
|
// …and says reclaim does not take the commits too.
|
||||||
|
expect(confirmText).toContain("never deleted");
|
||||||
|
|
||||||
|
view.el.querySelector<HTMLButtonElement>(".wt-confirm-cancel")!.click();
|
||||||
|
expect(reclaimed).toEqual([]);
|
||||||
|
|
||||||
|
view.el.querySelector<HTMLButtonElement>(".wt-reclaim-btn")!.click();
|
||||||
|
view.el.querySelector<HTMLButtonElement>(".wt-confirm-go")!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(reclaimed).toEqual(["11111111-2222-3333-4444-555555555555"]);
|
||||||
|
// The row is gone once the refresh lands.
|
||||||
|
expect(view.el.textContent).toContain("no retained worktrees");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts oldest-retained first, like every other client", async () => {
|
||||||
|
const { view } = mount([
|
||||||
|
worktree({ branch: "newer", retained_at: "2026-08-24T12:00:00Z" }),
|
||||||
|
worktree({ branch: "older", retained_at: "2026-08-24T08:00:00Z" }),
|
||||||
|
]);
|
||||||
|
await view.refresh();
|
||||||
|
const branches = [...view.el.querySelectorAll(".wt-branch")].map((n) => n.textContent);
|
||||||
|
expect(branches).toEqual(["older", "newer"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// Retained-worktrees view (#84): every worktree the daemon is holding that no
|
||||||
|
// session uses, listed from `GET /api/worktrees` with a reclaim action.
|
||||||
|
//
|
||||||
|
// Wording is shared with the CLI (`maestro worktrees list`) and the TUI
|
||||||
|
// overlay: same reason labels, same in-the-way phrasing via
|
||||||
|
// `formatInTheWay` (the mirror of `InTheWay`'s Display impl). Reclaim reads
|
||||||
|
// as what it is — destructive — naming what will be lost before it happens
|
||||||
|
// and saying out loud that the branch survives (docs/WORKTREES.md).
|
||||||
|
|
||||||
|
import { escapeHtml } from "./html.js";
|
||||||
|
import {
|
||||||
|
abbreviateCwd,
|
||||||
|
abbreviateFolderName,
|
||||||
|
formatInTheWay,
|
||||||
|
reasonLabel,
|
||||||
|
type RetainedWorktree,
|
||||||
|
type Uuid,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export interface WorktreesDeps {
|
||||||
|
/** `GET /api/worktrees`. */
|
||||||
|
load(): Promise<RetainedWorktree[]>;
|
||||||
|
/** `DELETE /api/worktrees/:id`. */
|
||||||
|
reclaim(id: Uuid): Promise<void>;
|
||||||
|
/** The daemon's home dir, for `~` abbreviation of paths. */
|
||||||
|
homeDir(): string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreesViewHandle {
|
||||||
|
el: HTMLElement;
|
||||||
|
/** Re-fetch the store and re-render. Safe to call repeatedly. */
|
||||||
|
refresh(): Promise<void>;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Oldest-retained first, matching `maestro worktrees list`'s ordering. */
|
||||||
|
function sorted(items: RetainedWorktree[]): RetainedWorktree[] {
|
||||||
|
return [...items].sort(
|
||||||
|
(a, b) => Date.parse(a.retained_at) - Date.parse(b.retained_at),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorktreesView(deps: WorktreesDeps): WorktreesViewHandle {
|
||||||
|
const el = document.createElement("section");
|
||||||
|
el.className = "worktrees";
|
||||||
|
el.innerHTML = `
|
||||||
|
<header class="wt-head">
|
||||||
|
<h2 class="wt-title">retained worktrees</h2>
|
||||||
|
<p class="wt-sub">
|
||||||
|
Kept when a session ended with unsaved work. Reclaiming removes the
|
||||||
|
directory only; branches are never deleted.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div class="wt-list"></div>
|
||||||
|
<div class="wt-confirm" role="alertdialog" aria-modal="true" hidden></div>
|
||||||
|
`;
|
||||||
|
const listHost = el.querySelector<HTMLElement>(".wt-list")!;
|
||||||
|
const confirmEl = el.querySelector<HTMLElement>(".wt-confirm")!;
|
||||||
|
|
||||||
|
let pendingId: string | null = null;
|
||||||
|
|
||||||
|
const render = (items: RetainedWorktree[]) => {
|
||||||
|
listHost.replaceChildren();
|
||||||
|
if (items.length === 0) {
|
||||||
|
// A drained store is the normal state — say so plainly, not like
|
||||||
|
// something went wrong.
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "empty";
|
||||||
|
empty.innerHTML = `
|
||||||
|
<div class="empty-glyph" aria-hidden="true">⌂</div>
|
||||||
|
<div class="empty-title">no retained worktrees</div>
|
||||||
|
<p class="empty-hint">Nothing is being held. Worktrees kept after a force-kill or an unsaved exit show up here.</p>`;
|
||||||
|
listHost.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const home = deps.homeDir();
|
||||||
|
for (const w of sorted(items)) {
|
||||||
|
listHost.appendChild(renderRow(w, home));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRow = (w: RetainedWorktree, home: string | undefined): HTMLElement => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "wt-row";
|
||||||
|
const clear = formatInTheWay(w.in_the_way ?? {}) === "no unsaved work";
|
||||||
|
row.innerHTML = `
|
||||||
|
<span class="wt-branch">${escapeHtml(w.branch)}</span>
|
||||||
|
<span class="wt-path" title="${escapeHtml(w.path)}">${abbreviateFolderName(abbreviateCwd(w.path, home))}</span>
|
||||||
|
<span class="wt-reason">${escapeHtml(reasonLabel(w.reason))}</span>
|
||||||
|
<span class="wt-way"${clear ? ' data-clear="true"' : ""}>${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}</span>
|
||||||
|
<button class="btn wt-reclaim-btn" type="button">Reclaim</button>
|
||||||
|
`;
|
||||||
|
row.querySelector(".wt-reclaim-btn")!.addEventListener("click", () => {
|
||||||
|
showConfirm(w);
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showConfirm = (w: RetainedWorktree) => {
|
||||||
|
pendingId = w.id;
|
||||||
|
// Mirrors the TUI's ReclaimConfirm line for line: what goes away, what is
|
||||||
|
// in the way right now, and that the branch survives reclaim.
|
||||||
|
confirmEl.innerHTML = `
|
||||||
|
<div class="wt-confirm-panel">
|
||||||
|
<p class="wt-confirm-q">Reclaim worktree ${escapeHtml(w.branch)}?</p>
|
||||||
|
<p class="wt-confirm-way">Currently in the way: ${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}</p>
|
||||||
|
<p class="wt-confirm-note">The directory is removed; the branch is never deleted.</p>
|
||||||
|
<div class="wt-confirm-actions">
|
||||||
|
<button class="btn btn-danger wt-confirm-go" type="button">Reclaim</button>
|
||||||
|
<button class="btn btn-ghost wt-confirm-cancel" type="button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
confirmEl.hidden = false;
|
||||||
|
confirmEl.querySelector<HTMLButtonElement>(".wt-confirm-go")!.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideConfirm = () => {
|
||||||
|
pendingId = null;
|
||||||
|
confirmEl.hidden = true;
|
||||||
|
confirmEl.replaceChildren();
|
||||||
|
};
|
||||||
|
|
||||||
|
confirmEl.addEventListener("click", async (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest(".wt-confirm-cancel")) {
|
||||||
|
hideConfirm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!target.closest(".wt-confirm-go") || !pendingId) return;
|
||||||
|
const id = pendingId;
|
||||||
|
hideConfirm();
|
||||||
|
try {
|
||||||
|
await deps.reclaim(id);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Failed to reclaim: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && !confirmEl.hidden) hideConfirm();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
try {
|
||||||
|
render(await deps.load());
|
||||||
|
} catch (err) {
|
||||||
|
listHost.replaceChildren();
|
||||||
|
const fail = document.createElement("div");
|
||||||
|
fail.className = "empty";
|
||||||
|
fail.innerHTML = `<div class="empty-title">Couldn't load retained worktrees</div><p class="empty-hint"></p>`;
|
||||||
|
fail.querySelector(".empty-hint")!.textContent = (err as Error).message;
|
||||||
|
listHost.appendChild(fail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
el,
|
||||||
|
refresh,
|
||||||
|
dispose() {
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
el.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user