Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86443d195e | ||
|
|
abaa498b9f | ||
|
|
62581d9f85 | ||
|
|
267f8a6712 | ||
|
|
a575b8bef1 | ||
|
|
e9e0136a89 | ||
|
|
4921f62796 | ||
|
|
898c3cc763 | ||
|
|
44b9745e8c | ||
|
|
cc8af94254 | ||
|
|
50a9d35f62 | ||
|
|
a9ef3ff97b | ||
|
|
132b606830 |
@@ -41,6 +41,36 @@ pub enum Command {
|
|||||||
Attach(AttachArgs),
|
Attach(AttachArgs),
|
||||||
/// Kill a session by ID.
|
/// Kill a session by ID.
|
||||||
Kill(KillArgs),
|
Kill(KillArgs),
|
||||||
|
/// List retained worktrees, or reclaim one by ID (#86).
|
||||||
|
Worktrees(WorktreesArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `maestro worktrees <list|reclaim>`
|
||||||
|
#[derive(Debug, clap::Args)]
|
||||||
|
pub struct WorktreesArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: WorktreesCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum WorktreesCommand {
|
||||||
|
/// List retained worktrees and what is in the way of reclaiming each.
|
||||||
|
List(ListWorktreesArgs),
|
||||||
|
/// Reclaim one retained worktree: removes the directory, never the branch.
|
||||||
|
Reclaim(ReclaimArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, clap::Args)]
|
||||||
|
pub struct ListWorktreesArgs {
|
||||||
|
/// Output as JSON instead of a table.
|
||||||
|
#[arg(long)]
|
||||||
|
pub json: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, clap::Args)]
|
||||||
|
pub struct ReclaimArgs {
|
||||||
|
/// Retained worktree ID to reclaim. Required — there is no reclaim-all.
|
||||||
|
pub id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, clap::Args)]
|
#[derive(Debug, clap::Args)]
|
||||||
@@ -241,4 +271,56 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(err.to_string().contains("KEY=VALUE"));
|
assert!(err.to_string().contains("KEY=VALUE"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_worktrees_list() {
|
||||||
|
let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap();
|
||||||
|
match cli.command {
|
||||||
|
Command::Worktrees(w) => match w.command {
|
||||||
|
WorktreesCommand::List(a) => assert!(a.json),
|
||||||
|
_ => panic!("wrong worktrees subcommand"),
|
||||||
|
},
|
||||||
|
_ => panic!("wrong subcommand"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_worktrees_reclaim() {
|
||||||
|
let id = "01234567-89ab-cdef-0123-456789abcdef";
|
||||||
|
let cli = Cli::try_parse_from(["maestro", "worktrees", "reclaim", id]).unwrap();
|
||||||
|
match cli.command {
|
||||||
|
Command::Worktrees(w) => match w.command {
|
||||||
|
WorktreesCommand::Reclaim(a) => assert_eq!(a.id.to_string(), id),
|
||||||
|
_ => panic!("wrong worktrees subcommand"),
|
||||||
|
},
|
||||||
|
_ => panic!("wrong subcommand"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contract rule: reclaim refuses to run without an explicit target.
|
||||||
|
/// There is no reclaim-everything convenience.
|
||||||
|
#[test]
|
||||||
|
fn reclaim_requires_an_explicit_target() {
|
||||||
|
let err = Cli::try_parse_from(["maestro", "worktrees", "reclaim"]).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err.kind(),
|
||||||
|
clap::error::ErrorKind::InvalidSubcommand
|
||||||
|
| clap::error::ErrorKind::MissingRequiredArgument
|
||||||
|
),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worktrees_without_subcommand_shows_help() {
|
||||||
|
let err = Cli::try_parse_from(["maestro", "worktrees"]).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err.kind(),
|
||||||
|
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
|
||||||
|
),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ use std::path::{Path, PathBuf};
|
|||||||
use maestro_protocol::DaemonClient;
|
use maestro_protocol::DaemonClient;
|
||||||
|
|
||||||
use crate::attach::{parse_detach_key, run_attach_url};
|
use crate::attach::{parse_detach_key, run_attach_url};
|
||||||
use crate::cli::Command;
|
use crate::cli::{Command, WorktreesCommand};
|
||||||
use crate::sessions::{run_kill, run_list, run_spawn};
|
use crate::sessions::{run_kill, run_list, run_spawn};
|
||||||
|
use crate::worktrees::{run_list as run_worktree_list, run_reclaim as run_worktree_reclaim};
|
||||||
use maestro_config::{default_config_path, MaestroConfigFile};
|
use maestro_config::{default_config_path, MaestroConfigFile};
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -64,6 +65,26 @@ pub async fn dispatch(
|
|||||||
run_kill(&daemon, &args).await?;
|
run_kill(&daemon, &args).await?;
|
||||||
println!("killed {}", args.session_id);
|
println!("killed {}", args.session_id);
|
||||||
}
|
}
|
||||||
|
Command::Worktrees(args) => match args.command {
|
||||||
|
WorktreesCommand::List(list_args) => {
|
||||||
|
let mut stdout = std::io::stdout().lock();
|
||||||
|
run_worktree_list(&daemon, &list_args, &mut stdout).await?;
|
||||||
|
}
|
||||||
|
WorktreesCommand::Reclaim(reclaim_args) => {
|
||||||
|
// The daemon removes the directory and the store entry; the
|
||||||
|
// branch is never deleted — worth saying out loud (#86).
|
||||||
|
let entry = run_worktree_reclaim(&daemon, &reclaim_args).await?;
|
||||||
|
match entry {
|
||||||
|
Some(wt) => println!(
|
||||||
|
"reclaimed {} ({})\nbranch '{}' kept",
|
||||||
|
wt.id,
|
||||||
|
wt.path.display(),
|
||||||
|
wt.branch
|
||||||
|
),
|
||||||
|
None => println!("reclaimed {}\nbranch kept", reclaim_args.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ pub mod attach;
|
|||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod dispatch;
|
pub mod dispatch;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
|
pub mod worktrees;
|
||||||
|
|
||||||
pub use dispatch::{dispatch, CliError};
|
pub use dispatch::{dispatch, CliError};
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
|
|
||||||
/// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web
|
/// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web
|
||||||
/// UI's abbreviation so CLI and browser show the same directory shorthand.
|
/// UI's abbreviation so CLI and browser show the same directory shorthand.
|
||||||
fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
|
pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
|
||||||
let p = path.to_string_lossy();
|
let p = path.to_string_lossy();
|
||||||
if !home.is_empty() {
|
if !home.is_empty() {
|
||||||
if p == home {
|
if p == home {
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
//! `maestro worktrees list / reclaim` — the retained-store surface (#86).
|
||||||
|
//!
|
||||||
|
//! `reclaim` removes one directory; it never deletes the branch, which is
|
||||||
|
//! said out loud in the output because "reclaim" otherwise sounds like it
|
||||||
|
//! takes the commits too.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
|
||||||
|
|
||||||
|
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
|
||||||
|
use crate::sessions::abbreviate_dir;
|
||||||
|
|
||||||
|
/// `maestro worktrees list`
|
||||||
|
pub async fn run_list(
|
||||||
|
client: &DaemonClient,
|
||||||
|
args: &ListWorktreesArgs,
|
||||||
|
out: &mut impl Write,
|
||||||
|
) -> Result<(), ClientError> {
|
||||||
|
let worktrees = client.list_worktrees().await?;
|
||||||
|
let info = client.daemon_info().await.unwrap_or_default();
|
||||||
|
|
||||||
|
if args.json {
|
||||||
|
let body = serde_json::json!({ "worktrees": worktrees });
|
||||||
|
writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
print_worktree_table(out, &worktrees, &info);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `maestro worktrees reclaim <id>`
|
||||||
|
///
|
||||||
|
/// Returns the entry as listed just before the reclaim, when it was found in
|
||||||
|
/// the listing, so the confirmation can name what was removed.
|
||||||
|
pub async fn run_reclaim(
|
||||||
|
client: &DaemonClient,
|
||||||
|
args: &ReclaimArgs,
|
||||||
|
) -> Result<Option<RetainedWorktree>, ClientError> {
|
||||||
|
let listed = client
|
||||||
|
.list_worktrees()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.find(|w| w.id == args.id);
|
||||||
|
client.reclaim_worktree(args.id).await?;
|
||||||
|
Ok(listed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_worktree_table(out: &mut impl Write, worktrees: &[RetainedWorktree], info: &DaemonInfo) {
|
||||||
|
if worktrees.is_empty() {
|
||||||
|
writeln!(out, "no retained worktrees").ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sorted: Vec<&RetainedWorktree> = worktrees.iter().collect();
|
||||||
|
sorted.sort_by_key(|w| w.retained_at);
|
||||||
|
|
||||||
|
if !info.hostname.is_empty() {
|
||||||
|
let warn = if maestro_protocol::is_compatible(&info.version) {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
" (protocol mismatch)"
|
||||||
|
};
|
||||||
|
let version = if info.version.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" maestro {}{warn}", info.version)
|
||||||
|
};
|
||||||
|
writeln!(out, "\n{}{version}", info.hostname).ok();
|
||||||
|
}
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" {:<36} {:<24} {:<28} {:<24} IN THE WAY",
|
||||||
|
"ID", "BRANCH", "PATH", "REASON"
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
for w in sorted {
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" {:<36} {:<24} {:<28} {:<24} {}",
|
||||||
|
w.id,
|
||||||
|
w.branch,
|
||||||
|
abbreviate_dir(&w.path, &info.home_dir),
|
||||||
|
reason_label(w.reason),
|
||||||
|
blocker_label(&w.in_the_way),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
// The branch survives every reclaim — see docs/WORKTREES.md.
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"\nreclaim removes the directory only; branches are never deleted"
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reason_label(reason: maestro_protocol::RetainedReason) -> &'static str {
|
||||||
|
match reason {
|
||||||
|
maestro_protocol::RetainedReason::ForceKilled => "force-killed",
|
||||||
|
maestro_protocol::RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same breakdown that blocks an unforced kill, as words: what is still
|
||||||
|
/// in the way, or `clear` when the work has since been committed and pushed.
|
||||||
|
fn blocker_label(in_the_way: &InTheWay) -> String {
|
||||||
|
if in_the_way.is_clear() {
|
||||||
|
return "clear".into();
|
||||||
|
}
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
if in_the_way.uncommitted_files > 0 {
|
||||||
|
parts.push(format!("{} uncommitted", in_the_way.uncommitted_files));
|
||||||
|
}
|
||||||
|
if in_the_way.untracked_files > 0 {
|
||||||
|
parts.push(format!("{} untracked", in_the_way.untracked_files));
|
||||||
|
}
|
||||||
|
if in_the_way.unpushed_commits > 0 {
|
||||||
|
parts.push(format!("{} unpushed", in_the_way.unpushed_commits));
|
||||||
|
}
|
||||||
|
parts.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
|
use maestro_protocol::{DaemonInfo, InTheWay, RetainedReason, RetainedWorktree};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree {
|
||||||
|
RetainedWorktree {
|
||||||
|
id: Uuid::from_u128(id),
|
||||||
|
path: PathBuf::from(path),
|
||||||
|
base_repo: PathBuf::from("/home/naps/maestro"),
|
||||||
|
branch: branch.into(),
|
||||||
|
reason: RetainedReason::ForceKilled,
|
||||||
|
retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
|
||||||
|
in_the_way: InTheWay::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info(home: &str) -> DaemonInfo {
|
||||||
|
DaemonInfo {
|
||||||
|
hostname: "naps-laptop".into(),
|
||||||
|
home_dir: home.into(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_table() {
|
||||||
|
let mut w = worktree("feat/login", "/home/naps/maestro/worktrees/feat/login", 1);
|
||||||
|
w.in_the_way = InTheWay {
|
||||||
|
uncommitted_files: 2,
|
||||||
|
untracked_files: 1,
|
||||||
|
unpushed_commits: 3,
|
||||||
|
};
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
super::print_worktree_table(&mut buf, &[w], &info("/home/naps"));
|
||||||
|
let s = String::from_utf8(buf).unwrap();
|
||||||
|
assert!(s.contains("naps-laptop"), "{s}");
|
||||||
|
assert!(s.contains("feat/login"), "{s}");
|
||||||
|
// Home dir collapses to ~ in the PATH column, like sessions' DIR.
|
||||||
|
assert!(s.contains("~/maestro/worktrees/feat/login"), "{s}");
|
||||||
|
assert!(s.contains("force-killed"), "{s}");
|
||||||
|
assert!(s.contains("2 uncommitted, 1 untracked, 3 unpushed"), "{s}");
|
||||||
|
assert!(s.contains("IN THE WAY"), "{s}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contract rule, visible to the operator: reclaiming a tree must not
|
||||||
|
/// throw away commits on its branch.
|
||||||
|
#[test]
|
||||||
|
fn says_the_branch_survives() {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
super::print_worktree_table(
|
||||||
|
&mut buf,
|
||||||
|
&[worktree("feat/x", "/repo/worktrees/feat/x", 1)],
|
||||||
|
&info(""),
|
||||||
|
);
|
||||||
|
let s = String::from_utf8(buf).unwrap();
|
||||||
|
assert!(s.contains("never deleted"), "{s}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_tree_says_clear() {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
super::print_worktree_table(
|
||||||
|
&mut buf,
|
||||||
|
&[worktree("main-ish", "/repo/worktrees/main-ish", 1)],
|
||||||
|
&info(""),
|
||||||
|
);
|
||||||
|
let s = String::from_utf8(buf).unwrap();
|
||||||
|
assert!(s.contains("clear"), "{s}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_store() {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
super::print_worktree_table(&mut buf, &[], &info(""));
|
||||||
|
assert_eq!(String::from_utf8(buf).unwrap(), "no retained worktrees\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
config.home_dir.clone(),
|
config.home_dir.clone(),
|
||||||
config.spawn_presets.clone(),
|
config.spawn_presets.clone(),
|
||||||
projects,
|
projects,
|
||||||
|
std::sync::Arc::new(crate::retained_store::RetainedStore::new(
|
||||||
|
config.retained_worktree_dir(),
|
||||||
|
)),
|
||||||
));
|
));
|
||||||
|
|
||||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||||
|
|||||||
@@ -103,6 +103,11 @@ 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
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ fn io_error(dir: &Path, e: &std::io::Error) -> ErrorInfo {
|
|||||||
ErrorInfo {
|
ErrorInfo {
|
||||||
code: code.into(),
|
code: code.into(),
|
||||||
message: format!("{message}: {}", dir.display()),
|
message: format!("{message}: {}", dir.display()),
|
||||||
|
unsaved_work: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ 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;
|
||||||
pub mod tmux;
|
pub mod tmux;
|
||||||
pub mod worktree;
|
pub mod worktree;
|
||||||
|
pub mod worktree_probe;
|
||||||
|
|
||||||
pub use config::{default_config_path, default_state_dir, load_config, DaemonConfig, EnvOverrides};
|
pub use config::{default_config_path, default_state_dir, load_config, DaemonConfig, EnvOverrides};
|
||||||
pub use session::{SessionError, SessionManager};
|
pub use session::{SessionError, SessionManager};
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ 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 {
|
||||||
@@ -50,6 +53,8 @@ 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))
|
||||||
@@ -94,6 +99,8 @@ fn session_error_status(code: &str) -> StatusCode {
|
|||||||
match code {
|
match code {
|
||||||
"not_found" => StatusCode::NOT_FOUND,
|
"not_found" => StatusCode::NOT_FOUND,
|
||||||
"invalid_env" => StatusCode::BAD_REQUEST,
|
"invalid_env" => StatusCode::BAD_REQUEST,
|
||||||
|
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||||
|
"kill_guarded" => StatusCode::CONFLICT,
|
||||||
_ => StatusCode::BAD_GATEWAY,
|
_ => StatusCode::BAD_GATEWAY,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,17 +175,124 @@ async fn create_session(
|
|||||||
/// `kill` already reports a missing session as `SessionError::NotFound`, which
|
/// `kill` already reports a missing session as `SessionError::NotFound`, which
|
||||||
/// maps to 404. Checking first would only add a window in which the liveness
|
/// maps to 404. Checking first would only add a window in which the liveness
|
||||||
/// watcher reaps the session between the two awaits, turning a 404 into a 502.
|
/// watcher reaps the session between the two awaits, turning a 404 into a 502.
|
||||||
|
///
|
||||||
|
/// The full error info is passed through rather than rebuilt from
|
||||||
|
/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work
|
||||||
|
/// breakdown clients render as "3 uncommitted files, 2 unpushed commits".
|
||||||
async fn kill_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
async fn kill_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
||||||
match s.manager.kill(id).await {
|
match s.manager.kill(id).await {
|
||||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let info = e.to_error_info();
|
let info = e.to_error_info();
|
||||||
error_response(
|
let status = session_error_status(&info.code);
|
||||||
session_error_status(&info.code),
|
(status, Json(ApiError { error: info })).into_response()
|
||||||
info.code.as_str(),
|
}
|
||||||
info.message.as_str(),
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/worktrees` (#80): every retained worktree, with the in-the-way
|
||||||
|
/// breakdown recomputed at read time — never stored, so a tree whose work has
|
||||||
|
/// since been committed or pushed says so. Entries whose directory somebody
|
||||||
|
/// removed by hand are pruned on the way through.
|
||||||
|
async fn list_worktrees(State(s): State<LocalApiState>) -> Response {
|
||||||
|
let retained = s.retained.clone();
|
||||||
|
let listed = tokio::task::spawn_blocking(move || {
|
||||||
|
retained.prune_missing()?;
|
||||||
|
retained
|
||||||
|
.load_all()?
|
||||||
|
.iter()
|
||||||
|
.map(|entry| {
|
||||||
|
// A tree git cannot read (permissions, corrupt checkout)
|
||||||
|
// still gets listed; it just reports nothing in the way.
|
||||||
|
let in_the_way = crate::worktree_probe::in_the_way(&entry.path).unwrap_or_default();
|
||||||
|
Ok(maestro_protocol::RetainedWorktree {
|
||||||
|
id: entry.id,
|
||||||
|
path: entry.path.clone(),
|
||||||
|
base_repo: entry.base_repo.clone(),
|
||||||
|
branch: entry.branch.clone(),
|
||||||
|
reason: entry.reason,
|
||||||
|
retained_at: entry.retained_at,
|
||||||
|
in_the_way,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, crate::retained_store::RetainedStoreError>>()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match listed {
|
||||||
|
Ok(Ok(worktrees)) => Json(worktrees).into_response(),
|
||||||
|
Ok(Err(e)) => error_response(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"store_failed",
|
||||||
|
&e.to_string(),
|
||||||
|
),
|
||||||
|
Err(e) => error_response(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"join_failed",
|
||||||
|
&e.to_string(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/worktrees/:id` (#80): reclaim one retained worktree. Removes
|
||||||
|
/// the directory and drops the store entry, without consulting the guard —
|
||||||
|
/// reclaim is the deliberate destructive act, chosen while looking at what is
|
||||||
|
/// in the way. The branch itself is never deleted; commits on it survive.
|
||||||
|
async fn reclaim_worktree(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
||||||
|
let found = {
|
||||||
|
let retained = s.retained.clone();
|
||||||
|
tokio::task::spawn_blocking(move || -> Result<
|
||||||
|
Option<crate::retained_store::RetainedWorktreeRecord>,
|
||||||
|
crate::retained_store::RetainedStoreError,
|
||||||
|
> {
|
||||||
|
Ok(retained.load_all()?.into_iter().find(|e| e.id == id))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
let entry = match found {
|
||||||
|
Ok(Ok(Some(entry))) => entry,
|
||||||
|
Ok(Ok(None)) => {
|
||||||
|
return error_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"not_found",
|
||||||
|
"no such retained worktree",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
return error_response(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"store_failed",
|
||||||
|
&e.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return error_response(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"join_failed",
|
||||||
|
&e.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let outcome = {
|
||||||
|
let retained = s.retained.clone();
|
||||||
|
tokio::task::spawn_blocking(move || -> Result<(), String> {
|
||||||
|
crate::worktree_probe::reclaim(&entry.base_repo, &entry.path)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
retained.remove(id).map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
match outcome {
|
||||||
|
Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
// The directory could not be removed: leave the entry so the
|
||||||
|
// operator still sees the tree and can retry.
|
||||||
|
Ok(Err(e)) => error_response(StatusCode::BAD_GATEWAY, "reclaim_failed", &e.to_string()),
|
||||||
|
Err(e) => error_response(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"join_failed",
|
||||||
|
&e.to_string(),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +311,7 @@ pub fn state_for(
|
|||||||
home_dir: impl Into<String>,
|
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,
|
||||||
@@ -204,6 +319,7 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
//! Store of retained worktrees (#78): worktrees the daemon created whose
|
||||||
|
//! session is gone.
|
||||||
|
//!
|
||||||
|
//! One JSON file per entry under `<state_dir>/retained-worktrees/`, written
|
||||||
|
//! the way `sidecar.rs` writes session records — atomic tmp+rename replace,
|
||||||
|
//! missing or malformed files tolerated on read. The store survives a daemon
|
||||||
|
//! restart; it is the recovery path for everything else in the worktree
|
||||||
|
//! lifecycle.
|
||||||
|
//!
|
||||||
|
//! It is not an ownership record: it must never be consulted to decide whether
|
||||||
|
//! a path may be adopted. Its only job is to remember what the daemon is
|
||||||
|
//! holding so it can be listed and reclaimed.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
/// Why a worktree outlived its session — [`maestro_protocol::RetainedReason`],
|
||||||
|
/// so the store and the wire payload cannot drift apart.
|
||||||
|
pub use maestro_protocol::RetainedReason;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum RetainedStoreError {
|
||||||
|
#[error("io: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("json: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct RetainedWorktreeRecord {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub base_repo: PathBuf,
|
||||||
|
pub branch: String,
|
||||||
|
pub reason: RetainedReason,
|
||||||
|
pub retained_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RetainedStore {
|
||||||
|
dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RetainedStore {
|
||||||
|
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||||
|
Self { dir: dir.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dir(&self) -> &Path {
|
||||||
|
&self.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_for(&self, id: Uuid) -> PathBuf {
|
||||||
|
self.dir.join(format!("{id}.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a retained worktree and return the entry that was written.
|
||||||
|
pub fn retain(
|
||||||
|
&self,
|
||||||
|
path: &Path,
|
||||||
|
base_repo: &Path,
|
||||||
|
branch: &str,
|
||||||
|
reason: RetainedReason,
|
||||||
|
) -> Result<RetainedWorktreeRecord, RetainedStoreError> {
|
||||||
|
let entry = RetainedWorktreeRecord {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
base_repo: base_repo.to_path_buf(),
|
||||||
|
branch: branch.to_string(),
|
||||||
|
reason,
|
||||||
|
retained_at: Utc::now(),
|
||||||
|
};
|
||||||
|
self.overwrite(&entry)?;
|
||||||
|
Ok(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically write `entry`. Creates the directory if it does not exist.
|
||||||
|
pub fn overwrite(&self, entry: &RetainedWorktreeRecord) -> Result<(), RetainedStoreError> {
|
||||||
|
std::fs::create_dir_all(&self.dir)?;
|
||||||
|
let final_path = self.path_for(entry.id);
|
||||||
|
let tmp = self.dir.join(format!("{}.json.tmp", entry.id));
|
||||||
|
let json = serde_json::to_vec_pretty(entry)?;
|
||||||
|
std::fs::write(&tmp, json)?;
|
||||||
|
std::fs::rename(&tmp, &final_path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop one entry. No-op when the id is unknown — reclaiming an entry the
|
||||||
|
/// store already lost (e.g. pruned) must not fail.
|
||||||
|
pub fn remove(&self, id: Uuid) -> Result<(), RetainedStoreError> {
|
||||||
|
match std::fs::remove_file(self.path_for(id)) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read every entry. Files that are missing or malformed are skipped
|
||||||
|
/// silently: a corrupt record must not hide the others, and an empty or
|
||||||
|
/// absent directory simply means nothing is retained.
|
||||||
|
pub fn load_all(&self) -> Result<Vec<RetainedWorktreeRecord>, RetainedStoreError> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let entries = match std::fs::read_dir(&self.dir) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(bytes) = std::fs::read(&path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Ok(worktree) = serde_json::from_slice::<RetainedWorktreeRecord>(&bytes) {
|
||||||
|
out.push(worktree);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by_key(|w| w.retained_at);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop entries whose worktree directory no longer exists — somebody
|
||||||
|
/// removed it by hand. Returns the ids that were pruned.
|
||||||
|
pub fn prune_missing(&self) -> Result<Vec<Uuid>, RetainedStoreError> {
|
||||||
|
let mut pruned = Vec::new();
|
||||||
|
for entry in self.load_all()? {
|
||||||
|
if !entry.path.exists() {
|
||||||
|
self.remove(entry.id)?;
|
||||||
|
pruned.push(entry.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(pruned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::Duration;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn tmp_dir() -> tempfile::TempDir {
|
||||||
|
tempfile::tempdir().expect("tempdir")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_path(root: &std::path::Path, name: &str) -> PathBuf {
|
||||||
|
let p = root.join(name);
|
||||||
|
std::fs::create_dir_all(&p).expect("mkdir");
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retain_then_load_round_trips_every_field() {
|
||||||
|
let dir = tmp_dir();
|
||||||
|
let store = RetainedStore::new(dir.path());
|
||||||
|
let wt = sample_path(dir.path(), "wt");
|
||||||
|
let repo = sample_path(dir.path(), "repo");
|
||||||
|
|
||||||
|
let entry = store
|
||||||
|
.retain(&wt, &repo, "feature/login", RetainedReason::ForceKilled)
|
||||||
|
.expect("retain");
|
||||||
|
|
||||||
|
let all = store.load_all().expect("load_all");
|
||||||
|
assert_eq!(all, vec![entry.clone()]);
|
||||||
|
assert_eq!(entry.path, wt);
|
||||||
|
assert_eq!(entry.base_repo, repo);
|
||||||
|
assert_eq!(entry.branch, "feature/login");
|
||||||
|
assert_eq!(entry.reason, RetainedReason::ForceKilled);
|
||||||
|
let age = Utc::now() - entry.retained_at;
|
||||||
|
assert!(age >= Duration::zero() && age < Duration::minutes(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reason_serializes_kebab_case() {
|
||||||
|
let json =
|
||||||
|
serde_json::to_string(&RetainedReason::ExitedWithUnsavedWork).expect("serialize");
|
||||||
|
assert_eq!(json, r#""exited-with-unsaved-work""#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entries_survive_a_restart() {
|
||||||
|
let dir = tmp_dir();
|
||||||
|
let wt = sample_path(dir.path(), "wt");
|
||||||
|
|
||||||
|
let first = RetainedStore::new(dir.path());
|
||||||
|
let entry = first
|
||||||
|
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
|
||||||
|
.expect("retain");
|
||||||
|
|
||||||
|
// A fresh store over the same state_dir — what a daemon restart sees.
|
||||||
|
let second = RetainedStore::new(dir.path());
|
||||||
|
assert_eq!(second.load_all().expect("load_all"), vec![entry]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_store_reads_as_empty() {
|
||||||
|
let dir = tmp_dir();
|
||||||
|
let store = RetainedStore::new(dir.path().join("does-not-exist"));
|
||||||
|
assert_eq!(store.load_all().expect("load_all"), Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_entries_are_skipped_silently() {
|
||||||
|
let dir = tmp_dir();
|
||||||
|
let store = RetainedStore::new(dir.path());
|
||||||
|
let wt = sample_path(dir.path(), "wt");
|
||||||
|
store
|
||||||
|
.retain(&wt, dir.path(), "kept", RetainedReason::ForceKilled)
|
||||||
|
.expect("retain");
|
||||||
|
std::fs::write(dir.path().join("not-a-uuid.json"), b"{oops").expect("write junk");
|
||||||
|
|
||||||
|
let all = store.load_all().expect("load_all");
|
||||||
|
assert_eq!(all.len(), 1);
|
||||||
|
assert_eq!(all[0].branch, "kept");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ids_are_unique() {
|
||||||
|
let dir = tmp_dir();
|
||||||
|
let store = RetainedStore::new(dir.path());
|
||||||
|
let a = store
|
||||||
|
.retain(dir.path(), dir.path(), "a", RetainedReason::ForceKilled)
|
||||||
|
.expect("retain");
|
||||||
|
let b = store
|
||||||
|
.retain(dir.path(), dir.path(), "b", RetainedReason::ForceKilled)
|
||||||
|
.expect("retain");
|
||||||
|
assert_ne!(a.id, b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_drops_the_entry() {
|
||||||
|
let dir = tmp_dir();
|
||||||
|
let store = RetainedStore::new(dir.path());
|
||||||
|
let wt = sample_path(dir.path(), "wt");
|
||||||
|
let entry = store
|
||||||
|
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
|
||||||
|
.expect("retain");
|
||||||
|
|
||||||
|
store.remove(entry.id).expect("remove");
|
||||||
|
assert_eq!(store.load_all().expect("load_all"), Vec::new());
|
||||||
|
|
||||||
|
// Removing an unknown id is a no-op, not an error.
|
||||||
|
store.remove(entry.id).expect("remove again");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_all_is_sorted_by_time() {
|
||||||
|
let dir = tmp_dir();
|
||||||
|
let store = RetainedStore::new(dir.path());
|
||||||
|
let mut first = store
|
||||||
|
.retain(dir.path(), dir.path(), "first", RetainedReason::ForceKilled)
|
||||||
|
.expect("retain");
|
||||||
|
first.retained_at -= Duration::hours(1);
|
||||||
|
store.overwrite(&first).expect("rewrite");
|
||||||
|
|
||||||
|
let second = store
|
||||||
|
.retain(
|
||||||
|
dir.path(),
|
||||||
|
dir.path(),
|
||||||
|
"second",
|
||||||
|
RetainedReason::ForceKilled,
|
||||||
|
)
|
||||||
|
.expect("retain");
|
||||||
|
|
||||||
|
let branches: Vec<_> = store
|
||||||
|
.load_all()
|
||||||
|
.expect("load_all")
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| e.branch)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(branches, vec!["first", "second"]);
|
||||||
|
let _ = second;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prune_missing_drops_gone_directories_only() {
|
||||||
|
let dir = tmp_dir();
|
||||||
|
let store = RetainedStore::new(dir.path());
|
||||||
|
let alive = sample_path(dir.path(), "alive");
|
||||||
|
let gone = sample_path(dir.path(), "gone");
|
||||||
|
let keep = store
|
||||||
|
.retain(&alive, dir.path(), "keep", RetainedReason::ForceKilled)
|
||||||
|
.expect("retain");
|
||||||
|
let drop = store
|
||||||
|
.retain(
|
||||||
|
&gone,
|
||||||
|
dir.path(),
|
||||||
|
"drop",
|
||||||
|
RetainedReason::ExitedWithUnsavedWork,
|
||||||
|
)
|
||||||
|
.expect("retain");
|
||||||
|
std::fs::remove_dir_all(&gone).expect("rm");
|
||||||
|
|
||||||
|
let pruned = store.prune_missing().expect("prune_missing");
|
||||||
|
assert_eq!(pruned, vec![drop.id]);
|
||||||
|
|
||||||
|
let branches: Vec<_> = store
|
||||||
|
.load_all()
|
||||||
|
.expect("load_all")
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| e.branch)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(branches, vec!["keep"]);
|
||||||
|
let _ = keep;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,8 +38,8 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
|
|||||||
pub struct SessionRecord {
|
pub struct SessionRecord {
|
||||||
pub session: Session,
|
pub session: Session,
|
||||||
pub fifo_path: PathBuf,
|
pub fifo_path: PathBuf,
|
||||||
/// Worktree we created at spawn time; removed on kill unless the
|
/// Worktree we created at spawn time; removed on kill, but only after the
|
||||||
/// dirty-work guard refuses (#56).
|
/// kill guard (#79) found nothing worth keeping in it.
|
||||||
pub worktree_path: Option<PathBuf>,
|
pub worktree_path: Option<PathBuf>,
|
||||||
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
||||||
/// worktree remove` is invoked from the base repo.
|
/// worktree remove` is invoked from the base repo.
|
||||||
@@ -61,6 +61,11 @@ pub enum SessionError {
|
|||||||
NotFound(Uuid),
|
NotFound(Uuid),
|
||||||
#[error("invalid environment variable name: {0:?}")]
|
#[error("invalid environment variable name: {0:?}")]
|
||||||
InvalidEnv(String),
|
InvalidEnv(String),
|
||||||
|
/// The kill was refused (#79): the session's worktree holds unsaved work.
|
||||||
|
/// Nothing happened — the session keeps running and the tree is untouched,
|
||||||
|
/// so it stays the operator's handle on their own unfinished work.
|
||||||
|
#[error("kill refused: the session's worktree holds unsaved work ({0})")]
|
||||||
|
KillGuarded(maestro_protocol::InTheWay),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionError {
|
impl SessionError {
|
||||||
@@ -72,10 +77,16 @@ impl SessionError {
|
|||||||
SessionError::Io(_) => "io_failed",
|
SessionError::Io(_) => "io_failed",
|
||||||
SessionError::NotFound(_) => "not_found",
|
SessionError::NotFound(_) => "not_found",
|
||||||
SessionError::InvalidEnv(_) => "invalid_env",
|
SessionError::InvalidEnv(_) => "invalid_env",
|
||||||
|
SessionError::KillGuarded(_) => "kill_guarded",
|
||||||
|
};
|
||||||
|
let unsaved_work = match self {
|
||||||
|
SessionError::KillGuarded(b) => Some(*b),
|
||||||
|
_ => None,
|
||||||
};
|
};
|
||||||
ErrorInfo {
|
ErrorInfo {
|
||||||
code: code.into(),
|
code: code.into(),
|
||||||
message: self.to_string(),
|
message: self.to_string(),
|
||||||
|
unsaved_work,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,20 +381,48 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kill a session and remove its on-disk artifacts. The worktree is
|
/// Kill a session and remove its on-disk artifacts.
|
||||||
/// always pruned -- unless it holds uncommitted changes or commits not on
|
///
|
||||||
/// any other ref, in which case the guard leaves it on disk (#56).
|
/// Refused (#79) when the session's worktree holds unsaved work —
|
||||||
|
/// uncommitted tracked changes, untracked non-ignored files, or commits
|
||||||
|
/// reachable from no remote-tracking ref. Nothing is killed and nothing
|
||||||
|
/// is removed; the error names what blocked it and how much of it there
|
||||||
|
/// is. The session stays as the operator's handle on their own unfinished
|
||||||
|
/// work, so ending a session that still matters becomes a deliberate
|
||||||
|
/// `--force`, which retains rather than destroys (WORKTREES.md).
|
||||||
///
|
///
|
||||||
/// This is also how an already-dead session is dismissed. tmux is only
|
/// This is also how an already-dead session is dismissed. tmux is only
|
||||||
/// asked to kill something it still has — calling `kill-session` on a
|
/// asked to kill something it still has — calling `kill-session` on a
|
||||||
/// session tmux already lost fails, and that failure is not interesting.
|
/// session tmux already lost fails, and that failure is not interesting.
|
||||||
pub async fn kill(&self, id: Uuid) -> Result<(), SessionError> {
|
pub async fn kill(&self, id: Uuid) -> Result<(), SessionError> {
|
||||||
|
let (worktree, alive) = {
|
||||||
|
let sessions = self.inner.sessions.lock().await;
|
||||||
|
match sessions.get(&id) {
|
||||||
|
None => return Err(SessionError::NotFound(id)),
|
||||||
|
Some(record) => (
|
||||||
|
record
|
||||||
|
.worktree_base_repo
|
||||||
|
.clone()
|
||||||
|
.zip(record.worktree_path.clone()),
|
||||||
|
record.session.status == SessionStatus::Running,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// The guard runs before anything is torn down: refusing must leave the
|
||||||
|
// session running, exactly as the operator left it.
|
||||||
|
if let Some((_, path)) = worktree {
|
||||||
|
let probe = tmux_blocking(move || crate::worktree_probe::in_the_way(&path))
|
||||||
|
.await
|
||||||
|
.map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e)))?;
|
||||||
|
if !probe.is_clear() {
|
||||||
|
return Err(SessionError::KillGuarded(probe));
|
||||||
|
}
|
||||||
|
}
|
||||||
let removed = {
|
let removed = {
|
||||||
let mut sessions = self.inner.sessions.lock().await;
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
sessions.remove(&id)
|
sessions.remove(&id)
|
||||||
};
|
};
|
||||||
let record = removed.ok_or(SessionError::NotFound(id))?;
|
let record = removed.ok_or(SessionError::NotFound(id))?;
|
||||||
let alive = record.session.status == SessionStatus::Running;
|
|
||||||
cleanup_record(&self.inner, id, record, alive).await;
|
cleanup_record(&self.inner, id, record, alive).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -720,10 +759,9 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
|
|||||||
if let Err(e) = inner.sidecar.delete(id) {
|
if let Err(e) = inner.sidecar.delete(id) {
|
||||||
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
||||||
}
|
}
|
||||||
// Cleanup is unconditional now (#56): `cleanup_on_kill` is no longer a
|
// The kill already passed the unsaved-work guard (#79), so this removal
|
||||||
// user-facing choice. The manager's guard is what protects unsaved work --
|
// is unconditional; the manager's own nested-worktree refusal is all that
|
||||||
// when it refuses, leave the tree on disk and say where it is so the
|
// can still leave a tree on disk, and it says where.
|
||||||
// operator can find it.
|
|
||||||
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
||||||
if let Err(e) = inner.worktree_mgr.remove(base, path) {
|
if let Err(e) = inner.worktree_mgr.remove(base, path) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
|
|||||||
@@ -10,11 +10,24 @@ pub enum WorktreeError {
|
|||||||
Spawn(#[from] std::io::Error),
|
Spawn(#[from] std::io::Error),
|
||||||
#[error("git exited with code {code}: {stderr}")]
|
#[error("git exited with code {code}: {stderr}")]
|
||||||
NonZero { code: i32, stderr: String },
|
NonZero { code: i32, stderr: String },
|
||||||
/// The worktree holds unsaved work; removal was refused (#56).
|
/// A registered worktree lives under the tree about to be removed; git
|
||||||
|
/// hides its contents from `git status`, so removal would delete its
|
||||||
|
/// uncommitted work unseen (#54 review).
|
||||||
#[error("left {} on disk: {}", .path.display(), .reason)]
|
#[error("left {} on disk: {}", .path.display(), .reason)]
|
||||||
Guarded { path: PathBuf, reason: String },
|
Guarded { path: PathBuf, reason: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<crate::worktree_probe::ProbeError> for WorktreeError {
|
||||||
|
fn from(e: crate::worktree_probe::ProbeError) -> Self {
|
||||||
|
match e {
|
||||||
|
crate::worktree_probe::ProbeError::Spawn(io) => WorktreeError::Spawn(io),
|
||||||
|
crate::worktree_probe::ProbeError::NonZero { code, stderr } => {
|
||||||
|
WorktreeError::NonZero { code, stderr }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs `git worktree add` rooted at `base_repo`. By default worktrees land
|
/// Runs `git worktree add` rooted at `base_repo`. By default worktrees land
|
||||||
/// inside the repository itself at `<repo>/worktrees/<branch>` (#56) so they
|
/// inside the repository itself at `<repo>/worktrees/<branch>` (#56) so they
|
||||||
/// are findable from the repo; an operator-set root overrides that.
|
/// are findable from the repo; an operator-set root overrides that.
|
||||||
@@ -126,12 +139,13 @@ impl WorktreeManager {
|
|||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `git -C <base_repo> worktree remove <path>` — but only when the
|
/// `git -C <base_repo> worktree remove <path>`.
|
||||||
/// worktree holds nothing unique (#56). A worktree with uncommitted
|
///
|
||||||
/// changes, or with commits reachable from no other ref, is refused and
|
/// Unconditional (#79): the guard moved off the worktree and onto the
|
||||||
/// left on disk so the operator can rescue it; cleanup is unconditional,
|
/// kill, so by the time removal runs the caller has already chosen to
|
||||||
/// which makes this guard the only thing standing between a stray kill
|
/// destroy the tree — the kill only gets here after
|
||||||
/// and destroyed work.
|
/// [`crate::worktree_probe::in_the_way`] came back clear. Still refused for a tree holding a
|
||||||
|
/// nested registered worktree, which `git status` cannot see.
|
||||||
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
|
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
|
||||||
// Gone from disk already (operator `rm -rf`, or a create that failed
|
// Gone from disk already (operator `rm -rf`, or a create that failed
|
||||||
// half-way). Probing it with git would fail and be misread as unsaved
|
// half-way). Probing it with git would fail and be misread as unsaved
|
||||||
@@ -147,12 +161,6 @@ impl WorktreeManager {
|
|||||||
reason: format!("holds a nested worktree at {}", inner.display()),
|
reason: format!("holds a nested worktree at {}", inner.display()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Some(reason) = unsafe_to_remove(path)? {
|
|
||||||
return Err(WorktreeError::Guarded {
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let out = Command::new("git")
|
let out = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(base_repo)
|
.arg(base_repo)
|
||||||
@@ -251,24 +259,6 @@ fn branch_relpath(branch: &str) -> Option<PathBuf> {
|
|||||||
Some(candidate.to_path_buf())
|
Some(candidate.to_path_buf())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `Some(reason)` when deleting `path` would destroy work.
|
|
||||||
///
|
|
||||||
/// Only an unclean working tree qualifies. `git worktree remove` deletes the
|
|
||||||
/// checkout but keeps `refs/heads/<branch>`, so committed work — pushed or
|
|
||||||
/// not — stays reachable and is never at risk. Guarding on "commits on no
|
|
||||||
/// other ref" instead would refuse every ordinary unpushed feature branch,
|
|
||||||
/// and because the path is deterministic that branch could then never be
|
|
||||||
/// spawned again (#56 review).
|
|
||||||
fn unsafe_to_remove(path: &Path) -> Result<Option<String>, WorktreeError> {
|
|
||||||
// `--ignored=no` keeps build output out of it; untracked sources count,
|
|
||||||
// since removal would delete them.
|
|
||||||
let status = run_git(path, ["status", "--porcelain", "--ignored=no"])?;
|
|
||||||
if !status.stdout.is_empty() {
|
|
||||||
return Ok(Some("has uncommitted changes".into()));
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
|
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
|
||||||
/// no longer exists.
|
/// no longer exists.
|
||||||
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
||||||
@@ -280,22 +270,6 @@ fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
|||||||
check(out)
|
check(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_git(
|
|
||||||
dir: &Path,
|
|
||||||
args: impl IntoIterator<Item = &'static str>,
|
|
||||||
) -> Result<Output, WorktreeError> {
|
|
||||||
let mut cmd = Command::new("git");
|
|
||||||
cmd.arg("-C").arg(dir).args(args);
|
|
||||||
let out = cmd.output()?;
|
|
||||||
if !out.status.success() {
|
|
||||||
return Err(WorktreeError::NonZero {
|
|
||||||
code: out.status.code().unwrap_or(-1),
|
|
||||||
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check(out: Output) -> Result<(), WorktreeError> {
|
fn check(out: Output) -> Result<(), WorktreeError> {
|
||||||
if out.status.success() {
|
if out.status.success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
//! The one git probe for what a worktree holds that would be lost, plus
|
||||||
|
//! guard-free reclaim (#79, #80).
|
||||||
|
//!
|
||||||
|
//! [`in_the_way`] is the single predicate behind all three paths: the kill
|
||||||
|
//! refusal (#79), the natural-exit classifier (#82), and the retained list
|
||||||
|
//! (#80). It lives here rather than in `worktree.rs` so that none of them can
|
||||||
|
//! answer the question differently — #79 and #80 were built in parallel and
|
||||||
|
//! each grew its own copy, which is exactly the drift this collapses.
|
||||||
|
//!
|
||||||
|
//! Reclaim never consults it: reclaim is the deliberate destructive act, and
|
||||||
|
//! the operator chose it while looking at what was in the way.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use maestro_protocol::InTheWay;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ProbeError {
|
||||||
|
#[error("failed to spawn git: {0}")]
|
||||||
|
Spawn(#[from] std::io::Error),
|
||||||
|
#[error("git exited with code {code}: {stderr}")]
|
||||||
|
NonZero { code: i32, stderr: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a kill refusal would report about the worktree at `path`, right now.
|
||||||
|
///
|
||||||
|
/// Ignored files never count (`--ignored=no`) — every Rust worktree has a
|
||||||
|
/// `target/`, and counting those would put every tree permanently in the way.
|
||||||
|
/// A directory that no longer exists reports nothing in the way: there is
|
||||||
|
/// nothing left to lose, and the store's own pruning drops the entry.
|
||||||
|
pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
|
||||||
|
let mut breakdown = InTheWay::default();
|
||||||
|
// `symlink_metadata` rather than `exists`: a broken symlink is gone for
|
||||||
|
// our purposes, and following it would probe the wrong tree.
|
||||||
|
if std::fs::symlink_metadata(path).is_err() {
|
||||||
|
return Ok(breakdown);
|
||||||
|
}
|
||||||
|
let status = run_git(
|
||||||
|
path,
|
||||||
|
[
|
||||||
|
"status",
|
||||||
|
"--porcelain",
|
||||||
|
"--ignored=no",
|
||||||
|
"--untracked-files=normal",
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
for line in String::from_utf8_lossy(&status.stdout).lines() {
|
||||||
|
if line.starts_with("??") {
|
||||||
|
breakdown.untracked_files += 1;
|
||||||
|
} else if !line.is_empty() {
|
||||||
|
breakdown.uncommitted_files += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Resolved inside the worktree so a detached HEAD still has a starting
|
||||||
|
// point; asking the base repo for `refs/heads/<branch>` fails there.
|
||||||
|
let head = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?;
|
||||||
|
let branch = String::from_utf8_lossy(&head.stdout).trim().to_string();
|
||||||
|
let tip: &str = if branch == "HEAD" { "HEAD" } else { &branch };
|
||||||
|
// Commits reachable from the tip but from no remote-tracking ref. A branch
|
||||||
|
// pushed anywhere counts as pushed; there is no "which remote" question to
|
||||||
|
// get wrong.
|
||||||
|
let unpushed = run_git(path, ["rev-list", "--count", tip, "--not", "--remotes"])?;
|
||||||
|
breakdown.unpushed_commits = String::from_utf8_lossy(&unpushed.stdout)
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(0);
|
||||||
|
Ok(breakdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a retained worktree's directory without consulting any guard, per
|
||||||
|
/// the reclaim contract: the operator chose this while looking at what was in
|
||||||
|
/// the way. `git worktree remove` keeps `refs/heads/<branch>`, so commits on
|
||||||
|
/// the branch survive even when they were never pushed.
|
||||||
|
///
|
||||||
|
/// A directory that is already gone only leaves a stale registration behind;
|
||||||
|
/// `worktree prune` clears it so the branch can be spawned again.
|
||||||
|
pub fn reclaim(base_repo: &Path, path: &Path) -> Result<(), ProbeError> {
|
||||||
|
if !path.exists() {
|
||||||
|
return run_git(base_repo, ["worktree", "prune"]).map(|_| ());
|
||||||
|
}
|
||||||
|
run_git(
|
||||||
|
base_repo,
|
||||||
|
[
|
||||||
|
"worktree",
|
||||||
|
"remove",
|
||||||
|
"--force",
|
||||||
|
path.to_str().expect("worktree path is valid utf-8"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_git<I, S>(dir: &Path, args: I) -> Result<std::process::Output, ProbeError>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: AsRef<std::ffi::OsStr>,
|
||||||
|
{
|
||||||
|
let mut cmd = std::process::Command::new("git");
|
||||||
|
cmd.arg("-C").arg(dir).args(args);
|
||||||
|
let out = cmd.output()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(ProbeError::NonZero {
|
||||||
|
code: out.status.code().unwrap_or(-1),
|
||||||
|
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
struct Repo {
|
||||||
|
root: tempfile::TempDir,
|
||||||
|
repo: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git(dir: &Path, args: &[&str]) {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(dir)
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.expect("git spawn");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"git {args:?} failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A repo with a remote, one commit on `main`, and a linked worktree for
|
||||||
|
/// branch `feature` at `<repo>/worktrees/feature`.
|
||||||
|
fn repo_with_worktree() -> Repo {
|
||||||
|
let root = tempfile::tempdir().expect("tempdir");
|
||||||
|
let repo = root.path().join("repo");
|
||||||
|
let remote = root.path().join("remote.git");
|
||||||
|
std::fs::create_dir_all(&repo).unwrap();
|
||||||
|
std::fs::create_dir_all(&remote).unwrap();
|
||||||
|
git(&repo, &["init", "-q", "-b", "main"]);
|
||||||
|
git(&repo, &["config", "user.email", "t@t"]);
|
||||||
|
git(&repo, &["config", "user.name", "T"]);
|
||||||
|
std::fs::write(repo.join("README"), b"hello\n").unwrap();
|
||||||
|
git(&repo, &["add", "."]);
|
||||||
|
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||||
|
git(&remote, &["init", "-q", "--bare"]);
|
||||||
|
git(&repo, &["remote", "add", "origin", &path_str(&remote)]);
|
||||||
|
git(&repo, &["push", "-q", "-u", "origin", "main"]);
|
||||||
|
git(
|
||||||
|
&repo,
|
||||||
|
&[
|
||||||
|
"worktree",
|
||||||
|
"add",
|
||||||
|
"-q",
|
||||||
|
"-b",
|
||||||
|
"feature",
|
||||||
|
"worktrees/feature",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
Repo { root, repo }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_str(path: &Path) -> String {
|
||||||
|
path.to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Repo {
|
||||||
|
fn wt(&self) -> PathBuf {
|
||||||
|
self.repo.join("worktrees").join("feature")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_in_wt(&self, msg: &str) {
|
||||||
|
std::fs::write(self.wt().join("README"), msg.as_bytes()).unwrap();
|
||||||
|
git(&self.wt(), &["add", "-A"]);
|
||||||
|
git(&self.wt(), &["commit", "-qm", msg]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_clean_pushed_worktree_has_nothing_in_the_way() {
|
||||||
|
let r = repo_with_worktree();
|
||||||
|
git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]);
|
||||||
|
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||||
|
assert_eq!(breakdown, InTheWay::default());
|
||||||
|
assert!(breakdown.is_clear());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uncommitted_untracked_and_unpushed_are_counted_separately() {
|
||||||
|
let r = repo_with_worktree();
|
||||||
|
std::fs::write(r.wt().join("README"), b"edited\n").unwrap();
|
||||||
|
std::fs::write(r.wt().join("scratch.txt"), b"untracked\n").unwrap();
|
||||||
|
|
||||||
|
let dirty = in_the_way(&r.wt()).expect("probe");
|
||||||
|
assert_eq!(dirty.uncommitted_files, 1);
|
||||||
|
assert_eq!(dirty.untracked_files, 1);
|
||||||
|
assert_eq!(dirty.unpushed_commits, 0);
|
||||||
|
assert!(!dirty.is_clear());
|
||||||
|
|
||||||
|
// Committing clears exactly one dimension and creates another.
|
||||||
|
git(&r.wt(), &["add", "README"]);
|
||||||
|
git(&r.wt(), &["commit", "-qm", "wip"]);
|
||||||
|
|
||||||
|
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||||
|
assert_eq!(breakdown.uncommitted_files, 0);
|
||||||
|
assert_eq!(breakdown.untracked_files, 1);
|
||||||
|
assert_eq!(breakdown.unpushed_commits, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tracked_edits_count_as_uncommitted() {
|
||||||
|
let r = repo_with_worktree();
|
||||||
|
std::fs::write(r.wt().join("README"), b"edited\n").unwrap();
|
||||||
|
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||||
|
assert_eq!(breakdown.uncommitted_files, 1);
|
||||||
|
assert_eq!(breakdown.untracked_files, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pushing_clears_unpushed_but_keeps_other_entries() {
|
||||||
|
let r = repo_with_worktree();
|
||||||
|
std::fs::write(r.wt().join("new.txt"), b"x\n").unwrap();
|
||||||
|
r.commit_in_wt("wip");
|
||||||
|
|
||||||
|
let before = in_the_way(&r.wt()).expect("probe");
|
||||||
|
assert_eq!(before.unpushed_commits, 1);
|
||||||
|
|
||||||
|
git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]);
|
||||||
|
// Same stored entry, different tree: the breakdown must move with it.
|
||||||
|
let after = in_the_way(&r.wt()).expect("probe");
|
||||||
|
assert_eq!(after.unpushed_commits, 0);
|
||||||
|
assert_eq!(after.untracked_files, 0);
|
||||||
|
|
||||||
|
std::fs::write(r.wt().join("loose.txt"), b"x\n").unwrap();
|
||||||
|
let dirty = in_the_way(&r.wt()).expect("probe");
|
||||||
|
assert_eq!(dirty.untracked_files, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignored_files_never_count() {
|
||||||
|
let r = repo_with_worktree();
|
||||||
|
std::fs::write(r.wt().join(".gitignore"), b"target/\n").unwrap();
|
||||||
|
std::fs::create_dir_all(r.wt().join("target")).unwrap();
|
||||||
|
std::fs::write(r.wt().join("target/artifact"), b"x").unwrap();
|
||||||
|
|
||||||
|
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||||
|
assert_eq!(
|
||||||
|
breakdown,
|
||||||
|
InTheWay {
|
||||||
|
untracked_files: 1,
|
||||||
|
..InTheWay::default()
|
||||||
|
},
|
||||||
|
"the .gitignore itself is untracked; target/ is not"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_directory_reports_nothing_in_the_way() {
|
||||||
|
let r = repo_with_worktree();
|
||||||
|
let gone = r.root.path().join("nowhere");
|
||||||
|
let breakdown = in_the_way(&gone).expect("probe");
|
||||||
|
assert!(breakdown.is_clear());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reclaim_removes_the_directory_and_prunes_a_gone_one() {
|
||||||
|
let r = repo_with_worktree();
|
||||||
|
r.commit_in_wt("precious");
|
||||||
|
reclaim(&r.repo, &r.wt()).expect("reclaim");
|
||||||
|
assert!(!r.wt().exists());
|
||||||
|
|
||||||
|
// A second reclaim over the now-missing path just prunes.
|
||||||
|
reclaim(&r.repo, &r.wt()).expect("reclaim again");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reclaim_keeps_the_branch_and_its_commits() {
|
||||||
|
let r = repo_with_worktree();
|
||||||
|
r.commit_in_wt("precious");
|
||||||
|
let sha = {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(r.wt())
|
||||||
|
.args(["rev-parse", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
reclaim(&r.repo, &r.wt()).expect("reclaim");
|
||||||
|
|
||||||
|
let out = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&r.repo)
|
||||||
|
.args(["rev-parse", "--verify", "refs/heads/feature"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"branch must survive reclaim: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), sha);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reclaim_is_guard_free_on_a_dirty_tree() {
|
||||||
|
let r = repo_with_worktree();
|
||||||
|
std::fs::write(r.wt().join("README"), b"unsaved\n").unwrap();
|
||||||
|
std::fs::write(r.wt().join("notes.txt"), b"unsaved\n").unwrap();
|
||||||
|
reclaim(&r.repo, &r.wt()).expect("reclaim ignores the guard");
|
||||||
|
assert!(!r.wt().exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ async fn spawn_daemon_ws() -> Harness {
|
|||||||
"/home/test",
|
"/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,6 +27,7 @@ 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
|
||||||
|
|||||||
@@ -93,6 +93,38 @@ fn run(cmd: &mut Command) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An empty bare repository at `dir`, usable as a push target (#79).
|
||||||
|
pub fn init_bare_repo(dir: &Path) {
|
||||||
|
run(Command::new("git")
|
||||||
|
.arg("init")
|
||||||
|
.arg("-q")
|
||||||
|
.arg("--bare")
|
||||||
|
.arg(dir.to_str().expect("non-utf8 bare repo path")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push every branch of `repo` to `origin`, creating remote-tracking refs.
|
||||||
|
///
|
||||||
|
/// The unsaved-work probe (#79) counts commits reachable from no
|
||||||
|
/// remote-tracking ref as unpushed, so tests that want a killable worktree
|
||||||
|
/// must give its branch one. Safe to call again: re-adding an existing
|
||||||
|
/// remote is ignored.
|
||||||
|
pub fn push_all_to_origin(repo: &Path, origin: &Path) {
|
||||||
|
let _ = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repo)
|
||||||
|
.args([
|
||||||
|
"remote",
|
||||||
|
"add",
|
||||||
|
"origin",
|
||||||
|
origin.to_str().expect("non-utf8 origin path"),
|
||||||
|
])
|
||||||
|
.status();
|
||||||
|
run(Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repo)
|
||||||
|
.args(["push", "-q", "--all", "origin"]));
|
||||||
|
}
|
||||||
|
|
||||||
/// Async polling sibling of [`wait_for`] for tokio tests.
|
/// Async polling sibling of [`wait_for`] for tokio tests.
|
||||||
pub async fn wait_for_async<F, Fut>(timeout: Duration, mut cond: F)
|
pub async fn wait_for_async<F, Fut>(timeout: Duration, mut cond: F)
|
||||||
where
|
where
|
||||||
@@ -163,6 +195,15 @@ 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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use std::time::Duration;
|
|||||||
use common::DaemonFixture;
|
use common::DaemonFixture;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_daemon::local_api;
|
use maestro_daemon::local_api;
|
||||||
use maestro_protocol::{CreateSessionRequest, Session};
|
use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||||
@@ -48,6 +48,7 @@ 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
|
||||||
@@ -338,6 +339,53 @@ async fn delete_owned_session_kills_it() {
|
|||||||
assert!(!h.manager.has(session.id).await);
|
assert!(!h.manager.has(session.id).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409
|
||||||
|
/// with the structured breakdown, and the session keeps running.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the_breakdown() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let repo = tempfile::TempDir::new().unwrap();
|
||||||
|
common::init_repo(repo.path());
|
||||||
|
let session = h
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: repo.path().to_path_buf(),
|
||||||
|
command: "sleep 60".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: Some(WorktreeSpec {
|
||||||
|
base_repo: repo.path().to_path_buf(),
|
||||||
|
branch: "feat/guarded".into(),
|
||||||
|
cleanup_on_kill: true,
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
repo.path()
|
||||||
|
.join("worktrees")
|
||||||
|
.join("feat")
|
||||||
|
.join("guarded")
|
||||||
|
.join("README"),
|
||||||
|
b"unsaved edits\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.delete(url(h.addr, &format!("/api/sessions/{}", session.id)))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), reqwest::StatusCode::CONFLICT);
|
||||||
|
let body: ApiError = resp.json().await.unwrap();
|
||||||
|
assert_eq!(body.error.code, "kill_guarded");
|
||||||
|
let breakdown = body.error.unsaved_work.expect("breakdown on the wire");
|
||||||
|
assert_eq!(breakdown.uncommitted_files, 1);
|
||||||
|
|
||||||
|
assert!(h.manager.has(session.id).await, "the session keeps running");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn attach_ws_streams_pty_bytes() {
|
async fn attach_ws_streams_pty_bytes() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
@@ -492,3 +540,234 @@ async fn next_event(ws: &mut WsClient) -> maestro_protocol::CatalogEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/worktrees + DELETE /api/worktrees/:id (#80)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `git -C dir args`, asserting success — the worktree endpoints are tested
|
||||||
|
/// against real git, like everything else in this crate.
|
||||||
|
fn git(dir: &std::path::Path, args: &[&str]) {
|
||||||
|
let out = std::process::Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(dir)
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.expect("git spawn");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"git {args:?} failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RetainedFixture {
|
||||||
|
repo: tempfile::TempDir,
|
||||||
|
wt: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A real repo with a linked worktree for branch `feat/login` whose tree holds
|
||||||
|
/// an uncommitted tracked edit and an unpushed commit.
|
||||||
|
fn retained_fixture() -> RetainedFixture {
|
||||||
|
let repo = tempfile::tempdir().unwrap();
|
||||||
|
common::init_repo(repo.path());
|
||||||
|
// A pushed main, so "unpushed" means commits made on the feature branch,
|
||||||
|
// not the shared history.
|
||||||
|
let remote = repo.path().join("remote.git");
|
||||||
|
std::fs::create_dir_all(&remote).unwrap();
|
||||||
|
let remote_s = remote.to_string_lossy().into_owned();
|
||||||
|
git(&remote, &["init", "-q", "--bare"]);
|
||||||
|
git(repo.path(), &["remote", "add", "origin", &remote_s]);
|
||||||
|
git(repo.path(), &["push", "-q", "-u", "origin", "main"]);
|
||||||
|
git(
|
||||||
|
repo.path(),
|
||||||
|
&[
|
||||||
|
"worktree",
|
||||||
|
"add",
|
||||||
|
"-q",
|
||||||
|
"-b",
|
||||||
|
"feat/login",
|
||||||
|
"worktrees/feat/login",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let wt = repo.path().join("worktrees").join("feat").join("login");
|
||||||
|
RetainedFixture { repo, wt }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RetainedFixture {
|
||||||
|
fn commit_in_wt(&self, msg: &str) {
|
||||||
|
std::fs::write(self.wt.join("README"), msg.as_bytes()).unwrap();
|
||||||
|
git(&self.wt, &["add", "-A"]);
|
||||||
|
git(&self.wt, &["commit", "-qm", msg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn branch_sha(&self, branch: &str) -> String {
|
||||||
|
let out = std::process::Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(self.repo.path())
|
||||||
|
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "branch {branch} must exist");
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn worktrees_lists_retained_entries_with_recomputed_breakdown() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let fx = retained_fixture();
|
||||||
|
// Uncommitted tracked edit + an untracked file; the branch has no commits
|
||||||
|
// beyond main yet.
|
||||||
|
std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap();
|
||||||
|
std::fs::write(fx.wt.join("scratch.txt"), b"loose\n").unwrap();
|
||||||
|
|
||||||
|
let store = h._fx.retained_store();
|
||||||
|
let entry = store
|
||||||
|
.retain(
|
||||||
|
&fx.wt,
|
||||||
|
fx.repo.path(),
|
||||||
|
"feat/login",
|
||||||
|
maestro_daemon::retained_store::RetainedReason::ForceKilled,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
||||||
|
reqwest::get(url(h.addr, "/api/worktrees"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(listed.len(), 1);
|
||||||
|
let w = &listed[0];
|
||||||
|
assert_eq!(w.id, entry.id);
|
||||||
|
assert_eq!(w.path, fx.wt);
|
||||||
|
assert_eq!(w.base_repo, fx.repo.path());
|
||||||
|
assert_eq!(w.branch, "feat/login");
|
||||||
|
assert_eq!(w.reason, maestro_protocol::RetainedReason::ForceKilled);
|
||||||
|
assert_eq!(w.in_the_way.uncommitted_files, 1);
|
||||||
|
assert_eq!(w.in_the_way.untracked_files, 1);
|
||||||
|
assert_eq!(w.in_the_way.unpushed_commits, 0);
|
||||||
|
|
||||||
|
// The operator saves the work themselves. The stored entry is untouched —
|
||||||
|
// the next read must report the tree as it is *now*, not as it was when
|
||||||
|
// it was retained.
|
||||||
|
fx.commit_in_wt("saved");
|
||||||
|
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
||||||
|
reqwest::get(url(h.addr, "/api/worktrees"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(listed.len(), 1, "same entry, still retained");
|
||||||
|
assert_eq!(listed[0].id, entry.id);
|
||||||
|
assert_eq!(listed[0].in_the_way.uncommitted_files, 0);
|
||||||
|
assert_eq!(listed[0].in_the_way.unpushed_commits, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reclaim_removes_directory_and_entry_but_never_the_branch() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let fx = retained_fixture();
|
||||||
|
fx.commit_in_wt("precious");
|
||||||
|
let sha = fx.branch_sha("feat/login");
|
||||||
|
std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap(); // guard would refuse
|
||||||
|
|
||||||
|
let store = h._fx.retained_store();
|
||||||
|
let entry = store
|
||||||
|
.retain(
|
||||||
|
&fx.wt,
|
||||||
|
fx.repo.path(),
|
||||||
|
"feat/login",
|
||||||
|
maestro_daemon::retained_store::RetainedReason::ExitedWithUnsavedWork,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.delete(url(h.addr, &format!("/api/worktrees/{}", entry.id)))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
assert!(!fx.wt.exists(), "reclaim removes the directory");
|
||||||
|
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
||||||
|
reqwest::get(url(h.addr, "/api/worktrees"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(listed.is_empty(), "reclaim drops the store entry");
|
||||||
|
assert_eq!(
|
||||||
|
fx.branch_sha("feat/login"),
|
||||||
|
sha,
|
||||||
|
"the branch and its commits survive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reclaim_of_an_already_gone_directory_still_drops_the_entry() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let fx = retained_fixture();
|
||||||
|
let store = h._fx.retained_store();
|
||||||
|
let entry = store
|
||||||
|
.retain(
|
||||||
|
&fx.wt,
|
||||||
|
fx.repo.path(),
|
||||||
|
"feat/login",
|
||||||
|
maestro_daemon::retained_store::RetainedReason::ForceKilled,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::remove_dir_all(&fx.wt).unwrap(); // operator rm -rf
|
||||||
|
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.delete(url(h.addr, &format!("/api/worktrees/{}", entry.id)))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
assert!(store.load_all().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reclaim_unknown_id_returns_404() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.delete(url(h.addr, &format!("/api/worktrees/{}", Uuid::new_v4())))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn worktrees_prunes_entries_whose_directory_vanished_by_hand() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let fx = retained_fixture();
|
||||||
|
let store = h._fx.retained_store();
|
||||||
|
store
|
||||||
|
.retain(
|
||||||
|
&fx.wt,
|
||||||
|
fx.repo.path(),
|
||||||
|
"feat/login",
|
||||||
|
maestro_daemon::retained_store::RetainedReason::ForceKilled,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::remove_dir_all(&fx.wt).unwrap();
|
||||||
|
|
||||||
|
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
||||||
|
reqwest::get(url(h.addr, "/api/worktrees"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(listed.is_empty(), "a hand-removed directory is not listed");
|
||||||
|
assert!(
|
||||||
|
store.load_all().unwrap().is_empty(),
|
||||||
|
"and its entry is gone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ mod common;
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use common::{init_repo, wait_for_async, DaemonFixture};
|
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
|
||||||
|
use maestro_daemon::SessionError;
|
||||||
use maestro_protocol::CreateSessionRequest;
|
use maestro_protocol::CreateSessionRequest;
|
||||||
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
|
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
@@ -363,8 +365,9 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
|||||||
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
|
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cleanup is unconditional (#56): even a spec that says "don't clean up"
|
/// Cleanup on kill is unconditional once the guard passes (#79): even a spec
|
||||||
/// loses its worktree on kill — the guard, not the flag, protects work.
|
/// that says "don't clean up" loses its worktree — the branch was pushed, so
|
||||||
|
/// nothing in the tree is unique and removal goes ahead.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn kill_removes_worktree_even_when_spec_says_keep() {
|
async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
@@ -390,14 +393,21 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
|||||||
.expect("create");
|
.expect("create");
|
||||||
assert!(wt_path.exists());
|
assert!(wt_path.exists());
|
||||||
|
|
||||||
|
// Push the branch so its commits are reachable from a remote-tracking ref.
|
||||||
|
let origin = TempDir::new().unwrap();
|
||||||
|
init_bare_repo(origin.path());
|
||||||
|
push_all_to_origin(repo.path(), origin.path());
|
||||||
|
|
||||||
fx.manager.kill(s.id).await.expect("kill");
|
fx.manager.kill(s.id).await.expect("kill");
|
||||||
assert!(!wt_path.exists(), "worktree must be removed on kill");
|
assert!(!wt_path.exists(), "worktree must be removed on kill");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The guard (#56): a worktree holding uncommitted changes survives the kill,
|
/// The kill guard (#79): a worktree holding uncommitted changes refuses the
|
||||||
/// left on disk where the operator can find it.
|
/// kill outright. Nothing happens — the session keeps running as the
|
||||||
|
/// operator's handle on their unfinished work, and the tree is untouched.
|
||||||
|
/// The refusal names what blocked it and how much of it there is.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
|
async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
let repo = TempDir::new().unwrap();
|
let repo = TempDir::new().unwrap();
|
||||||
init_repo(repo.path());
|
init_repo(repo.path());
|
||||||
@@ -420,13 +430,138 @@ async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
|
|||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||||
|
std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap();
|
||||||
|
|
||||||
fx.manager.kill(s.id).await.expect("kill");
|
let err = fx.manager.kill(s.id).await.expect_err("kill must refuse");
|
||||||
|
let SessionError::KillGuarded(breakdown) = err else {
|
||||||
|
panic!("expected KillGuarded, got {err:?}");
|
||||||
|
};
|
||||||
|
assert_eq!(breakdown.uncommitted_files, 1);
|
||||||
|
assert_eq!(breakdown.untracked_files, 1);
|
||||||
assert!(
|
assert!(
|
||||||
wt_path.exists(),
|
breakdown.unpushed_commits >= 1,
|
||||||
"dirty worktree must be left on disk, not destroyed"
|
"a fresh branch has no remote-tracking ref"
|
||||||
|
);
|
||||||
|
let message = breakdown.to_string();
|
||||||
|
assert!(message.contains("1 uncommitted file"), "{message}");
|
||||||
|
assert!(message.contains("1 untracked file"), "{message}");
|
||||||
|
|
||||||
|
// Nothing happened: the session survives and so does the tree.
|
||||||
|
assert!(fx.manager.has(s.id).await, "the session must keep running");
|
||||||
|
assert!(wt_path.exists(), "the worktree must be untouched");
|
||||||
|
|
||||||
|
// A second kill after the work is saved goes through.
|
||||||
|
run_git_in(&wt_path, &["add", "."]);
|
||||||
|
run_git_in(&wt_path, &["commit", "-q", "-m", "saved"]);
|
||||||
|
let origin = TempDir::new().unwrap();
|
||||||
|
init_bare_repo(origin.path());
|
||||||
|
push_all_to_origin(repo.path(), origin.path());
|
||||||
|
fx.manager.kill(s.id).await.expect("kill after saving");
|
||||||
|
assert!(!fx.manager.has(s.id).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unpushed commits alone block the kill (#79): a feature branch almost
|
||||||
|
/// always has them, which is why `--force` (which retains) is the ordinary
|
||||||
|
/// way to end such a session.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn killing_refuses_until_the_branch_is_pushed() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let repo = TempDir::new().unwrap();
|
||||||
|
init_repo(repo.path());
|
||||||
|
let wt_path = repo.path().join("worktrees").join("feat").join("pushed");
|
||||||
|
|
||||||
|
let s = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: repo.path().to_path_buf(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: Some(WorktreeSpec {
|
||||||
|
base_repo: repo.path().to_path_buf(),
|
||||||
|
branch: "feat/pushed".into(),
|
||||||
|
cleanup_on_kill: true,
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
|
||||||
|
let err = fx.manager.kill(s.id).await.expect_err("unpushed blocks");
|
||||||
|
let SessionError::KillGuarded(breakdown) = err else {
|
||||||
|
panic!("expected KillGuarded, got {err:?}");
|
||||||
|
};
|
||||||
|
assert_eq!(breakdown.uncommitted_files, 0);
|
||||||
|
assert_eq!(breakdown.untracked_files, 0);
|
||||||
|
assert!(breakdown.unpushed_commits >= 1);
|
||||||
|
|
||||||
|
let origin = TempDir::new().unwrap();
|
||||||
|
init_bare_repo(origin.path());
|
||||||
|
push_all_to_origin(repo.path(), origin.path());
|
||||||
|
|
||||||
|
fx.manager.kill(s.id).await.expect("pushed branch kills");
|
||||||
|
assert!(!wt_path.exists());
|
||||||
|
assert!(!fx.manager.has(s.id).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ignored files NEVER block (#79): build output like `target/` exists in
|
||||||
|
/// every Rust checkout, and an ignored-only worktree stays killable.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn killing_an_ignored_only_worktree_succeeds() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let repo = TempDir::new().unwrap();
|
||||||
|
init_repo(repo.path());
|
||||||
|
// .gitignore must be committed before the worktree branches off, or it
|
||||||
|
// would itself read as an untracked file.
|
||||||
|
std::fs::write(repo.path().join(".gitignore"), b"/target\n").unwrap();
|
||||||
|
run_git_in(repo.path(), &["add", ".gitignore"]);
|
||||||
|
run_git_in(repo.path(), &["commit", "-q", "-m", "ignore target"]);
|
||||||
|
let origin = TempDir::new().unwrap();
|
||||||
|
init_bare_repo(origin.path());
|
||||||
|
|
||||||
|
let wt_path = repo.path().join("worktrees").join("feat").join("rusty");
|
||||||
|
let s = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: repo.path().to_path_buf(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: Some(WorktreeSpec {
|
||||||
|
base_repo: repo.path().to_path_buf(),
|
||||||
|
branch: "feat/rusty".into(),
|
||||||
|
cleanup_on_kill: true,
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
std::fs::create_dir_all(wt_path.join("target")).unwrap();
|
||||||
|
std::fs::write(wt_path.join("target").join("app.o"), b"binary\n").unwrap();
|
||||||
|
|
||||||
|
// Branch pushed; the only dirt is ignored output.
|
||||||
|
push_all_to_origin(repo.path(), origin.path());
|
||||||
|
|
||||||
|
fx.manager
|
||||||
|
.kill(s.id)
|
||||||
|
.await
|
||||||
|
.expect("ignored files never block");
|
||||||
|
assert!(!wt_path.exists());
|
||||||
|
assert!(!fx.manager.has(s.id).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_git_in(dir: &std::path::Path, args: &[&str]) {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(dir)
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.expect("git spawn");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"git {args:?} failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
);
|
);
|
||||||
assert!(fx.manager.snapshot().await.is_empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ mod common;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use common::init_repo;
|
use common::{init_bare_repo, init_repo, push_all_to_origin};
|
||||||
use maestro_daemon::worktree::WorktreeManager;
|
use maestro_daemon::worktree::WorktreeManager;
|
||||||
|
use maestro_daemon::worktree_probe::in_the_way;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn worktree_branches(repo: &PathBuf) -> Vec<String> {
|
fn worktree_branches(repo: &PathBuf) -> Vec<String> {
|
||||||
@@ -134,6 +135,123 @@ fn add_twice_generates_distinct_paths() {
|
|||||||
assert!(p2.exists());
|
assert!(p2.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The unsaved-work probe (#79) counts uncommitted tracked changes.
|
||||||
|
#[test]
|
||||||
|
fn the_probe_counts_uncommitted_tracked_changes() {
|
||||||
|
let repo_dir = TempDir::new().unwrap();
|
||||||
|
let repo = repo_dir.path().to_path_buf();
|
||||||
|
init_repo(&repo);
|
||||||
|
|
||||||
|
let mgr = WorktreeManager::new(None);
|
||||||
|
let path = mgr.add_new(&repo, "feat/dirty").expect("add");
|
||||||
|
std::fs::write(path.join("README"), b"unsaved edits\n").unwrap();
|
||||||
|
|
||||||
|
let w = in_the_way(&path).expect("probe");
|
||||||
|
assert_eq!(w.uncommitted_files, 1);
|
||||||
|
assert_eq!(w.untracked_files, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Untracked sources count; a staged new file is also unsaved tracked work.
|
||||||
|
#[test]
|
||||||
|
fn the_probe_counts_untracked_files() {
|
||||||
|
let repo_dir = TempDir::new().unwrap();
|
||||||
|
let repo = repo_dir.path().to_path_buf();
|
||||||
|
init_repo(&repo);
|
||||||
|
|
||||||
|
let mgr = WorktreeManager::new(None);
|
||||||
|
let path = mgr.add_new(&repo, "feat/untracked").expect("add");
|
||||||
|
std::fs::write(path.join("notes.txt"), b"wip\n").unwrap();
|
||||||
|
std::fs::write(path.join("more.txt"), b"also wip\n").unwrap();
|
||||||
|
|
||||||
|
let w = in_the_way(&path).expect("probe");
|
||||||
|
assert_eq!(w.untracked_files, 2);
|
||||||
|
assert_eq!(w.uncommitted_files, 0);
|
||||||
|
|
||||||
|
run_git(&path, &["add", "notes.txt"]);
|
||||||
|
let w = in_the_way(&path).expect("probe");
|
||||||
|
assert_eq!(w.uncommitted_files, 1, "staged counts as uncommitted");
|
||||||
|
assert_eq!(w.untracked_files, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ignored files NEVER block: every Rust checkout has a `target/`, and
|
||||||
|
/// counting ignored output would leave no session killable (#79).
|
||||||
|
#[test]
|
||||||
|
fn the_probe_ignores_ignored_files() {
|
||||||
|
let repo_dir = TempDir::new().unwrap();
|
||||||
|
let repo = repo_dir.path().to_path_buf();
|
||||||
|
init_repo(&repo);
|
||||||
|
|
||||||
|
// .gitignore must be committed, or it would itself read as untracked.
|
||||||
|
std::fs::write(repo.join(".gitignore"), b"/target\n").unwrap();
|
||||||
|
run_git(&repo, &["add", ".gitignore"]);
|
||||||
|
run_git(&repo, &["commit", "-q", "-m", "ignore target"]);
|
||||||
|
|
||||||
|
let mgr = WorktreeManager::new(None);
|
||||||
|
let path = mgr.add_new(&repo, "feat/builds").expect("add");
|
||||||
|
std::fs::create_dir_all(path.join("target")).unwrap();
|
||||||
|
std::fs::write(path.join("target").join("app.o"), b"binary\n").unwrap();
|
||||||
|
|
||||||
|
let w = in_the_way(&path).expect("probe");
|
||||||
|
assert_eq!(w.uncommitted_files, 0);
|
||||||
|
assert_eq!(w.untracked_files, 0);
|
||||||
|
|
||||||
|
// With the branch pushed too, an ignored-only tree reads fully clean.
|
||||||
|
let origin_dir = TempDir::new().unwrap();
|
||||||
|
init_bare_repo(origin_dir.path());
|
||||||
|
push_all_to_origin(&repo, origin_dir.path());
|
||||||
|
let w = in_the_way(&path).expect("probe after push");
|
||||||
|
assert!(w.is_clear(), "ignored-only worktree must read clean: {w:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commits reachable from no remote-tracking ref block; pushing the branch
|
||||||
|
/// clears them. With no remote at all every commit is unreachable from one,
|
||||||
|
/// which is exactly why feature branches almost always refuse (#79).
|
||||||
|
#[test]
|
||||||
|
fn the_probe_counts_unpushed_commits_until_the_branch_is_pushed() {
|
||||||
|
let repo_dir = TempDir::new().unwrap();
|
||||||
|
let repo = repo_dir.path().to_path_buf();
|
||||||
|
init_repo(&repo);
|
||||||
|
|
||||||
|
let mgr = WorktreeManager::new(None);
|
||||||
|
let path = mgr.add_new(&repo, "feat/branch").expect("add");
|
||||||
|
|
||||||
|
let w = in_the_way(&path).expect("probe");
|
||||||
|
assert!(
|
||||||
|
w.unpushed_commits >= 1,
|
||||||
|
"a fresh branch has no remote-tracking ref: {w:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let origin_dir = TempDir::new().unwrap();
|
||||||
|
init_bare_repo(origin_dir.path());
|
||||||
|
push_all_to_origin(&repo, origin_dir.path());
|
||||||
|
|
||||||
|
let w = in_the_way(&path).expect("probe after push");
|
||||||
|
assert_eq!(w.unpushed_commits, 0, "{w:?}");
|
||||||
|
assert!(w.is_clear(), "pushed and clean must be empty: {w:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full breakdown names each blocker with its count.
|
||||||
|
#[test]
|
||||||
|
fn the_probe_reports_every_blocker_at_once() {
|
||||||
|
let repo_dir = TempDir::new().unwrap();
|
||||||
|
let repo = repo_dir.path().to_path_buf();
|
||||||
|
init_repo(&repo);
|
||||||
|
|
||||||
|
let mgr = WorktreeManager::new(None);
|
||||||
|
let path = mgr.add_new(&repo, "feat/mixed").expect("add");
|
||||||
|
std::fs::write(path.join("README"), b"edited\n").unwrap();
|
||||||
|
std::fs::write(path.join("scratch.txt"), b"untracked\n").unwrap();
|
||||||
|
|
||||||
|
let w = in_the_way(&path).expect("probe");
|
||||||
|
assert_eq!(w.uncommitted_files, 1);
|
||||||
|
assert_eq!(w.untracked_files, 1);
|
||||||
|
assert!(w.unpushed_commits >= 1);
|
||||||
|
let summary = w.to_string();
|
||||||
|
assert!(summary.contains("1 uncommitted file"), "{summary}");
|
||||||
|
assert!(summary.contains("1 untracked file"), "{summary}");
|
||||||
|
assert!(summary.contains("unpushed commit"), "{summary}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remove_prunes_worktree() {
|
fn remove_prunes_worktree() {
|
||||||
let repo_dir = TempDir::new().unwrap();
|
let repo_dir = TempDir::new().unwrap();
|
||||||
@@ -211,8 +329,14 @@ fn add_existing_checks_out_branch_without_creating_it() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removal is guarded upstream now (#79): the kill refuses unsaved work
|
||||||
|
/// before anything is torn down, so by the time `remove` runs the tree is
|
||||||
|
/// clean. Git still refuses a dirty tree on its own — this asserts that last
|
||||||
|
/// line of defense — and committed-but-unpushed work does not block removal,
|
||||||
|
/// because `worktree remove` keeps `refs/heads/<branch>` and the commits stay
|
||||||
|
/// reachable.
|
||||||
#[test]
|
#[test]
|
||||||
fn remove_refuses_worktree_with_uncommitted_changes() {
|
fn remove_still_relies_on_git_refusing_a_dirty_tree() {
|
||||||
let repo_dir = TempDir::new().unwrap();
|
let repo_dir = TempDir::new().unwrap();
|
||||||
let repo = repo_dir.path().to_path_buf();
|
let repo = repo_dir.path().to_path_buf();
|
||||||
init_repo(&repo);
|
init_repo(&repo);
|
||||||
@@ -225,10 +349,6 @@ fn remove_refuses_worktree_with_uncommitted_changes() {
|
|||||||
.expect_err("dirty worktree must be refused");
|
.expect_err("dirty worktree must be refused");
|
||||||
assert!(path.exists(), "guarded worktree must stay on disk");
|
assert!(path.exists(), "guarded worktree must stay on disk");
|
||||||
|
|
||||||
// Committing it makes removal safe: `worktree remove` keeps
|
|
||||||
// `refs/heads/feat/dirty`, so the work is still reachable. Refusing here
|
|
||||||
// instead would make every unpushed branch a one-shot, since the path is
|
|
||||||
// deterministic and the leftover tree blocks the next spawn (#56 review).
|
|
||||||
run_git(&path, &["add", "."]);
|
run_git(&path, &["add", "."]);
|
||||||
run_git(&path, &["commit", "-q", "-m", "saved"]);
|
run_git(&path, &["commit", "-q", "-m", "saved"]);
|
||||||
|
|
||||||
@@ -410,7 +530,8 @@ fn a_guarded_leftover_is_never_adopted() {
|
|||||||
let first = mgr.add_new(&repo, "feat/resume").expect("add");
|
let first = mgr.add_new(&repo, "feat/resume").expect("add");
|
||||||
std::fs::write(first.join("notes.txt"), b"unsaved\n").unwrap();
|
std::fs::write(first.join("notes.txt"), b"unsaved\n").unwrap();
|
||||||
|
|
||||||
// The guard refuses, leaving it on disk.
|
// Removal is refused — git itself refuses a dirty tree — leaving it on
|
||||||
|
// disk; the daemon-side guard lives on the kill (#79).
|
||||||
mgr.remove(&repo, &first)
|
mgr.remove(&repo, &first)
|
||||||
.expect_err("dirty tree is guarded");
|
.expect_err("dirty tree is guarded");
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ async fn kill_removes_clean_worktree() {
|
|||||||
"expected a worktree for feature/e2e under <repo>/worktrees, got: {worktrees:?}"
|
"expected a worktree for feature/e2e under <repo>/worktrees, got: {worktrees:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
push_to_throwaway_origin(base.path());
|
||||||
client.kill_session(s.id).await.expect("kill");
|
client.kill_session(s.id).await.expect("kill");
|
||||||
|
|
||||||
// Give the daemon a moment to remove the worktree, then verify.
|
// Give the daemon a moment to remove the worktree, then verify.
|
||||||
@@ -177,11 +178,12 @@ async fn kill_removes_clean_worktree() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The guard (#56): killing a session whose worktree holds uncommitted
|
/// The kill guard (#79): killing a session whose worktree holds uncommitted
|
||||||
/// changes must leave the tree on disk — cleanup is unconditional, so this
|
/// changes is refused outright. Nothing happens — the session keeps running
|
||||||
/// guard is what keeps a stray kill from destroying work.
|
/// as the operator's handle on their unfinished work, the tree stays on disk,
|
||||||
|
/// and the refusal names what blocked it and how much.
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn kill_spares_worktree_with_uncommitted_changes() {
|
async fn kill_refuses_worktree_with_uncommitted_changes() {
|
||||||
let (_daemon, client) = bring_up().await;
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
let base = TempDir::new().unwrap();
|
let base = TempDir::new().unwrap();
|
||||||
@@ -209,17 +211,25 @@ async fn kill_spares_worktree_with_uncommitted_changes() {
|
|||||||
assert!(wt_path.exists());
|
assert!(wt_path.exists());
|
||||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||||
|
|
||||||
client.kill_session(s.id).await.expect("kill");
|
let err = client
|
||||||
|
.kill_session(s.id)
|
||||||
// The session is gone but the dirty worktree stays findable on disk.
|
.await
|
||||||
let deadline = Instant::now() + Duration::from_secs(5);
|
.expect_err("kill must refuse");
|
||||||
while Instant::now() < deadline {
|
match err {
|
||||||
let sessions = client.list_sessions().await.unwrap_or_default();
|
maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => {
|
||||||
if sessions.is_empty() {
|
assert_eq!(info.code, "kill_guarded", "{info:?}");
|
||||||
break;
|
let breakdown = info.unsaved_work.expect("breakdown on the wire");
|
||||||
|
assert_eq!(breakdown.uncommitted_files, 1);
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
other => panic!("expected a kill_guarded refusal, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The session survives and the dirty worktree stays findable on disk.
|
||||||
|
let sessions = client.list_sessions().await.unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
sessions.iter().any(|x| x.id == s.id),
|
||||||
|
"a refused kill must keep the session alive"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
wt_path.exists(),
|
wt_path.exists(),
|
||||||
"guarded worktree must be left on disk after kill"
|
"guarded worktree must be left on disk after kill"
|
||||||
@@ -270,6 +280,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
|
|||||||
"expected a worktree checked out at feature/existing, got: {worktrees:?}"
|
"expected a worktree checked out at feature/existing, got: {worktrees:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
push_to_throwaway_origin(base.path());
|
||||||
client.kill_session(s.id).await.expect("kill");
|
client.kill_session(s.id).await.expect("kill");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +328,7 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
push_to_throwaway_origin(base.path());
|
||||||
client.kill_session(s.id).await.expect("kill");
|
client.kill_session(s.id).await.expect("kill");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,6 +376,7 @@ async fn second_spawn_on_same_branch_is_rejected() {
|
|||||||
"original worktree must be untouched"
|
"original worktree must be untouched"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
push_to_throwaway_origin(base.path());
|
||||||
client.kill_session(first.id).await.expect("kill");
|
client.kill_session(first.id).await.expect("kill");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,6 +419,7 @@ async fn spawn_from_linked_worktree_places_in_main_checkout() {
|
|||||||
"worktrees must never nest inside another worktree"
|
"worktrees must never nest inside another worktree"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
push_to_throwaway_origin(base.path());
|
||||||
client.kill_session(s.id).await.expect("kill");
|
client.kill_session(s.id).await.expect("kill");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,6 +788,24 @@ fn git_worktree_list(dir: &std::path::Path) -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Push every branch of `repo` to a throwaway bare remote, so its commits are
|
||||||
|
/// reachable from remote-tracking refs. Without one the kill guard (#79)
|
||||||
|
/// reads every commit as unpushed, and any worktree session refuses to die.
|
||||||
|
fn push_to_throwaway_origin(repo: &std::path::Path) {
|
||||||
|
let origin = tempfile::TempDir::new().unwrap();
|
||||||
|
run_git(
|
||||||
|
&["init", "-q", "--bare", origin.path().to_str().unwrap()],
|
||||||
|
repo,
|
||||||
|
);
|
||||||
|
let _ = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repo)
|
||||||
|
.args(["remote", "add", "origin", origin.path().to_str().unwrap()])
|
||||||
|
.output()
|
||||||
|
.expect("git remote add");
|
||||||
|
run_git(&["push", "-q", "--all", "origin"], repo);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn daemon_events_ws_streams_snapshot_then_session_added() {
|
async fn daemon_events_ws_streams_snapshot_then_session_added() {
|
||||||
// NAP-248: subscribe to the daemon's WS /api/events, expect an initial
|
// NAP-248: subscribe to the daemon's WS /api/events, expect an initial
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use uuid::Uuid;
|
|||||||
use crate::routes::{self, UrlError};
|
use crate::routes::{self, UrlError};
|
||||||
use crate::{
|
use crate::{
|
||||||
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||||
Project, RecentDirs, Session,
|
Project, RecentDirs, RetainedWorktree, Session,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Time budget for establishing a TCP connection to the daemon.
|
/// Time budget for establishing a TCP connection to the daemon.
|
||||||
@@ -148,6 +148,23 @@ impl DaemonClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `GET /api/worktrees` (#80): every retained worktree with its in-the-way
|
||||||
|
/// breakdown as of this read.
|
||||||
|
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
|
||||||
|
self.get(routes::WORKTREES, &[]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/worktrees/:id` (#86): reclaim one retained worktree. The
|
||||||
|
/// daemon removes the directory and drops the store entry; the branch is
|
||||||
|
/// never deleted.
|
||||||
|
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
|
||||||
|
let resp = self
|
||||||
|
.send(Method::DELETE, &routes::worktree(id), &[], None::<&()>)
|
||||||
|
.await?;
|
||||||
|
check(resp).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// -- plumbing -----------------------------------------------------------
|
// -- plumbing -----------------------------------------------------------
|
||||||
|
|
||||||
async fn get<T: DeserializeOwned>(
|
async fn get<T: DeserializeOwned>(
|
||||||
|
|||||||
@@ -143,6 +143,87 @@ pub struct SessionWorktree {
|
|||||||
pub base_repo: PathBuf,
|
pub base_repo: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why a retained worktree outlived its session (#80). Same values the
|
||||||
|
/// daemon's retained store persists; new variants are additive but clients
|
||||||
|
/// should render unknown reasons generically.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum RetainedReason {
|
||||||
|
/// The session was killed with `--force` while its worktree held unsaved
|
||||||
|
/// work.
|
||||||
|
ForceKilled,
|
||||||
|
/// The session ended by itself and its worktree held unsaved work.
|
||||||
|
ExitedWithUnsavedWork,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a kill refusal would report about a worktree right now (#80) — the
|
||||||
|
/// same breakdown that blocks an unforced kill, recomputed on every read of
|
||||||
|
/// [`RetainedWorktree`] so it reflects the tree now rather than when it was
|
||||||
|
/// retained.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct InTheWay {
|
||||||
|
/// Tracked files with uncommitted modifications.
|
||||||
|
#[serde(default)]
|
||||||
|
pub uncommitted_files: usize,
|
||||||
|
/// Untracked files that are not ignored (ignored files never block).
|
||||||
|
#[serde(default)]
|
||||||
|
pub untracked_files: usize,
|
||||||
|
/// Commits on the branch reachable from no remote-tracking ref.
|
||||||
|
#[serde(default)]
|
||||||
|
pub unpushed_commits: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InTheWay {
|
||||||
|
/// Nothing blocks any more — the work has since been committed and pushed.
|
||||||
|
pub fn is_clear(&self) -> bool {
|
||||||
|
*self == Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry of `GET /api/worktrees` (#80): a retained worktree with its
|
||||||
|
/// in-the-way state as of this read. Reclaiming it via
|
||||||
|
/// `DELETE /api/worktrees/:id` removes the directory but **never** the branch.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RetainedWorktree {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub base_repo: PathBuf,
|
||||||
|
pub branch: String,
|
||||||
|
pub reason: RetainedReason,
|
||||||
|
pub retained_at: DateTime<Utc>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub in_the_way: InTheWay,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for InTheWay {
|
||||||
|
/// "3 uncommitted files, 2 unpushed commits" — zero parts are omitted;
|
||||||
|
/// all-zero renders "no unsaved work". This is the phrasing every client
|
||||||
|
/// uses for both the kill refusal and the retained list, so the two
|
||||||
|
/// cannot describe the same tree differently.
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let parts = [
|
||||||
|
(self.uncommitted_files, "uncommitted file"),
|
||||||
|
(self.untracked_files, "untracked file"),
|
||||||
|
(self.unpushed_commits, "unpushed commit"),
|
||||||
|
];
|
||||||
|
let said: Vec<String> = parts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(n, _)| *n > 0)
|
||||||
|
.map(|(n, unit)| {
|
||||||
|
if n == 1 {
|
||||||
|
format!("1 {unit}")
|
||||||
|
} else {
|
||||||
|
format!("{n} {unit}s")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if said.is_empty() {
|
||||||
|
return write!(f, "no unsaved work");
|
||||||
|
}
|
||||||
|
write!(f, "{}", said.join(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// HTTP `POST /api/sessions` body.
|
/// HTTP `POST /api/sessions` body.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct CreateSessionRequest {
|
pub struct CreateSessionRequest {
|
||||||
@@ -248,6 +329,10 @@ pub struct BranchInfo {
|
|||||||
pub struct ErrorInfo {
|
pub struct ErrorInfo {
|
||||||
pub code: String,
|
pub code: String,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
/// Set only by the kill guard (#79): the unsaved work that blocked
|
||||||
|
/// `DELETE /api/sessions/:id`, so a client can render counts directly.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub unsaved_work: Option<InTheWay>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The envelope every `/api/*` failure is wrapped in: `{"error": {…}}`.
|
/// The envelope every `/api/*` failure is wrapped in: `{"error": {…}}`.
|
||||||
@@ -268,6 +353,7 @@ impl ApiError {
|
|||||||
error: ErrorInfo {
|
error: ErrorInfo {
|
||||||
code: code.into(),
|
code: code.into(),
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
|
unsaved_work: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -539,6 +625,60 @@ 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]
|
#[test]
|
||||||
fn catalog_event_round_trips_and_tags() {
|
fn catalog_event_round_trips_and_tags() {
|
||||||
let session = sample_session();
|
let session = sample_session();
|
||||||
@@ -578,6 +718,7 @@ mod tests {
|
|||||||
ClientControl::Error(ErrorInfo {
|
ClientControl::Error(ErrorInfo {
|
||||||
code: "bad_frame".into(),
|
code: "bad_frame".into(),
|
||||||
message: "decode failed".into(),
|
message: "decode failed".into(),
|
||||||
|
unsaved_work: None,
|
||||||
}),
|
}),
|
||||||
resize,
|
resize,
|
||||||
] {
|
] {
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ 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";
|
||||||
|
|
||||||
@@ -40,6 +43,11 @@ pub fn session(id: Uuid) -> String {
|
|||||||
format!("{SESSIONS}/{id}")
|
format!("{SESSIONS}/{id}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80).
|
||||||
|
pub fn worktree(id: Uuid) -> String {
|
||||||
|
format!("{WORKTREES}/{id}")
|
||||||
|
}
|
||||||
|
|
||||||
/// `WS /api/attach/:id` — raw PTY bytes.
|
/// `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}")
|
||||||
@@ -50,6 +58,7 @@ pub fn attach(id: Uuid) -> String {
|
|||||||
pub mod patterns {
|
pub mod patterns {
|
||||||
pub const SESSION: &str = "/api/sessions/:id";
|
pub const SESSION: &str = "/api/sessions/:id";
|
||||||
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.
|
||||||
@@ -160,6 +169,10 @@ 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"
|
||||||
@@ -178,6 +191,10 @@ 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]
|
||||||
@@ -191,6 +208,7 @@ 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}");
|
||||||
|
|||||||
Reference in New Issue
Block a user