Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Archive, Restore and Compare commands #677

Draft
wants to merge 5 commits into
base: develop
Choose a base branch
from
Draft
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
60 changes: 40 additions & 20 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,46 @@
"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.update",
"title": "AppMap: Update Out-of-Date AppMaps"
},
{
"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.reCompare",
"title": "AppMap: Re-Compare AppMap Archives"
},
{
"command": "appmap.context.openInFileExplorer",
"title": "AppMap View: Open in File Explorer"
Expand Down Expand Up @@ -169,26 +209,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": {
Expand Down
69 changes: 69 additions & 0 deletions src/commands/WorkspaceAppMapCommand.ts
Original file line number Diff line number Diff line change
@@ -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<ProjectStructure> {
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<boolean> {
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<boolean> {
return childProcessRunCommand(this.context, errorCode, errorMessage, args, project.projectDir);
}

static register<T extends WorkspaceAppMapCommand>(
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<void>;
}
61 changes: 61 additions & 0 deletions src/commands/archive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import * as vscode from 'vscode';
import ErrorCode from '../telemetry/definitions/errorCodes';
import { promisify } from 'util';
import { join } from 'path';
import { glob } from 'glob';
import { rm, stat } from 'fs/promises';
import { ProjectStructure, WorkspaceAppMapCommand } from './WorkspaceAppMapCommand';

export const ArchiveAppMapsCommandId = 'appmap.archive';

export default function archive(context: vscode.ExtensionContext) {
WorkspaceAppMapCommand.register(context, Archive, ArchiveAppMapsCommandId);
}

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;
}

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<string, Date>();
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));

vscode.window.showInformationMessage(
`Created AppMap archive ${newArchiveFiles.join(', ')}`
);
}
);
}
}
37 changes: 37 additions & 0 deletions src/commands/chooseRevision.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
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;
}
140 changes: 140 additions & 0 deletions src/commands/compare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import * as vscode from 'vscode';
import ErrorCode from '../telemetry/definitions/errorCodes';
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 CompareAppMapsCommandId = 'appmap.compare';

export default function compare(context: vscode.ExtensionContext) {
WorkspaceAppMapCommand.register(context, Compare, CompareAppMapsCommandId);
}

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
);

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;

let headRevisionFolder: string;

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 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;

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);
}
);
}
}
Loading