Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4921f62796 | ||
|
|
44b9745e8c | ||
|
|
cc8af94254 | ||
|
|
50a9d35f62 | ||
|
|
a9ef3ff97b | ||
|
|
0457a61a61 |
@@ -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?;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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))
|
||||||
@@ -182,6 +187,114 @@ async fn kill_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `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, &entry.base_repo, &entry.branch)
|
||||||
|
.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(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
||||||
(status, Json(ApiError::new(code, message))).into_response()
|
(status, Json(ApiError::new(code, message))).into_response()
|
||||||
}
|
}
|
||||||
@@ -197,6 +310,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 +318,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
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;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -24,20 +27,8 @@ pub enum RetainedStoreError {
|
|||||||
Json(#[from] serde_json::Error),
|
Json(#[from] serde_json::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why a worktree outlived its session. Kebab-case on the wire; new variants
|
|
||||||
/// are additive but clients should render unknown reasons generically.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::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,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct RetainedWorktree {
|
pub struct RetainedWorktreeRecord {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
pub base_repo: PathBuf,
|
pub base_repo: PathBuf,
|
||||||
@@ -71,8 +62,8 @@ impl RetainedStore {
|
|||||||
base_repo: &Path,
|
base_repo: &Path,
|
||||||
branch: &str,
|
branch: &str,
|
||||||
reason: RetainedReason,
|
reason: RetainedReason,
|
||||||
) -> Result<RetainedWorktree, RetainedStoreError> {
|
) -> Result<RetainedWorktreeRecord, RetainedStoreError> {
|
||||||
let entry = RetainedWorktree {
|
let entry = RetainedWorktreeRecord {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
base_repo: base_repo.to_path_buf(),
|
base_repo: base_repo.to_path_buf(),
|
||||||
@@ -85,7 +76,7 @@ impl RetainedStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Atomically write `entry`. Creates the directory if it does not exist.
|
/// Atomically write `entry`. Creates the directory if it does not exist.
|
||||||
pub fn overwrite(&self, entry: &RetainedWorktree) -> Result<(), RetainedStoreError> {
|
pub fn overwrite(&self, entry: &RetainedWorktreeRecord) -> Result<(), RetainedStoreError> {
|
||||||
std::fs::create_dir_all(&self.dir)?;
|
std::fs::create_dir_all(&self.dir)?;
|
||||||
let final_path = self.path_for(entry.id);
|
let final_path = self.path_for(entry.id);
|
||||||
let tmp = self.dir.join(format!("{}.json.tmp", entry.id));
|
let tmp = self.dir.join(format!("{}.json.tmp", entry.id));
|
||||||
@@ -108,7 +99,7 @@ impl RetainedStore {
|
|||||||
/// Read every entry. Files that are missing or malformed are skipped
|
/// Read every entry. Files that are missing or malformed are skipped
|
||||||
/// silently: a corrupt record must not hide the others, and an empty or
|
/// silently: a corrupt record must not hide the others, and an empty or
|
||||||
/// absent directory simply means nothing is retained.
|
/// absent directory simply means nothing is retained.
|
||||||
pub fn load_all(&self) -> Result<Vec<RetainedWorktree>, RetainedStoreError> {
|
pub fn load_all(&self) -> Result<Vec<RetainedWorktreeRecord>, RetainedStoreError> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let entries = match std::fs::read_dir(&self.dir) {
|
let entries = match std::fs::read_dir(&self.dir) {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
@@ -124,7 +115,7 @@ impl RetainedStore {
|
|||||||
let Ok(bytes) = std::fs::read(&path) else {
|
let Ok(bytes) = std::fs::read(&path) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if let Ok(worktree) = serde_json::from_slice::<RetainedWorktree>(&bytes) {
|
if let Ok(worktree) = serde_json::from_slice::<RetainedWorktreeRecord>(&bytes) {
|
||||||
out.push(worktree);
|
out.push(worktree);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,25 +226,9 @@ impl SessionManager {
|
|||||||
let WorktreeSpec {
|
let WorktreeSpec {
|
||||||
base_repo, branch, ..
|
base_repo, branch, ..
|
||||||
} = spec;
|
} = spec;
|
||||||
// `run_add` adopts a leftover tree for this branch — the
|
// A path collision is sidestepped inside `run_add` (#77), and
|
||||||
// dirty-work guard refuses to delete unsaved work and the path
|
// a branch already checked out elsewhere is git's refusal,
|
||||||
// is deterministic, so erroring would burn the branch forever.
|
// not ours.
|
||||||
// A tree a *live* session is using is a different matter:
|
|
||||||
// two sessions must not share one checkout, so reject it here
|
|
||||||
// where the session records are visible (#67 review).
|
|
||||||
let planned = self.inner.worktree_mgr.planned_path(base_repo, branch);
|
|
||||||
if self
|
|
||||||
.inner
|
|
||||||
.sessions
|
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.values()
|
|
||||||
.any(|r| r.worktree_path.as_deref() == Some(planned.as_path()))
|
|
||||||
{
|
|
||||||
return Err(SessionError::Worktree(
|
|
||||||
crate::worktree::WorktreeError::PathExists { path: planned },
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let path = if spec.create_branch {
|
let path = if spec.create_branch {
|
||||||
self.inner.worktree_mgr.add_new(base_repo, branch)
|
self.inner.worktree_mgr.add_new(base_repo, branch)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ 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 },
|
||||||
/// A worktree already occupies the target path. The caller decides what
|
|
||||||
/// to do; the daemon never relocates to a different directory (#56).
|
|
||||||
#[error("worktree path already exists: {}", .path.display())]
|
|
||||||
PathExists { path: PathBuf },
|
|
||||||
/// The worktree holds unsaved work; removal was refused (#56).
|
/// The worktree holds unsaved work; removal was refused (#56).
|
||||||
#[error("left {} on disk: {}", .path.display(), .reason)]
|
#[error("left {} on disk: {}", .path.display(), .reason)]
|
||||||
Guarded { path: PathBuf, reason: String },
|
Guarded { path: PathBuf, reason: String },
|
||||||
@@ -39,9 +35,9 @@ impl WorktreeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Where a worktree for `branch` will be created: the override root, or
|
/// Where a worktree for `branch` will be created: the override root, or
|
||||||
/// `<main checkout>/worktrees/<sanitized branch>` by default. The path is
|
/// `<main checkout>/worktrees/<sanitized branch>` by default. If the path
|
||||||
/// predictable (no UUID suffix), so an existing directory at the target
|
/// is occupied, [`Self::add_new`] / [`Self::add_existing`] sidestep to
|
||||||
/// is an error rather than a silently different location.
|
/// `<path>-2`, `<path>-3`, ... instead.
|
||||||
///
|
///
|
||||||
/// `base_repo` is whatever the client picked, which the project list makes
|
/// `base_repo` is whatever the client picked, which the project list makes
|
||||||
/// easy to point at a *linked worktree* — placing the new tree inside it
|
/// easy to point at a *linked worktree* — placing the new tree inside it
|
||||||
@@ -51,14 +47,7 @@ impl WorktreeManager {
|
|||||||
/// uncommitted work (#54 review). Always place against the main checkout.
|
/// uncommitted work (#54 review). Always place against the main checkout.
|
||||||
///
|
///
|
||||||
/// The override root gets the repo's basename too: without it two repos
|
/// The override root gets the repo's basename too: without it two repos
|
||||||
/// spawning the same branch name target one directory and the second
|
/// spawning the same branch name target one directory.
|
||||||
/// fails with `PathExists`.
|
|
||||||
/// Where [`Self::add_new`] / [`Self::add_existing`] would place this
|
|
||||||
/// branch's worktree. Callers use it to check ownership before creating.
|
|
||||||
pub fn planned_path(&self, base_repo: &Path, branch: &str) -> PathBuf {
|
|
||||||
self.target_path(base_repo, branch)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf {
|
fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf {
|
||||||
// An unusable branch name falls back to a digest rather than escaping
|
// An unusable branch name falls back to a digest rather than escaping
|
||||||
// the root; `run_add` surfaces git's own error for it anyway.
|
// the root; `run_add` surfaces git's own error for it anyway.
|
||||||
@@ -80,6 +69,14 @@ impl WorktreeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`Self::target_path`] with `-n` appended to the final component, the
|
||||||
|
/// collision suffix (#77).
|
||||||
|
fn target_path_suffixed(&self, base_repo: &Path, branch: &str, n: u32) -> PathBuf {
|
||||||
|
let mut name = self.target_path(base_repo, branch).into_os_string();
|
||||||
|
name.push(format!("-{n}"));
|
||||||
|
PathBuf::from(name)
|
||||||
|
}
|
||||||
|
|
||||||
/// `git -C <base_repo> worktree add -b <branch> <path>` — creates the
|
/// `git -C <base_repo> worktree add -b <branch> <path>` — creates the
|
||||||
/// branch. Returns the path of the new worktree on success.
|
/// branch. Returns the path of the new worktree on success.
|
||||||
pub fn add_new(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
|
pub fn add_new(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
|
||||||
@@ -98,22 +95,18 @@ impl WorktreeManager {
|
|||||||
branch: &str,
|
branch: &str,
|
||||||
create_branch: bool,
|
create_branch: bool,
|
||||||
) -> Result<PathBuf, WorktreeError> {
|
) -> Result<PathBuf, WorktreeError> {
|
||||||
let path = self.target_path(base_repo, branch);
|
// Clear registrations whose directory is gone first, so a
|
||||||
// Clear registrations whose directory is gone before testing the
|
// previously-leaked entry doesn't push this branch to a suffix.
|
||||||
// path, so a previously-leaked entry doesn't block this branch.
|
|
||||||
prune(base_repo)?;
|
prune(base_repo)?;
|
||||||
if path.symlink_metadata().is_ok() {
|
// Anything occupying the target — a tree we left behind, one the
|
||||||
// A tree we left behind for this very branch — the dirty-work
|
// operator made, an unrelated directory — is sidestepped with a
|
||||||
// guard refuses to delete unsaved work, and the path is
|
// numeric suffix on the directory; the branch stays whatever was
|
||||||
// deterministic, so erroring here would burn the branch
|
// requested (#77). The daemon never adopts what it finds.
|
||||||
// permanently: no spawn could ever produce a worktree for it
|
let mut path = self.target_path(base_repo, branch);
|
||||||
// again and the product offers no way to clear it. Adopt it
|
let mut n = 1;
|
||||||
// instead; landing in the operator's own unsaved work on the
|
while path.symlink_metadata().is_ok() {
|
||||||
// branch they asked for is what they wanted (#67 review).
|
n += 1;
|
||||||
if registered_for(base_repo, &path, branch) {
|
path = self.target_path_suffixed(base_repo, branch, n);
|
||||||
return Ok(path);
|
|
||||||
}
|
|
||||||
return Err(WorktreeError::PathExists { path });
|
|
||||||
}
|
}
|
||||||
// The parent (`<repo>/worktrees` or the override root) may not exist
|
// The parent (`<repo>/worktrees` or the override root) may not exist
|
||||||
// yet; the target itself must not.
|
// yet; the target itself must not.
|
||||||
@@ -192,34 +185,6 @@ fn main_checkout(path: &Path) -> PathBuf {
|
|||||||
.unwrap_or_else(|| path.to_path_buf())
|
.unwrap_or_else(|| path.to_path_buf())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when `path` is already a registered worktree of `base_repo` holding
|
|
||||||
/// `branch` — i.e. one this daemon created for this branch and later declined
|
|
||||||
/// to remove.
|
|
||||||
fn registered_for(base_repo: &Path, path: &Path, branch: &str) -> bool {
|
|
||||||
let Ok(out) = Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(base_repo)
|
|
||||||
.args(["worktree", "list", "--porcelain"])
|
|
||||||
.output()
|
|
||||||
else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let want = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
|
||||||
let text = String::from_utf8_lossy(&out.stdout);
|
|
||||||
let mut here = false;
|
|
||||||
for line in text.lines() {
|
|
||||||
if let Some(p) = line.strip_prefix("worktree ") {
|
|
||||||
let p = PathBuf::from(p);
|
|
||||||
here = p.canonicalize().unwrap_or(p) == want;
|
|
||||||
} else if here {
|
|
||||||
if let Some(r) = line.strip_prefix("branch ") {
|
|
||||||
return r.trim() == format!("refs/heads/{branch}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `Some(inner)` when a registered worktree lives under `path`. Git omits
|
/// `Some(inner)` when a registered worktree lives under `path`. Git omits
|
||||||
/// those directories from `git status`, so the dirty-work guard cannot see
|
/// those directories from `git status`, so the dirty-work guard cannot see
|
||||||
/// them and removal would delete them (#54 review).
|
/// them and removal would delete them (#54 review).
|
||||||
@@ -268,9 +233,7 @@ fn short_hash(s: &str) -> String {
|
|||||||
/// `.git/refs/heads/feat/example` — so it is used verbatim and `feat/example`
|
/// `.git/refs/heads/feat/example` — so it is used verbatim and `feat/example`
|
||||||
/// nests under `worktrees/feat/`. That keeps the path readable *and* keeps the
|
/// nests under `worktrees/feat/`. That keeps the path readable *and* keeps the
|
||||||
/// mapping injective: flattening `/` and `.` to `-` made `feat/x`, `feat.x`
|
/// mapping injective: flattening `/` and `.` to `-` made `feat/x`, `feat.x`
|
||||||
/// and `feat-x` share one directory, and with deterministic paths plus
|
/// and `feat-x` share one directory (#54 review).
|
||||||
/// `PathExists` as a hard error the losers could never get a worktree at all
|
|
||||||
/// (#54 review).
|
|
||||||
///
|
///
|
||||||
/// Git already forbids holding both `feat` and `feat/x`, so the directory
|
/// Git already forbids holding both `feat` and `feat/x`, so the directory
|
||||||
/// collision that nesting could cause cannot arise from valid refs. Only
|
/// collision that nesting could cause cannot arise from valid refs. Only
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
//! Read-side git probes for retained worktrees (#80): what is currently in
|
||||||
|
//! the way, and guard-free reclaim.
|
||||||
|
//!
|
||||||
|
//! The kill-refusal breakdown lives in `worktree.rs`, which this module must
|
||||||
|
//! not touch (#77 owns it) and must not consult for reclaim — reclaim is the
|
||||||
|
//! deliberate destructive act and never asks the guard. Issue #79 will factor
|
||||||
|
//! the shared git plumbing out of `worktree.rs`; until then these probes are
|
||||||
|
//! duplicated here on purpose.
|
||||||
|
|
||||||
|
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, base_repo: &Path, branch: &str) -> Result<InTheWay, ProbeError> {
|
||||||
|
let mut breakdown = InTheWay::default();
|
||||||
|
if !path.exists() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Commits reachable from the branch 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(
|
||||||
|
base_repo,
|
||||||
|
[
|
||||||
|
"rev-list",
|
||||||
|
"--count",
|
||||||
|
&format!("refs/heads/{branch}"),
|
||||||
|
"--not",
|
||||||
|
"--glob=refs/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(), &r.repo, "feature").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(), &r.repo, "feature").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(), &r.repo, "feature").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(), &r.repo, "feature").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(), &r.repo, "feature").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(), &r.repo, "feature").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(), &r.repo, "feature").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(), &r.repo, "feature").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, &r.repo, "feature").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
|
||||||
|
|||||||
@@ -163,6 +163,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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,28 +95,26 @@ fn worktree_root_override_redirects_placement() {
|
|||||||
assert!(path.exists());
|
assert!(path.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Anything at all occupying the target path — ours, the operator's, or an
|
||||||
|
/// unrelated directory — is sidestepped, not adopted and not reported (#77).
|
||||||
#[test]
|
#[test]
|
||||||
fn existing_directory_at_target_path_is_an_error_carrying_the_path() {
|
fn an_occupied_target_path_gets_a_numeric_suffix() {
|
||||||
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);
|
||||||
|
|
||||||
// A predictable path means collisions are possible; they must be
|
// A non-git directory sits exactly where the worktree would go.
|
||||||
// reported, never silently relocated.
|
let squatter = repo.join("worktrees").join("feat").join("collide");
|
||||||
std::fs::create_dir_all(repo.join("worktrees").join("feat").join("collide")).unwrap();
|
std::fs::create_dir_all(&squatter).unwrap();
|
||||||
|
std::fs::write(squatter.join("operator-notes.txt"), b"mine\n").unwrap();
|
||||||
|
|
||||||
let mgr = WorktreeManager::new(None);
|
let mgr = WorktreeManager::new(None);
|
||||||
let err = mgr.add_new(&repo, "feat/collide").unwrap_err();
|
let path = mgr.add_new(&repo, "feat/collide").expect("sidesteps");
|
||||||
let msg = format!("{err}");
|
assert_eq!(path, repo.join("worktrees").join("feat").join("collide-2"));
|
||||||
|
assert!(path.exists());
|
||||||
assert!(
|
assert!(
|
||||||
msg.contains(
|
squatter.join("operator-notes.txt").exists(),
|
||||||
repo.join("worktrees")
|
"the occupant is untouched"
|
||||||
.join("feat")
|
|
||||||
.join("collide")
|
|
||||||
.to_str()
|
|
||||||
.unwrap()
|
|
||||||
),
|
|
||||||
"{msg}"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,9 +379,8 @@ fn override_root_keeps_repos_apart() {
|
|||||||
assert_ne!(a, b, "same branch name in two repos must not collide");
|
assert_ne!(a, b, "same branch name in two repos must not collide");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flattening `/` and `.` to `-` made these three share one directory, and a
|
/// Flattening `/` and `.` to `-` made these three share one directory, so the
|
||||||
/// deterministic path plus a hard `PathExists` meant the losers could never
|
/// losers could never get a worktree (#54 review).
|
||||||
/// get a worktree (#54 review).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn similar_branch_names_get_distinct_directories() {
|
fn similar_branch_names_get_distinct_directories() {
|
||||||
let repo_dir = TempDir::new().unwrap();
|
let repo_dir = TempDir::new().unwrap();
|
||||||
@@ -400,12 +397,11 @@ fn similar_branch_names_get_distinct_directories() {
|
|||||||
assert!(a.ends_with("feat/x"), "{a:?}");
|
assert!(a.ends_with("feat/x"), "{a:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The dead end the cleanup guard created: it refuses to delete unsaved work,
|
/// A guarded leftover stays on disk untouched, and is never adopted (#77).
|
||||||
/// the path is deterministic, and `PathExists` is a hard error — so one stray
|
/// Re-spawning its branch is refused by git itself — the branch is still
|
||||||
/// untracked file used to burn that branch permanently, with no way to clear
|
/// checked out inside the leftover — which the caller surfaces.
|
||||||
/// it from the product. The leftover is adopted instead (#67 review).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_guarded_leftover_is_adopted_rather_than_burning_the_branch() {
|
fn a_guarded_leftover_is_never_adopted() {
|
||||||
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);
|
||||||
@@ -417,28 +413,39 @@ fn a_guarded_leftover_is_adopted_rather_than_burning_the_branch() {
|
|||||||
// The guard refuses, leaving it on disk.
|
// The guard refuses, leaving it on disk.
|
||||||
mgr.remove(&repo, &first)
|
mgr.remove(&repo, &first)
|
||||||
.expect_err("dirty tree is guarded");
|
.expect_err("dirty tree is guarded");
|
||||||
assert!(first.exists());
|
|
||||||
|
|
||||||
// Spawning that branch again lands in the operator's own work, not an error.
|
let err = mgr
|
||||||
let again = mgr
|
|
||||||
.add_existing(&repo, "feat/resume")
|
.add_existing(&repo, "feat/resume")
|
||||||
.expect("leftover is adopted");
|
.expect_err("a branch already checked out cannot be spawned twice");
|
||||||
assert_eq!(again, first);
|
assert!(format!("{err}").contains("git exited"), "{err}");
|
||||||
assert!(
|
assert!(
|
||||||
again.join("notes.txt").exists(),
|
first.join("notes.txt").exists(),
|
||||||
"their work is still there"
|
"the leftover's unsaved work is untouched"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adoption must not extend to a directory that merely sits in the way.
|
/// The `worktree_root` override gets the same collision suffix (#77).
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unrelated_directory_at_the_target_path_is_still_an_error() {
|
fn the_override_root_suffixes_collisions_too() {
|
||||||
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);
|
||||||
|
|
||||||
std::fs::create_dir_all(repo.join("worktrees").join("feat").join("squatter")).unwrap();
|
let wt_root = TempDir::new().unwrap();
|
||||||
let mgr = WorktreeManager::new(None);
|
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||||
mgr.add_new(&repo, "feat/squatter")
|
|
||||||
.expect_err("a non-worktree directory must not be adopted");
|
// First add settles `<root>/<repo basename>-<digest>/` so the squatter
|
||||||
|
// can be created at the exact target.
|
||||||
|
mgr.add_new(&repo, "feat/first").expect("add first");
|
||||||
|
let repo_dir_in_root = std::fs::read_dir(wt_root.path())
|
||||||
|
.unwrap()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.path();
|
||||||
|
let squatter = repo_dir_in_root.join("feat").join("collide");
|
||||||
|
std::fs::create_dir_all(&squatter).unwrap();
|
||||||
|
|
||||||
|
let path = mgr.add_new(&repo, "feat/collide").expect("sidesteps");
|
||||||
|
assert_eq!(path, squatter.with_file_name("collide-2"), "{path:?}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,58 @@ 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,
|
||||||
|
}
|
||||||
|
|
||||||
/// 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 {
|
||||||
@@ -539,6 +591,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();
|
||||||
|
|||||||
@@ -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