From 6f00ab984f435f408672f65a6593593f59311680 Mon Sep 17 00:00:00 2001 From: jnnmsl <223879930+jnnmsl@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:14:05 +0200 Subject: [PATCH 01/18] docs(source-control): spec + plan for per-commit branch selection --- ...6-07-15-source-control-branch-selection.md | 884 ++++++++++++++++++ ...-source-control-branch-selection-design.md | 153 +++ 2 files changed, 1037 insertions(+) create mode 100644 .agents/plans/2026-07-15-source-control-branch-selection.md create mode 100644 .agents/specs/2026-07-15-source-control-branch-selection-design.md diff --git a/.agents/plans/2026-07-15-source-control-branch-selection.md b/.agents/plans/2026-07-15-source-control-branch-selection.md new file mode 100644 index 00000000000..2692f974d01 --- /dev/null +++ b/.agents/plans/2026-07-15-source-control-branch-selection.md @@ -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 ` 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` + - `checkoutExistingBranch(branch: string): Promise` + - `push(options: { force: boolean; branch: string; setUpstream?: boolean }): Promise` (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(); + 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 { + 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 { + 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 { + 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` ` @@ -1277,6 +1307,44 @@ onMounted(async () => { +
+ + + + + +
+ + {{ + i18n.baseText('settings.sourceControl.modals.push.branch.base', { + interpolate: { branch: defaultBranch }, + }) + }} + + {{ i18n.baseText('settings.sourceControl.modals.push.commitMessage') }} @@ -1415,6 +1483,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; From 4395e33803e384672d61d5d8efb0286d2df400cc Mon Sep 17 00:00:00 2001 From: jnnmsl <223879930+jnnmsl@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:13:41 +0200 Subject: [PATCH 09/18] feat(source-control): relabel fixed branch as default branch --- packages/frontend/@n8n/i18n/src/locales/en.json | 3 ++- .../sourceControl.ee/views/SettingsSourceControl.vue | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index b75d7e01036..9346c0d7906 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -3767,7 +3767,8 @@ "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.protected": "{bold}: prevent editing workflows (recommended for production environments).", "settings.sourceControl.protected.bold": "Protected instance", "settings.sourceControl.color": "Color", diff --git a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue index 96f9cef546d..a9d24b34900 100644 --- a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue +++ b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue @@ -24,6 +24,7 @@ import { N8nFormInput, N8nHeading, N8nNotice, + N8nText, N8nTooltip, } from '@n8n/design-system'; const locale = useI18n(); @@ -507,6 +508,9 @@ watch(connectionType, () => { /> + + {{ locale.baseText('settings.sourceControl.branches.caption') }} + Date: Wed, 15 Jul 2026 15:23:16 +0200 Subject: [PATCH 10/18] fix(source-control): reset target branch to remote and make branch create idempotent Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api-types/src/utils/git-branch-name.ts | 2 +- .../source-control-git.service.test.ts | 23 ++++++++++++++++--- .../source-control-git.service.ee.ts | 9 ++++++-- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/@n8n/api-types/src/utils/git-branch-name.ts b/packages/@n8n/api-types/src/utils/git-branch-name.ts index 456fa4ec9c0..03a09e84b8f 100644 --- a/packages/@n8n/api-types/src/utils/git-branch-name.ts +++ b/packages/@n8n/api-types/src/utils/git-branch-name.ts @@ -4,7 +4,7 @@ * in the git layer. */ export function isValidGitBranchName(name: string): boolean { - if (!name || name.length === 0) return false; + if (!name) 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; diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-git.service.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-git.service.test.ts index 3cab320fa23..5d91eb2477f 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-git.service.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-git.service.test.ts @@ -513,21 +513,38 @@ describe('SourceControlGitService', () => { }); describe('createBranchFrom', () => { - it('resets base then creates the new branch', async () => { + 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']); - expect(git.checkoutBranch).toHaveBeenCalledWith('feat/x', '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 and sets upstream', async () => { + 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], + ); }); }); diff --git a/packages/cli/src/modules/source-control.ee/source-control-git.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-git.service.ee.ts index a9c786c5465..d444267eae1 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-git.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-git.service.ee.ts @@ -390,6 +390,9 @@ export class SourceControlGitService { 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]); } @@ -400,7 +403,9 @@ export class SourceControlGitService { // 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); + // 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 }> { @@ -466,7 +471,7 @@ export class SourceControlGitService { ): Promise { 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(); const flags: string[] = []; From c09e33520caa8847d71270ac4ea84d1ae011bfcb Mon Sep 17 00:00:00 2001 From: jnnmsl <223879930+jnnmsl@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:28:00 +0200 Subject: [PATCH 11/18] fix(source-control): show inline error for invalid branch name --- .../components/SourceControlPushModal.test.ts | 55 +++++++++++++++++++ .../components/SourceControlPushModal.vue | 16 ++++++ .../views/SettingsSourceControl.vue | 2 +- 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.test.ts b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.test.ts index 2ada42783b8..c4de56bfb9d 100644 --- a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.test.ts @@ -1988,5 +1988,60 @@ describe('SourceControlPushModal', () => { }), ); }); + + 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(); + }); + }); }); }); diff --git a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue index ea2b7ac0037..ba3a82d3d30 100644 --- a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue +++ b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue @@ -484,6 +484,13 @@ const isBranchValid = computed(() => : 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; @@ -1337,6 +1344,15 @@ onMounted(async () => { :label="i18n.baseText('settings.sourceControl.modals.push.branch.newBranch')" /> + + {{ i18n.baseText('settings.sourceControl.modals.push.branch.invalid') }} + {{ i18n.baseText('settings.sourceControl.modals.push.branch.base', { diff --git a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue index a9d24b34900..e9ae1de7dd4 100644 --- a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue +++ b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue @@ -508,7 +508,7 @@ watch(connectionType, () => { /> - + {{ locale.baseText('settings.sourceControl.branches.caption') }} Date: Wed, 15 Jul 2026 20:12:41 +0200 Subject: [PATCH 12/18] fix(source-control): tolerate pull on a branch with no upstream yet A branch created via the commit window's "New branch" option has no remote counterpart until its first push. getStatus always resets and pulls the workfolder before diffing, so pushing to a freshly created branch failed with "no tracking information for the current branch." Treat that specific git error as nothing-to-sync instead of surfacing it as a user-facing failure. Co-Authored-By: Claude Sonnet 5 --- .../source-control-helper.ee.test.ts | 22 ++++++++++++ .../source-control-status.service.test.ts | 36 +++++++++++++++++++ .../source-control-helper.ee.ts | 5 +++ .../source-control-status.service.ee.ts | 7 ++++ 4 files changed, 70 insertions(+) diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-helper.ee.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-helper.ee.test.ts index c5ffef8feee..751a453bd6f 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-helper.ee.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-helper.ee.test.ts @@ -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); diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts index 42600224c6e..447d6e237eb 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts @@ -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 = { diff --git a/packages/cli/src/modules/source-control.ee/source-control-helper.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-helper.ee.ts index be4624887eb..5dc06ba4617 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-helper.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-helper.ee.ts @@ -282,6 +282,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) { const sshpk = await import('sshpk'); const keyPair: KeyPair = { diff --git a/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts index 525534d6b96..74338cc40d1 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts @@ -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)}`, ); From fec0cd3a8e2a7c7f42a8d69744209fe931734b44 Mon Sep 17 00:00:00 2001 From: jnnmsl <223879930+jnnmsl@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:42:12 +0200 Subject: [PATCH 13/18] fix(source-control): prune stale remote branches on fetch git branch -r (used to populate the commit window's branch picker) lists local remote-tracking refs, which a plain git fetch never removes. A branch deleted on the remote kept showing up in the dropdown indefinitely. Fetch now prunes stale refs. Co-Authored-By: Claude Sonnet 5 --- .../__tests__/source-control-git.service.test.ts | 14 ++++++++++++++ .../source-control-git.service.ee.ts | 4 +++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-git.service.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-git.service.test.ts index 5d91eb2477f..2c4ba1e16d6 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-git.service.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-git.service.test.ts @@ -577,6 +577,20 @@ describe('SourceControlGitService', () => { 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', () => { diff --git a/packages/cli/src/modules/source-control.ee/source-control-git.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-git.service.ee.ts index d444267eae1..52b21bdfb65 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-git.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-git.service.ee.ts @@ -448,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 { From 240bb86b9925ecf1ede270816705f407c385ba78 Mon Sep 17 00:00:00 2001 From: jnnmsl <223879930+jnnmsl@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:28:16 +0200 Subject: [PATCH 14/18] fix(source-control): move default-branch caption next to its label The caption explaining the default branch sat below the dropdown, reading as a description of the unrelated read-only checkbox beneath it. Move it inline next to the "Default branch" label instead. Co-Authored-By: Claude Sonnet 5 --- .../sourceControl.ee/views/SettingsSourceControl.vue | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue index e9ae1de7dd4..e143cead429 100644 --- a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue +++ b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue @@ -24,7 +24,6 @@ import { N8nFormInput, N8nHeading, N8nNotice, - N8nText, N8nTooltip, } from '@n8n/design-system'; const locale = useI18n(); @@ -474,7 +473,10 @@ watch(connectionType, () => { {{ locale.baseText('settings.sourceControl.instanceSettings') }} - +
{ />
- - {{ locale.baseText('settings.sourceControl.branches.caption') }} - Date: Thu, 16 Jul 2026 08:19:40 +0200 Subject: [PATCH 15/18] feat(source-control): gate branch selection behind an env var Per-commit branch selection now requires opting in via N8N_SOURCECONTROL_BRANCH_SELECTION_ENABLED (default off). When disabled, push ignores any requested branch/createBranch and always targets the default branch, and pull never re-checks-out the default branch - both behave exactly as before this feature existed. The flag is exposed via GET /source-control/preferences for the frontend. Co-Authored-By: Claude Sonnet 5 --- ...rce-control-preferences.service.ee.test.ts | 20 ++++++- .../__tests__/source-control.config.test.ts | 32 ++++++++++++ .../source-control.controller.ee.test.ts | 11 +++- .../__tests__/source-control.service.test.ts | 52 ++++++++++++++++++- .../source-control-preferences.service.ee.ts | 4 ++ .../source-control.config.ts | 4 ++ .../source-control.controller.ee.ts | 8 ++- .../source-control.service.ee.ts | 15 ++++-- 8 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/modules/source-control.ee/__tests__/source-control.config.test.ts diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-preferences.service.ee.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-preferences.service.ee.test.ts index c9de12b47a1..a576763b534 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-preferences.service.ee.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-preferences.service.ee.test.ts @@ -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(); const mockLogger = mock(); const mockSettingsRepository = mock(); + const mockSourceControlConfig = mock({ 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 = { branchName: 'main', diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control.config.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control.config.test.ts new file mode 100644 index 00000000000..ed008a4dec1 --- /dev/null +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control.config.test.ts @@ -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); + }); + }); +}); diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control.controller.ee.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control.controller.ee.test.ts index fb643244c66..5f4e5daef19 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control.controller.ee.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control.controller.ee.test.ts @@ -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({ 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(); }); diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control.service.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control.service.test.ts index 216e5c6172c..5ca094a17ed 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control.service.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control.service.test.ts @@ -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({ role: GLOBAL_MEMBER_ROLE }); const globalMemberUserWithId = mock({ 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({ branchSelectionEnabled: true }); const preferencesService = new SourceControlPreferencesService( Container.get(InstanceSettings), mock(), mock(), mock(), - mock(), + mockSourceControlConfig, ); const sourceControlImportService = mock(); const sourceControlExportService = mock(); @@ -78,6 +82,7 @@ describe('SourceControlService', () => { vi.spyOn(sourceControlService, 'sanityCheck').mockResolvedValue(undefined); // Reset mock implementations mockStatusService.getStatus.mockReset(); + mockSourceControlConfig.branchSelectionEnabled = true; }); describe('pushWorkfolder', () => { @@ -592,6 +597,37 @@ describe('SourceControlService', () => { 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', () => { @@ -735,6 +771,20 @@ describe('SourceControlService', () => { // 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(); + mockStatusService.getStatus.mockResolvedValueOnce([]); + sourceControlImportService.importWorkflowFromWorkFolder.mockResolvedValue([]); + + // ACT + await sourceControlService.pullWorkfolder(user, { force: true, autoPublish: 'none' }); + + // ASSERT + expect(gitService.checkoutExistingBranch).not.toHaveBeenCalled(); + }); }); describe('getStatus', () => { diff --git a/packages/cli/src/modules/source-control.ee/source-control-preferences.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-preferences.service.ee.ts index a72ab346b0b..3e08285ff56 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-preferences.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-preferences.service.ee.ts @@ -234,6 +234,10 @@ export class SourceControlPreferencesService { return this._sourceControlPreferences.branchReadOnly; } + isBranchSelectionEnabled(): boolean { + return this.sourceControlConfig.branchSelectionEnabled; + } + isSourceControlConnected(): boolean { return this.sourceControlPreferences.connected; } diff --git a/packages/cli/src/modules/source-control.ee/source-control.config.ts b/packages/cli/src/modules/source-control.ee/source-control.config.ts index 04d12dce4fd..5d39cb8c796 100644 --- a/packages/cli/src/modules/source-control.ee/source-control.config.ts +++ b/packages/cli/src/modules/source-control.ee/source-control.config.ts @@ -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; } diff --git a/packages/cli/src/modules/source-control.ee/source-control.controller.ee.ts b/packages/cli/src/modules/source-control.ee/source-control.controller.ee.ts index 3b8e5b9839e..d81e71dc423 100644 --- a/packages/cli/src/modules/source-control.ee/source-control.controller.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control.controller.ee.ts @@ -38,16 +38,20 @@ export class SourceControlController { ) {} @Get('/preferences') - async getPreferences(req: AuthenticatedRequest): Promise> { + async getPreferences( + req: AuthenticatedRequest, + ): Promise & { 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); diff --git a/packages/cli/src/modules/source-control.ee/source-control.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control.service.ee.ts index e17fe2d0494..189acae7c6b 100644 --- a/packages/cli/src/modules/source-control.ee/source-control.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control.service.ee.ts @@ -294,7 +294,11 @@ export class SourceControlService { const defaultBranch = this.sourceControlPreferencesService.getBranchName(); const requested = options.branch?.trim(); - if (!requested || requested === defaultBranch) { + if ( + !requested || + requested === defaultBranch || + !this.sourceControlPreferencesService.isBranchSelectionEnabled() + ) { return { targetBranch: defaultBranch, isNewBranch: false, defaultBranch }; } @@ -567,9 +571,12 @@ export class SourceControlService { await this.sanityCheck(); // 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); + // 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', From ba3d37bd312756b2e6946066052d9b07b1500ff8 Mon Sep 17 00:00:00 2001 From: jnnmsl <223879930+jnnmsl@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:33:26 +0200 Subject: [PATCH 16/18] feat(source-control): hide branch selection UI when the feature is disabled The commit modal's branch dropdown/toggle and the "Default branch" settings label now depend on the branchSelectionEnabled preference (driven by N8N_SOURCECONTROL_BRANCH_SELECTION_ENABLED). When disabled, the modal never fetches branches, sends no branch/createBranch fields, and the settings page shows its original label - matching the flow before this feature existed. Co-Authored-By: Claude Sonnet 5 --- .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../server/endpoints/sourceControl.ts | 1 + .../MainSidebarSourceControl.test.ts | 4 + .../components/SourceControlPushModal.test.ts | 56 +++++++- .../components/SourceControlPushModal.vue | 126 ++++++++++-------- .../sourceControl.store.test.ts | 2 + .../sourceControl.ee/sourceControl.store.ts | 1 + .../sourceControl.ee/sourceControl.types.ts | 1 + .../views/SettingsSourceControl.test.ts | 52 ++++++++ .../views/SettingsSourceControl.vue | 3 +- 10 files changed, 189 insertions(+), 58 deletions(-) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 9346c0d7906..c20a5125658 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -3769,6 +3769,7 @@ "settings.sourceControl.instanceSettings": "Instance settings", "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", diff --git a/packages/frontend/editor-ui/src/__tests__/server/endpoints/sourceControl.ts b/packages/frontend/editor-ui/src/__tests__/server/endpoints/sourceControl.ts index c7276e1547b..b5e098335b0 100644 --- a/packages/frontend/editor-ui/src/__tests__/server/endpoints/sourceControl.ts +++ b/packages/frontend/editor-ui/src/__tests__/server/endpoints/sourceControl.ts @@ -11,6 +11,7 @@ export function routesForSourceControl(server: Server) { branches: [], repositoryUrl: '', branchReadOnly: false, + branchSelectionEnabled: false, branchColor: '#1d6acb', connected: false, publicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHEX+25m', diff --git a/packages/frontend/editor-ui/src/app/components/MainSidebarSourceControl.test.ts b/packages/frontend/editor-ui/src/app/components/MainSidebarSourceControl.test.ts index e0e1b0cca12..ce8f33c42b3 100644 --- a/packages/frontend/editor-ui/src/app/components/MainSidebarSourceControl.test.ts +++ b/packages/frontend/editor-ui/src/app/components/MainSidebarSourceControl.test.ts @@ -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: '', diff --git a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.test.ts b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.test.ts index c4de56bfb9d..20171bfd32c 100644 --- a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.test.ts @@ -154,9 +154,11 @@ describe('SourceControlPushModal', () => { 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 + // 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; @@ -1933,6 +1935,58 @@ describe('SourceControlPushModal', () => { ); }); + 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'; diff --git a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue index ba3a82d3d30..e8c2be879e3 100644 --- a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue +++ b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue @@ -478,11 +478,15 @@ const branchOptions = computed(() => ); const defaultBranch = computed(() => sourceControlStore.preferences.branchName); -const isBranchValid = computed(() => - isCreatingBranch.value +const isBranchValid = computed(() => { + if (!sourceControlStore.preferences.branchSelectionEnabled) { + return true; + } + + return isCreatingBranch.value ? isValidGitBranchName(newBranchName.value.trim()) - : selectedBranch.value.length > 0, -); + : selectedBranch.value.length > 0; +}); const showBranchNameError = computed( () => @@ -640,8 +644,14 @@ async function commitAndPush() { force: true, commitMessage: commitMessage.value, fileNames: files, - branch: isCreatingBranch.value ? newBranchName.value.trim() : selectedBranch.value, - createBranch: isCreatingBranch.value, + branch: sourceControlStore.preferences.branchSelectionEnabled + ? isCreatingBranch.value + ? newBranchName.value.trim() + : selectedBranch.value + : undefined, + createBranch: sourceControlStore.preferences.branchSelectionEnabled + ? isCreatingBranch.value + : undefined, }); toast.showToast({ @@ -918,12 +928,14 @@ onMounted(async () => { // Always load fresh data to ensure workflow names are populated await loadSourceControlStatus(); - try { - await sourceControlStore.getBranches(); - } catch { - // non-fatal: fall back to the default branch already in preferences + 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; } - selectedBranch.value = sourceControlStore.preferences.branchName; }); @@ -1314,52 +1326,54 @@ onMounted(async () => {
-
- - +
+ + + + - - - -
- - {{ i18n.baseText('settings.sourceControl.modals.push.branch.invalid') }} - - - {{ - i18n.baseText('settings.sourceControl.modals.push.branch.base', { - interpolate: { branch: defaultBranch }, - }) - }} - + +
+ + {{ i18n.baseText('settings.sourceControl.modals.push.branch.invalid') }} + + + {{ + i18n.baseText('settings.sourceControl.modals.push.branch.base', { + interpolate: { branch: defaultBranch }, + }) + }} + + {{ i18n.baseText('settings.sourceControl.modals.push.commitMessage') }} diff --git a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/sourceControl.store.test.ts b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/sourceControl.store.test.ts index f2da510d02c..8c40d0ddbaa 100644 --- a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/sourceControl.store.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/sourceControl.store.test.ts @@ -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: '', diff --git a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/sourceControl.store.ts b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/sourceControl.store.ts index ded2359f9dc..1e8724c33b3 100644 --- a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/sourceControl.store.ts +++ b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/sourceControl.store.ts @@ -29,6 +29,7 @@ export const useSourceControlStore = defineStore('sourceControl', () => { branches: [], repositoryUrl: '', branchReadOnly: false, + branchSelectionEnabled: false, branchColor: DEFAULT_BRANCH_COLOR, connected: false, publicKey: '', diff --git a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/sourceControl.types.ts b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/sourceControl.types.ts index 70bd07cd892..81bce9f7d8a 100644 --- a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/sourceControl.types.ts +++ b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/sourceControl.types.ts @@ -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 & { diff --git a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.test.ts b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.test.ts index d0a430d78f6..afbbcac5158 100644 --- a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.test.ts @@ -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; diff --git a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue index e143cead429..5ba7b8d9aaf 100644 --- a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue +++ b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue @@ -473,10 +473,11 @@ watch(connectionType, () => { {{ locale.baseText('settings.sourceControl.instanceSettings') }} -