diff --git a/.vscode/settings.json b/.vscode/settings.json index f2cf272e59c..040a7968964 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,7 +21,10 @@ "inso", "libcurl", "mockbin", + "Revalidator", "svgr", + "Uncommited", + "Unpushed", "unstage", "Unstaged", "upsert", diff --git a/packages/insomnia-smoke-test/tests/smoke/git-interactions.test.ts b/packages/insomnia-smoke-test/tests/smoke/git-interactions.test.ts index 735b8ca6711..1e2405d286f 100644 --- a/packages/insomnia-smoke-test/tests/smoke/git-interactions.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/git-interactions.test.ts @@ -2,7 +2,7 @@ import { test } from '../../playwright/test'; test('Git Interactions (clone, checkout branch, pull, push, stage changes, ...)', async ({ page }) => { const gitSyncSmokeTestToken = process.env.GIT_SYNC_SMOKE_TEST_TOKEN; - test.setTimeout(60000); + test.setTimeout(600000); // read env variable to skip test if (!gitSyncSmokeTestToken) { diff --git a/packages/insomnia/src/sync/git/git-vcs.ts b/packages/insomnia/src/sync/git/git-vcs.ts index 8d6922958b5..52e64fdbbbb 100644 --- a/packages/insomnia/src/sync/git/git-vcs.ts +++ b/packages/insomnia/src/sync/git/git-vcs.ts @@ -636,9 +636,13 @@ export class GitVCS { } } - async pull(gitCredentials?: GitCredentials | null) { + async _hasUncommittedChanges() { const changes = await this.status(); - const hasUncommittedChanges = changes.staged.length > 0 || changes.unstaged.length > 0; + return changes.staged.length > 0 || changes.unstaged.length > 0; + } + + async pull(gitCredentials?: GitCredentials | null) { + const hasUncommittedChanges = await this._hasUncommittedChanges(); if (hasUncommittedChanges) { throw new Error('Cannot pull with uncommitted changes, please commit local changes first.'); } @@ -658,7 +662,6 @@ export class GitVCS { err, oursBranch, theirsBranch, - gitCredentials ); } else { throw err; @@ -667,11 +670,11 @@ export class GitVCS { ); } + // Collect merge conflict details from isomorphic-git git.Errors.MergeConflictError and throw a MergeConflictError which will be used to display the conflicts in the SyncMergeModal async _collectMergeConflicts( mergeConflictError: InstanceType, oursBranch: string, theirsBranch: string, - gitCredentials?: GitCredentials | null, ) { const { filepaths, bothModified, deleteByUs, deleteByTheirs, @@ -691,13 +694,11 @@ export class GitVCS { const oursHeadCommitOid = await git.resolveRef({ ...this._baseOpts, - ...gitCallbacks(gitCredentials), ref: oursBranch, }); const theirsHeadCommitOid = await git.resolveRef({ ...this._baseOpts, - ...gitCallbacks(gitCredentials), ref: theirsBranch, }); @@ -706,7 +707,6 @@ export class GitVCS { function readBlob(filepath: string, oid: string) { return git.readBlob({ ..._baseOpts, - ...gitCallbacks(gitCredentials), oid, filepath, }).then( @@ -798,6 +798,11 @@ export class GitVCS { commitParent: string[]; }) { console.log('[git] continue to merge after resolving merge conflicts', await this.getCurrentBranch()); + + // Because wo don't need to do anything with the conflicts that user has chosen to keep 'ours' + // Here we just filter in conflicts that user has chosen to keep 'theirs' + handledMergeConflicts = handledMergeConflicts.filter(conflict => conflict.choose !== conflict.mineBlob); + for (const conflict of handledMergeConflicts) { assertIsPromiseFsClient(this._baseOpts.fs); if (conflict.theirsBlobContent) { @@ -813,6 +818,10 @@ export class GitVCS { await git.remove({ ...this._baseOpts, filepath: conflict.key }); } } + + // Add other non-conflicted files to the stage area + await git.add({ ...this._baseOpts, filepath: '.' }); + await git.commit({ ...this._baseOpts, message: commitMessage, @@ -820,14 +829,39 @@ export class GitVCS { }); } - async merge(theirBranch: string) { - const ours = await this.getCurrentBranch(); - console.log(`[git] Merge ${ours} <-- ${theirBranch}`); + async merge({ + theirsBranch, + allowUncommittedChangesBeforeMerge = false, + }: { + theirsBranch: string; + allowUncommittedChangesBeforeMerge?: boolean; + }) { + if (!allowUncommittedChangesBeforeMerge) { + const hasUncommittedChanges = await this._hasUncommittedChanges(); + if (hasUncommittedChanges) { + throw new Error('There are uncommitted changes on current branch. Please commit them before merging.'); + } + } + const oursBranch = await this.getCurrentBranch(); + console.log(`[git] Merge ${oursBranch} <-- ${theirsBranch}`); return git.merge({ ...this._baseOpts, - ours, - theirs: theirBranch, - }); + ours: oursBranch, + theirs: theirsBranch, + abortOnConflict: false, + }).catch( + async err => { + if (err instanceof git.Errors.MergeConflictError) { + return await this._collectMergeConflicts( + err, + oursBranch, + theirsBranch, + ); + } else { + throw err; + } + }, + ); } async fetch({ diff --git a/packages/insomnia/src/sync/git/ne-db-client.ts b/packages/insomnia/src/sync/git/ne-db-client.ts index 4f7f0588ed7..271b37bd166 100644 --- a/packages/insomnia/src/sync/git/ne-db-client.ts +++ b/packages/insomnia/src/sync/git/ne-db-client.ts @@ -93,7 +93,14 @@ export class NeDBClient { return; } - const doc: BaseModel = YAML.parse(data.toString()); + const dataStr = data.toString(); + + // Skip the file if there is a conflict marker + if (dataStr.split('\n').includes('=======')) { + return; + } + + const doc: BaseModel = YAML.parse(dataStr); if (id !== doc._id) { throw new Error(`Doc _id does not match file path [${doc._id} != ${id || 'null'}]`); diff --git a/packages/insomnia/src/ui/components/base/prompt-button.tsx b/packages/insomnia/src/ui/components/base/prompt-button.tsx index e37a44b2bc3..e46d77d93f4 100644 --- a/packages/insomnia/src/ui/components/base/prompt-button.tsx +++ b/packages/insomnia/src/ui/components/base/prompt-button.tsx @@ -8,17 +8,19 @@ import React, { useState, } from 'react'; -type PromptStateEnum = 'default' | 'ask' | 'done'; +type PromptStateEnum = 'default' | 'ask' | 'loading' | 'done'; interface Props { className?: string; disabled?: boolean; confirmMessage?: string; doneMessage?: string; + loadingMessage?: string; tabIndex?: number; title?: string; fullWidth?: boolean; onClick?: (event: MouseEvent, value?: T) => void; + referToOnClickReturnValue?: boolean; } export const PromptButton = ({ @@ -26,11 +28,13 @@ export const PromptButton = ({ disabled, confirmMessage = 'Click to confirm', doneMessage = 'Done', + loadingMessage = 'Loading', tabIndex, title, className, fullWidth = false, children, + referToOnClickReturnValue = false, }: PropsWithChildren>) => { // Create flag to store the state value. const [state, setState] = useState('default'); @@ -65,17 +69,32 @@ export const PromptButton = ({ clearTimeout(triggerTimeout.current); } // Fire the click handler - onClick?.(event); - // Set the state to done (but delay a bit to not alarm user) - // using global.setTimeout to force use of the Node timeout rather than DOM timeout - doneTimeout.current = global.setTimeout(() => { - setState('done'); - }, 100); - // Set a timeout to hide the confirmation - // using global.setTimeout to force use of the Node timeout rather than DOM timeout - triggerTimeout.current = global.setTimeout(() => { - setState('default'); - }, 2000); + const retVal: any = onClick?.(event); + if (!referToOnClickReturnValue) { + // Set the state to done (but delay a bit to not alarm user) + // using global.setTimeout to force use of the Node timeout rather than DOM timeout + doneTimeout.current = global.setTimeout(() => { + setState('done'); + }, 100); + // Set a timeout to hide the confirmation + // using global.setTimeout to force use of the Node timeout rather than DOM timeout + triggerTimeout.current = global.setTimeout(() => { + setState('default'); + }, 2000); + } else { + if (retVal instanceof Promise) { + setState('loading'); + retVal.then(() => { + setState('done'); + }).finally(() => { + triggerTimeout.current = global.setTimeout(() => { + setState('default'); + }, 1000); + }); + } else { + throw new Error('onClick must return a Promise when referToOnClickReturnValue is true'); + } + } } }; @@ -94,6 +113,7 @@ export const PromptButton = ({ promptState={state} confirmMessage={confirmMessage} doneMessage={doneMessage} + loadingMessage={loadingMessage} > {children} @@ -105,9 +125,11 @@ interface PromptMessageProps { promptState: PromptStateEnum; confirmMessage?: string; doneMessage?: string; + loadingMessage: string; children: ReactNode; } -const PromptMessage: FunctionComponent = ({ promptState, confirmMessage, doneMessage, children }) => { +const PromptMessage: FunctionComponent = ({ promptState, confirmMessage, doneMessage, loadingMessage, children }) => { + if (promptState === 'ask') { return ( @@ -119,6 +141,15 @@ const PromptMessage: FunctionComponent = ({ promptState, con ); } + if (promptState === 'loading') { + return ( + + + {loadingMessage} + + ); + } + if (promptState === 'done' && doneMessage) { return {doneMessage}; } diff --git a/packages/insomnia/src/ui/components/modals/git-branches-modal.tsx b/packages/insomnia/src/ui/components/modals/git-branches-modal.tsx index 093ce178b76..19d12b7064d 100644 --- a/packages/insomnia/src/ui/components/modals/git-branches-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-branches-modal.tsx @@ -1,11 +1,14 @@ -import React, { type FC, useEffect } from 'react'; +import React, { type FC, useEffect, useState } from 'react'; import { Button, Dialog, GridList, GridListItem, Heading, Input, Label, Modal, ModalOverlay, TextField } from 'react-aria-components'; -import { useFetcher, useParams } from 'react-router-dom'; +import { useFetcher, useParams, useRevalidator } from 'react-router-dom'; -import type { CreateNewGitBranchResult, GitBranchesLoaderData } from '../../routes/git-actions'; +import { MergeConflictError } from '../../../sync/git/git-vcs'; +import type { MergeConflict } from '../../../sync/types'; +import { checkGitCanPush, continueMerge, type CreateNewGitBranchResult, type GitBranchesLoaderData, type GitChangesLoaderData, mergeGitBranch } from '../../routes/git-actions'; import { PromptButton } from '../base/prompt-button'; import { Icon } from '../icon'; -import { showAlert } from '.'; +import { showAlert, showModal } from '.'; +import { SyncMergeModal } from './sync-merge-modal'; const LocalBranchItem = ({ branch, @@ -13,12 +16,14 @@ const LocalBranchItem = ({ organizationId, projectId, workspaceId, + hasUncommittedChanges, }: { branch: string; isCurrent: boolean; organizationId: string; projectId: string; workspaceId: string; + hasUncommittedChanges: boolean; }) => { const checkoutBranchFetcher = useFetcher<{} | { error: string }>(); const mergeBranchFetcher = useFetcher(); @@ -54,62 +59,123 @@ const LocalBranchItem = ({ } }, [deleteBranchFetcher.data, deleteBranchFetcher.state]); + const [errMsg, setErrMsg] = useState(''); + + const { revalidate } = useRevalidator(); + return ( -
- {branch} {isCurrent ? '*' : ''} -
- {branch !== 'master' && ( - deleteBranchFetcher.submit( - { +
+
+ {branch} {isCurrent ? '*' : ''} +
+ {branch !== 'master' && ( + { + setErrMsg(''); + deleteBranchFetcher.submit( + { + branch, + }, + { + method: 'POST', + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/branch/delete`, + }, + ); + }} + > + + Delete + + )} + + { + setErrMsg(''); + try { + if (hasUncommittedChanges) { + throw new Error('There are uncommitted changes on current branch. Please commit them first.'); + } + try { + await mergeGitBranch({ + theirsBranch: branch, + workspaceId, + allowUncommittedChangesBeforeMerge: true, + }); + checkGitCanPush(workspaceId); + revalidate(); + } catch (err) { + if (err instanceof MergeConflictError) { + const data = err.data; + await new Promise((resolve, reject) => { + showModal(SyncMergeModal, { + conflicts: data.conflicts, + labels: data.labels, + handleDone: (conflicts?: MergeConflict[]) => { + if (Array.isArray(conflicts) && conflicts.length > 0) { + continueMerge({ + handledMergeConflicts: conflicts, + commitMessage: data.commitMessage, + commitParent: data.commitParent, + }).then( + resolve, + reject, + ).finally(() => { + checkGitCanPush(workspaceId); + revalidate(); + }); + } else { + // user aborted merge + reject(new Error('You aborted the merge, no changes were made to working tree.')); + } + }, + }); + }); + } else { + throw new Error(`Merge failed: ${err.message}`); + } + } + } catch (err) { + setErrMsg(err.message); + throw err; + } + }} > - - Delete + + Merge - )} - - mergeBranchFetcher.submit({ - branch, - }, { - method: 'POST', - action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/branch/merge`, - })} - > - - Merge - +
+ {errMsg && ( +
+ {errMsg} +
+ )}
); }; @@ -200,6 +266,21 @@ export const GitBranchesModal: FC = (({ const createNewBranchError = createBranchFetcher.data?.errors && createBranchFetcher.data.errors.length > 0 ? createBranchFetcher.data.errors[0] : null; + const gitChangesFetcher = useFetcher(); + useEffect(() => { + if (gitChangesFetcher.state === 'idle' && !gitChangesFetcher.data) { + // file://./../../routes/git-actions.tsx#gitChangesLoader + gitChangesFetcher.load(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/changes`); + } + }, [organizationId, projectId, workspaceId, gitChangesFetcher]); + + const hasUncommittedChanges = Boolean( + gitChangesFetcher.data?.changes && ( + gitChangesFetcher.data.changes.staged.length > 0 || + gitChangesFetcher.data.changes.unstaged.length > 0 + ) + ); + return ( = (({ textValue={item.name} className="p-2 w-full focus:outline-none focus:bg-[--hl-sm] transition-colors" > - + )} diff --git a/packages/insomnia/src/ui/components/modals/git-staging-modal.tsx b/packages/insomnia/src/ui/components/modals/git-staging-modal.tsx index f00436791f4..e1e3d67be1e 100644 --- a/packages/insomnia/src/ui/components/modals/git-staging-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-staging-modal.tsx @@ -3,7 +3,7 @@ import React, { type FC, useEffect } from 'react'; import { Button, Dialog, GridList, GridListItem, Heading, Label, Modal, ModalOverlay, TextArea, TextField, Tooltip, TooltipTrigger } from 'react-aria-components'; import { useFetcher, useParams } from 'react-router-dom'; -import type { CommitToGitRepoResult, GitChangesLoaderData, GitDiffResult } from '../../routes/git-actions'; +import type { GitChangesLoaderData, GitDiffResult } from '../../routes/git-actions'; import { Icon } from '../icon'; import { showAlert } from '.'; @@ -74,10 +74,7 @@ export const GitStagingModal: FC<{ onClose: () => void }> = ({ workspaceId: string; }; const gitChangesFetcher = useFetcher(); - const gitCommitFetcher = useFetcher(); - const rollbackFetcher = useFetcher<{ - errors?: string[]; - }>(); + const stageChangesFetcher = useFetcher<{ errors?: string[]; }>(); @@ -163,21 +160,11 @@ export const GitStagingModal: FC<{ onClose: () => void }> = ({ }; const { Form, formAction, state, data } = useFetcher<{ errors?: string[] }>(); - const errors = gitCommitFetcher.data?.errors || rollbackFetcher.data?.errors; const isCreatingSnapshot = state === 'loading' && formAction === '/organization/:organizationId/project/:projectId/workspace/:workspaceId/git/commit'; const isPushing = state === 'loading' && formAction === '/organization/:organizationId/project/:projectId/workspace/:workspaceId/git/commit-and-push'; const previewDiffItem = diffChangesFetcher.data && 'diff' in diffChangesFetcher.data ? diffChangesFetcher.data.diff : null; - useEffect(() => { - if (errors && errors?.length > 0) { - showAlert({ - title: 'Push Failed', - message: errors.join('\n'), - }); - } - }, [errors]); - const allChanges = [...changes.staged, ...changes.unstaged]; const allChangesLength = allChanges.length; const noCommitErrors = data && 'errors' in data && data.errors?.length === 0; diff --git a/packages/insomnia/src/ui/index.tsx b/packages/insomnia/src/ui/index.tsx index b96b315313e..db4ec3a7dac 100644 --- a/packages/insomnia/src/ui/index.tsx +++ b/packages/insomnia/src/ui/index.tsx @@ -961,11 +961,6 @@ async function renderApp() { action: async (...args) => (await import('./routes/git-actions')).checkoutGitBranchAction(...args), }, - { - path: 'merge', - action: async (...args) => - (await import('./routes/git-actions')).mergeGitBranchAction(...args), - }, ], }, ], diff --git a/packages/insomnia/src/ui/routes/git-actions.tsx b/packages/insomnia/src/ui/routes/git-actions.tsx index f609e8982a6..d9a3ad075d4 100644 --- a/packages/insomnia/src/ui/routes/git-actions.tsx +++ b/packages/insomnia/src/ui/routes/git-actions.tsx @@ -1067,66 +1067,50 @@ export const checkoutGitBranchAction: ActionFunction = async ({ return {}; }; -export interface MergeGitBranchResult { - errors?: string[]; -} - -export const mergeGitBranchAction: ActionFunction = async ({ - request, - params, -}): Promise => { - const { workspaceId } = params; - invariant(workspaceId, 'Workspace ID is required'); - - const workspace = await models.workspace.getById(workspaceId); - - invariant(workspace, 'Workspace not found'); - - const workspaceMeta = await models.workspaceMeta.getByParentId(workspaceId); - - const repoId = workspaceMeta?.gitRepositoryId; - - invariant(repoId, 'Workspace is not linked to a git repository'); - - const repo = await models.gitRepository.getById(repoId); - invariant(repo, 'Git Repository not found'); +export const mergeGitBranch = async ({ + theirsBranch, + workspaceId, + allowUncommittedChangesBeforeMerge = false, +}: { + theirsBranch: string; + workspaceId: string; + allowUncommittedChangesBeforeMerge?: boolean; +}) => { + const { + providerName, + } = await getGitRepoInfo(workspaceId); + invariant(typeof theirsBranch === 'string', 'Branch name is required'); - const formData = await request.formData(); - const branch = formData.get('branch'); - invariant(typeof branch === 'string', 'Branch name is required'); + const bufferId = await database.bufferChanges(); try { - const providerName = getOauth2FormatName(repo?.credentials); - await GitVCS.merge(branch); - // Apparently merge doesn't update the working dir so need to checkout too - const bufferId = await database.bufferChanges(); - - await GitVCS.checkout(branch); - window.main.trackSegmentEvent({ - event: SegmentEvent.vcsAction, properties: { - ...vcsSegmentEventProperties('git', 'checkout_branch'), - providerName, - }, + await GitVCS.merge({ + theirsBranch, + allowUncommittedChangesBeforeMerge, }); - await database.flushChanges(bufferId, true); + // isomorphic-git does not update the working area after merge, we need to do it manually by checking out the current branch + const currentBranch = await GitVCS.getCurrentBranch(); + await GitVCS.checkout(currentBranch); window.main.trackSegmentEvent({ event: SegmentEvent.vcsAction, properties: { ...vcsSegmentEventProperties('git', 'merge_branch'), providerName, }, }); - checkGitCanPush(workspaceId); } catch (err) { if (err instanceof Errors.HttpError) { - return { - errors: [`${err.message}, ${err.data.response}`], - }; + err = new Error(`${err.message}, ${err.data.response}`); } - const errorMessage = err instanceof Error ? err.message : 'Unknown error'; - return { errors: [errorMessage] }; - } + const errorMessage = err instanceof Error ? err.message : 'Unknown Error'; + window.main.trackSegmentEvent({ + event: + SegmentEvent.vcsAction, properties: + vcsSegmentEventProperties('git', 'merge_branch', errorMessage), + }); - return {}; + throw err; + } + await database.flushChanges(bufferId, true); }; export interface DeleteGitBranchResult { @@ -1267,24 +1251,29 @@ export const pushToGitRemoteAction: ActionFunction = async ({ return {}; }; -export const pullFromGitRemote = async (workspaceId: string) => { +async function getGitRepoInfo(workspaceId: string) { invariant(workspaceId, 'Workspace ID is required'); const workspace = await models.workspace.getById(workspaceId); invariant(workspace, 'Workspace not found'); - const workspaceMeta = await models.workspaceMeta.getByParentId(workspaceId); - const repoId = workspaceMeta?.gitRepositoryId; - invariant(repoId, 'Workspace is not linked to a git repository'); - const gitRepository = await models.gitRepository.getById(repoId); - invariant(gitRepository, 'Git Repository not found'); + const providerName = getOauth2FormatName(gitRepository.credentials); + return { + gitRepository, + providerName, + }; +} - const bufferId = await database.bufferChanges(); +export const pullFromGitRemote = async (workspaceId: string) => { + const { + gitRepository, + providerName, + } = await getGitRepoInfo(workspaceId); - const providerName = getOauth2FormatName(gitRepository.credentials); + const bufferId = await database.bufferChanges(); try { await GitVCS.pull(gitRepository.credentials); @@ -1322,9 +1311,6 @@ export const continueMerge = async ( commitParent: string[]; } ) => { - // filter in conflicts that user has chosen to keep 'theirs' - handledMergeConflicts = handledMergeConflicts.filter(conflict => conflict.choose !== conflict.mineBlob); - const bufferId = await database.bufferChanges(); await GitVCS.continueMerge({