Files
kandev/apps/web/e2e/tests/task/local-executor-branch-split.spec.ts
T
João Salavisa 6ceda6ae4e fix(local-executor): preserve commits in changes panel after refresh (#834)
* fix(local-executor): preserve commits in changes panel after refresh

For a local executor on a feature branch, the create-task dialog sent
the chip's branch as base_branch with empty checkout_branch. agentctl
then recomputed merge-base(HEAD, origin/<branch>) on every changes-panel
refresh; for a never-pushed branch that collapsed to HEAD and the panel
went empty. Repository creation also pinned default_branch to .git/HEAD,
permanently anchoring the repo's integration ref to whatever feature
branch the user happened to be on at first task-create time.

The frontend now splits the chip into base_branch=default_branch +
checkout_branch=working_branch (skipping the split when fresh-branch is
on, where the chip semantically means "fork from"). The backend probes
origin/HEAD then origin/main → main → master (new common/gitref
package) to pick a repo's integration branch, and resolveRepoInput no
longer falls back to BaseBranch when default_branch is missing.

Covered by 9 unit tests on the helper, 6 Go tests on the probe order,
and 7 e2e tests across plain creation, the fork toggle, and the
validate-path probe.

* fix: address PR review feedback

- gitref: preserve slashes in HEAD-fallback branch names (TrimPrefix
  refs/heads/ instead of Split + last component); branches like
  "feature/my-feature" no longer collapse to "my-feature" and poison
  the stored default_branch.
- gitref: drop the broken packed-refs symref scan in readOriginHEAD;
  the loose refs/remotes/origin/HEAD check + pickFirstExistingBranch
  candidates already cover every realistic clone.
- service.resolveRepoInput: validate repoInput.LocalPath via
  resolveAllowedLocalPath() before probing — closes 8 CodeQL
  path-injection alerts.
- buildRepositoriesPayload: fall through to the legacy non-split shape
  when default_branch is unknown for the row (legacy repos with unset
  default_branch). Synthesizing base_branch=rowBranch was reproducing
  the very bug this PR fixes.

* fix(gitref): explicit traversal guard at I/O boundary for CodeQL

Move the path validation from service.resolveAllowedLocalPath (which
CodeQL's taint analysis doesn't recognize as a sanitizer) into a
guardRepoPath check at gitref.DefaultBranch's entry. The new check
rejects relative paths and any '..' segments before any os.Stat /
os.ReadFile call sees the value, which CodeQL recognizes as a
go/path-injection sanitizer.

The upstream allowlist check stays in place — this is a belt-and-
suspenders second barrier at the actual I/O boundary, and it also
makes the gitref package safe for any future callers (migrations,
tests) without requiring them to thread a Service in.

* fix: address review feedback on hardening + e2e race

- gitref.guardRepoPath: check the RAW input for '..' segments before
  filepath.Clean. Clean resolves '..' away from absolute paths
  ("/allowed/../etc" -> "/etc"), so iterating the cleaned path was a
  silent no-op for the very inputs we wanted to reject. Split on both
  '/' and the OS separator. Updated the test with three traversal
  cases.
- gitref_test.go: switch from strings.EqualFold to strict equality —
  git branch names are case-sensitive, "Main" should not pass for
  "main".
- e2e: replace waitForRequest with waitForResponse in
  local-executor-branch-split.spec.ts. waitForRequest resolves on send
  (before the server has persisted the task), so the followup
  apiClient.getTask call could race the write on slow CI. The two
  tests that need the request body keep access via
  (await waitForResponse).request().

* fix(ws): forward CheckoutBranch in wsUpdateTask

The PR fix added CheckoutBranch forwarding to wsCreateTask but missed
the same conversion loop in wsUpdateTask. Any task-update payload with
a repositories array would silently zero out checkout_branch and
re-introduce the merge-base collapse on refresh — same root cause this
PR fixes, in the update path.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-05 22:05:01 +01:00

595 lines
24 KiB
TypeScript

import path from "node:path";
import fs from "node:fs";
import { execSync } from "node:child_process";
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { makeGitEnv } from "../../helpers/git-helper";
/**
* Regression suite for the "new branch on local executor empties the changes
* panel after refresh" bug.
*
* Failure mode that originally shipped:
* 1. The dialog seeded the chip with the user's currently-checked-out branch
* and submitted it as `base_branch`.
* 2. agentctl recomputed merge-base(HEAD, origin/<chip-branch>) on every
* refresh; for a never-pushed feature branch that collapsed to HEAD and
* `git log HEAD..HEAD` returned empty.
* 3. Live polling kept showing commits via WS while the user was on the page,
* but the moment they refreshed, the panel went blank.
*
* The forward fix splits the chip's branch into two pieces for the local
* executor (the only mode where there is no worktree and the chip semantics
* differ): `base_branch` anchors to the repo's `default_branch` (the
* integration target — what the merge-base needs to reference), and
* `checkout_branch` carries the actual working branch the preparer should
* check out. The shape is what these tests assert end-to-end.
*
* They also pin the second half of the fix: `repositories.default_branch`
* must be probed from the repo's actual integration ref (origin/HEAD,
* origin/main, origin/master, …), not from `.git/HEAD` — otherwise a user
* who first ran the dialog while checked out on a feature branch would
* permanently pin their repo's default to that feature branch, and every
* downstream merge-base lookup would be anchored wrong.
*/
test.describe("Local executor branch split", () => {
test.describe.configure({ retries: 1 });
type Setup = {
repoDir: string;
repoName: string;
repositoryId: string;
profileId: string;
profileName: string;
defaultBranchOnDisk: string;
};
/**
* Build an isolated repo per test inside backend.tmpDir so:
* - The discoveryRoots() allowlist accepts it (createRepository hits the
* same path validation as the dialog).
* - `git init -b main` ensures the integration branch exists locally and
* readGitDefaultBranch will probe it correctly.
* - Multiple feature branches let the chip exercise both "match default"
* and "differs from default" paths.
*/
async function setupLocalRepo(opts: {
apiClient: import("../../helpers/api-client").ApiClient;
backendTmpDir: string;
workspaceId: string;
suffix: string;
/** Branch the test wants the working tree checked out on at task-create
* time. Must already be created below. */
headBranch: string;
/** Extra branches to create alongside `main`. The chip dropdown lists
* them; the test picks one to drive the split. */
extraBranches?: string[];
}): Promise<Setup | null> {
const { executors } = await opts.apiClient.listExecutors();
const localExec = executors.find((e) => e.type === "local");
if (!localExec) return null;
const profileName = `E2E Branch Split ${opts.suffix}`;
const profile = await opts.apiClient.createExecutorProfile(localExec.id, profileName);
const repoName = `E2E Branch Split Repo ${opts.suffix}`;
const repoDir = path.join(opts.backendTmpDir, "repos", `e2e-branch-split-${opts.suffix}`);
fs.mkdirSync(repoDir, { recursive: true });
const env = makeGitEnv(opts.backendTmpDir);
execSync("git init -b main", { cwd: repoDir, env });
execSync('git commit --allow-empty -m "init on main"', { cwd: repoDir, env });
for (const branch of opts.extraBranches ?? []) {
execSync(`git checkout -B ${branch}`, { cwd: repoDir, env });
execSync(`git commit --allow-empty -m "${branch} commit"`, { cwd: repoDir, env });
}
execSync(`git checkout ${opts.headBranch}`, { cwd: repoDir, env });
// Register the repo with default_branch=main so the workspace store has
// the correct integration ref. The frontend's split logic looks this
// value up to populate base_branch.
const repo = await opts.apiClient.createRepository(opts.workspaceId, repoDir, "main", {
name: repoName,
});
return {
repoDir,
repoName,
repositoryId: repo.id,
profileId: profile.id,
profileName,
defaultBranchOnDisk: "main",
};
}
function escapeRe(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Drives the create-task dialog up to the point of submission with the
* local executor profile and the seeded repo selected. Returns when
* submission has fired so callers can read back the persisted task.
*/
async function createTaskViaDialog(opts: {
testPage: import("@playwright/test").Page;
title: string;
description: string;
profileName: string;
repoName: string;
/** When set, click the chip and pick this branch; otherwise leave the
* auto-seeded current branch. */
pickBranch?: string;
}) {
const { testPage } = opts;
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
await testPage.getByTestId("task-title-input").fill(opts.title);
await testPage.getByTestId("task-description-input").fill(opts.description);
await testPage.getByTestId("repo-chip-trigger").first().click();
await testPage
.getByRole("option", { name: new RegExp(`^${escapeRe(opts.repoName)}\\b`, "i") })
.first()
.click();
await testPage.getByTestId("executor-profile-selector").click();
await testPage
.getByRole("option", { name: new RegExp(`^${escapeRe(opts.profileName)}\\b`, "i") })
.first()
.click();
// Wait for the chip to settle on the workspace's current branch — the
// autoselect effect runs after currentLocalBranch resolves, and submitting
// before then would race with the very logic we're testing.
const branchSelector = testPage.getByTestId("branch-chip-trigger").first();
await expect(branchSelector).toBeEnabled({ timeout: 5_000 });
if (opts.pickBranch) {
await branchSelector.click();
await testPage
.getByRole("option", { name: new RegExp(`^${escapeRe(opts.pickBranch)}\\b`) })
.first()
.click();
await expect(branchSelector).toContainText(opts.pickBranch);
}
// Use the "create without starting agent" path so the test doesn't wait
// for agent boot. We care about the persisted task shape, not session
// lifecycle. The chevron opens the split-button menu.
//
// Wait on the *response* rather than the request — waitForRequest
// resolves the moment the browser sends the POST, before the server has
// finished writing to the database. Callers that read the task back via
// the API immediately after would race the persistence.
const createTaskResponse = opts.testPage.waitForResponse(
(res) => res.url().endsWith("/api/v1/tasks") && res.request().method() === "POST",
);
await testPage.getByTestId("submit-start-agent-chevron").click();
await testPage.getByTestId("submit-create-without-agent").click();
await createTaskResponse;
}
test("submits with base_branch=default_branch and checkout_branch=working_branch", async ({
testPage,
apiClient,
backend,
seedData,
}) => {
// The pre-fix shape was {base_branch: "feature/x", checkout_branch: ""}
// which caused the post-refresh empty-panel bug. The new shape isolates
// the integration ref from the working branch so the merge-base
// recomputation downstream lands on the actual fork point instead of
// collapsing to HEAD.
const setup = await setupLocalRepo({
apiClient,
backendTmpDir: backend.tmpDir,
workspaceId: seedData.workspaceId,
suffix: "split",
headBranch: "feature/x",
extraBranches: ["feature/x"],
});
if (!setup) {
test.skip(true, "No local executor available");
return;
}
try {
await createTaskViaDialog({
testPage,
title: "Local executor branch split",
description: "regression: base/checkout split for local executor",
profileName: setup.profileName,
repoName: setup.repoName,
});
// Find the just-created task by its title and assert its repository row.
const tasksRes = await apiClient.rawRequest(
"GET",
`/api/v1/workspaces/${seedData.workspaceId}/tasks`,
);
const tasksJson = (await tasksRes.json()) as {
tasks: Array<{ id: string; title: string }>;
};
const task = tasksJson.tasks.find((t) => t.title === "Local executor branch split");
expect(task, "task should exist").toBeTruthy();
const fullTask = await apiClient.getTask(task!.id);
const taskRepo = fullTask.repositories?.[0];
expect(taskRepo, "task should have a repository row").toBeTruthy();
expect(taskRepo!.repository_id).toBe(setup.repositoryId);
expect(
taskRepo!.base_branch,
"base_branch must be the repo's default_branch — that's what the changes panel uses as merge-base reference",
).toBe("main");
expect(
taskRepo!.checkout_branch,
"checkout_branch carries the working branch the preparer must switch to",
).toBe("feature/x");
} finally {
await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {});
}
});
test("checkout_branch is omitted when picked branch matches default_branch", async ({
testPage,
apiClient,
backend,
seedData,
}) => {
// When the user is on `main` (matching default_branch), there's nothing
// for the preparer to check out. The frontend should NOT send a redundant
// checkout_branch — the LocalPreparer's "skip when current matches"
// optimization relies on it being absent so we don't fire spurious git
// ops on every task creation.
const setup = await setupLocalRepo({
apiClient,
backendTmpDir: backend.tmpDir,
workspaceId: seedData.workspaceId,
suffix: "match",
headBranch: "main",
});
if (!setup) {
test.skip(true, "No local executor available");
return;
}
try {
await createTaskViaDialog({
testPage,
title: "Branch matches default",
description: "no checkout_branch needed",
profileName: setup.profileName,
repoName: setup.repoName,
});
const tasksRes = await apiClient.rawRequest(
"GET",
`/api/v1/workspaces/${seedData.workspaceId}/tasks`,
);
const tasksJson = (await tasksRes.json()) as {
tasks: Array<{ id: string; title: string }>;
};
const task = tasksJson.tasks.find((t) => t.title === "Branch matches default");
expect(task, "task should exist").toBeTruthy();
const fullTask = await apiClient.getTask(task!.id);
const taskRepo = fullTask.repositories?.[0];
expect(taskRepo!.base_branch).toBe("main");
// omitempty on the DTO — empty string round-trips as undefined/missing.
expect(taskRepo!.checkout_branch ?? "").toBe("");
} finally {
await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {});
}
});
test("explicit branch pick: chip switches to develop, payload still splits correctly", async ({
testPage,
apiClient,
backend,
seedData,
}) => {
// The chip auto-seeds with the workspace's current branch (main here),
// but the user can switch to any existing branch. The split must still
// anchor base_branch to default_branch regardless of how the chip got
// its value (autoselect vs explicit pick).
const setup = await setupLocalRepo({
apiClient,
backendTmpDir: backend.tmpDir,
workspaceId: seedData.workspaceId,
suffix: "pick",
headBranch: "main",
extraBranches: ["develop"],
});
if (!setup) {
test.skip(true, "No local executor available");
return;
}
try {
await createTaskViaDialog({
testPage,
title: "Branch pick split",
description: "user explicitly picked develop",
profileName: setup.profileName,
repoName: setup.repoName,
pickBranch: "develop",
});
const tasksRes = await apiClient.rawRequest(
"GET",
`/api/v1/workspaces/${seedData.workspaceId}/tasks`,
);
const tasksJson = (await tasksRes.json()) as {
tasks: Array<{ id: string; title: string }>;
};
const task = tasksJson.tasks.find((t) => t.title === "Branch pick split");
expect(task, "task should exist").toBeTruthy();
const fullTask = await apiClient.getTask(task!.id);
const taskRepo = fullTask.repositories?.[0];
expect(taskRepo!.base_branch).toBe("main");
expect(taskRepo!.checkout_branch).toBe("develop");
} finally {
await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {});
}
});
});
/**
* Fresh-branch flow has different chip semantics: row.branch is the base to
* fork FROM, not a working branch. The split must NOT apply here — otherwise
* picking a non-default base would silently fork from main instead of the
* picked base.
*/
test.describe("Local executor + fresh-branch toggle", () => {
test.describe.configure({ retries: 1 });
type Setup = {
repoDir: string;
repoName: string;
profileId: string;
profileName: string;
};
async function setupFreshBranchRepo(opts: {
apiClient: import("../../helpers/api-client").ApiClient;
backendTmpDir: string;
workspaceId: string;
suffix: string;
}): Promise<Setup | null> {
const { executors } = await opts.apiClient.listExecutors();
const localExec = executors.find((e) => e.type === "local");
if (!localExec) return null;
const profileName = `E2E Fresh Split ${opts.suffix}`;
const profile = await opts.apiClient.createExecutorProfile(localExec.id, profileName);
const repoName = `E2E Fresh Split Repo ${opts.suffix}`;
const repoDir = path.join(opts.backendTmpDir, "repos", `e2e-fresh-split-${opts.suffix}`);
fs.mkdirSync(repoDir, { recursive: true });
const env = makeGitEnv(opts.backendTmpDir);
execSync("git init -b main", { cwd: repoDir, env });
execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env });
execSync("git checkout -b develop", { cwd: repoDir, env });
execSync('git commit --allow-empty -m "develop work"', { cwd: repoDir, env });
execSync("git checkout main", { cwd: repoDir, env });
await opts.apiClient.createRepository(opts.workspaceId, repoDir, "main", { name: repoName });
return { repoDir, repoName, profileId: profile.id, profileName };
}
function escapeRe(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
test("payload sends base_branch=picked-base verbatim, no checkout_branch split", async ({
testPage,
apiClient,
backend,
seedData,
}) => {
// Regression for the gap noticed during review: the local-executor branch
// split would otherwise force base_branch=default_branch and the backend's
// applyFreshBranch would fork from main instead of the user's pick.
//
// The frontend's contract is to send the chip's value verbatim as
// base_branch when fresh-branch is active. Backend persistence of the
// resulting fork-branch name is a separate concern covered by
// service-layer tests on PerformFreshBranch. We only assert the wire
// payload here so this test stays focused on the regression and doesn't
// entangle with the post-create session-launch path.
const setup = await setupFreshBranchRepo({
apiClient,
backendTmpDir: backend.tmpDir,
workspaceId: seedData.workspaceId,
suffix: "develop-base",
});
if (!setup) {
test.skip(true, "No local executor available");
return;
}
try {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
await testPage.getByTestId("task-title-input").fill("Fork from develop");
await testPage.getByTestId("task-description-input").fill("non-default fork base");
await testPage.getByTestId("repo-chip-trigger").first().click();
await testPage
.getByRole("option", { name: new RegExp(`^${escapeRe(setup.repoName)}\\b`, "i") })
.first()
.click();
await testPage.getByTestId("executor-profile-selector").click();
await testPage
.getByRole("option", { name: new RegExp(`^${escapeRe(setup.profileName)}\\b`, "i") })
.first()
.click();
// Flip on fresh-branch and pick "develop" as the base to fork from.
await testPage.getByTestId("fresh-branch-toggle").click();
const branchSelector = testPage.getByTestId("branch-chip-trigger").first();
await expect(branchSelector).toBeEnabled({ timeout: 5_000 });
await branchSelector.click();
await testPage
.getByRole("option", { name: /develop/ })
.first()
.click();
await expect(branchSelector).toContainText("develop");
// Wait on the response so the server has finished persisting before we
// inspect the request body. waitForRequest resolves on send, which
// would race the rest of the test on slow CI.
const createTaskResponse = testPage.waitForResponse(
(res) => res.url().endsWith("/api/v1/tasks") && res.request().method() === "POST",
);
await testPage.getByTestId("submit-start-agent-chevron").click();
await testPage.getByTestId("submit-create-without-agent").click();
const req = (await createTaskResponse).request();
// Wire-level contract: base_branch must carry "develop" verbatim and
// checkout_branch must be absent. fresh_branch must be true so the
// backend's applyFreshBranch flow knows to fork. Without the split
// bypass, base_branch would be "main" and applyFreshBranch would fork
// from the wrong base.
const payload = JSON.parse(req.postData() ?? "{}") as {
repositories?: Array<{
base_branch?: string;
checkout_branch?: string;
fresh_branch?: boolean;
}>;
};
const repo = payload.repositories?.[0];
expect(repo?.base_branch).toBe("develop");
expect(repo?.checkout_branch ?? undefined).toBeUndefined();
expect(repo?.fresh_branch).toBe(true);
} finally {
await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {});
}
});
test("forks from default base when chip is left on the auto-seeded current branch", async ({
testPage,
apiClient,
backend,
seedData,
}) => {
// Common path: user is on main (the workspace default), enables fresh-
// branch, leaves the chip alone. Backend forks from main. We just verify
// the resulting branch points at main's tip and the wire payload sent
// base_branch="main" without a redundant checkout_branch.
const setup = await setupFreshBranchRepo({
apiClient,
backendTmpDir: backend.tmpDir,
workspaceId: seedData.workspaceId,
suffix: "main-base",
});
if (!setup) {
test.skip(true, "No local executor available");
return;
}
try {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
await testPage.getByTestId("task-title-input").fill("Fork from main");
await testPage.getByTestId("task-description-input").fill("default fork base");
await testPage.getByTestId("repo-chip-trigger").first().click();
await testPage
.getByRole("option", { name: new RegExp(`^${escapeRe(setup.repoName)}\\b`, "i") })
.first()
.click();
await testPage.getByTestId("executor-profile-selector").click();
await testPage
.getByRole("option", { name: new RegExp(`^${escapeRe(setup.profileName)}\\b`, "i") })
.first()
.click();
await testPage.getByTestId("fresh-branch-toggle").click();
const branchSelector = testPage.getByTestId("branch-chip-trigger").first();
await expect(branchSelector).toBeEnabled({ timeout: 5_000 });
await expect(branchSelector).toContainText("main");
const createTaskResponse = testPage.waitForResponse(
(res) => res.url().endsWith("/api/v1/tasks") && res.request().method() === "POST",
);
await testPage.getByTestId("submit-start-agent-chevron").click();
await testPage.getByTestId("submit-create-without-agent").click();
const req = (await createTaskResponse).request();
const payload = JSON.parse(req.postData() ?? "{}") as {
repositories?: Array<{ base_branch?: string; checkout_branch?: string }>;
};
expect(payload.repositories?.[0]?.base_branch).toBe("main");
expect(payload.repositories?.[0]?.checkout_branch ?? undefined).toBeUndefined();
} finally {
await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {});
}
});
});
/**
* Pins the readGitDefaultBranch fix: the validate-path endpoint (which the
* dialog uses to populate `default_branch` for discovered local paths) must
* return the integration branch, not whatever ref `.git/HEAD` happens to
* point at. The pre-fix code read HEAD directly, so a user checked out on
* a feature branch would permanently pin the resulting `repositories`
* row's `default_branch` to that feature branch.
*/
test.describe("Repository default_branch detection", () => {
test.describe.configure({ retries: 1 });
test("validate-path returns 'main' even when HEAD is on a feature branch", async ({
apiClient,
backend,
seedData,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-default-branch-probe");
fs.mkdirSync(repoDir, { recursive: true });
const env = makeGitEnv(backend.tmpDir);
execSync("git init -b main", { cwd: repoDir, env });
execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env });
execSync("git checkout -b feature/probe", { cwd: repoDir, env });
execSync('git commit --allow-empty -m "feature work"', { cwd: repoDir, env });
// Leave HEAD on feature/probe — the pre-fix code returned this string,
// which is exactly what we're guarding against.
expect(
execSync("git rev-parse --abbrev-ref HEAD", { cwd: repoDir, env }).toString().trim(),
).toBe("feature/probe");
const res = await apiClient.rawRequest(
"GET",
`/api/v1/workspaces/${seedData.workspaceId}/repositories/validate?path=${encodeURIComponent(repoDir)}`,
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
is_git: boolean;
default_branch: string;
allowed: boolean;
};
expect(body.is_git).toBe(true);
expect(body.allowed).toBe(true);
expect(
body.default_branch,
"must probe origin/HEAD or main/master, not echo back .git/HEAD",
).toBe("main");
});
test("validate-path falls back to feature branch only when no main/master exists", async ({
apiClient,
backend,
seedData,
}) => {
// A truly local-only repo with only a feature branch has no integration
// ref to anchor to. The probe should return the current HEAD as a last
// resort so the dialog doesn't error out — callers can still override.
const repoDir = path.join(backend.tmpDir, "repos", "e2e-default-branch-fallback");
fs.mkdirSync(repoDir, { recursive: true });
const env = makeGitEnv(backend.tmpDir);
execSync("git init -b solo", { cwd: repoDir, env });
execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env });
const res = await apiClient.rawRequest(
"GET",
`/api/v1/workspaces/${seedData.workspaceId}/repositories/validate?path=${encodeURIComponent(repoDir)}`,
);
const body = (await res.json()) as { default_branch: string };
expect(body.default_branch).toBe("solo");
});
});