This commit is contained in:
Jannick Namyslo 2026-07-27 10:56:57 +02:00 committed by GitHub
commit fcd080eb38
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 2139 additions and 17 deletions

View File

@ -0,0 +1,884 @@
# Source Control Branch Selection Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users pick the target git branch in the commit window (and optionally create a new feature branch off the default), instead of being locked to one branch fixed in Admin Settings.
**Architecture:** Admin Settings keeps `branchName` but relabels it "Default branch" (used for pull/reset/status and as the commit default). The push DTO gains `branch` + `createBranch`. The push service resolves the target branch, checks it out (creating from the default when asked) before exporting/committing, and pushes to it. Pull explicitly re-checks-out the default branch first. The commit modal gains a branch dropdown + "New branch" toggle.
**Tech Stack:** TypeScript, zod (`@n8n/api-types` DTOs), simple-git (backend git service), Vue 3 + Pinia (`editor-ui`), Vitest.
## Global Constraints
- Never use `any`; use proper types or `unknown` (repo TS rule).
- No `ApplicationError`; use `UserError` / `OperationalError` / `UnexpectedError`.
- All new UI text goes through `@n8n/i18n` (`packages/frontend/@n8n/i18n/src/locales/en.json`).
- `data-testid` must be a single value.
- Backend source-control module path: `packages/cli/src/modules/source-control.ee/`.
- Frontend feature path: `packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/`.
- Run tests from within the package dir (`pushd`/`popd`). Backend cli tests: `pnpm test <file>` inside `packages/cli`. api-types tests inside `packages/@n8n/api-types`. Frontend tests inside `packages/frontend/editor-ui`.
- The git branch validator must be defined once in `@n8n/api-types` and reused by both the DTO and the frontend (DRY).
- `SOURCE_CONTROL_ORIGIN = 'origin'` and the default branch getter is `sourceControlPreferencesService.getBranchName()`.
---
### Task 1: Shared git branch-name validator + DTO fields
**Files:**
- Create: `packages/@n8n/api-types/src/utils/git-branch-name.ts`
- Create: `packages/@n8n/api-types/src/utils/__tests__/git-branch-name.test.ts`
- Modify: `packages/@n8n/api-types/src/dto/source-control/push-work-folder-request.dto.ts`
- Modify: `packages/@n8n/api-types/src/dto/source-control/__tests__/push-work-folder-request.dto.test.ts` (create if absent)
- Modify: `packages/@n8n/api-types/src/index.ts` (export the validator)
**Interfaces:**
- Produces: `isValidGitBranchName(name: string): boolean` — exported from `@n8n/api-types`.
- Produces: `PushWorkFolderRequestDto` now has `branch?: string` and `createBranch?: boolean`.
- [ ] **Step 1: Write the failing validator test**
Create `packages/@n8n/api-types/src/utils/__tests__/git-branch-name.test.ts`:
```typescript
import { isValidGitBranchName } from '../git-branch-name';
describe('isValidGitBranchName', () => {
test.each([
'main',
'develop',
'feat/my-thing',
'release/1.2.3',
'user/feature_x',
])('accepts valid name %s', (name) => {
expect(isValidGitBranchName(name)).toBe(true);
});
test.each([
'',
' ',
'has space',
'-leading-dash',
'/leading-slash',
'trailing-slash/',
'double//slash',
'dot..dot',
'ends.lock'.replace('ends', 'branch.lock') /* branch.lock */,
'has~tilde',
'has^caret',
'has:colon',
'has?question',
'has*star',
'has[bracket',
'has\\backslash',
'has@{seq',
'ends.',
])('rejects invalid name %j', (name) => {
expect(isValidGitBranchName(name)).toBe(false);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run (inside `packages/@n8n/api-types`): `pnpm test src/utils/__tests__/git-branch-name.test.ts`
Expected: FAIL — cannot find module `../git-branch-name`.
- [ ] **Step 3: Implement the validator**
Create `packages/@n8n/api-types/src/utils/git-branch-name.ts`:
```typescript
/**
* Approximates `git check-ref-format --branch`. Rejects names git would refuse
* so branch creation/checkout fails fast with a clear message instead of deep
* in the git layer.
*/
export function isValidGitBranchName(name: string): boolean {
if (!name || name.length === 0) return false;
if (/\s/.test(name)) return false; // no whitespace
if (name.startsWith('-') || name.startsWith('/')) return false;
if (name.endsWith('/') || name.endsWith('.') || name.endsWith('.lock')) return false;
if (name.includes('..') || name.includes('//') || name.includes('@{')) return false;
// disallowed characters: ~ ^ : ? * [ \ and ASCII control chars
if (/[~^:?*[\\\x00-\x1f\x7f]/.test(name)) return false;
return true;
}
```
- [ ] **Step 4: Run validator test to verify it passes**
Run: `pnpm test src/utils/__tests__/git-branch-name.test.ts`
Expected: PASS.
- [ ] **Step 5: Export the validator**
In `packages/@n8n/api-types/src/index.ts`, add an export line alongside the other `export * from './...'` lines:
```typescript
export * from './utils/git-branch-name';
```
(If `src/utils` has no barrel, add the direct export above; match the file's existing export style.)
- [ ] **Step 6: Write the failing DTO test**
Create/modify `packages/@n8n/api-types/src/dto/source-control/__tests__/push-work-folder-request.dto.test.ts`:
```typescript
import { PushWorkFolderRequestDto } from '../push-work-folder-request.dto';
describe('PushWorkFolderRequestDto', () => {
test('accepts a valid target branch', () => {
const result = PushWorkFolderRequestDto.safeParse({
fileNames: [],
branch: 'feat/my-thing',
createBranch: true,
});
expect(result.success).toBe(true);
});
test('rejects an invalid branch name', () => {
const result = PushWorkFolderRequestDto.safeParse({
fileNames: [],
branch: 'bad branch',
});
expect(result.success).toBe(false);
});
test('allows omitting branch (backwards compatible)', () => {
const result = PushWorkFolderRequestDto.safeParse({ fileNames: [] });
expect(result.success).toBe(true);
});
});
```
- [ ] **Step 7: Run DTO test to verify it fails**
Run: `pnpm test src/dto/source-control/__tests__/push-work-folder-request.dto.test.ts`
Expected: FAIL — invalid branch currently parses successfully (no `branch` field yet).
- [ ] **Step 8: Add fields to the DTO**
Replace `packages/@n8n/api-types/src/dto/source-control/push-work-folder-request.dto.ts`:
```typescript
import { z } from 'zod';
import { SourceControlledFileSchema } from '../../schemas/source-controlled-file.schema';
import { isValidGitBranchName } from '../../utils/git-branch-name';
import { Z } from '../../zod-class';
export class PushWorkFolderRequestDto extends Z.class({
force: z.boolean().optional(),
commitMessage: z.string().optional(),
fileNames: z.array(SourceControlledFileSchema),
branch: z
.string()
.refine(isValidGitBranchName, { message: 'Invalid git branch name' })
.optional(),
createBranch: z.boolean().optional(),
}) {}
```
- [ ] **Step 9: Run DTO test to verify it passes**
Run: `pnpm test src/dto/source-control/__tests__/push-work-folder-request.dto.test.ts`
Expected: PASS.
- [ ] **Step 10: Typecheck and commit**
Run (inside `packages/@n8n/api-types`): `pnpm typecheck`
Expected: no errors.
```bash
git add packages/@n8n/api-types/src/utils/git-branch-name.ts \
packages/@n8n/api-types/src/utils/__tests__/git-branch-name.test.ts \
packages/@n8n/api-types/src/dto/source-control/push-work-folder-request.dto.ts \
packages/@n8n/api-types/src/dto/source-control/__tests__/push-work-folder-request.dto.test.ts \
packages/@n8n/api-types/src/index.ts
git commit -m "feat(api-types): add branch selection fields to push DTO"
```
---
### Task 2: Git service — create-branch, force checkout, upstream push
**Files:**
- Modify: `packages/cli/src/modules/source-control.ee/source-control-git.service.ee.ts`
- Test: `packages/cli/src/modules/source-control.ee/__tests__/source-control-git.service.ee.test.ts` (create if absent; check for an existing test file first)
**Interfaces:**
- Consumes: existing `this.git` (simple-git instance), `SOURCE_CONTROL_ORIGIN`, `SOURCE_CONTROL_DEFAULT_BRANCH`.
- Produces:
- `createBranchFrom(newBranch: string, baseBranch: string): Promise<void>`
- `checkoutExistingBranch(branch: string): Promise<void>`
- `push(options: { force: boolean; branch: string; setUpstream?: boolean }): Promise<PushResult>` (extended signature — `setUpstream` optional, defaults false).
- [ ] **Step 1: Write the failing git-service test**
In the git service test file (create `__tests__/source-control-git.service.ee.test.ts` if none), add. Adjust the mock/instantiation to match the existing test setup in the module if one already exists:
```typescript
import { mock } from 'jest-mock-extended';
import type { SimpleGit } from 'simple-git';
import { SourceControlGitService } from '../source-control-git.service.ee';
describe('SourceControlGitService branch ops', () => {
const git = mock<SimpleGit>();
const service = new SourceControlGitService(mock(), mock(), mock());
// @ts-expect-error assign private git instance for the test
service.git = git;
beforeEach(() => jest.clearAllMocks());
test('createBranchFrom resets base then creates the new branch', async () => {
await service.createBranchFrom('feat/x', 'main');
expect(git.checkout).toHaveBeenCalledWith('main', ['-f']);
expect(git.raw).toHaveBeenCalledWith(['reset', '--hard', 'origin/main']);
expect(git.checkoutBranch).toHaveBeenCalledWith('feat/x', 'main');
});
test('checkoutExistingBranch force-checks-out and sets upstream', async () => {
await service.checkoutExistingBranch('feat/x');
expect(git.checkout).toHaveBeenCalledWith('feat/x', ['-f']);
expect(git.branch).toHaveBeenCalledWith(['--set-upstream-to=origin/feat/x', 'feat/x']);
});
test('push with setUpstream passes -u', async () => {
await service.push({ force: false, branch: 'feat/x', setUpstream: true });
expect(git.push).toHaveBeenCalledWith('origin', 'feat/x', ['-u']);
});
});
```
> Note: n8n cli uses Vitest, not Jest. If the surrounding tests import from `vitest` and use `vi`/`mock` helpers, mirror that style (`import { mock } from 'vitest-mock-extended'`, `vi.clearAllMocks()`). Match whatever the neighboring test files in this module already do.
- [ ] **Step 2: Run test to verify it fails**
Run (inside `packages/cli`): `pnpm test src/modules/source-control.ee/__tests__/source-control-git.service.ee.test.ts`
Expected: FAIL — `createBranchFrom` / `checkoutExistingBranch` not defined; `push` ignores `setUpstream`.
- [ ] **Step 3: Add the git-service methods**
In `source-control-git.service.ee.ts`, add after `setBranch` (around line 386):
```typescript
async checkoutExistingBranch(branch: string): Promise<void> {
if (!this.git) {
throw new UnexpectedError('Git is not initialized (checkoutExistingBranch)');
}
await this.git.checkout(branch, ['-f']);
await this.git.branch([`--set-upstream-to=${SOURCE_CONTROL_ORIGIN}/${branch}`, branch]);
}
async createBranchFrom(newBranch: string, baseBranch: string): Promise<void> {
if (!this.git) {
throw new UnexpectedError('Git is not initialized (createBranchFrom)');
}
// Start from the latest remote state of the base branch, then branch off it.
await this.git.checkout(baseBranch, ['-f']);
await this.git.raw(['reset', '--hard', `${SOURCE_CONTROL_ORIGIN}/${baseBranch}`]);
await this.git.checkoutBranch(newBranch, baseBranch);
}
```
- [ ] **Step 4: Extend `push` to support upstream**
Replace the existing `push` method (lines ~443-458):
```typescript
async push(
options: { force: boolean; branch: string; setUpstream?: boolean } = {
force: false,
branch: SOURCE_CONTROL_DEFAULT_BRANCH,
},
): Promise<PushResult> {
const { force, branch, setUpstream } = options;
if (!this.git) {
throw new UnexpectedError('Git is not initialized (push)');
}
await this.setGitCommand();
const flags: string[] = [];
if (setUpstream) flags.push('-u');
if (force) flags.push('-f');
if (flags.length > 0) {
return await this.git.push(SOURCE_CONTROL_ORIGIN, branch, flags);
}
return await this.git.push(SOURCE_CONTROL_ORIGIN, branch);
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `pnpm test src/modules/source-control.ee/__tests__/source-control-git.service.ee.test.ts`
Expected: PASS.
- [ ] **Step 6: Typecheck and commit**
Run (inside `packages/cli`): `pnpm typecheck`
```bash
git add packages/cli/src/modules/source-control.ee/source-control-git.service.ee.ts \
packages/cli/src/modules/source-control.ee/__tests__/source-control-git.service.ee.test.ts
git commit -m "feat(source-control): add branch create/checkout git helpers"
```
---
### Task 3: Push service — resolve and switch target branch
**Files:**
- Modify: `packages/cli/src/modules/source-control.ee/source-control.service.ee.ts`
- Test: `packages/cli/src/modules/source-control.ee/__tests__/source-control.service.ee.test.ts` (existing — check first)
**Interfaces:**
- Consumes: `PushWorkFolderRequestDto` (`branch`, `createBranch`) from Task 1; `gitService.createBranchFrom`, `gitService.checkoutExistingBranch`, `gitService.push({ setUpstream })` from Task 2.
- Produces: `pushWorkfolderWithoutLock` commits and pushes to the resolved branch.
- [ ] **Step 1: Write the failing service test**
Add to the service test file (mirror the existing mock setup in that file for `gitService`, `sourceControlPreferencesService`, export/import services):
```typescript
describe('pushWorkfolder branch selection', () => {
// Assumes the suite's existing beforeEach wires mocks so a plain push resolves.
// getBranchName() returns the default branch.
test('creates a new branch off the default before committing', async () => {
sourceControlPreferencesService.getBranchName.mockReturnValue('main');
await service.pushWorkfolder(user, {
fileNames: [],
branch: 'feat/x',
createBranch: true,
commitMessage: 'msg',
});
expect(gitService.createBranchFrom).toHaveBeenCalledWith('feat/x', 'main');
expect(gitService.push).toHaveBeenCalledWith(
expect.objectContaining({ branch: 'feat/x', setUpstream: true }),
);
});
test('checks out an existing branch before committing', async () => {
sourceControlPreferencesService.getBranchName.mockReturnValue('main');
await service.pushWorkfolder(user, {
fileNames: [],
branch: 'develop',
commitMessage: 'msg',
});
expect(gitService.checkoutExistingBranch).toHaveBeenCalledWith('develop');
expect(gitService.push).toHaveBeenCalledWith(
expect.objectContaining({ branch: 'develop', setUpstream: false }),
);
});
test('falls back to the default branch when none given', async () => {
sourceControlPreferencesService.getBranchName.mockReturnValue('main');
await service.pushWorkfolder(user, { fileNames: [], commitMessage: 'msg' });
expect(gitService.createBranchFrom).not.toHaveBeenCalled();
expect(gitService.checkoutExistingBranch).not.toHaveBeenCalled();
expect(gitService.push).toHaveBeenCalledWith(
expect.objectContaining({ branch: 'main' }),
);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run (inside `packages/cli`): `pnpm test src/modules/source-control.ee/__tests__/source-control.service.ee.test.ts -t "branch selection"`
Expected: FAIL — service ignores `branch`/`createBranch`; still pushes the default.
- [ ] **Step 3: Add the branch-prep helper**
In `source-control.service.ee.ts`, add a private method (near `setBranch`, ~line 285):
```typescript
/**
* Resolves the branch a push targets and switches the working clone onto it.
* Called before export/commit so the commit lands on the right branch.
* Returns the target branch and whether it was newly created (needs upstream).
*/
private async prepareBranchForPush(
options: PushWorkFolderRequestDto,
): Promise<{ targetBranch: string; isNewBranch: boolean; defaultBranch: string }> {
const defaultBranch = this.sourceControlPreferencesService.getBranchName();
const requested = options.branch?.trim();
if (!requested || requested === defaultBranch) {
return { targetBranch: defaultBranch, isNewBranch: false, defaultBranch };
}
await this.gitService.fetch();
if (options.createBranch) {
await this.gitService.createBranchFrom(requested, defaultBranch);
return { targetBranch: requested, isNewBranch: true, defaultBranch };
}
await this.gitService.checkoutExistingBranch(requested);
return { targetBranch: requested, isNewBranch: false, defaultBranch };
}
```
- [ ] **Step 4: Call it before export and use the result at push time**
In `pushWorkfolderWithoutLock`, right after `const context = await this.sourceControlContextFactory.createContext(user);` (line ~335) and before the `filesToPush` mapping, insert:
```typescript
const { targetBranch, isNewBranch, defaultBranch } =
await this.prepareBranchForPush(options);
```
Then replace the push block (lines ~476-494). Old:
```typescript
const branchName = this.sourceControlPreferencesService.getBranchName();
let pushResult: PushResult | undefined;
try {
pushResult = await this.gitService.push({
branch: branchName,
force: options.force ?? false,
});
// Only mark files as pushed after successful push
statusResult.forEach((result) => (result.pushed = true));
} catch (error) {
this.logger.error('Failed to push changes', { error });
try {
await this.gitService.resetBranch({ hard: true, target: `origin/${branchName}` });
} catch (resetError) {
this.logger.error('Failed to reset branch after push error', { error: resetError });
}
throw error;
}
```
New:
```typescript
let pushResult: PushResult | undefined;
try {
pushResult = await this.gitService.push({
branch: targetBranch,
force: options.force ?? false,
setUpstream: isNewBranch,
});
// Only mark files as pushed after successful push
statusResult.forEach((result) => (result.pushed = true));
} catch (error) {
this.logger.error('Failed to push changes', { error });
try {
// A brand-new branch has no origin ref yet; fall back to the default.
const resetTarget = isNewBranch
? `origin/${defaultBranch}`
: `origin/${targetBranch}`;
await this.gitService.resetBranch({ hard: true, target: resetTarget });
} catch (resetError) {
this.logger.error('Failed to reset branch after push error', { error: resetError });
}
throw error;
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `pnpm test src/modules/source-control.ee/__tests__/source-control.service.ee.test.ts -t "branch selection"`
Expected: PASS.
- [ ] **Step 6: Run the full service test file (regression)**
Run: `pnpm test src/modules/source-control.ee/__tests__/source-control.service.ee.test.ts`
Expected: PASS (existing push tests still pass — plain push resolves to the default branch).
- [ ] **Step 7: Typecheck and commit**
Run (inside `packages/cli`): `pnpm typecheck`
```bash
git add packages/cli/src/modules/source-control.ee/source-control.service.ee.ts \
packages/cli/src/modules/source-control.ee/__tests__/source-control.service.ee.test.ts
git commit -m "feat(source-control): push to selected or newly created branch"
```
---
### Task 4: Pull re-checks-out the default branch
**Files:**
- Modify: `packages/cli/src/modules/source-control.ee/source-control.service.ee.ts`
- Test: `packages/cli/src/modules/source-control.ee/__tests__/source-control.service.ee.test.ts`
**Interfaces:**
- Consumes: `gitService.checkoutExistingBranch` (Task 2), `sourceControlPreferencesService.getBranchName()`.
- Produces: `pullWorkfolderWithoutLock` always operates on the default branch.
- [ ] **Step 1: Write the failing test**
```typescript
test('pull checks out the default branch first', async () => {
sourceControlPreferencesService.getBranchName.mockReturnValue('main');
await service.pullWorkfolder(user, { force: true, autoPublish: 'never' });
expect(gitService.checkoutExistingBranch).toHaveBeenCalledWith('main');
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm test src/modules/source-control.ee/__tests__/source-control.service.ee.test.ts -t "pull checks out"`
Expected: FAIL — pull never checks out the default branch.
- [ ] **Step 3: Add checkout to pull**
In `pullWorkfolderWithoutLock`, right after `await this.sanityCheck();` (line ~523), insert:
```typescript
// Pull always targets the default branch; a prior feature-branch commit
// may have left HEAD elsewhere, so switch back first.
const defaultBranch = this.sourceControlPreferencesService.getBranchName();
await this.gitService.checkoutExistingBranch(defaultBranch);
```
- [ ] **Step 4: Run test to verify it passes**
Run: `pnpm test src/modules/source-control.ee/__tests__/source-control.service.ee.test.ts -t "pull checks out"`
Expected: PASS.
- [ ] **Step 5: Run full file (regression), typecheck, commit**
Run: `pnpm test src/modules/source-control.ee/__tests__/source-control.service.ee.test.ts` → PASS
Run: `pnpm typecheck`
```bash
git add packages/cli/src/modules/source-control.ee/source-control.service.ee.ts \
packages/cli/src/modules/source-control.ee/__tests__/source-control.service.ee.test.ts
git commit -m "feat(source-control): pull always targets the default branch"
```
---
### Task 5: Frontend store — thread branch + createBranch through push
**Files:**
- Modify: `packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/sourceControl.store.ts`
- Test: `packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/__tests__/sourceControl.store.test.ts` (create if absent; check first)
**Interfaces:**
- Consumes: `vcApi.pushWorkfolder` (already forwards a `PushWorkFolderRequestDto` verbatim — no api.ts change needed).
- Produces: `pushWorkfolder(data: { commitMessage; fileNames; force; branch?; createBranch? })` forwards the new fields.
- [ ] **Step 1: Write the failing store test**
```typescript
import { setActivePinia, createPinia } from 'pinia';
import { useSourceControlStore } from '../sourceControl.store';
import * as vcApi from '../sourceControl.api';
vi.mock('../sourceControl.api');
describe('sourceControl store pushWorkfolder', () => {
beforeEach(() => setActivePinia(createPinia()));
test('forwards branch and createBranch', async () => {
const spy = vi.spyOn(vcApi, 'pushWorkfolder').mockResolvedValue({ files: [], commit: null });
const store = useSourceControlStore();
await store.pushWorkfolder({
commitMessage: 'msg',
fileNames: [],
force: true,
branch: 'feat/x',
createBranch: true,
});
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ branch: 'feat/x', createBranch: true }),
);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run (inside `packages/frontend/editor-ui`): `pnpm test src/features/integrations/sourceControl.ee/__tests__/sourceControl.store.test.ts`
Expected: FAIL — `branch`/`createBranch` not forwarded.
- [ ] **Step 3: Update the store action**
Replace `pushWorkfolder` in `sourceControl.store.ts` (lines ~45-56):
```typescript
const pushWorkfolder = async (data: {
commitMessage: string;
fileNames: SourceControlledFile[];
force: boolean;
branch?: string;
createBranch?: boolean;
}) => {
state.commitMessage = data.commitMessage;
return await vcApi.pushWorkfolder(rootStore.restApiContext, {
force: data.force,
commitMessage: data.commitMessage,
fileNames: data.fileNames,
branch: data.branch,
createBranch: data.createBranch,
});
};
```
- [ ] **Step 4: Run test to verify it passes**
Run: `pnpm test src/features/integrations/sourceControl.ee/__tests__/sourceControl.store.test.ts`
Expected: PASS.
- [ ] **Step 5: Typecheck and commit**
Run (inside `packages/frontend/editor-ui`): `pnpm typecheck`
```bash
git add packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/sourceControl.store.ts \
packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/__tests__/sourceControl.store.test.ts
git commit -m "feat(source-control): forward branch selection from store"
```
---
### Task 6: Commit modal — branch dropdown + New branch toggle
**Files:**
- Modify: `packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue`
- Modify: `packages/frontend/@n8n/i18n/src/locales/en.json`
- Test: `packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/__tests__/SourceControlPushModal.*.test.ts` (extend existing modal test if present; else create)
**Interfaces:**
- Consumes: `sourceControlStore.getBranches()` (populates `preferences.branches` + `preferences.branchName`), `sourceControlStore.preferences.branchName` (default branch), `isValidGitBranchName` from `@n8n/api-types`, `sourceControlStore.pushWorkfolder` (Task 5).
- Produces: `commitAndPush` sends `branch` + `createBranch`.
- [ ] **Step 1: Add i18n strings**
In `packages/frontend/@n8n/i18n/src/locales/en.json`, add near the push modal keys (after line ~3794):
```json
"settings.sourceControl.modals.push.branch": "Branch",
"settings.sourceControl.modals.push.branch.newBranch": "New branch",
"settings.sourceControl.modals.push.branch.newBranchPlaceholder": "e.g. feat/my-change",
"settings.sourceControl.modals.push.branch.base": "Base: {branch}",
"settings.sourceControl.modals.push.branch.invalid": "Invalid git branch name",
```
- [ ] **Step 2: Write the failing modal test**
Add a test that mounts the modal (reuse the existing modal test's render helper / mocks) and asserts the branch select renders and the toggle swaps to a text input:
```typescript
test('renders branch selector and toggles to new-branch input', async () => {
// render helper from the existing modal test; store.getBranches mocked to
// set preferences.branches = ['main', 'develop'], preferences.branchName = 'main'
const { getByTestId, queryByTestId } = renderModal();
expect(getByTestId('source-control-push-modal-branch-select')).toBeInTheDocument();
expect(queryByTestId('source-control-push-modal-branch-new')).not.toBeInTheDocument();
await userEvent.click(getByTestId('source-control-push-modal-branch-new-toggle'));
expect(getByTestId('source-control-push-modal-branch-new')).toBeInTheDocument();
});
```
- [ ] **Step 3: Run test to verify it fails**
Run (inside `packages/frontend/editor-ui`): `pnpm test SourceControlPushModal`
Expected: FAIL — branch testids don't exist yet.
- [ ] **Step 4: Add branch state + fetch to the script**
In `SourceControlPushModal.vue` `<script setup>`, add imports and state (near `const commitMessage = ref('');`, line ~469):
```typescript
import { isValidGitBranchName } from '@n8n/api-types';
const isCreatingBranch = ref(false);
const selectedBranch = ref('');
const newBranchName = ref('');
const branchOptions = computed(() =>
sourceControlStore.preferences.branches.map((b) => ({ label: b, value: b })),
);
const defaultBranch = computed(() => sourceControlStore.preferences.branchName);
const isBranchValid = computed(() =>
isCreatingBranch.value
? isValidGitBranchName(newBranchName.value.trim())
: selectedBranch.value.length > 0,
);
```
- [ ] **Step 5: Fetch branches on mount and default the selection**
In the existing `onMounted` (line ~887), after `await loadSourceControlStatus();`, add:
```typescript
try {
await sourceControlStore.getBranches();
} catch (error) {
// non-fatal: fall back to the default branch already in preferences
}
selectedBranch.value = sourceControlStore.preferences.branchName;
```
- [ ] **Step 6: Fold `isBranchValid` into submit-disabled**
Find `isSubmitDisabled` (the `:disabled` binding on the submit button uses it) and add `|| !isBranchValid.value` to its computed definition. If `isSubmitDisabled` is a computed, edit its return expression; e.g.:
```typescript
const isSubmitDisabled = computed(
() => selectedCount.value === 0 || !isBranchValid.value,
);
```
(Preserve any existing conditions in that computed — append `|| !isBranchValid.value`.)
- [ ] **Step 7: Pass branch into commitAndPush**
Update the `pushWorkfolder` call in `commitAndPush` (lines ~611-615):
```typescript
await sourceControlStore.pushWorkfolder({
force: true,
commitMessage: commitMessage.value,
fileNames: files,
branch: isCreatingBranch.value ? newBranchName.value.trim() : selectedBranch.value,
createBranch: isCreatingBranch.value,
});
```
- [ ] **Step 8: Add the branch UI to the footer template**
In the footer (`<template #footer>`, before the `commitMessage` `<N8nText bold tag="p">` block at line ~1280), insert:
```vue
<div :class="$style.branchRow">
<N8nSelect
v-if="!isCreatingBranch"
v-model="selectedBranch"
data-test-id="source-control-push-modal-branch-select"
:placeholder="i18n.baseText('settings.sourceControl.modals.push.branch')"
filterable
teleported="false"
>
<N8nOption
v-for="option in branchOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</N8nSelect>
<N8nInput
v-else
v-model="newBranchName"
data-test-id="source-control-push-modal-branch-new"
:placeholder="
i18n.baseText('settings.sourceControl.modals.push.branch.newBranchPlaceholder')
"
/>
<N8nCheckbox
v-model="isCreatingBranch"
data-test-id="source-control-push-modal-branch-new-toggle"
:label="i18n.baseText('settings.sourceControl.modals.push.branch.newBranch')"
/>
</div>
<N8nText v-if="isCreatingBranch" size="small" color="text-light">
{{ i18n.baseText('settings.sourceControl.modals.push.branch.base', { interpolate: { branch: defaultBranch } }) }}
</N8nText>
```
Ensure `N8nOption` and `N8nCheckbox` are added to the `@n8n/design-system` import block (lines ~55-64) if not already imported. Add a `.branchRow` style in the `<style module>` block:
```scss
.branchRow {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
margin-bottom: var(--spacing-2xs);
}
```
- [ ] **Step 9: Run modal test to verify it passes**
Run: `pnpm test SourceControlPushModal`
Expected: PASS.
- [ ] **Step 10: Lint, typecheck, commit**
Run (inside `packages/frontend/editor-ui`): `pnpm lint && pnpm typecheck`
```bash
git add packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue \
packages/frontend/@n8n/i18n/src/locales/en.json \
packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/__tests__/
git commit -m "feat(source-control): add branch picker to commit modal"
```
---
### Task 7: Relabel Admin Settings branch as "Default branch"
**Files:**
- Modify: `packages/frontend/@n8n/i18n/src/locales/en.json`
- Modify: `packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue`
**Interfaces:**
- No API change; UI copy only.
- [ ] **Step 1: Update the i18n string**
In `packages/frontend/@n8n/i18n/src/locales/en.json`, change line ~3770:
```json
"settings.sourceControl.branches": "Default branch",
```
- [ ] **Step 2: Add a helper caption string (optional clarity)**
Add near it:
```json
"settings.sourceControl.branches.caption": "Used for pull. Choose or create the branch for each commit in the push dialog.",
```
- [ ] **Step 3: Reference the caption under the branch select in the settings view**
In `SettingsSourceControl.vue`, under the branch `<N8nFormInput>` (around line ~489), add:
```vue
<N8nText size="small" color="text-light">
{{ i18n.baseText('settings.sourceControl.branches.caption') }}
</N8nText>
```
- [ ] **Step 4: Verify the settings view renders (lint + typecheck)**
Run (inside `packages/frontend/editor-ui`): `pnpm lint && pnpm typecheck`
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add packages/frontend/@n8n/i18n/src/locales/en.json \
packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue
git commit -m "feat(source-control): relabel fixed branch as default branch"
```
---
## Final Verification
- [ ] Build affected packages: from repo root `pnpm build > build.log 2>&1`, then `tail -n 20 build.log` — no errors (api-types is consumed by cli + editor-ui, so build before final lint/typecheck).
- [ ] Backend: inside `packages/cli`, `pnpm test src/modules/source-control.ee` → PASS.
- [ ] api-types: inside `packages/@n8n/api-types`, `pnpm test src/dto/source-control src/utils` → PASS.
- [ ] Frontend: inside `packages/frontend/editor-ui`, `pnpm test SourceControlPushModal sourceControl.store` → PASS.
- [ ] Repo-wide `pnpm lint` and `pnpm typecheck` clean.
- [ ] Manual smoke (optional, via `pnpm dev` against service containers): connect a repo, open the commit modal, pick an existing branch and push; toggle "New branch", enter `feat/smoke`, push, and confirm the branch appears on the remote; pull and confirm it targets the default branch.
## Self-Review Notes
- **Spec coverage:** default-branch relabel (Task 7), per-commit branch select (Task 6), new-branch creation off default (Tasks 23, 6), pull-always-default (Task 4), DTO + validation (Task 1), read-only flag untouched (no task — intentionally unchanged). All spec sections covered.
- **Type consistency:** `createBranchFrom(newBranch, baseBranch)`, `checkoutExistingBranch(branch)`, `push({ force, branch, setUpstream })`, `prepareBranchForPush → { targetBranch, isNewBranch, defaultBranch }`, store `pushWorkfolder({ ..., branch?, createBranch? })` — names consistent across Tasks 2/3/5/6.
- **Test-setup caveat:** the exact mock style (Vitest `vi` vs jest) and render helpers must match each package's existing tests; steps note this where it matters.

View File

@ -0,0 +1,153 @@
# Source Control: per-commit branch selection & feature-branch creation
- **Date:** 2026-07-15
- **Status:** Approved (design)
- **Area:** `packages/cli/src/modules/source-control.ee`, `packages/@n8n/api-types`, `packages/frontend/editor-ui/src/features/integrations/sourceControl.ee`
## Problem
Today an n8n instance is fixed to a single git branch, chosen once in Admin
Settings. Every commit (push) and every pull targets that one branch. Users who
work with feature branches cannot pick a branch per commit, nor create a new
branch from the instance.
## Goal
1. Connect a repository to the instance without being locked to one branch for
all operations.
2. Let the user choose the target branch in the commit (push) window.
3. Optionally create a new branch from the commit window, feature-branch style.
## Non-goals
- No PR/MR creation (provider-specific; out of scope).
- No change to the read-only-instance flag semantics.
- No per-pull branch selector.
## Model
- The repository connects; Admin Settings keeps a **default branch** (the
existing `branchName` field, relabeled in UI/i18n from "Branch" to
"Default branch"). No schema change.
- **Pull / reset / status** always operate on the admin default branch. Pull
explicitly checks out the default branch first, so a prior feature-branch
commit never leaves `HEAD` pointing elsewhere.
- The **commit window** selects the push target: an existing branch, or a new
branch created off the latest default branch.
- The **read-only-instance flag** (`branchReadOnly`) is unchanged and
orthogonal. It marks the whole instance read-only (blocks editing and,
consequently, pushing). When set, the commit window does not apply.
### Clarification on `branchReadOnly`
`branchReadOnly` is a whole-instance "read-only environment" toggle, not a
git-branch protection. It blocks instance editing on the frontend
(`useWorkflowSaving.ts:160`, `useGlobalEntityCreation.ts`,
`MainSidebarHeader.vue`) and blocks push on the backend
(`source-control.service.ee.ts:331`). It is left untouched by this work.
## Backend (`source-control.ee`)
### DTO — `packages/@n8n/api-types/src/dto/source-control/push-work-folder-request.dto.ts`
Add two optional fields to `PushWorkFolderRequestDto`:
- `branch?: string` — target branch for this push. When omitted, the admin
default branch is used (current behavior).
- `createBranch?: boolean` — when `true`, `branch` names a new branch to be
created off the latest default branch.
Validate `branch` with a proper git ref-name rule (reject spaces, the
characters `~ ^ : ? * [ \`, `..`, a leading `-`, trailing `.lock`, empty
segments) rather than the current loose `/^[a-zA-Z0-9]/` used on preferences.
### Service — `source-control.service.ee.ts`
In `pushWorkfolderWithoutLock`, replace the hardcoded
`this.sourceControlPreferencesService.getBranchName()` (line ~476) with the
resolved target branch. The whole method is already mutex-guarded.
Resolution logic:
- `createBranch === true`: `fetch` → checkout default → reset to
`origin/<default>``checkout -b <branch>` → export DB → commit →
`push -u origin <branch>`.
- `branch` given and existing: `fetch` → checkout `<branch>` → export DB →
commit → push.
- no `branch` (or `branch === default`): current behavior unchanged.
Before exporting, force-sync the working tree (the DB is the source of truth;
files are re-exported anyway) so a branch switch never fails on stale exported
files.
On push failure the existing recovery (reset to `origin/<branch>`) applies to
the resolved branch.
### Pull — `pullWorkfolderWithoutLock`
Ensure the default branch is checked out before pulling, so a prior
feature-branch commit does not leave `HEAD` on another branch.
### Git service — `source-control-git.service.ee.ts`
- Add a `createBranch(name, base)` helper (`checkout -b` from base).
- Reuse existing `getBranches`, `setBranch`, `push`, `fetch`, `commit`,
`stage`.
## Frontend (`sourceControl.ee`)
### `components/SourceControlPushModal.vue`
Add a branch control above the commit message (chosen UI: dropdown + toggle):
- A dropdown of existing branches, defaulting to the admin default branch.
- A "New branch" toggle. When on, the dropdown is replaced by a text input
with a "base: `<default>`" hint and client-side git-ref-name validation.
- On modal open, call the existing `getBranches()` to populate the dropdown
(show a loading state during the fetch).
### API / store / types
- `sourceControl.api.ts`, `sourceControl.store.ts`, `sourceControl.types.ts`:
thread `branch` and `createBranch` through the push payload.
### `views/SettingsSourceControl.vue`
- Relabel the branch select from "Branch" to "Default branch" (i18n string in
`@n8n/i18n`).
## Testing
### Backend unit (Vitest, mock git service)
- Push to an existing non-default branch.
- Create a new branch off default and push with upstream.
- No branch given → default branch (unchanged behavior).
- Invalid branch name rejected by DTO validation.
- Read-only instance still blocks push.
### Frontend unit (Vitest)
- Modal renders the branch dropdown populated from `getBranches`.
- Toggle swaps dropdown for the new-branch text input.
- Invalid branch name blocks submit.
- Push payload carries `branch` / `createBranch` correctly.
### E2E (Playwright) — optional, may defer
- Commit to a newly created feature branch against a git remote fixture.
## Risks & mitigations
- **Branch switch touches the shared working clone.** Already mutex-guarded;
force-sync the working tree before export so checkout never conflicts with
stale exported files.
- **Target existing branch behind remote → non-fast-forward push rejected.**
Existing failure path resets to `origin/<branch>`; the `force` toggle already
exists. No new behavior.
## Migration
No DB migration. `branchName` keeps its meaning as the default branch;
existing connected instances keep working with their current branch as the
default.

View File

@ -40,6 +40,20 @@ describe('PushWorkFolderRequestDto', () => {
],
},
},
{
name: 'push request with a valid target branch',
request: {
fileNames: [],
branch: 'feat/my-thing',
createBranch: true,
},
},
{
name: 'push request omitting branch (backwards compatible)',
request: {
fileNames: [],
},
},
])('should validate $name', ({ request }) => {
const result = PushWorkFolderRequestDto.safeParse(request);
expect(result.success).toBe(true);
@ -100,6 +114,14 @@ describe('PushWorkFolderRequestDto', () => {
},
expectedErrorPath: ['force'],
},
{
name: 'invalid branch name',
request: {
fileNames: [],
branch: 'bad branch',
},
expectedErrorPath: ['branch'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = PushWorkFolderRequestDto.safeParse(request);
expect(result.success).toBe(false);

View File

@ -1,10 +1,16 @@
import { z } from 'zod';
import { SourceControlledFileSchema } from '../../schemas/source-controlled-file.schema';
import { isValidGitBranchName } from '../../utils/git-branch-name';
import { Z } from '../../zod-class';
export class PushWorkFolderRequestDto extends Z.class({
force: z.boolean().optional(),
commitMessage: z.string().optional(),
fileNames: z.array(SourceControlledFileSchema),
branch: z
.string()
.refine(isValidGitBranchName, { message: 'Invalid git branch name' })
.optional(),
createBranch: z.boolean().optional(),
}) {}

View File

@ -585,6 +585,7 @@ export {
} from './schemas/eval-insights.schema';
export { ALLOWED_DOMAINS, isAllowedDomain } from './utils/allowed-domains';
export { isValidGitBranchName } from './utils/git-branch-name';
export type { PublishTimelineEvent } from './schemas/workflow-publish-timeline.schema';
export {

View File

@ -0,0 +1,41 @@
import { isValidGitBranchName } from '../git-branch-name';
describe('isValidGitBranchName', () => {
test.each([
'main',
'develop',
'feat/my-thing',
'release/1.2.3',
'user/feature_x',
'foo./bar', // a dot at the end of a non-last component is fine
])('accepts valid name %s', (name) => {
expect(isValidGitBranchName(name)).toBe(true);
});
test.each([
'',
' ',
'has space',
'-leading-dash',
'/leading-slash',
'trailing-slash/',
'double//slash',
'dot..dot',
'branch.lock',
'has~tilde',
'has^caret',
'has:colon',
'has?question',
'has*star',
'has[bracket',
'has\\backslash',
'has@{seq',
'ends.',
'foo/.bar', // component starting with a dot, nested
'.foo/bar', // component starting with a dot, first segment
'foo.lock/bar', // component ending in .lock, not the last segment
'foo/bar.lock/baz', // component ending in .lock, mid-path
])('rejects invalid name %j', (name) => {
expect(isValidGitBranchName(name)).toBe(false);
});
});

View File

@ -0,0 +1,21 @@
/**
* Approximates `git check-ref-format --branch`. Rejects names git would refuse
* so branch creation/checkout fails fast with a clear message instead of deep
* in the git layer.
*/
export function isValidGitBranchName(name: string): boolean {
if (!name) return false;
if (/\s/.test(name)) return false; // no whitespace
if (name.startsWith('-') || name.startsWith('/')) return false;
if (name.endsWith('/') || name.endsWith('.')) return false;
if (name.includes('..') || name.includes('//') || name.includes('@{')) return false;
// disallowed characters: ~ ^ : ? * [ \ and ASCII control chars
// eslint-disable-next-line no-control-regex
if (/[~^:?*[\\\x00-\x1f\x7f]/.test(name)) return false;
// Git's per-component rules apply to every slash-separated segment, not just the
// whole name: no segment may start with a dot, or end with the sequence `.lock`.
if (name.split('/').some((segment) => segment.startsWith('.') || segment.endsWith('.lock'))) {
return false;
}
return true;
}

View File

@ -502,6 +502,97 @@ describe('SourceControlGitService', () => {
});
});
describe('branch ops', () => {
let gitService: SourceControlGitService;
let git: ReturnType<typeof mock<SimpleGit>>;
beforeEach(() => {
gitService = new SourceControlGitService(mock(), mock(), mock());
git = mock<SimpleGit>();
gitService.git = git;
});
describe('createBranchFrom', () => {
it('resets base then force-creates the new branch with -B', async () => {
await gitService.createBranchFrom('feat/x', 'main');
expect(git.checkout).toHaveBeenCalledWith('main', ['-f']);
expect(git.raw).toHaveBeenCalledWith(['reset', '--hard', 'origin/main']);
// `-B` (create-or-reset), not the throwing `checkoutBranch`/`-b`
expect(git.checkout).toHaveBeenCalledWith(['-B', 'feat/x', 'main']);
expect(git.checkoutBranch).not.toHaveBeenCalled();
});
it('is idempotent when the branch already exists locally', async () => {
// A prior failed push can leave the local branch behind; retrying must not throw.
await gitService.createBranchFrom('feat/x', 'main');
await gitService.createBranchFrom('feat/x', 'main');
expect(git.checkout).toHaveBeenCalledWith(['-B', 'feat/x', 'main']);
expect(git.checkoutBranch).not.toHaveBeenCalled();
});
});
describe('checkoutExistingBranch', () => {
it('force-checks-out, resets to remote tip, and sets upstream', async () => {
await gitService.checkoutExistingBranch('feat/x');
expect(git.checkout).toHaveBeenCalledWith('feat/x', ['-f']);
expect(git.raw).toHaveBeenCalledWith(['reset', '--hard', 'origin/feat/x']);
expect(git.branch).toHaveBeenCalledWith(['--set-upstream-to=origin/feat/x', 'feat/x']);
// Reset must happen after the checkout so it operates on the target branch.
expect(git.checkout.mock.invocationCallOrder[0]).toBeLessThan(
git.raw.mock.invocationCallOrder[0],
);
});
});
describe('push', () => {
beforeEach(() => {
vi.spyOn(gitService, 'setGitCommand').mockResolvedValue();
});
it('passes -u when setUpstream is true', async () => {
await gitService.push({ force: false, branch: 'feat/x', setUpstream: true });
expect(git.push).toHaveBeenCalledWith('origin', 'feat/x', ['-u']);
});
it('passes -u and -f when both setUpstream and force are true', async () => {
await gitService.push({ force: true, branch: 'feat/x', setUpstream: true });
expect(git.push).toHaveBeenCalledWith('origin', 'feat/x', ['-u', '-f']);
});
it('remains backwards compatible without setUpstream', async () => {
await gitService.push({ force: false, branch: 'feat/x' });
expect(git.push).toHaveBeenCalledWith('origin', 'feat/x');
});
it('passes -f when only force is true', async () => {
await gitService.push({ force: true, branch: 'feat/x' });
expect(git.push).toHaveBeenCalledWith('origin', 'feat/x', ['-f']);
});
});
describe('fetch', () => {
beforeEach(() => {
vi.spyOn(gitService, 'setGitCommand').mockResolvedValue();
});
it('prunes stale remote-tracking branches', async () => {
await gitService.fetch();
// Without --prune, a branch deleted on the remote keeps showing up in
// `git branch -r` (and therefore the branch picker) indefinitely.
expect(git.fetch).toHaveBeenCalledWith(['--prune']);
});
});
});
describe('getFileContent', () => {
it('should return file content at HEAD version', async () => {
// Arrange

View File

@ -21,6 +21,7 @@ import {
getTrackingInformationFromPrePushResult,
getTrackingInformationFromPullResult,
hasOwnerChanged,
isNoUpstreamBranchError,
isWorkflowModified,
mapInBatches,
mergeRemoteCrendetialDataIntoLocalCredentialData,
@ -246,6 +247,27 @@ describe('Source Control Helper', () => {
});
});
describe('isNoUpstreamBranchError', () => {
it('should detect a missing-tracking-branch git error', () => {
expect(
isNoUpstreamBranchError(
new Error(
'There is no tracking information for the current branch.\nPlease specify which branch you want to merge with.',
),
),
).toBe(true);
});
it('should be case-insensitive', () => {
expect(isNoUpstreamBranchError(new Error('NO TRACKING INFORMATION found'))).toBe(true);
});
it('should return false for unrelated errors', () => {
expect(isNoUpstreamBranchError(new Error('network unreachable'))).toBe(false);
expect(isNoUpstreamBranchError('network unreachable')).toBe(false);
});
});
describe('mapInBatches', () => {
it('should preserve input order and length', async () => {
const items = Array.from({ length: 45 }, (_, i) => i);

View File

@ -10,6 +10,7 @@ import { mock } from 'vitest-mock-extended';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import { SourceControlPreferencesService } from '../source-control-preferences.service.ee';
import type { SourceControlConfig } from '../source-control.config';
import type { SourceControlPreferences } from '../types/source-control-preferences';
// Restore real fs modules for these tests since we need actual file operations
@ -21,14 +22,31 @@ describe('SourceControlPreferencesService', () => {
const mockCipher = mock<Cipher>();
const mockLogger = mock<Logger>();
const mockSettingsRepository = mock<SettingsRepository>();
const mockSourceControlConfig = mock<SourceControlConfig>({ branchSelectionEnabled: false });
const service = new SourceControlPreferencesService(
instanceSettings,
mockLogger,
mockCipher,
mockSettingsRepository,
mock(),
mockSourceControlConfig,
);
describe('isBranchSelectionEnabled', () => {
afterEach(() => {
mockSourceControlConfig.branchSelectionEnabled = false;
});
it('should return false when the config flag is off', () => {
mockSourceControlConfig.branchSelectionEnabled = false;
expect(service.isBranchSelectionEnabled()).toBe(false);
});
it('should return true when the config flag is on', () => {
mockSourceControlConfig.branchSelectionEnabled = true;
expect(service.isBranchSelectionEnabled()).toBe(true);
});
});
it('should class validate correct preferences', async () => {
const validPreferences: Partial<SourceControlPreferences> = {
branchName: 'main',

View File

@ -395,6 +395,42 @@ describe('getStatus', () => {
expect(sourceControlContextFactory.createContext).toHaveBeenCalledWith(user);
});
it('should not fail status when the current branch has no upstream yet', async () => {
// ARRANGE
// A branch created via the commit window's "New branch" option has no
// remote counterpart until its first push, so `git pull` rejects with this
// error - it should be treated as "nothing to sync yet", not a failure.
gitService.pull.mockRejectedValueOnce(
new Error(
'There is no tracking information for the current branch.\nPlease specify which branch you want to merge with.',
),
);
// ACT
const result = await sourceControlStatusService.getStatus(globalAdminUser, {
direction: 'push',
verbose: false,
preferLocalVersion: true,
});
// ASSERT
expect(result).toEqual([]);
});
it('should throw a user-facing error when pull fails for any other reason', async () => {
// ARRANGE
gitService.pull.mockRejectedValueOnce(new Error('network unreachable'));
// ACT & ASSERT
await expect(
sourceControlStatusService.getStatus(globalAdminUser, {
direction: 'push',
verbose: false,
preferLocalVersion: true,
}),
).rejects.toThrowError('Unable to fetch updates from git');
});
describe('project', () => {
// Mock data for reusable test scenarios
const mockProjects: Record<string, ExportableProjectWithFileName> = {

View File

@ -0,0 +1,32 @@
import { Container } from '@n8n/di';
import { SourceControlConfig } from '../source-control.config';
describe('SourceControlConfig', () => {
const originalEnv = process.env;
beforeEach(() => {
Container.reset();
});
afterEach(() => {
process.env = originalEnv;
});
describe('branchSelectionEnabled', () => {
test('defaults to false', () => {
process.env = {};
expect(Container.get(SourceControlConfig).branchSelectionEnabled).toBe(false);
});
test('is true when N8N_SOURCECONTROL_BRANCH_SELECTION_ENABLED=true', () => {
process.env = { N8N_SOURCECONTROL_BRANCH_SELECTION_ENABLED: 'true' };
expect(Container.get(SourceControlConfig).branchSelectionEnabled).toBe(true);
});
test('is false when N8N_SOURCECONTROL_BRANCH_SELECTION_ENABLED=false', () => {
process.env = { N8N_SOURCECONTROL_BRANCH_SELECTION_ENABLED: 'false' };
expect(Container.get(SourceControlConfig).branchSelectionEnabled).toBe(false);
});
});
});

View File

@ -249,6 +249,7 @@ describe('SourceControlController', () => {
beforeEach(() => {
(sourceControlPreferencesService.getPreferences as Mock).mockReturnValue(fullPreferences);
(sourceControlPreferencesService.isBranchSelectionEnabled as Mock).mockReturnValue(true);
});
it('should return full preferences (including public key) for users with sourceControl:manage', async () => {
@ -260,7 +261,11 @@ describe('SourceControlController', () => {
const result = await controller.getPreferences(req);
expect(permissions.hasGlobalScope).toHaveBeenCalledWith(req.user, 'sourceControl:manage');
expect(result).toEqual({ ...fullPreferences, publicKey: mockPublicKey });
expect(result).toEqual({
...fullPreferences,
publicKey: mockPublicKey,
branchSelectionEnabled: true,
});
expect(sourceControlContextFactory.createContext).not.toHaveBeenCalled();
});
@ -278,11 +283,12 @@ describe('SourceControlController', () => {
branchReadOnly: fullPreferences.branchReadOnly,
branchName: fullPreferences.branchName,
branchColor: fullPreferences.branchColor,
branchSelectionEnabled: true,
});
expect(sourceControlPreferencesService.getPublicKey).not.toHaveBeenCalled();
});
it('should return only branchReadOnly for users with no source-control access', async () => {
it('should return only branchReadOnly and branchSelectionEnabled for users with no source-control access', async () => {
(permissions.hasGlobalScope as Mock).mockReturnValue(false);
const user = mock<User>({ id: 'user-1' });
(sourceControlContextFactory.createContext as Mock).mockResolvedValue(
@ -293,6 +299,7 @@ describe('SourceControlController', () => {
expect(result).toEqual({
branchReadOnly: fullPreferences.branchReadOnly,
branchSelectionEnabled: true,
});
expect(sourceControlPreferencesService.getPublicKey).not.toHaveBeenCalled();
});

View File

@ -16,6 +16,7 @@ import type { SourceControlGitService } from '../source-control-git.service.ee';
import type { SourceControlImportService } from '../source-control-import.service.ee';
import type { SourceControlContextFactory } from '../source-control-context.factory';
import type { SourceControlScopedService } from '../source-control-scoped.service';
import type { SourceControlConfig } from '../source-control.config';
import {
SOURCE_CONTROL_DEFAULT_BRANCH_COLOR,
SOURCE_CONTROL_DEFAULT_EMAIL,
@ -48,12 +49,15 @@ const globalMemberUser = mock<User>({ role: GLOBAL_MEMBER_ROLE });
const globalMemberUserWithId = mock<User>({ id: 'user-id', role: GLOBAL_MEMBER_ROLE });
describe('SourceControlService', () => {
// Defaults to enabled here since most of this suite exercises the branch-selection
// feature; tests for the disabled path explicitly flip this to false.
const mockSourceControlConfig = mock<SourceControlConfig>({ branchSelectionEnabled: true });
const preferencesService = new SourceControlPreferencesService(
Container.get(InstanceSettings),
mock(),
mock(),
mock(),
mock(),
mockSourceControlConfig,
);
const sourceControlImportService = mock<SourceControlImportService>();
const sourceControlExportService = mock<SourceControlExportService>();
@ -78,6 +82,7 @@ describe('SourceControlService', () => {
vi.spyOn(sourceControlService, 'sanityCheck').mockResolvedValue(undefined);
// Reset mock implementations
mockStatusService.getStatus.mockReset();
mockSourceControlConfig.branchSelectionEnabled = true;
});
describe('pushWorkfolder', () => {
@ -293,6 +298,7 @@ describe('SourceControlService', () => {
expect(gitService.push).toHaveBeenCalledWith({
branch: 'main', // default branch
force: false,
setUpstream: false,
});
// The result should include the status and push result
@ -374,6 +380,7 @@ describe('SourceControlService', () => {
expect(gitService.push).toHaveBeenCalledWith({
branch: 'main', // default branch
force: false,
setUpstream: false,
});
expect(result).toHaveProperty('statusCode', 200);
});
@ -513,6 +520,155 @@ describe('SourceControlService', () => {
});
});
describe('pushWorkfolder branch selection', () => {
const user = mock<User>();
beforeEach(() => {
// A plain push resolves: empty status, credentials export returns a valid
// result, and the push itself succeeds. getBranchName() is the default branch.
mockStatusService.getStatus.mockResolvedValue([]);
(isContainedWithin as Mock).mockReturnValue(true);
gitService.push.mockResolvedValue(mock<PushResult>());
sourceControlExportService.exportCredentialsToWorkFolder.mockResolvedValue({
count: 0,
missingIds: [],
folder: '',
files: [],
});
vi.spyOn(preferencesService, 'getBranchName').mockReturnValue('main');
});
it('creates a new branch off the default before committing', async () => {
await sourceControlService.pushWorkfolder(user, {
fileNames: [],
branch: 'feat/x',
createBranch: true,
commitMessage: 'msg',
});
expect(gitService.createBranchFrom).toHaveBeenCalledWith('feat/x', 'main');
expect(gitService.push).toHaveBeenCalledWith(
expect.objectContaining({ branch: 'feat/x', setUpstream: true }),
);
// The remote must be fetched before branching off it.
expect(gitService.fetch.mock.invocationCallOrder[0]).toBeLessThan(
gitService.createBranchFrom.mock.invocationCallOrder[0],
);
});
it('checks out an existing branch before committing', async () => {
await sourceControlService.pushWorkfolder(user, {
fileNames: [],
branch: 'develop',
commitMessage: 'msg',
});
expect(gitService.checkoutExistingBranch).toHaveBeenCalledWith('develop');
expect(gitService.push).toHaveBeenCalledWith(
expect.objectContaining({ branch: 'develop', setUpstream: false }),
);
// The remote must be fetched before checking out the target branch.
expect(gitService.fetch.mock.invocationCallOrder[0]).toBeLessThan(
gitService.checkoutExistingBranch.mock.invocationCallOrder[0],
);
});
it('restores the default branch after pushing to a non-default branch', async () => {
await sourceControlService.pushWorkfolder(user, {
fileNames: [],
branch: 'develop',
commitMessage: 'msg',
});
// The working clone is checked out onto 'develop' to push, then restored
// to the default branch so pull and the next push find HEAD on default.
expect(gitService.checkoutExistingBranch).toHaveBeenCalledWith('main');
expect(gitService.push.mock.invocationCallOrder[0]).toBeLessThan(
gitService.checkoutExistingBranch.mock.invocationCallOrder[1],
);
});
it('still restores the default branch when preparing the branch fails', async () => {
const prepareError = new Error('checkout failed');
gitService.checkoutExistingBranch
.mockRejectedValueOnce(prepareError) // checking out the requested branch fails
.mockResolvedValueOnce(undefined); // restoring the default branch afterwards succeeds
await expect(
sourceControlService.pushWorkfolder(user, {
fileNames: [],
branch: 'develop',
commitMessage: 'msg',
}),
).rejects.toThrow(prepareError);
// Even though branch preparation failed (and no commit/push was attempted), cleanup
// still restores the default branch so pull and the next push find HEAD on default.
expect(gitService.push).not.toHaveBeenCalled();
expect(gitService.checkoutExistingBranch).toHaveBeenCalledWith('main');
});
it('propagates a failure to restore the default branch after a successful push', async () => {
const restoreError = new Error('checkout failed');
gitService.checkoutExistingBranch
.mockResolvedValueOnce(undefined) // checking out 'develop' succeeds
.mockRejectedValueOnce(restoreError); // restoring 'main' afterwards fails
await expect(
sourceControlService.pushWorkfolder(user, {
fileNames: [],
branch: 'develop',
commitMessage: 'msg',
}),
).rejects.toThrow(/restore the default branch/);
// The push itself went through; only the post-push restore failed. The error from
// that failure must still surface instead of returning a false success.
expect(gitService.push).toHaveBeenCalled();
});
it('falls back to the default branch when none given', async () => {
await sourceControlService.pushWorkfolder(user, { fileNames: [], commitMessage: 'msg' });
expect(gitService.createBranchFrom).not.toHaveBeenCalled();
// A plain default-branch push neither switches branches nor restores one afterwards.
expect(gitService.checkoutExistingBranch).not.toHaveBeenCalled();
expect(gitService.fetch).not.toHaveBeenCalled();
expect(gitService.push).toHaveBeenCalledWith(expect.objectContaining({ branch: 'main' }));
});
describe('when branch selection is disabled', () => {
beforeEach(() => {
mockSourceControlConfig.branchSelectionEnabled = false;
});
it('ignores a requested branch and pushes the default branch', async () => {
await sourceControlService.pushWorkfolder(user, {
fileNames: [],
branch: 'develop',
commitMessage: 'msg',
});
expect(gitService.checkoutExistingBranch).not.toHaveBeenCalled();
expect(gitService.fetch).not.toHaveBeenCalled();
expect(gitService.push).toHaveBeenCalledWith(expect.objectContaining({ branch: 'main' }));
});
it('ignores a branch-creation request and pushes the default branch', async () => {
await sourceControlService.pushWorkfolder(user, {
fileNames: [],
branch: 'feat/x',
createBranch: true,
commitMessage: 'msg',
});
expect(gitService.createBranchFrom).not.toHaveBeenCalled();
expect(gitService.fetch).not.toHaveBeenCalled();
expect(gitService.push).toHaveBeenCalledWith(expect.objectContaining({ branch: 'main' }));
});
});
});
describe('pullWorkfolder', () => {
it('does not filter locally created credentials', async () => {
// ARRANGE
@ -640,6 +796,34 @@ describe('SourceControlService', () => {
const dataTableEntry = result.statusResult.find((f) => f.id === 'dtNew');
expect(dataTableEntry?.conflict).toBe(true);
});
it('checks out the default branch first', async () => {
// ARRANGE
const user = mock<User>();
mockStatusService.getStatus.mockResolvedValueOnce([]);
sourceControlImportService.importWorkflowFromWorkFolder.mockResolvedValue([]);
vi.spyOn(preferencesService, 'getBranchName').mockReturnValue('main');
// ACT
await sourceControlService.pullWorkfolder(user, { force: true, autoPublish: 'none' });
// ASSERT
expect(gitService.checkoutExistingBranch).toHaveBeenCalledWith('main');
});
it('does not check out the default branch when branch selection is disabled', async () => {
// ARRANGE
mockSourceControlConfig.branchSelectionEnabled = false;
const user = mock<User>();
mockStatusService.getStatus.mockResolvedValueOnce([]);
sourceControlImportService.importWorkflowFromWorkFolder.mockResolvedValue([]);
// ACT
await sourceControlService.pullWorkfolder(user, { force: true, autoPublish: 'none' });
// ASSERT
expect(gitService.checkoutExistingBranch).not.toHaveBeenCalled();
});
});
describe('getStatus', () => {

View File

@ -385,6 +385,29 @@ export class SourceControlGitService {
return await this.getBranches();
}
async checkoutExistingBranch(branch: string): Promise<void> {
if (!this.git) {
throw new UnexpectedError('Git is not initialized (checkoutExistingBranch)');
}
await this.git.checkout(branch, ['-f']);
// Reset to the freshly-fetched remote tip so the subsequent commit builds on
// the latest remote state and the force-push cannot silently drop remote commits.
await this.git.raw(['reset', '--hard', `${SOURCE_CONTROL_ORIGIN}/${branch}`]);
await this.git.branch([`--set-upstream-to=${SOURCE_CONTROL_ORIGIN}/${branch}`, branch]);
}
async createBranchFrom(newBranch: string, baseBranch: string): Promise<void> {
if (!this.git) {
throw new UnexpectedError('Git is not initialized (createBranchFrom)');
}
// Start from the latest remote state of the base branch, then branch off it.
await this.git.checkout(baseBranch, ['-f']);
await this.git.raw(['reset', '--hard', `${SOURCE_CONTROL_ORIGIN}/${baseBranch}`]);
// Use `-B` (create-or-reset) so retrying after a failed push is idempotent
// even when a stale local branch of the same name still exists.
await this.git.checkout(['-B', newBranch, baseBranch]);
}
async getCurrentBranch(): Promise<{ current: string; remote: string }> {
if (!this.git) {
throw new UnexpectedError('Git is not initialized (getCurrentBranch)');
@ -425,7 +448,9 @@ export class SourceControlGitService {
throw new UnexpectedError('Git is not initialized (fetch)');
}
await this.setGitCommand();
return await this.git.fetch();
// Prune stale remote-tracking refs so branches deleted on the remote stop
// showing up locally (plain `git fetch` never removes them on its own).
return await this.git.fetch(['--prune']);
}
async pull(options: { ffOnly: boolean } = { ffOnly: true }): Promise<PullResult> {
@ -441,18 +466,21 @@ export class SourceControlGitService {
}
async push(
options: { force: boolean; branch: string } = {
options: { force: boolean; branch: string; setUpstream?: boolean } = {
force: false,
branch: SOURCE_CONTROL_DEFAULT_BRANCH,
},
): Promise<PushResult> {
const { force, branch } = options;
const { force, branch, setUpstream } = options;
if (!this.git) {
throw new UnexpectedError('Git is not initialized ({)');
throw new UnexpectedError('Git is not initialized (push)');
}
await this.setGitCommand();
if (force) {
return await this.git.push(SOURCE_CONTROL_ORIGIN, branch, ['-f']);
const flags: string[] = [];
if (setUpstream) flags.push('-u');
if (force) flags.push('-f');
if (flags.length > 0) {
return await this.git.push(SOURCE_CONTROL_ORIGIN, branch, flags);
}
return await this.git.push(SOURCE_CONTROL_ORIGIN, branch);
}

View File

@ -286,6 +286,11 @@ export function isSourceControlLicensed() {
return license.isSourceControlLicensed();
}
export function isNoUpstreamBranchError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.toLowerCase().includes('no tracking information');
}
export async function generateSshKeyPair(keyType: KeyPairType) {
// sshpk is CommonJS (`export =`): under nodenext, a native dynamic import only
// hoists some named exports onto the namespace (parsePrivateKey is missed), so

View File

@ -234,6 +234,10 @@ export class SourceControlPreferencesService {
return this._sourceControlPreferences.branchReadOnly;
}
isBranchSelectionEnabled(): boolean {
return this.sourceControlConfig.branchSelectionEnabled;
}
isSourceControlConnected(): boolean {
return this.sourceControlPreferences.connected;
}

View File

@ -24,6 +24,7 @@ import {
getVariablesPath,
isWorkflowModified,
isDataTableModified,
isNoUpstreamBranchError,
areSameCredentials,
} from './source-control-helper.ee';
import { SourceControlImportService } from './source-control-import.service.ee';
@ -233,6 +234,12 @@ export class SourceControlStatusService {
await this.gitService.resetBranch();
await this.gitService.pull();
} catch (error) {
if (isNoUpstreamBranchError(error)) {
// A branch created via the commit window's "New branch" option has no
// remote counterpart until its first push, so there is nothing to pull yet.
this.logger.debug('Skipping pull: current branch has no upstream yet');
return;
}
this.logger.error(
`Failed to reset workfolder: ${error instanceof Error ? error.message : String(error)}`,
);

View File

@ -5,4 +5,8 @@ export class SourceControlConfig {
/** Default SSH key type to use when generating SSH keys. */
@Env('N8N_SOURCECONTROL_DEFAULT_SSH_KEY_TYPE')
defaultKeyPairType: 'ed25519' | 'rsa' = 'ed25519';
/** Whether to allow selecting or creating a git branch per commit, instead of always using the configured default branch. */
@Env('N8N_SOURCECONTROL_BRANCH_SELECTION_ENABLED')
branchSelectionEnabled: boolean = false;
}

View File

@ -38,16 +38,20 @@ export class SourceControlController {
) {}
@Get('/preferences')
async getPreferences(req: AuthenticatedRequest): Promise<Partial<SourceControlPreferences>> {
async getPreferences(
req: AuthenticatedRequest,
): Promise<Partial<SourceControlPreferences> & { branchSelectionEnabled: boolean }> {
const preferences = this.sourceControlPreferencesService.getPreferences();
const branchSelectionEnabled = this.sourceControlPreferencesService.isBranchSelectionEnabled();
if (hasGlobalScope(req.user, 'sourceControl:manage')) {
const publicKey = await this.sourceControlPreferencesService.getPublicKey();
return { ...preferences, publicKey };
return { ...preferences, publicKey, branchSelectionEnabled };
}
const publicSubset = {
branchReadOnly: preferences.branchReadOnly,
branchSelectionEnabled,
};
const ctx = await this.sourceControlContextFactory.createContext(req.user);

View File

@ -45,6 +45,7 @@ import {
import { SourceControlScopedService } from './source-control-scoped.service';
import { SourceControlStatusService } from './source-control-status.service.ee';
import type { ImportResult } from './types/import-result';
import type { SourceControlContext } from './types/source-control-context';
import type { SourceControlGetStatus } from './types/source-control-get-status';
import type { SourceControlPreferences } from './types/source-control-preferences';
@ -283,6 +284,38 @@ export class SourceControlService {
return await this.gitService.setBranch(branch);
}
/** Whether a requested branch actually needs the branch-selection flow to run. */
private isBranchSelectionApplicable(requested: string | undefined): boolean {
if (!requested) return false;
const defaultBranch = this.sourceControlPreferencesService.getBranchName();
return (
requested !== defaultBranch && this.sourceControlPreferencesService.isBranchSelectionEnabled()
);
}
/**
* Resolves the branch a push targets and switches the working clone onto it.
* Called before export/commit so the commit lands on the right branch.
* Only call when `isBranchSelectionApplicable()` is true for `requested`.
* Returns the target branch and whether it was newly created (needs upstream).
*/
private async prepareBranchForPush(
requested: string,
options: PushWorkFolderRequestDto,
): Promise<{ targetBranch: string; isNewBranch: boolean; defaultBranch: string }> {
const defaultBranch = this.sourceControlPreferencesService.getBranchName();
await this.gitService.fetch();
if (options.createBranch) {
await this.gitService.createBranchFrom(requested, defaultBranch);
return { targetBranch: requested, isNewBranch: true, defaultBranch };
}
await this.gitService.checkoutExistingBranch(requested);
return { targetBranch: requested, isNewBranch: false, defaultBranch };
}
// will reset the branch to the remote branch and pull
// this will discard all local changes
async resetWorkfolder(): Promise<ImportResult | undefined> {
@ -334,6 +367,69 @@ export class SourceControlService {
const context = await this.sourceControlContextFactory.createContext(user);
const defaultBranch = this.sourceControlPreferencesService.getBranchName();
const requestedBranch = options.branch?.trim();
const branchSelectionActive = this.isBranchSelectionApplicable(requestedBranch);
let targetBranch = defaultBranch;
let isNewBranch = false;
let result:
| {
statusCode: number;
pushResult: PushResult | undefined;
statusResult: SourceControlledFile[];
}
| undefined;
let pushError: unknown;
try {
if (branchSelectionActive && requestedBranch) {
({ targetBranch, isNewBranch } = await this.prepareBranchForPush(requestedBranch, options));
}
result = await this.exportAndPushFiles(user, options, context, targetBranch, isNewBranch);
} catch (error) {
pushError = error;
}
// Restore the working clone to the default branch. Pull and the next push
// assume HEAD stays on the default branch between operations. Checked via
// branchSelectionActive (not targetBranch/defaultBranch) so a failure while
// resolving the target branch above is cleaned up too.
if (branchSelectionActive) {
try {
await this.gitService.checkoutExistingBranch(defaultBranch);
} catch (restoreError) {
this.logger.error('Failed to restore default branch after push', {
error: restoreError,
});
// Surface this instead of swallowing it: silently returning success here
// would leave the shared clone on the wrong branch for the next operation.
// If the push itself already failed, that original error takes priority.
pushError ??= new UserError(
'Push may have succeeded, but failed to restore the default branch afterwards. Retry, or reconnect from the Source Control settings page if this keeps happening.',
);
}
}
if (pushError) throw pushError;
if (!result) throw new UnexpectedError('Push finished without a result');
return result;
}
private async exportAndPushFiles(
user: User,
options: PushWorkFolderRequestDto,
context: SourceControlContext,
targetBranch: string,
isNewBranch: boolean,
): Promise<{
statusCode: number;
pushResult: PushResult | undefined;
statusResult: SourceControlledFile[];
}> {
const defaultBranch = this.sourceControlPreferencesService.getBranchName();
let filesToPush: SourceControlledFile[] = options.fileNames.map((file) => {
const normalizedPath = normalizeAndValidateSourceControlledFilePath(
this.gitFolder,
@ -473,12 +569,12 @@ export class SourceControlService {
throw error;
}
const branchName = this.sourceControlPreferencesService.getBranchName();
let pushResult: PushResult | undefined;
try {
pushResult = await this.gitService.push({
branch: branchName,
branch: targetBranch,
force: options.force ?? false,
setUpstream: isNewBranch,
});
// Only mark files as pushed after successful push
@ -486,7 +582,9 @@ export class SourceControlService {
} catch (error) {
this.logger.error('Failed to push changes', { error });
try {
await this.gitService.resetBranch({ hard: true, target: `origin/${branchName}` });
// A brand-new branch has no origin ref yet; fall back to the default.
const resetTarget = isNewBranch ? `origin/${defaultBranch}` : `origin/${targetBranch}`;
await this.gitService.resetBranch({ hard: true, target: resetTarget });
} catch (resetError) {
this.logger.error('Failed to reset branch after push error', { error: resetError });
}
@ -522,6 +620,14 @@ export class SourceControlService {
): Promise<{ statusCode: number; statusResult: SourceControlledFile[] }> {
await this.sanityCheck();
// Pull always targets the default branch; a prior feature-branch commit
// may have left HEAD elsewhere, so switch back first. Only relevant when
// branch selection is enabled - otherwise HEAD never moves off default.
if (this.sourceControlPreferencesService.isBranchSelectionEnabled()) {
const defaultBranch = this.sourceControlPreferencesService.getBranchName();
await this.gitService.checkoutExistingBranch(defaultBranch);
}
const statusResult = await this.sourceControlStatusService.getStatus(user, {
direction: 'pull',
verbose: false,

View File

@ -3914,7 +3914,9 @@
"settings.sourceControl.button.disconnect": "Disconnect Git",
"settings.sourceControl.button.save": "Save settings",
"settings.sourceControl.instanceSettings": "Instance settings",
"settings.sourceControl.branches": "Branch connected to this n8n instance",
"settings.sourceControl.branches": "Default branch",
"settings.sourceControl.branches.caption": "Used for pull. Choose or create the branch for each commit in the push dialog.",
"settings.sourceControl.branches.legacy": "Branch connected to this n8n instance",
"settings.sourceControl.protected": "{bold}: prevent editing workflows (recommended for production environments).",
"settings.sourceControl.protected.bold": "Protected instance",
"settings.sourceControl.color": "Color",
@ -3939,6 +3941,11 @@
"settings.sourceControl.modals.push.noWorkflowChanges.moreInfo": "More info",
"settings.sourceControl.modals.push.commitMessage": "Commit message",
"settings.sourceControl.modals.push.commitMessage.placeholder": "e.g. My commit",
"settings.sourceControl.modals.push.branch": "Branch",
"settings.sourceControl.modals.push.branch.newBranch": "New branch",
"settings.sourceControl.modals.push.branch.newBranchPlaceholder": "e.g. feat/my-change",
"settings.sourceControl.modals.push.branch.base": "Base: {branch}",
"settings.sourceControl.modals.push.branch.invalid": "Invalid git branch name",
"settings.sourceControl.modals.push.buttons.cancel": "Cancel",
"settings.sourceControl.modals.push.buttons.save": "Commit and push",
"settings.sourceControl.modals.push.modifiedCredentialsNotice": "Modified credentials will update expressions, numbers, and boolean values, any other values will not be synced, because they would be exposed as plain text in the connected git repository.",

View File

@ -11,6 +11,7 @@ export function routesForSourceControl(server: Server) {
branches: [],
repositoryUrl: '',
branchReadOnly: false,
branchSelectionEnabled: false,
branchColor: '#1d6acb',
connected: false,
publicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHEX+25m',

View File

@ -79,6 +79,7 @@ describe('MainSidebarSourceControl', () => {
branches: [],
repositoryUrl: '',
branchReadOnly: false,
branchSelectionEnabled: false,
branchColor: '#5296D6',
connected: true,
publicKey: '',
@ -120,6 +121,7 @@ describe('MainSidebarSourceControl', () => {
branches: [],
repositoryUrl: '',
branchReadOnly: true,
branchSelectionEnabled: false,
branchColor: '#5296D6',
connected: true,
publicKey: '',
@ -142,6 +144,7 @@ describe('MainSidebarSourceControl', () => {
branches: [],
repositoryUrl: '',
branchReadOnly: false,
branchSelectionEnabled: false,
branchColor: '#5296D6',
connected: true,
publicKey: '',
@ -171,6 +174,7 @@ describe('MainSidebarSourceControl', () => {
branches: [],
repositoryUrl: '',
branchReadOnly: true,
branchSelectionEnabled: false,
branchColor: '#5296D6',
connected: true,
publicKey: '',

View File

@ -153,6 +153,12 @@ describe('SourceControlPushModal', () => {
pinia = createTestingPinia();
sourceControlStore = mockedStore(useSourceControlStore);
sourceControlStore.getAggregatedStatus.mockResolvedValue([]);
// Default branch state so pre-existing tests (that don't care about
// branch selection) aren't blocked by the new branch-validity check.
// Tests for the disabled-flag path explicitly flip this to false.
sourceControlStore.preferences.branchName = 'main';
sourceControlStore.preferences.branches = ['main'];
sourceControlStore.preferences.branchSelectionEnabled = true;
settingsStore = mockedStore(useSettingsStore);
settingsStore.settings.enterprise = defaultSettings.enterprise;
@ -1853,4 +1859,243 @@ describe('SourceControlPushModal', () => {
expect(sourceControlStore.pushWorkfolder).not.toHaveBeenCalled();
});
});
describe('branch selection', () => {
it('renders branch selector and toggles to new-branch input', async () => {
sourceControlStore.preferences.branches = ['main', 'develop'];
sourceControlStore.preferences.branchName = 'main';
const { getByTestId, queryByTestId, getByText } = renderModal({
pinia,
props: {
data: {
eventBus,
status: [],
},
},
});
await waitFor(() => {
expect(getByText('Commit and push changes')).toBeInTheDocument();
});
await waitFor(() => {
expect(getByTestId('source-control-push-modal-branch-select')).toBeInTheDocument();
});
expect(queryByTestId('source-control-push-modal-branch-new')).not.toBeInTheDocument();
await userEvent.click(getByTestId('source-control-push-modal-branch-new-toggle'));
expect(getByTestId('source-control-push-modal-branch-new')).toBeInTheDocument();
});
it('sends the selected branch and createBranch flag when pushing', async () => {
const status: SourceControlledFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'variables',
type: 'variables',
status: 'created',
location: 'local',
conflict: false,
file: '',
updatedAt: '2024-09-20T10:31:40.000Z',
},
];
sourceControlStore.getAggregatedStatus.mockResolvedValue(status);
sourceControlStore.preferences.branches = ['main', 'develop'];
sourceControlStore.preferences.branchName = 'main';
const { getByTestId, getByText } = renderModal({
pinia,
props: {
data: {
eventBus,
status,
},
},
});
await waitFor(() => {
expect(getByText('Commit and push changes')).toBeInTheDocument();
});
await waitFor(() => {
expect(getByTestId('source-control-push-modal-branch-select')).toBeInTheDocument();
});
await userEvent.type(getByTestId('source-control-push-modal-commit'), 'commit message');
await userEvent.click(getByTestId('source-control-push-modal-submit'));
expect(sourceControlStore.pushWorkfolder).toHaveBeenCalledWith(
expect.objectContaining({
branch: 'main',
createBranch: false,
}),
);
});
it('hides the branch selector and omits branch fields when the feature is disabled', async () => {
const status: SourceControlledFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'variables',
type: 'variables',
status: 'created',
location: 'local',
conflict: false,
file: '',
updatedAt: '2024-09-20T10:31:40.000Z',
},
];
sourceControlStore.getAggregatedStatus.mockResolvedValue(status);
sourceControlStore.preferences.branches = ['main', 'develop'];
sourceControlStore.preferences.branchName = 'main';
sourceControlStore.preferences.branchSelectionEnabled = false;
const { getByTestId, queryByTestId, getByText } = renderModal({
pinia,
props: {
data: {
eventBus,
status,
},
},
});
await waitFor(() => {
expect(getByText('Commit and push changes')).toBeInTheDocument();
});
await waitFor(() => {
expect(getByTestId('source-control-push-modal-commit')).toBeInTheDocument();
});
expect(queryByTestId('source-control-push-modal-branch-select')).not.toBeInTheDocument();
expect(queryByTestId('source-control-push-modal-branch-new-toggle')).not.toBeInTheDocument();
expect(sourceControlStore.getBranches).not.toHaveBeenCalled();
await userEvent.type(getByTestId('source-control-push-modal-commit'), 'commit message');
await userEvent.click(getByTestId('source-control-push-modal-submit'));
expect(sourceControlStore.pushWorkfolder).toHaveBeenCalledWith(
expect.objectContaining({
branch: undefined,
createBranch: undefined,
}),
);
});
it('disables submit until a valid new branch name is entered', async () => {
sourceControlStore.preferences.branches = ['main'];
sourceControlStore.preferences.branchName = 'main';
const status: SourceControlledFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'variables',
type: 'variables',
status: 'created',
location: 'local',
conflict: false,
file: '',
updatedAt: '2024-09-20T10:31:40.000Z',
},
];
sourceControlStore.getAggregatedStatus.mockResolvedValue(status);
const { getByTestId, getByText } = renderModal({
pinia,
props: {
data: {
eventBus,
status,
},
},
});
await waitFor(() => {
expect(getByText('Commit and push changes')).toBeInTheDocument();
});
await waitFor(() => {
expect(getByTestId('source-control-push-modal-commit')).toBeInTheDocument();
});
await userEvent.type(getByTestId('source-control-push-modal-commit'), 'commit message');
await userEvent.click(getByTestId('source-control-push-modal-branch-new-toggle'));
const submitButton = getByTestId('source-control-push-modal-submit');
expect(submitButton).toBeDisabled();
await userEvent.type(getByTestId('source-control-push-modal-branch-new'), 'feat/my-change');
expect(submitButton).not.toBeDisabled();
await userEvent.click(submitButton);
expect(sourceControlStore.pushWorkfolder).toHaveBeenCalledWith(
expect.objectContaining({
branch: 'feat/my-change',
createBranch: true,
}),
);
});
it('shows an inline error for an invalid new branch name and hides it once valid', async () => {
sourceControlStore.preferences.branches = ['main'];
sourceControlStore.preferences.branchName = 'main';
const status: SourceControlledFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'variables',
type: 'variables',
status: 'created',
location: 'local',
conflict: false,
file: '',
updatedAt: '2024-09-20T10:31:40.000Z',
},
];
sourceControlStore.getAggregatedStatus.mockResolvedValue(status);
const { getByTestId, queryByTestId, getByText } = renderModal({
pinia,
props: {
data: {
eventBus,
status,
},
},
});
await waitFor(() => {
expect(getByText('Commit and push changes')).toBeInTheDocument();
});
await waitFor(() => {
expect(getByTestId('source-control-push-modal-branch-new-toggle')).toBeInTheDocument();
});
await userEvent.click(getByTestId('source-control-push-modal-branch-new-toggle'));
// Empty new-branch field should not show the error
expect(queryByTestId('source-control-push-modal-branch-error')).not.toBeInTheDocument();
await userEvent.type(getByTestId('source-control-push-modal-branch-new'), 'bad branch');
await waitFor(() => {
expect(getByTestId('source-control-push-modal-branch-error')).toBeVisible();
});
await userEvent.clear(getByTestId('source-control-push-modal-branch-new'));
await userEvent.type(getByTestId('source-control-push-modal-branch-new'), 'feat/x');
await waitFor(() => {
expect(queryByTestId('source-control-push-modal-branch-error')).not.toBeInTheDocument();
});
});
});
});

View File

@ -28,6 +28,7 @@ import {
import type { SourceControlTreeRow } from '../sourceControl.types';
import type { SourceControlledFile, SourceControlledFileStatus } from '@n8n/api-types';
import {
isValidGitBranchName,
ROLE,
SOURCE_CONTROL_FILE_LOCATION,
SOURCE_CONTROL_FILE_STATUS,
@ -467,11 +468,42 @@ const sortedDataTables = useSourceControlFileList({
});
const commitMessage = ref('');
const isCreatingBranch = ref(false);
const selectedBranch = ref('');
const newBranchName = ref('');
const branchOptions = computed(() =>
sourceControlStore.preferences.branches.map((b) => ({ label: b, value: b })),
);
const defaultBranch = computed(() => sourceControlStore.preferences.branchName);
const isBranchValid = computed(() => {
if (!sourceControlStore.preferences.branchSelectionEnabled) {
return true;
}
return isCreatingBranch.value
? isValidGitBranchName(newBranchName.value.trim())
: selectedBranch.value.length > 0;
});
const showBranchNameError = computed(
() =>
isCreatingBranch.value &&
newBranchName.value.trim().length > 0 &&
!isValidGitBranchName(newBranchName.value.trim()),
);
const isSubmitDisabled = computed(() => {
if (!commitMessage.value.trim()) {
return true;
}
if (!isBranchValid.value) {
return true;
}
const toBePushed =
selectedCredentials.size +
selectedDataTables.size +
@ -612,6 +644,14 @@ async function commitAndPush() {
force: true,
commitMessage: commitMessage.value,
fileNames: files,
branch: sourceControlStore.preferences.branchSelectionEnabled
? isCreatingBranch.value
? newBranchName.value.trim()
: selectedBranch.value
: undefined,
createBranch: sourceControlStore.preferences.branchSelectionEnabled
? isCreatingBranch.value
: undefined,
});
toast.showToast({
@ -887,6 +927,15 @@ watch(
onMounted(async () => {
// Always load fresh data to ensure workflow names are populated
await loadSourceControlStatus();
if (sourceControlStore.preferences.branchSelectionEnabled) {
try {
await sourceControlStore.getBranches();
} catch {
// non-fatal: fall back to the default branch already in preferences
}
selectedBranch.value = sourceControlStore.preferences.branchName;
}
});
</script>
@ -1277,6 +1326,55 @@ onMounted(async () => {
</N8nText>
</N8nNotice>
<template v-if="sourceControlStore.preferences.branchSelectionEnabled">
<div :class="$style.branchRow">
<N8nSelect
v-if="!isCreatingBranch"
v-model="selectedBranch"
data-test-id="source-control-push-modal-branch-select"
:placeholder="i18n.baseText('settings.sourceControl.modals.push.branch')"
filterable
:teleported="false"
>
<N8nOption
v-for="option in branchOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</N8nSelect>
<N8nInput
v-else
v-model="newBranchName"
data-test-id="source-control-push-modal-branch-new"
:placeholder="
i18n.baseText('settings.sourceControl.modals.push.branch.newBranchPlaceholder')
"
/>
<N8nCheckbox
v-model="isCreatingBranch"
data-test-id="source-control-push-modal-branch-new-toggle"
:label="i18n.baseText('settings.sourceControl.modals.push.branch.newBranch')"
/>
</div>
<N8nText
v-if="showBranchNameError"
data-test-id="source-control-push-modal-branch-error"
size="small"
color="danger"
class="mb-2xs"
>
{{ i18n.baseText('settings.sourceControl.modals.push.branch.invalid') }}
</N8nText>
<N8nText v-if="isCreatingBranch" size="small" color="text-light">
{{
i18n.baseText('settings.sourceControl.modals.push.branch.base', {
interpolate: { branch: defaultBranch },
})
}}
</N8nText>
</template>
<N8nText bold tag="p">
{{ i18n.baseText('settings.sourceControl.modals.push.commitMessage') }}
</N8nText>
@ -1415,6 +1513,13 @@ onMounted(async () => {
align-items: center;
}
.branchRow {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
margin-bottom: var(--spacing--2xs);
}
.footer {
display: flex;
flex-direction: row;

View File

@ -95,6 +95,7 @@ describe('useSourceControlStore', () => {
connected: true,
branchColor: '#4f46e5',
branchReadOnly: false,
branchSelectionEnabled: false,
branches: ['main', 'develop'],
};
@ -124,6 +125,7 @@ describe('useSourceControlStore', () => {
connected: false,
repositoryUrl: '',
branchReadOnly: false,
branchSelectionEnabled: false,
branchColor: '#000000',
branches: [],
branchName: '',
@ -233,10 +235,30 @@ describe('useSourceControlStore', () => {
force: data.force,
commitMessage: data.commitMessage,
fileNames: data.fileNames,
branch: undefined,
createBranch: undefined,
},
);
expect(sourceControlStore.state.commitMessage).toBe(data.commitMessage);
});
it('forwards branch and createBranch', async () => {
const mockPushWorkfolder = vi.mocked(vcApi.pushWorkfolder);
mockPushWorkfolder.mockResolvedValue({ files: [], commit: null });
await sourceControlStore.pushWorkfolder({
commitMessage: 'msg',
fileNames: [],
force: true,
branch: 'feat/x',
createBranch: true,
});
expect(mockPushWorkfolder).toHaveBeenCalledWith(
{}, // restApiContext
expect.objectContaining({ branch: 'feat/x', createBranch: true }),
);
});
});
describe('pullWorkfolder', () => {

View File

@ -29,6 +29,7 @@ export const useSourceControlStore = defineStore('sourceControl', () => {
branches: [],
repositoryUrl: '',
branchReadOnly: false,
branchSelectionEnabled: false,
branchColor: DEFAULT_BRANCH_COLOR,
connected: false,
publicKey: '',
@ -46,12 +47,16 @@ export const useSourceControlStore = defineStore('sourceControl', () => {
commitMessage: string;
fileNames: SourceControlledFile[];
force: boolean;
branch?: string;
createBranch?: boolean;
}) => {
state.commitMessage = data.commitMessage;
return await vcApi.pushWorkfolder(rootStore.restApiContext, {
force: data.force,
commitMessage: data.commitMessage,
fileNames: data.fileNames,
branch: data.branch,
createBranch: data.createBranch,
});
};

View File

@ -9,6 +9,7 @@ export type SshKeyTypes = ['ed25519', 'rsa'];
// - Global admin (sourceControl:manage): SourceControlPreferences (full)
export type SourceControlPublicPreferences = {
branchReadOnly: boolean;
branchSelectionEnabled: boolean;
};
export type SourceControlProjectPreferences = SourceControlPublicPreferences & {

View File

@ -178,6 +178,58 @@ describe('SettingsSourceControl', () => {
expect(generateKeyPairSpy).toHaveBeenCalledWith('rsa');
}, 10000);
describe('branch label', () => {
beforeEach(() => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.SourceControl] = true;
});
it('shows "Default branch" with a caption when branch selection is enabled', async () => {
vi.spyOn(sourceControlStore, 'getPreferences').mockImplementation(async () => {
sourceControlStore.setPreferences({
connected: true,
branchName: 'main',
branchColor: '#5296D6',
branchReadOnly: false,
branchSelectionEnabled: true,
});
});
const { getByText, queryByText } = renderComponent({ pinia });
await waitFor(() => expect(getByText('Default branch')).toBeInTheDocument());
expect(
queryByText(
'(Used for pull. Choose or create the branch for each commit in the push dialog.)',
),
).toBeInTheDocument();
expect(queryByText('Branch connected to this n8n instance')).not.toBeInTheDocument();
});
it('shows the legacy label without a caption when branch selection is disabled', async () => {
vi.spyOn(sourceControlStore, 'getPreferences').mockImplementation(async () => {
sourceControlStore.setPreferences({
connected: true,
branchName: 'main',
branchColor: '#5296D6',
branchReadOnly: false,
branchSelectionEnabled: false,
});
});
const { getByText, queryByText } = renderComponent({ pinia });
await waitFor(() =>
expect(getByText('Branch connected to this n8n instance')).toBeInTheDocument(),
);
expect(queryByText('Default branch')).not.toBeInTheDocument();
expect(
queryByText(
'(Used for pull. Choose or create the branch for each commit in the push dialog.)',
),
).not.toBeInTheDocument();
});
});
describe('Protocol Selection', () => {
beforeEach(() => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.SourceControl] = true;

View File

@ -473,7 +473,11 @@ watch(connectionType, () => {
<N8nHeading size="xlarge" tag="h2" class="mb-s">{{
locale.baseText('settings.sourceControl.instanceSettings')
}}</N8nHeading>
<label>{{ locale.baseText('settings.sourceControl.branches') }}</label>
<label v-if="sourceControlStore.preferences.branchSelectionEnabled">
{{ locale.baseText('settings.sourceControl.branches') }}
<small>({{ locale.baseText('settings.sourceControl.branches.caption') }})</small>
</label>
<label v-else>{{ locale.baseText('settings.sourceControl.branches.legacy') }}</label>
<div :class="$style.branchSelection">
<N8nFormInput
id="branchName"