diff --git a/packages/nodes-base/nodes/Git/Git.node.ts b/packages/nodes-base/nodes/Git/Git.node.ts index 35800db279..80a91abea7 100644 --- a/packages/nodes-base/nodes/Git/Git.node.ts +++ b/packages/nodes-base/nodes/Git/Git.node.ts @@ -5,7 +5,7 @@ import type { INodeType, INodeTypeDescription, } from 'n8n-workflow'; -import { NodeConnectionTypes } from 'n8n-workflow'; +import { NodeConnectionTypes, assertParamIsBoolean, assertParamIsString } from 'n8n-workflow'; import type { LogOptions, SimpleGit, SimpleGitOptions } from 'simple-git'; import simpleGit from 'simple-git'; import { URL } from 'url'; @@ -17,6 +17,7 @@ import { commitFields, logFields, pushFields, + switchBranchFields, tagFields, } from './descriptions'; @@ -141,6 +142,12 @@ export class Git implements INodeType { description: 'Return status of current repository', action: 'Return status of current repository', }, + { + name: 'Switch Branch', + value: 'switchBranch', + description: 'Switch to a different branch', + action: 'Switch to a different branch', + }, { name: 'Tag', value: 'tag', @@ -191,6 +198,7 @@ export class Git implements INodeType { ...commitFields, ...logFields, ...pushFields, + ...switchBranchFields, ...tagFields, // ...userSetupFields, ], @@ -215,6 +223,58 @@ export class Git implements INodeType { return repositoryPath; }; + interface CheckoutBranchOptions { + branchName: string; + createBranch?: boolean; + startPoint?: string; + force?: boolean; + setUpstream?: boolean; + remoteName?: string; + } + + const checkoutBranch = async ( + git: SimpleGit, + options: CheckoutBranchOptions, + ): Promise => { + const { + branchName, + createBranch = true, + startPoint, + force = false, + setUpstream = false, + remoteName = 'origin', + } = options; + try { + if (force) { + await git.checkout(['-f', branchName]); + } else { + await git.checkout(branchName); + } + } catch (error) { + if (createBranch) { + // Try to create the branch when checkout fails + if (startPoint) { + await git.checkoutBranch(branchName, startPoint); + } else { + await git.checkoutLocalBranch(branchName); + } + // If we reach here, branch creation succeeded + } else { + // Don't create branch, throw original error + throw error; + } + } + + if (setUpstream) { + try { + await git.addConfig(`branch.${branchName}.remote`, remoteName); + await git.addConfig(`branch.${branchName}.merge`, `refs/heads/${branchName}`); + } catch (upstreamError) { + // Upstream setup failed but that's non-fatal + } + } + }; + const operation = this.getNodeParameter('operation', 0); const returnItems: INodeExecutionData[] = []; for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { @@ -304,6 +364,14 @@ export class Git implements INodeType { // ---------------------------------- const message = this.getNodeParameter('message', itemIndex, '') as string; + const branch = options.branch; + if (branch !== undefined && branch !== '') { + assertParamIsString('branch', branch, this.getNode()); + await checkoutBranch(git, { + branchName: branch, + setUpstream: true, + }); + } let pathsToAdd: string[] | undefined = undefined; if (options.files !== undefined) { @@ -379,6 +447,16 @@ export class Git implements INodeType { // push // ---------------------------------- + const branch = options.branch; + if (branch !== undefined && branch !== '') { + assertParamIsString('branch', branch, this.getNode()); + await checkoutBranch(git, { + branchName: branch, + createBranch: false, + setUpstream: true, + }); + } + if (options.repository) { const targetRepository = await prepareRepository(options.targetRepository as string); await git.push(targetRepository); @@ -464,6 +542,56 @@ export class Git implements INodeType { }; }), ); + } else if (operation === 'switchBranch') { + // ---------------------------------- + // switchBranch + // ---------------------------------- + + const branchName = this.getNodeParameter('branchName', itemIndex); + assertParamIsString('branchName', branchName, this.getNode()); + + const createBranch = options.createBranch; + if (createBranch !== undefined) { + assertParamIsBoolean('createBranch', createBranch, this.getNode()); + } + const remoteName = + typeof options.remoteName === 'string' && options.remoteName + ? options.remoteName + : 'origin'; + + const startPoint = options.startPoint; + if (startPoint !== undefined) { + assertParamIsString('startPoint', startPoint, this.getNode()); + } + + const setUpstream = options.setUpstream; + if (setUpstream !== undefined) { + assertParamIsBoolean('setUpstream', setUpstream, this.getNode()); + } + + const force = options.force; + if (force !== undefined) { + assertParamIsBoolean('force', force, this.getNode()); + } + + await checkoutBranch(git, { + branchName, + createBranch, + startPoint, + force, + setUpstream, + remoteName, + }); + + returnItems.push({ + json: { + success: true, + branch: branchName, + }, + pairedItem: { + item: itemIndex, + }, + }); } else if (operation === 'tag') { // ---------------------------------- // tag diff --git a/packages/nodes-base/nodes/Git/descriptions/CommitDescription.ts b/packages/nodes-base/nodes/Git/descriptions/CommitDescription.ts index 4e2570f4a8..789124d292 100644 --- a/packages/nodes-base/nodes/Git/descriptions/CommitDescription.ts +++ b/packages/nodes-base/nodes/Git/descriptions/CommitDescription.ts @@ -25,6 +25,15 @@ export const commitFields: INodeProperties[] = [ placeholder: 'Add option', default: {}, options: [ + { + displayName: 'Branch', + name: 'branch', + type: 'string', + default: '', + placeholder: 'main', + description: + 'The branch to switch to before committing. If empty or not set, will commit to current branch.', + }, { displayName: 'Paths to Add', name: 'pathsToAdd', diff --git a/packages/nodes-base/nodes/Git/descriptions/PushDescription.ts b/packages/nodes-base/nodes/Git/descriptions/PushDescription.ts index 45054de1b1..f4b759aeb6 100644 --- a/packages/nodes-base/nodes/Git/descriptions/PushDescription.ts +++ b/packages/nodes-base/nodes/Git/descriptions/PushDescription.ts @@ -13,6 +13,15 @@ export const pushFields: INodeProperties[] = [ placeholder: 'Add option', default: {}, options: [ + { + displayName: 'Branch', + name: 'branch', + type: 'string', + default: '', + placeholder: 'main', + description: + 'The branch to switch to before pushing. If empty or not set, will push current branch.', + }, { displayName: 'Target Repository', name: 'targetRepository', diff --git a/packages/nodes-base/nodes/Git/descriptions/SwitchBranchDescription.ts b/packages/nodes-base/nodes/Git/descriptions/SwitchBranchDescription.ts new file mode 100644 index 0000000000..33e1db9887 --- /dev/null +++ b/packages/nodes-base/nodes/Git/descriptions/SwitchBranchDescription.ts @@ -0,0 +1,86 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const switchBranchFields: INodeProperties[] = [ + { + displayName: 'Branch Name', + name: 'branchName', + type: 'string', + displayOptions: { + show: { + operation: ['switchBranch'], + }, + }, + default: '', + placeholder: 'feature/new-feature', + required: true, + description: 'The name of the branch to switch to', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + displayOptions: { + show: { + operation: ['switchBranch'], + }, + }, + placeholder: 'Add option', + default: {}, + options: [ + { + displayName: 'Create Branch If Not Exists', + name: 'createBranch', + type: 'boolean', + default: true, + description: 'Whether to create the branch if it does not exist', + }, + { + displayName: 'Start Point', + name: 'startPoint', + type: 'string', + default: '', + placeholder: 'main', + description: + 'The commit/branch/tag to create the new branch from. If not set, creates from current HEAD.', + displayOptions: { + show: { + createBranch: [true], + }, + }, + }, + { + displayName: 'Force Switch', + name: 'force', + type: 'boolean', + default: false, + description: 'Whether to force the branch switch, discarding any local changes', + }, + { + displayName: 'Set Upstream', + name: 'setUpstream', + type: 'boolean', + default: false, + description: 'Whether to set up tracking to a remote branch when creating a new branch', + displayOptions: { + show: { + createBranch: [true], + }, + }, + }, + { + displayName: 'Remote Name', + name: 'remoteName', + type: 'string', + default: 'origin', + placeholder: 'origin', + description: 'The name of the remote to track', + displayOptions: { + show: { + createBranch: [true], + setUpstream: [true], + }, + }, + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Git/descriptions/index.ts b/packages/nodes-base/nodes/Git/descriptions/index.ts index 568f5df05e..1e50743750 100644 --- a/packages/nodes-base/nodes/Git/descriptions/index.ts +++ b/packages/nodes-base/nodes/Git/descriptions/index.ts @@ -4,4 +4,5 @@ export * from './CloneDescription'; export * from './CommitDescription'; export * from './LogDescription'; export * from './PushDescription'; +export * from './SwitchBranchDescription'; export * from './TagDescription'; diff --git a/packages/nodes-base/nodes/Git/test/Git.node.test.ts b/packages/nodes-base/nodes/Git/test/Git.node.test.ts new file mode 100644 index 0000000000..568511249e --- /dev/null +++ b/packages/nodes-base/nodes/Git/test/Git.node.test.ts @@ -0,0 +1,562 @@ +import * as fsPromises from 'fs/promises'; +import { mock } from 'jest-mock-extended'; +import type { IExecuteFunctions } from 'n8n-workflow'; +import type { SimpleGit } from 'simple-git'; + +import { Git } from '../Git.node'; + +// Mock simple-git +const mockGit = { + checkout: jest.fn(), + checkoutBranch: jest.fn(), + checkoutLocalBranch: jest.fn(), + add: jest.fn(), + commit: jest.fn(), + push: jest.fn(), + pull: jest.fn(), + clone: jest.fn(), + addConfig: jest.fn(), + fetch: jest.fn(), + log: jest.fn(), + pushTags: jest.fn(), + listConfig: jest.fn(), + status: jest.fn(), + addTag: jest.fn(), + env: jest.fn().mockReturnThis(), +} as unknown as jest.Mocked; + +jest.mock('simple-git', () => ({ + __esModule: true, + default: () => mockGit, +})); + +// Mock filesystem operations +jest.mock('fs/promises', () => ({ + access: jest.fn(), + mkdir: jest.fn(), +})); + +const mockFsPromises = jest.mocked(fsPromises); + +describe('Git Node', () => { + let gitNode: Git; + let mockExecuteFunctions: jest.Mocked; + + beforeEach(() => { + gitNode = new Git(); + mockExecuteFunctions = mock({ + getInputData: jest.fn(() => [{ json: {} }]), + getNodeParameter: jest.fn(), + continueOnFail: jest.fn(() => false), + helpers: { + returnJsonArray: jest.fn((data: any[]) => data.map((item: any) => ({ json: item }))), + }, + }); + jest.clearAllMocks(); + }); + + describe('Branch switching', () => { + it('should switch to existing branch for commit operation', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('commit') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({ branch: 'feature' }) + .mockReturnValueOnce('test commit'); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.checkout).toHaveBeenCalledWith('feature'); + expect(mockGit.commit).toHaveBeenCalledWith('test commit', undefined); + }); + + it('should commit specific files when pathsToAdd is provided', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('commit') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({ + branch: 'feature-branch', + files: true, + pathsToAdd: 'src/file1.js,src/file2.js,README.md', + }) + .mockReturnValueOnce('Add specific files'); + + mockGit.checkout.mockResolvedValueOnce('Switched to branch feature-branch' as any); + + const result = await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.checkout).toHaveBeenCalledWith('feature-branch'); + expect(mockGit.commit).toHaveBeenCalledWith('Add specific files', [ + 'src/file1.js', + 'src/file2.js', + 'README.md', + ]); + expect(result[0]).toEqual([{ json: { success: true }, pairedItem: { item: 0 } }]); + }); + + it('should fail when trying to push to non-existent branch', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('push') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({ branch: 'non-existent-branch' }) + .mockReturnValueOnce('none'); + + const error = new Error('Branch not found'); + mockGit.checkout.mockRejectedValueOnce(error); + + await expect(gitNode.execute.call(mockExecuteFunctions)).rejects.toThrow('Branch not found'); + + expect(mockGit.checkout).toHaveBeenCalledWith('non-existent-branch'); + expect(mockGit.checkoutLocalBranch).not.toHaveBeenCalled(); + expect(mockGit.push).not.toHaveBeenCalled(); + }); + + it('should set upstream when creating branch for commit operation', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('commit') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({ branch: 'feature-branch' }) + .mockReturnValueOnce('commit message'); + + mockGit.checkout.mockRejectedValueOnce(new Error('Branch not found')); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.checkout).toHaveBeenCalledWith('feature-branch'); + expect(mockGit.checkoutLocalBranch).toHaveBeenCalledWith('feature-branch'); + expect(mockGit.addConfig).toHaveBeenCalledWith('branch.feature-branch.remote', 'origin'); + expect(mockGit.addConfig).toHaveBeenCalledWith( + 'branch.feature-branch.merge', + 'refs/heads/feature-branch', + ); + expect(mockGit.commit).toHaveBeenCalledWith('commit message', undefined); + }); + + it('should set upstream when switching to existing branch for push operation', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('push') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({ branch: 'existing-branch' }) + .mockReturnValueOnce('none'); + + // Branch exists, so checkout succeeds + mockGit.checkout.mockResolvedValueOnce('Switched to branch existing-branch' as any); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.checkout).toHaveBeenCalledWith('existing-branch'); + expect(mockGit.checkoutLocalBranch).not.toHaveBeenCalled(); + expect(mockGit.addConfig).toHaveBeenCalledWith('branch.existing-branch.remote', 'origin'); + expect(mockGit.addConfig).toHaveBeenCalledWith( + 'branch.existing-branch.merge', + 'refs/heads/existing-branch', + ); + expect(mockGit.push).toHaveBeenCalled(); + }); + + it('should push to specific repository when repository option is provided', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('push') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({ + branch: 'feature-branch', + repository: true, + targetRepository: 'https://github.com/example/repo.git', + }) + .mockReturnValueOnce('none'); + + // Branch exists, so checkout succeeds + mockGit.checkout.mockResolvedValueOnce('Switched to branch feature-branch' as any); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.checkout).toHaveBeenCalledWith('feature-branch'); + expect(mockGit.push).toHaveBeenCalledWith('https://github.com/example/repo.git'); + }); + + it('should not switch branch when pushing with empty branch string', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('push') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({ branch: '' }) // empty string branch + .mockReturnValueOnce('gitPassword'); + + // Mock git config for push operation + mockGit.listConfig.mockResolvedValueOnce({ + values: { '.git/config': { 'remote.origin.url': 'https://github.com/test/repo.git' } }, + } as any); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.checkout).not.toHaveBeenCalled(); + expect(mockGit.push).toHaveBeenCalled(); + }); + + it('should handle switchBranch operation to existing branch', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('switchBranch') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({}) + .mockReturnValueOnce('existing-branch'); + + const result = await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.checkout).toHaveBeenCalledWith('existing-branch'); + expect(result[0]).toEqual([ + { json: { success: true, branch: 'existing-branch' }, pairedItem: { item: 0 } }, + ]); + }); + + it('should create new branch when switchBranch fails and createBranch is true', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('switchBranch') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({ createBranch: true }) + .mockReturnValueOnce('new-branch'); + + mockGit.checkout.mockRejectedValueOnce(new Error('Branch not found')); + + const result = await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.checkout).toHaveBeenCalledWith('new-branch'); + expect(mockGit.checkoutLocalBranch).toHaveBeenCalledWith('new-branch'); + expect(result[0]).toEqual([ + { json: { success: true, branch: 'new-branch' }, pairedItem: { item: 0 } }, + ]); + }); + + it('should create branch from start point when specified', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('switchBranch') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({ createBranch: true, startPoint: 'main' }) + .mockReturnValueOnce('feature-branch'); + + mockGit.checkout.mockRejectedValueOnce(new Error('Branch not found')); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.checkout).toHaveBeenCalledWith('feature-branch'); + expect(mockGit.checkoutBranch).toHaveBeenCalledWith('feature-branch', 'main'); + }); + + it('should force checkout when force option is enabled', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('switchBranch') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({ force: true }) + .mockReturnValueOnce('force-branch'); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.checkout).toHaveBeenCalledWith(['-f', 'force-branch']); + }); + + it('should throw error when createBranch is false and branch does not exist', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('switchBranch') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({ createBranch: false }) + .mockReturnValueOnce('nonexistent-branch'); + + const error = new Error('Branch not found'); + mockGit.checkout.mockRejectedValueOnce(error); + + await expect(gitNode.execute.call(mockExecuteFunctions)).rejects.toThrow('Branch not found'); + }); + + it('should set upstream tracking when creating new branch with setUpstream option', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('switchBranch') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({ + createBranch: true, + setUpstream: true, + remoteName: 'origin', + }) + .mockReturnValueOnce('feature-branch'); + + mockGit.checkout.mockRejectedValueOnce(new Error('Branch not found')); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.checkout).toHaveBeenCalledWith('feature-branch'); + expect(mockGit.checkoutLocalBranch).toHaveBeenCalledWith('feature-branch'); + expect(mockGit.addConfig).toHaveBeenCalledWith('branch.feature-branch.remote', 'origin'); + expect(mockGit.addConfig).toHaveBeenCalledWith( + 'branch.feature-branch.merge', + 'refs/heads/feature-branch', + ); + }); + + it('should use default remote name when not specified', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('switchBranch') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({ + createBranch: true, + setUpstream: true, + // remoteName not specified, should default to 'origin' + }) + .mockReturnValueOnce('feature-branch'); + + mockGit.checkout.mockRejectedValueOnce(new Error('Branch not found')); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.addConfig).toHaveBeenCalledWith('branch.feature-branch.remote', 'origin'); + }); + + it('should continue successfully even if upstream setup fails', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('switchBranch') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({ + createBranch: true, + setUpstream: true, + remoteName: 'origin', + }) + .mockReturnValueOnce('feature-branch'); + + mockGit.checkout.mockRejectedValueOnce(new Error('Branch not found')); + mockGit.addConfig.mockRejectedValueOnce(new Error('Remote not found')); + + const result = await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.checkoutLocalBranch).toHaveBeenCalledWith('feature-branch'); + expect(result[0]).toEqual([ + { + json: { success: true, branch: 'feature-branch' }, + pairedItem: { item: 0 }, + }, + ]); + }); + + it('should not switch branch when not specified', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('commit') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({}) // no branch + .mockReturnValueOnce('test commit'); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.checkout).not.toHaveBeenCalled(); + expect(mockGit.commit).toHaveBeenCalledWith('test commit', undefined); + }); + + it('should not switch branch when empty string is provided', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('commit') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({ branch: '' }) // empty string branch + .mockReturnValueOnce('test commit'); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.checkout).not.toHaveBeenCalled(); + expect(mockGit.commit).toHaveBeenCalledWith('test commit', undefined); + }); + }); + + describe('All operations coverage', () => { + it('should handle add operation', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('add') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({}) + .mockReturnValueOnce('file.txt'); + + const result = await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.add).toHaveBeenCalledWith(['file.txt']); + expect(result[0]).toEqual([{ json: { success: true }, pairedItem: { item: 0 } }]); + }); + + it('should handle addConfig operation', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('addConfig') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({}) + .mockReturnValueOnce('user.name') + .mockReturnValueOnce('test user'); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.addConfig).toHaveBeenCalledWith('user.name', 'test user', false); + }); + + it('should handle clone operation and create directory when it does not exist', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('clone') + .mockReturnValueOnce('/new-repo') + .mockReturnValueOnce({}) + .mockReturnValueOnce('https://github.com/test/repo.git'); + + // Simulate directory not existing - access() throws + mockFsPromises.access.mockRejectedValueOnce(new Error('Directory does not exist')); + mockFsPromises.mkdir.mockResolvedValueOnce(undefined); + + const result = await gitNode.execute.call(mockExecuteFunctions); + + expect(mockFsPromises.access).toHaveBeenCalledWith('/new-repo'); + expect(mockFsPromises.mkdir).toHaveBeenCalledWith('/new-repo'); + expect(mockGit.clone).toHaveBeenCalledWith('https://github.com/test/repo.git', '.'); + expect(result[0]).toEqual([{ json: { success: true }, pairedItem: { item: 0 } }]); + }); + + it('should handle clone operation when directory already exists', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('clone') + .mockReturnValueOnce('/existing-repo') + .mockReturnValueOnce({}) + .mockReturnValueOnce('https://github.com/test/repo.git'); + + // Simulate directory already exists - access() succeeds + mockFsPromises.access.mockResolvedValueOnce(undefined); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockFsPromises.access).toHaveBeenCalledWith('/existing-repo'); + expect(mockFsPromises.mkdir).not.toHaveBeenCalled(); + expect(mockGit.clone).toHaveBeenCalledWith('https://github.com/test/repo.git', '.'); + }); + + it('should handle fetch operation', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('fetch') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({}); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.fetch).toHaveBeenCalled(); + }); + + it('should handle pull operation', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('pull') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({}); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.pull).toHaveBeenCalled(); + }); + + it('should handle log operation', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('log') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({}) + .mockReturnValueOnce(false) // returnAll + .mockReturnValueOnce(10); // limit + + const mockLogData = [ + { hash: 'abc123', message: 'test commit', author_name: 'John Doe' }, + { hash: 'def456', message: 'another commit', author_name: 'Jane Smith' }, + ]; + mockGit.log.mockResolvedValueOnce({ all: mockLogData } as any); + + const result = await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.log).toHaveBeenCalledWith({ maxCount: 10 }); + expect(result[0]).toHaveLength(2); + expect(result[0]).toEqual([ + { + json: { hash: 'abc123', message: 'test commit', author_name: 'John Doe' }, + pairedItem: { item: 0 }, + }, + { + json: { hash: 'def456', message: 'another commit', author_name: 'Jane Smith' }, + pairedItem: { item: 0 }, + }, + ]); + }); + + it('should handle pushTags operation', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('pushTags') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({}); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.pushTags).toHaveBeenCalled(); + }); + + it('should handle listConfig operation', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('listConfig') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({}); + + mockGit.listConfig.mockResolvedValueOnce({ + values: { '.git/config': { 'user.name': 'test' } }, + } as any); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.listConfig).toHaveBeenCalled(); + }); + + it('should handle status operation', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('status') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({}); + + mockGit.status.mockResolvedValueOnce({ current: 'main' } as any); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.status).toHaveBeenCalled(); + }); + + it('should handle tag operation', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('tag') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({}) + .mockReturnValueOnce('v1.0.0'); + + await gitNode.execute.call(mockExecuteFunctions); + + expect(mockGit.addTag).toHaveBeenCalledWith('v1.0.0'); + }); + }); + + describe('Error handling', () => { + it('should continue on fail when enabled', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('commit') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({ branch: 'bad-branch' }) + .mockReturnValueOnce('test'); + + mockExecuteFunctions.continueOnFail.mockReturnValueOnce(true); + mockGit.checkout.mockRejectedValueOnce(new Error('Branch error')); + mockGit.checkoutLocalBranch.mockRejectedValueOnce(new Error('Create error')); + + const result = await gitNode.execute.call(mockExecuteFunctions); + + expect(result[0]).toEqual([ + { + json: { error: 'Error: Create error' }, + pairedItem: { item: 0 }, + }, + ]); + }); + + it('should throw on fail when continueOnFail is disabled', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('add') + .mockReturnValueOnce('/repo') + .mockReturnValueOnce({}) + .mockReturnValueOnce('file.txt'); + + mockGit.add.mockRejectedValueOnce(new Error('Add failed')); + + await expect(gitNode.execute.call(mockExecuteFunctions)).rejects.toThrow('Add failed'); + }); + }); +});