Skip to content

Commit

Permalink
Support for resolving conflicts when pulling from remote git branch. [I…
Browse files Browse the repository at this point in the history
…NS-4550] (#8118)

Support for resolving conflicts when pulling from remote git branch.
  • Loading branch information
yaoweiprc authored Nov 5, 2024
1 parent 67fa4a1 commit c385971
Show file tree
Hide file tree
Showing 13 changed files with 327 additions and 87 deletions.
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"inso",
"libcurl",
"svgr",
"unstage",
"Unstaged",
"xmark"
],
}
199 changes: 198 additions & 1 deletion packages/insomnia/src/sync/git/git-vcs.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
});
}

Expand Down Expand Up @@ -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();
3 changes: 3 additions & 0 deletions packages/insomnia/src/sync/git/mem-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ interface FSDir {

type FSEntry = FSDir | FSFile | FSLink;

/**
* In-memory file system client
*/
export class MemClient {
__fs: FSEntry;
__ino: 0;
Expand Down
6 changes: 6 additions & 0 deletions packages/insomnia/src/sync/git/ne-db-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit c385971

Please sign in to comment.