Skip to content

Commit df78ef9

Browse files
authored
feat: multi dev branches (#4023)
Closes this feature request: [https://triggerdev.featurebase.app/p/isolated-dev-sessions-for-multiple-local-trigger-dev-instances](https://triggerdev.featurebase.app/p/isolated-dev-sessions-for-multiple-local-trigger-dev-instances) ### Feature notes: - CLI `trigger dev` works as before - `trigger dev --branch my-branch` to create a new branch and run against it. - `trigger dev archive --branch my-branch` to archive (or in webapp). - New webapp page to manage and archive dev branches, currently feature flagged. ### Implementation details: - No changes to data model, no backfill. `isBranchableEnvironment` column is ignored for dev branches, we use `parentEnvironmentId IS NULL` instead. - `x-trigger-branch` overloaded for preview and dev branches - New `TRIGGER_DEV_BRANCH` env var available locally. `TRIGGER_PREVIEW_BRANCH` overloaded for child runs. - Lots of new glue code to sanitise the branch checks. ### Rollout - Deploy webapp/API changes (all backwards compatible) - Manual tests on some orgs - Deploy docs, release CLI, flip feature flag for webapp feature ### NB - `api.v1.projects.$projectRef.environments.ts` will return `isBranchableEnvironment: true` for all dev environments. ### Prerequisites - [x] Typecheck will not pass until we make a new release of `@trigger.dev/platform` and bump it here
1 parent bc605ee commit df78ef9

74 files changed

Lines changed: 2589 additions & 454 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/dev-branches.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"trigger.dev": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
Add support for dev branches to the webapp and CLI. This allows humans (and agents) to run multiple local dev servers simultaneously, with a separate dashboard for each one.

.server-changes/dev-branches.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Adds support for dev branches similar to the preview branches already supported.

apps/webapp/app/components/BlankStatePanels.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import { useFeatures } from "~/hooks/useFeatures";
2222
import { useOrganization } from "~/hooks/useOrganizations";
2323
import { useProject } from "~/hooks/useProject";
2424
import { type MinimumEnvironment } from "~/presenters/SelectBestEnvironmentPresenter.server";
25-
import { NewBranchPanel } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route";
25+
import { type BranchableEnvironmentToken } from "~/utils/branchableEnvironment";
26+
import { NewBranchPanel } from "~/routes/resources.branches.create";
2627
import { GitHubSettingsPanel } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
2728
import {
2829
docsPath,
@@ -488,24 +489,27 @@ export function BranchesNoBranchableEnvironment({ showSelfServe }: { showSelfSer
488489
}
489490

490491
export function BranchesNoBranches({
491-
parentEnvironment,
492+
env,
492493
limits,
493494
canUpgrade,
494495
showSelfServe,
495496
}: {
496-
parentEnvironment: { id: string };
497+
env: BranchableEnvironmentToken;
497498
limits: { used: number; limit: number };
498499
canUpgrade: boolean;
499500
showSelfServe: boolean;
500501
}) {
501502
const organization = useOrganization();
502503

504+
const envTextClassName = env === "preview" ? "text-preview" : "text-dev";
505+
const branchesLabel = env === "preview" ? "preview branches" : "dev branches";
506+
503507
if (limits.used >= limits.limit) {
504508
return (
505509
<InfoPanel
506-
title="Upgrade to get preview branches"
510+
title={`Upgrade to get ${branchesLabel}`}
507511
icon={BranchEnvironmentIconSmall}
508-
iconClassName="text-preview"
512+
iconClassName={envTextClassName}
509513
panelClassName="max-w-full"
510514
accessory={
511515
showSelfServe && canUpgrade ? (
@@ -536,7 +540,7 @@ export function BranchesNoBranches({
536540
<InfoPanel
537541
title="Create your first branch"
538542
icon={BranchEnvironmentIconSmall}
539-
iconClassName="text-preview"
543+
iconClassName={envTextClassName}
540544
panelClassName="max-w-full"
541545
accessory={
542546
<NewBranchPanel
@@ -549,7 +553,7 @@ export function BranchesNoBranches({
549553
New branch
550554
</Button>
551555
}
552-
parentEnvironment={parentEnvironment}
556+
env={env}
553557
/>
554558
}
555559
>

apps/webapp/app/components/DevPresence.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export function DevPresenceProvider({ children, enabled = true }: DevPresencePro
4242

4343
// Only subscribe to event source if enabled is true
4444
const streamedEvents = useEventSource(
45-
`/resources/orgs/${organization.slug}/projects/${project.slug}/dev/presence`,
45+
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/presence`,
4646
{
4747
event: "presence",
4848
disabled: !enabled,

apps/webapp/app/components/environments/EnvironmentLabel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ export function environmentFullTitle(environment: Environment) {
178178
}
179179
}
180180

181-
export function environmentTextClassName(environment: Environment) {
181+
export function environmentTextClassName(environment: { type: Environment["type"] }) {
182182
switch (environment.type) {
183183
case "PRODUCTION":
184184
return "text-prod";

apps/webapp/app/components/navigation/EnvironmentSelector.tsx

Lines changed: 65 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { ChevronRightIcon, Cog8ToothIcon } from "@heroicons/react/20/solid";
2+
import { DEFAULT_DEV_BRANCH } from "@trigger.dev/core/v3/utils/gitBranch";
3+
import { isBranchableEnvironment } from "~/utils/branchableEnvironment";
24
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
35
import { useNavigation } from "@remix-run/react";
46
import { useEffect, useRef, useState } from "react";
@@ -9,8 +11,8 @@ import { useFeatures } from "~/hooks/useFeatures";
911
import { useOrganization, type MatchedOrganization } from "~/hooks/useOrganizations";
1012
import { useProject } from "~/hooks/useProject";
1113
import { cn } from "~/utils/cn";
12-
import { branchesPath, docsPath, v3BillingPath } from "~/utils/pathBuilder";
13-
import { EnvironmentCombo, EnvironmentIcon, EnvironmentLabel, environmentFullTitle } from "../environments/EnvironmentLabel";
14+
import { branchesPath, branchesDevPath, docsPath, v3BillingPath } from "~/utils/pathBuilder";
15+
import { EnvironmentCombo, EnvironmentIcon, EnvironmentLabel, environmentFullTitle, environmentTextClassName } from "../environments/EnvironmentLabel";
1416
import { ButtonContent } from "../primitives/Buttons";
1517
import { Header2 } from "../primitives/Headers";
1618
import { Paragraph } from "../primitives/Paragraph";
@@ -50,6 +52,7 @@ export function EnvironmentSelector({
5052
}, [navigation.location?.pathname]);
5153

5254
const hasStaging = project.environments.some((env) => env.type === "STAGING");
55+
const devBranchesEnabled = Boolean(organization.featureFlags?.devBranchesEnabled);
5356

5457
return (
5558
<Popover onOpenChange={(open) => setIsMenuOpen(open)} open={isMenuOpen}>
@@ -104,34 +107,40 @@ export function EnvironmentSelector({
104107
>
105108
<div className="flex flex-col gap-1 p-1">
106109
{project.environments
107-
.filter((env) => env.branchName === null)
110+
.filter((env) => env.parentEnvironmentId === null)
108111
.map((env) => {
109-
switch (env.isBranchableEnvironment) {
110-
case true: {
111-
const branchEnvironments = project.environments.filter(
112-
(e) => e.parentEnvironmentId === env.id
113-
);
114-
return (
115-
<Branches
116-
key={env.id}
117-
parentEnvironment={env}
118-
branchEnvironments={branchEnvironments}
119-
currentEnvironment={environment}
120-
/>
121-
);
122-
}
123-
case false:
124-
return (
125-
<PopoverMenuItem
126-
key={env.id}
127-
to={urlForEnvironment(env)}
128-
title={
129-
<EnvironmentCombo environment={env} className="mx-auto grow text-2sm" />
130-
}
131-
isSelected={env.id === environment.id}
132-
/>
133-
);
112+
// DEVELOPMENT is only branchable in the UI when the org has the
113+
// multi-branch dev flag on. Without it, dev renders as a plain
114+
// selector button (the original behavior). PREVIEW is unaffected.
115+
const renderAsBranchable =
116+
isBranchableEnvironment(env) &&
117+
(env.type !== "DEVELOPMENT" || devBranchesEnabled);
118+
119+
if (renderAsBranchable) {
120+
const branchEnvironments = project.environments.filter(
121+
(e) => e.parentEnvironmentId === env.id
122+
);
123+
const allBranchEnvironments = env.type === "DEVELOPMENT" ? [env, ...branchEnvironments] : branchEnvironments;
124+
return (
125+
<Branches
126+
key={env.id}
127+
parentEnvironment={env}
128+
branchEnvironments={allBranchEnvironments}
129+
currentEnvironment={environment}
130+
/>
131+
);
134132
}
133+
134+
return (
135+
<PopoverMenuItem
136+
key={env.id}
137+
to={urlForEnvironment(env)}
138+
title={
139+
<EnvironmentCombo environment={env} className="mx-auto grow text-2sm" />
140+
}
141+
isSelected={env.id === environment.id}
142+
/>
143+
);
135144
})}
136145
</div>
137146
{!hasStaging && isManagedCloud && (
@@ -226,7 +235,14 @@ function Branches({
226235
? "no-active-branches"
227236
: "has-branches";
228237

229-
const currentBranchIsArchived = environment.archivedAt !== null;
238+
// Only surface the active environment's archived-branch item in the submenu it
239+
// actually belongs to. Both Development and Preview render this component, so
240+
// without the parent check an archived dev branch would leak into the Preview
241+
// submenu (and vice-versa).
242+
const currentBranchIsArchived =
243+
environment.archivedAt !== null && environment.parentEnvironmentId === parentEnvironment.id;
244+
245+
const envTextClassName = environmentTextClassName(parentEnvironment);
230246

231247
return (
232248
<Popover onOpenChange={(open) => setMenuOpen(open)} open={isMenuOpen}>
@@ -260,11 +276,11 @@ function Branches({
260276
to={urlForEnvironment(environment)}
261277
title={
262278
<>
263-
<span className="block w-full text-preview">{environment.branchName}</span>
279+
<span className={cn("block w-full", envTextClassName)}>{environment.branchName}</span>
264280
<Badge variant="extra-small">Archived</Badge>
265281
</>
266282
}
267-
icon={<BranchEnvironmentIconSmall className="size-4 shrink-0 text-preview" />}
283+
icon={<BranchEnvironmentIconSmall className={cn("size-4 shrink-0", envTextClassName)} />}
268284
isSelected={environment.id === currentEnvironment.id}
269285
/>
270286
)}
@@ -276,16 +292,16 @@ function Branches({
276292
<PopoverMenuItem
277293
key={env.id}
278294
to={urlForEnvironment(env)}
279-
title={<span className="block w-full text-preview">{env.branchName}</span>}
280-
icon={<BranchEnvironmentIconSmall className="size-4 shrink-0 text-preview" />}
295+
title={<span className={cn("block w-full", envTextClassName)}>{env.branchName ?? DEFAULT_DEV_BRANCH}</span>}
296+
icon={<BranchEnvironmentIconSmall className={cn("size-4 shrink-0", envTextClassName)} />}
281297
isSelected={env.id === currentEnvironment.id}
282298
/>
283299
))}
284300
</>
285301
) : state === "no-branches" ? (
286302
<div className="flex max-w-sm flex-col gap-1 p-2">
287303
<div className="flex items-center gap-1">
288-
<BranchEnvironmentIconSmall className="size-4 text-preview" />
304+
<BranchEnvironmentIconSmall className={cn("size-4", envTextClassName)} />
289305
<Header2>Create your first branch</Header2>
290306
</div>
291307
<Paragraph spacing variant="small">
@@ -305,12 +321,21 @@ function Branches({
305321
)}
306322
</div>
307323
<div className="border-t border-charcoal-700 p-1">
308-
<PopoverMenuItem
309-
to={branchesPath(organization, project, environment)}
310-
title="Manage branches"
311-
icon={<Cog8ToothIcon className="size-4 text-text-dimmed" />}
312-
leadingIconClassName="text-text-dimmed"
313-
/>
324+
{parentEnvironment.type === "DEVELOPMENT" ? (
325+
<PopoverMenuItem
326+
to={branchesDevPath(organization, project, environment)}
327+
title="Manage dev branches"
328+
icon={<Cog8ToothIcon className="size-4 text-text-dimmed" />}
329+
leadingIconClassName="text-text-dimmed"
330+
/>
331+
) : (
332+
<PopoverMenuItem
333+
to={branchesPath(organization, project, environment)}
334+
title="Manage preview branches"
335+
icon={<Cog8ToothIcon className="size-4 text-text-dimmed" />}
336+
leadingIconClassName="text-text-dimmed"
337+
/>
338+
)}
314339
</div>
315340
</PopoverContent>
316341
</div>

apps/webapp/app/db.server.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,14 @@ function getClient() {
255255
queryPerformanceMonitor.onQuery("writer", log);
256256
});
257257

258-
// connect eagerly
259-
client.$connect();
258+
// Connect eagerly; Prisma will connect on use anyway.
259+
// Swallow the error when testing (DB likely unavailable)
260+
const connectPromise = client.$connect();
261+
if (env.NODE_ENV === "test") {
262+
connectPromise.catch((error) => {
263+
logger.warn("Failed to eagerly connect prisma client (writer)", { error });
264+
});
265+
}
260266

261267
console.log(`🔌 prisma client connected`);
262268

@@ -378,8 +384,14 @@ function getReplicaClient() {
378384
queryPerformanceMonitor.onQuery("replica", log);
379385
});
380386

381-
// connect eagerly
382-
replicaClient.$connect();
387+
// Connect eagerly; Prisma will connect on use anyway.
388+
// Swallow the error when testing (DB likely unavailable)
389+
const connectPromise = replicaClient.$connect();
390+
if (env.NODE_ENV === "test") {
391+
connectPromise.catch((error) => {
392+
logger.warn("Failed to eagerly connect prisma client (replica)", { error });
393+
});
394+
}
383395

384396
console.log(`🔌 read replica connected`);
385397

apps/webapp/app/models/member.server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,9 @@ export async function acceptInvite({
215215
organization: invite.organization,
216216
project,
217217
type: "DEVELOPMENT",
218-
isBranchableEnvironment: false,
218+
// We set this true but no backfill (yet!?) so never used
219+
// for dev environments
220+
isBranchableEnvironment: true,
219221
member,
220222
prismaClient: tx,
221223
});

apps/webapp/app/models/project.server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,9 @@ export async function createProject(
126126
organization,
127127
project,
128128
type: "DEVELOPMENT",
129-
isBranchableEnvironment: false,
129+
// We set this true but no backfill (yet!?) so never used
130+
// for dev environments
131+
isBranchableEnvironment: true,
130132
member,
131133
});
132134
}

0 commit comments

Comments
 (0)