Skip to content

Commit b4876a4

Browse files
authored
Merge pull request #1013 from MLH-Fellowship/feat/diff-merge-conflicts
Diff and resolve files on merge conflict
2 parents 0907a0d + 8f2b16d commit b4876a4

File tree

17 files changed

+571
-157
lines changed

17 files changed

+571
-157
lines changed

jupyterlab_git/git.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import pexpect
1515
import tornado
1616
import tornado.locks
17-
from nbdime import diff_notebooks
17+
from nbdime import diff_notebooks, merge_notebooks
1818
from jupyter_server.utils import ensure_async
1919

2020
from .log import get_logger
@@ -320,14 +320,20 @@ async def fetch(self, path):
320320

321321
return result
322322

323-
async def get_nbdiff(self, prev_content: str, curr_content: str) -> dict:
323+
async def get_nbdiff(
324+
self, prev_content: str, curr_content: str, base_content=None
325+
) -> dict:
324326
"""Compute the diff between two notebooks.
325327
326328
Args:
327329
prev_content: Notebook previous content
328330
curr_content: Notebook current content
331+
base_content: Notebook base content - only passed during a merge conflict
329332
Returns:
330-
{"base": Dict, "diff": Dict}
333+
if not base_content:
334+
{"base": Dict, "diff": Dict}
335+
else:
336+
{"base": Dict, "merge_decisions": Dict}
331337
"""
332338

333339
def read_notebook(content):
@@ -345,14 +351,35 @@ def read_notebook(content):
345351
else:
346352
return nbformat.reads(content, as_version=4)
347353

354+
# TODO Fix this in nbdime
355+
def remove_cell_ids(nb):
356+
for cell in nb.cells:
357+
del cell["id"]
358+
return nb
359+
348360
current_loop = tornado.ioloop.IOLoop.current()
349361
prev_nb = await current_loop.run_in_executor(None, read_notebook, prev_content)
350362
curr_nb = await current_loop.run_in_executor(None, read_notebook, curr_content)
351-
thediff = await current_loop.run_in_executor(
352-
None, diff_notebooks, prev_nb, curr_nb
353-
)
363+
if base_content:
364+
base_nb = await current_loop.run_in_executor(
365+
None, read_notebook, base_content
366+
)
367+
# Only remove ids from merge_notebooks as a workaround
368+
_, merge_decisions = await current_loop.run_in_executor(
369+
None,
370+
merge_notebooks,
371+
remove_cell_ids(base_nb),
372+
remove_cell_ids(prev_nb),
373+
remove_cell_ids(curr_nb),
374+
)
375+
376+
return {"base": base_nb, "merge_decisions": merge_decisions}
377+
else:
378+
thediff = await current_loop.run_in_executor(
379+
None, diff_notebooks, prev_nb, curr_nb
380+
)
354381

355-
return {"base": prev_nb, "diff": thediff}
382+
return {"base": prev_nb, "diff": thediff}
356383

357384
async def status(self, path):
358385
"""

jupyterlab_git/handlers.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -705,7 +705,10 @@ async def post(self):
705705
status_code=400, reason=f"Missing POST key: {e}"
706706
)
707707
try:
708-
content = await self.git.get_nbdiff(prev_content, curr_content)
708+
base_content = data.get("baseContent")
709+
content = await self.git.get_nbdiff(
710+
prev_content, curr_content, base_content
711+
)
709712
except Exception as e:
710713
get_logger().error(f"Error computing notebook diff.", exc_info=e)
711714
raise tornado.web.HTTPError(

src/commandsAndMenu.tsx

Lines changed: 108 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export function addCommands(
104104
settings: ISettingRegistry.ISettings,
105105
trans: TranslationBundle
106106
): void {
107-
const { commands, shell } = app;
107+
const { commands, shell, serviceManager } = app;
108108

109109
/**
110110
* Commit using a keystroke combination when in CommitBox.
@@ -419,16 +419,17 @@ export function addCommands(
419419
/**
420420
* Git display diff command - internal command
421421
*
422-
* @params model {Git.Diff.IModel<string>}: The diff model to display
422+
* @params model {Git.Diff.IModel: The diff model to display
423423
* @params isText {boolean}: Optional, whether the content is a plain text
424+
* @params isMerge {boolean}: Optional, whether the diff is a merge conflict
424425
* @returns the main area widget or null
425426
*/
426427
commands.addCommand(CommandIDs.gitShowDiff, {
427428
label: trans.__('Show Diff'),
428429
caption: trans.__('Display a file diff.'),
429430
execute: async args => {
430431
const { model, isText } = args as any as {
431-
model: Git.Diff.IModel<string>;
432+
model: Git.Diff.IModel;
432433
isText?: boolean;
433434
};
434435

@@ -470,26 +471,67 @@ export function addCommands(
470471

471472
diffWidget.toolbar.addItem('spacer', Toolbar.createSpacerItem());
472473

473-
const refreshButton = new ToolbarButton({
474-
label: trans.__('Refresh'),
475-
onClick: async () => {
476-
await widget.refresh();
477-
refreshButton.hide();
478-
},
479-
tooltip: trans.__('Refresh diff widget'),
480-
className: 'jp-git-diff-refresh'
481-
});
482-
refreshButton.hide();
483-
diffWidget.toolbar.addItem('refresh', refreshButton);
484-
485-
const refresh = () => {
486-
refreshButton.show();
487-
};
474+
// Do not allow the user to refresh during merge conflicts
475+
if (model.hasConflict) {
476+
const resolveButton = new ToolbarButton({
477+
label: trans.__('Mark as resolved'),
478+
onClick: async () => {
479+
if (!widget.isFileResolved) {
480+
const result = await showDialog({
481+
title: trans.__('Resolve with conflicts'),
482+
body: trans.__(
483+
'Are you sure you want to mark this file as resolved with merge conflicts?'
484+
)
485+
});
486+
487+
// Bail early if the user wants to finish resolving conflicts
488+
if (!result.button.accept) {
489+
return;
490+
}
491+
}
492+
493+
try {
494+
await serviceManager.contents.save(
495+
model.filename,
496+
await widget.getResolvedFile()
497+
);
498+
await gitModel.add(model.filename);
499+
await gitModel.refresh();
500+
} catch (reason) {
501+
logger.log({
502+
message: reason.message ?? reason,
503+
level: Level.ERROR
504+
});
505+
} finally {
506+
diffWidget.dispose();
507+
}
508+
},
509+
tooltip: trans.__('Mark file as resolved'),
510+
className: 'jp-git-diff-resolve'
511+
});
512+
513+
diffWidget.toolbar.addItem('resolve', resolveButton);
514+
} else {
515+
const refreshButton = new ToolbarButton({
516+
label: trans.__('Refresh'),
517+
onClick: async () => {
518+
await widget.refresh();
519+
refreshButton.hide();
520+
},
521+
tooltip: trans.__('Refresh diff widget'),
522+
className: 'jp-git-diff-refresh'
523+
});
524+
525+
refreshButton.hide();
526+
diffWidget.toolbar.addItem('refresh', refreshButton);
527+
528+
const refresh = () => {
529+
refreshButton.show();
530+
};
488531

489-
model.changed.connect(refresh);
490-
widget.disposed.connect(() => {
491-
model.changed.disconnect(refresh);
492-
});
532+
model.changed.connect(refresh);
533+
widget.disposed.connect(() => model.changed.disconnect(refresh));
534+
}
493535

494536
// Load the diff widget
495537
modelIsLoading.resolve();
@@ -569,25 +611,29 @@ export function addCommands(
569611

570612
const repositoryPath = gitModel.getRelativeFilePath();
571613
const filename = PathExt.join(repositoryPath, filePath);
572-
573-
let diffContext = context;
574-
if (!diffContext) {
575-
const specialRef =
576-
status === 'staged'
577-
? Git.Diff.SpecialRef.INDEX
578-
: Git.Diff.SpecialRef.WORKING;
579-
diffContext = {
580-
currentRef: specialRef,
581-
previousRef: 'HEAD'
582-
};
583-
}
614+
const specialRef =
615+
status === 'staged'
616+
? Git.Diff.SpecialRef.INDEX
617+
: Git.Diff.SpecialRef.WORKING;
618+
619+
const diffContext: Git.Diff.IContext =
620+
status === 'unmerged'
621+
? {
622+
currentRef: 'HEAD',
623+
previousRef: 'MERGE_HEAD',
624+
baseRef: 'ORIG_HEAD'
625+
}
626+
: context ?? {
627+
currentRef: specialRef,
628+
previousRef: 'HEAD'
629+
};
584630

585631
const challengerRef = Git.Diff.SpecialRef[diffContext.currentRef as any]
586632
? { special: Git.Diff.SpecialRef[diffContext.currentRef as any] }
587633
: { git: diffContext.currentRef };
588634

589-
// Create the diff widget
590-
const model = new DiffModel<string>({
635+
// Base props used for Diff Model
636+
const props: Omit<Git.Diff.IModel, 'changed' | 'hasConflict'> = {
591637
challenger: {
592638
content: async () => {
593639
return requestAPI<Git.IDiffContent>(
@@ -623,7 +669,32 @@ export function addCommands(
623669
source: diffContext.previousRef,
624670
updateAt: Date.now()
625671
}
626-
});
672+
};
673+
674+
if (diffContext.baseRef) {
675+
props.reference.label = trans.__('CURRENT');
676+
props.challenger.label = trans.__('INCOMING');
677+
678+
// Only add base when diff-ing merge conflicts
679+
props.base = {
680+
content: async () => {
681+
return requestAPI<Git.IDiffContent>(
682+
URLExt.join(repositoryPath, 'content'),
683+
'POST',
684+
{
685+
filename: filePath,
686+
reference: { git: diffContext.baseRef }
687+
}
688+
).then(data => data.content);
689+
},
690+
label: trans.__('RESULT'),
691+
source: diffContext.baseRef,
692+
updateAt: Date.now()
693+
};
694+
}
695+
696+
// Create the diff widget
697+
const model = new DiffModel(props);
627698

628699
const widget = await commands.execute(CommandIDs.gitShowDiff, {
629700
model,

src/components/FileItem.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,10 @@ export class FileItem extends React.PureComponent<IFileItemProps> {
173173
render(): JSX.Element {
174174
const { file } = this.props;
175175
const status_code = file.status === 'staged' ? file.x : file.y;
176-
const status = this._getFileChangedLabel(status_code as any);
176+
const status =
177+
file.status === 'unmerged'
178+
? 'Conflicted'
179+
: this._getFileChangedLabel(status_code as any);
177180

178181
return (
179182
<div
@@ -205,7 +208,11 @@ export class FileItem extends React.PureComponent<IFileItemProps> {
205208
/>
206209
{this.props.actions}
207210
<span className={this._getFileChangedLabelClass(this.props.file.y)}>
208-
{this.props.file.y === '?' ? 'U' : status_code}
211+
{this.props.file.status === 'unmerged'
212+
? '!'
213+
: this.props.file.y === '?'
214+
? 'U'
215+
: status_code}
209216
</span>
210217
</div>
211218
);

0 commit comments

Comments
 (0)