Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/docs/content/docs/cli/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,54 @@ superset workspaces create \
```
</Command>

<Command
name="superset workspaces import"
options={[
{ flag: "--project <projectId>", required: true, description: "Project ID." },
{ flag: "--path <worktreePath>", required: true, description: "Absolute path of the existing worktree on the target host." },
{ flag: "--branch <branch>", required: true, description: "Branch checked out in that worktree." },
{ flag: "--name <name>", description: "Workspace name. Defaults to the worktree directory name." },
{ flag: "--base-branch <branch>", description: "Branch to record as the compare base for this workspace." },
{ flag: "--host <id>", description: "Target host machineId. Mutually exclusive with `--local`; one is required." },
{ flag: "--local", description: "Target this machine. Mutually exclusive with `--host`; one is required." },
]}
output={`{
workspace: Workspace;
terminals: Array<{ terminalId: string; label?: string }>;
warnings: string[];
branchMismatch: boolean;
}`}
>
Register a git worktree that **already exists** on the target host as a
workspace, instead of creating a new one. Use this when a script prepared the
branch and directory under its own naming rules and Superset only needs to
manage sessions for it — the CLI counterpart of Project Settings → Import in
the desktop app.

`--path` is interpreted on the target host, so it must be absolute, and the
worktree must already be registered in that project's `git worktree list`
(run `git worktree add` first). A path that belongs to a different project's
repo is rejected.

The host reads the branch actually checked out at `--path` rather than
trusting `--branch`. If the two disagree, the returned workspace keeps the
real branch and `branchMismatch` is `true`.

Importing is idempotent: running it again for the same path reuses the
existing workspace, and a workspace whose branch was renamed in place is
re-pointed at the new branch rather than duplicated.

```bash
git -C ~/code/repo worktree add -b eng-123/login-fix ~/code/repo/.worktrees/eng-123

superset workspaces import \
--project prj_… \
--path ~/code/repo/.worktrees/eng-123 \
--branch eng-123/login-fix \
--local
```
</Command>

<Command
name="superset workspaces delete <id...>"
args={[
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/mcp-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ API keys grant full access to your organization. Keep them secret and never comm
|------|-------------|
| `workspaces_list` | List workspaces on a host (get the hostId from `hosts_list`) |
| `workspaces_create` | Create a workspace on a host (branch-scoped git worktree) |
| `workspaces_import` | Import a git worktree that already exists on a host as a workspace |
| `workspaces_update` | Update fields on a workspace on its host |
| `workspaces_delete` | Delete a workspace by UUID on its host |

Expand Down
118 changes: 118 additions & 0 deletions packages/cli/src/commands/workspaces/import/command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { afterEach, describe, expect, mock, test } from "bun:test";

let adoptInput: Record<string, unknown> | undefined;
let adoptedBranch = "feature/from-linear";

mock.module("../../../lib/host-target", () => ({
requireHostTarget: () => "host-1",
resolveHostTarget: () => ({
hostId: "host-1",
client: {
workspaceCreation: {
adopt: {
mutate: async (input: Record<string, unknown>) => {
adoptInput = input;
return {
workspace: {
id: "ws-1",
name: input.workspaceName,
branch: adoptedBranch,
},
terminals: [],
warnings: [],
};
},
},
},
},
}),
}));

const { default: importWorkspaceCommand } = await import("./command");

interface ImportResult {
data: {
workspace: { id: string; name: string; branch: string };
branchMismatch: boolean;
};
message: string;
}

function invoke(
overrides: {
path?: string;
branch?: string;
name?: string;
baseBranch?: string;
} = {},
): Promise<ImportResult> {
return importWorkspaceCommand.run({
ctx: {
config: { organizationId: "org-1" },
bearer: "bearer",
} as never,
args: {} as never,
options: {
local: true,
project: "project-1",
path: "/repo/.worktrees/from-linear",
branch: "feature/from-linear",
...overrides,
} as never,
signal: new AbortController().signal,
}) as Promise<ImportResult>;
}

afterEach(() => {
adoptInput = undefined;
adoptedBranch = "feature/from-linear";
});

describe("workspaces import", () => {
test("adopts a pre-existing worktree by path on the target host", async () => {
const result = await invoke();

expect(adoptInput).toMatchObject({
projectId: "project-1",
worktreePath: "/repo/.worktrees/from-linear",
branch: "feature/from-linear",
});
expect(result.data).toMatchObject({
workspace: { id: "ws-1", branch: "feature/from-linear" },
});
});

test("defaults the workspace name to the worktree directory name", async () => {
await invoke();

expect(adoptInput).toMatchObject({ workspaceName: "from-linear" });
});

test("honours an explicit --name", async () => {
await invoke({ name: "Linear ENG-123" });

expect(adoptInput).toMatchObject({ workspaceName: "Linear ENG-123" });
});

test("forwards --base-branch so the host records the compare base", async () => {
await invoke({ baseBranch: "main" });

expect(adoptInput).toMatchObject({ baseBranch: "main" });
});

test("rejects a relative --path instead of resolving it against the CLI cwd", async () => {
await expect(invoke({ path: ".worktrees/from-linear" })).rejects.toThrow(
/absolute path/i,
);
expect(adoptInput).toBeUndefined();
});

test("reports a branch mismatch when the worktree has another branch checked out", async () => {
adoptedBranch = "feature/actually-this-one";

const result = await invoke({ branch: "feature/from-linear" });

expect(result.data).toMatchObject({ branchMismatch: true });
expect(result.message).toContain("feature/actually-this-one");
});
});
86 changes: 86 additions & 0 deletions packages/cli/src/commands/workspaces/import/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { posix } from "node:path";
import { boolean, CLIError, string } from "@superset/cli-framework";
import { command } from "../../../lib/command";
import { requireHostTarget, resolveHostTarget } from "../../../lib/host-target";

export default command({
description: "Import an existing git worktree on a host as a workspace",
options: {
host: string().desc("Target host machineId"),
local: boolean().desc("Target this machine"),
project: string().required().desc("Project ID"),
path: string()
.required()
.desc("Absolute path of the existing worktree on the target host"),
branch: string().required().desc("Branch checked out in that worktree"),
name: string().desc(
"Workspace name (defaults to the worktree directory name)",
),
baseBranch: string().desc("Branch to record as the compare base"),
},
run: async ({ ctx, options }) => {
const organizationId = ctx.config.organizationId;
if (!organizationId) {
throw new CLIError("No active organization", "Run: superset auth login");
}

// The path is interpreted on the target host, which may not be this
// machine — resolving it against the CLI's cwd would silently point
// at the wrong directory.
if (!posix.isAbsolute(options.path)) {
throw new CLIError(
`--path must be an absolute path on the target host: ${options.path}`,
"Pass the full worktree path, e.g. --path /Users/me/code/repo/.worktrees/my-branch",
);
}
const worktreePath = posix.normalize(options.path).replace(/\/+$/, "");

const branch = options.branch.trim();
if (!branch) {
throw new CLIError(
"--branch is empty",
"Pass the branch checked out in the worktree",
);
}

const workspaceName = options.name ?? posix.basename(worktreePath);
if (!workspaceName) {
throw new CLIError(
"Could not derive a workspace name from --path",
"Pass --name <name>",
);
}

const hostId = requireHostTarget({
host: options.host ?? undefined,
local: options.local ?? undefined,
});

const target = resolveHostTarget({
requestedHostId: hostId,
organizationId,
userJwt: ctx.bearer,
});

const result = await target.client.workspaceCreation.adopt.mutate({
projectId: options.project,
workspaceName,
branch,
worktreePath,
baseBranch: options.baseBranch ?? undefined,
});

// The host reads the branch actually checked out at the path rather
// than trusting `--branch`, so surface a disagreement instead of
// silently registering a different branch than the caller asked for.
const adoptedBranch = result.workspace.branch;
const branchMismatch = adoptedBranch !== branch;

return {
data: { ...result, branchMismatch },
message: branchMismatch
? `Imported worktree ${worktreePath} as workspace "${result.workspace.name}" on host ${target.hostId} — it has "${adoptedBranch}" checked out, not "${branch}"`
: `Imported worktree ${worktreePath} as workspace "${result.workspace.name}" on host ${target.hostId}`,
};
},
});
2 changes: 2 additions & 0 deletions packages/mcp-v2/src/tools/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import * as terminalsRead from "./terminals/read";
import * as terminalsSend from "./terminals/send";
import * as workspacesCreate from "./workspaces/create";
import * as workspacesDelete from "./workspaces/delete";
import * as workspacesImport from "./workspaces/import";
import * as workspacesList from "./workspaces/list";
import * as workspacesUpdate from "./workspaces/update";

Expand All @@ -57,6 +58,7 @@ const REGISTRARS = [
automationsLogs,
workspacesList,
workspacesCreate,
workspacesImport,
workspacesUpdate,
workspacesDelete,
agentsCreate,
Expand Down
Loading