diff --git a/.vscode/settings.json b/.vscode/settings.json index 30fabc7dc..9f7c05bfe 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,6 +21,8 @@ "inso", "libcurl", "svgr", + "unstage", + "Unstaged", "xmark" ], } diff --git a/packages/insomnia/src/sync/git/git-vcs.ts b/packages/insomnia/src/sync/git/git-vcs.ts index 06ed8e0d9..c99af640b 100644 --- a/packages/insomnia/src/sync/git/git-vcs.ts +++ b/packages/insomnia/src/sync/git/git-vcs.ts @@ -1,7 +1,8 @@ import * as git from 'isomorphic-git'; import path from 'path'; -import { parse } from 'yaml'; +import { parse, stringify } from 'yaml'; +import type { MergeConflict } from '../types'; import { httpClient } from './http-client'; import { convertToPosixSep } from './path-sep'; import { gitCallbacks } from './utils'; @@ -624,12 +625,186 @@ export class GitVCS { } async pull(gitCredentials?: GitCredentials | null) { + const changes = await this.status(); + const hasUncommittedChanges = changes.staged.length > 0 || changes.unstaged.length > 0; + if (hasUncommittedChanges) { + throw new Error('Cannot pull with uncommitted changes, please commit local changes first.'); + } console.log('[git] Pull remote=origin', await this.getCurrentBranch()); return git.pull({ ...this._baseOpts, ...gitCallbacks(gitCredentials), remote: 'origin', singleBranch: true, + }).catch( + async err => { + if (err instanceof git.Errors.MergeConflictError) { + const oursBranch = await this.getCurrentBranch(); + const theirsBranch = `origin/${oursBranch}`; + + return await this._collectMergeConflicts( + err, + oursBranch, + theirsBranch, + gitCredentials + ); + } else { + throw err; + } + }, + ); + } + + async _collectMergeConflicts( + mergeConflictError: InstanceType, + oursBranch: string, + theirsBranch: string, + gitCredentials?: GitCredentials | null, + ) { + const { + filepaths, bothModified, deleteByUs, deleteByTheirs, + } = mergeConflictError.data; + if (filepaths.length) { + const mergeConflicts: MergeConflict[] = []; + const conflictPathsObj = { + bothModified, + deleteByUs, + deleteByTheirs, + }; + const conflictTypeList: (keyof typeof conflictPathsObj)[] = [ + 'bothModified', + 'deleteByUs', + 'deleteByTheirs', + ]; + + const oursHeadCommitOid = await git.resolveRef({ + ...this._baseOpts, + ...gitCallbacks(gitCredentials), + ref: oursBranch, + }); + + const theirsHeadCommitOid = await git.resolveRef({ + ...this._baseOpts, + ...gitCallbacks(gitCredentials), + ref: theirsBranch, + }); + + const _baseOpts = this._baseOpts; + + function readBlob(filepath: string, oid: string) { + return git.readBlob({ + ..._baseOpts, + ...gitCallbacks(gitCredentials), + oid, + filepath, + }).then( + ({ blob, oid: blobId }) => ({ + blobContent: parse(Buffer.from(blob).toString('utf8')), + blobId, + }) + ); + } + + function readOursBlob(filepath: string) { + return readBlob(filepath, oursHeadCommitOid); + } + + function readTheirsBlob(filepath: string) { + return readBlob(filepath, theirsHeadCommitOid); + } + + for (const conflictType of conflictTypeList) { + const conflictPaths = conflictPathsObj[conflictType]; + const message = { + 'bothModified': 'both modified', + 'deleteByUs': 'you deleted and they modified', + 'deleteByTheirs': 'they deleted and you modified', + }[conflictType]; + for (const conflictPath of conflictPaths) { + let mineBlobContent = null; + let mineBlobId = null; + + let theirsBlobContent = null; + let theirsBlobId = null; + + if (conflictType !== 'deleteByUs') { + const { + blobContent, + blobId, + } = await readOursBlob(conflictPath); + mineBlobContent = blobContent; + mineBlobId = blobId; + } + + if (conflictType !== 'deleteByTheirs') { + const { + blobContent, + blobId, + } = await readTheirsBlob(conflictPath); + theirsBlobContent = blobContent; + theirsBlobId = blobId; + } + const name = mineBlobContent?.name || theirsBlobContent?.name || ''; + + mergeConflicts.push({ + key: conflictPath, + name, + message, + mineBlob: mineBlobId, + theirsBlob: theirsBlobId, + choose: mineBlobId || theirsBlobId, + mineBlobContent, + theirsBlobContent, + }); + } + } + + throw new MergeConflictError('Need to solve merge conflicts first', { + conflicts: mergeConflicts, + labels: { + ours: `${oursBranch} ${oursHeadCommitOid}`, + theirs: `${theirsBranch} ${theirsHeadCommitOid}`, + }, + commitMessage: `Merge branch '${theirsBranch}' into ${oursBranch}`, + commitParent: [oursHeadCommitOid, theirsHeadCommitOid], + }); + + } else { + throw new Error('Merge conflict filepaths is of length 0'); + } + } + + // create a commit after resolving merge conflicts + async continueMerge({ + handledMergeConflicts, + commitMessage, + commitParent, + }: { + gitCredentials?: GitCredentials | null; + handledMergeConflicts: MergeConflict[]; + commitMessage: string; + commitParent: string[]; + }) { + console.log('[git] continue to merge after resolving merge conflicts', await this.getCurrentBranch()); + for (const conflict of handledMergeConflicts) { + assertIsPromiseFsClient(this._baseOpts.fs); + if (conflict.theirsBlobContent) { + await this._baseOpts.fs.promises.writeFile( + conflict.key, + stringify(conflict.theirsBlobContent), + ); + await git.add({ ...this._baseOpts, filepath: conflict.key }); + } else { + await this._baseOpts.fs.promises.unlink( + conflict.key, + ); + await git.remove({ ...this._baseOpts, filepath: conflict.key }); + } + } + await git.commit({ + ...this._baseOpts, + message: commitMessage, + parent: commitParent, }); } @@ -811,5 +986,27 @@ export class GitVCS { return newBranches; } } +export class MergeConflictError extends Error { + constructor(msg: string, data: { + conflicts: MergeConflict[]; + labels: { + ours: string; + theirs: string; + }; + commitMessage: string; + commitParent: string[]; + }) { + super(msg); + this.data = data; + } + data; + name = 'MergeConflictError'; +} + +function assertIsPromiseFsClient(fs: git.FsClient): asserts fs is git.PromiseFsClient { + if (!('promises' in fs)) { + throw new Error('Expected fs to be of PromiseFsClient'); + } +} export default new GitVCS(); diff --git a/packages/insomnia/src/sync/git/mem-client.ts b/packages/insomnia/src/sync/git/mem-client.ts index 41c505965..443628612 100644 --- a/packages/insomnia/src/sync/git/mem-client.ts +++ b/packages/insomnia/src/sync/git/mem-client.ts @@ -33,6 +33,9 @@ interface FSDir { type FSEntry = FSDir | FSFile | FSLink; +/** + * In-memory file system client + */ export class MemClient { __fs: FSEntry; __ino: 0; diff --git a/packages/insomnia/src/sync/git/ne-db-client.ts b/packages/insomnia/src/sync/git/ne-db-client.ts index 7c2b7e0c4..4f7f0588e 100644 --- a/packages/insomnia/src/sync/git/ne-db-client.ts +++ b/packages/insomnia/src/sync/git/ne-db-client.ts @@ -12,6 +12,11 @@ import parseGitPath from './parse-git-path'; import Stat from './stat'; import { SystemError } from './system-error'; +/** + * A fs client to access workspace data stored in NeDB as files. + * Used by isomorphic-git + * https://isomorphic-git.org/docs/en/fs#implementing-your-own-fs + */ export class NeDBClient { _workspaceId: string; _projectId: string; @@ -138,6 +143,7 @@ export class NeDBClient { if (root === null && id === null && type === null) { otherFolders = [GIT_INSOMNIA_DIR_NAME]; } else if (id === null && type === null) { + // TODO: It doesn't scale if we add another model which can be sync in the future otherFolders = [ models.workspace.type, models.environment.type, diff --git a/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx index f16006f09..bdec0b0cc 100644 --- a/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx @@ -6,22 +6,26 @@ import { useInterval } from 'react-use'; import type { GitRepository } from '../../../models/git-repository'; import { deleteGitRepository } from '../../../models/helpers/git-repository-operations'; +import { MergeConflictError } from '../../../sync/git/git-vcs'; import { getOauth2FormatName } from '../../../sync/git/utils'; +import type { MergeConflict } from '../../../sync/types'; import { checkGitCanPush, checkGitChanges, + continueMerge, type GitFetchLoaderData, type GitRepoLoaderData, type GitStatusResult, - type PullFromGitRemoteResult, + pullFromGitRemote, type PushToGitRemoteResult, } from '../../routes/git-actions'; import { Icon } from '../icon'; -import { showAlert } from '../modals'; +import { showAlert, showModal } from '../modals'; import { GitBranchesModal } from '../modals/git-branches-modal'; import { GitLogModal } from '../modals/git-log-modal'; import { GitRepositorySettingsModal } from '../modals/git-repository-settings-modal'; import { GitStagingModal } from '../modals/git-staging-modal'; +import { SyncMergeModal } from '../modals/sync-merge-modal'; interface Props { gitRepository: GitRepository | null; @@ -42,17 +46,17 @@ export const GitSyncDropdown: FC = ({ gitRepository, isInsomniaSyncEnable const [isGitStagingModalOpen, setIsGitStagingModalOpen] = useState(false); const gitPushFetcher = useFetcher(); - const gitPullFetcher = useFetcher(); const gitCheckoutFetcher = useFetcher(); const gitRepoDataFetcher = useFetcher(); const gitFetchFetcher = useFetcher(); const gitStatusFetcher = useFetcher(); const loadingPush = gitPushFetcher.state === 'loading'; - const loadingPull = gitPullFetcher.state === 'loading'; const loadingFetch = gitFetchFetcher.state === 'loading'; const loadingStatus = gitStatusFetcher.state === 'loading'; + const [isPulling, setIsPulling] = useState(false); + useEffect(() => { if ( gitRepository?.uri && @@ -60,6 +64,7 @@ export const GitSyncDropdown: FC = ({ gitRepository, isInsomniaSyncEnable gitRepoDataFetcher.state === 'idle' && !gitRepoDataFetcher.data ) { + // file://./../../routes/git-actions.tsx#gitRepoLoader gitRepoDataFetcher.load(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/repo`); } }, [ @@ -76,6 +81,7 @@ export const GitSyncDropdown: FC = ({ gitRepository, isInsomniaSyncEnable useEffect(() => { if (shouldFetchGitRepoStatus) { + // file://./../../routes/git-actions.tsx#gitStatusAction gitStatusFetcher.submit({}, { action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/status`, method: 'post', @@ -122,16 +128,6 @@ export const GitSyncDropdown: FC = ({ gitRepository, isInsomniaSyncEnable } }, [gitRepoDataFetcher.data]); - useEffect(() => { - const errors = [...(gitPullFetcher.data?.errors ?? [])]; - if (errors.length > 0) { - showAlert({ - title: 'Pull Failed', - message: errors.join('\n'), - }); - } - }, [gitPullFetcher.data?.errors]); - useEffect(() => { const errors = [...(gitCheckoutFetcher.data?.errors ?? [])]; if (errors.length > 0) { @@ -168,7 +164,7 @@ export const GitSyncDropdown: FC = ({ gitRepository, isInsomniaSyncEnable gitFetchFetcher.state === 'loading' || gitCheckoutFetcher.state === 'loading' || gitPushFetcher.state === 'loading' || - gitPullFetcher.state === 'loading'; + isPulling; const isSynced = Boolean(gitRepository?.uri && gitRepoDataFetcher.data && !('errors' in gitRepoDataFetcher.data)); @@ -195,17 +191,46 @@ export const GitSyncDropdown: FC = ({ gitRepository, isInsomniaSyncEnable }, { id: 'pull', - icon: loadingPull ? 'refresh' : 'cloud-download', + icon: isPulling ? 'refresh' : 'cloud-download', label: 'Pull', isDisabled: false, action: async () => { - gitPullFetcher.submit( - {}, - { - action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/pull`, - method: 'post', + try { + setIsPulling(true); + await pullFromGitRemote(workspaceId).finally(() => { + setIsPulling(false); + revalidate(); + }); + } catch (err) { + if (err instanceof MergeConflictError) { + const data = err.data; + showModal(SyncMergeModal, { + conflicts: data.conflicts, + labels: data.labels, + handleDone: (conflicts?: MergeConflict[]) => { + if (Array.isArray(conflicts) && conflicts.length > 0) { + setIsPulling(true); + continueMerge({ + handledMergeConflicts: conflicts, + commitMessage: data.commitMessage, + commitParent: data.commitParent, + }).finally(() => { + setIsPulling(false); + revalidate(); + }); + } else { + // user aborted merge, do nothing + } + }, + }); + } else { + showAlert({ + title: 'Pull Failed', + message: err.message, + bodyClassName: 'whitespace-break-spaces', + }); } - ); + } }, }, { @@ -313,6 +338,7 @@ export const GitSyncDropdown: FC = ({ gitRepository, isInsomniaSyncEnable isActive: branch === currentBranch, icon: 'code-branch', action: async () => { + // file://./../../routes/git-actions.tsx#gitCheckoutAction gitCheckoutFetcher.submit( { branch, diff --git a/packages/insomnia/src/ui/components/modals/alert-modal.tsx b/packages/insomnia/src/ui/components/modals/alert-modal.tsx index 093c017e3..7e23aee91 100644 --- a/packages/insomnia/src/ui/components/modals/alert-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/alert-modal.tsx @@ -1,3 +1,4 @@ +import classnames from 'classnames'; import React, { forwardRef, type ReactNode, useImperativeHandle, useRef, useState } from 'react'; import { Modal, type ModalHandle, type ModalProps } from '../base/modal'; @@ -11,6 +12,7 @@ export interface AlertModalOptions { addCancel?: boolean; okLabel?: React.ReactNode; onConfirm?: () => void | Promise; + bodyClassName?: string; } export interface AlertModalHandle { show: (options: AlertModalOptions) => void; @@ -23,29 +25,39 @@ export const AlertModal = forwardRef((_, ref) => { message: '', addCancel: false, okLabel: '', + bodyClassName: '', }); useImperativeHandle(ref, () => ({ hide: () => { modalRef.current?.hide(); }, - show: ({ title, message, addCancel, onConfirm, okLabel }) => { + show: ({ title, message, addCancel, onConfirm, okLabel, bodyClassName = '' }) => { setState({ title, message, addCancel, okLabel, onConfirm, + bodyClassName, }); modalRef.current?.show(); }, }), []); - const { message, title, addCancel, okLabel } = state; + const { message, title, addCancel, okLabel, bodyClassName } = state; return ( {title || 'Uh Oh!'} - {message} + + {message} +
{addCancel ? ( 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 8caa92207..093ce178b 100644 --- a/packages/insomnia/src/ui/components/modals/git-branches-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-branches-modal.tsx @@ -81,12 +81,15 @@ const LocalBranchItem = ({