Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@
"default": true,
"description": "Automatically strip outputs from cells with conflicts"
},
"mergeNB.autoResolve.whitespace": {
"type": "boolean",
"default": true,
"description": "Automatically resolve conflicts when source differences are whitespace-only"
},
"mergeNB.ui.showCellHeaders": {
"type": "boolean",
"default": false,
Expand All @@ -96,6 +101,11 @@
"type": "boolean",
"default": true,
"description": "Enable Ctrl+Z / Ctrl+Shift+Z undo and redo shortcuts in the conflict resolution web UI"
},
"mergeNB.ui.showBaseColumn": {
"type": "boolean",
"default": false,
"description": "Show the base (common ancestor) branch column in the conflict resolution view. When false, only current and incoming columns are shown."
}
}
}
Expand Down Expand Up @@ -151,4 +161,4 @@
"markdown-it": "^14.1.0",
"ws": "^8.19.0"
}
}
}
43 changes: 43 additions & 0 deletions src/conflictDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ import { matchCells, detectReordering } from './cellMatcher';
import { parseNotebook } from './notebookParser';
import { getSettings, MergeNBSettings } from './settings';

function stripAllWhitespace(text: string): string {
return text.replace(/\r\n/g, '\n');
}

function isWhitespaceOnlyDifference(left: string, right: string): boolean {
if (left === right) return false;
const normalizeLines = (s: string) =>
s.replace(/\r\n/g, '\n').split('\n').map(l => l.trimEnd()).join('\n');
return normalizeLines(left) === normalizeLines(right);
}

/**
* Result of auto-resolution preprocessing
*/
Expand Down Expand Up @@ -373,6 +384,38 @@ export function applyAutoResolutions(
}
}

// Auto-resolve whitespace-only differences when enabled
if (!autoResolved && effectiveSettings.autoResolveWhitespace) {
if (conflict.type === 'cell-modified') {
const currentSource = conflict.currentContent?.source;
const incomingSource = conflict.incomingContent?.source;

const currentSourceStr = Array.isArray(currentSource) ? currentSource.join('') : (currentSource || '');
const incomingSourceStr = Array.isArray(incomingSource) ? incomingSource.join('') : (incomingSource || '');

if (isWhitespaceOnlyDifference(currentSourceStr, incomingSourceStr)) {
autoResolved = true;
autoResolvedCount++;
autoResolvedDescriptions.push(`Whitespace-only change resolved (cell ${(conflict.currentCellIndex ?? 0) + 1})`);
}
}

if (!autoResolved && conflict.type === 'cell-added' && conflict.currentContent && conflict.incomingContent) {
const currentSource = Array.isArray(conflict.currentContent.source)
? conflict.currentContent.source.join('')
: conflict.currentContent.source;
const incomingSource = Array.isArray(conflict.incomingContent.source)
? conflict.incomingContent.source.join('')
: conflict.incomingContent.source;

if (isWhitespaceOnlyDifference(currentSource, incomingSource)) {
autoResolved = true;
autoResolvedCount++;
autoResolvedDescriptions.push(`Whitespace-only added cell resolved (cell ${(conflict.currentCellIndex ?? 0) + 1})`);
}
}
}

if (!autoResolved) {
remainingConflicts.push(conflict);
}
Expand Down
43 changes: 22 additions & 21 deletions src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* 6. Stages the resolved file in Git
*/

import * as path from 'path';
import * as path from 'path';
import * as vscode from 'vscode';
import { detectSemanticConflicts, applyAutoResolutions, AutoResolveResult } from './conflictDetector';
import { serializeNotebook, renumberExecutionCounts } from './notebookParser';
Expand Down Expand Up @@ -56,7 +56,7 @@ export interface ConflictedNotebook {
* Main service for handling notebook merge conflict resolution.
*/
export class NotebookConflictResolver {
constructor(private readonly extensionUri: vscode.Uri) {}
constructor(private readonly extensionUri: vscode.Uri) { }

/**
* Check if a file has semantic conflicts (Git UU status).
Expand All @@ -75,7 +75,7 @@ export class NotebookConflictResolver {
async hasAnyConflicts(uri: vscode.Uri): Promise<ConflictedNotebook | null> {
try {
const isUnmerged = await gitIntegration.isUnmergedFile(uri.fsPath);

if (isUnmerged) {
return {
uri,
Expand All @@ -94,24 +94,24 @@ export class NotebookConflictResolver {
*/
async findNotebooksWithConflicts(): Promise<ConflictedNotebook[]> {
const withConflicts: ConflictedNotebook[] = [];

// Get unmerged files from Git status
const unmergedFiles = await gitIntegration.getUnmergedFiles();

for (const file of unmergedFiles) {
// Only process .ipynb files
if (!file.path.endsWith('.ipynb')) {
continue;
}

const uri = vscode.Uri.file(file.path);

withConflicts.push({
uri,
hasSemanticConflicts: true
});
}

return withConflicts;
}

Expand All @@ -134,7 +134,7 @@ export class NotebookConflictResolver {
*/
async resolveSemanticConflicts(uri: vscode.Uri): Promise<void> {
const semanticConflict = await detectSemanticConflicts(uri.fsPath);

if (!semanticConflict) {
vscode.window.showInformationMessage('No semantic conflicts detected.');
return;
Expand Down Expand Up @@ -197,7 +197,8 @@ export class NotebookConflictResolver {
semanticConflict: filteredSemanticConflict,
autoResolveResult: autoResolveResult,
hideNonConflictOutputs: settings.hideNonConflictOutputs,
enableUndoRedoHotkeys: settings.enableUndoRedoHotkeys
enableUndoRedoHotkeys: settings.enableUndoRedoHotkeys,
showBaseColumn: settings.showBaseColumn
};

const resolutionCallback = async (resolution: UnifiedResolution): Promise<void> => {
Expand Down Expand Up @@ -227,12 +228,12 @@ export class NotebookConflictResolver {
}

const resolvedRows = resolution.resolvedRows;

if (!resolvedRows || resolvedRows.length === 0) {
// No resolutions provided
if (autoResolveResult) {
let resolvedNotebook = autoResolveResult.resolvedNotebook;

const renumber = await vscode.window.showQuickPick(
['Yes', 'No'],
{
Expand All @@ -259,9 +260,9 @@ export class NotebookConflictResolver {
}

await this.applySemanticResolutionsFromRows(
uri,
semanticConflict,
resolvedRows,
uri,
semanticConflict,
resolvedRows,
resolution.markAsResolved,
resolution.renumberExecutionCounts,
autoResolveResult
Expand Down Expand Up @@ -320,7 +321,7 @@ export class NotebookConflictResolver {

for (const row of rowsForResolution) {
const { baseCell, currentCell, incomingCell, resolution: res } = row;

let cellToUse: NotebookCell | undefined;

if (res) {
Expand Down Expand Up @@ -352,7 +353,7 @@ export class NotebookConflictResolver {
metadata: referenceCell?.metadata ? JSON.parse(JSON.stringify(referenceCell.metadata)) : {},
source: resolvedContent.split(/(?<=\n)/)
} as NotebookCell;

if (cellType === 'code') {
(cellToUse as any).execution_count = null;
(cellToUse as any).outputs = [];
Expand Down Expand Up @@ -394,7 +395,7 @@ export class NotebookConflictResolver {
markAsResolved,
renumberExecutionCounts: shouldRenumber
});

// Show success notification (non-blocking, fire and forget)
vscode.window.showInformationMessage(
`Resolved conflicts in ${path.basename(uri.fsPath)}`
Expand All @@ -408,12 +409,12 @@ export class NotebookConflictResolver {
const content = serializeNotebook(notebook);
const encoder = new TextEncoder();
await vscode.workspace.fs.writeFile(uri, encoder.encode(content));

// Mark as resolved with git add if requested
if (markAsResolved) {
await this.markFileAsResolved(uri);
}

// Fire event to notify extension (for status bar, decorations, etc.)
onDidResolveConflict.fire(uri);
}
Expand All @@ -433,7 +434,7 @@ export class NotebookConflictResolver {
vscode.window.showWarningMessage('Cannot mark file as resolved: not in a workspace');
return;
}

const relativePath = vscode.workspace.asRelativePath(uri, false);
await exec(`git add "${relativePath}"`, { cwd: workspaceFolder.uri.fsPath });
vscode.window.showInformationMessage(`Marked ${relativePath} as resolved`);
Expand Down
14 changes: 11 additions & 3 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
* - autoResolveExecutionCount: Set execution_count to null (default: true)
* - autoResolveKernelVersion: Use current kernel/Python version (default: true)
* - stripOutputs: Clear cell outputs during merge (default: true)
* - autoResolveWhitespace: Auto-resolve whitespace-only source diffs (default: true)
* - hideNonConflictOutputs: Hide outputs for non-conflicted cells in UI (default: true)
* - enableUndoRedoHotkeys: Enable Ctrl+Z / Ctrl+Shift+Z in web UI (default: true)
* - showBaseColumn: Show base branch column in 3-column view (default: false, true in headless/testing)
*
* These reduce manual conflict resolution for common non-semantic differences.
*/
Expand All @@ -24,17 +26,21 @@ export interface MergeNBSettings {
autoResolveExecutionCount: boolean;
autoResolveKernelVersion: boolean;
stripOutputs: boolean;
autoResolveWhitespace: boolean;
hideNonConflictOutputs: boolean;
enableUndoRedoHotkeys: boolean;
showBaseColumn: boolean;
}

/** Default settings used in headless mode */
const DEFAULT_SETTINGS: MergeNBSettings = {
autoResolveExecutionCount: true,
autoResolveKernelVersion: true,
stripOutputs: true,
autoResolveWhitespace: true,
hideNonConflictOutputs: true,
enableUndoRedoHotkeys: true
enableUndoRedoHotkeys: true,
showBaseColumn: true
};

/**
Expand All @@ -45,15 +51,17 @@ export function getSettings(): MergeNBSettings {
if (!vscode) {
return { ...DEFAULT_SETTINGS };
}

const config = vscode.workspace.getConfiguration('mergeNB');

return {
autoResolveExecutionCount: config.get<boolean>('autoResolve.executionCount', true),
autoResolveKernelVersion: config.get<boolean>('autoResolve.kernelVersion', true),
stripOutputs: config.get<boolean>('autoResolve.stripOutputs', true),
autoResolveWhitespace: config.get<boolean>('autoResolve.whitespace', true),
hideNonConflictOutputs: config.get<boolean>('ui.hideNonConflictOutputs', true),
enableUndoRedoHotkeys: config.get<boolean>('ui.enableUndoRedoHotkeys', true),
showBaseColumn: config.get<boolean>('ui.showBaseColumn', false),
};
}

Expand Down
7 changes: 7 additions & 0 deletions src/tests/repoSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ export function createMergeConflictRepo(
): string {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mergeNB-integration-'));

const vscodeDir = path.join(tmpDir, '.vscode');
fs.mkdirSync(vscodeDir, { recursive: true });
const settingsPath = path.join(vscodeDir, 'settings.json');
fs.writeFileSync(settingsPath, JSON.stringify({
'mergeNB.ui.showBaseColumn': true
}, null, 4));

git(tmpDir, 'init');
git(tmpDir, 'config', 'user.email', '"test@mergenb.test"');
git(tmpDir, 'config', 'user.name', '"MergeNB Test"');
Expand Down
Loading