The existing-branch picker could never succeed in either client: both treated the main checkout's HEAD as available, and since that is usually `main` it became the default pick — git then refuses the second checkout. A TUI test had baked the bug in by asserting the selection lands on the `is_head` entry. Both pickers now treat "checked out anywhere" as held. Worktree paths were still not injective. Flattening `/` and `.` to `-` made `feat/x`, `feat.x` and `feat-x` share one directory, and the override root keyed on the repo basename alone, so `~/a/maestro` and `~/b/maestro` collided. A branch name is already a valid relative path — git stores it as refs/heads/feat/example — so it is used verbatim and nests under `worktrees/feat/`; the override root gains a digest of the full repo path. Git forbids holding both `feat` and `feat/x`, so the nesting cannot collide for valid refs. Also: - Recovered sessions dropped their worktree association, so after a restart a kill cleaned up nothing and the leftover directory blocked that branch forever. It is persisted on Session now. - The nesting guard compared git's resolved paths against an unresolved one, so it silently passed on any symlinked repo path — and that guard is what stands between a kill and an inner tree's uncommitted work. - Draft loads ran on the TUI event loop: /api/projects on a cold cache walks all of $HOME, freezing keys, frames and attached panes for up to the request timeout. They are detached tasks posting to a channel now. - Fast-forward in the web client created the session and never mounted it, so `N` looked like a dead key while the TUI attached. - The fast-forward fallback seed forced the worktree toggle off, against both a normal draft and the TUI. - The projects background refresh honoured the TTL, so two of every three ticks were no-ops and requests paid for the rescan instead.
112 lines
3.2 KiB
Rust
112 lines
3.2 KiB
Rust
//! Round-trip and recovery tests for the on-disk sidecar session store.
|
|
|
|
mod common;
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use maestro_daemon::sidecar::SidecarStore;
|
|
use maestro_protocol::{Session, SessionStatus};
|
|
use serde_json::Value;
|
|
use tempfile::TempDir;
|
|
use uuid::Uuid;
|
|
|
|
fn sample(id: Uuid) -> Session {
|
|
Session {
|
|
id,
|
|
cwd: PathBuf::from("/tmp/work"),
|
|
command: "bash".into(),
|
|
env: HashMap::from([("FOO".into(), "bar".into())]),
|
|
metadata: HashMap::from([("name".into(), Value::String("maestro-impl".into()))]),
|
|
worktree: None,
|
|
status: SessionStatus::Running,
|
|
created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z")
|
|
.unwrap()
|
|
.with_timezone(&Utc),
|
|
foreground: None,
|
|
activity: Default::default(),
|
|
last_activity_at: chrono::Utc::now(),
|
|
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn write_then_read_round_trip() {
|
|
let dir = TempDir::new().unwrap();
|
|
let store = SidecarStore::new(dir.path());
|
|
let id = Uuid::new_v4();
|
|
let s = sample(id);
|
|
|
|
store.write(&s).expect("write");
|
|
let loaded = store.read(id).expect("read");
|
|
assert_eq!(loaded.as_ref(), Some(&s));
|
|
|
|
let path = store.path_for(id);
|
|
assert!(path.exists());
|
|
assert!(path.ends_with(format!("{id}.json")));
|
|
}
|
|
|
|
#[test]
|
|
fn read_missing_returns_none() {
|
|
let dir = TempDir::new().unwrap();
|
|
let store = SidecarStore::new(dir.path());
|
|
assert!(store.read(Uuid::new_v4()).unwrap().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn delete_removes_file_and_is_idempotent() {
|
|
let dir = TempDir::new().unwrap();
|
|
let store = SidecarStore::new(dir.path());
|
|
let id = Uuid::new_v4();
|
|
let s = sample(id);
|
|
|
|
store.write(&s).unwrap();
|
|
assert!(store.path_for(id).exists());
|
|
|
|
store.delete(id).unwrap();
|
|
assert!(!store.path_for(id).exists());
|
|
|
|
// second delete is a no-op
|
|
store.delete(id).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn load_all_recovers_every_sidecar_and_skips_garbage() {
|
|
let dir = TempDir::new().unwrap();
|
|
let store = SidecarStore::new(dir.path());
|
|
|
|
let s1 = sample(Uuid::new_v4());
|
|
let s2 = sample(Uuid::new_v4());
|
|
store.write(&s1).unwrap();
|
|
store.write(&s2).unwrap();
|
|
|
|
// garbage files in the directory should be ignored
|
|
std::fs::write(dir.path().join("not-json.txt"), b"hello").unwrap();
|
|
std::fs::write(dir.path().join("broken.json"), b"{not json}").unwrap();
|
|
|
|
let mut loaded = store.load_all().expect("load_all");
|
|
loaded.sort_by_key(|s| s.id);
|
|
let mut expected = vec![s1, s2];
|
|
expected.sort_by_key(|s| s.id);
|
|
assert_eq!(loaded, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn load_all_returns_empty_when_directory_missing() {
|
|
let dir = TempDir::new().unwrap();
|
|
let store = SidecarStore::new(dir.path().join("does-not-exist"));
|
|
assert!(store.load_all().unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn write_creates_parent_directory() {
|
|
let dir = TempDir::new().unwrap();
|
|
let nested = dir.path().join("a/b/c");
|
|
let store = SidecarStore::new(&nested);
|
|
let s = sample(Uuid::new_v4());
|
|
store.write(&s).expect("write");
|
|
assert!(nested.exists());
|
|
assert!(store.path_for(s.id).exists());
|
|
}
|