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<GitHistoryListNode> 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<GitHistoryListNode> 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<GitHistoryListNode> 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<GitHistoryListNode> 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<GitHistoryListNode> 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 = <React.Fragment>It is not under version control.</React.Fragment>; + this.status = { state: 'error', errorMessage: <React.Fragment> It is not under version control.</React.Fragment> }; + } else { + this.status = { state: 'error', errorMessage: <React.Fragment> No commits have been committed.</React.Fragment> }; } } } catch (error) { - this.errorMessage = error.message; + this.status = { state: 'error', errorMessage: error.message }; } } else { - this.commits = []; - this.errorMessage = <React.Fragment>There is no repository selected in this workspace.</React.Fragment>; + this.status = { state: 'error', errorMessage: <React.Fragment>There is no repository selected in this workspace.</React.Fragment> }; } } @@ -232,37 +241,48 @@ export class GitHistoryWidget extends GitNavigableListWidget<GitHistoryListNode> } 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()} - </React.Fragment>; - } 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 = <AlertMessage - type='WARNING' - header={`There is no Git history available${path}`}> - {reason} - </AlertMessage>; - } else { - content = <div className='spinnerContainer'> - <span className='fa fa-spinner fa-pulse fa-3x fa-fw'></span> - </div>; + switch (this.status.state) { + case 'ready': + content = < React.Fragment > + {this.renderHistoryHeader()} + {this.renderCommitList()} + </React.Fragment>; + 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 = <AlertMessage + type='WARNING' + header={`There is no Git history available${path}.`}> + {reason} + </AlertMessage>; + break; + + case 'loading': + content = <div className='spinnerContainer'> + <span className='fa fa-spinner fa-pulse fa-3x fa-fw'></span> + </div>; + break; } return <div className='git-diff-container'> {content} @@ -425,18 +445,18 @@ export class GitHistoryWidget extends GitNavigableListWidget<GitHistoryListNode> 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<CommitWithChanges[]>; + /** + * 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<string | undefined>; + /** * 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<string | undefined> { + 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<GitFileBlame | undefined> { 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<ScmAmendComponentProps, S } else if (nextCommit === undefined && this.state.lastCommit === undefined) { // No change here } else if (this.transitionHint === 'none') { - if (this.state.lastCommit) { - // If the 'last' commit changes, but we are not expecting an 'amend' - // or 'unamend' to occur, then we clear out the list of amended commits. - // This is because an unexpected change has happened to the repoistory, - // perhaps the user commited, merged, or something. The amended commits - // will no longer be valid. - await this.clearAmendingCommits(); - // There is a change to the last commit, but no transition hint so - // the view just updates without transition. - this.setState({ amendingCommits: [], lastCommit: nextCommit }); - } else { - // There should always be a previous 'last commit' - throw new Error('unexpected state'); - // this.setState({ amendingCommits: await this.buildAmendingList(), lastCommit: nextCommit }); - } + // If the 'last' commit changes, but we are not expecting an 'amend' + // or 'unamend' to occur, then we clear out the list of amended commits. + // This is because an unexpected change has happened to the repoistory, + // perhaps the user commited, merged, or something. The amended commits + // will no longer be valid. + + // Note that there may or may not have been a previous lastCommit (if the + // repository was previously empty with no initial commit then lastCommit + // will be undefined). Either way we clear the amending commits. + await this.clearAmendingCommits(); + + // There is a change to the last commit, but no transition hint so + // the view just updates without transition. + this.setState({ amendingCommits: [], lastCommit: nextCommit }); } else { if (this.state.lastCommit && nextCommit) { const direction: 'up' | 'down' = this.transitionHint === 'amend' ? 'up' : 'down';