mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-27 11:05:14 +02:00
Merge 58ea2a2e77 into 1f4e65419a
This commit is contained in:
commit
fcd080eb38
884
.agents/plans/2026-07-15-source-control-branch-selection.md
Normal file
884
.agents/plans/2026-07-15-source-control-branch-selection.md
Normal 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 2–3, 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
21
packages/@n8n/api-types/src/utils/git-branch-name.ts
Normal file
21
packages/@n8n/api-types/src/utils/git-branch-name.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -234,6 +234,10 @@ export class SourceControlPreferencesService {
|
|||
return this._sourceControlPreferences.branchReadOnly;
|
||||
}
|
||||
|
||||
isBranchSelectionEnabled(): boolean {
|
||||
return this.sourceControlConfig.branchSelectionEnabled;
|
||||
}
|
||||
|
||||
isSourceControlConnected(): boolean {
|
||||
return this.sourceControlPreferences.connected;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export function routesForSourceControl(server: Server) {
|
|||
branches: [],
|
||||
repositoryUrl: '',
|
||||
branchReadOnly: false,
|
||||
branchSelectionEnabled: false,
|
||||
branchColor: '#1d6acb',
|
||||
connected: false,
|
||||
publicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHEX+25m',
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 & {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user