Skip to content

Commit d0412a1

Browse files
committed
#480 When loading the Working File for a file from a historical commit, and the file has since been renamed, Git is now used to detect renames and enable the Working File to be opened.
1 parent 8238ea3 commit d0412a1

9 files changed

Lines changed: 289 additions & 45 deletions

File tree

src/commands.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ export class CommandManager extends Disposable {
324324
if (typeof uri === 'object' && uri.scheme === DiffDocProvider.scheme) {
325325
// A Git Graph URI has been provided
326326
const request = decodeDiffDocUri(uri);
327-
return openFile(request.repo, request.filePath, vscode.ViewColumn.Active).then((errorInfo) => {
327+
return openFile(request.repo, request.filePath, request.commit, this.dataSource, vscode.ViewColumn.Active).then((errorInfo) => {
328328
if (errorInfo !== null) {
329329
return showErrorMessage('Unable to Open File: ' + errorInfo);
330330
}

src/dataSource.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,20 @@ export class DataSource extends Disposable {
473473
}).then((url) => url, () => null);
474474
}
475475

476+
/**
477+
* Check to see if a file has been renamed between a commit and the working tree, and return the new file path.
478+
* @param repo The path of the repository.
479+
* @param commitHash The commit hash where `oldFilePath` is known to have existed.
480+
* @param oldFilePath The file path that may have been renamed.
481+
* @returns The new renamed file path, or NULL if either: the file wasn't renamed or the Git command failed to execute.
482+
*/
483+
public getNewPathOfRenamedFile(repo: string, commitHash: string, oldFilePath: string) {
484+
return this.getDiffNameStatus(repo, commitHash, '', 'R').then((renamed) => {
485+
const renamedRecordForFile = renamed.find((record) => record.oldFilePath === oldFilePath);
486+
return renamedRecordForFile ? renamedRecordForFile.newFilePath : null;
487+
}).catch(() => null);
488+
}
489+
476490
/**
477491
* Get the details of a tag.
478492
* @param repo The path of the repository.
@@ -1386,10 +1400,11 @@ export class DataSource extends Disposable {
13861400
* @param repo The path of the repository.
13871401
* @param fromHash The revision the diff is from.
13881402
* @param toHash The revision the diff is to.
1403+
* @param filter The types of file changes to retrieve (defaults to `AMDR`).
13891404
* @returns An array of `--name-status` records.
13901405
*/
1391-
private getDiffNameStatus(repo: string, fromHash: string, toHash: string) {
1392-
return this.execDiff(repo, fromHash, toHash, '--name-status').then((output) => {
1406+
private getDiffNameStatus(repo: string, fromHash: string, toHash: string, filter: string = 'AMDR') {
1407+
return this.execDiff(repo, fromHash, toHash, '--name-status', filter).then((output) => {
13931408
let records: DiffNameStatusRecord[] = [], i = 0;
13941409
while (i < output.length && output[i] !== '') {
13951410
let type = <GitFileStatus>output[i][0];
@@ -1415,10 +1430,11 @@ export class DataSource extends Disposable {
14151430
* @param repo The path of the repository.
14161431
* @param fromHash The revision the diff is from.
14171432
* @param toHash The revision the diff is to.
1433+
* @param filter The types of file changes to retrieve (defaults to `AMDR`).
14181434
* @returns An array of `--numstat` records.
14191435
*/
1420-
private getDiffNumStat(repo: string, fromHash: string, toHash: string) {
1421-
return this.execDiff(repo, fromHash, toHash, '--numstat').then((output) => {
1436+
private getDiffNumStat(repo: string, fromHash: string, toHash: string, filter: string = 'AMDR') {
1437+
return this.execDiff(repo, fromHash, toHash, '--numstat', filter).then((output) => {
14221438
let records: DiffNumStatRecord[] = [], i = 0;
14231439
while (i < output.length && output[i] !== '') {
14241440
let fields = output[i].split('\t');
@@ -1656,14 +1672,15 @@ export class DataSource extends Disposable {
16561672
* @param fromHash The revision the diff is from.
16571673
* @param toHash The revision the diff is to.
16581674
* @param arg Sets the data reported from the diff.
1675+
* @param filter The types of file changes to retrieve.
16591676
* @returns The diff output.
16601677
*/
1661-
private execDiff(repo: string, fromHash: string, toHash: string, arg: '--numstat' | '--name-status') {
1678+
private execDiff(repo: string, fromHash: string, toHash: string, arg: '--numstat' | '--name-status', filter: string) {
16621679
let args: string[];
16631680
if (fromHash === toHash) {
1664-
args = ['diff-tree', arg, '-r', '--root', '--find-renames', '--diff-filter=AMDR', '-z', fromHash];
1681+
args = ['diff-tree', arg, '-r', '--root', '--find-renames', '--diff-filter=' + filter, '-z', fromHash];
16651682
} else {
1666-
args = ['diff', arg, '--find-renames', '--diff-filter=AMDR', '-z', fromHash];
1683+
args = ['diff', arg, '--find-renames', '--diff-filter=' + filter, '-z', fromHash];
16671684
if (toHash !== '') args.push(toHash);
16681685
}
16691686

src/gitGraphView.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -467,7 +467,7 @@ export class GitGraphView extends Disposable {
467467
case 'openFile':
468468
this.sendMessage({
469469
command: 'openFile',
470-
error: await openFile(msg.repo, msg.filePath)
470+
error: await openFile(msg.repo, msg.filePath, msg.hash, this.dataSource)
471471
});
472472
break;
473473
case 'openTerminal':
@@ -585,7 +585,7 @@ export class GitGraphView extends Disposable {
585585
case 'viewDiffWithWorkingFile':
586586
this.sendMessage({
587587
command: 'viewDiffWithWorkingFile',
588-
error: await viewDiffWithWorkingFile(msg.repo, msg.hash, msg.filePath)
588+
error: await viewDiffWithWorkingFile(msg.repo, msg.hash, msg.filePath, this.dataSource)
589589
});
590590
break;
591591
case 'viewFileAtRevision':

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,7 @@ export interface ResponseOpenExternalUrl extends ResponseWithErrorInfo {
978978

979979
export interface RequestOpenFile extends RepoRequest {
980980
readonly command: 'openFile';
981+
readonly hash: string;
981982
readonly filePath: string;
982983
}
983984
export interface ResponseOpenFile extends ResponseWithErrorInfo {

src/utils.ts

Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,17 @@ export async function resolveToSymbolicPath(path: string) {
105105
return path;
106106
}
107107

108+
/**
109+
* Checks whether a file exists, and the user has access to read it.
110+
* @param path The path of the file.
111+
* @returns Promise resolving to a boolean: TRUE => File exists, FALSE => File doesn't exist.
112+
*/
113+
export function doesFileExist(path: string) {
114+
return new Promise<boolean>((resolve) => {
115+
fs.access(path, fs.constants.R_OK, (err) => resolve(err === null));
116+
});
117+
}
118+
108119

109120
/* General Methods */
110121

@@ -334,26 +345,38 @@ export function openExternalUrl(url: string, type: string = 'External URL'): The
334345
* Open a file within a repository in Visual Studio Code.
335346
* @param repo The repository the file is contained in.
336347
* @param filePath The relative path of the file within the repository.
348+
* @param hash An optional commit hash where the file is known to have existed.
349+
* @param dataSource An optional DataSource instance, that's used to check if the file has been renamed.
337350
* @param viewColumn An optional ViewColumn that the file should be opened in.
338351
* @returns A promise resolving to the ErrorInfo of the executed command.
339352
*/
340-
export function openFile(repo: string, filePath: string, viewColumn: vscode.ViewColumn | null = null) {
341-
return new Promise<ErrorInfo>(resolve => {
342-
const p = path.join(repo, filePath);
343-
fs.access(p, fs.constants.R_OK, (err) => {
344-
if (err === null) {
345-
vscode.commands.executeCommand('vscode.open', vscode.Uri.file(p), {
346-
preview: true,
347-
viewColumn: viewColumn === null ? getConfig().openNewTabEditorGroup : viewColumn
348-
}).then(
349-
() => resolve(null),
350-
() => resolve('Visual Studio Code was unable to open ' + filePath + '.')
351-
);
352-
} else {
353-
resolve('The file ' + filePath + ' doesn\'t currently exist in this repository.');
353+
export async function openFile(repo: string, filePath: string, hash: string | null = null, dataSource: DataSource | null = null, viewColumn: vscode.ViewColumn | null = null) {
354+
let newFilePath = filePath;
355+
let newAbsoluteFilePath = path.join(repo, newFilePath);
356+
let fileExists = await doesFileExist(newAbsoluteFilePath);
357+
if (!fileExists && hash !== null && dataSource !== null) {
358+
const renamedFilePath = await dataSource.getNewPathOfRenamedFile(repo, hash, filePath);
359+
if (renamedFilePath !== null) {
360+
const renamedAbsoluteFilePath = path.join(repo, renamedFilePath);
361+
if (await doesFileExist(renamedAbsoluteFilePath)) {
362+
newFilePath = renamedFilePath;
363+
newAbsoluteFilePath = renamedAbsoluteFilePath;
364+
fileExists = true;
354365
}
355-
});
356-
});
366+
}
367+
}
368+
369+
if (fileExists) {
370+
return vscode.commands.executeCommand('vscode.open', vscode.Uri.file(newAbsoluteFilePath), {
371+
preview: true,
372+
viewColumn: viewColumn === null ? getConfig().openNewTabEditorGroup : viewColumn
373+
}).then(
374+
() => null,
375+
() => 'Visual Studio Code was unable to open ' + newFilePath + '.'
376+
);
377+
} else {
378+
return 'The file ' + newFilePath + ' doesn\'t currently exist in this repository.';
379+
}
357380
}
358381

359382
/**
@@ -394,15 +417,27 @@ export function viewDiff(repo: string, fromHash: string, toHash: string, oldFile
394417
* @param repo The repository the file is contained in.
395418
* @param hash The revision of the left-side of the Diff View.
396419
* @param filePath The relative path of the file within the repository.
420+
* @param dataSource A DataSource instance, that's used to check if the file has been renamed.
397421
* @returns A promise resolving to the ErrorInfo of the executed command.
398422
*/
399-
export function viewDiffWithWorkingFile(repo: string, hash: string, filePath: string) {
400-
return new Promise<ErrorInfo>((resolve) => {
401-
const p = path.join(repo, filePath);
402-
fs.access(p, fs.constants.R_OK, (err) => {
403-
resolve(viewDiff(repo, hash, UNCOMMITTED, filePath, filePath, err === null ? GitFileStatus.Modified : GitFileStatus.Deleted));
404-
});
405-
});
423+
export async function viewDiffWithWorkingFile(repo: string, hash: string, filePath: string, dataSource: DataSource) {
424+
let newFilePath = filePath;
425+
let fileExists = await doesFileExist(path.join(repo, newFilePath));
426+
if (!fileExists) {
427+
const renamedFilePath = await dataSource.getNewPathOfRenamedFile(repo, hash, filePath);
428+
if (renamedFilePath !== null && await doesFileExist(path.join(repo, renamedFilePath))) {
429+
newFilePath = renamedFilePath;
430+
fileExists = true;
431+
}
432+
}
433+
434+
const type = fileExists
435+
? filePath === newFilePath
436+
? GitFileStatus.Modified
437+
: GitFileStatus.Renamed
438+
: GitFileStatus.Deleted;
439+
440+
return viewDiff(repo, hash, UNCOMMITTED, filePath, newFilePath, type);
406441
}
407442

408443
/**
@@ -412,7 +447,7 @@ export function viewDiffWithWorkingFile(repo: string, hash: string, filePath: st
412447
* @param filePath The relative path of the file within the repository.
413448
* @returns A promise resolving to the ErrorInfo of the executed command.
414449
*/
415-
export async function viewFileAtRevision(repo: string, hash: string, filePath: string) {
450+
export function viewFileAtRevision(repo: string, hash: string, filePath: string) {
416451
const pathComponents = filePath.split('/');
417452
const title = abbrevCommit(hash) + ': ' + pathComponents[pathComponents.length - 1];
418453

tests/commands.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,7 +1210,7 @@ describe('CommandManager', () => {
12101210
await vscode.commands.executeCommand('git-graph.openFile', encodeDiffDocUri('/path/to/repo', 'subfolder/modified.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', GitFileStatus.Modified, DiffSide.New));
12111211

12121212
// Assert
1213-
expect(spyOnOpenFile).toHaveBeenCalledWith('/path/to/repo', 'subfolder/modified.txt', vscode.ViewColumn.Active);
1213+
expect(spyOnOpenFile).toHaveBeenCalledWith('/path/to/repo', 'subfolder/modified.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', dataSource, vscode.ViewColumn.Active);
12141214
});
12151215

12161216
it('Should open the file of the active text editor', async () => {
@@ -1221,7 +1221,7 @@ describe('CommandManager', () => {
12211221
await vscode.commands.executeCommand('git-graph.openFile');
12221222

12231223
// Assert
1224-
expect(spyOnOpenFile).toHaveBeenCalledWith('/path/to/repo', 'subfolder/modified.txt', vscode.ViewColumn.Active);
1224+
expect(spyOnOpenFile).toHaveBeenCalledWith('/path/to/repo', 'subfolder/modified.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', dataSource, vscode.ViewColumn.Active);
12251225
});
12261226

12271227
it('Should display an error message when no URI is provided', async () => {
@@ -1257,7 +1257,7 @@ describe('CommandManager', () => {
12571257
await vscode.commands.executeCommand('git-graph.openFile');
12581258

12591259
// Assert
1260-
expect(spyOnOpenFile).toHaveBeenCalledWith('/path/to/repo', 'subfolder/modified.txt', vscode.ViewColumn.Active);
1260+
expect(spyOnOpenFile).toHaveBeenCalledWith('/path/to/repo', 'subfolder/modified.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', dataSource, vscode.ViewColumn.Active);
12611261
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Unable to Open File: Error Message');
12621262
});
12631263
});

tests/dataSource.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3796,6 +3796,44 @@ describe('DataSource', () => {
37963796
});
37973797
});
37983798

3799+
describe('getNewPathOfRenamedFile', () => {
3800+
it('Should return the new path of a file that was renamed', async () => {
3801+
// Setup
3802+
mockGitSuccessOnce(['R100', 'dir/renamed-old.txt', 'dir/renamed-new.txt', ''].join('\0'));
3803+
3804+
// Run
3805+
const result = await dataSource.getNewPathOfRenamedFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'dir/renamed-old.txt');
3806+
3807+
// Assert
3808+
expect(result).toBe('dir/renamed-new.txt');
3809+
expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['diff', '--name-status', '--find-renames', '--diff-filter=R', '-z', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b'], expect.objectContaining({ cwd: '/path/to/repo' }));
3810+
});
3811+
3812+
it('Should return NULL when a file wasn\'t renamed', async () => {
3813+
// Setup
3814+
mockGitSuccessOnce(['R100', 'dir/renamed-old.txt', 'dir/renamed-new.txt', ''].join('\0'));
3815+
3816+
// Run
3817+
const result = await dataSource.getNewPathOfRenamedFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'dir/deleted.txt');
3818+
3819+
// Assert
3820+
expect(result).toBe(null);
3821+
expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['diff', '--name-status', '--find-renames', '--diff-filter=R', '-z', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b'], expect.objectContaining({ cwd: '/path/to/repo' }));
3822+
});
3823+
3824+
it('Should return NULL when git threw an error', async () => {
3825+
// Setup
3826+
mockGitThrowingErrorOnce();
3827+
3828+
// Run
3829+
const result = await dataSource.getNewPathOfRenamedFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'dir/deleted.txt');
3830+
3831+
// Assert
3832+
expect(result).toBe(null);
3833+
expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['diff', '--name-status', '--find-renames', '--diff-filter=R', '-z', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b'], expect.objectContaining({ cwd: '/path/to/repo' }));
3834+
});
3835+
});
3836+
37993837
describe('getTagDetails', () => {
38003838
it('Should return the tags details', async () => {
38013839
// Setup

0 commit comments

Comments
 (0)