Skip to content

Commit

Permalink
mhutchie#480 When loading the Working File for a file from a historic…
Browse files Browse the repository at this point in the history
…al commit, and the file has since been renamed, Git is now used to detect renames and enable the Working File to be opened.
  • Loading branch information
mhutchie committed Mar 13, 2021
1 parent 8238ea3 commit d0412a1
Show file tree
Hide file tree
Showing 9 changed files with 289 additions and 45 deletions.
2 changes: 1 addition & 1 deletion src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ export class CommandManager extends Disposable {
if (typeof uri === 'object' && uri.scheme === DiffDocProvider.scheme) {
// A Git Graph URI has been provided
const request = decodeDiffDocUri(uri);
return openFile(request.repo, request.filePath, vscode.ViewColumn.Active).then((errorInfo) => {
return openFile(request.repo, request.filePath, request.commit, this.dataSource, vscode.ViewColumn.Active).then((errorInfo) => {
if (errorInfo !== null) {
return showErrorMessage('Unable to Open File: ' + errorInfo);
}
Expand Down
31 changes: 24 additions & 7 deletions src/dataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,20 @@ export class DataSource extends Disposable {
}).then((url) => url, () => null);
}

/**
* Check to see if a file has been renamed between a commit and the working tree, and return the new file path.
* @param repo The path of the repository.
* @param commitHash The commit hash where `oldFilePath` is known to have existed.
* @param oldFilePath The file path that may have been renamed.
* @returns The new renamed file path, or NULL if either: the file wasn't renamed or the Git command failed to execute.
*/
public getNewPathOfRenamedFile(repo: string, commitHash: string, oldFilePath: string) {
return this.getDiffNameStatus(repo, commitHash, '', 'R').then((renamed) => {
const renamedRecordForFile = renamed.find((record) => record.oldFilePath === oldFilePath);
return renamedRecordForFile ? renamedRecordForFile.newFilePath : null;
}).catch(() => null);
}

/**
* Get the details of a tag.
* @param repo The path of the repository.
Expand Down Expand Up @@ -1386,10 +1400,11 @@ export class DataSource extends Disposable {
* @param repo The path of the repository.
* @param fromHash The revision the diff is from.
* @param toHash The revision the diff is to.
* @param filter The types of file changes to retrieve (defaults to `AMDR`).
* @returns An array of `--name-status` records.
*/
private getDiffNameStatus(repo: string, fromHash: string, toHash: string) {
return this.execDiff(repo, fromHash, toHash, '--name-status').then((output) => {
private getDiffNameStatus(repo: string, fromHash: string, toHash: string, filter: string = 'AMDR') {
return this.execDiff(repo, fromHash, toHash, '--name-status', filter).then((output) => {
let records: DiffNameStatusRecord[] = [], i = 0;
while (i < output.length && output[i] !== '') {
let type = <GitFileStatus>output[i][0];
Expand All @@ -1415,10 +1430,11 @@ export class DataSource extends Disposable {
* @param repo The path of the repository.
* @param fromHash The revision the diff is from.
* @param toHash The revision the diff is to.
* @param filter The types of file changes to retrieve (defaults to `AMDR`).
* @returns An array of `--numstat` records.
*/
private getDiffNumStat(repo: string, fromHash: string, toHash: string) {
return this.execDiff(repo, fromHash, toHash, '--numstat').then((output) => {
private getDiffNumStat(repo: string, fromHash: string, toHash: string, filter: string = 'AMDR') {
return this.execDiff(repo, fromHash, toHash, '--numstat', filter).then((output) => {
let records: DiffNumStatRecord[] = [], i = 0;
while (i < output.length && output[i] !== '') {
let fields = output[i].split('\t');
Expand Down Expand Up @@ -1656,14 +1672,15 @@ export class DataSource extends Disposable {
* @param fromHash The revision the diff is from.
* @param toHash The revision the diff is to.
* @param arg Sets the data reported from the diff.
* @param filter The types of file changes to retrieve.
* @returns The diff output.
*/
private execDiff(repo: string, fromHash: string, toHash: string, arg: '--numstat' | '--name-status') {
private execDiff(repo: string, fromHash: string, toHash: string, arg: '--numstat' | '--name-status', filter: string) {
let args: string[];
if (fromHash === toHash) {
args = ['diff-tree', arg, '-r', '--root', '--find-renames', '--diff-filter=AMDR', '-z', fromHash];
args = ['diff-tree', arg, '-r', '--root', '--find-renames', '--diff-filter=' + filter, '-z', fromHash];
} else {
args = ['diff', arg, '--find-renames', '--diff-filter=AMDR', '-z', fromHash];
args = ['diff', arg, '--find-renames', '--diff-filter=' + filter, '-z', fromHash];
if (toHash !== '') args.push(toHash);
}

Expand Down
4 changes: 2 additions & 2 deletions src/gitGraphView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,7 @@ export class GitGraphView extends Disposable {
case 'openFile':
this.sendMessage({
command: 'openFile',
error: await openFile(msg.repo, msg.filePath)
error: await openFile(msg.repo, msg.filePath, msg.hash, this.dataSource)
});
break;
case 'openTerminal':
Expand Down Expand Up @@ -585,7 +585,7 @@ export class GitGraphView extends Disposable {
case 'viewDiffWithWorkingFile':
this.sendMessage({
command: 'viewDiffWithWorkingFile',
error: await viewDiffWithWorkingFile(msg.repo, msg.hash, msg.filePath)
error: await viewDiffWithWorkingFile(msg.repo, msg.hash, msg.filePath, this.dataSource)
});
break;
case 'viewFileAtRevision':
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,7 @@ export interface ResponseOpenExternalUrl extends ResponseWithErrorInfo {

export interface RequestOpenFile extends RepoRequest {
readonly command: 'openFile';
readonly hash: string;
readonly filePath: string;
}
export interface ResponseOpenFile extends ResponseWithErrorInfo {
Expand Down
83 changes: 59 additions & 24 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,17 @@ export async function resolveToSymbolicPath(path: string) {
return path;
}

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


/* General Methods */

Expand Down Expand Up @@ -334,26 +345,38 @@ export function openExternalUrl(url: string, type: string = 'External URL'): The
* Open a file within a repository in Visual Studio Code.
* @param repo The repository the file is contained in.
* @param filePath The relative path of the file within the repository.
* @param hash An optional commit hash where the file is known to have existed.
* @param dataSource An optional DataSource instance, that's used to check if the file has been renamed.
* @param viewColumn An optional ViewColumn that the file should be opened in.
* @returns A promise resolving to the ErrorInfo of the executed command.
*/
export function openFile(repo: string, filePath: string, viewColumn: vscode.ViewColumn | null = null) {
return new Promise<ErrorInfo>(resolve => {
const p = path.join(repo, filePath);
fs.access(p, fs.constants.R_OK, (err) => {
if (err === null) {
vscode.commands.executeCommand('vscode.open', vscode.Uri.file(p), {
preview: true,
viewColumn: viewColumn === null ? getConfig().openNewTabEditorGroup : viewColumn
}).then(
() => resolve(null),
() => resolve('Visual Studio Code was unable to open ' + filePath + '.')
);
} else {
resolve('The file ' + filePath + ' doesn\'t currently exist in this repository.');
export async function openFile(repo: string, filePath: string, hash: string | null = null, dataSource: DataSource | null = null, viewColumn: vscode.ViewColumn | null = null) {
let newFilePath = filePath;
let newAbsoluteFilePath = path.join(repo, newFilePath);
let fileExists = await doesFileExist(newAbsoluteFilePath);
if (!fileExists && hash !== null && dataSource !== null) {
const renamedFilePath = await dataSource.getNewPathOfRenamedFile(repo, hash, filePath);
if (renamedFilePath !== null) {
const renamedAbsoluteFilePath = path.join(repo, renamedFilePath);
if (await doesFileExist(renamedAbsoluteFilePath)) {
newFilePath = renamedFilePath;
newAbsoluteFilePath = renamedAbsoluteFilePath;
fileExists = true;
}
});
});
}
}

if (fileExists) {
return vscode.commands.executeCommand('vscode.open', vscode.Uri.file(newAbsoluteFilePath), {
preview: true,
viewColumn: viewColumn === null ? getConfig().openNewTabEditorGroup : viewColumn
}).then(
() => null,
() => 'Visual Studio Code was unable to open ' + newFilePath + '.'
);
} else {
return 'The file ' + newFilePath + ' doesn\'t currently exist in this repository.';
}
}

/**
Expand Down Expand Up @@ -394,15 +417,27 @@ export function viewDiff(repo: string, fromHash: string, toHash: string, oldFile
* @param repo The repository the file is contained in.
* @param hash The revision of the left-side of the Diff View.
* @param filePath The relative path of the file within the repository.
* @param dataSource A DataSource instance, that's used to check if the file has been renamed.
* @returns A promise resolving to the ErrorInfo of the executed command.
*/
export function viewDiffWithWorkingFile(repo: string, hash: string, filePath: string) {
return new Promise<ErrorInfo>((resolve) => {
const p = path.join(repo, filePath);
fs.access(p, fs.constants.R_OK, (err) => {
resolve(viewDiff(repo, hash, UNCOMMITTED, filePath, filePath, err === null ? GitFileStatus.Modified : GitFileStatus.Deleted));
});
});
export async function viewDiffWithWorkingFile(repo: string, hash: string, filePath: string, dataSource: DataSource) {
let newFilePath = filePath;
let fileExists = await doesFileExist(path.join(repo, newFilePath));
if (!fileExists) {
const renamedFilePath = await dataSource.getNewPathOfRenamedFile(repo, hash, filePath);
if (renamedFilePath !== null && await doesFileExist(path.join(repo, renamedFilePath))) {
newFilePath = renamedFilePath;
fileExists = true;
}
}

const type = fileExists
? filePath === newFilePath
? GitFileStatus.Modified
: GitFileStatus.Renamed
: GitFileStatus.Deleted;

return viewDiff(repo, hash, UNCOMMITTED, filePath, newFilePath, type);
}

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

Expand Down
6 changes: 3 additions & 3 deletions tests/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1210,7 +1210,7 @@ describe('CommandManager', () => {
await vscode.commands.executeCommand('git-graph.openFile', encodeDiffDocUri('/path/to/repo', 'subfolder/modified.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', GitFileStatus.Modified, DiffSide.New));

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

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

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

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

// Assert
expect(spyOnOpenFile).toHaveBeenCalledWith('/path/to/repo', 'subfolder/modified.txt', vscode.ViewColumn.Active);
expect(spyOnOpenFile).toHaveBeenCalledWith('/path/to/repo', 'subfolder/modified.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', dataSource, vscode.ViewColumn.Active);
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Unable to Open File: Error Message');
});
});
Expand Down
38 changes: 38 additions & 0 deletions tests/dataSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3796,6 +3796,44 @@ describe('DataSource', () => {
});
});

describe('getNewPathOfRenamedFile', () => {
it('Should return the new path of a file that was renamed', async () => {
// Setup
mockGitSuccessOnce(['R100', 'dir/renamed-old.txt', 'dir/renamed-new.txt', ''].join('\0'));

// Run
const result = await dataSource.getNewPathOfRenamedFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'dir/renamed-old.txt');

// Assert
expect(result).toBe('dir/renamed-new.txt');
expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['diff', '--name-status', '--find-renames', '--diff-filter=R', '-z', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b'], expect.objectContaining({ cwd: '/path/to/repo' }));
});

it('Should return NULL when a file wasn\'t renamed', async () => {
// Setup
mockGitSuccessOnce(['R100', 'dir/renamed-old.txt', 'dir/renamed-new.txt', ''].join('\0'));

// Run
const result = await dataSource.getNewPathOfRenamedFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'dir/deleted.txt');

// Assert
expect(result).toBe(null);
expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['diff', '--name-status', '--find-renames', '--diff-filter=R', '-z', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b'], expect.objectContaining({ cwd: '/path/to/repo' }));
});

it('Should return NULL when git threw an error', async () => {
// Setup
mockGitThrowingErrorOnce();

// Run
const result = await dataSource.getNewPathOfRenamedFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'dir/deleted.txt');

// Assert
expect(result).toBe(null);
expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['diff', '--name-status', '--find-renames', '--diff-filter=R', '-z', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b'], expect.objectContaining({ cwd: '/path/to/repo' }));
});
});

describe('getTagDetails', () => {
it('Should return the tags details', async () => {
// Setup
Expand Down
Loading

0 comments on commit d0412a1

Please sign in to comment.