Nothing has honoured WorktreeSpec.cleanup_on_kill since #56, and after #79 and #81 survival is decided by what is in a worktree, not by what the spawn asked for. A field that lies is worse than a missing one. Removes the field from the protocol, the --cleanup-on-kill flag from the CLI, and the tests asserting its default.
283 lines
10 KiB
Rust
283 lines
10 KiB
Rust
//! Fast-forward spawn (#67): one keystroke, every default resolved, no form.
|
|
//!
|
|
//! The defaults mirror what a draft pane would settle on after the operator
|
|
//! picked the most recent project and kept their hands off everything else:
|
|
//! its last command (or the first non-custom preset), a freshly generated
|
|
//! name, and a worktree with a branch named after that name. Any default
|
|
//! that cannot be resolved — no projects yet, no command, no generated
|
|
//! name — downgrades the keystroke to a draft pane pre-filled with whatever
|
|
//! did resolve ([`FastForwardDefaults::apply_to_draft`]).
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use maestro_protocol::{CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
|
|
|
use crate::draft::DraftPane;
|
|
|
|
/// What one fast-forward keystroke resolved. Every field optional: `None`
|
|
/// anywhere means "open a draft instead of spawning".
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct FastForwardDefaults {
|
|
pub project: Option<Project>,
|
|
pub command: Option<String>,
|
|
pub name: Option<String>,
|
|
}
|
|
|
|
/// Resolve the fast-forward defaults from the daemon's answers. `name` is
|
|
/// `None` when `/api/names` yielded nothing; `presets` may be empty (the
|
|
/// caller falls back to the built-ins it already has).
|
|
pub fn resolve(
|
|
projects: &[Project],
|
|
presets: &[SpawnPreset],
|
|
name: Option<String>,
|
|
now: DateTime<Utc>,
|
|
) -> FastForwardDefaults {
|
|
let project = crate::projects::sort_projects(projects.to_vec(), now)
|
|
.into_iter()
|
|
.next();
|
|
let command = project
|
|
.as_ref()
|
|
.and_then(|p| p.last_command.clone())
|
|
.filter(|c| !c.trim().is_empty())
|
|
.or_else(|| {
|
|
presets
|
|
.iter()
|
|
.find(|p| !p.command.is_empty())
|
|
.map(|p| p.command.clone())
|
|
});
|
|
FastForwardDefaults {
|
|
project,
|
|
command,
|
|
name,
|
|
}
|
|
}
|
|
|
|
impl FastForwardDefaults {
|
|
/// The spawn request when *every* default resolved: worktree on, new
|
|
/// branch named after the generated session name, base repo from the
|
|
/// scan (`None` means the project is itself a main checkout).
|
|
pub fn build_request(&self) -> Option<CreateSessionRequest> {
|
|
let project = self.project.as_ref()?;
|
|
let command = self.command.as_deref()?.trim();
|
|
let name = self.name.as_deref()?;
|
|
if command.is_empty() || name.is_empty() {
|
|
return None;
|
|
}
|
|
let mut metadata = std::collections::HashMap::new();
|
|
metadata.insert(
|
|
"name".to_string(),
|
|
serde_json::Value::String(name.to_string()),
|
|
);
|
|
Some(CreateSessionRequest {
|
|
cwd: project.path.clone(),
|
|
command: command.to_string(),
|
|
env: Default::default(),
|
|
metadata,
|
|
worktree: Some(WorktreeSpec {
|
|
base_repo: project
|
|
.base_repo
|
|
.clone()
|
|
.unwrap_or_else(|| project.path.clone()),
|
|
branch: name.to_string(),
|
|
create_branch: true,
|
|
}),
|
|
})
|
|
}
|
|
|
|
/// Seed a fresh draft pane with whatever did resolve — the fallback when
|
|
/// [`Self::build_request`] is `None`. The operator fills in the rest.
|
|
pub fn apply_to_draft(&self, draft: &mut DraftPane) {
|
|
if let Some(project) = &self.project {
|
|
draft.picked_cwd = Some(PathBuf::from(&project.path));
|
|
// The scan already knows this directory's repo (#61): no probe
|
|
// round trip needed, exactly like picking the row by hand.
|
|
draft.base_repo = Some(
|
|
project
|
|
.base_repo
|
|
.clone()
|
|
.unwrap_or_else(|| project.path.clone()),
|
|
);
|
|
draft.probed_cwd = Some(PathBuf::from(&project.path));
|
|
}
|
|
if let Some(command) = &self.command {
|
|
if let Some(idx) = draft.presets.iter().position(|p| &p.command == command) {
|
|
draft.selected_preset = idx;
|
|
} else if !command.trim().is_empty() {
|
|
// A last_command no preset advertises lands in custom free text.
|
|
draft.selected_preset = draft.presets.len().saturating_sub(1);
|
|
draft.custom_command = crate::text_input::TextInput::with_value(command);
|
|
}
|
|
}
|
|
if let Some(name) = &self.name {
|
|
draft.set_name(name.clone());
|
|
// Seeded, not generated: a later refill must not clobber it.
|
|
draft.name_touched = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use chrono::TimeDelta;
|
|
|
|
fn project(path: &str, days_ago: i64, cmd: Option<&str>) -> Project {
|
|
Project {
|
|
path: PathBuf::from(path),
|
|
name: path.rsplit('/').next().unwrap_or(path).into(),
|
|
is_worktree: false,
|
|
base_repo: None,
|
|
last_activity: Some(Utc::now() - TimeDelta::days(days_ago)),
|
|
last_command: cmd.map(String::from),
|
|
}
|
|
}
|
|
|
|
fn repo_project(path: &str, base: &str, days_ago: i64, cmd: Option<&str>) -> Project {
|
|
Project {
|
|
base_repo: Some(PathBuf::from(base)),
|
|
..project(path, days_ago, cmd)
|
|
}
|
|
}
|
|
|
|
fn preset(id: &str, command: &str) -> SpawnPreset {
|
|
SpawnPreset {
|
|
id: id.into(),
|
|
label: id.into(),
|
|
command: command.into(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn resolves_every_default_from_the_most_recent_project() {
|
|
let projects = vec![
|
|
project("/repos/old", 30, Some("zsh")),
|
|
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
|
|
];
|
|
let presets = vec![preset("aider", "aider"), preset("custom", "")];
|
|
let r = resolve(&projects, &presets, Some("amber-falcon".into()), Utc::now());
|
|
assert_eq!(
|
|
r.project.as_ref().unwrap().path,
|
|
PathBuf::from("/repos/fresh")
|
|
);
|
|
assert_eq!(r.command.as_deref(), Some("claude"));
|
|
assert_eq!(r.name.as_deref(), Some("amber-falcon"));
|
|
|
|
let req = r.build_request().expect("all defaults resolved");
|
|
assert_eq!(req.cwd, PathBuf::from("/repos/fresh"));
|
|
assert_eq!(req.command, "claude");
|
|
assert_eq!(
|
|
req.metadata.get("name").and_then(|v| v.as_str()),
|
|
Some("amber-falcon")
|
|
);
|
|
let wt = req.worktree.expect("worktree on");
|
|
assert_eq!(wt.base_repo, PathBuf::from("/repos/fresh"));
|
|
assert_eq!(wt.branch, "amber-falcon");
|
|
assert!(wt.create_branch, "fresh branch named after the session");
|
|
}
|
|
|
|
#[test]
|
|
fn worktree_of_the_most_recent_project_targets_its_base_repo() {
|
|
let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)];
|
|
let r = resolve(
|
|
&projects,
|
|
&[preset("zsh", "zsh")],
|
|
Some("s".into()),
|
|
Utc::now(),
|
|
);
|
|
let wt = r.build_request().unwrap().worktree.unwrap();
|
|
assert_eq!(wt.base_repo, PathBuf::from("/repos/main"));
|
|
}
|
|
|
|
#[test]
|
|
fn missing_last_command_falls_back_to_the_first_non_custom_preset() {
|
|
let projects = vec![project("/repos/p", 0, None)];
|
|
let presets = vec![preset("custom", ""), preset("claude", "claude")];
|
|
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
|
|
assert_eq!(r.command.as_deref(), Some("claude"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_last_command_is_no_command_at_all() {
|
|
let projects = vec![project("/repos/p", 0, Some(""))];
|
|
let presets = vec![preset("custom", "")];
|
|
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
|
|
assert_eq!(r.command, None);
|
|
assert!(r.build_request().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn nothing_resolves_when_no_projects_are_known_yet() {
|
|
let r = resolve(
|
|
&[],
|
|
&[preset("claude", "claude")],
|
|
Some("n".into()),
|
|
Utc::now(),
|
|
);
|
|
assert_eq!(r.project, None);
|
|
// No project → no directory → no request, even with command + name.
|
|
assert!(r.build_request().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_generated_name_downgrades_to_a_draft() {
|
|
let projects = vec![project("/repos/p", 0, Some("claude"))];
|
|
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
|
|
assert!(r.build_request().is_none(), "no name → no immediate spawn");
|
|
}
|
|
|
|
#[test]
|
|
fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() {
|
|
let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))];
|
|
let r = resolve(&projects, &[preset("aider", "aider")], None, Utc::now());
|
|
|
|
let mut draft = DraftPane::default();
|
|
r.apply_to_draft(&mut draft);
|
|
assert_eq!(draft.cwd(), Some(PathBuf::from("/repos/p")));
|
|
assert_eq!(draft.base_repo, Some(PathBuf::from("/repos/p")));
|
|
assert_eq!(draft.command(), "aider");
|
|
assert!(
|
|
draft.name.value().is_empty(),
|
|
"nothing to seed the name with"
|
|
);
|
|
assert!(draft.worktree_enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn a_resolved_name_survives_late_refills_and_tracks_the_branch() {
|
|
let projects = vec![project("/repos/p", 0, Some("claude"))];
|
|
let named = resolve(
|
|
&projects,
|
|
&[preset("claude", "claude")],
|
|
Some("amber-falcon".into()),
|
|
Utc::now(),
|
|
);
|
|
let mut draft = DraftPane::default();
|
|
named.apply_to_draft(&mut draft);
|
|
// A later batch must not overwrite the name the spawn was seeded with.
|
|
draft.seed_names(vec!["late-name".into()]);
|
|
assert_eq!(draft.name.value(), "amber-falcon");
|
|
assert_eq!(draft.branch.value(), "amber-falcon");
|
|
}
|
|
|
|
#[test]
|
|
fn an_unresolved_name_still_gets_generated_by_a_later_batch() {
|
|
let projects = vec![project("/repos/p", 0, Some("claude"))];
|
|
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
|
|
let mut draft = DraftPane::default();
|
|
r.apply_to_draft(&mut draft);
|
|
draft.seed_names(vec!["late-name".into()]);
|
|
assert_eq!(draft.name.value(), "late-name");
|
|
}
|
|
|
|
#[test]
|
|
fn a_last_command_no_preset_advertises_lands_in_custom_text() {
|
|
let projects = vec![project("/repos/p", 0, Some("nvim -d"))];
|
|
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
|
|
let mut draft = DraftPane::default();
|
|
r.apply_to_draft(&mut draft);
|
|
assert_eq!(draft.command(), "nvim -d");
|
|
assert!(draft.is_custom());
|
|
}
|
|
}
|