feat(maestro): GET /api/worktrees and DELETE reclaim endpoints
This commit is contained in:
@@ -48,6 +48,7 @@ async fn spawn_with(
|
||||
"/home/test",
|
||||
presets,
|
||||
projects,
|
||||
std::sync::Arc::new(fx.retained_store()),
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
@@ -492,3 +493,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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user