The existing-branch picker could never succeed in either client: both treated the main checkout's HEAD as available, and since that is usually `main` it became the default pick — git then refuses the second checkout. A TUI test had baked the bug in by asserting the selection lands on the `is_head` entry. Both pickers now treat "checked out anywhere" as held. Worktree paths were still not injective. Flattening `/` and `.` to `-` made `feat/x`, `feat.x` and `feat-x` share one directory, and the override root keyed on the repo basename alone, so `~/a/maestro` and `~/b/maestro` collided. A branch name is already a valid relative path — git stores it as refs/heads/feat/example — so it is used verbatim and nests under `worktrees/feat/`; the override root gains a digest of the full repo path. Git forbids holding both `feat` and `feat/x`, so the nesting cannot collide for valid refs. Also: - Recovered sessions dropped their worktree association, so after a restart a kill cleaned up nothing and the leftover directory blocked that branch forever. It is persisted on Session now. - The nesting guard compared git's resolved paths against an unresolved one, so it silently passed on any symlinked repo path — and that guard is what stands between a kill and an inner tree's uncommitted work. - Draft loads ran on the TUI event loop: /api/projects on a cold cache walks all of $HOME, freezing keys, frames and attached panes for up to the request timeout. They are detached tasks posting to a channel now. - Fast-forward in the web client created the session and never mounted it, so `N` looked like a dead key while the TUI attached. - The fast-forward fallback seed forced the worktree toggle off, against both a normal draft and the TUI. - The projects background refresh honoured the TTL, so two of every three ticks were no-ops and requests paid for the rescan instead.
97 lines
3.2 KiB
TypeScript
97 lines
3.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
buildFastForwardRequest,
|
|
fastForwardSeed,
|
|
resolveFastForward,
|
|
} from "./fastForward.js";
|
|
import type { Project, SpawnPreset } from "./types.js";
|
|
|
|
const presets: SpawnPreset[] = [
|
|
{ id: "custom", label: "Custom…", command: "" },
|
|
{ id: "claude", label: "Claude", command: "claude" },
|
|
];
|
|
|
|
function project(overrides: Partial<Project>): Project {
|
|
return {
|
|
path: "/repos/p",
|
|
name: "p",
|
|
last_activity: new Date().toISOString(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("resolveFastForward (#67)", () => {
|
|
it("resolves every default from the most recent project", () => {
|
|
const projects = [
|
|
project({ path: "/repos/old", name: "old", last_activity: "2020-01-01T00:00:00Z" }),
|
|
project({
|
|
path: "/repos/fresh",
|
|
name: "fresh",
|
|
base_repo: "/repos/fresh",
|
|
last_command: "aider",
|
|
}),
|
|
];
|
|
const res = resolveFastForward(projects, presets, "amber-falcon");
|
|
expect(res.project?.path).toBe("/repos/fresh");
|
|
expect(res.command).toBe("aider");
|
|
expect(res.name).toBe("amber-falcon");
|
|
|
|
const req = buildFastForwardRequest(res)!;
|
|
expect(req.cwd).toBe("/repos/fresh");
|
|
expect(req.command).toBe("aider");
|
|
expect(req.metadata).toEqual({ name: "amber-falcon" });
|
|
expect(req.worktree).toEqual({
|
|
base_repo: "/repos/fresh",
|
|
branch: "amber-falcon",
|
|
cleanup_on_kill: true,
|
|
create_branch: true,
|
|
});
|
|
});
|
|
|
|
it("falls back to the first non-custom preset when there is no last command", () => {
|
|
const res = resolveFastForward([project({ last_command: undefined })], presets, "n");
|
|
expect(res.command).toBe("claude");
|
|
});
|
|
|
|
it("treats an empty last command as no command", () => {
|
|
const res = resolveFastForward([project({ last_command: "" })], [presets[0]], "n");
|
|
expect(res.command).toBeUndefined();
|
|
expect(buildFastForwardRequest(res)).toBeNull();
|
|
});
|
|
|
|
it("resolves nothing when no projects are known yet", () => {
|
|
const res = resolveFastForward([], presets, "n");
|
|
expect(res.project).toBeUndefined();
|
|
expect(buildFastForwardRequest(res)).toBeNull();
|
|
});
|
|
|
|
it("downgrades to a draft when the generated name is missing", () => {
|
|
const res = resolveFastForward([project({ last_command: "claude" })], presets);
|
|
expect(res.name).toBeUndefined();
|
|
expect(buildFastForwardRequest(res)).toBeNull();
|
|
});
|
|
|
|
it("seeds the fallback draft with whatever did resolve", () => {
|
|
const res = resolveFastForward(
|
|
[project({ path: "/repos/p", base_repo: "/repos/p", last_command: "aider" })],
|
|
presets,
|
|
);
|
|
expect(fastForwardSeed(res)).toEqual({
|
|
cwd: "/repos/p",
|
|
command: "aider",
|
|
name: "",
|
|
worktreeEnabled: true,
|
|
worktreeBranch: undefined,
|
|
});
|
|
});
|
|
|
|
it("leaves the worktree default alone when not even a project resolved", () => {
|
|
// Not `false`: the form defaults the toggle on and a false would stick,
|
|
// so the fallback draft would disagree with a normally-opened draft and
|
|
// with the TUI's fast path.
|
|
const seed = fastForwardSeed(resolveFastForward([], presets));
|
|
expect(seed.worktreeEnabled).toBeUndefined();
|
|
expect(seed.cwd).toBe("");
|
|
});
|
|
});
|