From fedad2f5d99e9569456e3eebd466c0185310b0b7 Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Mon, 1 May 2023 16:12:52 -0400 Subject: [PATCH 1/5] feat: Archive, Restore and Compare commands --- package.json | 52 ++++---- src/commands/archive.ts | 74 ++++++++++++ src/commands/compare.ts | 152 ++++++++++++++++++++++++ src/commands/generateOpenApi.ts | 28 +---- src/commands/restore.ts | 66 ++++++++++ src/commands/runCommand.ts | 42 +++++++ src/commands/validation.ts | 44 +++++++ src/extension.ts | 8 ++ src/services/appmapConfigManager.ts | 22 +++- src/services/nodeProcessService.ts | 1 - src/telemetry/definitions/errorCodes.ts | 4 + 11 files changed, 447 insertions(+), 46 deletions(-) create mode 100644 src/commands/archive.ts create mode 100644 src/commands/compare.ts create mode 100644 src/commands/restore.ts create mode 100644 src/commands/runCommand.ts create mode 100644 src/commands/validation.ts diff --git a/package.json b/package.json index e1de160e..be47cc33 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,38 @@ "command": "appmap.copyOutOfDateTestsToClipboard", "title": "AppMap: Copy Out-of-Date Tests to Clipboard" }, + { + "command": "appmap.generateOpenApi", + "title": "AppMap: Generate OpenAPI" + }, + { + "command": "appmap.sequenceDiagram", + "title": "AppMap: Generate Sequence Diagram" + }, + { + "command": "appmap.compareSequenceDiagrams", + "title": "AppMap: Compare Sequence Diagrams" + }, + { + "command": "appmap.login", + "title": "AppMap: Login" + }, + { + "command": "appmap.logout", + "title": "AppMap: Logout" + }, + { + "command": "appmap.archive", + "title": "AppMap: Archive Current AppMaps" + }, + { + "command": "appmap.restore", + "title": "AppMap: Restore AppMap Archive" + }, + { + "command": "appmap.compare", + "title": "AppMap: Compare AppMap Archives" + }, { "command": "appmap.context.openInFileExplorer", "title": "AppMap View: Open in File Explorer" @@ -169,26 +201,6 @@ { "command": "appmap.context.deleteAppMaps", "title": "AppMap View: Delete AppMaps" - }, - { - "command": "appmap.generateOpenApi", - "title": "AppMap: Generate OpenAPI" - }, - { - "command": "appmap.sequenceDiagram", - "title": "AppMap: Generate Sequence Diagram" - }, - { - "command": "appmap.compareSequenceDiagrams", - "title": "AppMap: Compare Sequence Diagrams" - }, - { - "command": "appmap.login", - "title": "AppMap: Login" - }, - { - "command": "appmap.logout", - "title": "AppMap: Logout" } ], "configuration": { diff --git a/src/commands/archive.ts b/src/commands/archive.ts new file mode 100644 index 00000000..cb2627bd --- /dev/null +++ b/src/commands/archive.ts @@ -0,0 +1,74 @@ +import * as vscode from 'vscode'; +import chooseWorkspace from '../lib/chooseWorkspace'; +import { ProgramName, getModulePath } from '../services/nodeDependencyProcess'; +import ErrorCode from '../telemetry/definitions/errorCodes'; +import { AppmapConfigManager, DEFAULT_APPMAP_DIR } from '../services/appmapConfigManager'; +import { promisify } from 'util'; +import { join } from 'path'; +import { glob } from 'glob'; +import { rm, stat } from 'fs/promises'; +import runCommand from './runCommand'; + +export const ArchiveAppMaps = 'appmap.archive'; + +export default function archive(context: vscode.ExtensionContext) { + const command = vscode.commands.registerCommand( + ArchiveAppMaps, + async (workspaceFolder?: vscode.WorkspaceFolder) => { + if (!workspaceFolder) workspaceFolder = await chooseWorkspace(); + if (!workspaceFolder) return; + + const config = await AppmapConfigManager.getAppMapConfig(workspaceFolder); + const cwd = config?.configFolder || workspaceFolder.uri.fsPath; + + const appmapDir = config?.appmapDir || DEFAULT_APPMAP_DIR; + + const appmapFiles = await promisify(glob)(join(cwd, appmapDir, '**/*.appmap.json')); + if (appmapFiles.length === 0) { + vscode.window.showInformationMessage( + `No AppMaps found in ${config?.appmapDir}. Record some AppMaps and then try this command again.` + ); + return; + } + + vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Archiving AppMaps' }, + async () => { + const mtime = new Date(); + + // For now, make every archive a 'full' archive + await rm(join(cwd, appmapDir, 'appmap_archive.json'), { + force: true, + }); + + if ( + !(await runCommand( + context, + ErrorCode.ArchiveAppMapsFailure, + `An error occurred while archiving AppMaps`, + ['archive'], + cwd + )) + ) + return; + + const archiveFiles = await promisify(glob)(join(cwd, '.appmap/archive/full/*.tar')); + const mTimes = new Map(); + await Promise.all( + archiveFiles.map(async (file) => mTimes.set(file, (await stat(file)).mtime)) + ); + + const newArchiveFiles = [...archiveFiles] + .filter((file) => mTimes.get(file)! > mtime) + .map((file) => file.slice(cwd.length + 1)); + + vscode.window.showInformationMessage( + `Created AppMap archive ${newArchiveFiles.join(', ')}` + ); + } + ); + } + ); + + context.subscriptions.push(command); +} diff --git a/src/commands/compare.ts b/src/commands/compare.ts new file mode 100644 index 00000000..58870218 --- /dev/null +++ b/src/commands/compare.ts @@ -0,0 +1,152 @@ +import * as vscode from 'vscode'; +import chooseWorkspace from '../lib/chooseWorkspace'; +import ErrorCode from '../telemetry/definitions/errorCodes'; +import { AppmapConfigManager, DEFAULT_APPMAP_DIR } from '../services/appmapConfigManager'; +import { join, relative } from 'path'; +import { rm, symlink, unlink } from 'fs/promises'; +import { listRevisions } from './validation'; +import assert from 'assert'; +import runCommand from './runCommand'; +import { existsSync } from 'fs'; + +export const ArchiveAppMaps = 'appmap.compare'; + +export default function compare(context: vscode.ExtensionContext) { + const command = vscode.commands.registerCommand( + ArchiveAppMaps, + async (workspaceFolder?: vscode.WorkspaceFolder) => { + if (!workspaceFolder) workspaceFolder = await chooseWorkspace(); + if (!workspaceFolder) return; + + const config = await AppmapConfigManager.getAppMapConfig(workspaceFolder); + const cwd = config?.configFolder || workspaceFolder.uri.fsPath; + const appmapDir = config?.appmapDir || DEFAULT_APPMAP_DIR; + + const revisions = await listRevisions(cwd); + if (!revisions) return; + + async function chooseRevision( + revisionName: string, + exclude: string[] = [], + extraRevisions: string[] = [] + ): Promise { + assert(revisions); + const options: vscode.QuickPickOptions = { + canPickMany: false, + placeHolder: `Choose the ${revisionName} revision`, + }; + const avaliableRevisions = revisions?.filter((rev) => !exclude.includes(rev)); + return await vscode.window.showQuickPick( + [...extraRevisions, ...avaliableRevisions], + options + ); + } + + const baseRevision = await chooseRevision('base'); + if (!baseRevision) return; + + const currentAppMapsName = `AppMaps in ${appmapDir}`; + const headRevision = await chooseRevision('head', [baseRevision], [currentAppMapsName]); + if (!headRevision) return; + + let headRevisionFolder: string; + + if (headRevision === currentAppMapsName) { + headRevisionFolder = 'head'; + } else { + headRevisionFolder = headRevision; + } + + const compareDir = join( + '.appmap', + 'change-report', + [baseRevision, headRevisionFolder].join('-') + ); + + vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Comparing AppMap revisions' }, + async (progress: vscode.Progress<{ message?: string; increment?: number }>) => { + progress.report({ message: 'Removing existing comparison dir (if any)' }); + + try { + await unlink(join(cwd, compareDir, 'head')); + } catch (e) { + console.debug( + `Failed to unlink ${join(cwd, compareDir, 'head')}. This is probably benign.` + ); + } + await rm(join(cwd, compareDir), { recursive: true, force: true }); + + for (const [revisionName, revision] of [ + ['base', baseRevision], + ['head', headRevision], + ]) { + progress.report({ message: `Preparing '${revisionName}' AppMap data` }); + + if (existsSync(join(compareDir, revisionName))) return; + + if (revision === currentAppMapsName) { + await symlink(relative(compareDir, appmapDir), join(cwd, compareDir, revisionName)); + } else { + await runCommand( + context, + ErrorCode.RestoreAppMapsFailure, + `An error occurred while restoring ${revisionName} revision ${revision}`, + [ + 'restore', + '-d', + cwd, + '--revision', + revision, + '--output-dir', + join(compareDir, revisionName), + ], + cwd + ); + } + } + + progress.report({ message: `Comparing 'base' and 'head' revisions` }); + const compareArgs = [ + 'compare', + '--no-delete-unchanged', + '-b', + baseRevision, + '-h', + headRevisionFolder, + ]; + if ( + !(await runCommand( + context, + ErrorCode.CompareAppMapsFailure, + 'An error occurred while comparing AppMaps', + compareArgs, + cwd + )) + ) + return; + + progress.report({ message: `Generating Markdown report` }); + if ( + !(await runCommand( + context, + ErrorCode.CompareReportAppMapsFailure, + 'An error occurred while generating comparison report', + ['compare-report', compareDir], + cwd + )) + ) + return; + + vscode.window.showInformationMessage( + `Comparison is available at ${compareDir}/report.md` + ); + + vscode.commands.executeCommand('vscode.open', `${compareDir}/report.md`); + } + ); + } + ); + + context.subscriptions.push(command); +} diff --git a/src/commands/generateOpenApi.ts b/src/commands/generateOpenApi.ts index 11feb097..4d7508b1 100644 --- a/src/commands/generateOpenApi.ts +++ b/src/commands/generateOpenApi.ts @@ -12,8 +12,7 @@ import { } from '../services/nodeDependencyProcess'; import { DEBUG_EXCEPTION, GENERATE_OPENAPI, Telemetry } from '../telemetry'; import ErrorCode from '../telemetry/definitions/errorCodes'; -import { AppmapConfigManager, AppmapConfigManagerInstance } from '../services/appmapConfigManager'; -import { workspaceServices } from '../services/workspaceServices'; +import { AppmapConfigManager, DEFAULT_APPMAP_DIR } from '../services/appmapConfigManager'; export const GenerateOpenApi = 'appmap.generateOpenApi'; @@ -40,40 +39,25 @@ export default function generateOpenApi( globalStoragePath: context.globalStorageUri.fsPath, }); - let appmapDir = '.'; - let cwd = workspaceFolder.uri.fsPath; - - const appmapConfigManagerInstance = workspaceServices().getServiceInstanceFromClass( - AppmapConfigManager, - workspaceFolder - ) as AppmapConfigManagerInstance | undefined; - assert(appmapConfigManagerInstance); - - const appmapConfig = await appmapConfigManagerInstance.getAppmapConfig(); - - if (appmapConfig) { - appmapDir = appmapConfig.appmapDir; - cwd = appmapConfig.configFolder; - } + const config = await AppmapConfigManager.getAppMapConfig(workspaceFolder); const openApiCmd = spawn({ modulePath, - args: ['openapi', '--appmap-dir', appmapDir], - cwd, + args: ['openapi', '--appmap-dir', config?.appmapDir || DEFAULT_APPMAP_DIR], + cwd: config?.configFolder || workspaceFolder.uri.fsPath, saveOutput: true, }); try { await verifyCommandOutput(openApiCmd); } catch (e) { + console.error(e); Telemetry.sendEvent(DEBUG_EXCEPTION, { exception: e as Error, errorCode: ErrorCode.GenerateOpenApiFailure, log: openApiCmd.log.toString(), }); - vscode.window.showWarningMessage( - 'Failed to generate OpenAPI definitions. Please try again later.' - ); + vscode.window.showWarningMessage(`An error occurred generating OpenAPI`); return; } diff --git a/src/commands/restore.ts b/src/commands/restore.ts new file mode 100644 index 00000000..f6107f03 --- /dev/null +++ b/src/commands/restore.ts @@ -0,0 +1,66 @@ +import * as vscode from 'vscode'; +import chooseWorkspace from '../lib/chooseWorkspace'; +import ErrorCode from '../telemetry/definitions/errorCodes'; +import { AppmapConfigManager, DEFAULT_APPMAP_DIR } from '../services/appmapConfigManager'; +import { promisify } from 'util'; +import { join } from 'path'; +import { glob } from 'glob'; +import { rm, symlink, unlink } from 'fs/promises'; +import { listRevisions } from './validation'; +import runCommand from './runCommand'; + +export const ArchiveAppMaps = 'appmap.restore'; + +export default function restore(context: vscode.ExtensionContext) { + const command = vscode.commands.registerCommand( + ArchiveAppMaps, + async (workspaceFolder?: vscode.WorkspaceFolder) => { + if (!workspaceFolder) workspaceFolder = await chooseWorkspace(); + if (!workspaceFolder) return; + + const config = await AppmapConfigManager.getAppMapConfig(workspaceFolder); + const cwd = config?.configFolder || workspaceFolder.uri.fsPath; + + const revisions = await listRevisions(cwd); + if (!revisions) return; + + const options: vscode.QuickPickOptions = { + canPickMany: false, + placeHolder: 'Choose a revision to restore', + }; + const revision = await vscode.window.showQuickPick(revisions, options); + if (!revision) return; + + vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Restoring AppMap Archive' }, + async () => { + await rm(join(cwd, '.appmap', 'work', revision), { recursive: true, force: true }); + + if ( + !(await runCommand( + context, + ErrorCode.RestoreAppMapsFailure, + 'An error occurred while restoring AppMaps', + ['restore', '--revision', revision, '--exact'], + cwd + )) + ) + return; + + try { + await unlink(join(cwd, '.appmap', 'current')); + } catch { + console.debug(`Unlinking .appmap/current failed, this is probably benign`); + } + await symlink(join(cwd, '.appmap', 'work', revision), join(cwd, '.appmap', 'current')); + + vscode.window.showInformationMessage( + `Restored AppMap archive ${revision} to .appmap/current` + ); + } + ); + } + ); + + context.subscriptions.push(command); +} diff --git a/src/commands/runCommand.ts b/src/commands/runCommand.ts new file mode 100644 index 00000000..764df6f2 --- /dev/null +++ b/src/commands/runCommand.ts @@ -0,0 +1,42 @@ +import * as vscode from 'vscode'; + +import { + ProgramName, + getModulePath, + spawn, + verifyCommandOutput, +} from '../services/nodeDependencyProcess'; +import { DEBUG_EXCEPTION, Telemetry } from '../telemetry'; +import ErrorCode from '../telemetry/definitions/errorCodes'; + +export default async function runCommand( + context: vscode.ExtensionContext, + errorCode: ErrorCode, + errorMessage: string, + args: string[], + cwd: string +): Promise { + const modulePath = await getModulePath({ + dependency: ProgramName.Appmap, + globalStoragePath: context.globalStorageUri.fsPath, + }); + + const command = spawn({ + modulePath, + args, + cwd, + }); + try { + await verifyCommandOutput(command); + return true; + } catch (e) { + console.error(e); + Telemetry.sendEvent(DEBUG_EXCEPTION, { + exception: e as Error, + errorCode: errorCode, + log: command.log.toString(), + }); + vscode.window.showWarningMessage(errorMessage); + return false; + } +} diff --git a/src/commands/validation.ts b/src/commands/validation.ts new file mode 100644 index 00000000..1b074ff1 --- /dev/null +++ b/src/commands/validation.ts @@ -0,0 +1,44 @@ +import * as vscode from 'vscode'; +import { promisify } from 'util'; +import { basename, join } from 'path'; +import { glob } from 'glob'; +import { existsSync } from 'fs'; + +export async function ensureAppMapsExist(appmapDir: string): Promise { + const appmapFiles = await promisify(glob)(join(appmapDir, '**/*.appmap.json')); + if (appmapFiles.length === 0) { + vscode.window.showInformationMessage( + `No AppMaps found in ${appmapDir}. Record some AppMaps and then try this command again.` + ); + return false; + } + + return true; +} + +export async function ensureCurrentAppMapsExist(cwd: string): Promise { + if (!existsSync(join(cwd, '.appmap', 'current'))) { + vscode.window.showInformationMessage( + `.appmap/current directory does not exist. Use the command 'AppMap: Restore AppMap Archive' to create it` + ); + return false; + } + + return true; +} + +export async function listRevisions(cwd: string): Promise { + const archiveFiles = await promisify(glob)(join(cwd, '.appmap/archive/full/*.tar')); + const revisions = archiveFiles + .map((file) => file.split('/').pop()!) + .map((file) => basename(file, '.tar')); + + if (revisions.length === 0) { + vscode.window.showInformationMessage( + `No AppMap archives found in .appmap/archive/full. Use the command 'AppMap: Archive Current AppMaps' to create an archive` + ); + return; + } + + return revisions; +} diff --git a/src/extension.ts b/src/extension.ts index 185e3a94..6799a8e5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -59,6 +59,10 @@ import SignInManager from './services/signInManager'; import tryOpenInstallGuide from './commands/tryOpenInstallGuide'; import { AppmapConfigManager } from './services/appmapConfigManager'; import { findByName } from './commands/findByName'; +import archive from './commands/archive'; +import restore from './commands/restore'; +import compare from './commands/compare'; +import reCompare from './commands/reCompare'; export async function activate(context: vscode.ExtensionContext): Promise { Telemetry.register(context); @@ -258,6 +262,10 @@ export async function activate(context: vscode.ExtensionContext): Promise { const result = { configFolder: dirname(configFilePath), - appmapDir: AppmapConfigManager.DEFAULT_APPMAP_DIR, + appmapDir: DEFAULT_APPMAP_DIR, usingDefault: true, } as AppmapConfig; @@ -222,8 +222,24 @@ export class AppmapConfigManagerInstance implements WorkspaceServiceInstance { } } +export const DEFAULT_APPMAP_DIR = 'tmp/appmap'; + export class AppmapConfigManager implements WorkspaceService { - public static readonly DEFAULT_APPMAP_DIR = 'tmp/appmap'; + static async getAppMapConfig( + workspaceFolder: vscode.WorkspaceFolder + ): Promise { + const appmapConfigManagerInstance = workspaceServices().getServiceInstanceFromClass( + AppmapConfigManager, + workspaceFolder + ) as AppmapConfigManagerInstance | undefined; + assert(appmapConfigManagerInstance); + + const appmapConfig = await appmapConfigManagerInstance.getAppmapConfig(); + if (!appmapConfig) return; + + const { configFolder, appmapDir } = appmapConfig; + return { configFolder, appmapDir }; + } public async create(folder: vscode.WorkspaceFolder): Promise { const instance = new AppmapConfigManagerInstance(folder); diff --git a/src/services/nodeProcessService.ts b/src/services/nodeProcessService.ts index 3b12840e..8b42bf02 100644 --- a/src/services/nodeProcessService.ts +++ b/src/services/nodeProcessService.ts @@ -28,7 +28,6 @@ export class NodeProcessService implements WorkspaceService Date: Tue, 2 May 2023 16:10:55 -0400 Subject: [PATCH 2/5] feat: Re-Compare command --- package.json | 4 ++ src/commands/reCompare.ts | 86 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 src/commands/reCompare.ts diff --git a/package.json b/package.json index be47cc33..12b45d86 100644 --- a/package.json +++ b/package.json @@ -166,6 +166,10 @@ "command": "appmap.compare", "title": "AppMap: Compare AppMap Archives" }, + { + "command": "appmap.reCompare", + "title": "AppMap: Re-Run Last Comparison" + }, { "command": "appmap.context.openInFileExplorer", "title": "AppMap View: Open in File Explorer" diff --git a/src/commands/reCompare.ts b/src/commands/reCompare.ts new file mode 100644 index 00000000..ece4404e --- /dev/null +++ b/src/commands/reCompare.ts @@ -0,0 +1,86 @@ +import * as vscode from 'vscode'; +import chooseWorkspace from '../lib/chooseWorkspace'; +import ErrorCode from '../telemetry/definitions/errorCodes'; +import { AppmapConfigManager } from '../services/appmapConfigManager'; +import { basename, dirname, join, relative } from 'path'; +import { stat } from 'fs/promises'; +import runCommand from './runCommand'; +import { promisify } from 'util'; +import { glob } from 'glob'; + +export const ArchiveAppMaps = 'appmap.reCompare'; + +export default function reCompare(context: vscode.ExtensionContext) { + const command = vscode.commands.registerCommand( + ArchiveAppMaps, + async (workspaceFolder?: vscode.WorkspaceFolder) => { + if (!workspaceFolder) workspaceFolder = await chooseWorkspace(); + if (!workspaceFolder) return; + + const config = await AppmapConfigManager.getAppMapConfig(workspaceFolder); + const cwd = config?.configFolder || workspaceFolder.uri.fsPath; + + const compareFiles = await promisify(glob)( + join(cwd, '.appmap/change-report/*/change-report.json') + ); + const mTimes = new Map(); + await Promise.all( + compareFiles.map(async (file) => mTimes.set(file, (await stat(file)).mtime)) + ); + compareFiles.sort((a, b) => mTimes.get(b)!.getTime() - mTimes.get(a)!.getTime()); + const lastCompare = compareFiles.shift(); + const compareDir = dirname(lastCompare); + const compareDirName = basename(compareDir); + const [baseRevision, headRevision] = compareDirName.split('-'); + + vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Comparing AppMap revisions' }, + async (progress: vscode.Progress<{ message?: string; increment?: number }>) => { + progress.report({ message: `Comparing 'base' and 'head' revisions` }); + const compareArgs = [ + 'compare', + '--no-delete-unchanged', + '-b', + baseRevision, + '-h', + headRevision, + ]; + if ( + !(await runCommand( + context, + ErrorCode.CompareAppMapsFailure, + 'An error occurred while comparing AppMaps', + compareArgs, + cwd + )) + ) + return; + + progress.report({ message: `Generating Markdown report` }); + if ( + !(await runCommand( + context, + ErrorCode.CompareReportAppMapsFailure, + 'An error occurred while generating comparison report', + ['compare-report', compareDir], + cwd + )) + ) + return; + + vscode.window.showInformationMessage( + `Comparison is available at ${compareDir}/report.md` + ); + + await vscode.commands.executeCommand( + 'vscode.open', + vscode.Uri.file(join(compareDir, 'report.md')) + ); + await vscode.commands.executeCommand('markdown.showPreview'); + } + ); + } + ); + + context.subscriptions.push(command); +} From 2de3799a0d1cb0220ec7c6027c23f363b9b69cfd Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Tue, 2 May 2023 18:33:00 -0400 Subject: [PATCH 3/5] fixup! feat: Archive, Restore and Compare commands --- src/commands/compare.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/commands/compare.ts b/src/commands/compare.ts index 58870218..c7aaea8d 100644 --- a/src/commands/compare.ts +++ b/src/commands/compare.ts @@ -142,7 +142,11 @@ export default function compare(context: vscode.ExtensionContext) { `Comparison is available at ${compareDir}/report.md` ); - vscode.commands.executeCommand('vscode.open', `${compareDir}/report.md`); + await vscode.commands.executeCommand( + 'vscode.open', + vscode.Uri.file(join(compareDir, 'report.md')) + ); + await vscode.commands.executeCommand('markdown.showPreview'); } ); } From 7e6efbe580d1698c5ef5b1889ce1a6fbaf930f3a Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Wed, 3 May 2023 15:00:29 -0400 Subject: [PATCH 4/5] feat: Include branch and tags in quick pick --- src/commands/chooseRevision.ts | 37 ++++++++++++++++++++++ src/commands/compare.ts | 37 ++++++++-------------- src/commands/listRevisions.ts | 56 ++++++++++++++++++++++++++++++++++ src/commands/reCompare.ts | 14 ++++++++- src/commands/restore.ts | 13 +++----- src/commands/validation.ts | 18 +---------- 6 files changed, 123 insertions(+), 52 deletions(-) create mode 100644 src/commands/chooseRevision.ts create mode 100644 src/commands/listRevisions.ts diff --git a/src/commands/chooseRevision.ts b/src/commands/chooseRevision.ts new file mode 100644 index 00000000..160b880d --- /dev/null +++ b/src/commands/chooseRevision.ts @@ -0,0 +1,37 @@ +import * as vscode from 'vscode'; +import assert from 'assert'; +import { Revision } from './listRevisions'; + +export interface RevisionItem extends vscode.QuickPickItem { + revision?: Revision; +} + +export default async function chooseRevision( + revisions: Revision[], + prompt: string, + exclude: string[] = [] +): Promise { + assert(revisions); + const options: vscode.QuickPickOptions = { + canPickMany: false, + placeHolder: prompt, + }; + + const unsortedItems = revisions.map((revision) => ({ + revision: revision, + label: [revision.revision, revision.decorations].filter(Boolean).join(' '), + })); + + const items: RevisionItem[] = unsortedItems.filter((item) => item.revision.sortIndex); + items.sort((a, b) => a.revision?.sortIndex! - b.revision?.sortIndex!); + items.push({ label: '---' }); + items.push(...unsortedItems.filter((item) => !item.revision.sortIndex)); + + const avaliableRevisions = items.filter( + (rev) => !rev.revision?.revision || !exclude.includes(rev.revision.revision) + ); + + const revisionItem = await vscode.window.showQuickPick(avaliableRevisions, options); + + return revisionItem?.revision?.revision; +} diff --git a/src/commands/compare.ts b/src/commands/compare.ts index c7aaea8d..0ab1ba6c 100644 --- a/src/commands/compare.ts +++ b/src/commands/compare.ts @@ -4,10 +4,10 @@ import ErrorCode from '../telemetry/definitions/errorCodes'; import { AppmapConfigManager, DEFAULT_APPMAP_DIR } from '../services/appmapConfigManager'; import { join, relative } from 'path'; import { rm, symlink, unlink } from 'fs/promises'; -import { listRevisions } from './validation'; -import assert from 'assert'; +import { listRevisions } from './listRevisions'; import runCommand from './runCommand'; import { existsSync } from 'fs'; +import chooseRevision from './chooseRevision'; export const ArchiveAppMaps = 'appmap.compare'; @@ -23,35 +23,20 @@ export default function compare(context: vscode.ExtensionContext) { const appmapDir = config?.appmapDir || DEFAULT_APPMAP_DIR; const revisions = await listRevisions(cwd); - if (!revisions) return; - - async function chooseRevision( - revisionName: string, - exclude: string[] = [], - extraRevisions: string[] = [] - ): Promise { - assert(revisions); - const options: vscode.QuickPickOptions = { - canPickMany: false, - placeHolder: `Choose the ${revisionName} revision`, - }; - const avaliableRevisions = revisions?.filter((rev) => !exclude.includes(rev)); - return await vscode.window.showQuickPick( - [...extraRevisions, ...avaliableRevisions], - options - ); - } + if (!revisions || revisions.length === 0) return; - const baseRevision = await chooseRevision('base'); + const baseRevision = await chooseRevision(revisions, `Choose the base revision`); if (!baseRevision) return; - const currentAppMapsName = `AppMaps in ${appmapDir}`; - const headRevision = await chooseRevision('head', [baseRevision], [currentAppMapsName]); + const firstRevision = revisions[0].revision; + const headRevision = await chooseRevision(revisions, 'Choose the head revision', [ + baseRevision, + ]); if (!headRevision) return; let headRevisionFolder: string; - if (headRevision === currentAppMapsName) { + if (headRevision === firstRevision) { headRevisionFolder = 'head'; } else { headRevisionFolder = headRevision; @@ -85,7 +70,7 @@ export default function compare(context: vscode.ExtensionContext) { if (existsSync(join(compareDir, revisionName))) return; - if (revision === currentAppMapsName) { + if (revision === firstRevision) { await symlink(relative(compareDir, appmapDir), join(cwd, compareDir, revisionName)); } else { await runCommand( @@ -142,6 +127,8 @@ export default function compare(context: vscode.ExtensionContext) { `Comparison is available at ${compareDir}/report.md` ); + await new Promise((resolve) => setTimeout(resolve, 250)); + await vscode.commands.executeCommand( 'vscode.open', vscode.Uri.file(join(compareDir, 'report.md')) diff --git a/src/commands/listRevisions.ts b/src/commands/listRevisions.ts new file mode 100644 index 00000000..a27cdf5c --- /dev/null +++ b/src/commands/listRevisions.ts @@ -0,0 +1,56 @@ +import * as vscode from 'vscode'; +import { promisify } from 'util'; +import { basename, join } from 'path'; +import { glob } from 'glob'; +import { exec } from 'child_process'; + +export type Revision = { revision: string; sortIndex?: number; decorations?: string }; + +export async function listRevisions(cwd: string): Promise { + const archiveFiles = await promisify(glob)(join(cwd, '.appmap/archive/full/*.tar')); + const revisions: Revision[] = archiveFiles + .map((file) => file.split('/').pop()!) + .map((file) => basename(file, '.tar')) + .map((sha) => ({ revision: sha, inHistory: false })); + + if (revisions.length === 0) { + vscode.window.showInformationMessage( + `No AppMap archives found in .appmap/archive/full. Use the command 'AppMap: Archive Current AppMaps' to create an archive` + ); + return; + } + + const loadHistory = async () => { + try { + return (await promisify(exec)(`git log --pretty=format:'%H %d'`, { cwd })).stdout; + } catch (e) { + console.debug( + `Error loading git history for ${cwd} (this is probably not a git repository): ${e}` + ); + } + }; + + const historyOutput = await loadHistory(); + if (historyOutput) { + let order = 1; + const revisionInfo = historyOutput.split('\n').reduce( + (acc, line) => { + const [revision, ...branch] = line.split(' '); + if (branch.length > 0) acc.branch.set(revision, branch.join(' ')); + acc.order.set(revision, order); + order += 1; + return acc; + }, + { branch: new Map(), order: new Map() } + ); + revisions.forEach((revision) => { + const branch = revisionInfo.branch.get(revision.revision); + if (branch) { + revision.decorations = branch; + } + revision.sortIndex = revisionInfo.order.get(revision.revision); + }); + } + + return revisions; +} diff --git a/src/commands/reCompare.ts b/src/commands/reCompare.ts index ece4404e..a965b385 100644 --- a/src/commands/reCompare.ts +++ b/src/commands/reCompare.ts @@ -2,11 +2,12 @@ import * as vscode from 'vscode'; import chooseWorkspace from '../lib/chooseWorkspace'; import ErrorCode from '../telemetry/definitions/errorCodes'; import { AppmapConfigManager } from '../services/appmapConfigManager'; -import { basename, dirname, join, relative } from 'path'; +import { basename, dirname, join } from 'path'; import { stat } from 'fs/promises'; import runCommand from './runCommand'; import { promisify } from 'util'; import { glob } from 'glob'; +import assert from 'assert'; export const ArchiveAppMaps = 'appmap.reCompare'; @@ -23,12 +24,21 @@ export default function reCompare(context: vscode.ExtensionContext) { const compareFiles = await promisify(glob)( join(cwd, '.appmap/change-report/*/change-report.json') ); + if (compareFiles.length === 0) { + vscode.window.showInformationMessage( + `No change reports found. Run 'AppMap: Compare AppMap Archives', then you can run this ` + + `command to repeat that comparison using your updated AppMaps.` + ); + return; + } + const mTimes = new Map(); await Promise.all( compareFiles.map(async (file) => mTimes.set(file, (await stat(file)).mtime)) ); compareFiles.sort((a, b) => mTimes.get(b)!.getTime() - mTimes.get(a)!.getTime()); const lastCompare = compareFiles.shift(); + assert(lastCompare); const compareDir = dirname(lastCompare); const compareDirName = basename(compareDir); const [baseRevision, headRevision] = compareDirName.split('-'); @@ -72,6 +82,8 @@ export default function reCompare(context: vscode.ExtensionContext) { `Comparison is available at ${compareDir}/report.md` ); + await new Promise((resolve) => setTimeout(resolve, 250)); + await vscode.commands.executeCommand( 'vscode.open', vscode.Uri.file(join(compareDir, 'report.md')) diff --git a/src/commands/restore.ts b/src/commands/restore.ts index f6107f03..458d6122 100644 --- a/src/commands/restore.ts +++ b/src/commands/restore.ts @@ -1,13 +1,12 @@ import * as vscode from 'vscode'; import chooseWorkspace from '../lib/chooseWorkspace'; import ErrorCode from '../telemetry/definitions/errorCodes'; -import { AppmapConfigManager, DEFAULT_APPMAP_DIR } from '../services/appmapConfigManager'; -import { promisify } from 'util'; +import { AppmapConfigManager } from '../services/appmapConfigManager'; import { join } from 'path'; -import { glob } from 'glob'; import { rm, symlink, unlink } from 'fs/promises'; -import { listRevisions } from './validation'; +import { listRevisions } from './listRevisions'; import runCommand from './runCommand'; +import chooseRevision from './chooseRevision'; export const ArchiveAppMaps = 'appmap.restore'; @@ -24,11 +23,7 @@ export default function restore(context: vscode.ExtensionContext) { const revisions = await listRevisions(cwd); if (!revisions) return; - const options: vscode.QuickPickOptions = { - canPickMany: false, - placeHolder: 'Choose a revision to restore', - }; - const revision = await vscode.window.showQuickPick(revisions, options); + const revision = await chooseRevision(revisions, `Choose a revision to restore`); if (!revision) return; vscode.window.withProgress( diff --git a/src/commands/validation.ts b/src/commands/validation.ts index 1b074ff1..48def24e 100644 --- a/src/commands/validation.ts +++ b/src/commands/validation.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { promisify } from 'util'; -import { basename, join } from 'path'; +import { join } from 'path'; import { glob } from 'glob'; import { existsSync } from 'fs'; @@ -26,19 +26,3 @@ export async function ensureCurrentAppMapsExist(cwd: string): Promise { return true; } - -export async function listRevisions(cwd: string): Promise { - const archiveFiles = await promisify(glob)(join(cwd, '.appmap/archive/full/*.tar')); - const revisions = archiveFiles - .map((file) => file.split('/').pop()!) - .map((file) => basename(file, '.tar')); - - if (revisions.length === 0) { - vscode.window.showInformationMessage( - `No AppMap archives found in .appmap/archive/full. Use the command 'AppMap: Archive Current AppMaps' to create an archive` - ); - return; - } - - return revisions; -} From 4464a64523702ee70a55ca2d262dbbe17887eb3e Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Thu, 4 May 2023 18:09:13 -0400 Subject: [PATCH 5/5] refactor: Refactor appmap archive commands --- package.json | 6 +- src/commands/WorkspaceAppMapCommand.ts | 69 +++++++ src/commands/archive.ts | 103 +++++------ src/commands/compare.ts | 245 ++++++++++++------------- src/commands/reCompare.ts | 142 ++++++-------- src/commands/restore.ts | 87 ++++----- src/util.ts | 7 +- 7 files changed, 341 insertions(+), 318 deletions(-) create mode 100644 src/commands/WorkspaceAppMapCommand.ts diff --git a/package.json b/package.json index 12b45d86..dffc0b3a 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,10 @@ "command": "appmap.logout", "title": "AppMap: Logout" }, + { + "command": "appmap.update", + "title": "AppMap: Update Out-of-Date AppMaps" + }, { "command": "appmap.archive", "title": "AppMap: Archive Current AppMaps" @@ -168,7 +172,7 @@ }, { "command": "appmap.reCompare", - "title": "AppMap: Re-Run Last Comparison" + "title": "AppMap: Re-Compare AppMap Archives" }, { "command": "appmap.context.openInFileExplorer", diff --git a/src/commands/WorkspaceAppMapCommand.ts b/src/commands/WorkspaceAppMapCommand.ts new file mode 100644 index 00000000..a8c5d66b --- /dev/null +++ b/src/commands/WorkspaceAppMapCommand.ts @@ -0,0 +1,69 @@ +import * as vscode from 'vscode'; +import chooseWorkspace from '../lib/chooseWorkspace'; +import { join } from 'path'; +import { AppmapConfigManager, DEFAULT_APPMAP_DIR } from '../services/appmapConfigManager'; +import { default as childProcessRunCommand } from './runCommand'; + +export class ProjectStructure { + constructor(public projectDir: string, public appmapDir: string) {} + + path(...args: string[]): string { + return join(this.projectDir, ...args); + } + + static async build(workspaceFolder: vscode.WorkspaceFolder): Promise { + const config = await AppmapConfigManager.getAppMapConfig(workspaceFolder); + const cwd = config?.configFolder || workspaceFolder.uri.fsPath; + const appmapDir = config?.appmapDir || DEFAULT_APPMAP_DIR; + + return new ProjectStructure(cwd, appmapDir); + } +} + +export abstract class WorkspaceAppMapCommand { + constructor( + protected context: vscode.ExtensionContext, + protected workspaceFolder: vscode.WorkspaceFolder + ) {} + + async run(): Promise { + const project = await ProjectStructure.build(this.workspaceFolder); + if (!project) return false; + + await this.performRequest(project); + return true; + } + + protected async runCommand( + project: ProjectStructure, + errorCode: number, + errorMessage: string, + args: string[] + ): Promise { + return childProcessRunCommand(this.context, errorCode, errorMessage, args, project.projectDir); + } + + static register( + context: vscode.ExtensionContext, + commandClass: new ( + context: vscode.ExtensionContext, + workspaceFolder: vscode.WorkspaceFolder + ) => T, + commandId: string + ) { + const command = vscode.commands.registerCommand( + commandId, + async (workspaceFolder?: vscode.WorkspaceFolder) => { + if (!workspaceFolder) workspaceFolder = await chooseWorkspace(); + if (!workspaceFolder) return; + + // Construct an instance of dynamically provided class commandClass + const compare = new commandClass(context, workspaceFolder); + await compare.run(); + } + ); + context.subscriptions.push(command); + } + + protected abstract performRequest(project: ProjectStructure): Promise; +} diff --git a/src/commands/archive.ts b/src/commands/archive.ts index cb2627bd..d8333b36 100644 --- a/src/commands/archive.ts +++ b/src/commands/archive.ts @@ -1,74 +1,61 @@ import * as vscode from 'vscode'; -import chooseWorkspace from '../lib/chooseWorkspace'; -import { ProgramName, getModulePath } from '../services/nodeDependencyProcess'; import ErrorCode from '../telemetry/definitions/errorCodes'; -import { AppmapConfigManager, DEFAULT_APPMAP_DIR } from '../services/appmapConfigManager'; import { promisify } from 'util'; import { join } from 'path'; import { glob } from 'glob'; import { rm, stat } from 'fs/promises'; -import runCommand from './runCommand'; +import { ProjectStructure, WorkspaceAppMapCommand } from './WorkspaceAppMapCommand'; -export const ArchiveAppMaps = 'appmap.archive'; +export const ArchiveAppMapsCommandId = 'appmap.archive'; export default function archive(context: vscode.ExtensionContext) { - const command = vscode.commands.registerCommand( - ArchiveAppMaps, - async (workspaceFolder?: vscode.WorkspaceFolder) => { - if (!workspaceFolder) workspaceFolder = await chooseWorkspace(); - if (!workspaceFolder) return; + WorkspaceAppMapCommand.register(context, Archive, ArchiveAppMapsCommandId); +} - const config = await AppmapConfigManager.getAppMapConfig(workspaceFolder); - const cwd = config?.configFolder || workspaceFolder.uri.fsPath; +class Archive extends WorkspaceAppMapCommand { + protected async performRequest(project: ProjectStructure) { + const appmapFiles = await promisify(glob)(project.path(project.appmapDir, '**/*.appmap.json')); + if (appmapFiles.length === 0) { + vscode.window.showInformationMessage( + `No AppMaps found in ${project.appmapDir}. Record some AppMaps and then try this command again.` + ); + return; + } - const appmapDir = config?.appmapDir || DEFAULT_APPMAP_DIR; + vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Archiving AppMaps' }, + async () => { + const mtime = new Date(); + + // For now, make every archive a 'full' archive + await rm(project.path(project.appmapDir, 'appmap_archive.json'), { + force: true, + }); + + if ( + !(await this.runCommand( + project, + ErrorCode.ArchiveAppMapsFailure, + `An error occurred while archiving AppMaps`, + ['archive'] + )) + ) + return; + + const archiveFiles = await promisify(glob)(project.path('.appmap/archive/full/*.tar')); + const mTimes = new Map(); + await Promise.all( + archiveFiles.map(async (file) => mTimes.set(file, (await stat(file)).mtime)) + ); + + const newArchiveFiles = [...archiveFiles] + .filter((file) => mTimes.get(file)! > mtime) + .map((file) => file.slice(project.projectDir.length + 1)); - const appmapFiles = await promisify(glob)(join(cwd, appmapDir, '**/*.appmap.json')); - if (appmapFiles.length === 0) { vscode.window.showInformationMessage( - `No AppMaps found in ${config?.appmapDir}. Record some AppMaps and then try this command again.` + `Created AppMap archive ${newArchiveFiles.join(', ')}` ); - return; } - - vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: 'Archiving AppMaps' }, - async () => { - const mtime = new Date(); - - // For now, make every archive a 'full' archive - await rm(join(cwd, appmapDir, 'appmap_archive.json'), { - force: true, - }); - - if ( - !(await runCommand( - context, - ErrorCode.ArchiveAppMapsFailure, - `An error occurred while archiving AppMaps`, - ['archive'], - cwd - )) - ) - return; - - const archiveFiles = await promisify(glob)(join(cwd, '.appmap/archive/full/*.tar')); - const mTimes = new Map(); - await Promise.all( - archiveFiles.map(async (file) => mTimes.set(file, (await stat(file)).mtime)) - ); - - const newArchiveFiles = [...archiveFiles] - .filter((file) => mTimes.get(file)! > mtime) - .map((file) => file.slice(cwd.length + 1)); - - vscode.window.showInformationMessage( - `Created AppMap archive ${newArchiveFiles.join(', ')}` - ); - } - ); - } - ); - - context.subscriptions.push(command); + ); + } } diff --git a/src/commands/compare.ts b/src/commands/compare.ts index 0ab1ba6c..5748b356 100644 --- a/src/commands/compare.ts +++ b/src/commands/compare.ts @@ -1,143 +1,140 @@ import * as vscode from 'vscode'; -import chooseWorkspace from '../lib/chooseWorkspace'; import ErrorCode from '../telemetry/definitions/errorCodes'; -import { AppmapConfigManager, DEFAULT_APPMAP_DIR } from '../services/appmapConfigManager'; import { join, relative } from 'path'; import { rm, symlink, unlink } from 'fs/promises'; import { listRevisions } from './listRevisions'; import runCommand from './runCommand'; import { existsSync } from 'fs'; import chooseRevision from './chooseRevision'; +import { ProjectStructure, WorkspaceAppMapCommand } from './WorkspaceAppMapCommand'; +import { retry } from '../util'; -export const ArchiveAppMaps = 'appmap.compare'; +export const CompareAppMapsCommandId = 'appmap.compare'; export default function compare(context: vscode.ExtensionContext) { - const command = vscode.commands.registerCommand( - ArchiveAppMaps, - async (workspaceFolder?: vscode.WorkspaceFolder) => { - if (!workspaceFolder) workspaceFolder = await chooseWorkspace(); - if (!workspaceFolder) return; - - const config = await AppmapConfigManager.getAppMapConfig(workspaceFolder); - const cwd = config?.configFolder || workspaceFolder.uri.fsPath; - const appmapDir = config?.appmapDir || DEFAULT_APPMAP_DIR; - - const revisions = await listRevisions(cwd); - if (!revisions || revisions.length === 0) return; - - const baseRevision = await chooseRevision(revisions, `Choose the base revision`); - if (!baseRevision) return; - - const firstRevision = revisions[0].revision; - const headRevision = await chooseRevision(revisions, 'Choose the head revision', [ - baseRevision, - ]); - if (!headRevision) return; - - let headRevisionFolder: string; - - if (headRevision === firstRevision) { - headRevisionFolder = 'head'; - } else { - headRevisionFolder = headRevision; - } + WorkspaceAppMapCommand.register(context, Compare, CompareAppMapsCommandId); +} - const compareDir = join( - '.appmap', - 'change-report', - [baseRevision, headRevisionFolder].join('-') - ); - - vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: 'Comparing AppMap revisions' }, - async (progress: vscode.Progress<{ message?: string; increment?: number }>) => { - progress.report({ message: 'Removing existing comparison dir (if any)' }); - - try { - await unlink(join(cwd, compareDir, 'head')); - } catch (e) { - console.debug( - `Failed to unlink ${join(cwd, compareDir, 'head')}. This is probably benign.` - ); - } - await rm(join(cwd, compareDir), { recursive: true, force: true }); - - for (const [revisionName, revision] of [ - ['base', baseRevision], - ['head', headRevision], - ]) { - progress.report({ message: `Preparing '${revisionName}' AppMap data` }); - - if (existsSync(join(compareDir, revisionName))) return; - - if (revision === firstRevision) { - await symlink(relative(compareDir, appmapDir), join(cwd, compareDir, revisionName)); - } else { - await runCommand( - context, - ErrorCode.RestoreAppMapsFailure, - `An error occurred while restoring ${revisionName} revision ${revision}`, - [ - 'restore', - '-d', - cwd, - '--revision', - revision, - '--output-dir', - join(compareDir, revisionName), - ], - cwd - ); - } - } +export async function showReport(compareDir: string) { + vscode.window.showInformationMessage(`Comparison is available at ${compareDir}/report.md`); + + await retry( + async () => { + if (!existsSync(join(compareDir, 'report.md'))) + throw new Error(`report.md file does not exist`); + }, + 3, + 250, + false + ); - progress.report({ message: `Comparing 'base' and 'head' revisions` }); - const compareArgs = [ - 'compare', - '--no-delete-unchanged', - '-b', - baseRevision, - '-h', - headRevisionFolder, - ]; - if ( - !(await runCommand( - context, - ErrorCode.CompareAppMapsFailure, - 'An error occurred while comparing AppMaps', - compareArgs, - cwd - )) - ) - return; - - progress.report({ message: `Generating Markdown report` }); - if ( - !(await runCommand( - context, - ErrorCode.CompareReportAppMapsFailure, - 'An error occurred while generating comparison report', - ['compare-report', compareDir], - cwd - )) - ) - return; - - vscode.window.showInformationMessage( - `Comparison is available at ${compareDir}/report.md` - ); + await vscode.commands.executeCommand( + 'vscode.open', + vscode.Uri.file(join(compareDir, 'report.md')) + ); + await vscode.commands.executeCommand('markdown.showPreview'); +} + +class Compare extends WorkspaceAppMapCommand { + protected async performRequest(project: ProjectStructure) { + const revisions = await listRevisions(project.projectDir); + if (!revisions || revisions.length === 0) return; + + const baseRevision = await chooseRevision(revisions, `Choose the base revision`); + if (!baseRevision) return; + + const firstRevision = revisions[0].revision; + const headRevision = await chooseRevision(revisions, 'Choose the head revision', [ + baseRevision, + ]); + if (!headRevision) return; - await new Promise((resolve) => setTimeout(resolve, 250)); + let headRevisionFolder: string; - await vscode.commands.executeCommand( - 'vscode.open', - vscode.Uri.file(join(compareDir, 'report.md')) + if (headRevision === firstRevision) { + headRevisionFolder = 'head'; + } else { + headRevisionFolder = headRevision; + } + + const compareDir = join( + '.appmap', + 'change-report', + [baseRevision, headRevisionFolder].join('-') + ); + + vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Comparing AppMap revisions' }, + async (progress: vscode.Progress<{ message?: string; increment?: number }>) => { + progress.report({ message: 'Removing existing comparison dir (if any)' }); + + try { + await unlink(project.path(compareDir, 'head')); + } catch (e) { + console.debug( + `Failed to unlink ${project.path(compareDir, 'head')}. This is probably benign.` ); - await vscode.commands.executeCommand('markdown.showPreview'); } - ); - } - ); + await rm(project.path(compareDir), { recursive: true, force: true }); + + for (const [revisionName, revision] of [ + ['base', baseRevision], + ['head', headRevision], + ]) { + progress.report({ message: `Preparing '${revisionName}' AppMap data` }); + + if (existsSync(join(compareDir, revisionName))) return; - context.subscriptions.push(command); + if (revision === firstRevision) { + await symlink( + relative(compareDir, project.appmapDir), + project.path(compareDir, revisionName) + ); + } else { + await runCommand( + this.context, + ErrorCode.RestoreAppMapsFailure, + `An error occurred while restoring ${revisionName} revision ${revision}`, + ['restore', '--revision', revision, '--output-dir', join(compareDir, revisionName)], + project.projectDir + ); + } + } + + progress.report({ message: `Comparing 'base' and 'head' revisions` }); + const compareArgs = [ + 'compare', + '--no-delete-unchanged', + '-b', + baseRevision, + '-h', + headRevisionFolder, + ]; + if ( + !(await runCommand( + this.context, + ErrorCode.CompareAppMapsFailure, + 'An error occurred while comparing AppMaps', + compareArgs, + project.projectDir + )) + ) + return; + + progress.report({ message: `Generating Markdown report` }); + if ( + !(await runCommand( + this.context, + ErrorCode.CompareReportAppMapsFailure, + 'An error occurred while generating comparison report', + ['compare-report', compareDir], + project.projectDir + )) + ) + return; + + await showReport(compareDir); + } + ); + } } diff --git a/src/commands/reCompare.ts b/src/commands/reCompare.ts index a965b385..6e1433b3 100644 --- a/src/commands/reCompare.ts +++ b/src/commands/reCompare.ts @@ -1,98 +1,76 @@ import * as vscode from 'vscode'; -import chooseWorkspace from '../lib/chooseWorkspace'; import ErrorCode from '../telemetry/definitions/errorCodes'; -import { AppmapConfigManager } from '../services/appmapConfigManager'; -import { basename, dirname, join } from 'path'; +import { basename, dirname } from 'path'; import { stat } from 'fs/promises'; -import runCommand from './runCommand'; import { promisify } from 'util'; import { glob } from 'glob'; import assert from 'assert'; +import { ProjectStructure, WorkspaceAppMapCommand } from './WorkspaceAppMapCommand'; +import { showReport } from './compare'; -export const ArchiveAppMaps = 'appmap.reCompare'; +export const ReCompareAppMapsCommandId = 'appmap.reCompare'; export default function reCompare(context: vscode.ExtensionContext) { - const command = vscode.commands.registerCommand( - ArchiveAppMaps, - async (workspaceFolder?: vscode.WorkspaceFolder) => { - if (!workspaceFolder) workspaceFolder = await chooseWorkspace(); - if (!workspaceFolder) return; - - const config = await AppmapConfigManager.getAppMapConfig(workspaceFolder); - const cwd = config?.configFolder || workspaceFolder.uri.fsPath; - - const compareFiles = await promisify(glob)( - join(cwd, '.appmap/change-report/*/change-report.json') - ); - if (compareFiles.length === 0) { - vscode.window.showInformationMessage( - `No change reports found. Run 'AppMap: Compare AppMap Archives', then you can run this ` + - `command to repeat that comparison using your updated AppMaps.` - ); - return; - } + WorkspaceAppMapCommand.register(context, ReCompare, ReCompareAppMapsCommandId); +} - const mTimes = new Map(); - await Promise.all( - compareFiles.map(async (file) => mTimes.set(file, (await stat(file)).mtime)) +class ReCompare extends WorkspaceAppMapCommand { + protected async performRequest(project: ProjectStructure) { + const compareFiles = await promisify(glob)( + project.path('.appmap/change-report/*/change-report.json') + ); + if (compareFiles.length === 0) { + vscode.window.showInformationMessage( + `No change reports found. Run 'AppMap: Compare AppMap Archives', then you can run this ` + + `command to repeat that comparison using your updated AppMaps.` ); - compareFiles.sort((a, b) => mTimes.get(b)!.getTime() - mTimes.get(a)!.getTime()); - const lastCompare = compareFiles.shift(); - assert(lastCompare); - const compareDir = dirname(lastCompare); - const compareDirName = basename(compareDir); - const [baseRevision, headRevision] = compareDirName.split('-'); - - vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: 'Comparing AppMap revisions' }, - async (progress: vscode.Progress<{ message?: string; increment?: number }>) => { - progress.report({ message: `Comparing 'base' and 'head' revisions` }); - const compareArgs = [ - 'compare', - '--no-delete-unchanged', - '-b', - baseRevision, - '-h', - headRevision, - ]; - if ( - !(await runCommand( - context, - ErrorCode.CompareAppMapsFailure, - 'An error occurred while comparing AppMaps', - compareArgs, - cwd - )) - ) - return; + return; + } - progress.report({ message: `Generating Markdown report` }); - if ( - !(await runCommand( - context, - ErrorCode.CompareReportAppMapsFailure, - 'An error occurred while generating comparison report', - ['compare-report', compareDir], - cwd - )) - ) - return; + const mTimes = new Map(); + await Promise.all(compareFiles.map(async (file) => mTimes.set(file, (await stat(file)).mtime))); + compareFiles.sort((a, b) => mTimes.get(b)!.getTime() - mTimes.get(a)!.getTime()); + const lastCompare = compareFiles.shift(); + assert(lastCompare); + const compareDir = dirname(lastCompare); + const compareDirName = basename(compareDir); + const [baseRevision, headRevision] = compareDirName.split('-'); - vscode.window.showInformationMessage( - `Comparison is available at ${compareDir}/report.md` - ); + vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Comparing AppMap revisions' }, + async (progress: vscode.Progress<{ message?: string; increment?: number }>) => { + progress.report({ message: `Comparing 'base' and 'head' revisions` }); + const compareArgs = [ + 'compare', + '--no-delete-unchanged', + '-b', + baseRevision, + '-h', + headRevision, + ]; + if ( + !(await this.runCommand( + project, + ErrorCode.CompareAppMapsFailure, + 'An error occurred while comparing AppMaps', + compareArgs + )) + ) + return; - await new Promise((resolve) => setTimeout(resolve, 250)); + progress.report({ message: `Generating Markdown report` }); + if ( + !(await this.runCommand( + project, + ErrorCode.CompareReportAppMapsFailure, + 'An error occurred while generating comparison report', + ['compare-report', compareDir] + )) + ) + return; - await vscode.commands.executeCommand( - 'vscode.open', - vscode.Uri.file(join(compareDir, 'report.md')) - ); - await vscode.commands.executeCommand('markdown.showPreview'); - } - ); - } - ); - - context.subscriptions.push(command); + await showReport(compareDir); + } + ); + } } diff --git a/src/commands/restore.ts b/src/commands/restore.ts index 458d6122..17139f60 100644 --- a/src/commands/restore.ts +++ b/src/commands/restore.ts @@ -1,61 +1,48 @@ import * as vscode from 'vscode'; -import chooseWorkspace from '../lib/chooseWorkspace'; import ErrorCode from '../telemetry/definitions/errorCodes'; -import { AppmapConfigManager } from '../services/appmapConfigManager'; -import { join } from 'path'; import { rm, symlink, unlink } from 'fs/promises'; import { listRevisions } from './listRevisions'; -import runCommand from './runCommand'; import chooseRevision from './chooseRevision'; +import { ProjectStructure, WorkspaceAppMapCommand } from './WorkspaceAppMapCommand'; -export const ArchiveAppMaps = 'appmap.restore'; +export const RestoreAppMapsCommandId = 'appmap.restore'; export default function restore(context: vscode.ExtensionContext) { - const command = vscode.commands.registerCommand( - ArchiveAppMaps, - async (workspaceFolder?: vscode.WorkspaceFolder) => { - if (!workspaceFolder) workspaceFolder = await chooseWorkspace(); - if (!workspaceFolder) return; - - const config = await AppmapConfigManager.getAppMapConfig(workspaceFolder); - const cwd = config?.configFolder || workspaceFolder.uri.fsPath; - - const revisions = await listRevisions(cwd); - if (!revisions) return; - - const revision = await chooseRevision(revisions, `Choose a revision to restore`); - if (!revision) return; - - vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: 'Restoring AppMap Archive' }, - async () => { - await rm(join(cwd, '.appmap', 'work', revision), { recursive: true, force: true }); - - if ( - !(await runCommand( - context, - ErrorCode.RestoreAppMapsFailure, - 'An error occurred while restoring AppMaps', - ['restore', '--revision', revision, '--exact'], - cwd - )) - ) - return; - - try { - await unlink(join(cwd, '.appmap', 'current')); - } catch { - console.debug(`Unlinking .appmap/current failed, this is probably benign`); - } - await symlink(join(cwd, '.appmap', 'work', revision), join(cwd, '.appmap', 'current')); - - vscode.window.showInformationMessage( - `Restored AppMap archive ${revision} to .appmap/current` - ); + WorkspaceAppMapCommand.register(context, Restore, RestoreAppMapsCommandId); +} + +class Restore extends WorkspaceAppMapCommand { + protected async performRequest(project: ProjectStructure) { + const revisions = await listRevisions(project.projectDir); + if (!revisions) return; + + const revision = await chooseRevision(revisions, `Choose a revision to restore`); + if (!revision) return; + + vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Restoring AppMap Archive' }, + async () => { + await rm(project.path('.appmap', 'work', revision), { recursive: true, force: true }); + + if ( + !(await this.runCommand( + project, + ErrorCode.RestoreAppMapsFailure, + 'An error occurred while restoring AppMaps', + ['restore', '--revision', revision, '--exact'] + )) + ) + return; + + try { + await unlink(project.path('.appmap', 'base')); + } catch { + console.debug(`Unlinking .appmap/base failed, this is probably benign`); } - ); - } - ); + await symlink(project.path('.appmap', 'work', revision), project.path('.appmap', 'base')); - context.subscriptions.push(command); + vscode.window.showInformationMessage(`Restored AppMap archive ${revision} to .appmap/base`); + } + ); + } } diff --git a/src/util.ts b/src/util.ts index 064ab67f..7d65603f 100644 --- a/src/util.ts +++ b/src/util.ts @@ -81,7 +81,8 @@ export async function fileExists(filename: string): Promise { export async function retry( fn: () => void | Promise, retries = 3, - interval = 100 + interval = 100, + warnOnFailure = true ): Promise { try { await fn(); @@ -89,9 +90,9 @@ export async function retry( if (retries === 0) { throw e; } - console.warn(`Retrying after error: ${e}`); + if (warnOnFailure) console.warn(`Retrying after error: ${e}`); await new Promise((resolve) => setTimeout(resolve, interval)); - await retry(fn, retries - 1, interval); + await retry(fn, retries - 1, interval * 2); } }