Support for resolving conflicts when pulling from remote git branch. [INS-4550] (#8118)

Support for resolving conflicts when pulling from remote git branch.
This commit is contained in:
yaoweiprc 2024-11-05 15:38:54 +08:00 committed by GitHub
parent 67fa4a1b96
commit c38597187e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 327 additions and 87 deletions

View File

@ -21,6 +21,8 @@
"inso",
"libcurl",
"svgr",
"unstage",
"Unstaged",
"xmark"
],
}

View File

@ -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<typeof git.Errors.MergeConflictError>,
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();

View File

@ -33,6 +33,9 @@ interface FSDir {
type FSEntry = FSDir | FSFile | FSLink;
/**
* In-memory file system client
*/
export class MemClient {
__fs: FSEntry;
__ino: 0;

View File

@ -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,

View File

@ -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<Props> = ({ gitRepository, isInsomniaSyncEnable
const [isGitStagingModalOpen, setIsGitStagingModalOpen] = useState(false);
const gitPushFetcher = useFetcher<PushToGitRemoteResult>();
const gitPullFetcher = useFetcher<PullFromGitRemoteResult>();
const gitCheckoutFetcher = useFetcher();
const gitRepoDataFetcher = useFetcher<GitRepoLoaderData>();
const gitFetchFetcher = useFetcher<GitFetchLoaderData>();
const gitStatusFetcher = useFetcher<GitStatusResult>();
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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ gitRepository, isInsomniaSyncEnable
isActive: branch === currentBranch,
icon: 'code-branch',
action: async () => {
// file://./../../routes/git-actions.tsx#gitCheckoutAction
gitCheckoutFetcher.submit(
{
branch,

View File

@ -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<void>;
bodyClassName?: string;
}
export interface AlertModalHandle {
show: (options: AlertModalOptions) => void;
@ -23,29 +25,39 @@ export const AlertModal = forwardRef<AlertModalHandle, ModalProps>((_, 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 (
<Modal ref={modalRef}>
<ModalHeader>{title || 'Uh Oh!'}</ModalHeader>
<ModalBody className="wide pad">{message}</ModalBody>
<ModalBody
className={classnames([
'wide',
'pad',
bodyClassName,
])}
>
{message}
</ModalBody>
<ModalFooter>
<div>
{addCancel ? (

View File

@ -81,12 +81,15 @@ const LocalBranchItem = ({
<Button
className="px-4 py-1 font-semibold border border-solid border-[--hl-md] flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
isDisabled={isCurrent}
onPress={() => checkoutBranchFetcher.submit({
branch,
}, {
method: 'POST',
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/branch/checkout`,
})}
onPress={() => {
// file://./../../routes/git-actions.tsx#checkoutGitBranchAction
checkoutBranchFetcher.submit({
branch,
}, {
method: 'POST',
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/branch/checkout`,
});
}}
>
<Icon icon={checkoutBranchFetcher.state !== 'idle' ? 'spinner' : 'turn-up'} className={`w-5 ${checkoutBranchFetcher.state !== 'idle' ? 'animate-spin' : 'rotate-90'}`} />
Checkout

View File

@ -23,6 +23,7 @@ export const GitLogModal: FC<Props> = ({ onClose }) => {
useEffect(() => {
if (gitLogFetcher.state === 'idle' && !gitLogFetcher.data) {
// file://./../../routes/git-actions.tsx#gitLogLoader
gitLogFetcher.load(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/log`);
}
}, [organizationId, projectId, workspaceId, gitLogFetcher]);

View File

@ -47,6 +47,7 @@ export const GitRepositoryCloneModal = (props: ModalProps) => {
...credentials,
},
{
// file://./../../../routes/git-actions.tsx#cloneGitRepoAction
action: `/organization/${organizationId}/project/${projectId}/git/clone`,
method: 'post',
}

View File

@ -146,6 +146,7 @@ export const GitStagingModal: FC<{ onClose: () => void }> = ({
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]);

View File

@ -1,5 +1,5 @@
import { Differ, Viewer } from 'json-diff-kit';
import React, { forwardRef, useImperativeHandle, useState } from 'react';
import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
import { Button, Dialog, Form, GridList, GridListItem, Heading, Modal, ModalOverlay, Radio, RadioGroup } from 'react-aria-components';
import type { MergeConflict } from '../../../sync/types';
@ -33,12 +33,17 @@ export const SyncMergeModal = forwardRef<SyncMergeModalHandle>((_, ref) => {
labels: { ours: '', theirs: '' },
});
useImperativeHandle(ref, () => ({
hide: () => setState({
const reset = useCallback(() => {
setState({
conflicts: [],
isOpen: false,
labels: { ours: '', theirs: '' },
}),
});
setSelectedConflict(null);
}, []);
useImperativeHandle(ref, () => ({
hide: reset,
show: ({ conflicts, labels, handleDone }) => {
setState({
conflicts,
@ -46,12 +51,14 @@ export const SyncMergeModal = forwardRef<SyncMergeModalHandle>((_, ref) => {
isOpen: true,
labels,
});
// select the first conflict by default
setSelectedConflict(conflicts?.[0] || null);
window.main.trackSegmentEvent({
event: SegmentEvent.syncConflictResolutionStart,
});
},
}), []);
}), [reset]);
const { conflicts, handleDone } = state;
@ -61,11 +68,7 @@ export const SyncMergeModal = forwardRef<SyncMergeModalHandle>((_, ref) => {
<ModalOverlay
isOpen={state.isOpen}
onOpenChange={isOpen => {
!isOpen && setState({
conflicts: [],
isOpen: false,
labels: { ours: '', theirs: '' },
});
!isOpen && reset();
!isOpen && handleDone?.();
}}
@ -74,11 +77,7 @@ export const SyncMergeModal = forwardRef<SyncMergeModalHandle>((_, ref) => {
>
<Modal
onOpenChange={isOpen => {
!isOpen && setState({
conflicts: [],
isOpen: false,
labels: { ours: '', theirs: '' },
});
!isOpen && reset();
!isOpen && handleDone?.();
}}
@ -116,11 +115,7 @@ export const SyncMergeModal = forwardRef<SyncMergeModalHandle>((_, ref) => {
});
}
setState({
conflicts: [],
isOpen: false,
labels: { ours: '', theirs: '' },
});
reset();
}}
>
<div className='grid [grid-template-columns:300px_1fr] h-full overflow-hidden divide-x divide-solid divide-[--hl-md] gap-2'>

View File

@ -877,13 +877,6 @@ async function renderApp() {
path: 'changes',
loader: async (...args) =>
(await import('./routes/git-actions')).gitChangesLoader(...args),
shouldRevalidate: ({ formAction }) => {
if (formAction?.includes('git')) {
return true;
}
// disable revalidation for this loader, we will fetch this loader periodically through fetcher.load in component
return false;
},
},
{
path: 'log',
@ -925,11 +918,6 @@ async function renderApp() {
action: async (...args) =>
(await import('./routes/git-actions')).resetGitRepoAction(...args),
},
{
path: 'pull',
action: async (...args) =>
(await import('./routes/git-actions')).pullFromGitRemoteAction(...args),
},
{
path: 'push',
action: async (...args) =>

View File

@ -27,6 +27,7 @@ import { shallowClone } from '../../sync/git/shallow-clone';
import {
getOauth2FormatName,
} from '../../sync/git/utils';
import type { MergeConflict } from '../../sync/types';
import { invariant } from '../../utils/invariant';
import {
SegmentEvent,
@ -1266,14 +1267,7 @@ export const pushToGitRemoteAction: ActionFunction = async ({
return {};
};
export interface PullFromGitRemoteResult {
errors?: string[];
}
export const pullFromGitRemoteAction: ActionFunction = async ({
params,
}): Promise<PullFromGitRemoteResult> => {
const { workspaceId } = params;
export const pullFromGitRemote = async (workspaceId: string) => {
invariant(workspaceId, 'Workspace ID is required');
const workspace = await models.workspace.getById(workspaceId);
invariant(workspace, 'Workspace not found');
@ -1292,16 +1286,6 @@ export const pullFromGitRemoteAction: ActionFunction = async ({
const providerName = getOauth2FormatName(gitRepository.credentials);
try {
await GitVCS.fetch({
singleBranch: true,
depth: 1,
credentials: gitRepository?.credentials,
});
} catch (e) {
console.warn('Error fetching from remote', e);
}
try {
await GitVCS.pull(gitRepository.credentials);
window.main.trackSegmentEvent({
@ -1312,7 +1296,7 @@ export const pullFromGitRemoteAction: ActionFunction = async ({
});
} catch (err: unknown) {
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';
window.main.trackSegmentEvent({
@ -1321,14 +1305,35 @@ export const pullFromGitRemoteAction: ActionFunction = async ({
vcsSegmentEventProperties('git', 'pull', errorMessage),
});
return {
errors: [`${errorMessage}`],
};
throw err;
}
await database.flushChanges(bufferId);
};
return {};
export const continueMerge = async (
{
handledMergeConflicts,
commitMessage,
commitParent,
}: {
handledMergeConflicts: MergeConflict[];
commitMessage: string;
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({
handledMergeConflicts,
commitMessage,
commitParent,
});
await database.flushChanges(bufferId);
};
export interface GitChange {