feat(git): protected-branches enforcement + panel editor (#649)

projects.protected_branches existed end-to-end but nothing consulted it
— the panel had no editor and the git safety checks used hardcoded sets.
Now: GitService._protected_branches_for(slug) (frozenset, stripped,
fail-open to the hardcoded floor with a warning log) is unioned — never
replacing, only tightening — into rebase()'s refusal set, the shared
_delete_remote_branch_best_effort skip set (threaded through every
caller: task cleanup, PR merge/close cleanup), and sync_task_branch,
which now refuses to force-push a protected-named head (the dev-facing
sync_branch verb path the HTTP-only fix would have missed). Matching is
exact and case-sensitive; an empty list degrades to exactly the old
hardcoded behavior, pinned by union-floor regression tests (master/main
stay refused regardless of the project list).

Panel: chips editor for the field in the edit-project dialog (add via
Enter/comma, paste-splitting on comma-separated lists, dedup, clear-to-
empty persists []) with an honest tooltip scoped to what is actually
enforced. Tests cover both the incumbent GitHub-App dialog suite and the
new Protected Branches suite in one harness.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-22 20:30:05 +02:00
committed by GitHub
co-authored by Renn F
parent 4585a248ce
commit da4d9b333d
7 changed files with 775 additions and 25 deletions
@@ -267,3 +267,121 @@ describe("EditProjectDialog — GitHub App binding", () => {
expect(call.updates.github_installation_id).toBeNull();
});
});
describe("EditProjectDialog — Protected Branches", () => {
beforeEach(() => {
vi.clearAllMocks();
getCredentialsStatus.mockResolvedValue({ has_credentials: true });
mutateAsync.mockResolvedValue(makeProject());
useUpdateProject.mockReturnValue({ mutateAsync, isPending: false });
});
it("renders the project's existing protected branches as chips", async () => {
renderDialog(makeProject({ protected_branches: ["master", "slave"] }));
await screen.findByText("master");
expect(screen.getByText("slave")).toBeInTheDocument();
});
it("adds a branch via Enter and removes another via its chip, saving both changes", async () => {
renderDialog(makeProject({ protected_branches: ["master", "slave"] }));
await screen.findByText("master");
// Remove "slave".
fireEvent.click(screen.getByLabelText("Remove slave"));
expect(screen.queryByText("slave")).not.toBeInTheDocument();
// Add "release" by typing + Enter.
const input = screen.getByPlaceholderText(
"Type a branch name, press Enter",
);
fireEvent.change(input, { target: { value: "release" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText("release")).toBeInTheDocument();
// The input clears after a successful add.
expect(input).toHaveValue("");
fireEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
const call = mutateAsync.mock.calls[0][0] as {
updates: { protected_branches?: string[] };
};
expect(call.updates.protected_branches).toEqual(["master", "release"]);
});
it("adding via a trailing comma also commits the chip", async () => {
renderDialog(makeProject({ protected_branches: ["master", "slave"] }));
await screen.findByText("master");
const input = screen.getByPlaceholderText(
"Type a branch name, press Enter",
);
fireEvent.change(input, { target: { value: "hotfix" } });
fireEvent.keyDown(input, { key: "," });
expect(screen.getByText("hotfix")).toBeInTheDocument();
});
it("pasting a comma-separated list splits it into individual chips instead of one malformed chip", async () => {
renderDialog(makeProject({ protected_branches: ["master", "slave"] }));
await screen.findByText("master");
const input = screen.getByPlaceholderText(
"Type a branch name, press Enter",
);
fireEvent.paste(input, {
clipboardData: { getData: () => "release,hotfix,staging" },
});
expect(screen.getByText("release")).toBeInTheDocument();
expect(screen.getByText("hotfix")).toBeInTheDocument();
expect(screen.getByText("staging")).toBeInTheDocument();
expect(
screen.queryByText("release,hotfix,staging"),
).not.toBeInTheDocument();
expect(input).toHaveValue("");
});
it("does not add a duplicate chip for a branch already in the list", async () => {
renderDialog(makeProject({ protected_branches: ["master", "slave"] }));
await screen.findByText("master");
const input = screen.getByPlaceholderText(
"Type a branch name, press Enter",
);
fireEvent.change(input, { target: { value: "master" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getAllByText("master")).toHaveLength(1);
});
it("clearing every branch sends an explicit empty array, not an omitted field", async () => {
renderDialog(makeProject({ protected_branches: ["master", "slave"] }));
await screen.findByText("master");
fireEvent.click(screen.getByLabelText("Remove master"));
fireEvent.click(screen.getByLabelText("Remove slave"));
expect(screen.queryByText("master")).not.toBeInTheDocument();
expect(screen.queryByText("slave")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
const call = mutateAsync.mock.calls[0][0] as {
updates: { protected_branches?: string[] };
};
expect(call.updates.protected_branches).toEqual([]);
});
it("leaving the list untouched still round-trips the same branches on save", async () => {
renderDialog(makeProject({ protected_branches: ["master", "slave"] }));
await screen.findByText("master");
fireEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
const call = mutateAsync.mock.calls[0][0] as {
updates: { protected_branches?: string[] };
};
expect(call.updates.protected_branches).toEqual(["master", "slave"]);
});
});
@@ -23,9 +23,10 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ConventionsTab } from "@/components/conventions/conventions-tab";
import { Key, KeyRound, AlertTriangle } from "lucide-react";
import { Key, KeyRound, AlertTriangle, Plus, X } from "lucide-react";
import { toast } from "sonner";
import { Team, type ProjectUpdate, type Project } from "@/types";
import { githubAppApi } from "@/lib/api";
@@ -165,6 +166,43 @@ function EditProjectForm({
>(project.github_installation_id);
const [assignedCell, setAssignedCell] = useState(project.assigned_cell);
const [defaultBranch, setDefaultBranch] = useState(project.default_branch);
const [protectedBranches, setProtectedBranches] = useState<string[]>(
project.protected_branches ?? [],
);
const [protectedBranchInput, setProtectedBranchInput] = useState("");
// Shared by the single Enter/comma-key add and the multi-value paste
// handler below — trims, drops empties, and dedups against both the
// existing list and duplicates within the same batch.
const addProtectedBranches = (names: string[]) => {
const cleaned = names.map((n) => n.trim()).filter(Boolean);
if (cleaned.length === 0) return;
setProtectedBranches((prev) => {
const next = [...prev];
for (const name of cleaned) {
if (!next.includes(name)) next.push(name);
}
return next;
});
};
const addProtectedBranch = () => {
addProtectedBranches([protectedBranchInput]);
setProtectedBranchInput("");
};
const handleProtectedBranchPaste = (
e: React.ClipboardEvent<HTMLInputElement>,
) => {
const pasted = e.clipboardData.getData("text");
// A single name (no comma) falls through to normal paste-into-input
// behavior; only a multi-value paste is split into chips directly —
// otherwise "release,hotfix,staging" lands as one malformed chip.
if (!pasted.includes(",")) return;
e.preventDefault();
addProtectedBranches(pasted.split(","));
setProtectedBranchInput("");
};
const removeProtectedBranch = (branch: string) => {
setProtectedBranches((prev) => prev.filter((b) => b !== branch));
};
const [environments, setEnvironments] = useState(
project.environments ?? null,
);
@@ -284,6 +322,7 @@ function EditProjectForm({
github_installation_id: githubInstallationId,
assigned_cell: assignedCell,
default_branch: defaultBranch || "main",
protected_branches: protectedBranches,
environments,
is_active: isActive,
test_command: testCommand || undefined,
@@ -571,6 +610,56 @@ function EditProjectForm({
</p>
</div>
{/* Protected Branches */}
<div className="grid gap-2">
<HelpTip label="Branches the fleet refuses to rebase onto, sync (force-push) as a task's own branch, or delete on the remote, in addition to the always-protected master/main defaults — matched exactly, case-sensitive. Environment-ladder rungs get separate protection, but only for task-branch cleanup, not a PR's source-branch cleanup after merge.">
<Label htmlFor="protected_branch_input">Protected Branches</Label>
</HelpTip>
{protectedBranches.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{protectedBranches.map((branch) => (
<Badge key={branch} variant="secondary" className="gap-1 pr-1">
{branch}
<button
type="button"
onClick={() => removeProtectedBranch(branch)}
aria-label={`Remove ${branch}`}
className="rounded-full hover:bg-muted-foreground/20"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<div className="flex gap-2">
<Input
id="protected_branch_input"
value={protectedBranchInput}
onChange={(e) => setProtectedBranchInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addProtectedBranch();
}
}}
onPaste={handleProtectedBranchPaste}
placeholder="Type a branch name, press Enter"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={addProtectedBranch}
>
<Plus className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Enter or comma adds a branch; click the × on a chip to remove it.
</p>
</div>
{/* Environment ladder */}
<EnvironmentLadderEditor
rungs={environments}