feat(maestro): drop retained worktrees and header debug toggle
Removes the retained-worktree store end to end: the daemon's state-dir store, GET/DELETE /api/worktrees, `maestro worktrees list|reclaim`, the TUI overlay behind `w`, and the web `#/worktrees` view and header link. The kill guard stays. A forced kill and a dirty natural exit now leave the worktree on disk untouched instead of parking it in a store, so removing one is an ordinary `git worktree remove`. A pristine tree is still removed on exit. DELETE /api/sessions/:id always answers 204. The header's debug button also goes; Ctrl+Shift+D and `?hud` still toggle the HUD. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
43b90ccf0a
commit
d39fbdbbfc
@@ -63,8 +63,8 @@ nix/ # Home Manager module (`services.maestro`)
|
|||||||
See `docs/INSTALL.md` for end-user install instructions.
|
See `docs/INSTALL.md` for end-user install instructions.
|
||||||
|
|
||||||
`docs/WORKTREES.md` is the contract for session worktrees — placement,
|
`docs/WORKTREES.md` is the contract for session worktrees — placement,
|
||||||
collisions, what blocks a kill, what `--force` retains, and what reclaim
|
collisions, what blocks a kill, and what `--force` leaves behind. Read it
|
||||||
touches. Read it before changing anything that creates or removes one.
|
before changing anything that creates or removes one.
|
||||||
|
|
||||||
`docs/SIDEBAR.md` is the contract for session cards in the web and TUI
|
`docs/SIDEBAR.md` is the contract for session cards in the web and TUI
|
||||||
sidebars — line layout, activity colours, duration format, state precedence.
|
sidebars — line layout, activity colours, duration format, state precedence.
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ 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),
|
|
||||||
/// Install or update the agent activity hook (#106).
|
/// Install or update the agent activity hook (#106).
|
||||||
Hooks(HooksArgs),
|
Hooks(HooksArgs),
|
||||||
}
|
}
|
||||||
@@ -62,34 +60,6 @@ pub enum HooksCommand {
|
|||||||
Install,
|
Install,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `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)]
|
||||||
pub struct ListArgs {
|
pub struct ListArgs {
|
||||||
/// Output as JSON instead of a table.
|
/// Output as JSON instead of a table.
|
||||||
@@ -138,8 +108,7 @@ 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
|
/// End the session even when its worktree holds unsaved work (#81). The
|
||||||
/// worktree is retained — never removed — and enters the retained store;
|
/// worktree is left on disk — never removed.
|
||||||
/// the output says where the work went.
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub force: bool,
|
pub force: bool,
|
||||||
}
|
}
|
||||||
@@ -301,63 +270,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
_ => panic!("wrong subcommand"),
|
_ => panic!("wrong subcommand"),
|
||||||
}
|
}
|
||||||
// No subcommand → help, like `worktrees`.
|
// No subcommand → help.
|
||||||
let err = Cli::try_parse_from(["maestro", "hooks"]).unwrap_err();
|
let err = Cli::try_parse_from(["maestro", "hooks"]).unwrap_err();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
err.kind(),
|
err.kind(),
|
||||||
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
|
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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,9 +9,8 @@ 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, WorktreesCommand};
|
use crate::cli::Command;
|
||||||
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)]
|
||||||
@@ -80,37 +79,10 @@ pub async fn dispatch(
|
|||||||
if !args.force {
|
if !args.force {
|
||||||
warn_if_blocked(&daemon, args.session_id).await;
|
warn_if_blocked(&daemon, args.session_id).await;
|
||||||
}
|
}
|
||||||
let outcome = run_kill(&daemon, &args).await?;
|
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::Hooks(_) => unreachable!("hooks handled before the daemon client exists"),
|
Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"),
|
||||||
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(())
|
||||||
}
|
}
|
||||||
@@ -150,7 +122,7 @@ async fn warn_if_blocked(daemon: &DaemonClient, id: uuid::Uuid) {
|
|||||||
{
|
{
|
||||||
if !b.is_clear() {
|
if !b.is_clear() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to retain it"
|
"warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to end it anyway"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,5 @@ 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};
|
||||||
|
|||||||
@@ -62,10 +62,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
|||||||
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
||||||
/// of removing it; the outcome names what was kept so dispatch can say where
|
/// of removing it; the outcome names what was kept so dispatch can say where
|
||||||
/// the work went.
|
/// the work went.
|
||||||
pub async fn run_kill(
|
pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), ClientError> {
|
||||||
client: &DaemonClient,
|
|
||||||
args: &KillArgs,
|
|
||||||
) -> Result<maestro_protocol::KillOutcome, ClientError> {
|
|
||||||
client.kill_session(args.session_id, args.force).await
|
client.kill_session(args.session_id, args.force).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
//! `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::display::abbreviate_dir;
|
|
||||||
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
|
|
||||||
|
|
||||||
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
|
|
||||||
|
|
||||||
/// `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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -70,9 +70,6 @@ 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,
|
||||||
@@ -83,10 +80,7 @@ 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());
|
|
||||||
// Spawned sessions learn where to report back (#103). Derived from the
|
// Spawned sessions learn where to report back (#103). Derived from the
|
||||||
// configured bind address; set before any spawn can happen.
|
// configured bind address; set before any spawn can happen.
|
||||||
manager.set_daemon_url(format!("http://{}", config.bind_addr));
|
manager.set_daemon_url(format!("http://{}", config.bind_addr));
|
||||||
@@ -108,7 +102,6 @@ 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,
|
||||||
retained,
|
|
||||||
));
|
));
|
||||||
|
|
||||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||||
|
|||||||
@@ -107,11 +107,6 @@ impl DaemonConfig {
|
|||||||
pub fn spawn_history_path(&self) -> PathBuf {
|
pub fn spawn_history_path(&self) -> PathBuf {
|
||||||
self.state_dir.join("spawn-history.jsonl")
|
self.state_dir.join("spawn-history.jsonl")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retained-worktree store directory (#78): `<state_dir>/retained-worktrees/`.
|
|
||||||
pub fn retained_worktree_dir(&self) -> PathBuf {
|
|
||||||
self.state_dir.join("retained-worktrees")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
|
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ pub mod panes;
|
|||||||
pub mod proc;
|
pub mod proc;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod retained_store;
|
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
pub mod spawn_history;
|
pub mod spawn_history;
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ pub struct LocalApiState {
|
|||||||
pub presets: Arc<Vec<SpawnPreset>>,
|
pub presets: Arc<Vec<SpawnPreset>>,
|
||||||
/// Project discovery (#54) backing `GET /api/projects`.
|
/// Project discovery (#54) backing `GET /api/projects`.
|
||||||
pub projects: Arc<crate::projects::ProjectScanner>,
|
pub projects: Arc<crate::projects::ProjectScanner>,
|
||||||
/// Retained worktrees (#78) backing `GET /api/worktrees` and their
|
|
||||||
/// reclaim endpoint (#80).
|
|
||||||
pub retained: Arc<crate::retained_store::RetainedStore>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: LocalApiState) -> Router {
|
pub fn router(state: LocalApiState) -> Router {
|
||||||
@@ -57,8 +54,6 @@ pub fn router(state: LocalApiState) -> Router {
|
|||||||
.route(routes::GITROOT, get(git_root))
|
.route(routes::GITROOT, get(git_root))
|
||||||
.route(routes::BRANCHES, get(crate::branches::handler))
|
.route(routes::BRANCHES, get(crate::branches::handler))
|
||||||
.route(routes::NAMES, get(names))
|
.route(routes::NAMES, get(names))
|
||||||
.route(routes::WORKTREES, get(list_worktrees))
|
|
||||||
.route(routes::patterns::WORKTREE, delete(reclaim_worktree))
|
|
||||||
.route(routes::INFO, get(daemon_info))
|
.route(routes::INFO, get(daemon_info))
|
||||||
.route(routes::EVENTS, any(events_ws))
|
.route(routes::EVENTS, any(events_ws))
|
||||||
.route(routes::patterns::ATTACH, any(attach_ws))
|
.route(routes::patterns::ATTACH, any(attach_ws))
|
||||||
@@ -105,8 +100,6 @@ fn session_error_status(code: &str) -> StatusCode {
|
|||||||
"invalid_env" => StatusCode::BAD_REQUEST,
|
"invalid_env" => StatusCode::BAD_REQUEST,
|
||||||
// A guard refusal is the daemon doing its job, not a failure (#79).
|
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||||
"kill_guarded" => StatusCode::CONFLICT,
|
"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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,9 +179,8 @@ async fn create_session(
|
|||||||
/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work
|
/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work
|
||||||
/// breakdown clients render as "3 uncommitted files, 2 unpushed commits".
|
/// breakdown clients render as "3 uncommitted files, 2 unpushed commits".
|
||||||
///
|
///
|
||||||
/// With `force=true` (#81) the refusal is overridden and the worktree
|
/// With `force=true` (#81) the refusal is overridden and the worktree is left
|
||||||
/// retained; the answer is `200` with a [`KillOutcome`] naming what was kept.
|
/// on disk rather than removed. Either way the answer is `204`.
|
||||||
/// Without it the historical `204` stands.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct KillQuery {
|
struct KillQuery {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -201,13 +193,7 @@ async fn kill_session(
|
|||||||
Query(q): Query<KillQuery>,
|
Query(q): Query<KillQuery>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
match s.manager.kill(id, q.force).await {
|
match s.manager.kill(id, q.force).await {
|
||||||
Ok(outcome) => {
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
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();
|
||||||
let status = session_error_status(&info.code);
|
let status = session_error_status(&info.code);
|
||||||
@@ -238,112 +224,6 @@ async fn rename_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/worktrees` (#80): every retained worktree, with the in-the-way
|
|
||||||
/// breakdown recomputed at read time — never stored, so a tree whose work has
|
|
||||||
/// since been committed or pushed says so. Entries whose directory somebody
|
|
||||||
/// removed by hand are pruned on the way through.
|
|
||||||
async fn list_worktrees(State(s): State<LocalApiState>) -> Response {
|
|
||||||
let retained = s.retained.clone();
|
|
||||||
let listed = tokio::task::spawn_blocking(move || {
|
|
||||||
retained.prune_missing()?;
|
|
||||||
retained
|
|
||||||
.load_all()?
|
|
||||||
.iter()
|
|
||||||
.map(|entry| {
|
|
||||||
// A tree git cannot read (permissions, corrupt checkout)
|
|
||||||
// still gets listed; it just reports nothing in the way.
|
|
||||||
let in_the_way = crate::worktree_probe::in_the_way(&entry.path).unwrap_or_default();
|
|
||||||
Ok(maestro_protocol::RetainedWorktree {
|
|
||||||
id: entry.id,
|
|
||||||
path: entry.path.clone(),
|
|
||||||
base_repo: entry.base_repo.clone(),
|
|
||||||
branch: entry.branch.clone(),
|
|
||||||
reason: entry.reason,
|
|
||||||
retained_at: entry.retained_at,
|
|
||||||
in_the_way,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>, crate::retained_store::RetainedStoreError>>()
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
match listed {
|
|
||||||
Ok(Ok(worktrees)) => Json(worktrees).into_response(),
|
|
||||||
Ok(Err(e)) => error_response(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"store_failed",
|
|
||||||
&e.to_string(),
|
|
||||||
),
|
|
||||||
Err(e) => error_response(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"join_failed",
|
|
||||||
&e.to_string(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `DELETE /api/worktrees/:id` (#80): reclaim one retained worktree. Removes
|
|
||||||
/// the directory and drops the store entry, without consulting the guard —
|
|
||||||
/// reclaim is the deliberate destructive act, chosen while looking at what is
|
|
||||||
/// in the way. The branch itself is never deleted; commits on it survive.
|
|
||||||
async fn reclaim_worktree(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
|
||||||
let found = {
|
|
||||||
let retained = s.retained.clone();
|
|
||||||
tokio::task::spawn_blocking(move || -> Result<
|
|
||||||
Option<crate::retained_store::RetainedWorktreeRecord>,
|
|
||||||
crate::retained_store::RetainedStoreError,
|
|
||||||
> {
|
|
||||||
Ok(retained.load_all()?.into_iter().find(|e| e.id == id))
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
};
|
|
||||||
let entry = match found {
|
|
||||||
Ok(Ok(Some(entry))) => entry,
|
|
||||||
Ok(Ok(None)) => {
|
|
||||||
return error_response(
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
"not_found",
|
|
||||||
"no such retained worktree",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
return error_response(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"store_failed",
|
|
||||||
&e.to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
return error_response(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"join_failed",
|
|
||||||
&e.to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let outcome = {
|
|
||||||
let retained = s.retained.clone();
|
|
||||||
tokio::task::spawn_blocking(move || -> Result<(), String> {
|
|
||||||
crate::worktree_probe::reclaim(&entry.base_repo, &entry.path)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
retained.remove(id).map_err(|e| e.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
};
|
|
||||||
match outcome {
|
|
||||||
Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(),
|
|
||||||
// The directory could not be removed: leave the entry so the
|
|
||||||
// operator still sees the tree and can retry.
|
|
||||||
Ok(Err(e)) => error_response(StatusCode::BAD_GATEWAY, "reclaim_failed", &e.to_string()),
|
|
||||||
Err(e) => error_response(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"join_failed",
|
|
||||||
&e.to_string(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
|
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
|
||||||
/// The report holds above the daemon's inference for a TTL, then inference
|
/// The report holds above the daemon's inference for a TTL, then inference
|
||||||
/// resumes — an agent that dies mid-run cannot pin its session at `Error`
|
/// resumes — an agent that dies mid-run cannot pin its session at `Error`
|
||||||
@@ -382,7 +262,6 @@ pub fn state_for(
|
|||||||
home_dir: impl Into<String>,
|
home_dir: impl Into<String>,
|
||||||
presets: Vec<SpawnPreset>,
|
presets: Vec<SpawnPreset>,
|
||||||
projects: Arc<crate::projects::ProjectScanner>,
|
projects: Arc<crate::projects::ProjectScanner>,
|
||||||
retained: Arc<crate::retained_store::RetainedStore>,
|
|
||||||
) -> LocalApiState {
|
) -> LocalApiState {
|
||||||
LocalApiState {
|
LocalApiState {
|
||||||
manager,
|
manager,
|
||||||
@@ -390,7 +269,6 @@ pub fn state_for(
|
|||||||
home_dir: Arc::new(home_dir.into()),
|
home_dir: Arc::new(home_dir.into()),
|
||||||
presets: Arc::new(presets),
|
presets: Arc::new(presets),
|
||||||
projects,
|
projects,
|
||||||
retained,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,310 +0,0 @@
|
|||||||
//! Store of retained worktrees (#78): worktrees the daemon created whose
|
|
||||||
//! session is gone.
|
|
||||||
//!
|
|
||||||
//! One JSON file per entry under `<state_dir>/retained-worktrees/`, written
|
|
||||||
//! the way `sidecar.rs` writes session records — atomic tmp+rename replace,
|
|
||||||
//! missing or malformed files tolerated on read. The store survives a daemon
|
|
||||||
//! restart; it is the recovery path for everything else in the worktree
|
|
||||||
//! lifecycle.
|
|
||||||
//!
|
|
||||||
//! It is not an ownership record: it must never be consulted to decide whether
|
|
||||||
//! a path may be adopted. Its only job is to remember what the daemon is
|
|
||||||
//! holding so it can be listed and reclaimed.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
/// Why a worktree outlived its session — [`maestro_protocol::RetainedReason`],
|
|
||||||
/// so the store and the wire payload cannot drift apart.
|
|
||||||
pub use maestro_protocol::RetainedReason;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum RetainedStoreError {
|
|
||||||
#[error("io: {0}")]
|
|
||||||
Io(#[from] std::io::Error),
|
|
||||||
#[error("json: {0}")]
|
|
||||||
Json(#[from] serde_json::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
||||||
pub struct RetainedWorktreeRecord {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub path: PathBuf,
|
|
||||||
pub base_repo: PathBuf,
|
|
||||||
pub branch: String,
|
|
||||||
pub reason: RetainedReason,
|
|
||||||
pub retained_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct RetainedStore {
|
|
||||||
dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RetainedStore {
|
|
||||||
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
|
||||||
Self { dir: dir.into() }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn dir(&self) -> &Path {
|
|
||||||
&self.dir
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path_for(&self, id: Uuid) -> PathBuf {
|
|
||||||
self.dir.join(format!("{id}.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record a retained worktree and return the entry that was written.
|
|
||||||
pub fn retain(
|
|
||||||
&self,
|
|
||||||
path: &Path,
|
|
||||||
base_repo: &Path,
|
|
||||||
branch: &str,
|
|
||||||
reason: RetainedReason,
|
|
||||||
) -> Result<RetainedWorktreeRecord, RetainedStoreError> {
|
|
||||||
let entry = RetainedWorktreeRecord {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
base_repo: base_repo.to_path_buf(),
|
|
||||||
branch: branch.to_string(),
|
|
||||||
reason,
|
|
||||||
retained_at: Utc::now(),
|
|
||||||
};
|
|
||||||
self.overwrite(&entry)?;
|
|
||||||
Ok(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Atomically write `entry`. Creates the directory if it does not exist.
|
|
||||||
pub fn overwrite(&self, entry: &RetainedWorktreeRecord) -> Result<(), RetainedStoreError> {
|
|
||||||
std::fs::create_dir_all(&self.dir)?;
|
|
||||||
let final_path = self.path_for(entry.id);
|
|
||||||
let tmp = self.dir.join(format!("{}.json.tmp", entry.id));
|
|
||||||
let json = serde_json::to_vec_pretty(entry)?;
|
|
||||||
std::fs::write(&tmp, json)?;
|
|
||||||
std::fs::rename(&tmp, &final_path)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drop one entry. No-op when the id is unknown — reclaiming an entry the
|
|
||||||
/// store already lost (e.g. pruned) must not fail.
|
|
||||||
pub fn remove(&self, id: Uuid) -> Result<(), RetainedStoreError> {
|
|
||||||
match std::fs::remove_file(self.path_for(id)) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read every entry. Files that are missing or malformed are skipped
|
|
||||||
/// silently: a corrupt record must not hide the others, and an empty or
|
|
||||||
/// absent directory simply means nothing is retained.
|
|
||||||
pub fn load_all(&self) -> Result<Vec<RetainedWorktreeRecord>, RetainedStoreError> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let entries = match std::fs::read_dir(&self.dir) {
|
|
||||||
Ok(e) => e,
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
};
|
|
||||||
for entry in entries {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Ok(bytes) = std::fs::read(&path) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if let Ok(worktree) = serde_json::from_slice::<RetainedWorktreeRecord>(&bytes) {
|
|
||||||
out.push(worktree);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.sort_by_key(|w| w.retained_at);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drop entries whose worktree directory no longer exists — somebody
|
|
||||||
/// removed it by hand. Returns the ids that were pruned.
|
|
||||||
pub fn prune_missing(&self) -> Result<Vec<Uuid>, RetainedStoreError> {
|
|
||||||
let mut pruned = Vec::new();
|
|
||||||
for entry in self.load_all()? {
|
|
||||||
if !entry.path.exists() {
|
|
||||||
self.remove(entry.id)?;
|
|
||||||
pruned.push(entry.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(pruned)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use chrono::Duration;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
fn tmp_dir() -> tempfile::TempDir {
|
|
||||||
tempfile::tempdir().expect("tempdir")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sample_path(root: &std::path::Path, name: &str) -> PathBuf {
|
|
||||||
let p = root.join(name);
|
|
||||||
std::fs::create_dir_all(&p).expect("mkdir");
|
|
||||||
p
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn retain_then_load_round_trips_every_field() {
|
|
||||||
let dir = tmp_dir();
|
|
||||||
let store = RetainedStore::new(dir.path());
|
|
||||||
let wt = sample_path(dir.path(), "wt");
|
|
||||||
let repo = sample_path(dir.path(), "repo");
|
|
||||||
|
|
||||||
let entry = store
|
|
||||||
.retain(&wt, &repo, "feature/login", RetainedReason::ForceKilled)
|
|
||||||
.expect("retain");
|
|
||||||
|
|
||||||
let all = store.load_all().expect("load_all");
|
|
||||||
assert_eq!(all, vec![entry.clone()]);
|
|
||||||
assert_eq!(entry.path, wt);
|
|
||||||
assert_eq!(entry.base_repo, repo);
|
|
||||||
assert_eq!(entry.branch, "feature/login");
|
|
||||||
assert_eq!(entry.reason, RetainedReason::ForceKilled);
|
|
||||||
let age = Utc::now() - entry.retained_at;
|
|
||||||
assert!(age >= Duration::zero() && age < Duration::minutes(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reason_serializes_kebab_case() {
|
|
||||||
let json =
|
|
||||||
serde_json::to_string(&RetainedReason::ExitedWithUnsavedWork).expect("serialize");
|
|
||||||
assert_eq!(json, r#""exited-with-unsaved-work""#);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn entries_survive_a_restart() {
|
|
||||||
let dir = tmp_dir();
|
|
||||||
let wt = sample_path(dir.path(), "wt");
|
|
||||||
|
|
||||||
let first = RetainedStore::new(dir.path());
|
|
||||||
let entry = first
|
|
||||||
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
|
|
||||||
.expect("retain");
|
|
||||||
|
|
||||||
// A fresh store over the same state_dir — what a daemon restart sees.
|
|
||||||
let second = RetainedStore::new(dir.path());
|
|
||||||
assert_eq!(second.load_all().expect("load_all"), vec![entry]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_store_reads_as_empty() {
|
|
||||||
let dir = tmp_dir();
|
|
||||||
let store = RetainedStore::new(dir.path().join("does-not-exist"));
|
|
||||||
assert_eq!(store.load_all().expect("load_all"), Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_entries_are_skipped_silently() {
|
|
||||||
let dir = tmp_dir();
|
|
||||||
let store = RetainedStore::new(dir.path());
|
|
||||||
let wt = sample_path(dir.path(), "wt");
|
|
||||||
store
|
|
||||||
.retain(&wt, dir.path(), "kept", RetainedReason::ForceKilled)
|
|
||||||
.expect("retain");
|
|
||||||
std::fs::write(dir.path().join("not-a-uuid.json"), b"{oops").expect("write junk");
|
|
||||||
|
|
||||||
let all = store.load_all().expect("load_all");
|
|
||||||
assert_eq!(all.len(), 1);
|
|
||||||
assert_eq!(all[0].branch, "kept");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ids_are_unique() {
|
|
||||||
let dir = tmp_dir();
|
|
||||||
let store = RetainedStore::new(dir.path());
|
|
||||||
let a = store
|
|
||||||
.retain(dir.path(), dir.path(), "a", RetainedReason::ForceKilled)
|
|
||||||
.expect("retain");
|
|
||||||
let b = store
|
|
||||||
.retain(dir.path(), dir.path(), "b", RetainedReason::ForceKilled)
|
|
||||||
.expect("retain");
|
|
||||||
assert_ne!(a.id, b.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_drops_the_entry() {
|
|
||||||
let dir = tmp_dir();
|
|
||||||
let store = RetainedStore::new(dir.path());
|
|
||||||
let wt = sample_path(dir.path(), "wt");
|
|
||||||
let entry = store
|
|
||||||
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
|
|
||||||
.expect("retain");
|
|
||||||
|
|
||||||
store.remove(entry.id).expect("remove");
|
|
||||||
assert_eq!(store.load_all().expect("load_all"), Vec::new());
|
|
||||||
|
|
||||||
// Removing an unknown id is a no-op, not an error.
|
|
||||||
store.remove(entry.id).expect("remove again");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_all_is_sorted_by_time() {
|
|
||||||
let dir = tmp_dir();
|
|
||||||
let store = RetainedStore::new(dir.path());
|
|
||||||
let mut first = store
|
|
||||||
.retain(dir.path(), dir.path(), "first", RetainedReason::ForceKilled)
|
|
||||||
.expect("retain");
|
|
||||||
first.retained_at -= Duration::hours(1);
|
|
||||||
store.overwrite(&first).expect("rewrite");
|
|
||||||
|
|
||||||
let second = store
|
|
||||||
.retain(
|
|
||||||
dir.path(),
|
|
||||||
dir.path(),
|
|
||||||
"second",
|
|
||||||
RetainedReason::ForceKilled,
|
|
||||||
)
|
|
||||||
.expect("retain");
|
|
||||||
|
|
||||||
let branches: Vec<_> = store
|
|
||||||
.load_all()
|
|
||||||
.expect("load_all")
|
|
||||||
.into_iter()
|
|
||||||
.map(|e| e.branch)
|
|
||||||
.collect();
|
|
||||||
assert_eq!(branches, vec!["first", "second"]);
|
|
||||||
let _ = second;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prune_missing_drops_gone_directories_only() {
|
|
||||||
let dir = tmp_dir();
|
|
||||||
let store = RetainedStore::new(dir.path());
|
|
||||||
let alive = sample_path(dir.path(), "alive");
|
|
||||||
let gone = sample_path(dir.path(), "gone");
|
|
||||||
let keep = store
|
|
||||||
.retain(&alive, dir.path(), "keep", RetainedReason::ForceKilled)
|
|
||||||
.expect("retain");
|
|
||||||
let drop = store
|
|
||||||
.retain(
|
|
||||||
&gone,
|
|
||||||
dir.path(),
|
|
||||||
"drop",
|
|
||||||
RetainedReason::ExitedWithUnsavedWork,
|
|
||||||
)
|
|
||||||
.expect("retain");
|
|
||||||
std::fs::remove_dir_all(&gone).expect("rm");
|
|
||||||
|
|
||||||
let pruned = store.prune_missing().expect("prune_missing");
|
|
||||||
assert_eq!(pruned, vec![drop.id]);
|
|
||||||
|
|
||||||
let branches: Vec<_> = store
|
|
||||||
.load_all()
|
|
||||||
.expect("load_all")
|
|
||||||
.into_iter()
|
|
||||||
.map(|e| e.branch)
|
|
||||||
.collect();
|
|
||||||
assert_eq!(branches, vec!["keep"]);
|
|
||||||
let _ = keep;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,8 +12,8 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, KillOutcome, RetainedReason,
|
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, Session, SessionStatus,
|
||||||
RetainedWorktree, Session, SessionStatus, WorktreeSpec,
|
WorktreeSpec,
|
||||||
};
|
};
|
||||||
use tokio::sync::{broadcast, Mutex};
|
use tokio::sync::{broadcast, Mutex};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -51,11 +51,6 @@ pub struct SessionRecord {
|
|||||||
/// `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,
|
||||||
/// Agent-reported activity (#105) still within its TTL. While in force it
|
/// Agent-reported activity (#105) still within its TTL. While in force it
|
||||||
/// outranks the activity pass's own inference; when it lapses, inference
|
/// outranks the activity pass's own inference; when it lapses, inference
|
||||||
@@ -88,11 +83,6 @@ pub enum SessionError {
|
|||||||
/// so it stays the operator's handle on their own unfinished work.
|
/// so it stays the operator's handle on their own unfinished work.
|
||||||
#[error("kill refused: the session's worktree holds unsaved work ({0})")]
|
#[error("kill refused: the session's worktree holds unsaved work ({0})")]
|
||||||
KillGuarded(maestro_protocol::InTheWay),
|
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 {
|
||||||
@@ -105,7 +95,6 @@ impl SessionError {
|
|||||||
SessionError::NotFound(_) => "not_found",
|
SessionError::NotFound(_) => "not_found",
|
||||||
SessionError::InvalidEnv(_) => "invalid_env",
|
SessionError::InvalidEnv(_) => "invalid_env",
|
||||||
SessionError::KillGuarded(_) => "kill_guarded",
|
SessionError::KillGuarded(_) => "kill_guarded",
|
||||||
SessionError::RetainFailed(_) => "retain_failed",
|
|
||||||
};
|
};
|
||||||
let unsaved_work = match self {
|
let unsaved_work = match self {
|
||||||
SessionError::KillGuarded(b) => Some(*b),
|
SessionError::KillGuarded(b) => Some(*b),
|
||||||
@@ -138,9 +127,6 @@ 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>,
|
|
||||||
/// The daemon's own HTTP base URL (#103), exported to spawned sessions as
|
/// The daemon's own HTTP base URL (#103), exported to spawned sessions as
|
||||||
/// `MAESTRO_URL`. `None` when unknown — those sessions simply don't get
|
/// `MAESTRO_URL`. `None` when unknown — those sessions simply don't get
|
||||||
/// the variable. A mutex because the runner sets it after construction,
|
/// the variable. A mutex because the runner sets it after construction,
|
||||||
@@ -187,7 +173,6 @@ impl SessionManager {
|
|||||||
fifo_dir,
|
fifo_dir,
|
||||||
replay_bytes,
|
replay_bytes,
|
||||||
spawn_history: None,
|
spawn_history: None,
|
||||||
retained: None,
|
|
||||||
daemon_url: std::sync::Mutex::new(None),
|
daemon_url: std::sync::Mutex::new(None),
|
||||||
report_ttl: crate::activity::REPORT_TTL,
|
report_ttl: crate::activity::REPORT_TTL,
|
||||||
sessions: Mutex::new(HashMap::new()),
|
sessions: Mutex::new(HashMap::new()),
|
||||||
@@ -212,16 +197,6 @@ 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Override the agent-report TTL (#105). Tests pass a short one to watch
|
/// Override the agent-report TTL (#105). Tests pass a short one to watch
|
||||||
/// inference resume without waiting out [`crate::activity::REPORT_TTL`].
|
/// inference resume without waiting out [`crate::activity::REPORT_TTL`].
|
||||||
pub fn with_activity_report_ttl(mut self, ttl: Duration) -> Self {
|
pub fn with_activity_report_ttl(mut self, ttl: Duration) -> Self {
|
||||||
@@ -442,7 +417,6 @@ 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,
|
||||||
reported_activity: None,
|
reported_activity: None,
|
||||||
settled: false,
|
settled: false,
|
||||||
@@ -494,19 +468,19 @@ impl SessionManager {
|
|||||||
/// is removed; the error names what blocked it and how much of it there
|
/// 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
|
/// is. The session stays as the operator's handle on their own unfinished
|
||||||
/// work, so ending a session that still matters becomes a deliberate
|
/// work, so ending a session that still matters becomes a deliberate
|
||||||
/// `--force`, which retains rather than destroys (WORKTREES.md).
|
/// `--force`, which leaves the tree alone rather than destroying it
|
||||||
|
/// (WORKTREES.md).
|
||||||
///
|
///
|
||||||
/// With `force` (#81) the refusal — and nothing else — is overridden:
|
/// With `force` (#81) the refusal — and nothing else — is overridden:
|
||||||
/// the session ends and the guarded worktree is retained into the store
|
/// the session ends and the guarded worktree is left on disk, never
|
||||||
/// with reason `force-killed`, never removed. The outcome names what was
|
/// removed. A tree whose guard is clear never needed the override; force
|
||||||
/// kept so a client can say where the work went. A tree whose guard is
|
/// removes it as usual.
|
||||||
/// 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, force: bool) -> Result<KillOutcome, SessionError> {
|
pub async fn kill(&self, id: Uuid, force: bool) -> Result<(), SessionError> {
|
||||||
let (worktree, alive, known_branch) = {
|
let (worktree, alive) = {
|
||||||
let sessions = self.inner.sessions.lock().await;
|
let sessions = self.inner.sessions.lock().await;
|
||||||
match sessions.get(&id) {
|
match sessions.get(&id) {
|
||||||
None => return Err(SessionError::NotFound(id)),
|
None => return Err(SessionError::NotFound(id)),
|
||||||
@@ -516,7 +490,6 @@ impl SessionManager {
|
|||||||
.clone()
|
.clone()
|
||||||
.zip(record.worktree_path.clone()),
|
.zip(record.worktree_path.clone()),
|
||||||
record.session.status == SessionStatus::Running,
|
record.session.status == SessionStatus::Running,
|
||||||
record.worktree_branch.clone(),
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -529,35 +502,13 @@ impl SessionManager {
|
|||||||
return Err(SessionError::KillGuarded(probe.expect("guarded ⇒ probed")));
|
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))?;
|
||||||
cleanup_record(&self.inner, id, record, alive, retained.is_some()).await;
|
cleanup_record(&self.inner, id, record, alive, guarded).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
|
||||||
@@ -669,9 +620,6 @@ 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,
|
||||||
reported_activity: None,
|
reported_activity: None,
|
||||||
settled: false,
|
settled: false,
|
||||||
@@ -840,32 +788,6 @@ async fn probe_worktree(
|
|||||||
.map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e)))
|
.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>) {
|
||||||
@@ -1069,13 +991,11 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
|
|||||||
/// 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. The worktree, though, is settled right away: a pristine tree is
|
/// printed. The worktree, though, is settled right away: a pristine tree is
|
||||||
/// removed, anything else is retained into the store with reason
|
/// removed (#82) — nobody is waiting for an answer here, so there is nothing
|
||||||
/// `exited-with-unsaved-work` (#82) — nobody is waiting for an answer here,
|
/// to refuse. Once settled, the session drops its worktree reference so a
|
||||||
/// so there is nothing to refuse. Once settled, the session drops its
|
/// later dismissal does not re-probe it. A tree holding unsaved work is left
|
||||||
/// worktree reference so a later dismissal neither re-probes nor retains
|
/// alone and stays attached to the dead record for a later forced dismissal
|
||||||
/// twice. When settling is impossible (probe or store failure) nothing is
|
/// (#81), which leaves it on disk too.
|
||||||
/// 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.
|
||||||
@@ -1093,21 +1013,20 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
|||||||
.worktree_base_repo
|
.worktree_base_repo
|
||||||
.clone()
|
.clone()
|
||||||
.zip(record.worktree_path.clone());
|
.zip(record.worktree_path.clone());
|
||||||
let branch = record.worktree_branch.clone();
|
Some((session, worktree))
|
||||||
Some((session, worktree, branch))
|
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let Some((mut session, worktree, branch)) = flipped else {
|
let Some((mut session, worktree)) = 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 let Some(wt) = &worktree {
|
||||||
if settle_worktree_on_exit(inner, id, wt, branch).await {
|
if settle_worktree_on_exit(inner, id, wt).await {
|
||||||
// The tree is settled (removed or retained); a stale "in the
|
// The tree is settled (removed); a stale "in the way" would
|
||||||
// way" would describe something the session no longer holds.
|
// describe something the session no longer holds.
|
||||||
session.blocked_work = None;
|
session.blocked_work = None;
|
||||||
let mut sessions = inner.sessions.lock().await;
|
let mut sessions = inner.sessions.lock().await;
|
||||||
if let Some(record) = sessions.get_mut(&id) {
|
if let Some(record) = sessions.get_mut(&id) {
|
||||||
@@ -1115,7 +1034,6 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
|||||||
record.session.blocked_work = None;
|
record.session.blocked_work = None;
|
||||||
record.worktree_path = None;
|
record.worktree_path = None;
|
||||||
record.worktree_base_repo = None;
|
record.worktree_base_repo = None;
|
||||||
record.worktree_branch = None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1134,10 +1052,8 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
|||||||
|
|
||||||
/// Apply the exit rule (#82) to a vanished session's worktree: probe what
|
/// 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
|
/// would be lost through the kill's own predicate, then remove a pristine
|
||||||
/// tree or retain anything else under `exited-with-unsaved-work` — recorded
|
/// tree. Anything holding unsaved work is left exactly where it is — the
|
||||||
/// before anything is torn down, exactly as the forced kill does (#81). A
|
/// same thing a forced kill does with a guarded tree (#81).
|
||||||
/// 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,
|
/// Returns whether the worktree was fully accounted for. When it wasn't,
|
||||||
/// nothing was destroyed and the caller keeps the reference on the record.
|
/// nothing was destroyed and the caller keeps the reference on the record.
|
||||||
@@ -1145,7 +1061,6 @@ async fn settle_worktree_on_exit(
|
|||||||
inner: &Arc<Inner>,
|
inner: &Arc<Inner>,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
(base_repo, path): &(PathBuf, PathBuf),
|
(base_repo, path): &(PathBuf, PathBuf),
|
||||||
known_branch: Option<String>,
|
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let probe = match probe_worktree(&(base_repo.clone(), path.clone())).await {
|
let probe = match probe_worktree(&(base_repo.clone(), path.clone())).await {
|
||||||
Ok(probe) => probe,
|
Ok(probe) => probe,
|
||||||
@@ -1159,45 +1074,25 @@ async fn settle_worktree_on_exit(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if probe.is_clear() {
|
if !probe.is_clear() {
|
||||||
match inner.worktree_mgr.remove(base_repo, path) {
|
tracing::info!(
|
||||||
Ok(()) => {
|
%id,
|
||||||
tracing::info!(%id, path = %path.display(), "natural exit: pristine worktree removed");
|
path = %path.display(),
|
||||||
return true;
|
"natural exit: worktree holds unsaved work; leaving it on disk"
|
||||||
}
|
);
|
||||||
Err(e) => {
|
return false;
|
||||||
tracing::warn!(
|
|
||||||
%id,
|
|
||||||
path = %path.display(),
|
|
||||||
error = ?e,
|
|
||||||
"natural exit: removal failed; falling back to retention"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
match record_retention(
|
match inner.worktree_mgr.remove(base_repo, path) {
|
||||||
inner,
|
Ok(()) => {
|
||||||
&(base_repo.clone(), path.clone()),
|
tracing::info!(%id, path = %path.display(), "natural exit: pristine worktree removed");
|
||||||
known_branch,
|
|
||||||
RetainedReason::ExitedWithUnsavedWork,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(entry) => {
|
|
||||||
tracing::info!(
|
|
||||||
%id,
|
|
||||||
path = %path.display(),
|
|
||||||
entry = %entry.id,
|
|
||||||
"natural exit: worktree retained with unsaved work"
|
|
||||||
);
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(
|
tracing::warn!(
|
||||||
%id,
|
%id,
|
||||||
path = %path.display(),
|
path = %path.display(),
|
||||||
error = ?e,
|
error = ?e,
|
||||||
"natural exit: could not record the retained worktree; leaving it untouched"
|
"natural exit: removal failed; leaving the worktree on disk"
|
||||||
);
|
);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -1214,15 +1109,15 @@ async fn settle_worktree_on_exit(
|
|||||||
/// 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).
|
||||||
///
|
///
|
||||||
/// `retain_worktree` is `true` for a forced kill (#81) that retained the
|
/// `keep_worktree` is `true` for a forced kill (#81) over a guarded tree: the
|
||||||
/// worktree instead of removing it: the store entry is already written, so
|
/// session ends but the worktree is left on disk, so only the removal is
|
||||||
/// only the removal is skipped.
|
/// skipped.
|
||||||
async fn cleanup_record(
|
async fn cleanup_record(
|
||||||
inner: &Arc<Inner>,
|
inner: &Arc<Inner>,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
record: SessionRecord,
|
record: SessionRecord,
|
||||||
kill_tmux: bool,
|
kill_tmux: bool,
|
||||||
retain_worktree: bool,
|
keep_worktree: bool,
|
||||||
) {
|
) {
|
||||||
if kill_tmux {
|
if kill_tmux {
|
||||||
let tmux = inner.tmux.clone();
|
let tmux = inner.tmux.clone();
|
||||||
@@ -1233,11 +1128,11 @@ async fn cleanup_record(
|
|||||||
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
||||||
}
|
}
|
||||||
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 retain_worktree {
|
if keep_worktree {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
%id,
|
%id,
|
||||||
path = %path.display(),
|
path = %path.display(),
|
||||||
"forced kill: worktree retained in the store"
|
"forced kill: worktree left on disk"
|
||||||
);
|
);
|
||||||
} else if let Err(e) = inner.worktree_mgr.remove(base, path) {
|
} else if let Err(e) = inner.worktree_mgr.remove(base, path) {
|
||||||
// The kill already passed the unsaved-work guard (#79), so this
|
// The kill already passed the unsaved-work guard (#79), so this
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
//! The one git probe for what a worktree holds that would be lost, plus
|
//! The one git probe for what a worktree holds that would be lost (#79).
|
||||||
//! guard-free reclaim (#79, #80).
|
|
||||||
//!
|
//!
|
||||||
//! [`in_the_way`] is the single predicate behind all three paths: the kill
|
//! [`in_the_way`] is the single predicate behind both paths: the kill refusal
|
||||||
//! refusal (#79), the natural-exit classifier (#82), and the retained list
|
//! (#79) and the natural-exit classifier (#82). It lives here rather than in
|
||||||
//! (#80). It lives here rather than in `worktree.rs` so that none of them can
|
//! `worktree.rs` so that neither can answer the question differently.
|
||||||
//! answer the question differently — #79 and #80 were built in parallel and
|
|
||||||
//! each grew its own copy, which is exactly the drift this collapses.
|
|
||||||
//!
|
|
||||||
//! Reclaim never consults it: reclaim is the deliberate destructive act, and
|
|
||||||
//! the operator chose it while looking at what was in the way.
|
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -27,7 +21,7 @@ pub enum ProbeError {
|
|||||||
/// Ignored files never count (`--ignored=no`) — every Rust worktree has a
|
/// Ignored files never count (`--ignored=no`) — every Rust worktree has a
|
||||||
/// `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.
|
||||||
pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
|
pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
|
||||||
let mut breakdown = InTheWay::default();
|
let mut breakdown = InTheWay::default();
|
||||||
// `symlink_metadata` rather than `exists`: a broken symlink is gone for
|
// `symlink_metadata` rather than `exists`: a broken symlink is gone for
|
||||||
@@ -67,37 +61,6 @@ pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
|
|||||||
Ok(breakdown)
|
Ok(breakdown)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a retained worktree's directory without consulting any guard, per
|
|
||||||
/// the reclaim contract: the operator chose this while looking at what was in
|
|
||||||
/// the way. `git worktree remove` keeps `refs/heads/<branch>`, so commits on
|
|
||||||
/// the branch survive even when they were never pushed.
|
|
||||||
///
|
|
||||||
/// A directory that is already gone only leaves a stale registration behind;
|
|
||||||
/// `worktree prune` clears it so the branch can be spawned again.
|
|
||||||
pub fn reclaim(base_repo: &Path, path: &Path) -> Result<(), ProbeError> {
|
|
||||||
if !path.exists() {
|
|
||||||
return run_git(base_repo, ["worktree", "prune"]).map(|_| ());
|
|
||||||
}
|
|
||||||
run_git(
|
|
||||||
base_repo,
|
|
||||||
[
|
|
||||||
"worktree",
|
|
||||||
"remove",
|
|
||||||
"--force",
|
|
||||||
path.to_str().expect("worktree path is valid utf-8"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map(|_| ())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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>,
|
||||||
@@ -272,54 +235,4 @@ mod tests {
|
|||||||
let breakdown = in_the_way(&gone).expect("probe");
|
let breakdown = in_the_way(&gone).expect("probe");
|
||||||
assert!(breakdown.is_clear());
|
assert!(breakdown.is_clear());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reclaim_removes_the_directory_and_prunes_a_gone_one() {
|
|
||||||
let r = repo_with_worktree();
|
|
||||||
r.commit_in_wt("precious");
|
|
||||||
reclaim(&r.repo, &r.wt()).expect("reclaim");
|
|
||||||
assert!(!r.wt().exists());
|
|
||||||
|
|
||||||
// A second reclaim over the now-missing path just prunes.
|
|
||||||
reclaim(&r.repo, &r.wt()).expect("reclaim again");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reclaim_keeps_the_branch_and_its_commits() {
|
|
||||||
let r = repo_with_worktree();
|
|
||||||
r.commit_in_wt("precious");
|
|
||||||
let sha = {
|
|
||||||
let out = Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(r.wt())
|
|
||||||
.args(["rev-parse", "HEAD"])
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
reclaim(&r.repo, &r.wt()).expect("reclaim");
|
|
||||||
|
|
||||||
let out = Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(&r.repo)
|
|
||||||
.args(["rev-parse", "--verify", "refs/heads/feature"])
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
out.status.success(),
|
|
||||||
"branch must survive reclaim: {}",
|
|
||||||
String::from_utf8_lossy(&out.stderr)
|
|
||||||
);
|
|
||||||
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), sha);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reclaim_is_guard_free_on_a_dirty_tree() {
|
|
||||||
let r = repo_with_worktree();
|
|
||||||
std::fs::write(r.wt().join("README"), b"unsaved\n").unwrap();
|
|
||||||
std::fs::write(r.wt().join("notes.txt"), b"unsaved\n").unwrap();
|
|
||||||
reclaim(&r.repo, &r.wt()).expect("reclaim ignores the guard");
|
|
||||||
assert!(!r.wt().exists());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ async fn spawn_daemon_ws() -> Harness {
|
|||||||
"/home/test",
|
"/home/test",
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||||
std::sync::Arc::new(fx.retained_store()),
|
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ async fn spawn() -> Harness {
|
|||||||
"/home/test",
|
"/home/test",
|
||||||
presets,
|
presets,
|
||||||
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||||
std::sync::Arc::new(fx.retained_store()),
|
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -176,11 +176,6 @@ 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,
|
||||||
@@ -192,10 +187,6 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -204,15 +195,6 @@ impl DaemonFixture {
|
|||||||
self.state_dir.path().join("sessions")
|
self.state_dir.path().join("sessions")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retained-worktree store rooted at the fixture's own state dir (#78).
|
|
||||||
pub fn retained_store(&self) -> maestro_daemon::retained_store::RetainedStore {
|
|
||||||
maestro_daemon::retained_store::RetainedStore::new(self.retained_dir())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn retained_dir(&self) -> PathBuf {
|
|
||||||
self.state_dir.path().join("retained-worktrees")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn fifo_dir(&self) -> PathBuf {
|
pub fn fifo_dir(&self) -> PathBuf {
|
||||||
self.state_dir.path().join("fifos")
|
self.state_dir.path().join("fifos")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,13 +25,6 @@ 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,
|
||||||
@@ -55,7 +48,6 @@ async fn spawn_with(
|
|||||||
"/home/test",
|
"/home/test",
|
||||||
presets,
|
presets,
|
||||||
projects,
|
projects,
|
||||||
std::sync::Arc::new(fx.retained_store()),
|
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
@@ -537,11 +529,10 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
|
|||||||
assert!(h.manager.has(session.id).await, "the session keeps running");
|
assert!(h.manager.has(session.id).await, "the session keeps running");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `?force=true` on the wire (#81): the refusal is overridden, the session
|
/// `?force=true` on the wire (#81): the refusal is overridden and the session
|
||||||
/// ends, and the answer is 200 with the retained worktree named — path,
|
/// ends, while the guarded worktree survives on disk.
|
||||||
/// branch, reason — while the directory survives on disk.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn delete_with_force_ends_the_session_and_answers_what_was_retained() {
|
async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
let repo = tempfile::TempDir::new().unwrap();
|
let repo = tempfile::TempDir::new().unwrap();
|
||||||
common::init_repo(repo.path());
|
common::init_repo(repo.path());
|
||||||
@@ -574,22 +565,11 @@ async fn delete_with_force_ends_the_session_and_answers_what_was_retained() {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), reqwest::StatusCode::OK);
|
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
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.
|
// The tree would have blocked; force must not have removed it.
|
||||||
assert!(wt_path.exists(), "a forced kill never removes the worktree");
|
assert!(wt_path.exists(), "a forced kill never removes the worktree");
|
||||||
// And the store entry exists, so reclaim can find it (#80).
|
assert!(!h.manager.has(session.id).await, "the session is gone");
|
||||||
let all = h.fx_retained().load_all().unwrap();
|
|
||||||
assert_eq!(all.len(), 1);
|
|
||||||
assert_eq!(all[0].id, retained.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -746,234 +726,3 @@ async fn next_event(ws: &mut WsClient) -> maestro_protocol::CatalogEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /api/worktrees + DELETE /api/worktrees/:id (#80)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// `git -C dir args`, asserting success — the worktree endpoints are tested
|
|
||||||
/// against real git, like everything else in this crate.
|
|
||||||
fn git(dir: &std::path::Path, args: &[&str]) {
|
|
||||||
let out = std::process::Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(dir)
|
|
||||||
.args(args)
|
|
||||||
.output()
|
|
||||||
.expect("git spawn");
|
|
||||||
assert!(
|
|
||||||
out.status.success(),
|
|
||||||
"git {args:?} failed: {}",
|
|
||||||
String::from_utf8_lossy(&out.stderr)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RetainedFixture {
|
|
||||||
repo: tempfile::TempDir,
|
|
||||||
wt: std::path::PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A real repo with a linked worktree for branch `feat/login` whose tree holds
|
|
||||||
/// an uncommitted tracked edit and an unpushed commit.
|
|
||||||
fn retained_fixture() -> RetainedFixture {
|
|
||||||
let repo = tempfile::tempdir().unwrap();
|
|
||||||
common::init_repo(repo.path());
|
|
||||||
// A pushed main, so "unpushed" means commits made on the feature branch,
|
|
||||||
// not the shared history.
|
|
||||||
let remote = repo.path().join("remote.git");
|
|
||||||
std::fs::create_dir_all(&remote).unwrap();
|
|
||||||
let remote_s = remote.to_string_lossy().into_owned();
|
|
||||||
git(&remote, &["init", "-q", "--bare"]);
|
|
||||||
git(repo.path(), &["remote", "add", "origin", &remote_s]);
|
|
||||||
git(repo.path(), &["push", "-q", "-u", "origin", "main"]);
|
|
||||||
git(
|
|
||||||
repo.path(),
|
|
||||||
&[
|
|
||||||
"worktree",
|
|
||||||
"add",
|
|
||||||
"-q",
|
|
||||||
"-b",
|
|
||||||
"feat/login",
|
|
||||||
"worktrees/feat/login",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let wt = repo.path().join("worktrees").join("feat").join("login");
|
|
||||||
RetainedFixture { repo, wt }
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RetainedFixture {
|
|
||||||
fn commit_in_wt(&self, msg: &str) {
|
|
||||||
std::fs::write(self.wt.join("README"), msg.as_bytes()).unwrap();
|
|
||||||
git(&self.wt, &["add", "-A"]);
|
|
||||||
git(&self.wt, &["commit", "-qm", msg]);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn branch_sha(&self, branch: &str) -> String {
|
|
||||||
let out = std::process::Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(self.repo.path())
|
|
||||||
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert!(out.status.success(), "branch {branch} must exist");
|
|
||||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn worktrees_lists_retained_entries_with_recomputed_breakdown() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let fx = retained_fixture();
|
|
||||||
// Uncommitted tracked edit + an untracked file; the branch has no commits
|
|
||||||
// beyond main yet.
|
|
||||||
std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap();
|
|
||||||
std::fs::write(fx.wt.join("scratch.txt"), b"loose\n").unwrap();
|
|
||||||
|
|
||||||
let store = h._fx.retained_store();
|
|
||||||
let entry = store
|
|
||||||
.retain(
|
|
||||||
&fx.wt,
|
|
||||||
fx.repo.path(),
|
|
||||||
"feat/login",
|
|
||||||
maestro_daemon::retained_store::RetainedReason::ForceKilled,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
|
||||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(listed.len(), 1);
|
|
||||||
let w = &listed[0];
|
|
||||||
assert_eq!(w.id, entry.id);
|
|
||||||
assert_eq!(w.path, fx.wt);
|
|
||||||
assert_eq!(w.base_repo, fx.repo.path());
|
|
||||||
assert_eq!(w.branch, "feat/login");
|
|
||||||
assert_eq!(w.reason, maestro_protocol::RetainedReason::ForceKilled);
|
|
||||||
assert_eq!(w.in_the_way.uncommitted_files, 1);
|
|
||||||
assert_eq!(w.in_the_way.untracked_files, 1);
|
|
||||||
assert_eq!(w.in_the_way.unpushed_commits, 0);
|
|
||||||
|
|
||||||
// The operator saves the work themselves. The stored entry is untouched —
|
|
||||||
// the next read must report the tree as it is *now*, not as it was when
|
|
||||||
// it was retained.
|
|
||||||
fx.commit_in_wt("saved");
|
|
||||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
|
||||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(listed.len(), 1, "same entry, still retained");
|
|
||||||
assert_eq!(listed[0].id, entry.id);
|
|
||||||
assert_eq!(listed[0].in_the_way.uncommitted_files, 0);
|
|
||||||
assert_eq!(listed[0].in_the_way.unpushed_commits, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reclaim_removes_directory_and_entry_but_never_the_branch() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let fx = retained_fixture();
|
|
||||||
fx.commit_in_wt("precious");
|
|
||||||
let sha = fx.branch_sha("feat/login");
|
|
||||||
std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap(); // guard would refuse
|
|
||||||
|
|
||||||
let store = h._fx.retained_store();
|
|
||||||
let entry = store
|
|
||||||
.retain(
|
|
||||||
&fx.wt,
|
|
||||||
fx.repo.path(),
|
|
||||||
"feat/login",
|
|
||||||
maestro_daemon::retained_store::RetainedReason::ExitedWithUnsavedWork,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let resp = reqwest::Client::new()
|
|
||||||
.delete(url(h.addr, &format!("/api/worktrees/{}", entry.id)))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
|
||||||
|
|
||||||
assert!(!fx.wt.exists(), "reclaim removes the directory");
|
|
||||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
|
||||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(listed.is_empty(), "reclaim drops the store entry");
|
|
||||||
assert_eq!(
|
|
||||||
fx.branch_sha("feat/login"),
|
|
||||||
sha,
|
|
||||||
"the branch and its commits survive"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reclaim_of_an_already_gone_directory_still_drops_the_entry() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let fx = retained_fixture();
|
|
||||||
let store = h._fx.retained_store();
|
|
||||||
let entry = store
|
|
||||||
.retain(
|
|
||||||
&fx.wt,
|
|
||||||
fx.repo.path(),
|
|
||||||
"feat/login",
|
|
||||||
maestro_daemon::retained_store::RetainedReason::ForceKilled,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
std::fs::remove_dir_all(&fx.wt).unwrap(); // operator rm -rf
|
|
||||||
|
|
||||||
let resp = reqwest::Client::new()
|
|
||||||
.delete(url(h.addr, &format!("/api/worktrees/{}", entry.id)))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
|
||||||
assert!(store.load_all().unwrap().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reclaim_unknown_id_returns_404() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let resp = reqwest::Client::new()
|
|
||||||
.delete(url(h.addr, &format!("/api/worktrees/{}", Uuid::new_v4())))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn worktrees_prunes_entries_whose_directory_vanished_by_hand() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let fx = retained_fixture();
|
|
||||||
let store = h._fx.retained_store();
|
|
||||||
store
|
|
||||||
.retain(
|
|
||||||
&fx.wt,
|
|
||||||
fx.repo.path(),
|
|
||||||
"feat/login",
|
|
||||||
maestro_daemon::retained_store::RetainedReason::ForceKilled,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
std::fs::remove_dir_all(&fx.wt).unwrap();
|
|
||||||
|
|
||||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
|
||||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(listed.is_empty(), "a hand-removed directory is not listed");
|
|
||||||
assert!(
|
|
||||||
store.load_all().unwrap().is_empty(),
|
|
||||||
"and its entry is gone"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ use std::process::Command;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use common::{
|
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
|
||||||
init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture, TmuxFixture,
|
|
||||||
};
|
|
||||||
use maestro_daemon::SessionError;
|
use maestro_daemon::SessionError;
|
||||||
use maestro_protocol::CreateSessionRequest;
|
use maestro_protocol::CreateSessionRequest;
|
||||||
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
|
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
|
||||||
@@ -609,11 +607,9 @@ async fn killing_refuses_until_the_branch_is_pushed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `--force` overrides the refusal and nothing else (#81): the session ends,
|
/// `--force` overrides the refusal and nothing else (#81): the session ends,
|
||||||
/// but the guarded worktree is RETAINED — never removed — and enters the
|
/// but the guarded worktree is left on disk — never removed.
|
||||||
/// retained store with reason `force-killed`. The outcome names what was
|
|
||||||
/// kept, so a client can say where the work went.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn force_killing_a_guarded_worktree_retains_it_in_the_store() {
|
async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
|
||||||
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());
|
||||||
@@ -639,8 +635,7 @@ async fn force_killing_a_guarded_worktree_retains_it_in_the_store() {
|
|||||||
// Without force the same kill is refused (#79).
|
// Without force the same kill is refused (#79).
|
||||||
assert!(fx.manager.kill(s.id, false).await.is_err());
|
assert!(fx.manager.kill(s.id, false).await.is_err());
|
||||||
|
|
||||||
let outcome = fx
|
fx.manager
|
||||||
.manager
|
|
||||||
.kill(s.id, true)
|
.kill(s.id, true)
|
||||||
.await
|
.await
|
||||||
.expect("force overrides the refusal");
|
.expect("force overrides the refusal");
|
||||||
@@ -653,30 +648,11 @@ async fn force_killing_a_guarded_worktree_retains_it_in_the_store() {
|
|||||||
// ...but the session itself still ends.
|
// ...but the session itself still ends.
|
||||||
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());
|
||||||
|
|
||||||
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
|
/// 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
|
/// guard is clear never needed the override, so a forced kill removes it as
|
||||||
/// usual and retains nothing.
|
/// usual.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
@@ -704,10 +680,8 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
|||||||
init_bare_repo(origin.path());
|
init_bare_repo(origin.path());
|
||||||
push_all_to_origin(repo.path(), origin.path());
|
push_all_to_origin(repo.path(), origin.path());
|
||||||
|
|
||||||
let outcome = fx.manager.kill(s.id, true).await.expect("kill");
|
fx.manager.kill(s.id, true).await.expect("kill");
|
||||||
assert!(!wt_path.exists(), "a clear tree needs no override");
|
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
|
/// Ignored files NEVER block (#79): build output like `target/` exists in
|
||||||
@@ -757,12 +731,11 @@ async fn killing_an_ignored_only_worktree_succeeds() {
|
|||||||
|
|
||||||
/// Natural exit (#82): a session that ends on its own never reaches the kill
|
/// 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
|
/// path, so the watcher applies the kill's rule itself. A worktree holding
|
||||||
/// unsaved work is RETAINED into the store with reason
|
/// unsaved work is LEFT ON DISK and stays attached to the dead record; the
|
||||||
/// `exited-with-unsaved-work`; the session stays in the catalog marked dead
|
/// session stays in the catalog marked dead until dismissed, and dismissing
|
||||||
/// until dismissed — and dismissing it afterwards neither refuses nor
|
/// it goes through `--force`, which leaves the tree alone too.
|
||||||
/// retains a second time.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
|
async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
|
||||||
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());
|
||||||
@@ -793,56 +766,35 @@ async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// The tree would have blocked a kill, so the exit kept it...
|
// The settle step has been applied or refused either way — here refused,
|
||||||
|
// since the tree holds unsaved work. Only from this point is "the tree
|
||||||
|
// survived" a settled fact rather than a race (#115).
|
||||||
|
wait_for_async(Duration::from_secs(3), || async {
|
||||||
|
fx.manager.is_settled(s.id).await == Some(true)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// The tree would have blocked a kill, so the exit kept it.
|
||||||
assert!(
|
assert!(
|
||||||
wt_path.exists(),
|
wt_path.exists(),
|
||||||
"a dirty worktree must be retained on natural exit"
|
"a dirty worktree must be left alone on natural exit"
|
||||||
);
|
);
|
||||||
// ...in the store, under the exit reason.
|
|
||||||
// (`Dead` is observable a moment before the settle step's store write lands.)
|
|
||||||
wait_for_async(Duration::from_secs(3), || async {
|
|
||||||
fx.retained_store()
|
|
||||||
.load_all()
|
|
||||||
.map(|all| all.len() == 1)
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
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;
|
let snap = fx.manager.snapshot().await;
|
||||||
assert_eq!(snap.len(), 1);
|
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!(
|
assert!(
|
||||||
wt_path.exists(),
|
snap[0].worktree.is_some(),
|
||||||
"dismissal must not remove what was retained"
|
"the tree stays attached to the dead record"
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
fx.retained_store().load_all().unwrap().len(),
|
|
||||||
1,
|
|
||||||
"dismissal must not record a second entry for the same tree"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Dismissing it needs --force, and force never removes what would block.
|
||||||
|
assert!(fx.manager.kill(s.id, false).await.is_err());
|
||||||
|
fx.manager.kill(s.id, true).await.expect("forced dismissal");
|
||||||
|
assert!(!fx.manager.has(s.id).await);
|
||||||
|
assert!(wt_path.exists(), "dismissal must not remove the work");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Natural exit (#82), pristine side of the same rule: nothing in the 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.
|
/// nothing to keep — the worktree is removed.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
@@ -879,92 +831,18 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
// (`Dead` is observable a moment before the settle step removes the tree.)
|
// (`Dead` is observable a moment before the settle step removes the tree.)
|
||||||
wait_for_async(Duration::from_secs(3), || async {
|
wait_for_async(Duration::from_secs(3), || async { !wt_path.exists() }).await;
|
||||||
!wt_path.exists()
|
|
||||||
&& fx
|
|
||||||
.retained_store()
|
|
||||||
.load_all()
|
|
||||||
.map(|all| all.is_empty())
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!wt_path.exists(),
|
!wt_path.exists(),
|
||||||
"a pristine worktree leaves nothing behind"
|
"a pristine worktree leaves nothing behind"
|
||||||
);
|
);
|
||||||
assert!(
|
|
||||||
fx.retained_store().load_all().unwrap().is_empty(),
|
|
||||||
"nothing was retained"
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
fx.manager.has(s.id).await,
|
fx.manager.has(s.id).await,
|
||||||
"the session stays until dismissed"
|
"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;
|
|
||||||
|
|
||||||
// The settle step has been applied or refused either way — here refused,
|
|
||||||
// since there is no store to record the tree into. Only from this point
|
|
||||||
// is "the tree survived" a settled fact rather than a race (#115).
|
|
||||||
wait_for_async(Duration::from_secs(3), || async {
|
|
||||||
manager.is_settled(s.id).await == Some(true)
|
|
||||||
})
|
|
||||||
.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]) {
|
fn run_git_in(dir: &std::path::Path, args: &[&str]) {
|
||||||
let out = Command::new("git")
|
let out = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, KillOutcome, Project,
|
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, Project, Session,
|
||||||
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;
|
||||||
@@ -65,20 +64,12 @@ impl TestClient {
|
|||||||
Ok(self.daemon.kill_session(id, false).await.map(|_| ())?)
|
Ok(self.daemon.kill_session(id, false).await.map(|_| ())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kill with `force=true`: overrides a kill refusal and retains the
|
/// Kill with `force=true`: overrides a kill refusal and leaves the
|
||||||
/// guarded worktree (#81). Answers with what was kept.
|
/// guarded worktree on disk (#81).
|
||||||
pub async fn kill_session_forced(&self, id: Uuid) -> Result<KillOutcome, ClientError> {
|
pub async fn kill_session_forced(&self, id: Uuid) -> Result<(), ClientError> {
|
||||||
Ok(self.daemon.kill_session(id, true).await?)
|
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> {
|
||||||
Ok(self.daemon.list_projects().await?)
|
Ok(self.daemon.list_projects().await?)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-151
@@ -415,7 +415,7 @@ async fn spawn_from_linked_worktree_places_in_main_checkout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Worktree lifecycle: collisions, retention, reclaim (#88)
|
// Worktree lifecycle: collisions, guards, settling (#88)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// #77/#88: the target path `<repo>/worktrees/<branch>` is occupied by a real
|
/// #77/#88: the target path `<repo>/worktrees/<branch>` is occupied by a real
|
||||||
@@ -621,10 +621,10 @@ async fn guarded_kill_then_commit_and_push_makes_the_same_kill_succeed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// #81/#88: commits that no remote has refuse the kill; `--force` ends the
|
/// #81/#88: commits that no remote has refuse the kill; `--force` ends the
|
||||||
/// session anyway but RETAINS the worktree — force never destroys blocked
|
/// session anyway but LEAVES the worktree on disk — force never destroys
|
||||||
/// work, it parks it in the retained store with reason `force-killed`.
|
/// blocked work.
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn unpushed_commits_block_kill_and_force_retains_the_tree() {
|
async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
|
||||||
let (_daemon, client) = bring_up().await;
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
let base = TempDir::new().unwrap();
|
let base = TempDir::new().unwrap();
|
||||||
@@ -655,17 +655,7 @@ async fn unpushed_commits_block_kill_and_force_retains_the_tree() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Force overrides the refusal and nothing else: session gone, tree kept.
|
// Force overrides the refusal and nothing else: session gone, tree kept.
|
||||||
let outcome = client.kill_session_forced(s.id).await.expect("forced kill");
|
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!(
|
assert!(
|
||||||
!client
|
!client
|
||||||
@@ -676,18 +666,18 @@ async fn unpushed_commits_block_kill_and_force_retains_the_tree() {
|
|||||||
.any(|x| x.id == s.id),
|
.any(|x| x.id == s.id),
|
||||||
"the forced kill ends the session"
|
"the forced kill ends the session"
|
||||||
);
|
);
|
||||||
assert!(wt.is_dir(), "a force-retained worktree must stay on disk");
|
assert!(wt.is_dir(), "a guarded worktree must stay on disk");
|
||||||
assert!(
|
assert!(
|
||||||
git_worktree_list(base.path())
|
git_worktree_list(base.path())
|
||||||
.iter()
|
.iter()
|
||||||
.any(|l| l.contains("feature/local")),
|
.any(|l| l.contains("feature/local")),
|
||||||
"the retained tree stays registered in git"
|
"the kept tree stays registered in git"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #82/#88: a session that exits on its own never reaches the kill path, but
|
/// #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
|
/// its worktree follows the same rule. Dirty: left on disk. Pristine:
|
||||||
/// `exited-with-unsaved-work`; pristine → removed, leaving nothing behind.
|
/// removed, leaving nothing behind.
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
||||||
let (_daemon, client) = bring_up().await;
|
let (_daemon, client) = bring_up().await;
|
||||||
@@ -734,46 +724,18 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
|||||||
|
|
||||||
// The watcher reaps dead tmux sessions every couple of ticks and settles
|
// The watcher reaps dead tmux sessions every couple of ticks and settles
|
||||||
// each worktree accordingly. Poll for both outcomes.
|
// 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(
|
wait_for_async(
|
||||||
"pristine exit removes its worktree entirely",
|
"pristine exit removes its worktree entirely",
|
||||||
Duration::from_secs(20),
|
Duration::from_secs(20),
|
||||||
|| {
|
|| {
|
||||||
let client = client.clone();
|
|
||||||
let repo = pristine_repo.path().to_path_buf();
|
let repo = pristine_repo.path().to_path_buf();
|
||||||
let path = pristine_wt.clone();
|
let path = pristine_wt.clone();
|
||||||
async move {
|
async move {
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
return false;
|
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);
|
let registered = git_worktree_list(&repo);
|
||||||
!registered.iter().any(|l| l.contains("feature/tidy")) && listed_clear
|
!registered.iter().any(|l| l.contains("feature/tidy"))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -797,94 +759,6 @@ fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #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)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1280,21 +1154,6 @@ fn commit_all(dir: &std::path::Path, message: &str) {
|
|||||||
run_git(&["commit", "-q", "-m", message], 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,8 +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,
|
||||||
KillOutcome, Project, RecentDirs, RenameSessionRequest, ReportActivityRequest,
|
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, Session,
|
||||||
RetainedWorktree, Session,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Time budget for establishing a TCP connection to the daemon.
|
/// Time budget for establishing a TCP connection to the daemon.
|
||||||
@@ -116,9 +115,8 @@ impl DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Kill a session. With `force`, the daemon overrides the kill refusal
|
/// Kill a session. With `force`, the daemon overrides the kill refusal
|
||||||
/// (#79) and retains the worktree instead of removing it (#81); the
|
/// (#79) and leaves the worktree on disk instead of removing it (#81).
|
||||||
/// outcome then names what was kept and where.
|
pub async fn kill_session(&self, id: Uuid, force: bool) -> Result<(), ClientError> {
|
||||||
pub async fn kill_session(&self, id: Uuid, force: bool) -> Result<KillOutcome, ClientError> {
|
|
||||||
let query: &[(&str, &str)] = if force {
|
let query: &[(&str, &str)] = if force {
|
||||||
&[(routes::FORCE_QUERY, "true")]
|
&[(routes::FORCE_QUERY, "true")]
|
||||||
} else {
|
} else {
|
||||||
@@ -127,12 +125,8 @@ impl DaemonClient {
|
|||||||
let resp = self
|
let resp = self
|
||||||
.send(Method::DELETE, &routes::session(id), query, None::<&()>)
|
.send(Method::DELETE, &routes::session(id), query, None::<&()>)
|
||||||
.await?;
|
.await?;
|
||||||
let resp = check(resp).await?;
|
check(resp).await?;
|
||||||
// An unforced kill keeps its historical `204 No Content`.
|
Ok(())
|
||||||
if resp.status() == reqwest::StatusCode::NO_CONTENT {
|
|
||||||
return Ok(KillOutcome { retained: None });
|
|
||||||
}
|
|
||||||
json(resp).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PATCH /api/sessions/:id` (#125): rename a live session. A blank name
|
/// `PATCH /api/sessions/:id` (#125): rename a live session. A blank name
|
||||||
@@ -187,23 +181,6 @@ 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>(
|
||||||
|
|||||||
@@ -222,23 +222,9 @@ pub struct SessionWorktree {
|
|||||||
pub base_repo: PathBuf,
|
pub base_repo: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why a retained worktree outlived its session (#80). Same values the
|
/// What a kill refusal reports about a worktree (#80) — the breakdown that
|
||||||
/// daemon's retained store persists; new variants are additive but clients
|
/// blocks an unforced kill, recomputed on every probe so it reflects the tree
|
||||||
/// should render unknown reasons generically.
|
/// now.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "kebab-case")]
|
|
||||||
pub enum RetainedReason {
|
|
||||||
/// The session was killed with `--force` while its worktree held unsaved
|
|
||||||
/// work.
|
|
||||||
ForceKilled,
|
|
||||||
/// The session ended by itself and its worktree held unsaved work.
|
|
||||||
ExitedWithUnsavedWork,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What a kill refusal would report about a worktree right now (#80) — the
|
|
||||||
/// same breakdown that blocks an unforced kill, recomputed on every read of
|
|
||||||
/// [`RetainedWorktree`] so it reflects the tree now rather than when it was
|
|
||||||
/// retained.
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct InTheWay {
|
pub struct InTheWay {
|
||||||
/// Tracked files with uncommitted modifications.
|
/// Tracked files with uncommitted modifications.
|
||||||
@@ -259,34 +245,6 @@ impl InTheWay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One entry of `GET /api/worktrees` (#80): a retained worktree with its
|
|
||||||
/// in-the-way state as of this read. Reclaiming it via
|
|
||||||
/// `DELETE /api/worktrees/:id` removes the directory but **never** the branch.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct RetainedWorktree {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub path: PathBuf,
|
|
||||||
pub base_repo: PathBuf,
|
|
||||||
pub branch: String,
|
|
||||||
pub reason: RetainedReason,
|
|
||||||
pub retained_at: DateTime<Utc>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub in_the_way: InTheWay,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 {
|
impl std::fmt::Display for InTheWay {
|
||||||
/// "3 uncommitted files, 2 unpushed commits" — zero parts are omitted;
|
/// "3 uncommitted files, 2 unpushed commits" — zero parts are omitted;
|
||||||
/// all-zero renders "no unsaved work". This is the phrasing every client
|
/// all-zero renders "no unsaved work". This is the phrasing every client
|
||||||
@@ -873,76 +831,6 @@ mod tests {
|
|||||||
assert_eq!(spec, round_trip(&spec));
|
assert_eq!(spec, round_trip(&spec));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sample_retained_worktree() -> RetainedWorktree {
|
|
||||||
RetainedWorktree {
|
|
||||||
id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef),
|
|
||||||
path: PathBuf::from("/work/maestro/worktrees/feat/login"),
|
|
||||||
base_repo: PathBuf::from("/work/maestro"),
|
|
||||||
branch: "feat/login".into(),
|
|
||||||
reason: RetainedReason::ForceKilled,
|
|
||||||
retained_at: DateTime::parse_from_rfc3339("2026-08-24T10:00:00Z")
|
|
||||||
.unwrap()
|
|
||||||
.with_timezone(&Utc),
|
|
||||||
in_the_way: InTheWay {
|
|
||||||
uncommitted_files: 2,
|
|
||||||
untracked_files: 1,
|
|
||||||
unpushed_commits: 3,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn retained_worktree_round_trips() {
|
|
||||||
let wt = sample_retained_worktree();
|
|
||||||
assert_eq!(wt, round_trip(&wt));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A payload from a daemon predating `in_the_way` still decodes; the
|
|
||||||
/// breakdown defaults to "nothing in the way".
|
|
||||||
#[test]
|
|
||||||
fn retained_worktree_defaults_in_the_way_on_legacy_payload() {
|
|
||||||
let legacy = serde_json::json!({
|
|
||||||
"id": Uuid::from_u128(1),
|
|
||||||
"path": "/w/t",
|
|
||||||
"base_repo": "/w",
|
|
||||||
"branch": "feat/login",
|
|
||||||
"reason": "force-killed",
|
|
||||||
"retained_at": "2026-08-24T10:00:00Z",
|
|
||||||
});
|
|
||||||
let wt: RetainedWorktree = serde_json::from_value(legacy).unwrap();
|
|
||||||
assert_eq!(wt.reason, RetainedReason::ForceKilled);
|
|
||||||
assert_eq!(wt.in_the_way, InTheWay::default());
|
|
||||||
assert!(wt.in_the_way.is_clear());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn retained_reason_serializes_kebab_case() {
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_value(RetainedReason::ForceKilled).unwrap(),
|
|
||||||
serde_json::json!("force-killed")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_value(RetainedReason::ExitedWithUnsavedWork).unwrap(),
|
|
||||||
serde_json::json!("exited-with-unsaved-work")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn 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();
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ pub const BRANCHES: &str = "/api/branches";
|
|||||||
/// `GET` a batch of generated session handles (#53). Takes a `count` query
|
/// `GET` a batch of generated session handles (#53). Takes a `count` query
|
||||||
/// param (default 1, capped at [`maestro_names`]' batch cap).
|
/// param (default 1, capped at [`maestro_names`]' batch cap).
|
||||||
pub const NAMES: &str = "/api/names";
|
pub const NAMES: &str = "/api/names";
|
||||||
/// `GET` retained worktrees (#80): what the store holds, with the in-the-way
|
|
||||||
/// breakdown recomputed on read.
|
|
||||||
pub const WORKTREES: &str = "/api/worktrees";
|
|
||||||
/// `WS` catalog snapshot + session deltas.
|
/// `WS` catalog snapshot + session deltas.
|
||||||
pub const EVENTS: &str = "/api/events";
|
pub const EVENTS: &str = "/api/events";
|
||||||
|
|
||||||
@@ -39,7 +36,7 @@ pub const PATH_QUERY: &str = "path";
|
|||||||
pub const COUNT_QUERY: &str = "count";
|
pub const COUNT_QUERY: &str = "count";
|
||||||
|
|
||||||
/// Name of the boolean query parameter `DELETE /api/sessions/:id` takes (#81).
|
/// Name of the boolean query parameter `DELETE /api/sessions/:id` takes (#81).
|
||||||
/// `force=true` overrides the kill refusal and retains the worktree.
|
/// `force=true` overrides the kill refusal and leaves the worktree on disk.
|
||||||
pub const FORCE_QUERY: &str = "force";
|
pub const FORCE_QUERY: &str = "force";
|
||||||
|
|
||||||
/// `DELETE /api/sessions/:id`.
|
/// `DELETE /api/sessions/:id`.
|
||||||
@@ -52,11 +49,6 @@ pub fn session_activity(id: Uuid) -> String {
|
|||||||
format!("{SESSIONS}/{id}/activity")
|
format!("{SESSIONS}/{id}/activity")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80).
|
|
||||||
pub fn worktree(id: Uuid) -> String {
|
|
||||||
format!("{WORKTREES}/{id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `WS /api/attach/:id` — raw PTY bytes.
|
/// `WS /api/attach/:id` — raw PTY bytes.
|
||||||
pub fn attach(id: Uuid) -> String {
|
pub fn attach(id: Uuid) -> String {
|
||||||
format!("{API_PREFIX}/attach/{id}")
|
format!("{API_PREFIX}/attach/{id}")
|
||||||
@@ -68,7 +60,6 @@ pub mod patterns {
|
|||||||
pub const SESSION: &str = "/api/sessions/:id";
|
pub const SESSION: &str = "/api/sessions/:id";
|
||||||
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
|
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
|
||||||
pub const ATTACH: &str = "/api/attach/:id";
|
pub const ATTACH: &str = "/api/attach/:id";
|
||||||
pub const WORKTREE: &str = "/api/worktrees/:id";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A daemon base URL that could not be turned into a WebSocket base.
|
/// A daemon base URL that could not be turned into a WebSocket base.
|
||||||
@@ -183,10 +174,6 @@ mod tests {
|
|||||||
attach(id()),
|
attach(id()),
|
||||||
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
worktree(id()),
|
|
||||||
"/api/worktrees/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
attach_url("https://laptop.lan:7070", id()).unwrap(),
|
attach_url("https://laptop.lan:7070", id()).unwrap(),
|
||||||
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||||
@@ -209,10 +196,6 @@ mod tests {
|
|||||||
patterns::ATTACH.trim_end_matches(":id"),
|
patterns::ATTACH.trim_end_matches(":id"),
|
||||||
attach(id()).trim_end_matches(&id().to_string())
|
attach(id()).trim_end_matches(&id().to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
patterns::WORKTREE.trim_end_matches(":id"),
|
|
||||||
worktree(id()).trim_end_matches(&id().to_string())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -226,7 +209,6 @@ mod tests {
|
|||||||
GITROOT,
|
GITROOT,
|
||||||
BRANCHES,
|
BRANCHES,
|
||||||
NAMES,
|
NAMES,
|
||||||
WORKTREES,
|
|
||||||
EVENTS,
|
EVENTS,
|
||||||
] {
|
] {
|
||||||
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
||||||
|
|||||||
@@ -51,8 +51,6 @@ pub enum Action {
|
|||||||
/// Rename the highlighted session (#125): opens a prompt pre-filled with
|
/// Rename the highlighted session (#125): opens a prompt pre-filled with
|
||||||
/// its current name.
|
/// its current name.
|
||||||
RenameHighlighted,
|
RenameHighlighted,
|
||||||
/// Open the retained-worktrees view (#85).
|
|
||||||
ShowWorktrees,
|
|
||||||
|
|
||||||
// Chrome
|
// Chrome
|
||||||
ToggleSidebar,
|
ToggleSidebar,
|
||||||
@@ -96,7 +94,6 @@ impl Action {
|
|||||||
Action::FastForwardSpawn => "Create session with defaults".into(),
|
Action::FastForwardSpawn => "Create session with defaults".into(),
|
||||||
Action::KillHighlighted => "Kill selected session".into(),
|
Action::KillHighlighted => "Kill selected session".into(),
|
||||||
Action::RenameHighlighted => "Rename selected session".into(),
|
Action::RenameHighlighted => "Rename selected session".into(),
|
||||||
Action::ShowWorktrees => "Show retained worktrees".into(),
|
|
||||||
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
||||||
Action::ShowHelp => "Show help".into(),
|
Action::ShowHelp => "Show help".into(),
|
||||||
Action::Quit => "Quit Maestro".into(),
|
Action::Quit => "Quit Maestro".into(),
|
||||||
@@ -123,7 +120,6 @@ impl Action {
|
|||||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||||
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
||||||
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
|
|
||||||
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
||||||
"ShowHelp" => return Ok(Action::ShowHelp),
|
"ShowHelp" => return Ok(Action::ShowHelp),
|
||||||
"Quit" => return Ok(Action::Quit),
|
"Quit" => return Ok(Action::Quit),
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ 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::{
|
use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
|
||||||
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;
|
||||||
@@ -41,7 +39,6 @@ use crate::session_task::{SessionInput, SessionTask};
|
|||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem};
|
use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem};
|
||||||
use crate::text_input::TextInput;
|
use crate::text_input::TextInput;
|
||||||
use crate::worktrees::WorktreesView;
|
|
||||||
use crate::TuiError;
|
use crate::TuiError;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -61,8 +58,7 @@ pub struct App {
|
|||||||
help: HelpOverlay,
|
help: HelpOverlay,
|
||||||
chord: ChordState,
|
chord: ChordState,
|
||||||
daemon: DaemonClient,
|
daemon: DaemonClient,
|
||||||
/// Results of draft data loads (and the worktrees view fetch, #85),
|
/// Results of draft data loads, delivered back to the event loop.
|
||||||
/// 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
|
||||||
@@ -73,9 +69,6 @@ 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,
|
|
||||||
/// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the
|
/// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the
|
||||||
/// same reason the worktree entries are.
|
/// same reason the worktree entries are.
|
||||||
rename_input: TextInput,
|
rename_input: TextInput,
|
||||||
@@ -122,11 +115,6 @@ 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),
|
|
||||||
/// Rename a session (#125). An empty name clears the custom one, so the
|
/// Rename a session (#125). An empty name clears the custom one, so the
|
||||||
/// card falls back to the command.
|
/// card falls back to the command.
|
||||||
Rename {
|
Rename {
|
||||||
@@ -157,7 +145,6 @@ impl App {
|
|||||||
chord: ChordState::new(chord_timeout),
|
chord: ChordState::new(chord_timeout),
|
||||||
daemon,
|
daemon,
|
||||||
http,
|
http,
|
||||||
worktrees: WorktreesView::default(),
|
|
||||||
rename_input: TextInput::default(),
|
rename_input: TextInput::default(),
|
||||||
draft_tx,
|
draft_tx,
|
||||||
draft_rx,
|
draft_rx,
|
||||||
@@ -293,8 +280,6 @@ 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::Rename { id, name } => self.submit_rename(id, &name).await,
|
Effect::Rename { id, name } => self.submit_rename(id, &name).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) {
|
||||||
@@ -333,44 +318,6 @@ 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())
|
||||||
@@ -474,10 +421,6 @@ impl App {
|
|||||||
self.state.show_overlay(OverlayKind::Rename(sid));
|
self.state.show_overlay(OverlayKind::Rename(sid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::ShowWorktrees => {
|
|
||||||
self.state.show_overlay(OverlayKind::Worktrees);
|
|
||||||
effects.push(Effect::LoadWorktrees);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
effects
|
effects
|
||||||
}
|
}
|
||||||
@@ -791,22 +734,13 @@ 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 { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(),
|
DraftUpdate::FastForward { .. } => 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
|
||||||
@@ -861,7 +795,7 @@ impl App {
|
|||||||
DraftUpdate::Branches { cwd, branches, .. } => {
|
DraftUpdate::Branches { cwd, branches, .. } => {
|
||||||
draft.set_branches(&cwd, branches);
|
draft.set_branches(&cwd, branches);
|
||||||
}
|
}
|
||||||
DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(),
|
DraftUpdate::FastForward { .. } => unreachable!(),
|
||||||
}
|
}
|
||||||
if follow_up_branches {
|
if follow_up_branches {
|
||||||
self.spawn_load_branches(tab_idx);
|
self.spawn_load_branches(tab_idx);
|
||||||
@@ -961,13 +895,7 @@ impl App {
|
|||||||
|
|
||||||
async fn submit_kill(&mut self, sid: Uuid) {
|
async fn submit_kill(&mut self, sid: Uuid) {
|
||||||
match self.daemon.kill_session(sid, false).await {
|
match self.daemon.kill_session(sid, false).await {
|
||||||
Ok(outcome) => {
|
Ok(()) => self.release_session(sid),
|
||||||
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}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -980,38 +908,6 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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>>,
|
||||||
@@ -1059,7 +955,6 @@ impl App {
|
|||||||
state: &self.state,
|
state: &self.state,
|
||||||
draft,
|
draft,
|
||||||
help: &self.help,
|
help: &self.help,
|
||||||
worktrees: Some(&self.worktrees),
|
|
||||||
rename_input: &self.rename_input,
|
rename_input: &self.rename_input,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1248,11 +1143,6 @@ 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`.
|
||||||
@@ -2198,162 +2088,4 @@ mod tests {
|
|||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- 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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
("S", "FastForwardSpawn"),
|
("S", "FastForwardSpawn"),
|
||||||
("x", "KillHighlighted"),
|
("x", "KillHighlighted"),
|
||||||
("r", "RenameHighlighted"),
|
("r", "RenameHighlighted"),
|
||||||
("w", "ShowWorktrees"),
|
|
||||||
// Focus
|
// Focus
|
||||||
("ctrl-w h", "FocusSidebar"),
|
("ctrl-w h", "FocusSidebar"),
|
||||||
// Session scrollback
|
// Session scrollback
|
||||||
@@ -211,10 +210,6 @@ 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]
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ pub mod sidebar;
|
|||||||
pub mod state;
|
pub mod state;
|
||||||
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;
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ impl KillConfirm {
|
|||||||
f.render_widget(block, area);
|
f.render_widget(block, area);
|
||||||
let mut lines = vec![Line::from(format!("Kill session {}?", self.label))];
|
let mut lines = vec![Line::from(format!("Kill session {}?", self.label))];
|
||||||
if let Some(blockers) = &self.blockers {
|
if let Some(blockers) = &self.blockers {
|
||||||
// Same phrasing the reclaim confirmation uses for its own guard.
|
|
||||||
lines.push(Line::from(format!("Currently in the way: {blockers}")));
|
lines.push(Line::from(format!("Currently in the way: {blockers}")));
|
||||||
lines.push(Line::from("The kill will be refused unless forced."));
|
lines.push(Line::from("The kill will be refused unless forced."));
|
||||||
}
|
}
|
||||||
@@ -45,9 +44,8 @@ impl KillConfirm {
|
|||||||
|
|
||||||
/// Rename prompt for the highlighted session (#125).
|
/// Rename prompt for the highlighted session (#125).
|
||||||
///
|
///
|
||||||
/// Borrows its text buffer rather than owning it: the buffer lives on `App`
|
/// Borrows its text buffer rather than owning it: the buffer lives on `App`,
|
||||||
/// alongside the worktrees view's entries, so [`crate::state::OverlayKind`]
|
/// so [`crate::state::OverlayKind`] stays a plain comparable enum.
|
||||||
/// stays a plain comparable enum.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct RenamePrompt<'a> {
|
pub struct RenamePrompt<'a> {
|
||||||
/// What the card shows today — the session's name, or the command it
|
/// What the card shows today — the session's name, or the command it
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ use crate::palette;
|
|||||||
use crate::pane::Pane;
|
use crate::pane::Pane;
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
||||||
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`].
|
||||||
@@ -82,8 +81,6 @@ 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>,
|
|
||||||
/// Edit buffer behind the rename prompt (#125).
|
/// Edit buffer behind the rename prompt (#125).
|
||||||
pub rename_input: &'a crate::text_input::TextInput,
|
pub rename_input: &'a crate::text_input::TextInput,
|
||||||
}
|
}
|
||||||
@@ -193,22 +190,6 @@ 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);
|
||||||
@@ -372,7 +353,6 @@ mod tests {
|
|||||||
draft: Some(draft),
|
draft: Some(draft),
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
worktrees: None,
|
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -426,7 +406,6 @@ mod tests {
|
|||||||
draft: None,
|
draft: None,
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
worktrees: None,
|
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
@@ -466,7 +445,6 @@ mod tests {
|
|||||||
draft: None,
|
draft: None,
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
worktrees: None,
|
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
|
|||||||
@@ -61,21 +61,10 @@ impl Tab {
|
|||||||
pub enum OverlayKind {
|
pub enum OverlayKind {
|
||||||
KillConfirm(Uuid),
|
KillConfirm(Uuid),
|
||||||
/// Rename prompt for this session (#125). The edit buffer lives on `App`,
|
/// Rename prompt for this session (#125). The edit buffer lives on `App`,
|
||||||
/// like the worktrees view's entries, so this stays comparable.
|
/// so this stays comparable.
|
||||||
Rename(Uuid),
|
Rename(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)]
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
//! 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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+28
-55
@@ -12,9 +12,9 @@ have to reconstruct it.
|
|||||||
|
|
||||||
## Nothing is ever adopted
|
## Nothing is ever adopted
|
||||||
|
|
||||||
The daemon only ever removes a worktree it created in this session's lifetime or
|
The daemon only ever removes a worktree it created in this session's lifetime.
|
||||||
recorded in its retained store. It never takes ownership of a directory it finds
|
It never takes ownership of a directory it finds on disk, whatever that
|
||||||
on disk, whatever that directory looks like.
|
directory looks like.
|
||||||
|
|
||||||
There is no reliable way to ask "is this worktree mine?". A git worktree carries
|
There is no reliable way to ask "is this worktree mine?". A git worktree carries
|
||||||
no marker distinguishing one a daemon made from one a human made ten minutes
|
no marker distinguishing one a daemon made from one a human made ten minutes
|
||||||
@@ -69,75 +69,48 @@ its worktree still matters leaves a directory nobody is looking at.
|
|||||||
|
|
||||||
A consequence, accepted deliberately: a feature branch almost always has unpushed
|
A consequence, accepted deliberately: a feature branch almost always has unpushed
|
||||||
commits, so `--force` is the ordinary way to end a session, not an exception.
|
commits, so `--force` is the ordinary way to end a session, not an exception.
|
||||||
That is only safe because force retains.
|
That is only safe because force never removes what blocked.
|
||||||
|
|
||||||
### `--force`
|
### `--force`
|
||||||
|
|
||||||
`--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
|
**leaves the worktree on disk**, untouched. Force never removes a tree that
|
||||||
`force-killed`. Force never removes a tree that would have blocked. A tree
|
would have blocked. A tree whose guard is clear never needed the override, so a
|
||||||
whose guard is clear never needed the override, so a forced kill removes it as
|
forced kill removes it as usual — force changes what happens to blocked work,
|
||||||
usual — force changes what happens to blocked work, not ordinary cleanup.
|
not ordinary cleanup.
|
||||||
|
|
||||||
Ending a session and destroying its work are two separate destructive acts.
|
Ending a session and destroying its work are two separate acts. Maestro only
|
||||||
Each is chosen on its own, and the second one — reclaim — is chosen while
|
performs the first one. Removing a tree it left behind is the operator's own
|
||||||
looking at exactly what would be lost.
|
`git worktree remove`, chosen while 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
|
||||||
|
|
||||||
A session that ends by itself — the command finished, the operator typed `exit` —
|
A session that ends by itself — the command finished, the operator typed `exit` —
|
||||||
never reaches the kill path. When the watcher notices the tmux session is gone it
|
never reaches the kill path. When the watcher notices the tmux session is gone it
|
||||||
applies the same rule: a pristine worktree is removed, anything else is retained
|
applies the same rule: a pristine worktree is removed, anything else is left on
|
||||||
with reason `exited-with-unsaved-work`.
|
disk.
|
||||||
|
|
||||||
Nobody is waiting for an answer here, so there is nothing to refuse; the choice is
|
Nobody is waiting for an answer here, so there is nothing to refuse; the choice is
|
||||||
only between removing and retaining. It uses the same predicate as the kill, from
|
only between removing and leaving. 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
|
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
|
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
|
once. A removed tree drops off the record, so dismissing the session afterwards
|
||||||
afterwards neither refuses (there is no guard left to trip) nor records a second
|
does not refuse: there is no guard left to trip.
|
||||||
store entry.
|
|
||||||
|
|
||||||
If settling is impossible — the probe fails, or the store cannot take the entry —
|
A tree that was left alone stays attached to the dead record, where dismissing it
|
||||||
nothing is destroyed. The tree stays on disk *and* stays attached to the dead
|
later goes through `--force`, which leaves it on disk too. The same holds when
|
||||||
record, where dismissing it later still goes through `--force`'s
|
the probe itself fails: nothing is destroyed.
|
||||||
retain-before-teardown path. Losing the store must never mean losing the work.
|
|
||||||
|
|
||||||
## The retained store
|
## Nothing is removed on the operator's behalf
|
||||||
|
|
||||||
A `state_dir`-backed record of worktrees the daemon created that no longer have a
|
There is no store of worktrees the daemon is holding, no list endpoint, and no
|
||||||
session. For each one: the path, the base repository, the branch, why it was
|
reclaim command. A tree left behind by a forced kill or a dirty exit is an
|
||||||
retained, and when.
|
ordinary git worktree on disk; `git worktree list` finds it and
|
||||||
|
`git worktree remove` removes it.
|
||||||
|
|
||||||
It survives a daemon restart. It is the recovery path for everything above — a
|
That is deliberate. A tracked list of trees pending deletion is a second place
|
||||||
store that is lost on restart is worse than no store, because the worktrees are
|
the truth can live, and it goes stale the moment somebody removes a directory by
|
||||||
still on disk with nothing pointing at them.
|
hand. Git already knows which worktrees exist, and removing one is not a
|
||||||
|
decision Maestro is better placed to make.
|
||||||
Entries leave when the worktree is reclaimed, and when the daemon notices the
|
|
||||||
directory is gone because somebody removed it by hand.
|
|
||||||
|
|
||||||
**It is not an ownership record.** It must never be consulted to decide whether a
|
|
||||||
path may be adopted — see the first rule. Its only job is to remember what the
|
|
||||||
daemon is holding so that it can be listed and reclaimed.
|
|
||||||
|
|
||||||
## Reclaim
|
|
||||||
|
|
||||||
`GET /api/worktrees` lists the store: path, base repository, branch, reason, when,
|
|
||||||
and what is currently in the way. The in-the-way breakdown is recomputed on read,
|
|
||||||
not stored, so a tree whose work has since been pushed says so.
|
|
||||||
|
|
||||||
`DELETE /api/worktrees/<id>` reclaims one. It removes the directory and drops the
|
|
||||||
store entry. It does **not** consult the guard — reclaim is the deliberate
|
|
||||||
destructive act, and the operator chose it while looking at what was in the way.
|
|
||||||
|
|
||||||
It never deletes the branch. `git worktree remove` leaves the ref, and reclaiming
|
|
||||||
a tree must not throw away commits on it. This is worth saying out loud in every
|
|
||||||
client, because "reclaim" otherwise sounds like it takes the commits too.
|
|
||||||
|
|
||||||
Nothing is reclaimed automatically. There is no age-based expiry and no bulk
|
|
||||||
reclaim.
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type {
|
|||||||
NameBatch,
|
NameBatch,
|
||||||
Projects,
|
Projects,
|
||||||
RecentDirs,
|
RecentDirs,
|
||||||
RetainedWorktree,
|
|
||||||
Session,
|
Session,
|
||||||
Uuid,
|
Uuid,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
@@ -112,22 +111,4 @@ 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";
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { escapeHtml } from "./html.js";
|
import { escapeHtml } from "./html.js";
|
||||||
import { getDaemonInfo, killSession, renameSession } from "./api.js";
|
import { getDaemonInfo, killSession, renameSession } from "./api.js";
|
||||||
import { openContextMenu } from "./contextMenu.js";
|
import { openContextMenu } from "./contextMenu.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 {
|
import {
|
||||||
@@ -269,25 +268,6 @@ function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
|
|||||||
* the app-level actions (spawn, theme toggle). Kept out of the sidebar so these
|
* the app-level actions (spawn, theme toggle). Kept out of the sidebar so these
|
||||||
* stay put and reachable regardless of which pane is showing.
|
* stay put and reachable regardless of which pane is showing.
|
||||||
*/
|
*/
|
||||||
/** Navbar toggle for the debug HUD (#20), next to the design-kit shortcut.
|
|
||||||
* Reflects the current state and stays in sync with the Ctrl+Shift+D keybind. */
|
|
||||||
function createDebugToggle(): HTMLButtonElement {
|
|
||||||
const btn = document.createElement("button");
|
|
||||||
btn.type = "button";
|
|
||||||
btn.className = "btn btn-ghost debug-toggle";
|
|
||||||
btn.textContent = "debug";
|
|
||||||
btn.title = "Toggle the debug HUD (Ctrl+Shift+D)";
|
|
||||||
const sync = () => {
|
|
||||||
const on = isHudEnabled();
|
|
||||||
btn.setAttribute("aria-pressed", String(on));
|
|
||||||
btn.classList.toggle("is-active", on);
|
|
||||||
};
|
|
||||||
btn.addEventListener("click", () => toggleHud());
|
|
||||||
window.addEventListener(HUD_TOGGLE_EVENT, sync);
|
|
||||||
sync();
|
|
||||||
return btn;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): HTMLElement {
|
export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): HTMLElement {
|
||||||
const header = document.createElement("header");
|
const header = document.createElement("header");
|
||||||
header.className = "app-header";
|
header.className = "app-header";
|
||||||
@@ -315,15 +295,6 @@ 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) {
|
||||||
@@ -334,7 +305,6 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
|
|||||||
kit.title = "Open the design kit (DEV)";
|
kit.title = "Open the design kit (DEV)";
|
||||||
actions.appendChild(kit);
|
actions.appendChild(kit);
|
||||||
}
|
}
|
||||||
actions.appendChild(createDebugToggle());
|
|
||||||
actions.appendChild(createThemeToggle());
|
actions.appendChild(createThemeToggle());
|
||||||
const spawnBtn = document.createElement("button");
|
const spawnBtn = document.createElement("button");
|
||||||
spawnBtn.type = "button";
|
spawnBtn.type = "button";
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ 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,
|
||||||
@@ -24,7 +22,6 @@ 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";
|
||||||
|
|
||||||
@@ -352,47 +349,16 @@ 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) {
|
||||||
@@ -414,9 +380,6 @@ function dispatch(): void {
|
|||||||
// tears panes down.
|
// tears panes down.
|
||||||
showMultiView();
|
showMultiView();
|
||||||
return;
|
return;
|
||||||
case "worktrees":
|
|
||||||
showWorktrees();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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. */
|
||||||
@@ -30,11 +29,6 @@ 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)}`;
|
||||||
|
|||||||
@@ -2015,12 +2015,6 @@ input.leaf-rename:focus {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* debug-HUD navbar toggle: lit when the HUD is on. */
|
|
||||||
.debug-toggle.is-active {
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
multi-view (#30 splits, #31 tabs, #33 highlights)
|
multi-view (#30 splits, #31 tabs, #33 highlights)
|
||||||
Tabs of nested split trees; each session leaf hosts a live .attach. The
|
Tabs of nested split trees; each session leaf hosts a live .attach. The
|
||||||
@@ -2439,148 +2433,3 @@ input.leaf-rename:focus {
|
|||||||
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -157,10 +157,6 @@ 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),
|
/** 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
|
* recomputed by the daemon on every read. All counts default to 0 on the
|
||||||
* wire for older daemons. */
|
* wire for older daemons. */
|
||||||
@@ -170,19 +166,6 @@ export interface InTheWay {
|
|||||||
unpushed_commits?: 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 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 kill refusal, the TUI and this client, so no two clients can describe
|
||||||
* the same tree differently. Zero parts are omitted; all-zero renders
|
* the same tree differently. Zero parts are omitted; all-zero renders
|
||||||
@@ -199,12 +182,6 @@ export function formatInTheWay(w: InTheWay): string {
|
|||||||
return parts.length > 0 ? parts.join(", ") : "no unsaved work";
|
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
|
/** #83: this session's blocked-work breakdown when something actually blocks
|
||||||
* an unforced kill; `null` while clear or not yet probed. Render it with
|
* an unforced kill; `null` while clear or not yet probed. Render it with
|
||||||
* {@link formatInTheWay} so every client phrases it identically. */
|
* {@link formatInTheWay} so every client phrases it identically. */
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
// @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"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
// 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