Compare commits

..
Author SHA1 Message Date
Miguel Palhas af8acbfbf2 test(maestro): verify umbrella binary routes each subcommand to its crate
ci / web (push) Successful in 1m10s
ci / rust (push) Failing after 3m17s
ci / flake (push) Successful in 7m6s
Closes #10

The compile_error guard and per-feature build already had CI coverage
(the "feature-gate matrix" job). What was missing was proof that a
subcommand actually reaches its own crate's dispatch at runtime, not
just that clap parses it.

A malformed --config file makes cli/daemon/tui fail identically at
config load, before touching tmux, the network, or a terminal. That
turns "clap parsed it but the handler never ran" (a usage error, exit
2) into a distinguishable outcome from "it dispatched into the real
crate" (a `toml decode` runtime error via main's error printer, exit
1). The absence-side tests are feature-gated to only exist in a
single-feature build, so they run under CI's existing per-feature
matrix rather than the default all-features build.
2026-09-04 19:47:32 +01:00
Miguel Palhas 1ad7845f51 fix(maestro): refresh attach header when a session is renamed
Header name/command were baked into innerHTML at connection creation
and never revisited. ConnectionPool.mount() reused the cached
connection on later calls but dropped the fresh info, so a header
never picked up haiku autoname's rename.
2026-09-04 19:38:09 +01:00
Miguel PalhasandClaude Sonnet 5 f0c272e0b1 feat(maestro): copy terminal selection to clipboard
ci / web (push) Successful in 28s
ci / rust (push) Failing after 2m6s
ci / flake (push) Successful in 2m14s
xterm.js has no built-in copy-on-select; mirror onSelectionChange to
navigator.clipboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 14:33:24 +01:00
Miguel Palhas 624f6f19c8 Merge remote-tracking branch 'origin/fermata'
ci / web (push) Failing after 13s
ci / flake (push) Successful in 3m12s
ci / rust (push) Failing after 3m26s
2026-09-04 13:32:05 +01:00
Miguel PalhasandClaude Sonnet 5 49ab7e9c81 fix(maestro): suffix branch name on new-branch worktree collision
ci / rust (push) Failing after 3m26s
ci / web (push) Successful in 43s
ci / flake (push) Successful in 3m54s
git worktree add -b fails outright when the branch already exists, even
if the target directory is free. A random fast-forward handle commonly
collides with a branch an earlier, already-removed worktree left behind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:29:49 +01:00
9 changed files with 237 additions and 22 deletions
Generated
+1
View File
@@ -1051,6 +1051,7 @@ dependencies = [
"maestro-cli",
"maestro-daemon",
"maestro-tui",
"tempfile",
"tokio",
"tracing-subscriber",
]
+40 -12
View File
@@ -111,16 +111,33 @@ impl WorktreeManager {
// Clear registrations whose directory is gone first, so a
// previously-leaked entry doesn't push this branch to a suffix.
prune(base_repo)?;
// Anything occupying the target — a tree we left behind, one the
// operator made, an unrelated directory — is sidestepped with a
// numeric suffix on the directory; the branch stays whatever was
// requested (#77). The daemon never adopts what it finds.
let mut path = self.target_path(base_repo, branch);
let mut n = 1;
while path.symlink_metadata().is_ok() {
n += 1;
path = self.target_path_suffixed(base_repo, branch, n);
}
let (branch, path) = if create_branch {
// Worktrees are commonly abandoned without deleting their branch,
// so a random handle can collide with one of its own past runs.
// Unlike the `add_existing` arm below, the suffix goes on the
// branch name itself — the path is derived from it anyway.
let mut candidate = branch.to_string();
let mut path = self.target_path(base_repo, &candidate);
let mut n = 1;
while path.symlink_metadata().is_ok() || branch_exists(base_repo, &candidate) {
n += 1;
candidate = format!("{branch}-{n}");
path = self.target_path(base_repo, &candidate);
}
(candidate, path)
} else {
// Existing branch: the name must stay exact, so only the
// directory — anything occupying it, ours or the operator's — is
// sidestepped with a numeric suffix (#77). The daemon never
// adopts what it finds.
let mut path = self.target_path(base_repo, branch);
let mut n = 1;
while path.symlink_metadata().is_ok() {
n += 1;
path = self.target_path_suffixed(base_repo, branch, n);
}
(branch.to_string(), path)
};
// The parent (`<repo>/worktrees` or the override root) may not exist
// yet; the target itself must not.
if let Some(parent) = path.parent() {
@@ -129,10 +146,10 @@ impl WorktreeManager {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
if create_branch {
cmd.arg("-b").arg(branch).arg(&path);
cmd.arg("-b").arg(&branch).arg(&path);
} else {
// Existing branch: `worktree add <path> <branch>` with no `-b`.
cmd.arg(&path).arg(branch);
cmd.arg(&path).arg(&branch);
}
let out = cmd.output()?;
check(out)?;
@@ -266,6 +283,17 @@ fn branch_relpath(branch: &str) -> Option<PathBuf> {
Some(candidate.to_path_buf())
}
/// Whether `refs/heads/<branch>` already exists in `base_repo`.
fn branch_exists(base_repo: &Path, branch: &str) -> bool {
Command::new("git")
.arg("-C")
.arg(base_repo)
.args(["show-ref", "--verify", "--quiet"])
.arg(format!("refs/heads/{branch}"))
.status()
.is_ok_and(|s| s.success())
}
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
/// no longer exists.
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
+51
View File
@@ -497,6 +497,57 @@ async fn occupied_path_gets_suffixed_directory_and_both_worktrees_work() {
);
}
/// A random handle can reuse the name of a branch left behind by an earlier,
/// already-removed worktree — `git worktree add -b` refuses outright in that
/// case, even though the directory is free. The daemon suffixes the branch
/// itself (not just the directory) and both land on `-2`.
#[tokio::test(flavor = "multi_thread")]
async fn colliding_branch_name_gets_suffixed_too() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
// No worktree directory in the way, but the branch already exists.
run_git(&["branch", "handle"], base.path());
let s = client
.spawn_session(&worktree_req(base.path(), "handle", true))
.await
.expect("spawn onto a taken branch name");
let suffixed = base.path().join("worktrees").join("handle-2");
assert_eq!(
s.cwd, suffixed,
"the directory must follow the suffixed branch name"
);
assert!(suffixed.is_dir());
let registrations = git_worktree_list(base.path());
assert!(
registrations.iter().any(|l| l.contains("handle-2")),
"the -2 branch's worktree must be registered: {registrations:?}"
);
let branches = String::from_utf8(
std::process::Command::new("git")
.arg("-C")
.arg(base.path())
.args(["branch", "--list"])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
branches.contains("handle-2"),
"the branch itself must be named handle-2: {branches}"
);
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
/// #77/#88: the target path holds a plain directory the daemon never made and
/// git has never heard of. The daemon neither adopts it nor errors — it builds
/// beside it and leaves every byte of it alone.
+3
View File
@@ -29,3 +29,6 @@ tracing-subscriber.workspace = true
maestro-cli = { path = "../maestro-cli", optional = true, default-features = false }
maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false }
maestro-tui = { path = "../maestro-tui", optional = true }
[dev-dependencies]
tempfile.workspace = true
+102
View File
@@ -0,0 +1,102 @@
//! Process-level routing for the umbrella `maestro` binary (#10): each
//! enabled top-level subcommand must reach its own crate's runtime dispatch,
//! not just parse successfully.
//!
//! A malformed `--config` file makes every crate's dispatch fail identically
//! at config load, before any subcommand touches tmux, the network, or a
//! terminal. That lets the assertions tell "clap parsed it but the handler
//! never ran" (a usage error, exit 2) apart from "it dispatched into the real
//! crate" (a `toml decode` runtime error via `main`'s `maestro: {e}` printer,
//! exit 1).
//!
//! The absence tests below only compile when the matching feature is off, so
//! they run under CI's per-feature-subset matrix (`--no-default-features
//! --features cli|daemon|tui`) rather than the default all-features build.
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Output};
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_maestro"))
}
fn bad_config() -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().expect("create temp config file");
write!(f, "this is not valid toml {{{{{{").expect("write temp config");
f
}
fn run(args: &[&str]) -> Output {
Command::new(bin())
.args(args)
.output()
.unwrap_or_else(|e| panic!("spawn `maestro {args:?}`: {e}"))
}
/// Real dispatch happened: exit 1, `main`'s `"maestro: {e}"` prefix, and the
/// config crate's error surfacing through — never clap's own usage-error
/// page, which would mean the subcommand never reached its handler.
fn assert_dispatched(out: &Output) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "stderr:\n{stderr}");
assert!(stderr.starts_with("maestro: "), "stderr:\n{stderr}");
assert!(stderr.contains("toml decode"), "stderr:\n{stderr}");
assert!(!stderr.contains("Usage:"), "stderr:\n{stderr}");
}
/// Never reached a handler: clap rejected the subcommand outright because the
/// feature that defines it is off. Only used by the `not(feature = "...")`
/// tests below, which themselves only exist in a single-feature build.
#[cfg(not(all(feature = "cli", feature = "daemon", feature = "tui")))]
fn assert_unrecognized(out: &Output, name: &str) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "stderr:\n{stderr}");
assert!(
stderr.contains("unrecognized subcommand"),
"stderr:\n{stderr}"
);
assert!(stderr.contains(name), "stderr:\n{stderr}");
}
#[cfg(feature = "cli")]
#[test]
fn list_dispatches_into_maestro_cli() {
let cfg = bad_config();
let out = run(&["list", "--config", cfg.path().to_str().unwrap()]);
assert_dispatched(&out);
}
#[cfg(not(feature = "cli"))]
#[test]
fn list_is_unrecognized_without_cli_feature() {
assert_unrecognized(&run(&["list"]), "list");
}
#[cfg(feature = "daemon")]
#[test]
fn daemon_dispatches_into_maestro_daemon() {
let cfg = bad_config();
let out = run(&["daemon", "--config", cfg.path().to_str().unwrap()]);
assert_dispatched(&out);
}
#[cfg(not(feature = "daemon"))]
#[test]
fn daemon_is_unrecognized_without_daemon_feature() {
assert_unrecognized(&run(&["daemon"]), "daemon");
}
#[cfg(feature = "tui")]
#[test]
fn tui_dispatches_into_maestro_tui() {
let cfg = bad_config();
let out = run(&["tui", "--config", cfg.path().to_str().unwrap()]);
assert_dispatched(&out);
}
#[cfg(not(feature = "tui"))]
#[test]
fn tui_is_unrecognized_without_tui_feature() {
assert_unrecognized(&run(&["tui"]), "tui");
}
+12 -4
View File
@@ -35,10 +35,18 @@ The branch name is used verbatim as a relative path, so `feature/login` becomes
back to a short hash of the branch.
If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds
a free one. The suffix names the **directory only**. The branch is whatever was
requested; a second worktree of the same branch is not what happens here, because
git refuses to check the same branch out twice and the branch picker already
marks branches that are checked out elsewhere.
a free one.
For a *new* branch (`create_branch: true`), the suffix goes on the branch name
itself, and the directory follows since it is derived from the branch — a
random handle can otherwise reuse the name of a branch some earlier, already
removed worktree left behind, and `git worktree add -b` refuses outright even
though the directory is free.
For an *existing* branch (`create_branch: false`) the name must stay exact, so
only the directory is suffixed — a second worktree of the same branch is not
what happens here, because git refuses to check the same branch out twice and
the branch picker already marks branches that are checked out elsewhere.
### The `worktree_root` override
+26 -6
View File
@@ -43,6 +43,10 @@ export interface AttachConnection {
* handling — the leader's literal Ctrl-K escape hatch
* (docs/COMMAND-BAR.md). */
sendInput(data: string): void;
/** Refresh the header's name/command (e.g. after autoname renames the
* session) — the connection outlives mount/unmount, so this is the only
* way an already-open header picks up a new name. */
updateInfo(info: AttachInfo): void;
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
dispose(): void;
}
@@ -85,21 +89,25 @@ export function createAttachConnection(
el.className = "attach";
// Useful identity over a bare UUID: name · command. The full id stays in the
// tooltip for when you really need it.
const titleHtml = info
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
const titleMarkup = (i?: AttachInfo): { html: string; attr: string } => ({
html: i
? `<span class="at-name">${escapeHtml(i.name)}</span>` +
(i.command && i.command !== i.name ? commandFragment(i.command) : "")
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`,
attr: i ? `${i.name} · ${i.command} (${sessionId})` : sessionId,
});
const initialTitle = titleMarkup(info);
el.innerHTML = `
<header class="attach-header">
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
<span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span>
<span class="attach-title" title="${escapeHtml(initialTitle.attr)}">${initialTitle.html}</span>
<span class="attach-state" data-state="connecting">connecting…</span>
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
</header>
<div class="attach-body"></div>
`;
const titleEl = el.querySelector(".attach-title") as HTMLSpanElement;
const body = el.querySelector(".attach-body") as HTMLDivElement;
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
@@ -450,6 +458,12 @@ export function createAttachConnection(
const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows });
});
// xterm has no built-in copy-on-select; mirror the selection to the
// clipboard ourselves (best-effort — insecure contexts lack the API).
const selectionDisp = term.onSelectionChange(() => {
const text = term.getSelection();
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
});
window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null;
@@ -493,6 +507,11 @@ export function createAttachConnection(
// The closure above: the same bytes-out path keystrokes and the mobile key
// bar already take.
sendInput,
updateInfo(next: AttachInfo) {
const { html, attr } = titleMarkup(next);
titleEl.innerHTML = html;
titleEl.title = attr;
},
dispose() {
if (disposed) return;
disposed = true;
@@ -505,6 +524,7 @@ export function createAttachConnection(
visual?.removeEventListener("resize", onResize);
dataDisp.dispose();
resizeDisp.dispose();
selectionDisp.dispose();
releaseWebgl();
try {
ws.close();
+1
View File
@@ -16,6 +16,7 @@ vi.mock("./attach.js", () => ({
sendInput(data: string) {
sent.push({ id, data });
},
updateInfo() {},
dispose() {},
}),
}));
+1
View File
@@ -79,6 +79,7 @@ export class ConnectionPool {
if (conn) {
this.cache.delete(id);
this.cache.set(id, conn);
if (info) conn.updateInfo(info);
} else {
conn = createAttachConnection(
id,