diff --git a/package.json b/package.json index 9e1d06ef64..555909cd86 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ }, { "command": "tfvc.Undo", - "when": "scmProvider == tfvc", + "when": "scmProvider == tfvc && scmResourceGroup != conflicts", "group": "1_modification@1" }, { @@ -90,9 +90,19 @@ "when": "scmProvider == tfvc && scmResourceGroup == included", "group": "1_modification@2" }, + { + "command": "tfvc.ResolveTakeTheirs", + "when": "scmProvider == tfvc && scmResourceGroup == conflicts", + "group": "1_modification@1" + }, + { + "command": "tfvc.ResolveKeepYours", + "when": "scmProvider == tfvc && scmResourceGroup == conflicts", + "group": "1_modification@2" + }, { "command": "tfvc.Undo", - "when": "scmProvider == tfvc", + "when": "scmProvider == tfvc && scmResourceGroup != conflicts", "group": "inline@1" }, { @@ -104,6 +114,16 @@ "command": "tfvc.Exclude", "when": "scmProvider == tfvc && scmResourceGroup == included", "group": "inline@2" + }, + { + "command": "tfvc.ResolveTakeTheirs", + "when": "scmProvider == tfvc && scmResourceGroup == conflicts", + "group": "inline@1" + }, + { + "command": "tfvc.ResolveKeepYours", + "when": "scmProvider == tfvc && scmResourceGroup == conflicts", + "group": "inline@2" } ] }, @@ -275,6 +295,24 @@ "dark": "resources/icons/dark/refresh.svg" } }, + { + "command": "tfvc.ResolveKeepYours", + "title": "Resolve: Keep Yours", + "category": "TFVC", + "icon": { + "light": "resources/icons/light/resolve-keepyours.svg", + "dark": "resources/icons/dark/resolve-keepyours.svg" + } + }, + { + "command": "tfvc.ResolveTakeTheirs", + "title": "Resolve: Take Theirs", + "category": "TFVC", + "icon": { + "light": "resources/icons/light/resolve-taketheirs.svg", + "dark": "resources/icons/dark/resolve-taketheirs.svg" + } + }, { "command": "tfvc.Sync", "title": "Sync", diff --git a/resources/icons/dark/resolve-keepyours.svg b/resources/icons/dark/resolve-keepyours.svg new file mode 100644 index 0000000000..d9be7332a6 --- /dev/null +++ b/resources/icons/dark/resolve-keepyours.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/resources/icons/dark/resolve-taketheirs.svg b/resources/icons/dark/resolve-taketheirs.svg new file mode 100644 index 0000000000..4903b53afb --- /dev/null +++ b/resources/icons/dark/resolve-taketheirs.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/resources/icons/light/resolve-keepyours.svg b/resources/icons/light/resolve-keepyours.svg new file mode 100644 index 0000000000..5bb18de3c1 --- /dev/null +++ b/resources/icons/light/resolve-keepyours.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/resources/icons/light/resolve-taketheirs.svg b/resources/icons/light/resolve-taketheirs.svg new file mode 100644 index 0000000000..724be9ef09 --- /dev/null +++ b/resources/icons/light/resolve-taketheirs.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 71e0b3edcb..f2457d3c1d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,6 +8,7 @@ import { commands, Disposable, ExtensionContext } from "vscode"; import { CommandNames, TfvcCommandNames } from "./helpers/constants"; import { ExtensionManager } from "./extensionmanager"; import { TfvcSCMProvider } from "./tfvc/tfvcscmprovider"; +import { AutoResolveType } from "./tfvc/interfaces"; let _extensionManager: ExtensionManager; let _scmProvider: TfvcSCMProvider; @@ -62,6 +63,12 @@ export async function activate(context: ExtensionContext) { })); context.subscriptions.push(commands.registerCommand(TfvcCommandNames.Refresh, () => _extensionManager.Tfvc.TfvcRefresh())); context.subscriptions.push(commands.registerCommand(TfvcCommandNames.ShowOutput, () => _extensionManager.Tfvc.TfvcShowOutput())); + context.subscriptions.push(commands.registerCommand(TfvcCommandNames.ResolveKeepYours, (...args) => { + _extensionManager.Tfvc.TfvcResolve(args ? args[0] : undefined, AutoResolveType.KeepYours); + })); + context.subscriptions.push(commands.registerCommand(TfvcCommandNames.ResolveTakeTheirs, (...args) => { + _extensionManager.Tfvc.TfvcResolve(args ? args[0] : undefined, AutoResolveType.TakeTheirs); + })); context.subscriptions.push(commands.registerCommand(TfvcCommandNames.Checkin, () => _extensionManager.Tfvc.TfvcCheckin())); context.subscriptions.push(commands.registerCommand(TfvcCommandNames.Sync, () => _extensionManager.Tfvc.TfvcSync())); } diff --git a/src/helpers/constants.ts b/src/helpers/constants.ts index 116b35ef86..68f69d5976 100644 --- a/src/helpers/constants.ts +++ b/src/helpers/constants.ts @@ -44,6 +44,8 @@ export class TfvcCommandNames { static OpenDiff: string = TfvcCommandNames.CommandPrefix + "OpenDiff"; static OpenFile: string = TfvcCommandNames.CommandPrefix + "OpenFile"; static Refresh: string = TfvcCommandNames.CommandPrefix + "Refresh"; + static ResolveKeepYours: string = TfvcCommandNames.CommandPrefix + "ResolveKeepYours"; + static ResolveTakeTheirs: string = TfvcCommandNames.CommandPrefix + "ResolveTakeTheirs"; static ShowOutput: string = TfvcCommandNames.CommandPrefix + "ShowOutput"; static Status: string = TfvcCommandNames.CommandPrefix + "Status"; static Sync: string = TfvcCommandNames.CommandPrefix + "Sync"; diff --git a/src/helpers/strings.ts b/src/helpers/strings.ts index d8dec0ce31..82ab6f534f 100644 --- a/src/helpers/strings.ts +++ b/src/helpers/strings.ts @@ -67,6 +67,7 @@ export class Strings { static UndoChanges: string = "Undo Changes"; static NoChangesToCheckin: string = "There are no changes to checkin. Changes must be added to the 'Included' section to be checked in."; static AllFilesUpToDate: string = "All files are up to date."; + static CommandRequiresFileContext: string = "This command requires a file context and can only be executed from the TFVC viewlet window."; // TFVC viewlet Strings static ExcludedGroupName: string = "Excluded changes"; @@ -85,5 +86,14 @@ export class Strings { static ConflictAlreadyDeleted: string = "ALREADY DELETED"; static ConflictAlreadyExists: string = "ALREADY EXISTS"; static ConflictDeletedLocally: string = "DELETED LOCALLY"; + + // TFVC AutoResolveType Strings + static AutoResolveTypeAutoMerge: string = "Auto Merge"; + static AutoResolveTypeDeleteConflict: string = "Delete Conflict"; + static AutoResolveTypeKeepYours: string = "Keep Yours"; + static AutoResolveTypeKeepYoursRenameTheirs: string = "Keep Yours Rename Theirs"; + static AutoResolveTypeOverwriteLocal: string = "Overwrite Local"; + static AutoResolveTypeTakeTheirs: string = "Take Theirs"; + } /* tslint:enable:variable-name */ diff --git a/src/tfvc/commands/commandhelper.ts b/src/tfvc/commands/commandhelper.ts index a80854162f..2a352520b4 100644 --- a/src/tfvc/commands/commandhelper.ts +++ b/src/tfvc/commands/commandhelper.ts @@ -13,6 +13,12 @@ import { TfvcError, TfvcErrorCodes } from "../tfvcerror"; import { IExecutionResult } from "../interfaces"; export class CommandHelper { + public static RequireArgument(argument: any, argumentName: string) { + if (!argument) { + throw TfvcError.CreateArgumentMissingError(argumentName); + } + } + public static RequireStringArgument(argument: string, argumentName: string) { if (!argument || argument.trim().length === 0) { throw TfvcError.CreateArgumentMissingError(argumentName); diff --git a/src/tfvc/commands/resolveconflicts.ts b/src/tfvc/commands/resolveconflicts.ts new file mode 100644 index 0000000000..5dd6dbd94a --- /dev/null +++ b/src/tfvc/commands/resolveconflicts.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ +"use strict"; + +import { TeamServerContext} from "../../contexts/servercontext"; +import { AutoResolveType, IArgumentProvider, IExecutionResult, ITfvcCommand, IConflict } from "../interfaces"; +import { ConflictType } from "../scm/status"; +import { ArgumentBuilder } from "./argumentbuilder"; +import { CommandHelper } from "./commandhelper"; + +/** + * This command resolves conflicts based on given auto resolve type + * + * tf resolve [itemspec] + * [/auto:(AutoMerge|TakeTheirs|KeepYours|OverwriteLocal|DeleteConflict|KeepYoursRenameTheirs)] + * [/preview] [(/overridetype:overridetype | /converttotype:converttype] [/recursive] [/newname:path] [/noprompt] [/login:username, [password]] + */ +export class ResolveConflicts implements ITfvcCommand { + private _serverContext: TeamServerContext; + private _itemPaths: string[]; + private _autoResolveType: AutoResolveType; + + public constructor(serverContext: TeamServerContext, itemPaths: string[], autoResolveType: AutoResolveType) { + this._serverContext = serverContext; + CommandHelper.RequireStringArrayArgument(itemPaths, "itemPaths"); + CommandHelper.RequireArgument(autoResolveType, "autoResolveType"); + this._itemPaths = itemPaths; + this._autoResolveType = autoResolveType; + } + + public GetArguments(): IArgumentProvider { + const builder: ArgumentBuilder = new ArgumentBuilder("resolve", this._serverContext) + .AddAll(this._itemPaths) + .AddSwitchWithValue("auto", AutoResolveType[this._autoResolveType], false); + return builder; + } + + public GetOptions(): any { + return {}; + } + + /** + * Outputs the resolved conflicts in the following format: + * + * Resolved /Users/leantk/tfvc-tfs/tfsTest_01/TestAdd.txt as KeepYours + * Resolved /Users/leantk/tfvc-tfs/tfsTest_01/addFold/testHere2 as KeepYours + */ + public async ParseOutput(executionResult: IExecutionResult): Promise { + CommandHelper.ProcessErrors(this.GetArguments().GetCommand(), executionResult); + + let conflicts: IConflict[] = []; + const lines: string[] = CommandHelper.SplitIntoLines(executionResult.stdout, true, true); + for (let i: number = 0; i < lines.length; i++) { + const line: string = lines[i]; + const startIndex: number = line.indexOf("Resolved "); + const endIndex: number = line.lastIndexOf(" as "); + if (startIndex >= 0 && endIndex > startIndex) { + conflicts.push({ + localPath: line.slice(startIndex + "Resolved ".length, endIndex), + type: ConflictType.RESOLVED + }); + } + } + + return conflicts; + } +} diff --git a/src/tfvc/interfaces.ts b/src/tfvc/interfaces.ts index 5e8e2ff3cd..1bdc764e59 100644 --- a/src/tfvc/interfaces.ts +++ b/src/tfvc/interfaces.ts @@ -89,6 +89,15 @@ export interface IConflict { type: ConflictType; } +export enum AutoResolveType { + AutoMerge, + TakeTheirs, + KeepYours, + OverwriteLocal, + DeleteConflict, + KeepYoursRenameTheirs +} + export interface IExecutionResult { exitCode: number; stdout: string; diff --git a/src/tfvc/repository.ts b/src/tfvc/repository.ts index ab17175f62..659d19e218 100644 --- a/src/tfvc/repository.ts +++ b/src/tfvc/repository.ts @@ -8,7 +8,7 @@ import { TeamServerContext} from "../contexts/servercontext"; import { Logger } from "../helpers/logger"; import { ITfvcCommand, IExecutionResult } from "./interfaces"; import { Tfvc } from "./tfvc"; -import { IArgumentProvider, IConflict, IItemInfo, IPendingChange, ISyncResults, IWorkspace } from "./interfaces"; +import { AutoResolveType, IArgumentProvider, IConflict, IItemInfo, IPendingChange, ISyncResults, IWorkspace } from "./interfaces"; import { Add } from "./commands/add"; import { Checkin } from "./commands/checkin"; import { FindConflicts } from "./commands/findconflicts"; @@ -16,6 +16,7 @@ import { FindWorkspace } from "./commands/findworkspace"; import { GetInfo } from "./commands/getinfo"; import { GetFileContent } from "./commands/getfilecontent"; import { GetVersion } from "./commands/getversion"; +import { ResolveConflicts } from "./commands/resolveconflicts"; import { Status } from "./commands/status"; import { Sync } from "./commands/sync"; import { Undo } from "./commands/undo"; @@ -96,6 +97,12 @@ export class Repository { new Status(this._serverContext, ignoreFiles === undefined ? true : ignoreFiles)); } + public async ResolveConflicts(itemPaths: string[], autoResolveType: AutoResolveType): Promise { + Logger.LogDebug(`TFVC Repository.ResolveConflicts`); + return this.RunCommand( + new ResolveConflicts(this._serverContext, itemPaths, autoResolveType)); + } + public async Sync(itemPaths: string[], recursive: boolean): Promise { Logger.LogDebug(`TFVC Repository.Sync`); return this.RunCommand( diff --git a/src/tfvc/tfvc-extension.ts b/src/tfvc/tfvc-extension.ts index 4237c933bd..6d31f72865 100644 --- a/src/tfvc/tfvc-extension.ts +++ b/src/tfvc/tfvc-extension.ts @@ -19,7 +19,7 @@ import { TfvcSCMProvider } from "./tfvcscmprovider"; import { TfvcErrorCodes } from "./tfvcerror"; import { Repository } from "./repository"; import { UIHelper } from "./uihelper"; -import { ICheckinInfo, IItemInfo, IPendingChange, ISyncResults } from "./interfaces"; +import { AutoResolveType, ICheckinInfo, IItemInfo, IPendingChange, ISyncResults } from "./interfaces"; import { TfvcOutput } from "./tfvcoutput"; export class TfvcExtension { @@ -134,6 +134,23 @@ export class TfvcExtension { } } + public async TfvcResolve(uri: Uri, autoResolveType: AutoResolveType): Promise { + this.displayErrors(async () => { + if (uri) { + let localPath: string = TfvcSCMProvider.GetPathFromUri(uri); + const resolveTypeString: string = UIHelper.GetDisplayTextForAutoResolveType(autoResolveType); + const basename: string = path.basename(localPath); + const message: string = `Are you sure you want to resolve changes in ${basename} as ${resolveTypeString}?`; + if (await UIHelper.PromptForConfirmation(message, resolveTypeString)) { + await this._repo.ResolveConflicts([localPath], autoResolveType); + TfvcSCMProvider.Refresh(); + } + } else { + this._manager.DisplayWarningMessage(Strings.CommandRequiresFileContext); + } + }); + } + public async TfvcShowOutput(): Promise { TfvcOutput.Show(); } @@ -202,14 +219,10 @@ export class TfvcExtension { if (pathToUndo) { const basename: string = path.basename(pathToUndo); const message: string = `Are you sure you want to undo changes in ${basename}?`; - //TODO: use Modal api once vscode.d.ts exposes it (currently proposed) - const pick: string = await window.showWarningMessage(message, /*{ modal: true },*/ Strings.UndoChanges); - if (pick !== Strings.UndoChanges) { - return; + if (await UIHelper.PromptForConfirmation(message, Strings.UndoChanges)) { + this._manager.Telemetry.SendEvent(TfvcTelemetryEvents.Undo); + await this._repo.Undo([pathToUndo]); } - - this._manager.Telemetry.SendEvent(TfvcTelemetryEvents.Undo); - await this._repo.Undo([pathToUndo]); } } catch (err) { this._manager.DisplayErrorMessage(err.message); @@ -261,6 +274,19 @@ export class TfvcExtension { } } + private async displayErrors(funcToTry: () => Promise): Promise { + if (!this._manager.EnsureInitialized(RepositoryType.TFVC)) { + this._manager.DisplayErrorMessage(); + return; + } + + try { + await funcToTry(); + } catch (err) { + this._manager.DisplayErrorMessage(err.message); + } + } + public async InitializeClients(repoType: RepositoryType): Promise { //We only need to initialize for Tfvc repositories if (repoType !== RepositoryType.TFVC) { diff --git a/src/tfvc/uihelper.ts b/src/tfvc/uihelper.ts index 4979d9326c..49a80cf505 100644 --- a/src/tfvc/uihelper.ts +++ b/src/tfvc/uihelper.ts @@ -6,7 +6,7 @@ import { QuickPickItem, window, workspace } from "vscode"; import { Strings } from "../helpers/strings"; -import { IPendingChange, ISyncResults, ISyncItemResult, SyncType } from "./interfaces"; +import { AutoResolveType, IPendingChange, ISyncResults, ISyncItemResult, SyncType } from "./interfaces"; import { TfvcOutput } from "./tfvcoutput"; var path = require("path"); @@ -106,6 +106,18 @@ export class UIHelper { } } + public static GetDisplayTextForAutoResolveType(type: AutoResolveType): string { + switch (type) { + case AutoResolveType.AutoMerge: return Strings.AutoResolveTypeAutoMerge; + case AutoResolveType.DeleteConflict: return Strings.AutoResolveTypeDeleteConflict; + case AutoResolveType.KeepYours: return Strings.AutoResolveTypeKeepYours; + case AutoResolveType.KeepYoursRenameTheirs: return Strings.AutoResolveTypeKeepYoursRenameTheirs; + case AutoResolveType.OverwriteLocal: return Strings.AutoResolveTypeOverwriteLocal; + case AutoResolveType.TakeTheirs: return Strings.AutoResolveTypeTakeTheirs; + default: return Strings.AutoResolveTypeAutoMerge; + } + } + public static GetFileName(change: IPendingChange) { if (change && change.localItem) { var filename = path.parse(change.localItem).base; @@ -122,4 +134,11 @@ export class UIHelper { return change.localItem; } + + public static async PromptForConfirmation(message: string, okButtonText?: string): Promise { + okButtonText = okButtonText ? okButtonText : "OK"; + //TODO: use Modal api once vscode.d.ts exposes it (currently proposed) + const pick: string = await window.showWarningMessage(message, /*{ modal: true },*/ okButtonText); + return pick === okButtonText; + } } diff --git a/test/tfvc/commands/resolveconflicts.test.ts b/test/tfvc/commands/resolveconflicts.test.ts new file mode 100644 index 0000000000..83cc3704e1 --- /dev/null +++ b/test/tfvc/commands/resolveconflicts.test.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ +"use strict"; + +import { assert } from "chai"; +import { Strings } from "../../../src/helpers/strings"; +import { ResolveConflicts } from "../../../src/tfvc/commands/resolveconflicts"; +import { TfvcError } from "../../../src/tfvc/tfvcerror"; +import { AutoResolveType, IExecutionResult, IConflict } from "../../../src/tfvc/interfaces"; +import { ConflictType } from "../../../src/tfvc/scm/status"; +import { TeamServerContext } from "../../../src/contexts/servercontext"; +import { CredentialInfo } from "../../../src/info/credentialinfo"; +import { RepositoryInfo } from "../../../src/info/repositoryinfo"; + +describe("Tfvc-ResolveConflictsCommand", function() { + let serverUrl: string = "http://server:8080/tfs"; + let repoUrl: string = "http://server:8080/tfs/collection1/_git/repo1"; + let collectionUrl: string = "http://server:8080/tfs/collection1"; + let user: string = "user1"; + let pass: string = "pass1"; + let context: TeamServerContext; + + beforeEach(function() { + context = new TeamServerContext(repoUrl); + context.CredentialInfo = new CredentialInfo(user, pass); + context.RepoInfo = new RepositoryInfo({ + serverUrl: serverUrl, + collection: { + name: "collection1", + id: "" + }, + repository: { + remoteUrl: repoUrl, + id: "", + name: "", + project: { + name: "project1" + } + } + }); + }); + + it("should verify constructor", function() { + let localPaths: string[] = ["/usr/alias/repo1/file.txt"]; + new ResolveConflicts(undefined, localPaths, AutoResolveType.KeepYours); + }); + + it("should verify constructor with context", function() { + let localPaths: string[] = ["/usr/alias/repo1/file.txt"]; + new ResolveConflicts(context, localPaths, AutoResolveType.KeepYours); + }); + + it("should verify constructor - undefined args", function() { + assert.throws(() => new ResolveConflicts(undefined, undefined, undefined), TfvcError, /Argument is required/); + }); + + it("should verify arguments", function() { + let localPaths: string[] = ["/usr/alias/repo1/file.txt"]; + let cmd: ResolveConflicts = new ResolveConflicts(undefined, localPaths, AutoResolveType.KeepYours); + + assert.equal(cmd.GetArguments().GetArgumentsForDisplay(), "resolve -noprompt " + localPaths[0] + " -auto:KeepYours"); + }); + + it("should verify arguments with context", function() { + let localPaths: string[] = ["/usr/alias/repo1/file.txt"]; + let cmd: ResolveConflicts = new ResolveConflicts(context, localPaths, AutoResolveType.KeepYours); + + assert.equal(cmd.GetArguments().GetArgumentsForDisplay(), "resolve -noprompt -collection:" + collectionUrl + " ******** " + localPaths[0] + " -auto:KeepYours"); + }); + + it("should verify arguments with TakeTheirs", function() { + let localPaths: string[] = ["/usr/alias/repo1/file.txt"]; + let cmd: ResolveConflicts = new ResolveConflicts(context, localPaths, AutoResolveType.TakeTheirs); + + assert.equal(cmd.GetArguments().GetArgumentsForDisplay(), "resolve -noprompt -collection:" + collectionUrl + " ******** " + localPaths[0] + " -auto:TakeTheirs"); + }); + + it("should verify parse output - no output", async function() { + let localPaths: string[] = ["/usr/alias/repo1/file.txt"]; + let cmd: ResolveConflicts = new ResolveConflicts(undefined, localPaths, AutoResolveType.KeepYours); + let executionResult: IExecutionResult = { + exitCode: 0, + stdout: undefined, + stderr: undefined + }; + + let results: IConflict[] = await cmd.ParseOutput(executionResult); + assert.equal(results.length, 0); + }); + + it("should verify parse output - no errors", async function() { + let localPaths: string[] = ["/usr/alias/repo1/file.txt", "/usr/alias/repo1/file2.txt"]; + let cmd: ResolveConflicts = new ResolveConflicts(undefined, localPaths, AutoResolveType.KeepYours); + let executionResult: IExecutionResult = { + exitCode: 0, + stdout: "Resolved /usr/alias/repo1/file.txt as KeepYours\n" + + "Resolved /usr/alias/repo1/file2.txt as KeepYours", + stderr: undefined + }; + + let results: IConflict[] = await cmd.ParseOutput(executionResult); + assert.equal(results.length, 2); + assert.equal(results[0].localPath, "/usr/alias/repo1/file.txt"); + assert.equal(results[0].type, ConflictType.RESOLVED); + assert.equal(results[1].localPath, "/usr/alias/repo1/file2.txt"); + assert.equal(results[1].type, ConflictType.RESOLVED); + }); + + it("should verify parse output - errors - exit code 100", async function() { + let localPaths: string[] = ["/usr/alias/repo1/file.txt"]; + let cmd: ResolveConflicts = new ResolveConflicts(undefined, localPaths, AutoResolveType.KeepYours); + let executionResult: IExecutionResult = { + exitCode: 100, + stdout: "Something bad this way comes.", + stderr: undefined + }; + + try { + await cmd.ParseOutput(executionResult); + } catch (err) { + assert.equal(err.exitCode, 100); + assert.equal(err.tfvcCommand, "resolve"); + assert.equal(err.message.indexOf(Strings.TfExecFailedError), 0); + assert.equal(err.stdout.indexOf("Something bad this way comes."), 0); + } + }); +});