diff --git a/packages/git/src/browser/history/git-history-widget.tsx b/packages/git/src/browser/history/git-history-widget.tsx index dd7aef756dfc7..ea2d72c3c4066 100644 --- a/packages/git/src/browser/history/git-history-widget.tsx +++ b/packages/git/src/browser/history/git-history-widget.tsx @@ -52,14 +52,21 @@ export type GitHistoryListNode = (GitCommitNode | GitFileChangeNode); @injectable() export class GitHistoryWidget extends GitNavigableListWidget implements StatefulWidget { protected options: Git.Options.Log; - protected commits: GitCommitNode[]; - protected ready: boolean; protected singleFileMode: boolean; private cancelIndicator: CancellationTokenSource; protected listView: GitHistoryList | undefined; protected hasMoreCommits: boolean; protected allowScrollToSelected: boolean; - protected errorMessage: React.ReactNode; + + protected status: { + state: 'loading', + } | { + state: 'ready', + commits: GitCommitNode[]; + } | { + state: 'error', + errorMessage: React.ReactNode + }; constructor( @inject(OpenerService) protected readonly openerService: OpenerService, @@ -103,7 +110,6 @@ export class GitHistoryWidget extends GitNavigableListWidget async setContent(options?: Git.Options.Log) { this.resetState(options); - this.ready = false; if (options && options.uri) { const fileStat = await this.fileSystem.getFileStat(options.uri); this.singleFileMode = !!fileStat && !fileStat.isDirectory; @@ -117,7 +123,7 @@ export class GitHistoryWidget extends GitNavigableListWidget protected resetState(options?: Git.Options.Log) { this.options = options || {}; - this.commits = []; + this.status = { state: 'loading' }; this.gitNodes = []; this.hasMoreCommits = true; this.allowScrollToSelected = true; @@ -127,21 +133,22 @@ export class GitHistoryWidget extends GitNavigableListWidget let repository: Repository | undefined; repository = this.repositoryProvider.findRepositoryOrSelected(options); - this.errorMessage = undefined; this.cancelIndicator.cancel(); this.cancelIndicator = new CancellationTokenSource(); const token = this.cancelIndicator.token; if (repository) { try { + const currentCommits = this.status.state === 'ready' ? this.status.commits : []; + let changes = await this.git.log(repository, options); if (token.isCancellationRequested || !this.hasMoreCommits) { return; } - if (options && ((options.maxCount && changes.length < options.maxCount) || (!options.maxCount && this.commits))) { + if (options && ((options.maxCount && changes.length < options.maxCount) || (!options.maxCount && currentCommits))) { this.hasMoreCommits = false; } - if (this.commits.length > 0) { + if (currentCommits.length > 0) { changes = changes.slice(1); } if (changes.length > 0) { @@ -164,21 +171,23 @@ export class GitHistoryWidget extends GitNavigableListWidget selected: false }); } - this.commits.push(...commits); + currentCommits.push(...commits); + this.status = { state: 'ready', commits: currentCommits }; } else if (options && options.uri && repository) { const pathIsUnderVersionControl = await this.git.lsFiles(repository, options.uri, { errorUnmatch: true }); if (!pathIsUnderVersionControl) { - this.errorMessage = It is not under version control.; + this.status = { state: 'error', errorMessage: It is not under version control. }; + } else { + this.status = { state: 'error', errorMessage: No commits have been committed. }; } } } catch (error) { - this.errorMessage = error.message; + this.status = { state: 'error', errorMessage: error.message }; } } else { - this.commits = []; - this.errorMessage = There is no repository selected in this workspace.; + this.status = { state: 'error', errorMessage: There is no repository selected in this workspace. }; } } @@ -232,37 +241,48 @@ export class GitHistoryWidget extends GitNavigableListWidget } protected onDataReady(): void { - this.ready = true; - this.gitNodes = this.commits; + if (this.status.state === 'ready') { + this.gitNodes = this.status.commits; + } this.update(); } protected render(): React.ReactNode { let content: React.ReactNode; - if (this.ready && this.gitNodes.length > 0) { - content = < React.Fragment > - {this.renderHistoryHeader()} - {this.renderCommitList()} - ; - } else if (this.errorMessage) { - let path: React.ReactNode = ''; - let reason: React.ReactNode; - reason = this.errorMessage; - if (this.options.uri) { - const relPath = this.relativePath(this.options.uri); - const repo = this.repositoryProvider.findRepository(new URI(this.options.uri)); - const repoName = repo ? ` in ${new URI(repo.localUri).displayName}` : ''; - path = ` for ${decodeURIComponent(relPath)}${repoName}`; - } - content = - {reason} - ; - } else { - content =
- -
; + switch (this.status.state) { + case 'ready': + content = < React.Fragment > + {this.renderHistoryHeader()} + {this.renderCommitList()} + ; + break; + + case 'error': + let path: React.ReactNode = ''; + let reason: React.ReactNode; + reason = this.status.errorMessage; + if (this.options.uri) { + const relPathEncoded = this.relativePath(this.options.uri); + const relPath = relPathEncoded ? `${decodeURIComponent(relPathEncoded)}` : ''; + + const repo = this.repositoryProvider.findRepository(new URI(this.options.uri)); + const repoName = repo ? `${new URI(repo.localUri).displayName}` : ''; + + const relPathAndRepo = [relPath, repoName].filter(Boolean).join(' in '); + path = ` for ${relPathAndRepo}`; + } + content = + {reason} + ; + break; + + case 'loading': + content =
+ +
; + break; } return
{content} @@ -425,18 +445,18 @@ export class GitHistoryWidget extends GitNavigableListWidget protected navigateLeft(): void { const selected = this.getSelected(); - if (selected) { - const idx = this.commits.findIndex(c => c.commitSha === selected.commitSha); + if (selected && this.status.state === 'ready') { + const idx = this.status.commits.findIndex(c => c.commitSha === selected.commitSha); if (GitCommitNode.is(selected)) { if (selected.expanded) { this.addOrRemoveFileChangeNodes(selected); } else { if (idx > 0) { - this.selectNode(this.commits[idx - 1]); + this.selectNode(this.status.commits[idx - 1]); } } } else if (GitFileChangeNode.is(selected)) { - this.selectNode(this.commits[idx]); + this.selectNode(this.status.commits[idx]); } } this.update(); diff --git a/packages/git/src/common/git.ts b/packages/git/src/common/git.ts index b8e6cfefd0fcb..198c35a116db4 100644 --- a/packages/git/src/common/git.ts +++ b/packages/git/src/common/git.ts @@ -583,6 +583,18 @@ export namespace Git { } + /** + * Options for the `git rev-parse` command. + */ + export interface RevParse { + + /** + * The reference to parse. + */ + readonly ref: string; + + } + } } @@ -797,6 +809,15 @@ export interface Git extends Disposable { */ log(repository: Repository, options?: Git.Options.Log): Promise; + /** + * Returns the commit SHA of the given ref if the ref exists, or returns 'undefined' if the + * given ref does not exist. + * + * @param repository the repository where the ref may be found. + * @param options configuration containing the ref and optionally other properties for further refining the `git rev-parse` command execution. + */ + revParse(repository: Repository, options: Git.Options.RevParse): Promise; + /** * Returns the annotations of each line in the given file. * diff --git a/packages/git/src/node/dugite-git.spec.ts b/packages/git/src/node/dugite-git.spec.ts index 563a2715b9c8d..7401d12233917 100644 --- a/packages/git/src/node/dugite-git.spec.ts +++ b/packages/git/src/node/dugite-git.spec.ts @@ -772,10 +772,18 @@ describe('log', function () { const repository = { localUri }; const git = await createGit(); const result = await git.log(repository, { uri: localUri }); - expect(result.length === 1).to.be.true; - expect(result[0].author.email === 'jon@doe.com').to.be.true; + expect(result.length).to.be.equal(1); + expect(result[0].author.email).to.be.equal('jon@doe.com'); }); + it('should not fail when executed against an empty repository', async () => { + const root = await initRepository(track.mkdirSync('empty-log-test')); + const localUri = FileUri.create(root).toString(); + const repository = { localUri }; + const git = await createGit(); + const result = await git.log(repository, { uri: localUri }); + expect(result.length).to.be.equal(0); + }); }); function toPathSegment(repository: Repository, uri: string): string { diff --git a/packages/git/src/node/dugite-git.ts b/packages/git/src/node/dugite-git.ts index b6ef4885970f4..cc2be3d450fb7 100644 --- a/packages/git/src/node/dugite-git.ts +++ b/packages/git/src/node/dugite-git.ts @@ -657,13 +657,39 @@ export class DugiteGit implements Git { const file = Path.relative(this.getFsPath(repository), this.getFsPath(options.uri)) || '.'; args.push(...[file]); } - const result = await this.exec(repository, args); + + const successExitCodes = new Set([0, 128]); + let result = await this.exec(repository, args, { successExitCodes }); + if (result.exitCode !== 0) { + // Note that if no range specified then the 'to revision' defaults to HEAD + const rangeInvolvesHead = !options || !options.range || options.range.toRevision === 'HEAD'; + const repositoryHasNoHead = !await this.revParse(repository, { ref: 'HEAD' }); + // The 'log' command could potentially be valid when no HEAD if the revision range does not involve HEAD */ + if (rangeInvolvesHead && repositoryHasNoHead) { + // The range involves HEAD but there is no HEAD. 'no head' most likely means a newly created repository with + // no commits, but could potentially have commits with no HEAD. This is effectively an empty repository. + return []; + } + // Either the range did not involve HEAD or HEAD exists. The error must be something else, + // so re-run but this time we don't ignore the error. + result = await this.exec(repository, args); + } + return this.commitDetailsParser.parse( repository.localUri, result.stdout.trim() .split(CommitDetailsParser.COMMIT_CHUNK_DELIMITER) .filter(item => item && item.length > 0)); } + async revParse(repository: Repository, options: Git.Options.RevParse): Promise { + const ref = options.ref; + const successExitCodes = new Set([0, 128]); + const result = await this.exec(repository, ['rev-parse', ref], { successExitCodes }); + if (result.exitCode === 0) { + return result.stdout; // sha + } + } + async blame(repository: Repository, uri: string, options?: Git.Options.Blame): Promise { await this.ready.promise; const args = ['blame', '--root', '--incremental']; diff --git a/packages/scm/src/browser/scm-amend-component.tsx b/packages/scm/src/browser/scm-amend-component.tsx index cf139dc433049..a7d375c1c3ade 100644 --- a/packages/scm/src/browser/scm-amend-component.tsx +++ b/packages/scm/src/browser/scm-amend-component.tsx @@ -104,21 +104,20 @@ export class ScmAmendComponent extends React.Component