Compare commits

..
Author SHA1 Message Date
Miguel Palhas 2083911baa test(maestro): cover the whole worktree lifecycle end to end (#88)
Every scenario from the contract, against a real daemon, tmux and git:
collision suffixes with both trees working, foreign directories
sidestepped without adoption, the guarded kill and its refusal
breakdown, save-then-kill succeeding, --force retaining the tree,
natural exits settling dirty and pristine worktrees differently, the
retained list with reasons, reclaim keeping the branch, and the store
surviving a daemon restart.

Worktree specs are built through their wire shape so the tests carry no
reference to WorktreeSpec.cleanup_on_kill, which #87 is removing.
2026-08-24 12:33:39 +01:00
Miguel Palhas b5450651d1 test(maestro): expose forced kill and retained-worktree calls in the e2e harness 2026-08-24 12:33:39 +01:00
Miguel Palhas dde8dd8cfc merge: apply the worktree rule when a session exits on its own
Closes #82
2026-08-24 11:57:51 +01:00
Miguel Palhas 2fc19a886e feat(maestro): apply the kill's worktree rule when a session exits on its own
When the liveness watcher finds the tmux session gone, it now settles
the worktree through the same probe_worktree/record_retention helpers
the kill uses: pristine trees are removed, anything else is retained as
exited-with-unsaved-work before anything is torn down. The dead session
drops its worktree reference once settled, so dismissing it neither
refuses nor retains twice. If the probe or store write fails, nothing
is destroyed and the tree stays attached to the record.

WORKTREES.md: written down the settle-then-drop and nothing-destroyed-
when-unrecordable rules alongside the existing exit rule.
2026-08-24 11:54:43 +01:00
Miguel Palhas 0b95d11aa6 Merge remote-tracking branch 'origin/blitz/worktree-lifecycle' into wl/82-natural-exit 2026-08-24 11:54:11 +01:00
Miguel Palhas a85084d0d9 refactor(maestro): extract shared probe and retain helpers from the kill path
probe_worktree and record_retention will be reused by the natural-exit
path (#82); kill's behavior is unchanged.
2026-08-24 11:49:36 +01:00
Miguel Palhas 5282ba3411 merge: web retained-worktrees view with reclaim
Closes #84
2026-08-24 11:45:34 +01:00
Miguel Palhas 6f698df9f4 feat(maestro): retained-worktrees view with reclaim (#84) 2026-08-24 11:44:22 +01:00
Miguel Palhas 8e8540460f feat(maestro): web wire types + client calls for retained worktrees 2026-08-24 11:44:22 +01:00
Miguel Palhas 2d796a3556 merge: kill --force ends the session and retains the worktree
Closes #81
2026-08-24 11:39:28 +01:00
Miguel Palhas 1984bf9472 merge: tui retained-worktrees view with reclaim
Closes #85
2026-08-24 11:36:08 +01:00
Miguel Palhas 56c8e35d6f feat(maestro): wire worktrees view into the tui loop (#85)
w opens the view; r confirms reclaim and says the branch survives.
The in-the-way summary uses InTheWay's Display impl so the TUI cannot
drift from the CLI's wording.
2026-08-24 11:32:22 +01:00
Miguel Palhas 0166607064 feat(maestro): retained-worktrees view overlay (#85) 2026-08-24 11:26:56 +01:00
22 changed files with 2004 additions and 82 deletions
+155 -41
View File
@@ -441,21 +441,8 @@ impl SessionManager {
),
}
};
// The probe runs before anything is torn down: both refusing (#79)
// and recording the retention (#81) must see the tree as it stands,
// and a refused kill leaves the session running as the operator left
// it.
let probe = match &worktree {
Some((_, path)) => {
let path = path.clone();
Some(
tmux_blocking(move || crate::worktree_probe::in_the_way(&path))
.await
.map_err(|e| {
SessionError::Worktree(crate::worktree::WorktreeError::from(e))
})?,
)
}
Some(wt) => Some(probe_worktree(wt).await?),
None => None,
};
let guarded = probe.as_ref().is_some_and(|p| !p.is_clear());
@@ -463,29 +450,15 @@ impl SessionManager {
return Err(SessionError::KillGuarded(probe.expect("guarded ⇒ probed")));
}
// Record the retention BEFORE tearing down: a failed store write then
// leaves the session running and every artifact untouched, instead of
// ending a session whose work outlives it unrecorded.
let mut retained = None;
if guarded {
let store =
self.inner.retained.clone().ok_or_else(|| {
SessionError::RetainFailed("no retained store is wired".into())
})?;
let (base_repo, path) = worktree.as_ref().expect("guarded ⇒ worktree");
let branch = known_branch
.or_else(|| crate::worktree_probe::current_branch(path))
.unwrap_or_default();
let entry = {
let store = store.clone();
let base_repo = base_repo.clone();
let path = path.clone();
tmux_blocking(move || {
store.retain(&path, &base_repo, &branch, RetainedReason::ForceKilled)
})
.await
.map_err(|e| SessionError::RetainFailed(e.to_string()))?
};
let entry = record_retention(
&self.inner,
worktree.as_ref().expect("guarded ⇒ worktree"),
known_branch,
RetainedReason::ForceKilled,
)
.await?;
retained = Some((entry, probe.expect("guarded ⇒ probed")));
}
@@ -671,6 +644,49 @@ where
.expect("tmux blocking task panicked")
}
/// What would be lost if this session's worktree were removed right now.
///
/// The single [`crate::worktree_probe::in_the_way`] call site behind both
/// endings of a session — the kill guard (#79) and the natural-exit
/// classifier (#82) — so neither can grow its own answer. Runs on the
/// blocking pool; callers invoke it before anything is torn down, because
/// refusing (#79), recording a retention (#81) and classifying an exit (#82)
/// must all see the tree as it stands.
async fn probe_worktree(
(_, path): &(PathBuf, PathBuf),
) -> Result<maestro_protocol::InTheWay, SessionError> {
let path = path.clone();
tmux_blocking(move || crate::worktree_probe::in_the_way(&path))
.await
.map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e)))
}
/// Record a guarded worktree into the retained store with `reason`, BEFORE
/// the caller tears anything down: a failed write then leaves every artifact
/// untouched instead of destroying work nothing records anymore. Shared by
/// the forced kill (`ForceKilled`, #81) and the natural exit
/// (`ExitedWithUnsavedWork`, #82) so the two paths cannot drift apart.
async fn record_retention(
inner: &Arc<Inner>,
(base_repo, path): &(PathBuf, PathBuf),
known_branch: Option<String>,
reason: RetainedReason,
) -> Result<crate::retained_store::RetainedWorktreeRecord, SessionError> {
let store = inner
.retained
.clone()
.ok_or_else(|| SessionError::RetainFailed("no retained store is wired".into()))?;
// Sessions adopted from a sidecar carry no branch; ask the tree itself.
let branch = known_branch
.or_else(|| crate::worktree_probe::current_branch(path))
.unwrap_or_default();
let base_repo = base_repo.clone();
let path = path.clone();
tmux_blocking(move || store.retain(&path, &base_repo, &branch, reason))
.await
.map_err(|e| SessionError::RetainFailed(e.to_string()))
}
/// One pass of the liveness watcher: diff the in-memory session set against
/// the tmux-reported one, and reap anything we still track but tmux has lost.
async fn run_liveness_pass(inner: &Arc<Inner>) {
@@ -794,17 +810,23 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
}
/// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`]
/// and announce it, keeping everything else intact.
/// and settle its worktree per the natural-exit rule (#82).
///
/// The record, its [`PtyBridge`] and its sidecar all survive, so a client can
/// still attach and read the replay buffer — the last thing a crashed agent
/// printed. Artifacts are only released when the operator dismisses the session
/// via [`SessionManager::kill`].
/// printed. The worktree, though, is settled right away: a pristine tree is
/// removed, anything else is retained into the store with reason
/// `exited-with-unsaved-work` (#82) — nobody is waiting for an answer here,
/// so there is nothing to refuse. Once settled, the session drops its
/// worktree reference so a later dismissal neither re-probes nor retains
/// twice. When settling is impossible (probe or store failure) nothing is
/// destroyed: the tree stays attached to the dead record for a later forced
/// dismissal (#81).
///
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
/// adopts only what tmux still holds.
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
let updated = {
let flipped = {
let mut sessions = inner.sessions.lock().await;
match sessions.get_mut(&id) {
Some(record) if record.session.status == SessionStatus::Running => {
@@ -812,19 +834,111 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
// Release anyone attached; the scrollback survives for a later
// attach.
record.bridge.seal();
Some(record.session.clone())
let session = record.session.clone();
let worktree = record
.worktree_base_repo
.clone()
.zip(record.worktree_path.clone());
let branch = record.worktree_branch.clone();
Some((session, worktree, branch))
}
_ => None,
}
};
let Some(session) = updated else {
let Some((mut session, worktree, branch)) = flipped else {
return;
};
tracing::info!(%id, "session vanished from tmux — marking dead");
if let Some(wt) = &worktree {
if settle_worktree_on_exit(inner, id, wt, branch).await {
session.worktree = None;
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.session.worktree = None;
record.worktree_path = None;
record.worktree_base_repo = None;
record.worktree_branch = None;
}
}
}
let _ = inner.sidecar.write(&session);
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
}
/// Apply the exit rule (#82) to a vanished session's worktree: probe what
/// would be lost through the kill's own predicate, then remove a pristine
/// tree or retain anything else under `exited-with-unsaved-work` — recorded
/// before anything is torn down, exactly as the forced kill does (#81). A
/// removal that fails falls back to retaining, since whatever blocked it is
/// by definition something worth keeping.
///
/// Returns whether the worktree was fully accounted for. When it wasn't,
/// nothing was destroyed and the caller keeps the reference on the record.
async fn settle_worktree_on_exit(
inner: &Arc<Inner>,
id: Uuid,
(base_repo, path): &(PathBuf, PathBuf),
known_branch: Option<String>,
) -> bool {
let probe = match probe_worktree(&(base_repo.clone(), path.clone())).await {
Ok(probe) => probe,
Err(e) => {
tracing::error!(
%id,
path = %path.display(),
error = ?e,
"natural exit: could not probe the worktree; leaving it untouched"
);
return false;
}
};
if probe.is_clear() {
match inner.worktree_mgr.remove(base_repo, path) {
Ok(()) => {
tracing::info!(%id, path = %path.display(), "natural exit: pristine worktree removed");
return true;
}
Err(e) => {
tracing::warn!(
%id,
path = %path.display(),
error = ?e,
"natural exit: removal failed; falling back to retention"
);
}
}
}
match record_retention(
inner,
&(base_repo.clone(), path.clone()),
known_branch,
RetainedReason::ExitedWithUnsavedWork,
)
.await
{
Ok(entry) => {
tracing::info!(
%id,
path = %path.display(),
entry = %entry.id,
"natural exit: worktree retained with unsaved work"
);
true
}
Err(e) => {
tracing::error!(
%id,
path = %path.display(),
error = ?e,
"natural exit: could not record the retained worktree; leaving it untouched"
);
false
}
}
}
/// Tear down a session's artifacts after it has already been removed from the
/// live map. Shared by [`SessionManager::kill`] and [`reap_dead_session`],
/// which previously hand-rolled the same five-step choreography and only
+188 -1
View File
@@ -8,7 +8,9 @@ use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use common::{
init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture, TmuxFixture,
};
use maestro_daemon::SessionError;
use maestro_protocol::CreateSessionRequest;
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
@@ -668,6 +670,191 @@ async fn killing_an_ignored_only_worktree_succeeds() {
assert!(!fx.manager.has(s.id).await);
}
/// Natural exit (#82): a session that ends on its own never reaches the kill
/// path, so the watcher applies the kill's rule itself. A worktree holding
/// unsaved work is RETAINED into the store with reason
/// `exited-with-unsaved-work`; the session stays in the catalog marked dead
/// until dismissed — and dismissing it afterwards neither refuses nor
/// retains a second time.
#[tokio::test]
async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat").join("exited");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/exited".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
.await
.expect("create");
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
// The command finishes / the operator types exit: tmux loses the session.
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
fx.manager.start_liveness_watcher(Duration::from_millis(50));
wait_for_async(Duration::from_secs(3), || async {
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
})
.await;
// The tree would have blocked a kill, so the exit kept it...
assert!(
wt_path.exists(),
"a dirty worktree must be retained on natural exit"
);
// ...in the store, under the exit reason.
let all = fx.retained_store().load_all().expect("load_all");
assert_eq!(all.len(), 1);
assert_eq!(all[0].path, wt_path);
assert_eq!(all[0].branch, "feat/exited");
assert_eq!(
all[0].reason,
maestro_protocol::RetainedReason::ExitedWithUnsavedWork
);
// The session itself stays until dismissed; its worktree reference is
// settled, so dismissal is neither refused nor retained twice.
// (`Dead` is observable a moment before the settle step finishes.)
wait_for_async(Duration::from_secs(3), || async {
fx.manager.snapshot().await[0].worktree.is_none()
})
.await;
let snap = fx.manager.snapshot().await;
assert_eq!(snap.len(), 1);
assert_eq!(snap[0].worktree, None);
fx.manager.kill(s.id, false).await.expect("dismiss");
assert!(!fx.manager.has(s.id).await);
assert!(
wt_path.exists(),
"dismissal must not remove what was retained"
);
assert_eq!(
fx.retained_store().load_all().unwrap().len(),
1,
"dismissal must not record a second entry for the same tree"
);
}
/// Natural exit (#82), pristine side of the same rule: nothing in the tree,
/// nothing to keep — the worktree is removed and no store entry appears.
#[tokio::test]
async fn natural_exit_of_a_pristine_worktree_removes_it() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat").join("clean");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/clean".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
.await
.expect("create");
// Push everything so the branch has no unpushed commits: fully pristine.
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
fx.manager.start_liveness_watcher(Duration::from_millis(50));
wait_for_async(Duration::from_secs(3), || async {
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
})
.await;
assert!(
!wt_path.exists(),
"a pristine worktree leaves nothing behind"
);
assert!(
fx.retained_store().load_all().unwrap().is_empty(),
"nothing was retained"
);
assert!(
fx.manager.has(s.id).await,
"the session stays until dismissed"
);
}
/// When nothing can be recorded — here: no retained store wired — the exit
/// destroys nothing. The tree stays on disk AND stays attached to the dead
/// record, where dismissing it later still goes through #81's forced-kill
/// retention. Losing the store must not lose the work.
#[tokio::test]
async fn natural_exit_without_a_retained_store_destroys_nothing() {
let tmux = TmuxFixture::new();
let state_dir = TempDir::new().unwrap();
let manager = maestro_daemon::SessionManager::new(
Arc::new(tmux.tmux.clone()),
maestro_daemon::sidecar::SidecarStore::new(state_dir.path().join("sessions")),
maestro_daemon::worktree::WorktreeManager::new(None),
state_dir.path().join("fifos"),
);
// Deliberately NO with_retained_store.
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat").join("nostore");
let s = manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/nostore".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
.await
.expect("create");
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
tmux.tmux.kill_session(s.id).expect("kill tmux directly");
manager.start_liveness_watcher(Duration::from_millis(50));
wait_for_async(Duration::from_secs(3), || async {
manager.status(s.id).await == Some(SessionStatus::Dead)
})
.await;
assert!(
wt_path.exists(),
"nothing may be destroyed when it cannot be recorded"
);
let snap = manager.snapshot().await;
assert!(
snap[0].worktree.is_some(),
"the tree must stay attached to the record so a later forced kill can retain it"
);
}
fn run_git_in(dir: &std::path::Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
+16 -1
View File
@@ -12,7 +12,8 @@ use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, Project, Session,
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, KillOutcome, Project,
RetainedWorktree, Session,
};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message as WsMessage;
@@ -64,6 +65,20 @@ impl TestClient {
Ok(self.daemon.kill_session(id, false).await.map(|_| ())?)
}
/// Kill with `force=true`: overrides a kill refusal and retains the
/// guarded worktree (#81). Answers with what was kept.
pub async fn kill_session_forced(&self, id: Uuid) -> Result<KillOutcome, ClientError> {
Ok(self.daemon.kill_session(id, true).await?)
}
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
Ok(self.daemon.list_worktrees().await?)
}
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
Ok(self.daemon.reclaim_worktree(id).await?)
}
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
Ok(self.daemon.list_projects().await?)
}
+523 -33
View File
@@ -35,6 +35,18 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
}
}
/// A worktree spec built through its wire shape. The daemon and clients ship
/// together (#87 dropped `cleanup_on_kill` from the struct), so the JSON
/// object carries only the fields the lifecycle actually honours.
fn worktree_spec(base_repo: &std::path::Path, branch: &str, create_branch: bool) -> WorktreeSpec {
serde_json::from_value(serde_json::json!({
"base_repo": base_repo,
"branch": branch,
"create_branch": create_branch,
}))
.expect("worktree spec")
}
/// Spawn request carrying a worktree spec against `base`.
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
CreateSessionRequest {
@@ -42,12 +54,7 @@ fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> Cr
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.to_path_buf(),
branch: branch.into(),
cleanup_on_kill: true,
create_branch,
}),
worktree: Some(worktree_spec(base, branch, create_branch)),
}
}
@@ -141,12 +148,7 @@ async fn kill_removes_clean_worktree() {
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.path().to_path_buf(),
branch: "feature/e2e".into(),
cleanup_on_kill: true,
create_branch: true,
}),
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
@@ -160,7 +162,7 @@ async fn kill_removes_clean_worktree() {
"expected a worktree for feature/e2e under <repo>/worktrees, got: {worktrees:?}"
);
push_to_throwaway_origin(base.path());
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
// Give the daemon a moment to remove the worktree, then verify.
@@ -199,12 +201,7 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.path().to_path_buf(),
branch: "feature/unsaved".into(),
cleanup_on_kill: true,
create_branch: true,
}),
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
@@ -262,12 +259,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.path().to_path_buf(),
branch: "feature/existing".into(),
cleanup_on_kill: true,
create_branch: false,
}),
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
};
let s = client
@@ -280,7 +272,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
"expected a worktree checked out at feature/existing, got: {worktrees:?}"
);
push_to_throwaway_origin(base.path());
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
@@ -328,7 +320,7 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
)
.await;
push_to_throwaway_origin(base.path());
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
@@ -376,7 +368,7 @@ async fn second_spawn_on_same_branch_is_rejected() {
"original worktree must be untouched"
);
push_to_throwaway_origin(base.path());
let _origin = throwaway_origin(base.path());
client.kill_session(first.id).await.expect("kill");
}
@@ -419,10 +411,481 @@ async fn spawn_from_linked_worktree_places_in_main_checkout() {
"worktrees must never nest inside another worktree"
);
push_to_throwaway_origin(base.path());
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
// ---------------------------------------------------------------------------
// Worktree lifecycle: collisions, retention, reclaim (#88)
// ---------------------------------------------------------------------------
/// #77/#88: the target path `<repo>/worktrees/<branch>` is occupied by a real
/// registered worktree the operator made by hand. The daemon sidesteps to the
/// suffixed directory and never touches the occupant — and both trees keep
/// working: two registered worktrees, two sessions, each running in its own.
#[tokio::test(flavor = "multi_thread")]
async fn occupied_path_gets_suffixed_directory_and_both_worktrees_work() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
// An operator-made worktree sits exactly where `feature/collide` would go.
// Its branch is a different one, so the requested branch stays creatable.
let squatter = base
.path()
.join("worktrees")
.join("feature")
.join("collide");
run_git(
&[
"worktree",
"add",
"-q",
"-b",
"operator/manual",
&squatter.to_string_lossy(),
],
base.path(),
);
let s = client
.spawn_session(&worktree_req(base.path(), "feature/collide", true))
.await
.expect("spawn onto occupied path");
let suffixed = base
.path()
.join("worktrees")
.join("feature")
.join("collide-2");
assert_eq!(
s.cwd, suffixed,
"the daemon must take the suffixed directory, not the occupied one"
);
assert!(suffixed.is_dir(), "suffixed worktree must exist on disk");
// Both trees are registered, distinct, and the squatter is byte-intact.
let registrations = git_worktree_list(base.path());
assert!(
registrations
.iter()
.any(|l| l == &format!("worktree {}", squatter.canonicalize().unwrap().display())),
"operator's worktree must stay registered: {registrations:?}"
);
assert!(
registrations.iter().any(|l| l.contains("collide-2")),
"suffixed worktree must be registered too: {registrations:?}"
);
// Both sessions run inside their own tree at the same time.
let sessions = client.list_sessions().await.expect("list");
assert!(
sessions.iter().any(|x| x.id == s.id && x.cwd == suffixed),
"session must keep running in its suffixed tree"
);
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
// Only the daemon-made trees go; the operator's manual one survives.
let after = git_worktree_list(base.path());
assert!(
after
.iter()
.any(|l| l == &format!("worktree {}", squatter.canonicalize().unwrap().display())),
"operator's worktree must outlive the sessions: {after:?}"
);
}
/// #77/#88: the target path holds a plain directory the daemon never made and
/// git has never heard of. The daemon neither adopts it nor errors — it builds
/// beside it and leaves every byte of it alone.
#[tokio::test(flavor = "multi_thread")]
async fn foreign_directory_is_sidestepped_never_adopted() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let squatter = base
.path()
.join("worktrees")
.join("feature")
.join("foreign");
std::fs::create_dir_all(&squatter).unwrap();
std::fs::write(squatter.join("operator-notes.txt"), b"mine\n").unwrap();
let s = client
.spawn_session(&worktree_req(base.path(), "feature/foreign", true))
.await
.expect("spawn must sidestep, not adopt or fail");
let suffixed = base
.path()
.join("worktrees")
.join("feature")
.join("foreign-2");
assert_eq!(s.cwd, suffixed, "session must run in the suffixed tree");
assert_eq!(
std::fs::read(squatter.join("operator-notes.txt")).unwrap(),
b"mine\n",
"the foreign directory must be untouched"
);
let registrations = git_worktree_list(base.path());
let registered_paths: Vec<&str> = registrations
.iter()
.filter_map(|l| l.strip_prefix("worktree "))
.collect();
assert!(
!registered_paths
.iter()
.any(|p| *p == squatter.canonicalize().unwrap().display().to_string()),
"a plain directory must never be adopted as a worktree: {registered_paths:?}"
);
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
assert!(squatter.join("operator-notes.txt").exists());
}
/// #79/#88: killing with uncommitted changes is refused; committing and
/// pushing that work clears the guard and the very same kill then succeeds
/// and removes the tree. The session is the handle on the work: while it is
/// unsaved the session stays, once it is saved the tree goes.
#[tokio::test(flavor = "multi_thread")]
async fn guarded_kill_then_commit_and_push_makes_the_same_kill_succeed() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let _origin = throwaway_origin(base.path());
let wt = base.path().join("worktrees").join("feature").join("guard");
let s = client
.spawn_session(&worktree_req(base.path(), "feature/guard", true))
.await
.expect("spawn");
std::fs::write(wt.join("README"), b"work in progress\n").unwrap();
// Refused: the edit is uncommitted.
let err = client
.kill_session(s.id)
.await
.expect_err("kill must refuse");
match err {
maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => {
assert_eq!(info.code, "kill_guarded", "{info:?}");
let breakdown = info.unsaved_work.expect("breakdown on the wire");
assert_eq!(breakdown.uncommitted_files, 1);
assert_eq!(breakdown.unpushed_commits, 0);
}
other => panic!("expected a kill_guarded refusal, got {other:?}"),
}
assert!(
client
.list_sessions()
.await
.unwrap()
.iter()
.any(|x| x.id == s.id),
"a refused kill keeps the session running"
);
assert_eq!(
std::fs::read(wt.join("README")).unwrap(),
b"work in progress\n",
"the worktree is untouched by the refusal"
);
// The operator does exactly what the refusal asked for: save and push.
commit_all(&wt, "wip");
push_all(base.path());
client.kill_session(s.id).await.expect("kill after saving");
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if !wt.exists() {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert!(
!wt.exists(),
"a cleared worktree must be removed after kill"
);
let registrations = git_worktree_list(base.path());
assert!(
!registrations.iter().any(|l| l.contains("feature/guard")),
"removal must deregister the tree: {registrations:?}"
);
}
/// #81/#88: commits that no remote has refuse the kill; `--force` ends the
/// session anyway but RETAINS the worktree — force never destroys blocked
/// work, it parks it in the retained store with reason `force-killed`.
#[tokio::test(flavor = "multi_thread")]
async fn unpushed_commits_block_kill_and_force_retains_the_tree() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let _origin = throwaway_origin(base.path());
let wt = base.path().join("worktrees").join("feature").join("local");
let s = client
.spawn_session(&worktree_req(base.path(), "feature/local", true))
.await
.expect("spawn");
std::fs::write(wt.join("README"), b"local only\n").unwrap();
commit_all(&wt, "not pushed yet");
// Refused: the commit is on no remote-tracking ref.
let err = client
.kill_session(s.id)
.await
.expect_err("kill must refuse");
match err {
maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => {
assert_eq!(info.code, "kill_guarded", "{info:?}");
let breakdown = info.unsaved_work.expect("breakdown on the wire");
assert_eq!(breakdown.unpushed_commits, 1);
assert_eq!(breakdown.uncommitted_files, 0);
}
other => panic!("expected a kill_guarded refusal, got {other:?}"),
}
// Force overrides the refusal and nothing else: session gone, tree kept.
let outcome = client.kill_session_forced(s.id).await.expect("forced kill");
let retained = outcome
.retained
.expect("force must retain the guarded tree");
assert_eq!(
retained.reason,
maestro_protocol::RetainedReason::ForceKilled
);
assert_eq!(retained.path, wt);
assert_eq!(retained.branch, "feature/local");
assert_eq!(retained.in_the_way.unpushed_commits, 1);
assert!(
!client
.list_sessions()
.await
.unwrap()
.iter()
.any(|x| x.id == s.id),
"the forced kill ends the session"
);
assert!(wt.is_dir(), "a force-retained worktree must stay on disk");
assert!(
git_worktree_list(base.path())
.iter()
.any(|l| l.contains("feature/local")),
"the retained tree stays registered in git"
);
}
/// #82/#88: a session that exits on its own never reaches the kill path, but
/// its worktree follows the same rule. Dirty → retained with reason
/// `exited-with-unsaved-work`; pristine → removed, leaving nothing behind.
#[tokio::test(flavor = "multi_thread")]
async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
let (_daemon, client) = bring_up().await;
// Two repos so the two cases cannot see each other's state.
let dirty_repo = TempDir::new().unwrap();
init_repo(dirty_repo.path());
let _origin = throwaway_origin(dirty_repo.path());
let pristine_repo = TempDir::new().unwrap();
init_repo(pristine_repo.path());
let _origin_pristine = throwaway_origin(pristine_repo.path());
let dirty_wt = dirty_repo
.path()
.join("worktrees")
.join("feature")
.join("messy");
let pristine_wt = pristine_repo
.path()
.join("worktrees")
.join("feature")
.join("tidy");
let _dirty = client
.spawn_session(&CreateSessionRequest {
cwd: dirty_repo.path().to_path_buf(),
command: "sh -c 'echo unsaved > scratch.txt'".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
})
.await
.expect("spawn dirty");
let _pristine = client
.spawn_session(&create_req_cmd(
pristine_repo.path(),
"true",
"feature/tidy",
))
.await
.expect("spawn pristine");
assert!(dirty_wt.is_dir() && pristine_wt.is_dir());
// The watcher reaps dead tmux sessions every couple of ticks and settles
// each worktree accordingly. Poll for both outcomes.
wait_for_async(
"dirty exit retains its worktree",
Duration::from_secs(20),
|| {
let client = client.clone();
let path = dirty_wt.clone();
async move {
client
.list_worktrees()
.await
.ok()
.map(|wts| {
wts.iter().any(|w| {
w.path == path
&& w.reason
== maestro_protocol::RetainedReason::ExitedWithUnsavedWork
})
})
.unwrap_or(false)
}
},
)
.await;
wait_for_async(
"pristine exit removes its worktree entirely",
Duration::from_secs(20),
|| {
let client = client.clone();
let repo = pristine_repo.path().to_path_buf();
let path = pristine_wt.clone();
async move {
if path.exists() {
return false;
}
let listed = client.list_worktrees().await.ok();
let listed_clear = listed
.map(|wts| !wts.iter().any(|w| w.path == path))
.unwrap_or(false);
let registered = git_worktree_list(&repo);
!registered.iter().any(|l| l.contains("feature/tidy")) && listed_clear
}
},
)
.await;
// The dirty tree is still fully there, file included.
assert!(dirty_wt.is_dir());
assert_eq!(
std::fs::read(dirty_wt.join("scratch.txt")).unwrap(),
b"unsaved\n"
);
}
fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest {
CreateSessionRequest {
cwd: cwd.to_path_buf(),
command: command.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(cwd, branch, true)),
}
}
/// #80/#88: `GET /api/worktrees` lists what the daemon is holding, with the
/// reason and a live in-the-way breakdown; reclaim removes the directory and
/// the store entry but NEVER the branch — its commits outlive the reclaim.
#[tokio::test(flavor = "multi_thread")]
async fn worktrees_endpoint_lists_reasons_and_reclaim_keeps_the_branch() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let _origin = throwaway_origin(base.path());
let wt = base.path().join("worktrees").join("feature").join("parked");
let s = client
.spawn_session(&worktree_req(base.path(), "feature/parked", true))
.await
.expect("spawn");
std::fs::write(wt.join("README"), b"worth keeping\n").unwrap();
commit_all(&wt, "worth keeping");
let sha = branch_sha(&wt, "feature/parked");
let outcome = client.kill_session_forced(s.id).await.expect("force kill");
assert!(outcome.retained.is_some());
let listed = client.list_worktrees().await.expect("list worktrees");
assert_eq!(listed.len(), 1, "exactly one retained entry: {listed:?}");
let entry = &listed[0];
assert_eq!(entry.path, wt);
assert_eq!(entry.branch, "feature/parked");
assert_eq!(entry.reason, maestro_protocol::RetainedReason::ForceKilled);
assert_eq!(entry.in_the_way.unpushed_commits, 1);
client.reclaim_worktree(entry.id).await.expect("reclaim");
assert!(!wt.exists(), "reclaim removes the directory");
assert!(
client.list_worktrees().await.unwrap().is_empty(),
"reclaim drops the store entry"
);
// The whole point of reclaim: the branch and its commits survive.
assert_eq!(
branch_sha(base.path(), "feature/parked"),
sha,
"the branch must keep pointing at the same commit after reclaim"
);
}
/// The retained store is state-dir backed (#80): a daemon restart must not
/// orphan the trees it is holding — they are still on disk with entries
/// pointing at them, and still reclaimable afterwards.
#[tokio::test(flavor = "multi_thread")]
async fn retained_store_survives_a_daemon_restart() {
let (mut daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let _origin = throwaway_origin(base.path());
let wt = base
.path()
.join("worktrees")
.join("feature")
.join("durable");
let s = client
.spawn_session(&worktree_req(base.path(), "feature/durable", true))
.await
.expect("spawn");
std::fs::write(wt.join("README"), b"survives restarts\n").unwrap();
commit_all(&wt, "survives restarts");
client.kill_session_forced(s.id).await.expect("force kill");
daemon.restart().await;
let listed = client.list_worktrees().await.expect("list after restart");
assert_eq!(listed.len(), 1, "store must survive restart: {listed:?}");
assert_eq!(listed[0].path, wt);
assert_eq!(
listed[0].reason,
maestro_protocol::RetainedReason::ForceKilled
);
assert!(wt.is_dir(), "the tree itself must survive the restart too");
client
.reclaim_worktree(listed[0].id)
.await
.expect("reclaim");
assert!(!wt.exists());
}
// ---------------------------------------------------------------------------
// GET /api/projects + /api/branches (#54/#55/#66)
// ---------------------------------------------------------------------------
@@ -788,10 +1251,12 @@ fn git_worktree_list(dir: &std::path::Path) -> Vec<String> {
.collect()
}
/// Push every branch of `repo` to a throwaway bare remote, so its commits are
/// reachable from remote-tracking refs. Without one the kill guard (#79)
/// reads every commit as unpushed, and any worktree session refuses to die.
fn push_to_throwaway_origin(repo: &std::path::Path) {
/// Bare remote registered as `origin` on `repo`, seeded with every current
/// branch, so existing commits are reachable from remote-tracking refs.
/// Without one the kill guard (#79) reads every commit as unpushed, and any
/// worktree session refuses to die. The caller must keep the `TempDir` alive
/// while the remote is still needed; later pushes go through [`push_all`].
fn throwaway_origin(repo: &std::path::Path) -> TempDir {
let origin = tempfile::TempDir::new().unwrap();
run_git(
&["init", "-q", "--bare", origin.path().to_str().unwrap()],
@@ -803,9 +1268,34 @@ fn push_to_throwaway_origin(repo: &std::path::Path) {
.args(["remote", "add", "origin", origin.path().to_str().unwrap()])
.output()
.expect("git remote add");
push_all(repo);
origin
}
fn push_all(repo: &std::path::Path) {
run_git(&["push", "-q", "--all", "origin"], repo);
}
fn commit_all(dir: &std::path::Path, message: &str) {
run_git(&["add", "-A"], dir);
run_git(&["commit", "-q", "-m", message], dir);
}
fn branch_sha(repo: &std::path::Path, branch: &str) -> String {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
.output()
.expect("git rev-parse");
assert!(
out.status.success(),
"branch {branch} missing: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
#[tokio::test(flavor = "multi_thread")]
async fn daemon_events_ws_streams_snapshot_then_session_added() {
// NAP-248: subscribe to the daemon's WS /api/events, expect an initial
+3
View File
@@ -43,6 +43,8 @@ pub enum Action {
/// resolved; unresolvable defaults open a pre-filled draft instead.
FastForwardSpawn,
KillHighlighted,
/// Open the retained-worktrees view (#85).
ShowWorktrees,
// Tabs
NewTab,
@@ -84,6 +86,7 @@ impl Action {
"NewDraftPane" => return Ok(Action::NewDraftPane),
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
"NewTab" => return Ok(Action::NewTab),
"CloseTab" => return Ok(Action::CloseTab),
"NextTab" => return Ok(Action::NextTab),
+266 -4
View File
@@ -18,7 +18,9 @@ use std::time::Duration;
use chrono::Utc;
use crossterm::event::{Event as CtEvent, EventStream};
use futures_util::StreamExt;
use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
use maestro_protocol::{
routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch, RetainedWorktree,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use uuid::Uuid;
@@ -37,6 +39,7 @@ use crate::session_task::{SessionInput, SessionTask};
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
use crate::tabs::TabBar;
use crate::worktrees::WorktreesView;
use crate::TuiError;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -55,7 +58,8 @@ pub struct App {
help: HelpOverlay,
chord: ChordState,
daemon: DaemonClient,
/// Results of draft data loads, delivered back to the event loop.
/// Results of draft data loads (and the worktrees view fetch, #85),
/// delivered back to the event loop.
///
/// These are network calls — `/api/projects` on a cold cache walks all of
/// `$HOME` — and awaiting them inside `perform` froze the whole TUI: no
@@ -66,6 +70,9 @@ pub struct App {
/// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet:
/// `GET /api/names` (#64).
http: reqwest::Client,
/// Entries of the retained-worktrees view (#85). Daemon data, kept here
/// rather than in `AppState` so the overlay kind stays payload-free.
worktrees: WorktreesView,
region: RegionMap,
last_sent_size: HashMap<Uuid, (u16, u16)>,
clicks: ClickTracker,
@@ -109,6 +116,11 @@ pub enum Effect {
},
/// Fetch the draft target's branches from `/api/branches` (#65).
LoadBranches(usize),
/// Fetch `/api/worktrees` for the retained-worktrees view (#85).
LoadWorktrees,
/// Reclaim one retained worktree (#85) — removes its directory; the
/// branch is never deleted.
ReclaimWorktree(Uuid),
}
impl App {
@@ -132,6 +144,7 @@ impl App {
chord: ChordState::new(chord_timeout),
daemon,
http,
worktrees: WorktreesView::default(),
draft_tx,
draft_rx,
region: RegionMap::default(),
@@ -225,6 +238,8 @@ impl App {
self.spawn_probe_git_root(tab, seq, path)
}
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
Effect::LoadWorktrees => self.spawn_load_worktrees(),
Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await,
Effect::Forward { id, bytes } => {
if let Some(handle) = self.emulators.get(id) {
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
@@ -247,6 +262,44 @@ impl App {
}
_ => (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) => {
self.state.dismiss_overlay();
(OverlayDecision::Consumed, Vec::new())
@@ -317,6 +370,10 @@ impl App {
self.state.show_overlay(OverlayKind::KillConfirm(sid));
}
}
Action::ShowWorktrees => {
self.state.show_overlay(OverlayKind::Worktrees);
effects.push(Effect::LoadWorktrees);
}
}
effects
}
@@ -505,13 +562,22 @@ impl App {
self.fast_forward_spawn(info, projects, names).await;
return;
}
if let DraftUpdate::WorktreesLoaded {
worktrees,
home_dir,
} = update
{
self.worktrees.apply(worktrees);
self.worktrees.home_dir = home_dir;
return;
}
let want = match &update {
DraftUpdate::Data { draft, .. }
| DraftUpdate::Names { draft, .. }
| DraftUpdate::GitRoot { draft, .. }
| DraftUpdate::Branches { draft, .. } => *draft,
// Handled above; it targets no particular draft.
DraftUpdate::FastForward { .. } => unreachable!(),
DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(),
};
// Find it by identity: closing a tab shifts every index to its right,
// so an in-flight load addressed by index would be dropped or land on
@@ -566,7 +632,7 @@ impl App {
DraftUpdate::Branches { cwd, branches, .. } => {
draft.set_branches(&cwd, branches);
}
DraftUpdate::FastForward { .. } => unreachable!(),
DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(),
}
if follow_up_branches {
self.spawn_load_branches(tab_idx);
@@ -679,6 +745,38 @@ 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(
&mut self,
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
@@ -719,6 +817,7 @@ impl App {
state: &self.state,
draft,
help: &self.help,
worktrees: Some(&self.worktrees),
};
let mut new_region = RegionMap::default();
@@ -863,6 +962,11 @@ enum DraftUpdate {
projects: Option<Vec<maestro_protocol::Project>>,
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`.
@@ -1515,4 +1619,162 @@ mod tests {
app.dispatch(Action::KillHighlighted);
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid));
}
// -- retained worktrees (#85) -------------------------------------------
fn sample_worktree(id: u128) -> maestro_protocol::RetainedWorktree {
use chrono::TimeZone;
maestro_protocol::RetainedWorktree {
id: Uuid::from_u128(id),
path: "/home/naps/tea/maestro/worktrees/feat/login".into(),
base_repo: "/home/naps/tea/maestro".into(),
branch: "feat/login".into(),
reason: maestro_protocol::RetainedReason::ForceKilled,
retained_at: chrono::Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
in_the_way: maestro_protocol::InTheWay {
uncommitted_files: 2,
untracked_files: 1,
unpushed_commits: 3,
},
}
}
fn app_with_one_retained_worktree() -> App {
let mut app = make_app();
app.worktrees.home_dir = "/home/naps".into();
app.worktrees.apply(vec![sample_worktree(42)]);
app.state.show_overlay(OverlayKind::Worktrees);
app
}
#[test]
fn show_worktrees_opens_the_view_and_fetches() {
let mut app = make_app();
assert_eq!(
app.dispatch(Action::ShowWorktrees),
vec![Effect::LoadWorktrees]
);
assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees));
}
#[test]
fn r_over_the_highlighted_worktree_asks_for_a_confirmation() {
let mut app = app_with_one_retained_worktree();
let (decision, effects) = app.handle_overlay_input(&InputKey::char('r'));
assert_eq!(decision, OverlayDecision::Consumed);
assert!(effects.is_empty());
match app.state.overlay() {
Some(OverlayKind::ReclaimConfirm { id, branch, .. }) => {
assert_eq!(*id, Uuid::from_u128(42));
assert_eq!(branch, "feat/login");
}
other => panic!("expected a reclaim confirm, got {other:?}"),
}
}
/// The confirmation names what is in the way using the shared phrasing,
/// so the confirm step cannot drift from the list or the CLI.
#[test]
fn the_reclaim_confirmation_carries_the_shared_in_the_way_phrasing() {
let mut app = app_with_one_retained_worktree();
app.handle_overlay_input(&InputKey::char('r'));
match app.state.overlay() {
Some(OverlayKind::ReclaimConfirm { blockers, .. }) => {
assert_eq!(
blockers,
"2 uncommitted files, 1 untracked file, 3 unpushed commits"
);
}
other => panic!("expected a reclaim confirm, got {other:?}"),
}
}
#[test]
fn confirming_a_reclaim_asks_for_the_reclaim() {
let mut app = app_with_one_retained_worktree();
app.handle_overlay_input(&InputKey::char('r'));
let (decision, effects) = app.handle_overlay_input(&InputKey::char('y'));
assert_eq!(decision, OverlayDecision::Consumed);
assert_eq!(effects, vec![Effect::ReclaimWorktree(Uuid::from_u128(42))]);
assert!(app.state.overlay().is_none());
}
#[test]
fn declining_a_reclaim_returns_to_the_list() {
let mut app = app_with_one_retained_worktree();
app.handle_overlay_input(&InputKey::char('r'));
let (decision, effects) = app.handle_overlay_input(&InputKey::char('n'));
assert_eq!(decision, OverlayDecision::Consumed);
assert!(effects.is_empty());
assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees));
}
#[test]
fn j_and_k_move_the_highlight_inside_the_view() {
let mut app = make_app();
app.worktrees
.apply(vec![sample_worktree(1), sample_worktree(2)]);
// Distinct ids so movement is observable.
app.worktrees.items[1].branch = "other".into();
app.state.show_overlay(OverlayKind::Worktrees);
app.handle_overlay_input(&InputKey::char('j'));
assert_eq!(app.worktrees.selected().unwrap().branch, "other");
app.handle_overlay_input(&InputKey::char('k'));
assert_eq!(app.worktrees.selected().unwrap().branch, "feat/login");
}
#[tokio::test]
async fn loading_worktrees_fills_the_view_from_the_daemon() {
use axum::routing::{delete, get};
use axum::Json;
use serde_json::json;
async fn info() -> Json<serde_json::Value> {
Json(json!({"hostname": "box", "version": "0.1.0", "home_dir": "/home/naps"}))
}
async fn worktrees() -> Json<serde_json::Value> {
Json(json!([{
"id": Uuid::from_u128(42).to_string(),
"path": "/home/naps/tea/maestro/worktrees/feat/login",
"base_repo": "/home/naps/tea/maestro",
"branch": "feat/login",
"reason": "force-killed",
"retained_at": "2026-08-24T10:00:00Z",
"in_the_way": {"uncommitted_files": 2, "untracked_files": 1, "unpushed_commits": 3},
}]))
}
async fn reclaim() -> Json<serde_json::Value> {
Json(json!(null))
}
let app_router = axum::Router::new()
.route(maestro_protocol::routes::INFO, get(info))
.route(maestro_protocol::routes::WORKTREES, get(worktrees))
.route("/api/worktrees/:id", delete(reclaim));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
let mut app = make_app_at(&base);
let effects = app.dispatch(Action::ShowWorktrees);
app.perform(effects).await;
let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv())
.await
.expect("load completed")
.expect("channel open");
app.apply_draft_update(update).await;
assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees));
assert_eq!(app.worktrees.items.len(), 1);
assert_eq!(app.worktrees.items[0].branch, "feat/login");
assert_eq!(app.worktrees.home_dir, "/home/naps");
// Reclaiming drops the entry from the open view.
app.submit_reclaim(Uuid::from_u128(42)).await;
assert!(app.worktrees.items.is_empty());
}
}
+5
View File
@@ -50,6 +50,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
// thing with every default resolved and no form.
("S", "FastForwardSpawn"),
("x", "KillHighlighted"),
("w", "ShowWorktrees"),
// Tabs
("ctrl-t", "NewTab"),
("g t", "NextTab"),
@@ -166,6 +167,10 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "S"),
Some(Action::FastForwardSpawn),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "w"),
Some(Action::ShowWorktrees),
);
}
#[test]
+1 -1
View File
@@ -1186,7 +1186,7 @@ fn draw_text_field(
/// `~` abbreviation plus leading-segment collapsing, matching the web UI's
/// `abbreviateCwd` + `abbreviateFolderName`: `/home/u/tea/Maestro` renders as
/// `/h/u/t/Maestro` given home `/home/u`.
fn abbreviate_path(path: &Path, home: &str) -> String {
pub(crate) fn abbreviate_path(path: &Path, home: &str) -> String {
let raw = path.to_string_lossy();
let abbreviated = if !home.is_empty() {
if raw == home {
+1
View File
@@ -22,6 +22,7 @@ pub mod state;
pub mod tabs;
pub mod terminal;
pub mod text_input;
pub mod worktrees;
use std::path::PathBuf;
use std::time::Duration;
+1 -1
View File
@@ -82,7 +82,7 @@ impl Toast {
}
}
fn centered(parent: Rect, w: u16, h: u16) -> Rect {
pub(crate) fn centered(parent: Rect, w: u16, h: u16) -> Rect {
let w = w.min(parent.width);
let h = h.min(parent.height);
Rect {
+20
View File
@@ -15,6 +15,7 @@ use crate::pane::Pane;
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind};
use crate::tabs::TabBar;
use crate::worktrees::{ReclaimConfirm, WorktreesView};
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
/// coordinate to one of these via [`RegionMap::region_at`].
@@ -67,6 +68,8 @@ pub struct RenderInputs<'a> {
pub draft: Option<&'a crate::draft::DraftPane>,
pub state: &'a AppState,
pub help: &'a HelpOverlay,
/// Retained-worktree entries for the worktrees view (#85).
pub worktrees: Option<&'a WorktreesView>,
}
/// Draw one frame. Returns the `RegionMap` the input router will consult for
@@ -153,6 +156,22 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
inputs.help.render(f, 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)) => {
let t = Toast { text: text.clone() };
t.render(f, area);
@@ -298,6 +317,7 @@ mod tests {
draft: Some(draft),
state: &state,
help: &help,
worktrees: None,
};
let backend = ratatui::backend::TestBackend::new(80, 24);
+11
View File
@@ -50,6 +50,17 @@ pub enum OverlayKind {
KillConfirm(Uuid),
Help,
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)]
+259
View File
@@ -0,0 +1,259 @@
//! Retained-worktrees view (#85): an overlay listing every retained worktree
//! — branch, abbreviated path, reason, what is in the way — with j/k to move,
//! `r` to reclaim behind a confirmation, esc to close.
//!
//! The in-the-way summary comes straight from [`InTheWay`]'s `Display` impl,
//! the same source the kill refusal and the web client use, so no two clients
//! can describe the same tree differently.
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;
use maestro_protocol::{RetainedReason, RetainedWorktree};
use crate::draft::abbreviate_path;
/// The open worktrees overlay: its entries and which one is highlighted.
#[derive(Debug, Clone, Default)]
pub struct WorktreesView {
pub items: Vec<RetainedWorktree>,
pub highlight: usize,
/// Daemon-reported home dir, for `~` abbreviation of paths.
pub home_dir: String,
}
impl WorktreesView {
/// Replace the listing and keep the highlight pointing at something real.
pub fn apply(&mut self, items: Vec<RetainedWorktree>) {
self.items = items;
if !self.items.is_empty() {
self.highlight = self.highlight.min(self.items.len() - 1);
} else {
self.highlight = 0;
}
}
pub fn remove(&mut self, id: uuid::Uuid) {
let idx = self.items.iter().position(|w| w.id == id);
self.items.retain(|w| w.id != id);
if let Some(i) = idx {
self.highlight = self.highlight.min(self.items.len().saturating_sub(1));
let _ = i;
}
}
/// Move the highlight by `delta` (positive = down). Wraps. No-op on empty.
pub fn move_highlight(&mut self, delta: i32) {
if self.items.is_empty() {
return;
}
let len = self.items.len() as i32;
let next = (self.highlight as i32 + delta).rem_euclid(len);
self.highlight = next as usize;
}
pub fn selected(&self) -> Option<&RetainedWorktree> {
self.items.get(self.highlight)
}
pub fn render(&self, f: &mut Frame, parent: Rect) {
let h = (self.items.len() as u16 + 6).clamp(4, parent.height);
let area = super::overlays::centered(parent, parent.width.saturating_sub(4), h);
f.render_widget(Clear, area);
let block = Block::default()
.title(" retained worktrees ")
.borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let mut lines: Vec<Line> = Vec::new();
if self.items.is_empty() {
// A drained store is the normal state — say so plainly, not like
// something went wrong.
lines.push(Line::from("no retained worktrees"));
} else {
lines.push(Line::from(format!(
" {:<26} {:<34} {:<24} IN THE WAY",
"BRANCH", "PATH", "REASON"
)));
for (i, w) in self.items.iter().enumerate() {
let marker = if i == self.highlight { ">" } else { " " };
lines.push(Line::from(format!(
"{marker} {:<25} {:<34} {:<24} {}",
w.branch,
abbreviate_path(&w.path, &self.home_dir),
reason_label(w.reason),
w.in_the_way,
)));
}
}
lines.push(Line::from(""));
lines.push(Line::from("j/k: select r: reclaim esc: close"));
f.render_widget(Paragraph::new(lines), inner);
}
}
/// The confirmation guarding reclaim, mirroring [`super::overlays::KillConfirm`].
/// It names the branch and says out loud what reclaim does not take with it —
/// docs/WORKTREES.md: "reclaim" otherwise sounds like it takes the commits too.
#[derive(Debug, Clone)]
pub struct ReclaimConfirm {
pub branch: String,
/// [`maestro_protocol::InTheWay`] rendered via its `Display` impl.
pub blockers: String,
}
impl ReclaimConfirm {
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = super::overlays::centered(parent, 70, 7);
f.render_widget(Clear, area);
let block = Block::default().title(" reclaim ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(
ratatui::widgets::Paragraph::new(vec![
Line::from(format!("Reclaim worktree {}?", self.branch)),
Line::from(format!("Currently in the way: {}", self.blockers)),
Line::from("The directory is removed; the branch is never deleted."),
Line::from(""),
Line::from("[y] yes [n] no"),
]),
inner,
);
}
}
fn reason_label(reason: RetainedReason) -> &'static str {
match reason {
RetainedReason::ForceKilled => "force-killed",
RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work",
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use maestro_protocol::{InTheWay, RetainedReason};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn render_to_string<F>(w: u16, h: u16, draw: F) -> String
where
F: FnOnce(&mut Frame),
{
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(draw).unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree {
RetainedWorktree {
id: uuid::Uuid::from_u128(id),
path: path.into(),
base_repo: "/home/naps/tea/maestro".into(),
branch: branch.into(),
reason: RetainedReason::ForceKilled,
retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
in_the_way: InTheWay::default(),
}
}
#[test]
fn view_lists_branch_path_reason_and_what_is_in_the_way() {
let mut view = WorktreesView {
home_dir: "/home/naps".into(),
..Default::default()
};
let mut wt = worktree(
"feat/login",
"/home/naps/tea/maestro/worktrees/feat/login",
1,
);
wt.in_the_way = InTheWay {
uncommitted_files: 2,
untracked_files: 1,
unpushed_commits: 3,
};
view.apply(vec![wt]);
let rendered = render_to_string(160, 20, |f| view.render(f, f.area()));
assert!(rendered.contains("feat/login"), "{rendered}");
// Path abbreviated against the daemon's home dir, same helper as the
// draft pane's project list.
assert!(rendered.contains("~/t/m/w/f/login"), "{rendered}");
assert!(rendered.contains("force-killed"), "{rendered}");
// Phrasing comes from InTheWay's Display impl — shared with the kill
// refusal and the web client.
assert!(
rendered.contains("2 uncommitted files, 1 untracked file, 3 unpushed commits"),
"{rendered}"
);
}
#[test]
fn empty_store_looks_like_a_normal_state_not_an_error() {
let view = WorktreesView::default();
let rendered = render_to_string(80, 12, |f| view.render(f, f.area()));
assert!(rendered.contains("no retained worktrees"), "{rendered}");
assert!(!rendered.contains("error"), "{rendered}");
assert!(!rendered.contains("failed"), "{rendered}");
}
#[test]
fn highlight_moves_and_wraps() {
let mut view = WorktreesView::default();
view.apply(vec![
worktree("a", "/r/a", 1),
worktree("b", "/r/b", 2),
worktree("c", "/r/c", 3),
]);
assert_eq!(view.selected().unwrap().branch, "a");
view.move_highlight(1);
assert_eq!(view.selected().unwrap().branch, "b");
view.move_highlight(-1);
assert_eq!(view.selected().unwrap().branch, "a");
view.move_highlight(-1);
// Wraps backward to the last entry.
assert_eq!(view.selected().unwrap().branch, "c");
view.move_highlight(1);
assert_eq!(view.selected().unwrap().branch, "a");
}
#[test]
fn apply_clamps_a_stale_highlight() {
let mut view = WorktreesView::default();
view.apply(vec![worktree("a", "/r/a", 1), worktree("b", "/r/b", 2)]);
view.move_highlight(1);
view.apply(vec![worktree("c", "/r/c", 3)]);
assert_eq!(view.selected().unwrap().branch, "c");
}
#[test]
fn reclaim_confirm_names_the_branch_and_says_it_survives() {
let confirm = ReclaimConfirm {
branch: "feat/login".into(),
blockers: "2 uncommitted files".into(),
};
let rendered = render_to_string(80, 14, |f| confirm.render(f, f.area()));
assert!(
rendered.contains("Reclaim worktree feat/login?"),
"{rendered}"
);
assert!(rendered.contains("2 uncommitted files"), "{rendered}");
assert!(rendered.contains("never deleted"), "{rendered}");
assert!(rendered.contains("yes"), "{rendered}");
assert!(rendered.contains("no"), "{rendered}");
}
}
+11
View File
@@ -97,6 +97,17 @@ Nobody is waiting for an answer here, so there is nothing to refuse; the choice
only between removing and retaining. It uses the same predicate as the kill, from
the same place, so the two paths cannot drift apart.
The session itself stays in the catalog marked dead until it is dismissed — the
replay buffer of a crashed agent outlives it — but its worktree is settled at
once. Once settled, the session drops its worktree reference, so dismissing it
afterwards neither refuses (there is no guard left to trip) nor records a second
store entry.
If settling is impossible — the probe fails, or the store cannot take the entry —
nothing is destroyed. The tree stays on disk *and* stays attached to the dead
record, where dismissing it later still goes through `--force`'s
retain-before-teardown path. Losing the store must never mean losing the work.
## The retained store
A `state_dir`-backed record of worktrees the daemon created that no longer have a
+19
View File
@@ -9,6 +9,7 @@ import type {
NameBatch,
Projects,
RecentDirs,
RetainedWorktree,
Session,
Uuid,
} from "./types.js";
@@ -97,4 +98,22 @@ export async function fetchBranches(path: string): Promise<Branches> {
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";
+9
View File
@@ -180,6 +180,15 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
renderStatus();
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
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
if (import.meta.env.DEV) {
+37
View File
@@ -7,7 +7,9 @@ import {
createSession,
fetchNames,
getDaemonInfo,
listWorktrees,
projects as fetchProjects,
reclaimWorktree,
} from "./api.js";
import {
buildFastForwardRequest,
@@ -22,6 +24,7 @@ import { loadLayout, saveLayout } from "./persistence.js";
import { OPEN_SPAWN_EVENT } from "./catalog.js";
import { initTheme } from "./theme.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 "./styles.css";
@@ -345,16 +348,47 @@ document.addEventListener("keydown", (e) => {
type Route =
| { kind: "catalog" }
| { kind: "attach"; sessionId: string }
| { kind: "worktrees" }
| { kind: "not-found"; raw: string };
function parseRoute(): Route {
const hash = location.hash.replace(/^#/, "");
if (hash === "" || hash === "/") return { kind: "catalog" };
if (hash === "/worktrees") return { kind: "worktrees" };
const m = hash.match(/^\/attach\/([^/?#]+)$/);
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
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 {
const route = parseRoute();
switch (route.kind) {
@@ -376,6 +410,9 @@ function dispatch(): void {
// tears panes down.
showMultiView();
return;
case "worktrees":
showWorktrees();
return;
}
}
+6
View File
@@ -13,6 +13,7 @@ export const PROJECTS = "/api/projects";
export const GITROOT = "/api/gitroot";
export const NAMES = "/api/names";
export const BRANCHES = "/api/branches";
export const WORKTREES = "/api/worktrees";
export const EVENTS = "/api/events";
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
@@ -29,6 +30,11 @@ export function attach(id: Uuid): string {
return `${API_PREFIX}/attach/${encodeURIComponent(id)}`;
}
/** `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. */
export function withPathQuery(route: string, value: string): string {
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
+145
View File
@@ -2225,3 +2225,148 @@ textarea {
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);
}
+48
View File
@@ -133,6 +133,54 @@ export interface BranchInfo {
/** `GET /api/branches?path=` — the local branches of one repository (#55). */
export type Branches = BranchInfo[];
/** Why a retained worktree outlived its session (#80). New values are
* additive; unknown reasons render generically. */
export type RetainedReason = "force-killed" | "exited-with-unsaved-work";
/** What currently blocks an unforced kill of a worktree's session (#80),
* recomputed by the daemon on every read. All counts default to 0 on the
* wire for older daemons. */
export interface InTheWay {
uncommitted_files?: number;
untracked_files?: number;
unpushed_commits?: number;
}
/** One entry of `GET /api/worktrees` (#80): a retained worktree with its
* in-the-way state as of this read. Reclaiming it removes the directory but
* never the branch. Mirrors `maestro_protocol::RetainedWorktree`. */
export interface RetainedWorktree {
id: Uuid;
path: string;
base_repo: string;
branch: string;
reason: RetainedReason;
retained_at: IsoTimestamp;
in_the_way?: InTheWay;
}
/** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by
* the kill refusal, the TUI and this client, so no two clients can describe
* the same tree differently. Zero parts are omitted; all-zero renders
* "no unsaved work". */
export function formatInTheWay(w: InTheWay): string {
const parts: string[] = [];
const push = (n: number | undefined, unit: string): void => {
if (!n || n <= 0) return;
parts.push(n === 1 ? `1 ${unit}` : `${n} ${unit}s`);
};
push(w.uncommitted_files, "uncommitted file");
push(w.untracked_files, "untracked file");
push(w.unpushed_commits, "unpushed commit");
return parts.length > 0 ? parts.join(", ") : "no unsaved work";
}
/** Same reason labels the CLI table and the TUI overlay use; unknown future
* reasons render their raw wire value. */
export function reasonLabel(reason: RetainedReason): string {
return reason;
}
export interface CreateSessionRequest {
cwd: string;
command: string;
+114
View File
@@ -0,0 +1,114 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { createWorktreesView } from "./worktreesView.js";
import type { RetainedWorktree } from "./types.js";
function worktree(overrides: Partial<RetainedWorktree>): RetainedWorktree {
return {
id: "11111111-2222-3333-4444-555555555555",
path: "/home/naps/tea/maestro/worktrees/feat/login",
base_repo: "/home/naps/tea/maestro",
branch: "feat/login",
reason: "force-killed",
retained_at: "2026-08-24T10:00:00Z",
in_the_way: {
uncommitted_files: 2,
untracked_files: 1,
unpushed_commits: 3,
},
...overrides,
};
}
/** Mount a view over a scripted store and return (el, reclaimed-ids). */
function mount(items: RetainedWorktree[], homeDir = "/home/naps") {
const reclaimed: string[] = [];
const view = createWorktreesView({
load: async () => items,
reclaim: async (id) => {
reclaimed.push(id);
items.splice(
items.findIndex((w) => w.id === id),
1,
);
},
homeDir: () => homeDir,
});
document.body.appendChild(view.el);
return { view, reclaimed };
}
describe("retained-worktrees view (#84)", () => {
it("lists branch, abbreviated path, reason and what is in the way", async () => {
const { view } = mount([
worktree({
path: "/home/naps/tea/maestro/worktrees/feat/login",
}),
]);
await view.refresh();
const text = view.el.textContent ?? "";
expect(text).toContain("feat/login");
// Sidebar-style abbreviation of the path against the daemon's home dir,
// full path kept in the tooltip.
expect(text).toContain("t/m/w/f/login");
const pathEl = view.el.querySelector<HTMLElement>(".wt-path")!;
expect(pathEl.title).toBe("/home/naps/tea/maestro/worktrees/feat/login");
// Same reason labels as `maestro worktrees list` and the TUI overlay.
expect(text).toContain("force-killed");
// Same phrasing as InTheWay's Display impl — shared with the kill refusal.
expect(text).toContain("2 uncommitted files, 1 untracked file, 3 unpushed commits");
});
it("says a tree whose work has since been pushed is clear", async () => {
const { view } = mount([worktree({ in_the_way: {} })]);
await view.refresh();
expect(view.el.textContent).toContain("no unsaved work");
});
it("renders an empty store as a normal state, not an error", async () => {
const { view } = mount([]);
await view.refresh();
const text = view.el.textContent ?? "";
expect(text).toContain("no retained worktrees");
expect(text.toLowerCase()).not.toContain("error");
expect(text.toLowerCase()).not.toContain("failed");
});
it("guards reclaim with a confirmation that names what is lost and that the branch survives", async () => {
const wt = worktree({ branch: "feat/login" });
const { view, reclaimed } = mount([wt]);
await view.refresh();
view.el.querySelector<HTMLButtonElement>(".wt-reclaim-btn")!.click();
const confirmText = view.el.querySelector<HTMLElement>(".wt-confirm")!.textContent ?? "";
expect(confirmText).toContain("Reclaim worktree feat/login?");
// Names what will be lost before it happens…
expect(confirmText).toContain(
"2 uncommitted files, 1 untracked file, 3 unpushed commits",
);
// …and says reclaim does not take the commits too.
expect(confirmText).toContain("never deleted");
view.el.querySelector<HTMLButtonElement>(".wt-confirm-cancel")!.click();
expect(reclaimed).toEqual([]);
view.el.querySelector<HTMLButtonElement>(".wt-reclaim-btn")!.click();
view.el.querySelector<HTMLButtonElement>(".wt-confirm-go")!.click();
await Promise.resolve();
await Promise.resolve();
expect(reclaimed).toEqual(["11111111-2222-3333-4444-555555555555"]);
// The row is gone once the refresh lands.
expect(view.el.textContent).toContain("no retained worktrees");
});
it("sorts oldest-retained first, like every other client", async () => {
const { view } = mount([
worktree({ branch: "newer", retained_at: "2026-08-24T12:00:00Z" }),
worktree({ branch: "older", retained_at: "2026-08-24T08:00:00Z" }),
]);
await view.refresh();
const branches = [...view.el.querySelectorAll(".wt-branch")].map((n) => n.textContent);
expect(branches).toEqual(["older", "newer"]);
});
});
+166
View File
@@ -0,0 +1,166 @@
// Retained-worktrees view (#84): every worktree the daemon is holding that no
// session uses, listed from `GET /api/worktrees` with a reclaim action.
//
// Wording is shared with the CLI (`maestro worktrees list`) and the TUI
// overlay: same reason labels, same in-the-way phrasing via
// `formatInTheWay` (the mirror of `InTheWay`'s Display impl). Reclaim reads
// as what it is — destructive — naming what will be lost before it happens
// and saying out loud that the branch survives (docs/WORKTREES.md).
import { escapeHtml } from "./html.js";
import {
abbreviateCwd,
abbreviateFolderName,
formatInTheWay,
reasonLabel,
type RetainedWorktree,
type Uuid,
} from "./types.js";
export interface WorktreesDeps {
/** `GET /api/worktrees`. */
load(): Promise<RetainedWorktree[]>;
/** `DELETE /api/worktrees/:id`. */
reclaim(id: Uuid): Promise<void>;
/** The daemon's home dir, for `~` abbreviation of paths. */
homeDir(): string | undefined;
}
export interface WorktreesViewHandle {
el: HTMLElement;
/** Re-fetch the store and re-render. Safe to call repeatedly. */
refresh(): Promise<void>;
dispose(): void;
}
/** Oldest-retained first, matching `maestro worktrees list`'s ordering. */
function sorted(items: RetainedWorktree[]): RetainedWorktree[] {
return [...items].sort(
(a, b) => Date.parse(a.retained_at) - Date.parse(b.retained_at),
);
}
export function createWorktreesView(deps: WorktreesDeps): WorktreesViewHandle {
const el = document.createElement("section");
el.className = "worktrees";
el.innerHTML = `
<header class="wt-head">
<h2 class="wt-title">retained worktrees</h2>
<p class="wt-sub">
Kept when a session ended with unsaved work. Reclaiming removes the
directory only; branches are never deleted.
</p>
</header>
<div class="wt-list"></div>
<div class="wt-confirm" role="alertdialog" aria-modal="true" hidden></div>
`;
const listHost = el.querySelector<HTMLElement>(".wt-list")!;
const confirmEl = el.querySelector<HTMLElement>(".wt-confirm")!;
let pendingId: string | null = null;
const render = (items: RetainedWorktree[]) => {
listHost.replaceChildren();
if (items.length === 0) {
// A drained store is the normal state — say so plainly, not like
// something went wrong.
const empty = document.createElement("div");
empty.className = "empty";
empty.innerHTML = `
<div class="empty-glyph" aria-hidden="true">⌂</div>
<div class="empty-title">no retained worktrees</div>
<p class="empty-hint">Nothing is being held. Worktrees kept after a force-kill or an unsaved exit show up here.</p>`;
listHost.appendChild(empty);
return;
}
const home = deps.homeDir();
for (const w of sorted(items)) {
listHost.appendChild(renderRow(w, home));
}
};
const renderRow = (w: RetainedWorktree, home: string | undefined): HTMLElement => {
const row = document.createElement("div");
row.className = "wt-row";
const clear = formatInTheWay(w.in_the_way ?? {}) === "no unsaved work";
row.innerHTML = `
<span class="wt-branch">${escapeHtml(w.branch)}</span>
<span class="wt-path" title="${escapeHtml(w.path)}">${abbreviateFolderName(abbreviateCwd(w.path, home))}</span>
<span class="wt-reason">${escapeHtml(reasonLabel(w.reason))}</span>
<span class="wt-way"${clear ? ' data-clear="true"' : ""}>${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}</span>
<button class="btn wt-reclaim-btn" type="button">Reclaim</button>
`;
row.querySelector(".wt-reclaim-btn")!.addEventListener("click", () => {
showConfirm(w);
});
return row;
};
const showConfirm = (w: RetainedWorktree) => {
pendingId = w.id;
// Mirrors the TUI's ReclaimConfirm line for line: what goes away, what is
// in the way right now, and that the branch survives reclaim.
confirmEl.innerHTML = `
<div class="wt-confirm-panel">
<p class="wt-confirm-q">Reclaim worktree ${escapeHtml(w.branch)}?</p>
<p class="wt-confirm-way">Currently in the way: ${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}</p>
<p class="wt-confirm-note">The directory is removed; the branch is never deleted.</p>
<div class="wt-confirm-actions">
<button class="btn btn-danger wt-confirm-go" type="button">Reclaim</button>
<button class="btn btn-ghost wt-confirm-cancel" type="button">Cancel</button>
</div>
</div>`;
confirmEl.hidden = false;
confirmEl.querySelector<HTMLButtonElement>(".wt-confirm-go")!.focus();
};
const hideConfirm = () => {
pendingId = null;
confirmEl.hidden = true;
confirmEl.replaceChildren();
};
confirmEl.addEventListener("click", async (e) => {
const target = e.target as HTMLElement;
if (target.closest(".wt-confirm-cancel")) {
hideConfirm();
return;
}
if (!target.closest(".wt-confirm-go") || !pendingId) return;
const id = pendingId;
hideConfirm();
try {
await deps.reclaim(id);
} catch (err) {
alert(`Failed to reclaim: ${(err as Error).message}`);
}
await refresh();
});
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !confirmEl.hidden) hideConfirm();
};
document.addEventListener("keydown", onKey);
const refresh = async () => {
try {
render(await deps.load());
} catch (err) {
listHost.replaceChildren();
const fail = document.createElement("div");
fail.className = "empty";
fail.innerHTML = `<div class="empty-title">Couldn't load retained worktrees</div><p class="empty-hint"></p>`;
fail.querySelector(".empty-hint")!.textContent = (err as Error).message;
listHost.appendChild(fail);
}
};
return {
el,
refresh,
dispose() {
document.removeEventListener("keydown", onKey);
el.remove();
},
};
}