feat(maestro): refuse a kill when the worktree holds unsaved work (#79)
The guard moves off the worktree and onto the kill. DELETE /api/sessions/:id now runs the shared unsaved_work probe before tearing anything down and answers 409 kill_guarded with the breakdown on the wire, so a client can say "3 uncommitted files, 2 unpushed commits". The session keeps running and the tree is untouched. WorktreeManager::remove is unconditional now — git itself still refuses a dirty tree as a last line of defense.
This commit is contained in:
@@ -111,6 +111,7 @@ fn io_error(dir: &Path, e: &std::io::Error) -> ErrorInfo {
|
||||
ErrorInfo {
|
||||
code: code.into(),
|
||||
message: format!("{message}: {}", dir.display()),
|
||||
unsaved_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,8 @@ fn session_error_status(code: &str) -> StatusCode {
|
||||
match code {
|
||||
"not_found" => StatusCode::NOT_FOUND,
|
||||
"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,
|
||||
}
|
||||
}
|
||||
@@ -168,16 +170,17 @@ async fn create_session(
|
||||
/// `kill` already reports a missing session as `SessionError::NotFound`, which
|
||||
/// 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.
|
||||
///
|
||||
/// 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 {
|
||||
match s.manager.kill(id).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => {
|
||||
let info = e.to_error_info();
|
||||
error_response(
|
||||
session_error_status(&info.code),
|
||||
info.code.as_str(),
|
||||
info.message.as_str(),
|
||||
)
|
||||
let status = session_error_status(&info.code);
|
||||
(status, Json(ApiError { error: info })).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,8 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
|
||||
pub struct SessionRecord {
|
||||
pub session: Session,
|
||||
pub fifo_path: PathBuf,
|
||||
/// Worktree we created at spawn time; removed on kill unless the
|
||||
/// dirty-work guard refuses (#56).
|
||||
/// Worktree we created at spawn time; removed on kill, but only after the
|
||||
/// kill guard (#79) found nothing worth keeping in it.
|
||||
pub worktree_path: Option<PathBuf>,
|
||||
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
||||
/// worktree remove` is invoked from the base repo.
|
||||
@@ -61,6 +61,11 @@ pub enum SessionError {
|
||||
NotFound(Uuid),
|
||||
#[error("invalid environment variable name: {0:?}")]
|
||||
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::UnsavedWork),
|
||||
}
|
||||
|
||||
impl SessionError {
|
||||
@@ -72,10 +77,16 @@ impl SessionError {
|
||||
SessionError::Io(_) => "io_failed",
|
||||
SessionError::NotFound(_) => "not_found",
|
||||
SessionError::InvalidEnv(_) => "invalid_env",
|
||||
SessionError::KillGuarded(_) => "kill_guarded",
|
||||
};
|
||||
let unsaved_work = match self {
|
||||
SessionError::KillGuarded(b) => Some(*b),
|
||||
_ => None,
|
||||
};
|
||||
ErrorInfo {
|
||||
code: code.into(),
|
||||
message: self.to_string(),
|
||||
unsaved_work,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,20 +381,48 @@ impl SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill a session and remove its on-disk artifacts. The worktree is
|
||||
/// always pruned -- unless it holds uncommitted changes or commits not on
|
||||
/// any other ref, in which case the guard leaves it on disk (#56).
|
||||
/// Kill a session and remove its on-disk artifacts.
|
||||
///
|
||||
/// 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
|
||||
/// asked to kill something it still has — calling `kill-session` on a
|
||||
/// session tmux already lost fails, and that failure is not interesting.
|
||||
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::unsaved_work(&path))
|
||||
.await
|
||||
.map_err(SessionError::Worktree)?;
|
||||
if !probe.is_empty() {
|
||||
return Err(SessionError::KillGuarded(probe));
|
||||
}
|
||||
}
|
||||
let removed = {
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
sessions.remove(&id)
|
||||
};
|
||||
let record = removed.ok_or(SessionError::NotFound(id))?;
|
||||
let alive = record.session.status == SessionStatus::Running;
|
||||
cleanup_record(&self.inner, id, record, alive).await;
|
||||
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) {
|
||||
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
||||
}
|
||||
// Cleanup is unconditional now (#56): `cleanup_on_kill` is no longer a
|
||||
// user-facing choice. The manager's guard is what protects unsaved work --
|
||||
// when it refuses, leave the tree on disk and say where it is so the
|
||||
// operator can find it.
|
||||
// The kill already passed the unsaved-work guard (#79), so this removal
|
||||
// is unconditional; the manager's own nested-worktree refusal is all that
|
||||
// can still leave a tree on disk, and it says where.
|
||||
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
||||
if let Err(e) = inner.worktree_mgr.remove(base, path) {
|
||||
tracing::warn!(
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::time::Duration;
|
||||
use common::DaemonFixture;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_daemon::local_api;
|
||||
use maestro_protocol::{CreateSessionRequest, Session};
|
||||
use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
@@ -338,6 +338,53 @@ async fn delete_owned_session_kills_it() {
|
||||
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.dirty_tracked_files, 1);
|
||||
|
||||
assert!(h.manager.has(session.id).await, "the session keeps running");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attach_ws_streams_pty_bytes() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
|
||||
@@ -4,10 +4,12 @@ mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
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::{CatalogEvent, SessionStatus, WorktreeSpec};
|
||||
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());
|
||||
}
|
||||
|
||||
/// Cleanup is unconditional (#56): even a spec that says "don't clean up"
|
||||
/// loses its worktree on kill — the guard, not the flag, protects work.
|
||||
/// Cleanup on kill is unconditional once the guard passes (#79): even a spec
|
||||
/// 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]
|
||||
async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||
let fx = DaemonFixture::new();
|
||||
@@ -390,14 +393,21 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||
.expect("create");
|
||||
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");
|
||||
assert!(!wt_path.exists(), "worktree must be removed on kill");
|
||||
}
|
||||
|
||||
/// The guard (#56): a worktree holding uncommitted changes survives the kill,
|
||||
/// left on disk where the operator can find it.
|
||||
/// The kill guard (#79): a worktree holding uncommitted changes refuses the
|
||||
/// 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]
|
||||
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 repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
@@ -420,13 +430,138 @@ async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
|
||||
.await
|
||||
.expect("create");
|
||||
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.dirty_tracked_files, 1);
|
||||
assert_eq!(breakdown.untracked_files, 1);
|
||||
assert!(
|
||||
wt_path.exists(),
|
||||
"dirty worktree must be left on disk, not destroyed"
|
||||
breakdown.unpushed_commits >= 1,
|
||||
"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.dirty_tracked_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]
|
||||
|
||||
@@ -160,6 +160,7 @@ async fn kill_removes_clean_worktree() {
|
||||
"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");
|
||||
|
||||
// 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
|
||||
/// changes must leave the tree on disk — cleanup is unconditional, so this
|
||||
/// guard is what keeps a stray kill from destroying work.
|
||||
/// The kill guard (#79): killing a session whose worktree holds uncommitted
|
||||
/// changes is refused outright. Nothing happens — the session keeps running
|
||||
/// 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")]
|
||||
async fn kill_spares_worktree_with_uncommitted_changes() {
|
||||
async fn kill_refuses_worktree_with_uncommitted_changes() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
@@ -209,17 +211,25 @@ async fn kill_spares_worktree_with_uncommitted_changes() {
|
||||
assert!(wt_path.exists());
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
|
||||
// The session is gone but the dirty worktree stays findable on disk.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
let sessions = client.list_sessions().await.unwrap_or_default();
|
||||
if sessions.is_empty() {
|
||||
break;
|
||||
let err = client
|
||||
.kill_session(s.id)
|
||||
.await
|
||||
.expect_err("kill must refuse");
|
||||
match err {
|
||||
maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => {
|
||||
assert_eq!(info.code, "kill_guarded", "{info:?}");
|
||||
let breakdown = info.unsaved_work.expect("breakdown on the wire");
|
||||
assert_eq!(breakdown.dirty_tracked_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!(
|
||||
wt_path.exists(),
|
||||
"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:?}"
|
||||
);
|
||||
|
||||
push_to_throwaway_origin(base.path());
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
@@ -317,6 +328,7 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
|
||||
)
|
||||
.await;
|
||||
|
||||
push_to_throwaway_origin(base.path());
|
||||
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"
|
||||
);
|
||||
|
||||
push_to_throwaway_origin(base.path());
|
||||
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"
|
||||
);
|
||||
|
||||
push_to_throwaway_origin(base.path());
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
@@ -774,6 +788,24 @@ fn git_worktree_list(dir: &std::path::Path) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Push every branch of `repo` to a throwaway bare remote, so its commits are
|
||||
/// reachable from remote-tracking refs. Without one the kill guard (#79)
|
||||
/// reads every commit as unpushed, and any worktree session refuses to die.
|
||||
fn push_to_throwaway_origin(repo: &std::path::Path) {
|
||||
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")]
|
||||
async fn daemon_events_ws_streams_snapshot_then_session_added() {
|
||||
// NAP-248: subscribe to the daemon's WS /api/events, expect an initial
|
||||
|
||||
Reference in New Issue
Block a user