Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ npx branchly init # or: pnpm branchly init · yarn branchly init

That single command discovers your stack, **installs the adapter packages it needs**, writes a `branchly.config.ts`, and wires up the Git hook. The necessary adapters and your package manager are detected automatically.

Branchly automatically detects a `.env` file, direnv, CI secrets, or your Prisma datasource. It keeps the host and credentials and just swaps the database name: a maintenance connection for creating and cloning databases, and a fresh `app_<branch>` database for each branch. Nothing new to set.
Branchly automatically detects a `.env` file, Doppler, direnv, CI secrets, or your Prisma datasource. It keeps the host and credentials and just swaps the database name: a maintenance connection for creating and cloning databases, and a fresh `app_<branch>` database for each branch. Nothing new to set.

That's it. From now on, switching branches just works:

Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,12 @@ describe('runInit', () => {
reporter: createReporter({ quiet: true }),
installer,
env: {},
envProvider: 'direnv',
envProvider: 'doppler',
runCommand: () => Promise.resolve(true),
});
expect(ok).toBe(true);
const hook = await readFile(join(root, '.git', 'hooks', 'post-checkout'), 'utf8');
expect(hook).toContain('direnv exec . ');
expect(hook).toContain('doppler run -- ');
expect(hook).toContain('branchly on-checkout');
} finally {
await rm(root, { recursive: true, force: true });
Expand All @@ -150,7 +150,7 @@ describe('runInit', () => {
expect(ok).toBe(false);
const hook = await readFile(join(root, '.git', 'hooks', 'post-checkout'), 'utf8');
expect(hook).toContain('branchly on-checkout');
expect(hook).not.toContain('direnv exec');
expect(hook).not.toContain('doppler run');
} finally {
await rm(root, { recursive: true, force: true });
}
Expand Down
13 changes: 7 additions & 6 deletions packages/core/src/init/audit-injection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,27 +33,28 @@ const withDir = async (action: (root: string) => Promise<void>): Promise<void> =
describe('auditInjection', () => {
it('flags a detected injector whose wrapper is missing from the hook', async () => {
await withDir(async (root) => {
await writeFile(join(root, '.envrc'), 'export DATABASE_URL=x\n', 'utf8');
await writeFile(join(root, 'doppler.yaml'), 'setup:\n', 'utf8');
await writeHook(root, 'exec npx branchly on-checkout "$@"\n');
const unwrapped = unwrappedInjectors(await auditInjection(contextFor(root, () => Promise.resolve(true))));
expect(unwrapped.map((finding) => finding.provider.id)).toEqual(['direnv']);
expect(unwrapped.map((finding) => finding.provider.id)).toEqual(['doppler']);
});
});

it('does not flag when the hook already carries the wrapper', async () => {
await withDir(async (root) => {
await writeFile(join(root, '.envrc'), 'export DATABASE_URL=x\n', 'utf8');
await writeHook(root, 'exec direnv exec . npx branchly on-checkout "$@"\n');
await writeFile(join(root, 'doppler.yaml'), 'setup:\n', 'utf8');
await writeHook(root, 'exec doppler run -- npx branchly on-checkout "$@"\n');
const unwrapped = unwrappedInjectors(await auditInjection(contextFor(root, () => Promise.resolve(true))));
expect(unwrapped).toHaveLength(0);
});
});

it('does not flag an injector that cannot resolve the key', async () => {
await withDir(async (root) => {
await writeFile(join(root, '.envrc'), 'export DATABASE_URL=x\n', 'utf8');
await writeFile(join(root, 'doppler.yaml'), 'setup:\n', 'utf8');
await writeHook(root, 'exec npx branchly on-checkout "$@"\n');
const runner: CommandRunner = (command, args) => Promise.resolve(command === 'direnv' && args[0] === 'version');
const runner: CommandRunner = (command, args) =>
Promise.resolve(command === 'doppler' && args[0] === 'configure');
const unwrapped = unwrappedInjectors(await auditInjection(contextFor(root, runner)));
expect(unwrapped).toHaveLength(0);
});
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/init/env-providers/doppler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { access } from 'node:fs/promises';
import { join } from 'node:path';

import { resolveProbe } from './command-runner';
import type { EnvProvider, EnvProviderContext } from './types';

const CONFIG_FILES = ['doppler.yaml', '.doppler.yaml'];

const fileExists = (path: string): Promise<boolean> =>
access(path)
.then(() => true)
.catch(() => false);

const hasConfigFile = async (cwd: string): Promise<boolean> => {
const checks = await Promise.all(CONFIG_FILES.map((name) => fileExists(join(cwd, name))));
return checks.some((found) => found);
};

const hasConfiguredProject = (context: EnvProviderContext): Promise<boolean> =>
context.runCommand('doppler', ['configure', 'get', 'project', '--plain'], context.cwd);

const detectDoppler = async (context: EnvProviderContext): Promise<boolean> =>
(await hasConfigFile(context.cwd)) || hasConfiguredProject(context);

export const dopplerProvider: EnvProvider = {
id: 'doppler',
label: 'Doppler (doppler CLI)',
hookWrapMarker: 'doppler run --',
detect: detectDoppler,
wrapHookCommand: (command) => `doppler run -- ${command}`,
verifyResolves: (context) =>
context.runCommand('doppler', ['run', '--', 'node', '-e', resolveProbe(context.key)], context.cwd),
describe: () => 'runs hooks under `doppler run` so Doppler injects the value',
};
45 changes: 34 additions & 11 deletions packages/core/src/init/env-providers/env-providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { join } from 'node:path';
import { describe, expect, it } from 'vitest';

import { direnvProvider } from './direnv';
import { dopplerProvider } from './doppler';
import { envFileProvider } from './env-file';
import { detectEnvProviders, providerById } from './index';
import { shellProvider } from './shell';
Expand Down Expand Up @@ -59,37 +60,59 @@ describe('envFileProvider', () => {
});
});

describe('direnvProvider', () => {
it('detects only when both .envrc and the direnv binary are present', async () => {
describe('dopplerProvider', () => {
it('detects from a doppler.yaml file without running anything', async () => {
await withDir(async (root) => {
expect(await direnvProvider.detect(contextFor(root, {}, always))).toBe(false);
await writeFile(join(root, '.envrc'), 'export DATABASE_URL=x\n', 'utf8');
expect(await direnvProvider.detect(contextFor(root, {}, never))).toBe(false);
expect(await direnvProvider.detect(contextFor(root, {}, always))).toBe(true);
await writeFile(join(root, 'doppler.yaml'), 'setup:\n', 'utf8');
expect(await dopplerProvider.detect(contextFor(root, {}, never))).toBe(true);
});
});

it('wraps the hook with direnv exec and verifies via the runner', async () => {
it('detects from a configured project when no file is present', async () => {
await withDir(async (root) => {
expect(await dopplerProvider.detect(contextFor(root, {}, always))).toBe(true);
expect(await dopplerProvider.detect(contextFor(root, {}, never))).toBe(false);
});
});

it('wraps the hook with doppler run and verifies via the runner', async () => {
await withDir(async (root) => {
const seen: string[][] = [];
const runner: CommandRunner = (command, args) => {
seen.push([command, ...args]);
return Promise.resolve(true);
};
expect(direnvProvider.wrapHookCommand('npx branchly on-checkout')).toBe('direnv exec . npx branchly on-checkout');
expect(await direnvProvider.verifyResolves(contextFor(root, {}, runner))).toBe(true);
expect(seen[0]?.slice(0, 2)).toEqual(['direnv', 'exec']);
expect(dopplerProvider.wrapHookCommand('npx branchly on-checkout')).toBe(
'doppler run -- npx branchly on-checkout',
);
expect(await dopplerProvider.verifyResolves(contextFor(root, {}, runner))).toBe(true);
expect(seen[0]?.slice(0, 3)).toEqual(['doppler', 'run', '--']);
});
});
});

describe('direnvProvider', () => {
it('detects only when both .envrc and the direnv binary are present', async () => {
await withDir(async (root) => {
expect(await direnvProvider.detect(contextFor(root, {}, always))).toBe(false);
await writeFile(join(root, '.envrc'), 'export DATABASE_URL=x\n', 'utf8');
expect(await direnvProvider.detect(contextFor(root, {}, never))).toBe(false);
expect(await direnvProvider.detect(contextFor(root, {}, always))).toBe(true);
});
});

it('wraps the hook with direnv exec', () => {
expect(direnvProvider.wrapHookCommand('npx branchly on-checkout')).toBe('direnv exec . npx branchly on-checkout');
});
});

describe('detectEnvProviders / providerById', () => {
it('returns matches in priority order and resolves ids', async () => {
await withDir(async (root) => {
await writeFile(join(root, '.env'), 'DATABASE_URL=x\n', 'utf8');
const detected = await detectEnvProviders(contextFor(root, { DATABASE_URL: 'x' }, never));
expect(detected.map((provider) => provider.id)).toEqual(['env-file', 'shell']);
expect(providerById('direnv')).toBe(direnvProvider);
expect(providerById('doppler')).toBe(dopplerProvider);
expect(providerById('nope')).toBeNull();
});
});
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/init/env-providers/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { direnvProvider } from './direnv';
import { dopplerProvider } from './doppler';
import { envFileProvider } from './env-file';
import { shellProvider } from './shell';
import type { EnvProvider, EnvProviderContext } from './types';

export const ENV_PROVIDERS: readonly EnvProvider[] = [direnvProvider, envFileProvider, shellProvider];
export const ENV_PROVIDERS: readonly EnvProvider[] = [dopplerProvider, direnvProvider, envFileProvider, shellProvider];

export const FALLBACK_PROVIDER: EnvProvider = envFileProvider;

Expand Down
16 changes: 8 additions & 8 deletions packages/core/src/init/hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { join } from 'node:path';

import { describe, expect, it } from 'vitest';

import { direnvProvider } from './env-providers/direnv';
import { dopplerProvider } from './env-providers/doppler';
import { shellProvider } from './env-providers/shell';
import {
appendHookLine,
Expand All @@ -24,9 +24,9 @@ describe('hookCommand', () => {
expect(hookCommand(POST_CHECKOUT_HOOK, identity, 'npm')).toBe('npx branchly on-checkout "$@"');
});

it('wraps the command with the direnv injector', () => {
expect(hookCommand(POST_CHECKOUT_HOOK, direnvProvider.wrapHookCommand, 'npm')).toBe(
'direnv exec . npx branchly on-checkout "$@"',
it('wraps the command with the doppler injector', () => {
expect(hookCommand(POST_CHECKOUT_HOOK, dopplerProvider.wrapHookCommand, 'npm')).toBe(
'doppler run -- npx branchly on-checkout "$@"',
);
});

Expand Down Expand Up @@ -146,16 +146,16 @@ describe('installHook', () => {
}
});

it('wraps the hook with the direnv injector', async () => {
it('wraps the hook with the doppler injector', async () => {
const root = await mkdtemp(join(tmpdir(), 'branchly-hook-'));
try {
const result = await installHook(root, POST_CHECKOUT_HOOK, {
hooksPath: null,
injector: direnvProvider,
injector: dopplerProvider,
manager: 'npm',
});
expect(result.injector).toBe('direnv');
expect(await readFile(result.path, 'utf8')).toContain('direnv exec . npx branchly on-checkout');
expect(result.injector).toBe('doppler');
expect(await readFile(result.path, 'utf8')).toContain('doppler run -- npx branchly on-checkout');
} finally {
await rm(root, { recursive: true, force: true });
}
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/adapters/env-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@ The write is an **upsert**: an existing `DATABASE_URL=` line is replaced in plac

`branchly init` makes sure the file is gitignored — the value changes per branch and per machine, so it should never be committed.

Using direnv or CI-injected environments where a file write would be overridden at launch? Pair this with [`branchly run`](/branchly/guides/injected-envs/), or use the [direnv resolver](/branchly/adapters/direnv/).
Using Doppler or CI-injected environments where a file write would be overridden at launch? Pair this with [`branchly run`](/branchly/guides/injected-envs/), or use the [direnv resolver](/branchly/adapters/direnv/).
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/guides/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Tells you where you are: current branch, the key it maps to, whether the databas

## `branchly run -- <cmd>`

For setups where the environment is injected rather than read from a file (direnv, CI): provisions the current branch, then launches `<cmd>` with the per-branch connection set in its environment — set last, so it wins over injected values. See [injected environments](/branchly/guides/injected-envs/).
For setups where the environment is injected rather than read from a file (Doppler, CI): provisions the current branch, then launches `<cmd>` with the per-branch connection set in its environment — set last, so it wins over injected values. See [injected environments](/branchly/guides/injected-envs/).

## `branchly prune`

Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Any other keys in an adapter's block are passed through to that adapter as optio

## `env()` references

`env('DATABASE_URL')` defers a value to the environment at load time, so your config can be committed without committing a connection string. branchly also auto-loads `.env` (non-overriding, so direnv and CI-injected values win).
`env('DATABASE_URL')` defers a value to the environment at load time, so your config can be committed without committing a connection string. branchly also auto-loads `.env` (non-overriding, so Doppler, direnv, and CI-injected values win).

For the datasource `url`, branchly derives its maintenance connection from your app's existing connection string by swapping the database name — there is no separate admin variable to set.

Expand Down
15 changes: 8 additions & 7 deletions packages/docs/src/content/docs/guides/injected-envs.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
---
title: direnv & injected environments
title: Doppler, direnv & injected environments
description: Using branchly when your environment comes from a secret manager instead of a .env file.
---

The default resolver writes the per-branch connection into your `.env` file, which covers most projects. But plenty of teams inject environment instead — direnv, CI secrets. branchly is friendly to all of them, on both the input and the output side.
The default resolver writes the per-branch connection into your `.env` file, which covers most projects. But plenty of teams inject environment instead — Doppler, direnv, CI secrets. branchly is friendly to all of them, on both the input and the output side.

## The input side: where branchly finds your connection

branchly derives everything from your existing `DATABASE_URL` — it keeps the host and credentials and swaps the database name. The catch is that your **git hooks** run in their own environment, which may differ from your shell. So during `init`, branchly detects _how_ that value reaches your project and wires the hooks to match:

- **`.env` files** are auto-loaded, _non-overriding_ — values already present in the process environment always win. So direnv- or CI-injected values take precedence naturally.
- **direnv** — detected from an `.envrc` plus the `direnv` binary; `init` wraps the hooks in `direnv exec`, so the hook sees the same values your app does.
- **`.env` files** are auto-loaded, _non-overriding_ — values already present in the process environment always win. So Doppler-, direnv-, or CI-injected values take precedence naturally.
- **Doppler** — detected from a `doppler.yaml` **or** a logged-in `doppler` CLI with a configured project. When chosen, `init` wraps the Git hooks in `doppler run`, so the hook sees the same secrets your app does.
- **direnv** — detected from an `.envrc` plus the `direnv` binary; `init` wraps the hooks in `direnv exec`.
- **shell** — a value already exported in your environment. Convenient, but a fresh shell, CI runner, or git hook may not have it, so branchly says so rather than assuming.

In an interactive terminal, `init` confirms the detected source with you (and lets you pick another); in CI or with `--yes` it takes the top match, falling back to `.env`. Either way it then **verifies** that `DATABASE_URL` actually resolves the way the hooks will see it — if it can't, `init` tells you exactly what's missing instead of writing a config that silently won't work. You can pin the choice with `branchly init --env=direnv` (or `env-file`, `shell`), and re-check anytime with `branchly doctor`.
In an interactive terminal, `init` confirms the detected source with you (and lets you pick another); in CI or with `--yes` it takes the top match, falling back to `.env`. Either way it then **verifies** that `DATABASE_URL` actually resolves the way the hooks will see it — if it can't, `init` tells you exactly what's missing instead of writing a config that silently won't work. You can pin the choice with `branchly init --env=doppler` (or `direnv`, `env-file`, `shell`), and re-check anytime with `branchly doctor`.

## The output side: where your app finds the branch database

Expand All @@ -30,10 +31,10 @@ resolver: { use: 'direnv', file: '.envrc', key: 'DATABASE_URL' }
When your environment is injected at launch time, a file write isn't enough — the injector would overwrite it. `branchly run` solves the ordering: it provisions the current branch, then launches your command with the per-branch connection set **last**, so it wins:

```sh
direnv exec . branchly run -- npm run dev
doppler run -- branchly run -- npm run dev
```

direnv injects your environment, then branchly overrides just the database URL with the current branch's. Works the same with any injector:
Doppler injects your secrets, then branchly overrides just the database URL with the current branch's. Works the same with any injector:

```sh
branchly run -- npm run dev # plain
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/start/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ That single command:

## Connection detection

branchly never needs a new credential. It finds your existing connection — a `.env` file, direnv, CI secrets, or your Prisma datasource — keeps the host and credentials, and just swaps the database name: a maintenance connection for creating and cloning databases, and a fresh `app_<branch>` database per branch.
branchly never needs a new credential. It finds your existing connection — a `.env` file, Doppler, direnv, CI secrets, or your Prisma datasource — keeps the host and credentials, and just swaps the database name: a maintenance connection for creating and cloning databases, and a fresh `app_<branch>` database per branch.

## Requirements

Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/start/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ branchly init
◇ gitignore: .env is covered (branchly keeps its state in .git)
◇ checkout: installed at .husky/post-checkout 🪝
◇ merge: installed at .husky/post-merge 🪝
◇ next: nothing! branchly reuses your existing DATABASE_URL (.env, direnv, etc.) 🌱
◇ next: nothing! branchly reuses your existing DATABASE_URL (.env, Doppler, etc.) 🌱
└ branchly is set up — happy branching! 🎉
```
Expand Down
Loading