Skip to content

Commit 5aca938

Browse files
authored
Refactor merge file discovery in the Outline view (#182)
* Refactor merge file discovery in the Outline view * Update unit tests * Fix failing tests * Fix lint issue * Update missing tests * Refactor merge-command utility functions * Update DbgConfigNode * Update utility functions and update tests
1 parent 4233662 commit 5aca938

9 files changed

Lines changed: 336 additions & 372 deletions

src/utils/fs-utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,18 @@ export function deleteFileIfExists(filePath?: string) {
9393
}
9494
}
9595

96+
export function copyFile(source: string, destination: string) {
97+
fs.copyFileSync(source, destination);
98+
}
99+
100+
export function renameFile(source: string, destination: string) {
101+
fs.renameSync(source, destination);
102+
}
103+
104+
export function readDirectory(directoryPath: string): string[] {
105+
return fs.readdirSync(directoryPath);
106+
}
107+
96108
export function getFileModificationTime(filePath: string): number {
97109
if (fileExists(filePath)) {
98110
const stat = fs.statSync(filePath);

src/views/solution-outline/commands/merge-command.test.ts

Lines changed: 182 additions & 165 deletions
Large diffs are not rendered by default.

src/views/solution-outline/commands/merge-command.ts

Lines changed: 108 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ import { exec, ExecException, execSync } from 'child_process';
2121
import { COutlineItem } from '../tree-structure/solution-outline-item';
2222
import path from 'path';
2323
import * as os from 'os';
24-
import * as fs from 'fs';
2524
import { ActiveSolutionTracker } from '../../../solutions/active-solution-tracker';
25+
import semver from 'semver';
26+
import { extractVersion } from '../../../utils/string-utils';
27+
import * as fsUtils from '../../../utils/fs-utils';
2628

2729
export class MergeCommand {
2830
public static readonly mergeFile = `${PACKAGE_NAME}.mergeFile`;
29-
private static readonly disallowedCmdChars = /[\r\n&|<>^%"']/;
31+
private static readonly disallowedCmdChars = /[\r\n&|<>^%"']/;
3032

3133
constructor(
3234
private readonly commandsProvider: CommandsProvider,
@@ -47,23 +49,12 @@ export class MergeCommand {
4749
return;
4850
}
4951

50-
let local = node.getAttribute('local');
51-
if (!local) {
52-
vscode.window.showErrorMessage('Required local file is missing to perform merge.');
52+
const mergeFiles = this.discoverMergeFiles(node);
53+
if (!mergeFiles) {
5354
return;
5455
}
5556

56-
let update = node.getAttribute('update');
57-
if (!update) {
58-
vscode.window.showErrorMessage('Required update file is missing to perform merge.');
59-
return;
60-
}
61-
62-
let base = node.getAttribute('base');
63-
if (!base) {
64-
vscode.window.showErrorMessage('Required base file is missing to perform merge.');
65-
return;
66-
}
57+
let { local, update, base } = mergeFiles;
6758

6859
const codePath = this.getVSCodeExecutablePath();
6960
if (!codePath) {
@@ -75,7 +66,7 @@ export class MergeCommand {
7566
let merged = local + '.merged';
7667

7768
// make a copy of local to create merged file
78-
fs.copyFileSync(local, merged);
69+
fsUtils.copyFile(local, merged);
7970
node.setAttribute('merged', merged);
8071

8172
// ensure all paths are absolute
@@ -85,53 +76,22 @@ export class MergeCommand {
8576
merged = path.resolve(merged);
8677

8778
// get the modification time of the merged file before merge
88-
let mergedMTimeBefore = 0;
89-
if (fs.existsSync(merged)) {
90-
mergedMTimeBefore = fs.statSync(merged).mtimeMs;
91-
}
79+
const mergedMTimeBefore = fsUtils.getFileModificationTime(merged);
9280

9381
try {
9482
const command = this.buildMergeCommand(codePath, local, update, base, merged);
9583
const exitCode = await this.doOpen3WayMerge(command);
9684

9785
// get the modification time after merge
98-
let mergedMTimeAfter = 0;
99-
if (fs.existsSync(merged)) {
100-
mergedMTimeAfter = fs.statSync(merged).mtimeMs;
101-
}
86+
const mergedMTimeAfter = fsUtils.getFileModificationTime(merged);
10287

10388
if (exitCode !== 0) {
10489
console.warn(`Merge exited with code ${exitCode}. Conflicts may exist.`);
10590
return;
10691
}
10792

108-
// perform post-merge file operations
10993
if (exitCode === 0 && mergedMTimeAfter > mergedMTimeBefore) {
110-
// create .bak file of local file
111-
const backupPath = `${local}.bak`;
112-
fs.copyFileSync(local, backupPath);
113-
114-
// delete local file
115-
if (fs.existsSync(local)) {
116-
fs.unlinkSync(local);
117-
}
118-
119-
// delete base file
120-
if (fs.existsSync(base)) {
121-
fs.unlinkSync(base);
122-
}
123-
124-
// rename update file to base file
125-
const newBaseFileName = path.basename(update).replaceAll('update', 'base');
126-
const baseDirPath = path.dirname(update);
127-
const newBase = path.join(baseDirPath, newBaseFileName);
128-
fs.renameSync(update, newBase);
129-
130-
// rename merged file to local file
131-
fs.renameSync(merged, local);
132-
133-
// refresh tree view to update file status
134-
this.activeSolutionTracker.triggerReload();
94+
this.performPostMergeOperations(local, update, base, merged);
13595
}
13696

13797
} catch (err) {
@@ -141,6 +101,53 @@ export class MergeCommand {
141101
}
142102
}
143103

104+
private discoverMergeFiles(node: COutlineItem): { local: string; update: string; base: string } | undefined {
105+
const local = node.getAttribute('local');
106+
if (!local) {
107+
vscode.window.showErrorMessage('Required local file is missing to perform merge.');
108+
return undefined;
109+
}
110+
111+
const discovered = this.findNewestMergeFiles(local);
112+
const update = discovered.update;
113+
if (!update) {
114+
vscode.window.showErrorMessage('Required update file is missing to perform merge.');
115+
return undefined;
116+
}
117+
118+
const base = discovered.base;
119+
if (!base) {
120+
vscode.window.showErrorMessage('Required base file is missing to perform merge.');
121+
return undefined;
122+
}
123+
124+
return { local, update, base };
125+
}
126+
127+
private performPostMergeOperations(local: string, update: string, base: string, merged: string): void {
128+
// create .bak file of local file
129+
const backupPath = `${local}.bak`;
130+
fsUtils.copyFile(local, backupPath);
131+
132+
// delete local file
133+
fsUtils.deleteFileIfExists(local);
134+
135+
// delete base file
136+
fsUtils.deleteFileIfExists(base);
137+
138+
// rename update file to base file
139+
const newBaseFileName = path.basename(update).replaceAll('update', 'base');
140+
const baseDirPath = path.dirname(update);
141+
const newBase = path.join(baseDirPath, newBaseFileName);
142+
fsUtils.renameFile(update, newBase);
143+
144+
// rename merged file to local file
145+
fsUtils.renameFile(merged, local);
146+
147+
// refresh tree view to update file status
148+
this.activeSolutionTracker.triggerReload();
149+
}
150+
144151
private getVSCodeExecutablePath(): string | undefined {
145152
const platform = os.platform();
146153

@@ -154,7 +161,7 @@ export class MergeCommand {
154161
];
155162

156163
for (const p of possiblePaths) {
157-
if (fs.existsSync(p)) {
164+
if (fsUtils.fileExists(p)) {
158165
return p;
159166
}
160167
}
@@ -168,15 +175,15 @@ export class MergeCommand {
168175
];
169176

170177
for (const p of possiblePaths) {
171-
if (fs.existsSync(p)) {
178+
if (fsUtils.fileExists(p)) {
172179
return p;
173180
}
174181
}
175182

176183
// fallback to using 'which code'
177184
try {
178185
const resolved = execSync('which code', { stdio: ['pipe', 'pipe', 'ignore'] }).toString().trim();
179-
if (resolved && fs.existsSync(resolved)) return resolved;
186+
if (resolved && fsUtils.fileExists(resolved)) return resolved;
180187
} catch (err) {
181188
vscode.window.showWarningMessage(`Could not resolve 'code' binary via 'which': ${err instanceof Error ? err.message : String(err)}`);
182189
return undefined;
@@ -230,4 +237,51 @@ export class MergeCommand {
230237
});
231238
});
232239
}
240+
241+
private findNewestMergeFiles(localPath: string): { update: string | undefined; base: string | undefined } {
242+
const dir = path.dirname(localPath);
243+
const fileName = path.basename(localPath);
244+
245+
let fileNames: string[];
246+
try {
247+
fileNames = fsUtils.readDirectory(dir);
248+
} catch {
249+
return { update: undefined, base: undefined };
250+
}
251+
const updatePrefix = `${fileName}.update@`;
252+
const basePrefix = `${fileName}.base@`;
253+
254+
const update = this.resolveNewestMergeSibling(fileNames, updatePrefix);
255+
const base = this.resolveNewestMergeSibling(fileNames, basePrefix);
256+
257+
if (!update || !base) {
258+
return { update: undefined, base: undefined };
259+
}
260+
261+
return {
262+
update: update ? path.join(dir, update) : undefined,
263+
base: base ? path.join(dir, base) : undefined,
264+
};
265+
}
266+
267+
private resolveNewestMergeSibling(fileNames: string[], prefix: string): string | undefined {
268+
const matches = fileNames.filter(fileName => fileName.startsWith(prefix));
269+
return this.selectNewestMergeSibling(matches);
270+
}
271+
272+
private selectNewestMergeSibling(fileNames: string[]): string | undefined {
273+
if (fileNames.length === 0) {
274+
return undefined;
275+
}
276+
277+
return fileNames
278+
.map(fileName => {
279+
const version = semver.valid(extractVersion(fileName));
280+
return version ? { fileName, version } : undefined;
281+
})
282+
.filter((candidate): candidate is { fileName: string; version: string } => candidate !== undefined)
283+
.sort((left, right) => semver.rcompare(left.version, right.version))[0]?.fileName;
284+
}
285+
233286
}
287+

src/views/solution-outline/tree-structure/solution-outline-file-item.test.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,23 @@
1616

1717
import { FileItemBuilder } from './solution-outline-file-item';
1818
import { parseYamlToCTreeItem } from '../../../generic/tree-item-yaml-parser';
19-
import fs from 'fs';
20-
import os from 'os';
19+
import * as manifest from '../../../manifest';
2120
import path from 'path';
2221
import { COutlineItem } from './solution-outline-item';
22+
import { TestDataHandler } from '../../../__test__/test-data';
23+
import * as fsUtils from '../../../utils/fs-utils';
2324

2425

2526
describe('FileItem', () => {
27+
const testDataHandler = new TestDataHandler();
2628
let fileItem: FileItemBuilder;
2729
let projectDir: string;
2830
let cSolFile: string;
2931
let componentNode: COutlineItem;
3032

3133
beforeEach(async () => {
32-
const tmpDir = os.tmpdir();
33-
projectDir = fs.mkdtempSync(path.join(tmpDir, 'myProject'));
34+
testDataHandler.rmTmpDir();
35+
projectDir = path.join(testDataHandler.tmpDir, 'myProject');
3436
cSolFile = `${projectDir}/Blinky.csolution.yml`;
3537

3638
fileItem = new FileItemBuilder();
@@ -39,16 +41,20 @@ describe('FileItem', () => {
3941
componentNode.setTag('component');
4042
componentNode.setAttribute('label', 'Component X');
4143
componentNode.setAttribute('local', 'localPath');
42-
componentNode.setAttribute('update', 'updatePath');
43-
componentNode.setAttribute('base', 'basePath');
44-
4544
});
4645

47-
afterEach(() => {
48-
fs.rmSync(projectDir, { recursive: true, force: true });
46+
afterAll(() => {
47+
testDataHandler.dispose();
4948
});
5049

5150
it('creates file node with merge feature', async () => {
51+
fsUtils.writeTextFile(path.join(projectDir, 'RTE', 'CMSIS', 'RTX_Config.c'));
52+
fsUtils.writeTextFile(path.join(projectDir, 'RTE', 'CMSIS', 'RTX_Config.c.base@4.0.0'));
53+
fsUtils.writeTextFile(path.join(projectDir, 'RTE', 'CMSIS', 'RTX_Config.c.update@5.1.1'));
54+
fsUtils.writeTextFile(path.join(projectDir, 'RTE', 'CMSIS', 'RTX_Config.h'));
55+
fsUtils.writeTextFile(path.join(projectDir, 'RTE', 'CMSIS', 'RTX_Config.h.base@5.5.1'));
56+
fsUtils.writeTextFile(path.join(projectDir, 'RTE', 'CMSIS', 'RTX_Config.h.update@5.5.2'));
57+
5258
const cbuildContent = `
5359
build:
5460
components:
@@ -98,9 +104,9 @@ describe('FileItem', () => {
98104
createdChildren.forEach((child, idx) => {
99105
expect(child.getTag()).toBe('file');
100106
expect(child.getAttribute('label')).toContain(fileLabels[idx]);
101-
expect(child.getAttribute('update')).toContain('update');
102-
expect(child.getAttribute('base')).toContain('base');
103107
expect(child.getAttribute('description')).toBeUndefined();
108+
expect(path.basename(child.getAttribute('resourcePath') ?? '')).toBe(fileLabels[idx]);
109+
expect(child.getAttribute('features')).toContain(manifest.MERGE_FILE_CONTEXT);
104110
});
105111

106112
});

src/views/solution-outline/tree-structure/solution-outline-file-item.ts

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -93,46 +93,16 @@ export class FileItemBuilder extends SolutionOutlineItemBuilder {
9393
return item as COutlineItem;
9494
}
9595

96-
public addMergeFeature(f: ITreeItem<CTreeItem>, cfileItem: COutlineItem, options?: { skipValidation?: boolean; localPathOverride?: string }) {
97-
if (!options?.skipValidation) {
98-
const attr = f.getValue('attr');
99-
if (attr !== 'config') {
100-
return;
101-
}
102-
const localPath = cfileItem.getValue('resourcePath');
103-
if (!localPath) {
104-
return;
105-
}
106-
}
107-
108-
// use override if provided, else use resourcePath
109-
const localPath = options?.localPathOverride ?? cfileItem.getValue('resourcePath');
110-
111-
const update = f.getValue('update');
112-
if (!update) {
113-
return;
114-
}
115-
116-
const base = f.getValue('base');
117-
if (!base) {
96+
public addMergeFeature(f: ITreeItem<CTreeItem>, cfileItem: COutlineItem) {
97+
// Only config files with a resolvable resource path are eligible.
98+
const attr = f.getValue('attr');
99+
if (attr !== 'config') {
118100
return;
119101
}
120-
121102
const fileStatus = f.getValue('status');
122103
if (!fileStatus) {
123104
return;
124105
}
125-
126-
// resolve paths
127-
const rootFileName = f.rootFileName;
128-
const dir = path.dirname(rootFileName);
129-
const updatePath = path.join(dir, update);
130-
const basePath = path.join(dir, base);
131-
132-
// assign merge context
133106
setMergeFileContext(cfileItem);
134-
cfileItem.setAttribute('local', localPath);
135-
cfileItem.setAttribute('update', updatePath);
136-
cfileItem.setAttribute('base', basePath);
137107
}
138108
}

0 commit comments

Comments
 (0)